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.
This commit is contained in:
2026-07-16 10:23:01 -07:00
parent d0db0a54f7
commit ec0ec9153f
6 changed files with 66 additions and 19 deletions
@@ -44,6 +44,14 @@ object BridgeMultisigConstants {
const val THRESHOLD = 3 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 /** 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 * 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 * server-side - all three signing channels (this app, pwap-web, PezbridgeBot's Telegram
@@ -276,11 +276,8 @@ class BridgeViewModel(
return return
} }
val relevantAllowance = when (dir) { val expectManualReview = exceedsAutoPayBounds(dir, requested)
BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance if (expectManualReview && _consentChecked.value != true) {
BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance
}
if (requested > relevantAllowance && _consentChecked.value != true) {
updateWarningState() updateWarningState()
return return
} }
@@ -320,6 +317,7 @@ class BridgeViewModel(
amount = amount, amount = amount,
destChainId = destChainId, destChainId = destChainId,
destAssetId = destAssetId, destAssetId = destAssetId,
expectManualReview = expectManualReview,
) )
) )
} }
@@ -357,7 +355,12 @@ class BridgeViewModel(
* USDT on Polkadot Asset Hub. No signature can fix this; a hard block. * 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 * 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 * 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() { private fun updateWarningState() {
val dir = _direction.value ?: return val dir = _direction.value ?: return
val requested = BigDecimal.valueOf(currentAmount) val requested = BigDecimal.valueOf(currentAmount)
@@ -378,11 +381,7 @@ class BridgeViewModel(
return return
} }
val relevantAllowance = when (dir) { val needsConsent = currentAmount > 0 && exceedsAutoPayBounds(dir, requested)
BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance
BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance
}
val needsConsent = currentAmount > 0 && requested > relevantAllowance
_consentRequired.postValue(needsConsent) _consentRequired.postValue(needsConsent)
if (needsConsent) { if (needsConsent) {
@@ -397,6 +396,18 @@ class BridgeViewModel(
updateButtonState() 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() { private fun calculateOutput() {
val netOutput = currentAmount * (1 - FEE_PERCENT) // 1:1, fee-adjusted val netOutput = currentAmount * (1 - FEE_PERCENT) // 1:1, fee-adjusted
@@ -419,11 +430,7 @@ class BridgeViewModel(
val requested = BigDecimal.valueOf(currentAmount) val requested = BigDecimal.valueOf(currentAmount)
val reserveExceeded = dir == BridgeDirection.WUSDT_TO_USDT && requested > polkadotUsdtReserve val reserveExceeded = dir == BridgeDirection.WUSDT_TO_USDT && requested > polkadotUsdtReserve
val relevantAllowance = when (dir) { val consentSatisfied = !exceedsAutoPayBounds(dir, requested) || _consentChecked.value == true
BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance
BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance
}
val consentSatisfied = requested <= relevantAllowance || _consentChecked.value == true
_buttonState.value = when { _buttonState.value = when {
currentAmount <= 0 -> ButtonState.DISABLED currentAmount <= 0 -> ButtonState.DISABLED
@@ -25,7 +25,13 @@ class BridgeExecutionFragment : BaseFragment<BridgeExecutionViewModel, FragmentB
override fun createBinding() = FragmentBridgeExecutionBinding.inflate(layoutInflater) override fun createBinding() = FragmentBridgeExecutionBinding.inflate(layoutInflater)
override fun initViews() { override fun initViews() {
binder.bridgeExecutionToolbar.setHomeButtonListener { viewModel.doneClicked() } // Matches SwapExecutionFragment: a bridge transfer is in flight for the whole lifetime of
// this screen, so the only way out is the dedicated Done button once it becomes visible -
// neither the hardware back button nor the toolbar's home arrow should be able to
// navigate away mid-submission.
onBackPressed { /* suppress back presses */ }
binder.bridgeExecutionToolbar.setHomeButtonVisibility(false)
binder.bridgeExecutionDoneButton.setOnClickListener { viewModel.doneClicked() } binder.bridgeExecutionDoneButton.setOnClickListener { viewModel.doneClicked() }
@@ -94,7 +100,7 @@ class BridgeExecutionFragment : BaseFragment<BridgeExecutionViewModel, FragmentB
binder.bridgeExecutionPendingReviewAlert.setStylePreset(AlertView.StylePreset.ERROR) binder.bridgeExecutionPendingReviewAlert.setStylePreset(AlertView.StylePreset.ERROR)
binder.bridgeExecutionPendingReviewAlert.setMessage(state.message) binder.bridgeExecutionPendingReviewAlert.setMessage(state.message)
} }
BridgeExecutionState.DestinationPendingReview -> { BridgeExecutionState.DestinationPendingReview, BridgeExecutionState.AwaitingManualReview -> {
binder.bridgeExecutionPendingReviewAlert.visibility = View.VISIBLE binder.bridgeExecutionPendingReviewAlert.visibility = View.VISIBLE
binder.bridgeExecutionPendingReviewAlert.setStylePreset(AlertView.StylePreset.WARNING) binder.bridgeExecutionPendingReviewAlert.setStylePreset(AlertView.StylePreset.WARNING)
binder.bridgeExecutionPendingReviewAlert.setMessage(getString(R.string.bridge_deposit_pending_message)) binder.bridgeExecutionPendingReviewAlert.setMessage(getString(R.string.bridge_deposit_pending_message))
@@ -17,4 +17,9 @@ class BridgeExecutionPayload(
val amount: Double, val amount: Double,
val destChainId: String, val destChainId: String,
val destAssetId: Int, 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 ) : Parcelable
@@ -22,6 +22,14 @@ sealed class BridgeExecutionState {
* available. */ * available. */
object WaitingForDestination : BridgeExecutionState() 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. */ /** Destination balance increase actually observed within the wait window. */
object DestinationConfirmed : BridgeExecutionState() object DestinationConfirmed : BridgeExecutionState()
@@ -165,7 +165,20 @@ class BridgeExecutionViewModel(
} }
submissionResult.fold( 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 -> onFailure = { e ->
_state.postValue(BridgeExecutionState.OriginFailed(e.message ?: resourceManager.getString(R.string.bridge_deposit_pending_message))) _state.postValue(BridgeExecutionState.OriginFailed(e.message ?: resourceManager.getString(R.string.bridge_deposit_pending_message)))
_timerState.postValue(ExecutionTimerView.State.Error) _timerState.postValue(ExecutionTimerView.State.Error)