diff --git a/common/src/main/res/values/strings.xml b/common/src/main/res/values/strings.xml index 0a6eb8d1..47386504 100644 --- a/common/src/main/res/values/strings.xml +++ b/common/src/main/res/values/strings.xml @@ -365,6 +365,8 @@ Amount below minimum Enter amount This amount exceeds the bridge\'s current available reserve (%s USDT). Try a smaller amount. + 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. + I understand this may take time to complete and want to proceed Bridge allowance OK (%s) Sign renewal (%s) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigConstants.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigConstants.kt index 8b269215..db727176 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigConstants.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigConstants.kt @@ -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( diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigInteractor.kt index 27ecfff3..83910ef6 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigInteractor.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigInteractor.kt @@ -39,6 +39,14 @@ interface BridgeMultisigInteractor { suspend fun submitRenewalSignature(): Result + /** 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 + /** 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 = 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 = 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 = runCatching { - val chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB) + private suspend fun submitRenewalSignatureFor( + chain: Chain, + assetId: Int, + automationKeyAddress: String, + topupAmount: Long, + ): Result = 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 + } + } } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt index 10e9b15b..35151b6f 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt @@ -61,10 +61,18 @@ class BridgeFragment : BaseFragment() { 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() { 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) } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt index fc4e2b68..86aeb9dd 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt @@ -115,6 +115,22 @@ class BridgeViewModel( private val _signButtonLabel = MutableLiveData("") val signButtonLabel: LiveData = _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 = _polkadotSignButtonVisible + + private val _polkadotSignButtonRed = MutableLiveData(false) + val polkadotSignButtonRed: LiveData = _polkadotSignButtonRed + + private val _polkadotSignButtonEnabled = MutableLiveData(false) + val polkadotSignButtonEnabled: LiveData = _polkadotSignButtonEnabled + + private val _polkadotSignButtonLabel = MutableLiveData("") + val polkadotSignButtonLabel: LiveData = _polkadotSignButtonLabel + private val _fromCard = MutableLiveData() val fromCard: LiveData = _fromCard @@ -133,12 +149,38 @@ class BridgeViewModel( private val _fillAmountEvent = MutableLiveData>() val fillAmountEvent: LiveData> = _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 = _consentRequired + + private val _consentChecked = MutableLiveData(false) + val consentChecked: LiveData = _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, + red: MutableLiveData, + enabled: MutableLiveData, + label: MutableLiveData, + ) { 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)) } } } diff --git a/feature-assets/src/main/res/layout/fragment_bridge.xml b/feature-assets/src/main/res/layout/fragment_bridge.xml index 59feac79..b8d1dc16 100644 --- a/feature-assets/src/main/res/layout/fragment_bridge.xml +++ b/feature-assets/src/main/res/layout/fragment_bridge.xml @@ -170,7 +170,9 @@ - + - + + + + + +