From 6fd7a56382f54d896d16d244f6eb5be44f2904bd Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 16 Jul 2026 15:57:12 -0700 Subject: [PATCH] Bridge: add retry on origin failure, dedupe fee constant Retry: OriginFailed only offered "Done" (start over from the input screen) - the origin transfer never left the wallet in that state, so retrying is just re-running submit() from scratch, same as Swap's own retryClicked(). The execution screen's action button now shows "Try again"/retries when OriginFailed, "Done" otherwise - derived from the same state observer already driving the alert banner, not a second LiveData to keep in sync. FEE_PERCENT was duplicated as separate 0.001 literals in BridgeViewModel and BridgeExecutionViewModel. Moved to BridgeMultisigConstants (already the shared home for this feature's cross-cutting constants) so there's one number to change if the server-side fee_basis_points ever does. Deliberately NOT touching the deeper architecture items (LiveData vs StateFlow, extracting submit logic to a domain interactor) - Bridge's sealed BridgeExecutionState already gives it the single-source-of-truth property that motivated Swap's StateFlow design, and its submit/wait logic isn't shared with any other screen, so there's no duplication or desync bug those changes would actually fix here. This scope is staying USDT.p<->USDT only going forward (other bridge/swap pairs route through the separate pezsnowbridge DApp) - the real trigger for that refactor (a second bridge pair reusing this logic) will not happen in this screen, so it's not deferred tech debt, it's a closed decision. --- .../bridge/multisig/BridgeMultisigConstants.kt | 5 +++++ .../presentation/bridge/BridgeViewModel.kt | 3 +-- .../bridge/execution/BridgeExecutionFragment.kt | 14 ++++++++++++-- .../bridge/execution/BridgeExecutionViewModel.kt | 16 ++++++++++++---- 4 files changed, 30 insertions(+), 8 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 37ba9b73..58987a83 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,11 @@ object BridgeMultisigConstants { const val THRESHOLD = 3 + /** Single source of truth for the bridge fee - was previously duplicated as separate literals + * in BridgeViewModel and BridgeExecutionViewModel, both computing the same 1:1-minus-fee + * output. Must match usdt-bridge's own `fee_basis_points` (10 = 0.1%) server-side. */ + const val FEE_PERCENT = 0.001 + /** 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 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 98cf0ecd..08732174 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 @@ -69,7 +69,6 @@ class BridgeViewModel( const val POLKADOT_USDT_ASSET_ID = 1 // assetId in chains.json for Polkadot AH const val PEZKUWI_USDT_ASSET_ID = 1000 // assetId in chains.json for Pezkuwi AH - const val FEE_PERCENT = 0.001 const val MIN_USDT = 1.0 // USDT has 6 decimals on both Polkadot and Pezkuwi Asset Hub. @@ -409,7 +408,7 @@ class BridgeViewModel( } private fun calculateOutput() { - val netOutput = currentAmount * (1 - FEE_PERCENT) // 1:1, fee-adjusted + val netOutput = currentAmount * (1 - BridgeMultisigConstants.FEE_PERCENT) // 1:1, fee-adjusted _outputAmount.value = if (netOutput > 0) { BigDecimal(netOutput).setScale(6, RoundingMode.DOWN).stripTrailingZeros().toPlainString() 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 d7f5b739..ddda2720 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 @@ -33,8 +33,6 @@ class BridgeExecutionFragment : BaseFragment when (state) { is BridgeExecutionState.OriginFailed -> { binder.bridgeExecutionPendingReviewAlert.visibility = View.VISIBLE binder.bridgeExecutionPendingReviewAlert.setStylePreset(AlertView.StylePreset.ERROR) binder.bridgeExecutionPendingReviewAlert.setMessage(state.message) + binder.bridgeExecutionDoneButton.setText(R.string.common_try_again) + binder.bridgeExecutionDoneButton.setOnClickListener { viewModel.retryClicked() } } 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)) + binder.bridgeExecutionDoneButton.setText(R.string.common_done) + binder.bridgeExecutionDoneButton.setOnClickListener { viewModel.doneClicked() } } else -> { binder.bridgeExecutionPendingReviewAlert.visibility = View.GONE + binder.bridgeExecutionDoneButton.setText(R.string.common_done) + binder.bridgeExecutionDoneButton.setOnClickListener { viewModel.doneClicked() } } } } 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 ed8093fa..7c14d385 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 @@ -12,6 +12,7 @@ import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAcco import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback import io.novafoundation.nova.feature_assets.R import io.novafoundation.nova.feature_assets.domain.WalletInteractor +import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigConstants import io.novafoundation.nova.feature_assets.domain.send.SendInteractor import io.novafoundation.nova.feature_assets.presentation.AssetsRouter import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeAssetCardUi @@ -57,8 +58,6 @@ class BridgeExecutionViewModel( /** How long to actively watch the destination balance before admitting we can't confirm * completion yet - not a claim about how long the bridge itself actually takes. */ val DEPOSIT_WAIT_TIMEOUT = 90.seconds - - const val FEE_PERCENT = 0.001 } private val _state = MutableLiveData(BridgeExecutionState.SubmittingOrigin) @@ -98,6 +97,15 @@ class BridgeExecutionViewModel( router.back() } + /** Only meaningful from OriginFailed - the origin transfer never left the wallet (nothing to + * reverse), so retrying is just re-running submit() from scratch, matching what the user + * already confirmed on the input screen. Mirrors SwapExecutionViewModel.retryClicked(). */ + fun retryClicked() { + launch { + submit() + } + } + private suspend fun loadCards() { val originChain = chainRegistry.getChain(payload.originChainId) val destChain = chainRegistry.getChain(payload.destChainId) @@ -107,7 +115,7 @@ class BridgeExecutionViewModel( _fromAmountText.postValue(BigDecimal.valueOf(payload.amount).stripTrailingZeros().toPlainString()) - val netOutput = payload.amount * (1 - FEE_PERCENT) + val netOutput = payload.amount * (1 - BridgeMultisigConstants.FEE_PERCENT) _toAmountText.postValue(BigDecimal(netOutput).setScale(6, RoundingMode.DOWN).stripTrailingZeros().toPlainString()) } @@ -211,7 +219,7 @@ class BridgeExecutionViewModel( _label.postValue(resourceManager.getString(R.string.bridge_execution_waiting_destination_label, destChainName)) _timerState.postValue(ExecutionTimerView.State.CountdownTimer(DEPOSIT_WAIT_TIMEOUT)) - val minExpectedIncrease = BigDecimal.valueOf(payload.amount * (1 - FEE_PERCENT * 2)) // fee + rounding slack + val minExpectedIncrease = BigDecimal.valueOf(payload.amount * (1 - BridgeMultisigConstants.FEE_PERCENT * 2)) // fee + rounding slack val confirmed = withTimeoutOrNull(DEPOSIT_WAIT_TIMEOUT.inWholeMilliseconds) { walletInteractor.assetFlow(payload.destChainId, payload.destAssetId)