Split Bridge into input and dedicated execution screens, matching Swap

The single-screen design (amount input + live balance validation + submit
+ destination-wait, all in one ViewModel) was structurally fragile: the
live origin-balance observer and the amount-vs-balance validation ran in
the same reactive scope as execution, so a real successful transfer could
flash a false "insufficient balance" error the instant the balance dropped
post-submission. Patching that race (clearing the field, then an isExecuting
flag) kept fixing symptoms without addressing why the class of bug existed:
the app's own Swap flow avoids it entirely by keeping amount entry and
execution as separate screens/ViewModels, so validation code for the input
screen simply doesn't exist on the execution screen.

BridgeExecutionViewModel/Fragment now own the actual submit + destination-
wait, reached by navigating with a BridgeExecutionPayload once
BridgeViewModel.swapClicked() re-validates amount against the latest known
balance/reserve - a real confirmation gate, not just a UI button-disabled
look the domain layer never itself checked. A single BridgeExecutionState
sealed class (SubmittingOrigin/OriginFailed/WaitingForDestination/
DestinationConfirmed/DestinationPendingReview) replaces what used to be six
independently-updated LiveData flags that had to be kept in sync by hand.

Also fixed two real bugs found via live device testing with real funds:
- BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID was 1 (the wallet's own
  chains.json ordinal for USDT on Polkadot Asset Hub) instead of 1984 (the
  real on-chain Assets pallet id, confirmed directly against the chain).
  getPolkadotUsdtReserve() queried asset 1, which the multisig has never
  held anything at, so the wUSDT->USDT reserve check always reported 0
  USDT available regardless of the multisig's real (and growing) holdings.
- The deposit-wait label's text was static XML, never updated on the
  success path - a genuinely completed bridge (destination balance
  confirmed, checkmark shown) still read "Waiting for confirmation..."
  forever, indistinguishable from actually being stuck. The new execution
  screen's label is driven by BridgeExecutionState instead.
This commit is contained in:
2026-07-15 16:12:03 -07:00
parent cb8f0d8aae
commit 52e5645409
17 changed files with 544 additions and 210 deletions
@@ -57,6 +57,8 @@ import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.Cha
import io.novafoundation.nova.feature_ahm_impl.presentation.migrationDetails.ChainMigrationDetailsPayload
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.balance.detail.BalanceDetailFragment
import io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionFragment
import io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionPayload
import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowFragment
import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload
import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel
@@ -304,6 +306,14 @@ class Navigator(
.navigateInFirstAttachedContext()
}
override fun openBridgeExecution(payload: BridgeExecutionPayload) {
val bundle = BridgeExecutionFragment.getBundle(payload)
navigationBuilder().action(R.id.action_bridge_to_execution)
.setArgs(bundle)
.navigateInFirstAttachedContext()
}
override fun openTransferDetail(transaction: OperationParcelizeModel.Transfer) {
val bundle = TransferDetailFragment.getBundle(transaction)
@@ -1201,8 +1201,22 @@
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
<action
android:id="@+id/action_bridge_to_execution"
app:destination="@id/bridgeExecutionFragment"
app:enterAnim="@anim/fragment_open_enter"
app:exitAnim="@anim/fragment_open_exit"
app:popEnterAnim="@anim/fragment_close_enter"
app:popExitAnim="@anim/fragment_close_exit" />
</fragment>
<fragment
android:id="@+id/bridgeExecutionFragment"
android:name="io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionFragment"
android:label="BridgeExecutionFragment"
tools:layout="@layout/fragment_bridge_execution" />
<fragment
android:id="@+id/tradeProvidersFragment"
android:name="io.novafoundation.nova.feature_assets.presentation.trade.provider.TradeProviderListFragment"
+4 -1
View File
@@ -372,9 +372,12 @@
<string name="bridge_sign_in_progress">Signing…</string>
<string name="bridge_sign_error">Signing failed, tap to retry</string>
<string name="bridge_deposit_wait_label">Waiting for confirmation on Pezkuwi Asset Hub…</string>
<string name="bridge_deposit_success_label">Bridge complete — funds delivered</string>
<string name="bridge_deposit_pending_message">Still processing - this can take longer if it needs manual multisig review. Check back in a few minutes.</string>
<string name="bridge_execution_submitting_label">Submitting transfer…</string>
<string name="bridge_execution_waiting_destination_label">Waiting for confirmation on %s…</string>
<string name="wallet_asset_buy_sell">Buy/Sell</string>
@@ -35,6 +35,7 @@ import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.di
import io.novafoundation.nova.feature_assets.presentation.tokens.manage.di.ManageTokensComponent
import io.novafoundation.nova.feature_assets.presentation.topup.TopUpAddressCommunicator
import io.novafoundation.nova.feature_assets.presentation.bridge.di.BridgeComponent
import io.novafoundation.nova.feature_assets.presentation.bridge.execution.di.BridgeExecutionComponent
import io.novafoundation.nova.feature_assets.presentation.citizenship.di.CitizenshipComponent
import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.asset.di.AssetSellFlowComponent
import io.novafoundation.nova.feature_assets.presentation.trade.sell.flow.network.di.NetworkSellFlowComponent
@@ -115,6 +116,8 @@ interface AssetsFeatureComponent : AssetsFeatureApi {
fun bridgeComponentFactory(): BridgeComponent.Factory
fun bridgeExecutionComponentFactory(): BridgeExecutionComponent.Factory
fun giftsFlowComponent(): AssetGiftsFlowComponent.Factory
fun tradeProviderListComponent(): TradeProviderListComponent.Factory
@@ -14,7 +14,15 @@ package io.novafoundation.nova.feature_assets.domain.bridge.multisig
object BridgeMultisigConstants {
const val WUSDT_ASSET_ID = 1000
const val POLKADOT_USDT_ASSET_ID = 1
/** The real on-chain Assets pallet id for USDT on Polkadot Asset Hub - NOT the wallet's own
* internal chains.json ordinal (also confusingly "1" there; the real id lives under that
* entry's `typeExtras.assetId`). This constant is used for a raw runtime storage query
* (getPolkadotUsdtReserve), which bypasses the wallet's Chain.Asset abstraction entirely, so
* it needs the real protocol id. Querying "1" always returned an empty/zero balance - the
* multisig has never held anything at that id - which made the wUSDT->USDT reserve check
* report 0 permanently regardless of the multisig's real (and growing) USDT holdings. */
const val POLKADOT_USDT_ASSET_ID = 1984
const val MULTISIG_ADDRESS = "5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj"
@@ -1,6 +1,7 @@
package io.novafoundation.nova.feature_assets.presentation
import android.os.Bundle
import io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionPayload
import io.novafoundation.nova.feature_assets.presentation.flow.network.NetworkFlowPayload
import io.novafoundation.nova.feature_assets.presentation.model.OperationParcelizeModel
import io.novafoundation.nova.feature_assets.presentation.send.TransferDraft
@@ -27,6 +28,8 @@ interface AssetsRouter {
fun openConfirmTransfer(transferDraft: TransferDraft)
fun openBridgeExecution(payload: BridgeExecutionPayload)
fun openTransferDetail(transaction: OperationParcelizeModel.Transfer)
fun openExtrinsicDetail(extrinsic: OperationParcelizeModel.Extrinsic)
@@ -71,6 +71,7 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
super.onResume()
viewModel.refreshBridgeStatus()
viewModel.resetAmount()
}
override fun inject() {
@@ -156,19 +157,6 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
viewModel.fillAmountEvent.observeEvent { amount ->
binder.bridgeFromCard.amountInput.setText(amount)
}
viewModel.depositWaitLabelVisible.observe { visible ->
binder.bridgeDepositWaitLabel.visibility = if (visible) View.VISIBLE else View.GONE
}
viewModel.depositWaitState.observe { state ->
if (state == null) {
binder.bridgeExecutionTimer.visibility = View.GONE
} else {
binder.bridgeExecutionTimer.visibility = View.VISIBLE
binder.bridgeExecutionTimer.setState(state)
}
}
}
}
@@ -2,41 +2,27 @@ package io.novafoundation.nova.feature_assets.presentation.bridge
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.utils.Event
import io.novafoundation.nova.common.utils.images.Icon
import io.novafoundation.nova.common.view.ButtonState
import io.novafoundation.nova.common.view.ExecutionTimerView
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency
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.bridge.multisig.BridgeMultisigInteractor
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeSignerState
import io.novafoundation.nova.feature_assets.domain.send.SendInteractor
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.buildAssetTransfer
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionPayload
import io.novafoundation.nova.runtime.ext.ChainGeneses
import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
import java.math.RoundingMode
import kotlin.time.Duration.Companion.seconds
/**
* DOT<->HEZ used to be a second pair here, retired 2026-07 in favor of the multisig-custodied
@@ -45,6 +31,13 @@ import kotlin.time.Duration.Companion.seconds
* (see /home/myhez/res/validators-tiki.md). Kept the BridgePair/pairOptions/picker structure
* (rather than collapsing to a single hardcoded pair) since it costs nothing and is exactly the
* seam a future new pair would reuse.
*
* This screen is deliberately input-only: it has no submit/execution logic at all, the same way
* the app's own Swap flow keeps amount entry and execution as separate screens/ViewModels. See
* BridgeExecutionViewModel for the actual submit + destination-wait, reached via swapClicked()
* below. Amount-vs-balance/reserve validation here is a live *display* concern (the error text
* under the input, the button's enabled look) - swapClicked() re-checks the same conditions for
* real right before navigating, which is the actual confirmation gate.
*/
class BridgeViewModel(
private val router: AssetsRouter,
@@ -53,9 +46,6 @@ class BridgeViewModel(
private val assetIconProvider: AssetIconProvider,
private val walletInteractor: WalletInteractor,
private val bridgeMultisigInteractor: BridgeMultisigInteractor,
private val sendInteractor: SendInteractor,
private val sendUseCase: SendUseCase,
private val selectedAccountUseCase: SelectedAccountUseCase
) : BaseViewModel() {
companion object {
@@ -82,14 +72,6 @@ class BridgeViewModel(
// USDT has 6 decimals on both Polkadot and Pezkuwi Asset Hub.
val USDT_DECIMALS_DIVISOR: BigDecimal = BigDecimal.TEN.pow(6)
/** 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
/** Cosmetic only - the real completion signal is the awaited dispatch result, which can
* arrive before or after this visually elapses (same as the swap screen's own timer). */
val SUBMIT_WAIT_TIMEOUT = 30.seconds
}
private val _pair = MutableLiveData(BridgePair.USDT)
@@ -131,16 +113,6 @@ class BridgeViewModel(
private val _signButtonLabel = MutableLiveData("")
val signButtonLabel: LiveData<String> = _signButtonLabel
/** Null = hidden. Driven by an actual destination-balance observation after Swap is tapped -
* never just a cosmetic countdown that reports "done" regardless of whether funds arrived. */
private val _depositWaitState = MutableLiveData<ExecutionTimerView.State?>(null)
val depositWaitState: LiveData<ExecutionTimerView.State?> = _depositWaitState
private val _depositWaitLabelVisible = MutableLiveData(false)
val depositWaitLabelVisible: LiveData<Boolean> = _depositWaitLabelVisible
private var depositWaitJob: Job? = null
private val _fromCard = MutableLiveData<BridgeAssetCardUi>()
val fromCard: LiveData<BridgeAssetCardUi> = _fromCard
@@ -221,10 +193,38 @@ class BridgeViewModel(
_fillAmountEvent.value = Event(availableBalance.stripTrailingZeros().toPlainString())
}
/** Called every time this screen becomes visible again, including returning from a completed
* execution - always starts the next operation from a clean slate rather than leaving the
* just-spent amount sitting in the input (the entire class of bug that used to require a
* hand-timed field-clear right after submit no longer exists once input and execution are
* different screens: there is no "just submitted" moment on this screen anymore). */
fun resetAmount() {
currentAmount = 0.0
_fillAmountEvent.value = Event("")
calculateOutput()
updateInsufficientBalanceState()
updateWarningState()
}
/** The actual confirmation gate: re-checks amount against the latest known balance/reserve
* right before committing to a real on-chain transfer, rather than trusting the Swap
* button's enabled look alone (a UI affordance, not a domain guarantee - see
* BridgeExecutionViewModel's doc for why the previous single-screen design needed a
* best-effort in-flight flag here instead of a real gate). */
fun swapClicked() {
val dir = _direction.value ?: return
if (currentAmount <= 0) return
val requested = BigDecimal.valueOf(currentAmount)
if (requested > availableBalance) {
updateInsufficientBalanceState()
return
}
if (dir == BridgeDirection.WUSDT_TO_USDT && requested > polkadotUsdtReserve) {
updateWarningState()
return
}
val chainId = when (dir) {
BridgeDirection.USDT_TO_WUSDT -> POLKADOT_ASSET_HUB_ID
BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_ASSET_HUB_ID
@@ -245,142 +245,23 @@ class BridgeViewModel(
BridgeDirection.WUSDT_TO_USDT -> POLKADOT_USDT_ASSET_ID
}
val expectedAmount = currentAmount
val amount = currentAmount
launch {
val chain = chainRegistry.getChain(chainId)
val chainAsset = chain.assetsById.getValue(assetId)
val accountId = BRIDGE_ADDRESS_GENERIC.toAccountId()
val bridgeAddress = chain.addressOf(accountId)
submitBridgeTransfer(chain, chainAsset, bridgeAddress, expectedAmount, destChainId, destAssetId)
}
}
/** Submits the bridge deposit as a plain on-chain transfer and ACTUALLY AWAITS the real
* dispatch result - replacing the previous router.openSend(...) hand-off to the generic Send
* flow, whose confirm screen only submits (fire-and-forget) and pops back to this screen
* immediately, regardless of whether the transaction actually succeeded. A real user hit
* exactly that: "tapped confirm, the screen just closed like it succeeded" while the funds
* never arrived. This mirrors the same real submit-and-await pattern the app's own Swap
* execution screen already uses (SendUseCase.performOnChainTransferAndAwaitExecution, backed
* by ExtrinsicService.submitExtrinsicAndAwaitExecution - a genuine on-chain dispatch result,
* not a UI guess) instead of the shallow SendInteractor.performTransfer path.
*
* Two honest phases, each backed by a real signal:
* 1. Submit + await the ORIGIN chain transfer's own dispatch result (seconds, via the real
* extrinsic execution API) - Success/Error here reflect what actually happened on-chain.
* 2. Only once that's confirmed, wait for the DESTINATION balance to actually increase (the
* bridge's own off-chain relay step, which has no callback this wallet can observe
* directly - balance-delta is the most honest signal available for that specific step).
*/
private suspend fun submitBridgeTransfer(
chain: Chain,
chainAsset: Chain.Asset,
bridgeAddress: String,
amount: Double,
destChainId: String,
destAssetId: Int
) {
_depositWaitLabelVisible.postValue(true)
_depositWaitState.postValue(ExecutionTimerView.State.CountdownTimer(SUBMIT_WAIT_TIMEOUT))
val chainWithAsset = ChainWithAsset(chain, chainAsset)
val bigDecimalAmount = BigDecimal.valueOf(amount)
val submissionResult = runCatching {
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
val assetTransfer = buildAssetTransfer(
metaAccount = metaAccount,
feePaymentCurrency = FeePaymentCurrency.Native,
origin = chainWithAsset,
destination = chainWithAsset, // plain on-chain transfer, not a cross-chain route
amount = bigDecimalAmount,
transferringMaxAmount = false,
address = bridgeAddress,
router.openBridgeExecution(
BridgeExecutionPayload(
originChainId = chainId,
originAssetId = assetId,
bridgeAddress = bridgeAddress,
amount = amount,
destChainId = destChainId,
destAssetId = destAssetId,
)
)
val fee = sendInteractor.getFee(assetTransfer, viewModelScope)
val weightedTransfer = WeightedAssetTransfer(
sender = metaAccount,
recipient = bridgeAddress,
originChain = chain,
destinationChain = chain,
destinationChainAsset = chainAsset,
originChainAsset = chainAsset,
amount = bigDecimalAmount,
feePaymentCurrency = FeePaymentCurrency.Native,
fee = fee.originFee,
transferringMaxAmount = false,
)
sendUseCase.performOnChainTransferAndAwaitExecution(weightedTransfer, fee.originFee.submissionFee, viewModelScope)
.getOrThrow()
}
submissionResult.fold(
onSuccess = {
// The origin transfer already happened - clear the stale entered amount so the
// live balance-vs-amount check (updateInsufficientBalanceState/updateButtonState)
// doesn't flag the now-lower post-transfer balance against an amount that was
// already spent. Without this, a real successful transfer and a false "insufficient
// balance" error appear side by side, which reads as if something failed twice.
_fillAmountEvent.postValue(Event("0"))
// Real on-chain dispatch confirmed on the origin chain - now (and only now) wait
// for the bridge to actually credit the destination.
observeDepositCompletion(destChainId, destAssetId, amount)
},
onFailure = { e ->
_depositWaitState.postValue(ExecutionTimerView.State.Error)
_depositWaitLabelVisible.postValue(false)
_showWarning.postValue(true)
_warningBlocked.postValue(true)
_warningText.postValue(e.message ?: resourceManager.getString(R.string.bridge_deposit_pending_message))
}
)
}
/** Watches the DESTINATION balance for a real increase after a swap is submitted, rather than
* a cosmetic timer that reports "done" regardless of whether funds actually arrived - there is
* no completion callback from the generic Send flow this screen delegates to (it just pops
* back to the previous screen on success), so balance-delta is the only honest signal
* available. Bounded to a reasonable wait; if it elapses without a confirmed increase, this
* says so plainly rather than implying success or failure it can't actually confirm - the
* swap may just need manual 3-of-5 review, which can take longer.
*/
private fun observeDepositCompletion(destChainId: String, destAssetId: Int, expectedAmount: Double) {
depositWaitJob?.cancel()
depositWaitJob = launch {
val balanceBefore = try {
walletInteractor.assetFlow(destChainId, destAssetId).first().transferable
} catch (e: Exception) {
return@launch // Can't observe reliably - don't show a misleading progress state.
}
_depositWaitLabelVisible.postValue(true)
_depositWaitState.postValue(ExecutionTimerView.State.CountdownTimer(DEPOSIT_WAIT_TIMEOUT))
val minExpectedIncrease = BigDecimal.valueOf(expectedAmount * (1 - FEE_PERCENT * 2)) // fee + rounding slack
val confirmed = withTimeoutOrNull(DEPOSIT_WAIT_TIMEOUT.inWholeMilliseconds) {
walletInteractor.assetFlow(destChainId, destAssetId)
.first { it.transferable - balanceBefore >= minExpectedIncrease }
} != null
if (confirmed) {
_depositWaitState.postValue(ExecutionTimerView.State.Success)
} else {
// Not a failure - just not confirmed within the wait window. Hide the timer and
// say so plainly instead of showing a false success or a false error icon.
_depositWaitState.postValue(null)
_depositWaitLabelVisible.postValue(false)
_warningBlocked.postValue(false)
_showWarning.postValue(true)
_warningText.postValue(resourceManager.getString(R.string.bridge_deposit_pending_message))
}
}
}
@@ -15,10 +15,8 @@ import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAcco
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigInteractor
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.RealBridgeMultisigInteractor
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.BridgeViewModel
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
@@ -47,9 +45,6 @@ class BridgeModule {
assetIconProvider: AssetIconProvider,
walletInteractor: WalletInteractor,
bridgeMultisigInteractor: BridgeMultisigInteractor,
sendInteractor: SendInteractor,
sendUseCase: SendUseCase,
selectedAccountUseCase: SelectedAccountUseCase
): ViewModel {
return BridgeViewModel(
router,
@@ -58,9 +53,6 @@ class BridgeModule {
assetIconProvider,
walletInteractor,
bridgeMultisigInteractor,
sendInteractor,
sendUseCase,
selectedAccountUseCase
)
}
@@ -0,0 +1,84 @@
package io.novafoundation.nova.feature_assets.presentation.bridge.execution
import android.os.Bundle
import android.view.View
import io.novafoundation.nova.common.base.BaseFragment
import io.novafoundation.nova.common.di.FeatureUtils
import io.novafoundation.nova.common.view.AlertView
import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.databinding.FragmentBridgeExecutionBinding
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent
private const val KEY_PAYLOAD = "KEY_PAYLOAD"
class BridgeExecutionFragment : BaseFragment<BridgeExecutionViewModel, FragmentBridgeExecutionBinding>() {
companion object {
fun getBundle(payload: BridgeExecutionPayload) = Bundle().apply {
putParcelable(KEY_PAYLOAD, payload)
}
}
override fun createBinding() = FragmentBridgeExecutionBinding.inflate(layoutInflater)
override fun initViews() {
binder.bridgeExecutionToolbar.setHomeButtonListener { viewModel.doneClicked() }
binder.bridgeExecutionDoneButton.setOnClickListener { viewModel.doneClicked() }
}
override fun inject() {
val payload = argument<BridgeExecutionPayload>(KEY_PAYLOAD)
FeatureUtils.getFeature<AssetsFeatureComponent>(
requireContext(),
AssetsFeatureApi::class.java
)
.bridgeExecutionComponentFactory()
.create(this, payload)
.inject(this)
}
override fun subscribe(viewModel: BridgeExecutionViewModel) {
viewModel.label.observe { text ->
binder.bridgeExecutionLabel.text = text
}
viewModel.timerState.observe { state ->
if (state == null) {
binder.bridgeExecutionTimer.visibility = View.GONE
} else {
binder.bridgeExecutionTimer.visibility = View.VISIBLE
binder.bridgeExecutionTimer.setState(state)
}
}
viewModel.doneButtonVisible.observe { visible ->
binder.bridgeExecutionDoneButton.visibility = if (visible) View.VISIBLE else View.GONE
}
// 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.
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)
}
BridgeExecutionState.DestinationPendingReview -> {
binder.bridgeExecutionPendingReviewAlert.visibility = View.VISIBLE
binder.bridgeExecutionPendingReviewAlert.setStylePreset(AlertView.StylePreset.WARNING)
binder.bridgeExecutionPendingReviewAlert.setMessage(getString(R.string.bridge_deposit_pending_message))
}
else -> {
binder.bridgeExecutionPendingReviewAlert.visibility = View.GONE
}
}
}
}
}
@@ -0,0 +1,20 @@
package io.novafoundation.nova.feature_assets.presentation.bridge.execution
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
/**
* Everything BridgeExecutionViewModel needs to submit the origin transfer and watch for the
* destination credit - deliberately just ids/amounts (not display strings), matching this
* codebase's AssetPayload convention: the execution screen re-derives chain/asset display data
* itself from ChainRegistry rather than trusting stale strings carried across navigation.
*/
@Parcelize
class BridgeExecutionPayload(
val originChainId: String,
val originAssetId: Int,
val bridgeAddress: String,
val amount: Double,
val destChainId: String,
val destAssetId: Int,
) : Parcelable
@@ -0,0 +1,33 @@
package io.novafoundation.nova.feature_assets.presentation.bridge.execution
/**
* Every outcome the bridge execution screen can actually be in - the single source of truth the
* UI renders from, instead of several independent booleans/LiveData (isExecuting, showWarning,
* warningBlocked, depositWaitLabelVisible, ...) that each had to be kept in sync by hand on every
* code path. A state that isn't listed here isn't a state this screen can reach.
*/
sealed class BridgeExecutionState {
/** Submitting the real on-chain transfer on the origin chain and awaiting its own dispatch
* result - nothing has left the wallet's control yet as far as this screen can tell. */
object SubmittingOrigin : BridgeExecutionState()
/** The origin dispatch itself failed or was rejected (e.g. fee changed, node rejected it) -
* no funds moved. Safe to retry from scratch. */
data class OriginFailed(val message: String) : BridgeExecutionState()
/** Origin transfer confirmed on-chain - funds have left the wallet. Actively watching the
* destination balance for a real increase; the bridge's off-chain relay step has no
* callback this wallet can observe directly, so balance-delta is the most honest signal
* available. */
object WaitingForDestination : BridgeExecutionState()
/** Destination balance increase actually observed within the wait window. */
object DestinationConfirmed : BridgeExecutionState()
/** Not confirmed within the wait window - NOT a failure (the origin transfer already
* succeeded and is not reversible), just not observably complete yet. Could mean auto-pay
* is still in flight, or the amount exceeded the bridge's auto-pay bounds/reserve and is
* queued for manual 3-of-5 review, which can take longer than this screen waits. */
object DestinationPendingReview : BridgeExecutionState()
}
@@ -0,0 +1,173 @@
package io.novafoundation.nova.feature_assets.presentation.bridge.execution
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import io.novafoundation.nova.common.base.BaseViewModel
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.common.view.ExecutionTimerView
import io.novafoundation.nova.feature_account_api.data.fee.FeePaymentCurrency
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_assets.R
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.send.SendInteractor
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.WeightedAssetTransfer
import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.buildAssetTransfer
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.ChainWithAsset
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
import java.math.BigDecimal
import kotlin.time.Duration.Companion.seconds
/**
* Dedicated execution screen for a bridge transfer - deliberately has no amount input and no live
* balance-vs-amount validation, structurally the same way SwapExecutionViewModel has none: that
* code simply doesn't exist here, so it can't race against a live balance update the way it did
* when submit+wait lived on the same screen/ViewModel as the amount field (see
* BridgeViewModel.swapClicked - the confirmation gate - for where amount/reserve validation now
* happens instead, once, right before navigating here).
*/
class BridgeExecutionViewModel(
private val payload: BridgeExecutionPayload,
private val resourceManager: ResourceManager,
private val chainRegistry: ChainRegistry,
private val walletInteractor: WalletInteractor,
private val sendInteractor: SendInteractor,
private val sendUseCase: SendUseCase,
private val selectedAccountUseCase: SelectedAccountUseCase,
private val router: AssetsRouter,
) : BaseViewModel() {
companion object {
/** Cosmetic only - the real completion signal is the awaited dispatch result, which can
* arrive before or after this visually elapses (same as the swap screen's own timer). */
val SUBMIT_WAIT_TIMEOUT = 30.seconds
/** 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)
val state: LiveData<BridgeExecutionState> = _state
private val _label = MutableLiveData<String>()
val label: LiveData<String> = _label
private val _timerState = MutableLiveData<ExecutionTimerView.State?>(null)
val timerState: LiveData<ExecutionTimerView.State?> = _timerState
private val _doneButtonVisible = MutableLiveData(false)
val doneButtonVisible: LiveData<Boolean> = _doneButtonVisible
init {
launch {
submit()
}
}
fun doneClicked() {
router.back()
}
private suspend fun submit() {
_state.postValue(BridgeExecutionState.SubmittingOrigin)
_label.postValue(resourceManager.getString(R.string.bridge_execution_submitting_label))
_timerState.postValue(ExecutionTimerView.State.CountdownTimer(SUBMIT_WAIT_TIMEOUT))
val chain = chainRegistry.getChain(payload.originChainId)
val chainAsset = chain.assetsById.getValue(payload.originAssetId)
val chainWithAsset = ChainWithAsset(chain, chainAsset)
val bigDecimalAmount = BigDecimal.valueOf(payload.amount)
val submissionResult = runCatching {
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
val assetTransfer = buildAssetTransfer(
metaAccount = metaAccount,
feePaymentCurrency = FeePaymentCurrency.Native,
origin = chainWithAsset,
destination = chainWithAsset, // plain on-chain transfer, not a cross-chain route
amount = bigDecimalAmount,
transferringMaxAmount = false,
address = payload.bridgeAddress,
)
val fee = sendInteractor.getFee(assetTransfer, viewModelScope)
val weightedTransfer = WeightedAssetTransfer(
sender = metaAccount,
recipient = payload.bridgeAddress,
originChain = chain,
destinationChain = chain,
destinationChainAsset = chainAsset,
originChainAsset = chainAsset,
amount = bigDecimalAmount,
feePaymentCurrency = FeePaymentCurrency.Native,
fee = fee.originFee,
transferringMaxAmount = false,
)
sendUseCase.performOnChainTransferAndAwaitExecution(weightedTransfer, fee.originFee.submissionFee, viewModelScope)
.getOrThrow()
}
submissionResult.fold(
onSuccess = { observeDepositCompletion() },
onFailure = { e ->
_state.postValue(BridgeExecutionState.OriginFailed(e.message ?: resourceManager.getString(R.string.bridge_deposit_pending_message)))
_timerState.postValue(ExecutionTimerView.State.Error)
_doneButtonVisible.postValue(true)
}
)
}
/** Watches the DESTINATION balance for a real increase after the origin transfer already
* confirmed, rather than a cosmetic timer that reports "done" regardless of whether funds
* actually arrived - there is no completion callback for the bridge's own off-chain relay
* step, so balance-delta is the only honest signal available. Bounded to a reasonable wait;
* if it elapses without a confirmed increase, this says so plainly rather than implying
* success or failure it can't actually confirm - the swap may just need manual 3-of-5
* review, which can take longer. */
private suspend fun observeDepositCompletion() {
val balanceBefore = try {
walletInteractor.assetFlow(payload.destChainId, payload.destAssetId).first().transferable
} catch (e: Exception) {
// Can't observe reliably - don't show a misleading progress state.
_state.postValue(BridgeExecutionState.DestinationPendingReview)
_timerState.postValue(null)
_doneButtonVisible.postValue(true)
return
}
val destChainName = chainRegistry.getChain(payload.destChainId).name
_state.postValue(BridgeExecutionState.WaitingForDestination)
_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 confirmed = withTimeoutOrNull(DEPOSIT_WAIT_TIMEOUT.inWholeMilliseconds) {
walletInteractor.assetFlow(payload.destChainId, payload.destAssetId)
.first { it.transferable - balanceBefore >= minExpectedIncrease }
} != null
if (confirmed) {
_state.postValue(BridgeExecutionState.DestinationConfirmed)
_label.postValue(resourceManager.getString(R.string.bridge_deposit_success_label))
_timerState.postValue(ExecutionTimerView.State.Success)
} else {
// Not a failure - just not confirmed within the wait window.
_state.postValue(BridgeExecutionState.DestinationPendingReview)
_timerState.postValue(null)
}
_doneButtonVisible.postValue(true)
}
}
@@ -0,0 +1,28 @@
package io.novafoundation.nova.feature_assets.presentation.bridge.execution.di
import androidx.fragment.app.Fragment
import dagger.BindsInstance
import dagger.Subcomponent
import io.novafoundation.nova.common.di.scope.ScreenScope
import io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionFragment
import io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionPayload
@Subcomponent(
modules = [
BridgeExecutionModule::class
]
)
@ScreenScope
interface BridgeExecutionComponent {
@Subcomponent.Factory
interface Factory {
fun create(
@BindsInstance fragment: Fragment,
@BindsInstance payload: BridgeExecutionPayload
): BridgeExecutionComponent
}
fun inject(fragment: BridgeExecutionFragment)
}
@@ -0,0 +1,56 @@
package io.novafoundation.nova.feature_assets.presentation.bridge.execution.di
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import dagger.Module
import dagger.Provides
import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
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.execution.BridgeExecutionPayload
import io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionViewModel
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@Module(includes = [ViewModelModule::class])
class BridgeExecutionModule {
@Provides
@IntoMap
@ViewModelKey(BridgeExecutionViewModel::class)
fun provideViewModel(
payload: BridgeExecutionPayload,
resourceManager: ResourceManager,
chainRegistry: ChainRegistry,
walletInteractor: WalletInteractor,
sendInteractor: SendInteractor,
sendUseCase: SendUseCase,
selectedAccountUseCase: SelectedAccountUseCase,
router: AssetsRouter,
): ViewModel {
return BridgeExecutionViewModel(
payload,
resourceManager,
chainRegistry,
walletInteractor,
sendInteractor,
sendUseCase,
selectedAccountUseCase,
router
)
}
@Provides
fun provideViewModelCreator(
fragment: Fragment,
viewModelFactory: ViewModelProvider.Factory
): BridgeExecutionViewModel {
return ViewModelProvider(fragment, viewModelFactory).get(BridgeExecutionViewModel::class.java)
}
}
@@ -190,28 +190,6 @@
android:text="@string/bridge_sign_button"
android:visibility="gone" />
<!-- Deposit-wait progress: shown after tapping Swap, while we actively watch the
destination balance for a real confirmed increase - not a cosmetic timer that
completes regardless of whether funds actually arrived. Same reusable component
already used for swap execution (common.view.ExecutionTimerView). -->
<TextView
android:id="@+id/bridgeDepositWaitLabel"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/bridge_deposit_wait_label"
android:textColor="@color/text_secondary"
android:visibility="gone" />
<io.novafoundation.nova.common.view.ExecutionTimerView
android:id="@+id/bridgeExecutionTimer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:visibility="gone" />
</LinearLayout>
</ScrollView>
@@ -0,0 +1,60 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/secondary_screen_background">
<io.novafoundation.nova.common.view.Toolbar
android:id="@+id/bridgeExecutionToolbar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toTopOf="parent"
app:titleText="@string/bridge_title" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:gravity="center"
android:orientation="vertical"
android:padding="24dp"
app:layout_constraintBottom_toTopOf="@id/bridgeExecutionDoneButton"
app:layout_constraintTop_toBottomOf="@id/bridgeExecutionToolbar">
<TextView
android:id="@+id/bridgeExecutionLabel"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/text_secondary"
tools:text="Waiting for confirmation on Pezkuwi Asset Hub…" />
<io.novafoundation.nova.common.view.ExecutionTimerView
android:id="@+id/bridgeExecutionTimer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp" />
<io.novafoundation.nova.common.view.AlertView
android:id="@+id/bridgeExecutionPendingReviewAlert"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:visibility="gone"
app:alertMode="warning" />
</LinearLayout>
<io.novafoundation.nova.common.view.PrimaryButton
android:id="@+id/bridgeExecutionDoneButton"
style="@style/Widget.Nova.Button.Primary"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:text="@string/common_done"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>