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)