feat: block Bridge swap when send amount exceeds available balance

The Bridge screen let a user type any amount and enabled the Swap
button regardless of what they actually held, unlike the Swap screen
which shows a live "Max: X" and disables on overflow. The confirm-step
ValidationSystem would still catch it before signing, but only after
routing through Send - no immediate feedback on the Bridge screen
itself.

Wires WalletInteractor.assetFlow(chainId, assetId) to the currently
selected origin side (re-subscribed on every direction/pair change),
tracking Asset.transferable - the same field the real transfer
validation checks. Reuses the existing MaxAmountView/MaxAvailableView
widget the Swap screen already uses for the "Max: X" display, and adds
a matching error-bordered state to BridgeAssetInputView. The
"Insufficient balance" string (bridge_insufficient_balance) was
already translated into every locale but never wired to any code.
This commit is contained in:
2026-07-13 12:10:16 -07:00
parent e0cd1f5def
commit 7401783436
6 changed files with 111 additions and 9 deletions
@@ -10,6 +10,7 @@ import io.novafoundation.nova.common.view.setState
import io.novafoundation.nova.feature_assets.databinding.FragmentBridgeBinding
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxActionAvailability
class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
@@ -49,6 +50,10 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
}
})
binder.bridgeFromMaxAmount.setMaxActionAvailability(
MaxActionAvailability.Available { viewModel.maxClicked() }
)
// Swap button
binder.bridgeSwapButton.setOnClickListener {
viewModel.swapClicked()
@@ -105,6 +110,18 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
binder.bridgeWarningAlert.setMessage(text)
}
}
viewModel.maxAmountDisplay.observe { display ->
binder.bridgeFromMaxAmount.setMaxAmountDisplay(display)
}
viewModel.insufficientBalanceError.observe { error ->
binder.bridgeFromCard.setError(error)
}
viewModel.fillAmountEvent.observeEvent { amount ->
binder.bridgeFromCard.amountInput.setText(amount)
}
}
}
@@ -5,10 +5,12 @@ import androidx.lifecycle.MutableLiveData
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.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.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
@@ -18,6 +20,7 @@ import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
@@ -29,7 +32,8 @@ class BridgeViewModel(
private val router: AssetsRouter,
private val resourceManager: ResourceManager,
private val chainRegistry: ChainRegistry,
private val assetIconProvider: AssetIconProvider
private val assetIconProvider: AssetIconProvider,
private val walletInteractor: WalletInteractor
) : BaseViewModel() {
companion object {
@@ -90,10 +94,21 @@ class BridgeViewModel(
private val _pairOptions = MutableLiveData<List<BridgePairUi>>(emptyList())
val pairOptions: LiveData<List<BridgePairUi>> = _pairOptions
private val _maxAmountDisplay = MutableLiveData<String?>(null)
val maxAmountDisplay: LiveData<String?> = _maxAmountDisplay
private val _insufficientBalanceError = MutableLiveData<String?>(null)
val insufficientBalanceError: LiveData<String?> = _insufficientBalanceError
private val _fillAmountEvent = MutableLiveData<Event<String>>()
val fillAmountEvent: LiveData<Event<String>> = _fillAmountEvent
private var currentAmount: Double = 0.0
private var dotToHezRate: Double = FALLBACK_RATE
private var isHezToDotActive: Boolean = false
private var isWusdtToUsdtActive: Boolean = false
private var availableBalance: BigDecimal = BigDecimal.ZERO
private var balanceJob: Job? = null
init {
fetchExchangeRate()
@@ -150,9 +165,14 @@ class BridgeViewModel(
fun setAmount(amount: Double) {
currentAmount = amount
calculateOutput()
updateInsufficientBalanceState()
updateButtonState()
}
fun maxClicked() {
_fillAmountEvent.value = Event(availableBalance.stripTrailingZeros().toPlainString())
}
fun swapClicked() {
val dir = _direction.value ?: return
if (currentAmount <= 0) return
@@ -332,12 +352,21 @@ class BridgeViewModel(
_buttonState.value = when {
currentAmount <= 0 -> ButtonState.DISABLED
currentAmount < minimum -> ButtonState.DISABLED
BigDecimal.valueOf(currentAmount) > availableBalance -> ButtonState.DISABLED
dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED
dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED
else -> ButtonState.NORMAL
}
}
private fun updateInsufficientBalanceState() {
_insufficientBalanceError.value = if (currentAmount > 0 && BigDecimal.valueOf(currentAmount) > availableBalance) {
resourceManager.getString(R.string.bridge_insufficient_balance)
} else {
null
}
}
fun refreshBridgeStatus() {
fetchBridgeStatus()
}
@@ -370,6 +399,22 @@ class BridgeViewModel(
_fromCard.value = cardUiFor(originChainId, originAssetId)
_toCard.value = cardUiFor(destChainId, destAssetId)
}
observeOriginBalance(originChainId, originAssetId)
}
private fun observeOriginBalance(chainId: String, assetId: Int) {
balanceJob?.cancel()
balanceJob = launch {
walletInteractor.assetFlow(chainId, assetId).collect { asset ->
availableBalance = asset.transferable
_maxAmountDisplay.postValue(
"${availableBalance.setScale(6, RoundingMode.DOWN).stripTrailingZeros().toPlainString()} ${asset.token.configuration.symbol.value}"
)
updateInsufficientBalanceState()
updateButtonState()
}
}
}
private fun loadPairOptions() {
@@ -10,6 +10,7 @@ import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
import io.novafoundation.nova.common.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager
import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeViewModel
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@@ -24,9 +25,10 @@ class BridgeModule {
router: AssetsRouter,
resourceManager: ResourceManager,
chainRegistry: ChainRegistry,
assetIconProvider: AssetIconProvider
assetIconProvider: AssetIconProvider,
walletInteractor: WalletInteractor
): ViewModel {
return BridgeViewModel(router, resourceManager, chainRegistry, assetIconProvider)
return BridgeViewModel(router, resourceManager, chainRegistry, assetIconProvider, walletInteractor)
}
@Provides
@@ -11,7 +11,9 @@ import io.novafoundation.nova.common.utils.WithContextExtensions
import io.novafoundation.nova.common.utils.images.asUrlIcon
import io.novafoundation.nova.common.utils.images.setIconOrMakeGone
import io.novafoundation.nova.common.utils.inflater
import io.novafoundation.nova.common.utils.setVisible
import io.novafoundation.nova.common.view.shape.getInputBackground
import io.novafoundation.nova.common.view.shape.getInputBackgroundError
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_assets.databinding.ViewBridgeAssetInputBinding
import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeAssetCardUi
@@ -60,4 +62,15 @@ class BridgeAssetInputView @JvmOverloads constructor(
binder.bridgeAssetInputSubtitle.text = model.chainName
binder.bridgeAssetInputSubtitleImage.setIconOrMakeGone(model.chainIconUrl?.asUrlIcon(), imageLoader)
}
fun setError(message: String?) {
binder.bridgeAssetInputContainer.background = if (message != null) {
context.getInputBackgroundError()
} else {
context.getInputBackground()
}
binder.bridgeAssetInputError.text = message
binder.bridgeAssetInputError.setVisible(message != null)
}
}
@@ -25,13 +25,27 @@
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/bridgeFromLabel"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="wrap_content"
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/bridge_you_send"
android:textColor="@color/text_secondary" />
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:id="@+id/bridgeFromLabel"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/bridge_you_send"
android:textColor="@color/text_secondary" />
<io.novafoundation.nova.feature_wallet_api.presentation.view.amount.MaxAmountView
android:id="@+id/bridgeFromMaxAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
</LinearLayout>
<io.novafoundation.nova.feature_assets.presentation.bridge.view.BridgeAssetInputView
android:id="@+id/bridgeFromCard"
@@ -110,4 +110,15 @@
</androidx.constraintlayout.widget.ConstraintLayout>
<TextView
android:id="@+id/bridgeAssetInputError"
style="@style/TextAppearance.NovaFoundation.Regular.Caption1"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/text_negative"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/bridgeAssetInputContainer" />
</merge>