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.
This commit is contained in:
2026-07-16 15:57:12 -07:00
parent ec0ec9153f
commit 6fd7a56382
4 changed files with 30 additions and 8 deletions
@@ -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
@@ -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()
@@ -33,8 +33,6 @@ class BridgeExecutionFragment : BaseFragment<BridgeExecutionViewModel, FragmentB
binder.bridgeExecutionToolbar.setHomeButtonVisibility(false)
binder.bridgeExecutionDoneButton.setOnClickListener { viewModel.doneClicked() }
binder.bridgeExecutionFromCard.setEditable(false)
binder.bridgeExecutionToCard.setEditable(false)
}
@@ -93,20 +91,32 @@ class BridgeExecutionFragment : BaseFragment<BridgeExecutionViewModel, FragmentB
// Single source of truth for the alert banner - only OriginFailed (error) and
// DestinationPendingReview (warning) show it, everything else keeps it hidden. Avoids the
// old screen's problem of several independent LiveData all touching the same view.
//
// Also the single source of truth for the action button's label/click target: only
// OriginFailed offers a real retry (the origin transfer never left the wallet, so
// re-running submit() from scratch is safe and matches SwapExecutionFragment's
// "Try again" pattern) - every other resolved state means something already happened
// on-chain, so the button can only ever mean "Done".
viewModel.state.observe { state ->
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() }
}
}
}
@@ -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>(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)