From ec0ec9153f06d65d4489105c851ccea09876d57f Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 16 Jul 2026 10:23:01 -0700 Subject: [PATCH] Bridge: lock down execution navigation, predict manual review upfront Execution screen safety fix: hardware back and the toolbar home button could navigate away mid-submission (the transfer's own ViewModel scope gets cancelled on leaving, same class of risk as leaving Swap's execution screen mid-flight, which that screen has always prevented). Now matches SwapExecutionFragment exactly - back suppressed, home button hidden, the only way out is the Done button once the operation has actually resolved. Bridge previously only found out "this needs manual 3-of-5 review" after watching the destination balance for up to 90s and giving up - honest, but slow to tell the user something the input screen's own on-chain checks already knew before submission. The consent gate checked the automation key's remaining on-chain allowance but not usdt-bridge's separate hard per-tx cap (max_single_tx, 50,000 USDT) - a large single swap could read as "fast" against a freshly-renewed 200,000 allowance while the backend would still force it to manual review. Added that check (BridgeMultisigConstants.MAX_SINGLE_TX), forwarded the resulting prediction through BridgeExecutionPayload.expectManualReview, and added a new AwaitingManualReview state so the execution screen shows the same clear message ("needs approval from 3 of 5 signatories, ~2 hours, t.me/pezkuwidestek") immediately instead of after a false wait - regardless of whether manual review is needed because the amount itself is large or because other users' swaps have temporarily used up the automation key's approval. --- .../multisig/BridgeMultisigConstants.kt | 8 ++++ .../presentation/bridge/BridgeViewModel.kt | 39 +++++++++++-------- .../execution/BridgeExecutionFragment.kt | 10 ++++- .../execution/BridgeExecutionPayload.kt | 5 +++ .../bridge/execution/BridgeExecutionState.kt | 8 ++++ .../execution/BridgeExecutionViewModel.kt | 15 ++++++- 6 files changed, 66 insertions(+), 19 deletions(-) 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 db727176..37ba9b73 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 @@ -44,6 +44,14 @@ object BridgeMultisigConstants { const val THRESHOLD = 3 + /** Hard per-transaction ceiling the automation key may ever execute on its own, regardless of + * how much on-chain approval remains - must match usdt-bridge's own `max_single_tx` + * (default_max_single_tx() in bridge_config.json). A single withdrawal/deposit above this is + * ALWAYS queued for manual 3-of-5 review even right after a fresh renewal (200,000 wUSDT/USDT + * topup), so checking the remaining allowance alone is not sufficient to predict "will this + * auto-pay" - both checks are needed (see BridgeViewModel.updateWarningState). */ + const val MAX_SINGLE_TX = 50_000_000_000L // 50,000 USDT (6 decimals) + /** Renewal is offered once the remaining allowance drops below this (6 decimals). Must match * pezbridge_bot_config.json's renewal_threshold and usdt-bridge's auto_pay_daily_cap sizing * server-side - all three signing channels (this app, pwap-web, PezbridgeBot's Telegram 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 86aeb9dd..98cf0ecd 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 @@ -276,11 +276,8 @@ class BridgeViewModel( return } - val relevantAllowance = when (dir) { - BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance - BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance - } - if (requested > relevantAllowance && _consentChecked.value != true) { + val expectManualReview = exceedsAutoPayBounds(dir, requested) + if (expectManualReview && _consentChecked.value != true) { updateWarningState() return } @@ -320,6 +317,7 @@ class BridgeViewModel( amount = amount, destChainId = destChainId, destAssetId = destAssetId, + expectManualReview = expectManualReview, ) ) } @@ -357,7 +355,12 @@ class BridgeViewModel( * 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. */ + * this is an opt-in consent gate (see consentRequired/consentChecked), not a hard block. + * + * Consent is also required above MAX_SINGLE_TX regardless of the remaining allowance - a + * fresh renewal tops the allowance up to 200,000 but usdt-bridge's own hard per-tx cap + * (50,000) still forces manual review for anything above it, so checking only the allowance + * would wrongly predict "fast" for a large single swap. */ private fun updateWarningState() { val dir = _direction.value ?: return val requested = BigDecimal.valueOf(currentAmount) @@ -378,11 +381,7 @@ class BridgeViewModel( return } - val relevantAllowance = when (dir) { - BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance - BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance - } - val needsConsent = currentAmount > 0 && requested > relevantAllowance + val needsConsent = currentAmount > 0 && exceedsAutoPayBounds(dir, requested) _consentRequired.postValue(needsConsent) if (needsConsent) { @@ -397,6 +396,18 @@ class BridgeViewModel( updateButtonState() } + /** Shared by updateWarningState/updateButtonState/swapClicked so all three ever agree on the + * same prediction - see updateWarningState's doc comment for why both the on-chain allowance + * AND the hard per-tx cap need checking, not just the allowance. */ + private fun exceedsAutoPayBounds(dir: BridgeDirection, requested: BigDecimal): Boolean { + val relevantAllowance = when (dir) { + BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance + BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance + } + val maxSingleTx = BigDecimal.valueOf(BridgeMultisigConstants.MAX_SINGLE_TX).divide(USDT_DECIMALS_DIVISOR) + return requested > relevantAllowance || requested > maxSingleTx + } + private fun calculateOutput() { val netOutput = currentAmount * (1 - FEE_PERCENT) // 1:1, fee-adjusted @@ -419,11 +430,7 @@ class BridgeViewModel( 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 + val consentSatisfied = !exceedsAutoPayBounds(dir, requested) || _consentChecked.value == true _buttonState.value = when { currentAmount <= 0 -> ButtonState.DISABLED diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionFragment.kt index becccf48..d7f5b739 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionFragment.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionFragment.kt @@ -25,7 +25,13 @@ class BridgeExecutionFragment : BaseFragment { + BridgeExecutionState.DestinationPendingReview, BridgeExecutionState.AwaitingManualReview -> { binder.bridgeExecutionPendingReviewAlert.visibility = View.VISIBLE binder.bridgeExecutionPendingReviewAlert.setStylePreset(AlertView.StylePreset.WARNING) binder.bridgeExecutionPendingReviewAlert.setMessage(getString(R.string.bridge_deposit_pending_message)) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionPayload.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionPayload.kt index fbefad68..25dec3a4 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionPayload.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionPayload.kt @@ -17,4 +17,9 @@ class BridgeExecutionPayload( val amount: Double, val destChainId: String, val destAssetId: Int, + /** Computed on the input screen (BridgeViewModel.exceedsAutoPayBounds) from the real on-chain + * automation-key allowance and the hard per-tx cap, at the moment the user confirmed - tells + * this screen whether to expect a fast auto-pay or manual 3-of-5 review from frame one, + * instead of only finding out after a balance-watch timeout elapses. */ + val expectManualReview: Boolean, ) : Parcelable diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionState.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionState.kt index b030fdc1..adbb9549 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionState.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionState.kt @@ -22,6 +22,14 @@ sealed class BridgeExecutionState { * available. */ object WaitingForDestination : BridgeExecutionState() + /** Origin transfer confirmed on-chain, and BridgeViewModel already predicted (before this + * screen was even reached - see BridgeExecutionPayload.expectManualReview) that this amount + * cannot auto-pay and needs 3-of-5 signatory review. Shown immediately rather than only + * after a balance-watch timeout, since watching for a fast destination credit that's already + * known not to be coming would just be a slower way to arrive at the same DestinationPendingReview + * message. */ + object AwaitingManualReview : BridgeExecutionState() + /** Destination balance increase actually observed within the wait window. */ object DestinationConfirmed : BridgeExecutionState() diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionViewModel.kt index 6ff98740..ed8093fa 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionViewModel.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionViewModel.kt @@ -165,7 +165,20 @@ class BridgeExecutionViewModel( } submissionResult.fold( - onSuccess = { observeDepositCompletion() }, + onSuccess = { + if (payload.expectManualReview) { + // Already known, before this screen was even reached, that this amount can't + // auto-pay (see BridgeExecutionPayload.expectManualReview) - watching the + // destination balance for up to DEPOSIT_WAIT_TIMEOUT would just be a slower, + // falsely-hopeful way of arriving at the same place. Say so immediately. + _state.postValue(BridgeExecutionState.AwaitingManualReview) + _label.postValue(resourceManager.getString(R.string.bridge_deposit_pending_message)) + _timerState.postValue(null) + _doneButtonVisible.postValue(true) + } else { + observeDepositCompletion() + } + }, onFailure = { e -> _state.postValue(BridgeExecutionState.OriginFailed(e.message ?: resourceManager.getString(R.string.bridge_deposit_pending_message))) _timerState.postValue(ExecutionTimerView.State.Error)