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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+