Replace dead legacy-bot status check with a real per-amount reserve check

BridgeViewModel's wUSDT->USDT gate called http://217.77.6.126:3030/status -
the legacy single-key bridge bot's endpoint. That service was stopped this
same session (see res/validators-tiki.md), so the check always threw,
always caught, and always reported "inactive" - the red warning reflected a
dead dependency, not real reserve state.

Replaced with a direct on-chain read of the multisig's actual USDT balance
on Polkadot Asset Hub (BridgeMultisigInteractor.getPolkadotUsdtReserve, via
a new Assets.Account storage query mirroring the existing Approvals one).
The gate is now per-request: a specific withdrawal is blocked only if it
exceeds the real reserve, not on a global on/off flag requiring full 1:1
parity between total circulating supply and total reserve - matching how
the Rust listener's own reserve-shortfall check already works. Re-checked
on every amount change, not just once per screen load.

bridge_wusdt_to_usdt_blocked now takes the available reserve amount as a
parameter; updated the English and Turkish strings. Left values-ku
untouched deliberately - not fabricating Kurdish translation text without
native review.
This commit is contained in:
2026-07-14 18:57:59 -07:00
parent 6d27949cbf
commit a8dc9add24
6 changed files with 69 additions and 35 deletions
+1 -1
View File
@@ -253,7 +253,7 @@ Sizin için en iyi seçeneği bulmak amacıyla tekliflerini karşılaştırın.<
<string name="bridge_enter_amount">Tutar girin</string>
<string name="bridge_hez_to_dot_warning">HEZ → DOT yönü manuel işlenir, 24 saate kadar sürebilir.</string>
<string name="bridge_hez_to_dot_blocked">HEZ → DOT yönü şu anda devre dışı.</string>
<string name="bridge_wusdt_to_usdt_blocked">USDT(Pez) → USDT(Pol) yönü şu anda devre dışı.</string>
<string name="bridge_wusdt_to_usdt_blocked">Bu tutar köprünün mevcut rezervini (%s USDT) aşıyor. Daha küçük bir tutar deneyin.</string>
<string name="wallet_asset_buy_sell">Al/Sat</string>
<string name="account_ledger_import_start_step_otg">Telefon ayarlarınızda %s</string>
<string name="account_ledger_import_start_step_otg_highlighted">OTG\'yi etkinleştir</string>
+1 -1
View File
@@ -364,7 +364,7 @@
<string name="bridge_insufficient_balance">Insufficient balance</string>
<string name="bridge_below_minimum">Amount below minimum</string>
<string name="bridge_enter_amount">Enter amount</string>
<string name="bridge_wusdt_to_usdt_blocked">USDT(Pez)→USDT(Pol) swaps are temporarily unavailable. Waiting for 1:1 liquidity to be established.</string>
<string name="bridge_wusdt_to_usdt_blocked">This amount exceeds the bridge\'s current available reserve (%s USDT). Try a smaller amount.</string>
<string name="bridge_sign_status_ok">Bridge allowance OK (%s)</string>
<string name="bridge_sign_button">Sign renewal (%s)</string>
@@ -14,8 +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
const val MULTISIG_ADDRESS = "5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj"
/** Same multisig account, Polkadot Asset Hub SS58 encoding - where the real USDT backing
* wUSDT withdrawals actually sits. Used to check a specific withdrawal amount against the
* real reserve (see BridgeMultisigInteractor.getPolkadotUsdtReserve) instead of the old
* dead-external-service boolean check this replaced (217.77.6.126:3030/status belonged to
* the legacy bridge bot, stopped this same session - see res/validators-tiki.md). */
const val MULTISIG_ADDRESS_POLKADOT = "15sF76THfpefUaKomHZSpssayRbsp6Yt6ESgMrLjzJCmpe66"
const val AUTOMATION_KEY_ADDRESS = "5GQu4PFUb1f3MTJ7i7c1CtLgDk3TVvpSW1VbQCRmfkMoC8cM"
const val THRESHOLD = 3
@@ -38,6 +38,14 @@ interface BridgeMultisigInteractor {
suspend fun getSignerState(): BridgeSignerState?
suspend fun submitRenewalSignature(): Result<ExtrinsicExecutionResult>
/** Real USDT (base units) the multisig actually holds on Polkadot Asset Hub right now - the
* true backing for wUSDT->USDT withdrawals. Replaces the old wusdtToUsdtActive boolean
* fetched from the legacy bridge bot's :3030/status endpoint, which this session stopped
* (see res/validators-tiki.md) - that made the old check always report "inactive"
* regardless of real reserve. A specific withdrawal should be allowed whenever it's covered
* by this real balance, not gated on an unrelated dead service or on total supply parity. */
suspend fun getPolkadotUsdtReserve(): BigInteger
}
@FeatureScope
@@ -118,6 +126,18 @@ class RealBridgeMultisigInteractor @Inject constructor(
}.getOrThrow().requireOk()
}
override suspend fun getPolkadotUsdtReserve(): BigInteger {
val polkadotChain = chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB)
val multisigAccountId = BridgeMultisigConstants.MULTISIG_ADDRESS_POLKADOT.toAccountId().intoKey()
return storageDataSource.query(polkadotChain.id) {
runtime.metadata.bridgeAssets().assetBalance.query(
BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID.toBigInteger(),
multisigAccountId,
)
} ?: BigInteger.ZERO
}
private suspend fun queryRemainingAllowance(chain: Chain): BigInteger {
val multisigAccountId = BridgeMultisigConstants.MULTISIG_ADDRESS.toAccountId().intoKey()
val delegateAccountId = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS.toAccountId().intoKey()
@@ -48,6 +48,18 @@ val BridgeAssetsApi.approvalAmount: QueryableStorageEntry3<BigInteger, AccountId
key3FromInternalConverter = AccountIdKey.scaleDecoder,
)
/** `Assets.Account(asset_id, account) -> AssetAccount { balance, ... }` - only `balance` needed.
* Used to check a custody account's real held amount (e.g. the multisig's real USDT reserve on
* Polkadot Asset Hub), not the connected wallet's own balance. */
context(StorageQueryContext)
val BridgeAssetsApi.assetBalance: QueryableStorageEntry2<BigInteger, AccountIdKey, BigInteger>
get() = storage2(
name = "Account",
binding = { decoded, _, _ -> bindNumber(decoded.castToStruct()["balance"]) },
key2ToInternalConverter = AccountIdKey.scaleEncoder,
key2FromInternalConverter = AccountIdKey.scaleDecoder,
)
@JvmInline
value class BridgeMultisigApi(override val module: Module) : QueryableModule
@@ -21,14 +21,10 @@ import io.novafoundation.nova.runtime.ext.addressOf
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
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URL
/**
* DOT<->HEZ used to be a second pair here, retired 2026-07 in favor of the multisig-custodied
@@ -60,7 +56,8 @@ class BridgeViewModel(
const val FEE_PERCENT = 0.001
const val MIN_USDT = 1.0
const val BRIDGE_STATUS_API = "http://217.77.6.126:3030/status"
// USDT has 6 decimals on both Polkadot and Pezkuwi Asset Hub.
val USDT_DECIMALS_DIVISOR: BigDecimal = BigDecimal.TEN.pow(6)
}
private val _pair = MutableLiveData(BridgePair.USDT)
@@ -121,12 +118,15 @@ class BridgeViewModel(
val fillAmountEvent: LiveData<Event<String>> = _fillAmountEvent
private var currentAmount: Double = 0.0
private var isWusdtToUsdtActive: Boolean = false
/** Real USDT the multisig actually holds on Polkadot Asset Hub - see
* BridgeMultisigInteractor.getPolkadotUsdtReserve for why this replaced a dead external
* status check that always reported "inactive" regardless of the real reserve. */
private var polkadotUsdtReserve: BigDecimal = BigDecimal.ZERO
private var availableBalance: BigDecimal = BigDecimal.ZERO
private var balanceJob: Job? = null
init {
fetchBridgeStatus()
fetchReserveStatus()
refreshSignerState()
updateCards()
loadPairOptions()
@@ -171,7 +171,7 @@ class BridgeViewModel(
currentAmount = amount
calculateOutput()
updateInsufficientBalanceState()
updateButtonState()
updateWarningState() // re-check the entered amount against the cached reserve
}
fun maxClicked() {
@@ -208,27 +208,15 @@ class BridgeViewModel(
router.back()
}
private fun fetchBridgeStatus() {
private fun fetchReserveStatus() {
launch {
try {
isWusdtToUsdtActive = withContext(Dispatchers.IO) {
fetchStatusFromApi()
}
updateWarningState()
polkadotUsdtReserve = try {
val raw = bridgeMultisigInteractor.getPolkadotUsdtReserve()
BigDecimal(raw).divide(USDT_DECIMALS_DIVISOR)
} catch (e: Exception) {
isWusdtToUsdtActive = false
updateWarningState()
BigDecimal.ZERO
}
}
}
private fun fetchStatusFromApi(): Boolean {
return try {
val response = URL(BRIDGE_STATUS_API).readText()
val json = JSONObject(response)
json.optBoolean("wusdtToUsdtActive", false)
} catch (e: Exception) {
false
updateWarningState()
}
}
@@ -237,14 +225,20 @@ class BridgeViewModel(
when (dir) {
BridgeDirection.WUSDT_TO_USDT -> {
_showWarning.postValue(true)
if (!isWusdtToUsdtActive) {
val requested = BigDecimal.valueOf(currentAmount)
val exceedsReserve = currentAmount > 0 && requested > polkadotUsdtReserve
_showWarning.postValue(exceedsReserve)
if (exceedsReserve) {
_warningBlocked.postValue(true)
_warningText.postValue(resourceManager.getString(R.string.bridge_wusdt_to_usdt_blocked))
_warningText.postValue(
resourceManager.getString(
R.string.bridge_wusdt_to_usdt_blocked,
polkadotUsdtReserve.setScale(2, RoundingMode.DOWN).stripTrailingZeros().toPlainString()
)
)
} else {
_warningBlocked.postValue(false)
_warningText.postValue("")
_showWarning.postValue(false)
}
}
else -> {
@@ -274,11 +268,12 @@ class BridgeViewModel(
private fun updateButtonState() {
val dir = _direction.value ?: return
val requested = BigDecimal.valueOf(currentAmount)
_buttonState.value = when {
currentAmount <= 0 -> ButtonState.DISABLED
currentAmount < MIN_USDT -> ButtonState.DISABLED
BigDecimal.valueOf(currentAmount) > availableBalance -> ButtonState.DISABLED
dir == BridgeDirection.WUSDT_TO_USDT && !isWusdtToUsdtActive -> ButtonState.DISABLED
requested > availableBalance -> ButtonState.DISABLED
dir == BridgeDirection.WUSDT_TO_USDT && requested > polkadotUsdtReserve -> ButtonState.DISABLED
else -> ButtonState.NORMAL
}
}
@@ -292,7 +287,7 @@ class BridgeViewModel(
}
fun refreshBridgeStatus() {
fetchBridgeStatus()
fetchReserveStatus()
refreshSignerState()
}