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.databinding.FragmentBridgeBinding
import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi import io.novafoundation.nova.feature_assets.di.AssetsFeatureApi
import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent import io.novafoundation.nova.feature_assets.di.AssetsFeatureComponent
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.MaxActionAvailability
class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() { class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
@@ -49,6 +50,10 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
} }
}) })
binder.bridgeFromMaxAmount.setMaxActionAvailability(
MaxActionAvailability.Available { viewModel.maxClicked() }
)
// Swap button // Swap button
binder.bridgeSwapButton.setOnClickListener { binder.bridgeSwapButton.setOnClickListener {
viewModel.swapClicked() viewModel.swapClicked()
@@ -105,6 +110,18 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
binder.bridgeWarningAlert.setMessage(text) 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.base.BaseViewModel
import io.novafoundation.nova.common.presentation.AssetIconProvider import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager 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.utils.images.Icon
import io.novafoundation.nova.common.view.ButtonState import io.novafoundation.nova.common.view.ButtonState
import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback import io.novafoundation.nova.feature_account_api.presenatation.chain.getAssetIconOrFallback
import io.novafoundation.nova.feature_assets.R 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.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload import io.novafoundation.nova.feature_assets.presentation.send.amount.SendPayload
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload 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.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import org.json.JSONObject
@@ -29,7 +32,8 @@ class BridgeViewModel(
private val router: AssetsRouter, private val router: AssetsRouter,
private val resourceManager: ResourceManager, private val resourceManager: ResourceManager,
private val chainRegistry: ChainRegistry, private val chainRegistry: ChainRegistry,
private val assetIconProvider: AssetIconProvider private val assetIconProvider: AssetIconProvider,
private val walletInteractor: WalletInteractor
) : BaseViewModel() { ) : BaseViewModel() {
companion object { companion object {
@@ -90,10 +94,21 @@ class BridgeViewModel(
private val _pairOptions = MutableLiveData<List<BridgePairUi>>(emptyList()) private val _pairOptions = MutableLiveData<List<BridgePairUi>>(emptyList())
val pairOptions: LiveData<List<BridgePairUi>> = _pairOptions 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 currentAmount: Double = 0.0
private var dotToHezRate: Double = FALLBACK_RATE private var dotToHezRate: Double = FALLBACK_RATE
private var isHezToDotActive: Boolean = false private var isHezToDotActive: Boolean = false
private var isWusdtToUsdtActive: Boolean = false private var isWusdtToUsdtActive: Boolean = false
private var availableBalance: BigDecimal = BigDecimal.ZERO
private var balanceJob: Job? = null
init { init {
fetchExchangeRate() fetchExchangeRate()
@@ -150,9 +165,14 @@ class BridgeViewModel(
fun setAmount(amount: Double) { fun setAmount(amount: Double) {
currentAmount = amount currentAmount = amount
calculateOutput() calculateOutput()
updateInsufficientBalanceState()
updateButtonState() updateButtonState()
} }
fun maxClicked() {
_fillAmountEvent.value = Event(availableBalance.stripTrailingZeros().toPlainString())
}
fun swapClicked() { fun swapClicked() {
val dir = _direction.value ?: return val dir = _direction.value ?: return
if (currentAmount <= 0) return if (currentAmount <= 0) return
@@ -332,12 +352,21 @@ class BridgeViewModel(
_buttonState.value = when { _buttonState.value = when {
currentAmount <= 0 -> ButtonState.DISABLED currentAmount <= 0 -> ButtonState.DISABLED
currentAmount < minimum -> ButtonState.DISABLED currentAmount < minimum -> ButtonState.DISABLED
BigDecimal.valueOf(currentAmount) > availableBalance -> ButtonState.DISABLED
dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED dir == BridgeDirection.HEZ_TO_DOT && !isHezToDotActive -> ButtonState.DISABLED
dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED
else -> ButtonState.NORMAL 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() { fun refreshBridgeStatus() {
fetchBridgeStatus() fetchBridgeStatus()
} }
@@ -370,6 +399,22 @@ class BridgeViewModel(
_fromCard.value = cardUiFor(originChainId, originAssetId) _fromCard.value = cardUiFor(originChainId, originAssetId)
_toCard.value = cardUiFor(destChainId, destAssetId) _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() { 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.di.viewmodel.ViewModelModule
import io.novafoundation.nova.common.presentation.AssetIconProvider import io.novafoundation.nova.common.presentation.AssetIconProvider
import io.novafoundation.nova.common.resources.ResourceManager 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.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeViewModel import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeViewModel
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
@@ -24,9 +25,10 @@ class BridgeModule {
router: AssetsRouter, router: AssetsRouter,
resourceManager: ResourceManager, resourceManager: ResourceManager,
chainRegistry: ChainRegistry, chainRegistry: ChainRegistry,
assetIconProvider: AssetIconProvider assetIconProvider: AssetIconProvider,
walletInteractor: WalletInteractor
): ViewModel { ): ViewModel {
return BridgeViewModel(router, resourceManager, chainRegistry, assetIconProvider) return BridgeViewModel(router, resourceManager, chainRegistry, assetIconProvider, walletInteractor)
} }
@Provides @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.asUrlIcon
import io.novafoundation.nova.common.utils.images.setIconOrMakeGone import io.novafoundation.nova.common.utils.images.setIconOrMakeGone
import io.novafoundation.nova.common.utils.inflater 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.getInputBackground
import io.novafoundation.nova.common.view.shape.getInputBackgroundError
import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon import io.novafoundation.nova.feature_account_api.presenatation.chain.setTokenIcon
import io.novafoundation.nova.feature_assets.databinding.ViewBridgeAssetInputBinding import io.novafoundation.nova.feature_assets.databinding.ViewBridgeAssetInputBinding
import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeAssetCardUi import io.novafoundation.nova.feature_assets.presentation.bridge.BridgeAssetCardUi
@@ -60,4 +62,15 @@ class BridgeAssetInputView @JvmOverloads constructor(
binder.bridgeAssetInputSubtitle.text = model.chainName binder.bridgeAssetInputSubtitle.text = model.chainName
binder.bridgeAssetInputSubtitleImage.setIconOrMakeGone(model.chainIconUrl?.asUrlIcon(), imageLoader) 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:orientation="vertical"
android:padding="16dp"> android:padding="16dp">
<TextView <LinearLayout
android:id="@+id/bridgeFromLabel" android:layout_width="match_parent"
style="@style/TextAppearance.NovaFoundation.Regular.Footnote"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/bridge_you_send" android:gravity="center_vertical"
android:textColor="@color/text_secondary" /> 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 <io.novafoundation.nova.feature_assets.presentation.bridge.view.BridgeAssetInputView
android:id="@+id/bridgeFromCard" android:id="@+id/bridgeFromCard"
@@ -110,4 +110,15 @@
</androidx.constraintlayout.widget.ConstraintLayout> </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> </merge>