From 52e56454093bcf1d3a2e4baf5349380fef3561a1 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Wed, 15 Jul 2026 16:12:03 -0700 Subject: [PATCH] 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. --- .../root/navigation/navigators/Navigator.kt | 10 + .../res/navigation/split_screen_nav_graph.xml | 14 ++ common/src/main/res/values/strings.xml | 5 +- .../di/AssetsFeatureComponent.kt | 3 + .../multisig/BridgeMultisigConstants.kt | 10 +- .../presentation/AssetsRouter.kt | 3 + .../presentation/bridge/BridgeFragment.kt | 14 +- .../presentation/bridge/BridgeViewModel.kt | 211 ++++-------------- .../presentation/bridge/di/BridgeModule.kt | 8 - .../execution/BridgeExecutionFragment.kt | 84 +++++++ .../execution/BridgeExecutionPayload.kt | 20 ++ .../bridge/execution/BridgeExecutionState.kt | 33 +++ .../execution/BridgeExecutionViewModel.kt | 173 ++++++++++++++ .../execution/di/BridgeExecutionComponent.kt | 28 +++ .../execution/di/BridgeExecutionModule.kt | 56 +++++ .../src/main/res/layout/fragment_bridge.xml | 22 -- .../res/layout/fragment_bridge_execution.xml | 60 +++++ 17 files changed, 544 insertions(+), 210 deletions(-) create mode 100644 feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionFragment.kt create mode 100644 feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionPayload.kt create mode 100644 feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionState.kt create mode 100644 feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionViewModel.kt create mode 100644 feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/di/BridgeExecutionComponent.kt create mode 100644 feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/di/BridgeExecutionModule.kt create mode 100644 feature-assets/src/main/res/layout/fragment_bridge_execution.xml diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt index 99735fec..93aa25fe 100644 --- a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt @@ -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) diff --git a/app/src/main/res/navigation/split_screen_nav_graph.xml b/app/src/main/res/navigation/split_screen_nav_graph.xml index 7b652895..731ea0e5 100644 --- a/app/src/main/res/navigation/split_screen_nav_graph.xml +++ b/app/src/main/res/navigation/split_screen_nav_graph.xml @@ -1201,8 +1201,22 @@ app:popEnterAnim="@anim/fragment_close_enter" app:popExitAnim="@anim/fragment_close_exit" /> + + + + Signing… Signing failed, tap to retry - Waiting for confirmation on Pezkuwi Asset Hub… + Bridge complete — funds delivered Still processing - this can take longer if it needs manual multisig review. Check back in a few minutes. + Submitting transfer… + Waiting for confirmation on %s… + Buy/Sell diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt index 79ba3f09..2b7f4c82 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt @@ -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 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 81571ce4..8b269215 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 @@ -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" diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt index 51177c64..e9a8f688 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt @@ -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) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt index c961a6aa..10e9b15b 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt @@ -71,6 +71,7 @@ class BridgeFragment : BaseFragment() { super.onResume() viewModel.refreshBridgeStatus() + viewModel.resetAmount() } override fun inject() { @@ -156,19 +157,6 @@ class BridgeFragment : BaseFragment() { 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) - } - } } } 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 442a6197..a8645602 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 @@ -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 = _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(null) - val depositWaitState: LiveData = _depositWaitState - - private val _depositWaitLabelVisible = MutableLiveData(false) - val depositWaitLabelVisible: LiveData = _depositWaitLabelVisible - - private var depositWaitJob: Job? = null - private val _fromCard = MutableLiveData() val fromCard: LiveData = _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)) - } } } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt index 9ac58f2d..de719fbb 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt @@ -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 ) } 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 new file mode 100644 index 00000000..9b0d0b62 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionFragment.kt @@ -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() { + + 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(KEY_PAYLOAD) + + FeatureUtils.getFeature( + 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 + } + } + } + } +} 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 new file mode 100644 index 00000000..fbefad68 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionPayload.kt @@ -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 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 new file mode 100644 index 00000000..b030fdc1 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionState.kt @@ -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() +} 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 new file mode 100644 index 00000000..886e7771 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/BridgeExecutionViewModel.kt @@ -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.SubmittingOrigin) + val state: LiveData = _state + + private val _label = MutableLiveData() + val label: LiveData = _label + + private val _timerState = MutableLiveData(null) + val timerState: LiveData = _timerState + + private val _doneButtonVisible = MutableLiveData(false) + val doneButtonVisible: LiveData = _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) + } +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/di/BridgeExecutionComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/di/BridgeExecutionComponent.kt new file mode 100644 index 00000000..c2be8559 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/di/BridgeExecutionComponent.kt @@ -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) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/di/BridgeExecutionModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/di/BridgeExecutionModule.kt new file mode 100644 index 00000000..cdac55e0 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/execution/di/BridgeExecutionModule.kt @@ -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) + } +} diff --git a/feature-assets/src/main/res/layout/fragment_bridge.xml b/feature-assets/src/main/res/layout/fragment_bridge.xml index 2e72f57b..59feac79 100644 --- a/feature-assets/src/main/res/layout/fragment_bridge.xml +++ b/feature-assets/src/main/res/layout/fragment_bridge.xml @@ -190,28 +190,6 @@ android:text="@string/bridge_sign_button" android:visibility="gone" /> - - - - - diff --git a/feature-assets/src/main/res/layout/fragment_bridge_execution.xml b/feature-assets/src/main/res/layout/fragment_bridge_execution.xml new file mode 100644 index 00000000..fbf8ea54 --- /dev/null +++ b/feature-assets/src/main/res/layout/fragment_bridge_execution.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + +