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.