Add pre-submit consent gate for amounts exceeding auto-pay approval

Symmetric across both bridge directions, checked in priority order before
any funds move:
1. Real reserve exceeded (withdrawal only) - the multisig doesn't hold
   enough real USDT on Polkadot Asset Hub. No signature can fix this, so
   this stays a hard block (existing bridge_wusdt_to_usdt_blocked message),
   never offered as "proceed anyway".
2. Automation-key approval exceeded (either direction) - funds exist, the
   automation key just isn't currently approved to auto-pay that much.
   Resolvable by 3-of-5 signing, so this is now an explicit opt-in: a
   warning banner names the ~2h typical review window and the signatories'
   contact channel (t.me/pezkuwidestek), gated behind a WarningCheckBox the
   user must tick before Swap re-enables. Previously there was no signal at
   all here - a large amount would just silently queue for manual review
   after debiting the user, with no way to know that going in.

Both getWusdtRemainingAllowance/getPolkadotUsdtRemainingAllowance query the
real on-chain Assets.Approvals amount per leg - not a guess. Confirmed
on-chain (2026-07-16) the Polkadot leg has never had an approval granted at
all, so every wUSDT->USDT withdrawal currently hits the consent gate
regardless of amount, symmetric with what happens once a signatory grants
one (mirrors the existing wUSDT-side renewal flow exactly, including the
same 40,000/200,000 20%-threshold pattern) - added a second sign button so
a signatory can actually grant it from this screen.

swapClicked()'s confirmation gate now also enforces the consent requirement
server-side (well, client-domain-side) rather than only via the button's
enabled look, consistent with how it already re-checks balance/reserve.
This commit is contained in:
2026-07-15 19:46:24 -07:00
parent cf8127c8f8
commit 3c06ae00e0
6 changed files with 363 additions and 73 deletions
+2
View File
@@ -365,6 +365,8 @@
<string name="bridge_below_minimum">Amount below minimum</string>
<string name="bridge_enter_amount">Enter amount</string>
<string name="bridge_wusdt_to_usdt_blocked">This amount exceeds the bridge\'s current available reserve (%s USDT). Try a smaller amount.</string>
<string name="bridge_consent_required_message">This amount exceeds the automatic payout limit and needs approval from 3 of the bridge\'s 5 signatories - typically resolved within about 2 hours. You can reach them at t.me/pezkuwidestek in the meantime.</string>
<string name="bridge_consent_checkbox_label">I understand this may take time to complete and want to proceed</string>
<string name="bridge_sign_status_ok">Bridge allowance OK (%s)</string>
<string name="bridge_sign_button">Sign renewal (%s)</string>
@@ -34,6 +34,14 @@ object BridgeMultisigConstants {
const val MULTISIG_ADDRESS_POLKADOT = "15sF76THfpefUaKomHZSpssayRbsp6Yt6ESgMrLjzJCmpe66"
const val AUTOMATION_KEY_ADDRESS = "5GQu4PFUb1f3MTJ7i7c1CtLgDk3TVvpSW1VbQCRmfkMoC8cM"
/** Same automation key, Polkadot Asset Hub SS58 encoding - needed to query/renew its real
* spending approval on that chain (BridgeMultisigInteractor.getPolkadotUsdtRemainingAllowance
* / submitPolkadotRenewalSignature). Confirmed on-chain (2026-07-16): unlike the Pezkuwi/
* wUSDT side, this approval has never been granted at all - every wUSDT->USDT withdrawal
* falls back to manual 3-of-5 review until the signatories grant one via the renewal flow
* below, same as they did for the wUSDT side. */
const val AUTOMATION_KEY_ADDRESS_POLKADOT = "15MCCiWYSnvWnzJdfkf1M3Aq5N37CENaaWE5ZVR8DqPKNcVj"
const val THRESHOLD = 3
/** Renewal is offered once the remaining allowance drops below this (6 decimals). Must match
@@ -47,6 +55,15 @@ object BridgeMultisigConstants {
/** The standard amount a renewal tops the allowance back up to (6 decimals). */
const val TOPUP_AMOUNT = 200_000_000_000L // 200,000 wUSDT
/** Same 40,000/200,000 (20%) ratio as the wUSDT side, mirrored for the Polkadot/real-USDT
* leg for the exact same reason: catch a draining allowance and renew it before users ever
* hit the "needs manual review" consent gate, rather than only reacting after the fact. The
* ceiling being far above the multisig's current real Polkadot reserve is intentional and
* harmless - transfer_approved is still bounded by the real on-chain balance underneath, an
* approval is unused headroom, not a promise of funds (see getPolkadotUsdtReserve). */
const val POLKADOT_RENEWAL_THRESHOLD = 40_000_000_000L // 40,000 USDT
const val POLKADOT_TOPUP_AMOUNT = 200_000_000_000L // 200,000 USDT
data class Signatory(val role: String, val address: String)
val SIGNATORIES = listOf(
@@ -39,6 +39,14 @@ interface BridgeMultisigInteractor {
suspend fun submitRenewalSignature(): Result<ExtrinsicExecutionResult>
/** Same as getSignerState/submitRenewalSignature but for the automation key's real-USDT
* spending approval on Polkadot Asset Hub - the leg that gates wUSDT->USDT withdrawal
* auto-pay. Confirmed on-chain (2026-07-16) this approval has never been granted at all, so
* needsRenewal is currently always true for every signatory until the first renewal signs. */
suspend fun getPolkadotSignerState(): BridgeSignerState?
suspend fun submitPolkadotRenewalSignature(): Result<ExtrinsicExecutionResult>
/** Real USDT (base units) the multisig actually holds on Polkadot Asset Hub right now - the
* true backing for wUSDT->USDT withdrawals. Replaces the old wusdtToUsdtActive boolean
* fetched from the legacy bridge bot's :3030/status endpoint, which this session stopped
@@ -46,6 +54,18 @@ interface BridgeMultisigInteractor {
* regardless of real reserve. A specific withdrawal should be allowed whenever it's covered
* by this real balance, not gated on an unrelated dead service or on total supply parity. */
suspend fun getPolkadotUsdtReserve(): BigInteger
/** Real remaining amount (base units) the automation key is currently approved to auto-pay
* out of the multisig's own wUSDT on Pezkuwi Asset Hub - the deterministic on-chain fact
* that decides whether a USDT->wUSDT deposit CAN possibly auto-pay (bounded further by the
* backend's own daily cap, which isn't visible from the wallet - this is a necessary, not
* sufficient, condition for auto-pay). Available to any wallet, not just signatories, since
* it drives the Bridge screen's pre-submit consent gate for everyone. */
suspend fun getWusdtRemainingAllowance(): BigInteger
/** Same as getWusdtRemainingAllowance but for the automation key's real USDT approval on
* Polkadot Asset Hub - gates wUSDT->USDT withdrawal auto-pay. */
suspend fun getPolkadotUsdtRemainingAllowance(): BigInteger
}
@FeatureScope
@@ -58,6 +78,69 @@ class RealBridgeMultisigInteractor @Inject constructor(
override suspend fun getSignerState(): BridgeSignerState? {
val chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB)
return getSignerStateFor(
chain = chain,
assetId = BridgeMultisigConstants.WUSDT_ASSET_ID,
automationKeyAddress = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS,
renewalThreshold = BridgeMultisigConstants.RENEWAL_THRESHOLD,
)
}
override suspend fun submitRenewalSignature(): Result<ExtrinsicExecutionResult> = submitRenewalSignatureFor(
chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB),
assetId = BridgeMultisigConstants.WUSDT_ASSET_ID,
automationKeyAddress = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS,
topupAmount = BridgeMultisigConstants.TOPUP_AMOUNT,
)
override suspend fun getPolkadotSignerState(): BridgeSignerState? {
val chain = chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB)
return getSignerStateFor(
chain = chain,
assetId = BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID,
automationKeyAddress = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS_POLKADOT,
renewalThreshold = BridgeMultisigConstants.POLKADOT_RENEWAL_THRESHOLD,
)
}
override suspend fun submitPolkadotRenewalSignature(): Result<ExtrinsicExecutionResult> = submitRenewalSignatureFor(
chain = chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB),
assetId = BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID,
automationKeyAddress = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS_POLKADOT,
topupAmount = BridgeMultisigConstants.POLKADOT_TOPUP_AMOUNT,
)
override suspend fun getPolkadotUsdtReserve(): BigInteger {
val polkadotChain = chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB)
val multisigAccountId = BridgeMultisigConstants.MULTISIG_ADDRESS_POLKADOT.toAccountId().intoKey()
return storageDataSource.query(polkadotChain.id) {
runtime.metadata.bridgeAssets().assetBalance.query(
BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID.toBigInteger(),
multisigAccountId,
)
} ?: BigInteger.ZERO
}
override suspend fun getWusdtRemainingAllowance(): BigInteger {
val chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB)
return queryRemainingAllowance(chain, BridgeMultisigConstants.WUSDT_ASSET_ID, BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS)
}
override suspend fun getPolkadotUsdtRemainingAllowance(): BigInteger {
val chain = chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB)
return queryRemainingAllowance(chain, BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID, BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS_POLKADOT)
}
/** Shared by both legs - the only differences between the wUSDT (Pezkuwi) and USDT (Polkadot)
* renewal flows are which chain/asset/automation-key-address/threshold to use, the actual
* on-chain call shape (Assets.approve_transfer wrapped in Multisig.as_multi) is identical. */
private suspend fun getSignerStateFor(
chain: Chain,
assetId: Int,
automationKeyAddress: String,
renewalThreshold: Long,
): BridgeSignerState? {
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
val myAccountId = metaAccount.accountIdIn(chain)?.intoKey() ?: return null
@@ -65,13 +148,13 @@ class RealBridgeMultisigInteractor @Inject constructor(
it.address.toAccountId().intoKey() == myAccountId
} ?: return null
val remaining = queryRemainingAllowance(chain)
val needsRenewal = remaining < BigInteger.valueOf(BridgeMultisigConstants.RENEWAL_THRESHOLD)
val remaining = queryRemainingAllowance(chain, assetId, automationKeyAddress)
val needsRenewal = remaining < BigInteger.valueOf(renewalThreshold)
var alreadySigned = false
var approvalsSoFar = 0
if (needsRenewal) {
val pending = queryPendingRenewal(chain)
val pending = queryPendingRenewal(chain, assetId, automationKeyAddress)
if (pending != null) {
approvalsSoFar = pending.approvals.size
alreadySigned = pending.approvals.contains(myAccountId)
@@ -87,11 +170,15 @@ class RealBridgeMultisigInteractor @Inject constructor(
)
}
override suspend fun submitRenewalSignature(): Result<ExtrinsicExecutionResult> = runCatching {
val chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB)
private suspend fun submitRenewalSignatureFor(
chain: Chain,
assetId: Int,
automationKeyAddress: String,
topupAmount: Long,
): Result<ExtrinsicExecutionResult> = runCatching {
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
val myAccountId = requireNotNull(metaAccount.accountIdIn(chain)?.intoKey()) {
"Selected account has no address on Pezkuwi Asset Hub"
"Selected account has no address on ${chain.name}"
}
val otherSignatories = BridgeMultisigConstants.SIGNATORIES
@@ -99,7 +186,7 @@ class RealBridgeMultisigInteractor @Inject constructor(
.filter { it != myAccountId }
.sortedBy { it.toHexWithPrefix() }
val pending = queryPendingRenewal(chain)
val pending = queryPendingRenewal(chain, assetId, automationKeyAddress)
check(pending == null || !pending.approvals.contains(myAccountId)) {
"Already signed this renewal - waiting for other signers"
}
@@ -109,9 +196,9 @@ class RealBridgeMultisigInteractor @Inject constructor(
origin = TransactionOrigin.WalletWithId(metaAccount.id)
) {
val approveTransferCall = runtime.composeAssetsApproveTransfer(
assetId = BridgeMultisigConstants.WUSDT_ASSET_ID,
delegate = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS.toAccountId().intoKey(),
amount = BigInteger.valueOf(BridgeMultisigConstants.TOPUP_AMOUNT),
assetId = assetId,
delegate = automationKeyAddress.toAccountId().intoKey(),
amount = BigInteger.valueOf(topupAmount),
)
val multisigCall = runtime.composeBridgeMultisigAsMulti(
@@ -126,47 +213,49 @@ class RealBridgeMultisigInteractor @Inject constructor(
}.getOrThrow().requireOk()
}
override suspend fun getPolkadotUsdtReserve(): BigInteger {
val polkadotChain = chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB)
val multisigAccountId = BridgeMultisigConstants.MULTISIG_ADDRESS_POLKADOT.toAccountId().intoKey()
return storageDataSource.query(polkadotChain.id) {
runtime.metadata.bridgeAssets().assetBalance.query(
BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID.toBigInteger(),
multisigAccountId,
)
} ?: BigInteger.ZERO
}
private suspend fun queryRemainingAllowance(chain: Chain): BigInteger {
val multisigAccountId = BridgeMultisigConstants.MULTISIG_ADDRESS.toAccountId().intoKey()
val delegateAccountId = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS.toAccountId().intoKey()
private suspend fun queryRemainingAllowance(chain: Chain, assetId: Int, automationKeyAddress: String): BigInteger {
val multisigAccountId = multisigAddressFor(chain).toAccountId().intoKey()
val delegateAccountId = automationKeyAddress.toAccountId().intoKey()
return storageDataSource.query(chain.id) {
runtime.metadata.bridgeAssets().approvalAmount.query(
BridgeMultisigConstants.WUSDT_ASSET_ID.toBigInteger(),
assetId.toBigInteger(),
multisigAccountId,
delegateAccountId,
)
} ?: BigInteger.ZERO
}
private suspend fun queryPendingRenewal(chain: Chain): BridgeOnChainMultisig? {
val multisigAccountId = BridgeMultisigConstants.MULTISIG_ADDRESS.toAccountId().intoKey()
val callHash = renewalCallHash(chain)
private suspend fun queryPendingRenewal(chain: Chain, assetId: Int, automationKeyAddress: String): BridgeOnChainMultisig? {
val multisigAccountId = multisigAddressFor(chain).toAccountId().intoKey()
val callHash = renewalCallHash(chain, assetId, automationKeyAddress)
return storageDataSource.query(chain.id) {
runtime.metadata.bridgeMultisig().multisigs.query(multisigAccountId, callHash)
}
}
private suspend fun renewalCallHash(chain: Chain): AccountIdKey {
private suspend fun renewalCallHash(chain: Chain, assetId: Int, automationKeyAddress: String): AccountIdKey {
val topupAmount = if (chain.id == ChainGeneses.POLKADOT_ASSET_HUB) {
BridgeMultisigConstants.POLKADOT_TOPUP_AMOUNT
} else {
BridgeMultisigConstants.TOPUP_AMOUNT
}
val runtime = chainRegistry.getRuntime(chain.id)
val call = runtime.composeAssetsApproveTransfer(
assetId = BridgeMultisigConstants.WUSDT_ASSET_ID,
delegate = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS.toAccountId().intoKey(),
amount = BigInteger.valueOf(BridgeMultisigConstants.TOPUP_AMOUNT),
assetId = assetId,
delegate = automationKeyAddress.toAccountId().intoKey(),
amount = BigInteger.valueOf(topupAmount),
)
return call.callHash(runtime).intoCallHash()
}
private fun multisigAddressFor(chain: Chain): String {
return if (chain.id == ChainGeneses.POLKADOT_ASSET_HUB) {
BridgeMultisigConstants.MULTISIG_ADDRESS_POLKADOT
} else {
BridgeMultisigConstants.MULTISIG_ADDRESS
}
}
}
@@ -61,10 +61,18 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
viewModel.swapClicked()
}
// Multisig signatory-only sign button
// Multisig signatory-only sign buttons, one per leg
binder.bridgeSignButton.setOnClickListener {
viewModel.signClicked()
}
binder.bridgePolkadotSignButton.setOnClickListener {
viewModel.polkadotSignClicked()
}
binder.bridgeConsentCheckbox.setOnCheckedChangeListener { _, isChecked ->
viewModel.consentCheckboxToggled(isChecked)
}
}
override fun onResume() {
@@ -146,6 +154,37 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
binder.bridgeSignButton.text = label
}
viewModel.polkadotSignButtonVisible.observe { visible ->
binder.bridgePolkadotSignButton.visibility = if (visible) View.VISIBLE else View.GONE
}
viewModel.polkadotSignButtonRed.observe { red ->
val color = if (red) {
resources.getColor(R.color.error_border, null)
} else {
resources.getColor(R.color.text_positive, null)
}
binder.bridgePolkadotSignButton.setButtonColor(color)
}
viewModel.polkadotSignButtonEnabled.observe { enabled ->
binder.bridgePolkadotSignButton.isEnabled = enabled
}
viewModel.polkadotSignButtonLabel.observe { label ->
binder.bridgePolkadotSignButton.text = label
}
viewModel.consentRequired.observe { required ->
binder.bridgeConsentCheckbox.visibility = if (required) View.VISIBLE else View.GONE
}
viewModel.consentChecked.observe { checked ->
if (binder.bridgeConsentCheckbox.isChecked != checked) {
binder.bridgeConsentCheckbox.setChecked(checked)
}
}
viewModel.maxAmountDisplay.observe { display ->
binder.bridgeFromMaxAmount.setMaxAmountDisplay(display)
}
@@ -115,6 +115,22 @@ class BridgeViewModel(
private val _signButtonLabel = MutableLiveData("")
val signButtonLabel: LiveData<String> = _signButtonLabel
/** Same as the sign* set above but for the automation key's Polkadot-side USDT approval -
* see BridgeMultisigInteractor.getPolkadotSignerState for why this is currently always
* offered (approval has never been granted at all). A separate row rather than merging with
* the wUSDT one since a signatory may need to renew one leg without the other. */
private val _polkadotSignButtonVisible = MutableLiveData(false)
val polkadotSignButtonVisible: LiveData<Boolean> = _polkadotSignButtonVisible
private val _polkadotSignButtonRed = MutableLiveData(false)
val polkadotSignButtonRed: LiveData<Boolean> = _polkadotSignButtonRed
private val _polkadotSignButtonEnabled = MutableLiveData(false)
val polkadotSignButtonEnabled: LiveData<Boolean> = _polkadotSignButtonEnabled
private val _polkadotSignButtonLabel = MutableLiveData("")
val polkadotSignButtonLabel: LiveData<String> = _polkadotSignButtonLabel
private val _fromCard = MutableLiveData<BridgeAssetCardUi>()
val fromCard: LiveData<BridgeAssetCardUi> = _fromCard
@@ -133,12 +149,38 @@ class BridgeViewModel(
private val _fillAmountEvent = MutableLiveData<Event<String>>()
val fillAmountEvent: LiveData<Event<String>> = _fillAmountEvent
/** True when the entered amount exceeds the automation key's current on-chain approval for
* this direction - real funds exist and the transfer WILL succeed, it just won't auto-pay
* and needs 3-of-5 signatory review. Distinct from showWarning/warningBlocked (which cover
* the harder "not enough real reserve at all" case, unresolvable by any signature) - this
* one is resolvable, so the UI offers an explicit opt-in instead of a flat block. */
private val _consentRequired = MutableLiveData(false)
val consentRequired: LiveData<Boolean> = _consentRequired
private val _consentChecked = MutableLiveData(false)
val consentChecked: LiveData<Boolean> = _consentChecked
fun consentCheckboxToggled(checked: Boolean) {
_consentChecked.value = checked
updateButtonState()
}
private var currentAmount: Double = 0.0
/** Real USDT the multisig actually holds on Polkadot Asset Hub - see
* BridgeMultisigInteractor.getPolkadotUsdtReserve for why this replaced a dead external
* status check that always reported "inactive" regardless of the real reserve. */
private var polkadotUsdtReserve: BigDecimal = BigDecimal.ZERO
/** Remaining on-chain approval (BridgeMultisigInteractor.getWusdtRemainingAllowance /
* getPolkadotUsdtRemainingAllowance), one per leg - the deterministic fact that decides
* whether a given amount can possibly auto-pay in that direction, independent of the real
* reserve check above (a withdrawal can be under-reserved AND under-approved at once; those
* are checked in priority order in updateWarningState, since only the reserve one is truly
* unresolvable). */
private var wusdtRemainingAllowance: BigDecimal = BigDecimal.ZERO
private var polkadotUsdtRemainingAllowance: BigDecimal = BigDecimal.ZERO
private var availableBalance: BigDecimal = BigDecimal.ZERO
private var balanceJob: Job? = null
@@ -155,6 +197,7 @@ class BridgeViewModel(
_pair.value = newPair
// Reset direction to left (forward) when switching pair
_direction.value = BridgeDirection.USDT_TO_WUSDT
_consentChecked.value = false
updateUI()
calculateOutput()
updateWarningState()
@@ -166,6 +209,7 @@ class BridgeViewModel(
val newDir = BridgeDirection.USDT_TO_WUSDT
if (_direction.value != newDir) {
_direction.value = newDir
_consentChecked.value = false
updateUI()
calculateOutput()
updateWarningState()
@@ -177,6 +221,7 @@ class BridgeViewModel(
val newDir = BridgeDirection.WUSDT_TO_USDT
if (_direction.value != newDir) {
_direction.value = newDir
_consentChecked.value = false
updateUI()
calculateOutput()
updateWarningState()
@@ -186,9 +231,12 @@ class BridgeViewModel(
fun setAmount(amount: Double) {
currentAmount = amount
// A consent already given was for whatever amount was entered at the time - changing the
// amount means re-confirming, not silently carrying an old opt-in over to a new one.
_consentChecked.value = false
calculateOutput()
updateInsufficientBalanceState()
updateWarningState() // re-check the entered amount against the cached reserve
updateWarningState() // re-check the entered amount against the cached reserve/allowance
}
fun maxClicked() {
@@ -202,6 +250,7 @@ class BridgeViewModel(
* different screens: there is no "just submitted" moment on this screen anymore). */
fun resetAmount() {
currentAmount = 0.0
_consentChecked.value = false
_fillAmountEvent.value = Event("")
calculateOutput()
updateInsufficientBalanceState()
@@ -227,6 +276,15 @@ class BridgeViewModel(
return
}
val relevantAllowance = when (dir) {
BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance
BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance
}
if (requested > relevantAllowance && _consentChecked.value != true) {
updateWarningState()
return
}
val chainId = when (dir) {
BridgeDirection.USDT_TO_WUSDT -> POLKADOT_ASSET_HUB_ID
BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_ASSET_HUB_ID
@@ -279,35 +337,63 @@ class BridgeViewModel(
} catch (e: Exception) {
BigDecimal.ZERO
}
wusdtRemainingAllowance = try {
BigDecimal(bridgeMultisigInteractor.getWusdtRemainingAllowance()).divide(USDT_DECIMALS_DIVISOR)
} catch (e: Exception) {
BigDecimal.ZERO
}
polkadotUsdtRemainingAllowance = try {
BigDecimal(bridgeMultisigInteractor.getPolkadotUsdtRemainingAllowance()).divide(USDT_DECIMALS_DIVISOR)
} catch (e: Exception) {
BigDecimal.ZERO
}
updateWarningState()
}
}
/** Two independent, differently-resolvable "no" conditions, checked in priority order - never
* collapsed into one generic warning:
* 1. Real reserve exceeded (withdrawal direction only) - the bridge doesn't hold enough real
* USDT on Polkadot Asset Hub. No signature can fix this; a hard block.
* 2. Automation-key approval exceeded (either direction) - funds exist, the automation key
* just isn't currently approved to move that much without 3-of-5 review. Resolvable, so
* this is an opt-in consent gate (see consentRequired/consentChecked), not a hard block. */
private fun updateWarningState() {
val dir = _direction.value ?: return
val requested = BigDecimal.valueOf(currentAmount)
when (dir) {
BridgeDirection.WUSDT_TO_USDT -> {
val requested = BigDecimal.valueOf(currentAmount)
val exceedsReserve = currentAmount > 0 && requested > polkadotUsdtReserve
_showWarning.postValue(exceedsReserve)
if (exceedsReserve) {
_warningBlocked.postValue(true)
_warningText.postValue(
resourceManager.getString(
R.string.bridge_wusdt_to_usdt_blocked,
polkadotUsdtReserve.setScale(2, RoundingMode.DOWN).stripTrailingZeros().toPlainString()
)
)
} else {
_warningBlocked.postValue(false)
_warningText.postValue("")
}
}
else -> {
_showWarning.postValue(false)
}
val reserveExceeded = dir == BridgeDirection.WUSDT_TO_USDT && currentAmount > 0 && requested > polkadotUsdtReserve
if (reserveExceeded) {
_consentRequired.postValue(false)
_showWarning.postValue(true)
_warningBlocked.postValue(true)
_warningText.postValue(
resourceManager.getString(
R.string.bridge_wusdt_to_usdt_blocked,
polkadotUsdtReserve.setScale(2, RoundingMode.DOWN).stripTrailingZeros().toPlainString()
)
)
updateButtonState()
return
}
val relevantAllowance = when (dir) {
BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance
BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance
}
val needsConsent = currentAmount > 0 && requested > relevantAllowance
_consentRequired.postValue(needsConsent)
if (needsConsent) {
_showWarning.postValue(true)
_warningBlocked.postValue(false)
_warningText.postValue(resourceManager.getString(R.string.bridge_consent_required_message))
} else {
_showWarning.postValue(false)
_warningText.postValue("")
}
updateButtonState()
}
@@ -332,11 +418,19 @@ class BridgeViewModel(
val dir = _direction.value ?: return
val requested = BigDecimal.valueOf(currentAmount)
val reserveExceeded = dir == BridgeDirection.WUSDT_TO_USDT && requested > polkadotUsdtReserve
val relevantAllowance = when (dir) {
BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance
BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance
}
val consentSatisfied = requested <= relevantAllowance || _consentChecked.value == true
_buttonState.value = when {
currentAmount <= 0 -> ButtonState.DISABLED
currentAmount < MIN_USDT -> ButtonState.DISABLED
requested > availableBalance -> ButtonState.DISABLED
dir == BridgeDirection.WUSDT_TO_USDT && requested > polkadotUsdtReserve -> ButtonState.DISABLED
reserveExceeded -> ButtonState.DISABLED
!consentSatisfied -> ButtonState.DISABLED
else -> ButtonState.NORMAL
}
}
@@ -357,7 +451,11 @@ class BridgeViewModel(
fun refreshSignerState() {
launch {
val state = bridgeMultisigInteractor.getSignerState()
applySignerState(state)
applySignerState(state, _signButtonVisible, _signButtonRed, _signButtonEnabled, _signButtonLabel)
}
launch {
val state = bridgeMultisigInteractor.getPolkadotSignerState()
applySignerState(state, _polkadotSignButtonVisible, _polkadotSignButtonRed, _polkadotSignButtonEnabled, _polkadotSignButtonLabel)
}
}
@@ -376,29 +474,50 @@ class BridgeViewModel(
}
}
private fun applySignerState(state: BridgeSignerState?) {
fun polkadotSignClicked() {
if (_polkadotSignButtonEnabled.value != true) return
_polkadotSignButtonEnabled.postValue(false)
_polkadotSignButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_in_progress))
launch {
bridgeMultisigInteractor.submitPolkadotRenewalSignature()
.onFailure {
_polkadotSignButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_error))
}
refreshSignerState()
}
}
private fun applySignerState(
state: BridgeSignerState?,
visible: MutableLiveData<Boolean>,
red: MutableLiveData<Boolean>,
enabled: MutableLiveData<Boolean>,
label: MutableLiveData<String>,
) {
if (state == null) {
_signButtonVisible.postValue(false)
visible.postValue(false)
return
}
_signButtonVisible.postValue(true)
visible.postValue(true)
when {
!state.needsRenewal -> {
_signButtonRed.postValue(false)
_signButtonEnabled.postValue(false)
_signButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_status_ok, state.signatoryRole))
red.postValue(false)
enabled.postValue(false)
label.postValue(resourceManager.getString(R.string.bridge_sign_status_ok, state.signatoryRole))
}
state.alreadySignedPendingRenewal -> {
_signButtonRed.postValue(true)
_signButtonEnabled.postValue(false)
_signButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_waiting_others))
red.postValue(true)
enabled.postValue(false)
label.postValue(resourceManager.getString(R.string.bridge_sign_waiting_others))
}
else -> {
_signButtonRed.postValue(true)
_signButtonEnabled.postValue(true)
_signButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_button, state.signatoryRole))
red.postValue(true)
enabled.postValue(true)
label.postValue(resourceManager.getString(R.string.bridge_sign_button, state.signatoryRole))
}
}
}
@@ -170,7 +170,9 @@
</com.google.android.material.card.MaterialCardView>
<!-- Warning for USDT liquidity -->
<!-- Warning for USDT liquidity, or the consent message when the amount exceeds the
automation key's current approval (resolvable by 3-of-5 review, unlike the
reserve case above which isn't - see BridgeViewModel.updateWarningState). -->
<io.novafoundation.nova.common.view.AlertView
android:id="@+id/bridgeWarningAlert"
android:layout_width="match_parent"
@@ -179,8 +181,21 @@
android:visibility="gone"
app:alertMode="warning" />
<!-- Multisig signatory-only bridge allowance sign button. Only visible to a wallet
that is one of the bridge multisig's 5 known signatories. -->
<!-- Only shown alongside the consent message above (consentRequired) - explicit
opt-in required before the Swap button re-enables, never enabled by default. -->
<io.novafoundation.nova.common.view.WarningCheckBox
android:id="@+id/bridgeConsentCheckbox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:icon="@drawable/ic_warning_filled"
android:text="@string/bridge_consent_checkbox_label"
android:visibility="gone"
app:iconTint="@color/icon_warning" />
<!-- Multisig signatory-only bridge allowance sign buttons. Only visible to a wallet
that is one of the bridge multisig's 5 known signatories - one row per leg, since
a signatory may need to renew one chain's approval without the other. -->
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/bridgeSignButton"
style="@style/Widget.Nova.Button.Primary"
@@ -190,6 +205,15 @@
android:text="@string/bridge_sign_button"
android:visibility="gone" />
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/bridgePolkadotSignButton"
style="@style/Widget.Nova.Button.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/bridge_sign_button"
android:visibility="gone" />
</LinearLayout>
</ScrollView>