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 @@
-
+
-
+
+
+
+
+
+