mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 14:55:48 +00:00
Add pre-submit consent gate for amounts exceeding auto-pay approval
Symmetric across both bridge directions, checked in priority order before any funds move: 1. Real reserve exceeded (withdrawal only) - the multisig doesn't hold enough real USDT on Polkadot Asset Hub. No signature can fix this, so this stays a hard block (existing bridge_wusdt_to_usdt_blocked message), never offered as "proceed anyway". 2. Automation-key approval exceeded (either direction) - funds exist, the automation key just isn't currently approved to auto-pay that much. Resolvable by 3-of-5 signing, so this is now an explicit opt-in: a warning banner names the ~2h typical review window and the signatories' contact channel (t.me/pezkuwidestek), gated behind a WarningCheckBox the user must tick before Swap re-enables. Previously there was no signal at all here - a large amount would just silently queue for manual review after debiting the user, with no way to know that going in. Both getWusdtRemainingAllowance/getPolkadotUsdtRemainingAllowance query the real on-chain Assets.Approvals amount per leg - not a guess. Confirmed on-chain (2026-07-16) the Polkadot leg has never had an approval granted at all, so every wUSDT->USDT withdrawal currently hits the consent gate regardless of amount, symmetric with what happens once a signatory grants one (mirrors the existing wUSDT-side renewal flow exactly, including the same 40,000/200,000 20%-threshold pattern) - added a second sign button so a signatory can actually grant it from this screen. swapClicked()'s confirmation gate now also enforces the consent requirement server-side (well, client-domain-side) rather than only via the button's enabled look, consistent with how it already re-checks balance/reserve.
This commit is contained in:
@@ -365,6 +365,8 @@
|
||||
<string name="bridge_below_minimum">Amount below minimum</string>
|
||||
<string name="bridge_enter_amount">Enter amount</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_consent_required_message">This amount exceeds the automatic payout limit and needs approval from 3 of the bridge\'s 5 signatories - typically resolved within about 2 hours. You can reach them at t.me/pezkuwidestek in the meantime.</string>
|
||||
<string name="bridge_consent_checkbox_label">I understand this may take time to complete and want to proceed</string>
|
||||
|
||||
<string name="bridge_sign_status_ok">Bridge allowance OK (%s)</string>
|
||||
<string name="bridge_sign_button">Sign renewal (%s)</string>
|
||||
|
||||
+17
@@ -34,6 +34,14 @@ object BridgeMultisigConstants {
|
||||
const val MULTISIG_ADDRESS_POLKADOT = "15sF76THfpefUaKomHZSpssayRbsp6Yt6ESgMrLjzJCmpe66"
|
||||
const val AUTOMATION_KEY_ADDRESS = "5GQu4PFUb1f3MTJ7i7c1CtLgDk3TVvpSW1VbQCRmfkMoC8cM"
|
||||
|
||||
/** Same automation key, Polkadot Asset Hub SS58 encoding - needed to query/renew its real
|
||||
* spending approval on that chain (BridgeMultisigInteractor.getPolkadotUsdtRemainingAllowance
|
||||
* / submitPolkadotRenewalSignature). Confirmed on-chain (2026-07-16): unlike the Pezkuwi/
|
||||
* wUSDT side, this approval has never been granted at all - every wUSDT->USDT withdrawal
|
||||
* falls back to manual 3-of-5 review until the signatories grant one via the renewal flow
|
||||
* below, same as they did for the wUSDT side. */
|
||||
const val AUTOMATION_KEY_ADDRESS_POLKADOT = "15MCCiWYSnvWnzJdfkf1M3Aq5N37CENaaWE5ZVR8DqPKNcVj"
|
||||
|
||||
const val THRESHOLD = 3
|
||||
|
||||
/** Renewal is offered once the remaining allowance drops below this (6 decimals). Must match
|
||||
@@ -47,6 +55,15 @@ object BridgeMultisigConstants {
|
||||
/** The standard amount a renewal tops the allowance back up to (6 decimals). */
|
||||
const val TOPUP_AMOUNT = 200_000_000_000L // 200,000 wUSDT
|
||||
|
||||
/** Same 40,000/200,000 (20%) ratio as the wUSDT side, mirrored for the Polkadot/real-USDT
|
||||
* leg for the exact same reason: catch a draining allowance and renew it before users ever
|
||||
* hit the "needs manual review" consent gate, rather than only reacting after the fact. The
|
||||
* ceiling being far above the multisig's current real Polkadot reserve is intentional and
|
||||
* harmless - transfer_approved is still bounded by the real on-chain balance underneath, an
|
||||
* approval is unused headroom, not a promise of funds (see getPolkadotUsdtReserve). */
|
||||
const val POLKADOT_RENEWAL_THRESHOLD = 40_000_000_000L // 40,000 USDT
|
||||
const val POLKADOT_TOPUP_AMOUNT = 200_000_000_000L // 200,000 USDT
|
||||
|
||||
data class Signatory(val role: String, val address: String)
|
||||
|
||||
val SIGNATORIES = listOf(
|
||||
|
||||
+122
-33
@@ -39,6 +39,14 @@ interface BridgeMultisigInteractor {
|
||||
|
||||
suspend fun submitRenewalSignature(): Result<ExtrinsicExecutionResult>
|
||||
|
||||
/** Same as getSignerState/submitRenewalSignature but for the automation key's real-USDT
|
||||
* spending approval on Polkadot Asset Hub - the leg that gates wUSDT->USDT withdrawal
|
||||
* auto-pay. Confirmed on-chain (2026-07-16) this approval has never been granted at all, so
|
||||
* needsRenewal is currently always true for every signatory until the first renewal signs. */
|
||||
suspend fun getPolkadotSignerState(): BridgeSignerState?
|
||||
|
||||
suspend fun submitPolkadotRenewalSignature(): 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
|
||||
@@ -46,6 +54,18 @@ interface BridgeMultisigInteractor {
|
||||
* 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
|
||||
|
||||
/** Real remaining amount (base units) the automation key is currently approved to auto-pay
|
||||
* out of the multisig's own wUSDT on Pezkuwi Asset Hub - the deterministic on-chain fact
|
||||
* that decides whether a USDT->wUSDT deposit CAN possibly auto-pay (bounded further by the
|
||||
* backend's own daily cap, which isn't visible from the wallet - this is a necessary, not
|
||||
* sufficient, condition for auto-pay). Available to any wallet, not just signatories, since
|
||||
* it drives the Bridge screen's pre-submit consent gate for everyone. */
|
||||
suspend fun getWusdtRemainingAllowance(): BigInteger
|
||||
|
||||
/** Same as getWusdtRemainingAllowance but for the automation key's real USDT approval on
|
||||
* Polkadot Asset Hub - gates wUSDT->USDT withdrawal auto-pay. */
|
||||
suspend fun getPolkadotUsdtRemainingAllowance(): BigInteger
|
||||
}
|
||||
|
||||
@FeatureScope
|
||||
@@ -58,6 +78,69 @@ class RealBridgeMultisigInteractor @Inject constructor(
|
||||
|
||||
override suspend fun getSignerState(): BridgeSignerState? {
|
||||
val chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB)
|
||||
return getSignerStateFor(
|
||||
chain = chain,
|
||||
assetId = BridgeMultisigConstants.WUSDT_ASSET_ID,
|
||||
automationKeyAddress = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS,
|
||||
renewalThreshold = BridgeMultisigConstants.RENEWAL_THRESHOLD,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun submitRenewalSignature(): Result<ExtrinsicExecutionResult> = submitRenewalSignatureFor(
|
||||
chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB),
|
||||
assetId = BridgeMultisigConstants.WUSDT_ASSET_ID,
|
||||
automationKeyAddress = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS,
|
||||
topupAmount = BridgeMultisigConstants.TOPUP_AMOUNT,
|
||||
)
|
||||
|
||||
override suspend fun getPolkadotSignerState(): BridgeSignerState? {
|
||||
val chain = chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB)
|
||||
return getSignerStateFor(
|
||||
chain = chain,
|
||||
assetId = BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID,
|
||||
automationKeyAddress = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS_POLKADOT,
|
||||
renewalThreshold = BridgeMultisigConstants.POLKADOT_RENEWAL_THRESHOLD,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun submitPolkadotRenewalSignature(): Result<ExtrinsicExecutionResult> = submitRenewalSignatureFor(
|
||||
chain = chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB),
|
||||
assetId = BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID,
|
||||
automationKeyAddress = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS_POLKADOT,
|
||||
topupAmount = BridgeMultisigConstants.POLKADOT_TOPUP_AMOUNT,
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
override suspend fun getWusdtRemainingAllowance(): BigInteger {
|
||||
val chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB)
|
||||
return queryRemainingAllowance(chain, BridgeMultisigConstants.WUSDT_ASSET_ID, BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS)
|
||||
}
|
||||
|
||||
override suspend fun getPolkadotUsdtRemainingAllowance(): BigInteger {
|
||||
val chain = chainRegistry.getChain(ChainGeneses.POLKADOT_ASSET_HUB)
|
||||
return queryRemainingAllowance(chain, BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID, BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS_POLKADOT)
|
||||
}
|
||||
|
||||
/** Shared by both legs - the only differences between the wUSDT (Pezkuwi) and USDT (Polkadot)
|
||||
* renewal flows are which chain/asset/automation-key-address/threshold to use, the actual
|
||||
* on-chain call shape (Assets.approve_transfer wrapped in Multisig.as_multi) is identical. */
|
||||
private suspend fun getSignerStateFor(
|
||||
chain: Chain,
|
||||
assetId: Int,
|
||||
automationKeyAddress: String,
|
||||
renewalThreshold: Long,
|
||||
): BridgeSignerState? {
|
||||
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
|
||||
val myAccountId = metaAccount.accountIdIn(chain)?.intoKey() ?: return null
|
||||
|
||||
@@ -65,13 +148,13 @@ class RealBridgeMultisigInteractor @Inject constructor(
|
||||
it.address.toAccountId().intoKey() == myAccountId
|
||||
} ?: return null
|
||||
|
||||
val remaining = queryRemainingAllowance(chain)
|
||||
val needsRenewal = remaining < BigInteger.valueOf(BridgeMultisigConstants.RENEWAL_THRESHOLD)
|
||||
val remaining = queryRemainingAllowance(chain, assetId, automationKeyAddress)
|
||||
val needsRenewal = remaining < BigInteger.valueOf(renewalThreshold)
|
||||
|
||||
var alreadySigned = false
|
||||
var approvalsSoFar = 0
|
||||
if (needsRenewal) {
|
||||
val pending = queryPendingRenewal(chain)
|
||||
val pending = queryPendingRenewal(chain, assetId, automationKeyAddress)
|
||||
if (pending != null) {
|
||||
approvalsSoFar = pending.approvals.size
|
||||
alreadySigned = pending.approvals.contains(myAccountId)
|
||||
@@ -87,11 +170,15 @@ class RealBridgeMultisigInteractor @Inject constructor(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun submitRenewalSignature(): Result<ExtrinsicExecutionResult> = runCatching {
|
||||
val chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB)
|
||||
private suspend fun submitRenewalSignatureFor(
|
||||
chain: Chain,
|
||||
assetId: Int,
|
||||
automationKeyAddress: String,
|
||||
topupAmount: Long,
|
||||
): Result<ExtrinsicExecutionResult> = runCatching {
|
||||
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
|
||||
val myAccountId = requireNotNull(metaAccount.accountIdIn(chain)?.intoKey()) {
|
||||
"Selected account has no address on Pezkuwi Asset Hub"
|
||||
"Selected account has no address on ${chain.name}"
|
||||
}
|
||||
|
||||
val otherSignatories = BridgeMultisigConstants.SIGNATORIES
|
||||
@@ -99,7 +186,7 @@ class RealBridgeMultisigInteractor @Inject constructor(
|
||||
.filter { it != myAccountId }
|
||||
.sortedBy { it.toHexWithPrefix() }
|
||||
|
||||
val pending = queryPendingRenewal(chain)
|
||||
val pending = queryPendingRenewal(chain, assetId, automationKeyAddress)
|
||||
check(pending == null || !pending.approvals.contains(myAccountId)) {
|
||||
"Already signed this renewal - waiting for other signers"
|
||||
}
|
||||
@@ -109,9 +196,9 @@ class RealBridgeMultisigInteractor @Inject constructor(
|
||||
origin = TransactionOrigin.WalletWithId(metaAccount.id)
|
||||
) {
|
||||
val approveTransferCall = runtime.composeAssetsApproveTransfer(
|
||||
assetId = BridgeMultisigConstants.WUSDT_ASSET_ID,
|
||||
delegate = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS.toAccountId().intoKey(),
|
||||
amount = BigInteger.valueOf(BridgeMultisigConstants.TOPUP_AMOUNT),
|
||||
assetId = assetId,
|
||||
delegate = automationKeyAddress.toAccountId().intoKey(),
|
||||
amount = BigInteger.valueOf(topupAmount),
|
||||
)
|
||||
|
||||
val multisigCall = runtime.composeBridgeMultisigAsMulti(
|
||||
@@ -126,47 +213,49 @@ 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()
|
||||
private suspend fun queryRemainingAllowance(chain: Chain, assetId: Int, automationKeyAddress: String): BigInteger {
|
||||
val multisigAccountId = multisigAddressFor(chain).toAccountId().intoKey()
|
||||
val delegateAccountId = automationKeyAddress.toAccountId().intoKey()
|
||||
|
||||
return storageDataSource.query(chain.id) {
|
||||
runtime.metadata.bridgeAssets().approvalAmount.query(
|
||||
BridgeMultisigConstants.WUSDT_ASSET_ID.toBigInteger(),
|
||||
assetId.toBigInteger(),
|
||||
multisigAccountId,
|
||||
delegateAccountId,
|
||||
)
|
||||
} ?: BigInteger.ZERO
|
||||
}
|
||||
|
||||
private suspend fun queryPendingRenewal(chain: Chain): BridgeOnChainMultisig? {
|
||||
val multisigAccountId = BridgeMultisigConstants.MULTISIG_ADDRESS.toAccountId().intoKey()
|
||||
val callHash = renewalCallHash(chain)
|
||||
private suspend fun queryPendingRenewal(chain: Chain, assetId: Int, automationKeyAddress: String): BridgeOnChainMultisig? {
|
||||
val multisigAccountId = multisigAddressFor(chain).toAccountId().intoKey()
|
||||
val callHash = renewalCallHash(chain, assetId, automationKeyAddress)
|
||||
|
||||
return storageDataSource.query(chain.id) {
|
||||
runtime.metadata.bridgeMultisig().multisigs.query(multisigAccountId, callHash)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun renewalCallHash(chain: Chain): AccountIdKey {
|
||||
private suspend fun renewalCallHash(chain: Chain, assetId: Int, automationKeyAddress: String): AccountIdKey {
|
||||
val topupAmount = if (chain.id == ChainGeneses.POLKADOT_ASSET_HUB) {
|
||||
BridgeMultisigConstants.POLKADOT_TOPUP_AMOUNT
|
||||
} else {
|
||||
BridgeMultisigConstants.TOPUP_AMOUNT
|
||||
}
|
||||
|
||||
val runtime = chainRegistry.getRuntime(chain.id)
|
||||
val call = runtime.composeAssetsApproveTransfer(
|
||||
assetId = BridgeMultisigConstants.WUSDT_ASSET_ID,
|
||||
delegate = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS.toAccountId().intoKey(),
|
||||
amount = BigInteger.valueOf(BridgeMultisigConstants.TOPUP_AMOUNT),
|
||||
assetId = assetId,
|
||||
delegate = automationKeyAddress.toAccountId().intoKey(),
|
||||
amount = BigInteger.valueOf(topupAmount),
|
||||
)
|
||||
return call.callHash(runtime).intoCallHash()
|
||||
}
|
||||
|
||||
private fun multisigAddressFor(chain: Chain): String {
|
||||
return if (chain.id == ChainGeneses.POLKADOT_ASSET_HUB) {
|
||||
BridgeMultisigConstants.MULTISIG_ADDRESS_POLKADOT
|
||||
} else {
|
||||
BridgeMultisigConstants.MULTISIG_ADDRESS
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
-1
@@ -61,10 +61,18 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
||||
viewModel.swapClicked()
|
||||
}
|
||||
|
||||
// Multisig signatory-only sign button
|
||||
// Multisig signatory-only sign buttons, one per leg
|
||||
binder.bridgeSignButton.setOnClickListener {
|
||||
viewModel.signClicked()
|
||||
}
|
||||
|
||||
binder.bridgePolkadotSignButton.setOnClickListener {
|
||||
viewModel.polkadotSignClicked()
|
||||
}
|
||||
|
||||
binder.bridgeConsentCheckbox.setOnCheckedChangeListener { _, isChecked ->
|
||||
viewModel.consentCheckboxToggled(isChecked)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -146,6 +154,37 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
||||
binder.bridgeSignButton.text = label
|
||||
}
|
||||
|
||||
viewModel.polkadotSignButtonVisible.observe { visible ->
|
||||
binder.bridgePolkadotSignButton.visibility = if (visible) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
viewModel.polkadotSignButtonRed.observe { red ->
|
||||
val color = if (red) {
|
||||
resources.getColor(R.color.error_border, null)
|
||||
} else {
|
||||
resources.getColor(R.color.text_positive, null)
|
||||
}
|
||||
binder.bridgePolkadotSignButton.setButtonColor(color)
|
||||
}
|
||||
|
||||
viewModel.polkadotSignButtonEnabled.observe { enabled ->
|
||||
binder.bridgePolkadotSignButton.isEnabled = enabled
|
||||
}
|
||||
|
||||
viewModel.polkadotSignButtonLabel.observe { label ->
|
||||
binder.bridgePolkadotSignButton.text = label
|
||||
}
|
||||
|
||||
viewModel.consentRequired.observe { required ->
|
||||
binder.bridgeConsentCheckbox.visibility = if (required) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
viewModel.consentChecked.observe { checked ->
|
||||
if (binder.bridgeConsentCheckbox.isChecked != checked) {
|
||||
binder.bridgeConsentCheckbox.setChecked(checked)
|
||||
}
|
||||
}
|
||||
|
||||
viewModel.maxAmountDisplay.observe { display ->
|
||||
binder.bridgeFromMaxAmount.setMaxAmountDisplay(display)
|
||||
}
|
||||
|
||||
+155
-36
@@ -115,6 +115,22 @@ class BridgeViewModel(
|
||||
private val _signButtonLabel = MutableLiveData("")
|
||||
val signButtonLabel: LiveData<String> = _signButtonLabel
|
||||
|
||||
/** Same as the sign* set above but for the automation key's Polkadot-side USDT approval -
|
||||
* see BridgeMultisigInteractor.getPolkadotSignerState for why this is currently always
|
||||
* offered (approval has never been granted at all). A separate row rather than merging with
|
||||
* the wUSDT one since a signatory may need to renew one leg without the other. */
|
||||
private val _polkadotSignButtonVisible = MutableLiveData(false)
|
||||
val polkadotSignButtonVisible: LiveData<Boolean> = _polkadotSignButtonVisible
|
||||
|
||||
private val _polkadotSignButtonRed = MutableLiveData(false)
|
||||
val polkadotSignButtonRed: LiveData<Boolean> = _polkadotSignButtonRed
|
||||
|
||||
private val _polkadotSignButtonEnabled = MutableLiveData(false)
|
||||
val polkadotSignButtonEnabled: LiveData<Boolean> = _polkadotSignButtonEnabled
|
||||
|
||||
private val _polkadotSignButtonLabel = MutableLiveData("")
|
||||
val polkadotSignButtonLabel: LiveData<String> = _polkadotSignButtonLabel
|
||||
|
||||
private val _fromCard = MutableLiveData<BridgeAssetCardUi>()
|
||||
val fromCard: LiveData<BridgeAssetCardUi> = _fromCard
|
||||
|
||||
@@ -133,12 +149,38 @@ class BridgeViewModel(
|
||||
private val _fillAmountEvent = MutableLiveData<Event<String>>()
|
||||
val fillAmountEvent: LiveData<Event<String>> = _fillAmountEvent
|
||||
|
||||
/** True when the entered amount exceeds the automation key's current on-chain approval for
|
||||
* this direction - real funds exist and the transfer WILL succeed, it just won't auto-pay
|
||||
* and needs 3-of-5 signatory review. Distinct from showWarning/warningBlocked (which cover
|
||||
* the harder "not enough real reserve at all" case, unresolvable by any signature) - this
|
||||
* one is resolvable, so the UI offers an explicit opt-in instead of a flat block. */
|
||||
private val _consentRequired = MutableLiveData(false)
|
||||
val consentRequired: LiveData<Boolean> = _consentRequired
|
||||
|
||||
private val _consentChecked = MutableLiveData(false)
|
||||
val consentChecked: LiveData<Boolean> = _consentChecked
|
||||
|
||||
fun consentCheckboxToggled(checked: Boolean) {
|
||||
_consentChecked.value = checked
|
||||
updateButtonState()
|
||||
}
|
||||
|
||||
private var currentAmount: Double = 0.0
|
||||
|
||||
/** 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
|
||||
|
||||
/** Remaining on-chain approval (BridgeMultisigInteractor.getWusdtRemainingAllowance /
|
||||
* getPolkadotUsdtRemainingAllowance), one per leg - the deterministic fact that decides
|
||||
* whether a given amount can possibly auto-pay in that direction, independent of the real
|
||||
* reserve check above (a withdrawal can be under-reserved AND under-approved at once; those
|
||||
* are checked in priority order in updateWarningState, since only the reserve one is truly
|
||||
* unresolvable). */
|
||||
private var wusdtRemainingAllowance: BigDecimal = BigDecimal.ZERO
|
||||
private var polkadotUsdtRemainingAllowance: BigDecimal = BigDecimal.ZERO
|
||||
|
||||
private var availableBalance: BigDecimal = BigDecimal.ZERO
|
||||
private var balanceJob: Job? = null
|
||||
|
||||
@@ -155,6 +197,7 @@ class BridgeViewModel(
|
||||
_pair.value = newPair
|
||||
// Reset direction to left (forward) when switching pair
|
||||
_direction.value = BridgeDirection.USDT_TO_WUSDT
|
||||
_consentChecked.value = false
|
||||
updateUI()
|
||||
calculateOutput()
|
||||
updateWarningState()
|
||||
@@ -166,6 +209,7 @@ class BridgeViewModel(
|
||||
val newDir = BridgeDirection.USDT_TO_WUSDT
|
||||
if (_direction.value != newDir) {
|
||||
_direction.value = newDir
|
||||
_consentChecked.value = false
|
||||
updateUI()
|
||||
calculateOutput()
|
||||
updateWarningState()
|
||||
@@ -177,6 +221,7 @@ class BridgeViewModel(
|
||||
val newDir = BridgeDirection.WUSDT_TO_USDT
|
||||
if (_direction.value != newDir) {
|
||||
_direction.value = newDir
|
||||
_consentChecked.value = false
|
||||
updateUI()
|
||||
calculateOutput()
|
||||
updateWarningState()
|
||||
@@ -186,9 +231,12 @@ class BridgeViewModel(
|
||||
|
||||
fun setAmount(amount: Double) {
|
||||
currentAmount = amount
|
||||
// A consent already given was for whatever amount was entered at the time - changing the
|
||||
// amount means re-confirming, not silently carrying an old opt-in over to a new one.
|
||||
_consentChecked.value = false
|
||||
calculateOutput()
|
||||
updateInsufficientBalanceState()
|
||||
updateWarningState() // re-check the entered amount against the cached reserve
|
||||
updateWarningState() // re-check the entered amount against the cached reserve/allowance
|
||||
}
|
||||
|
||||
fun maxClicked() {
|
||||
@@ -202,6 +250,7 @@ class BridgeViewModel(
|
||||
* different screens: there is no "just submitted" moment on this screen anymore). */
|
||||
fun resetAmount() {
|
||||
currentAmount = 0.0
|
||||
_consentChecked.value = false
|
||||
_fillAmountEvent.value = Event("")
|
||||
calculateOutput()
|
||||
updateInsufficientBalanceState()
|
||||
@@ -227,6 +276,15 @@ class BridgeViewModel(
|
||||
return
|
||||
}
|
||||
|
||||
val relevantAllowance = when (dir) {
|
||||
BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance
|
||||
BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance
|
||||
}
|
||||
if (requested > relevantAllowance && _consentChecked.value != true) {
|
||||
updateWarningState()
|
||||
return
|
||||
}
|
||||
|
||||
val chainId = when (dir) {
|
||||
BridgeDirection.USDT_TO_WUSDT -> POLKADOT_ASSET_HUB_ID
|
||||
BridgeDirection.WUSDT_TO_USDT -> PEZKUWI_ASSET_HUB_ID
|
||||
@@ -279,35 +337,63 @@ class BridgeViewModel(
|
||||
} catch (e: Exception) {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
wusdtRemainingAllowance = try {
|
||||
BigDecimal(bridgeMultisigInteractor.getWusdtRemainingAllowance()).divide(USDT_DECIMALS_DIVISOR)
|
||||
} catch (e: Exception) {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
polkadotUsdtRemainingAllowance = try {
|
||||
BigDecimal(bridgeMultisigInteractor.getPolkadotUsdtRemainingAllowance()).divide(USDT_DECIMALS_DIVISOR)
|
||||
} catch (e: Exception) {
|
||||
BigDecimal.ZERO
|
||||
}
|
||||
updateWarningState()
|
||||
}
|
||||
}
|
||||
|
||||
/** Two independent, differently-resolvable "no" conditions, checked in priority order - never
|
||||
* collapsed into one generic warning:
|
||||
* 1. Real reserve exceeded (withdrawal direction only) - the bridge doesn't hold enough real
|
||||
* USDT on Polkadot Asset Hub. No signature can fix this; a hard block.
|
||||
* 2. Automation-key approval exceeded (either direction) - funds exist, the automation key
|
||||
* just isn't currently approved to move that much without 3-of-5 review. Resolvable, so
|
||||
* this is an opt-in consent gate (see consentRequired/consentChecked), not a hard block. */
|
||||
private fun updateWarningState() {
|
||||
val dir = _direction.value ?: return
|
||||
val requested = BigDecimal.valueOf(currentAmount)
|
||||
|
||||
when (dir) {
|
||||
BridgeDirection.WUSDT_TO_USDT -> {
|
||||
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,
|
||||
polkadotUsdtReserve.setScale(2, RoundingMode.DOWN).stripTrailingZeros().toPlainString()
|
||||
)
|
||||
)
|
||||
} else {
|
||||
_warningBlocked.postValue(false)
|
||||
_warningText.postValue("")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
_showWarning.postValue(false)
|
||||
}
|
||||
val reserveExceeded = dir == BridgeDirection.WUSDT_TO_USDT && currentAmount > 0 && requested > polkadotUsdtReserve
|
||||
|
||||
if (reserveExceeded) {
|
||||
_consentRequired.postValue(false)
|
||||
_showWarning.postValue(true)
|
||||
_warningBlocked.postValue(true)
|
||||
_warningText.postValue(
|
||||
resourceManager.getString(
|
||||
R.string.bridge_wusdt_to_usdt_blocked,
|
||||
polkadotUsdtReserve.setScale(2, RoundingMode.DOWN).stripTrailingZeros().toPlainString()
|
||||
)
|
||||
)
|
||||
updateButtonState()
|
||||
return
|
||||
}
|
||||
|
||||
val relevantAllowance = when (dir) {
|
||||
BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance
|
||||
BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance
|
||||
}
|
||||
val needsConsent = currentAmount > 0 && requested > relevantAllowance
|
||||
|
||||
_consentRequired.postValue(needsConsent)
|
||||
if (needsConsent) {
|
||||
_showWarning.postValue(true)
|
||||
_warningBlocked.postValue(false)
|
||||
_warningText.postValue(resourceManager.getString(R.string.bridge_consent_required_message))
|
||||
} else {
|
||||
_showWarning.postValue(false)
|
||||
_warningText.postValue("")
|
||||
}
|
||||
|
||||
updateButtonState()
|
||||
}
|
||||
|
||||
@@ -332,11 +418,19 @@ class BridgeViewModel(
|
||||
val dir = _direction.value ?: return
|
||||
|
||||
val requested = BigDecimal.valueOf(currentAmount)
|
||||
val reserveExceeded = dir == BridgeDirection.WUSDT_TO_USDT && requested > polkadotUsdtReserve
|
||||
val relevantAllowance = when (dir) {
|
||||
BridgeDirection.USDT_TO_WUSDT -> wusdtRemainingAllowance
|
||||
BridgeDirection.WUSDT_TO_USDT -> polkadotUsdtRemainingAllowance
|
||||
}
|
||||
val consentSatisfied = requested <= relevantAllowance || _consentChecked.value == true
|
||||
|
||||
_buttonState.value = when {
|
||||
currentAmount <= 0 -> ButtonState.DISABLED
|
||||
currentAmount < MIN_USDT -> ButtonState.DISABLED
|
||||
requested > availableBalance -> ButtonState.DISABLED
|
||||
dir == BridgeDirection.WUSDT_TO_USDT && requested > polkadotUsdtReserve -> ButtonState.DISABLED
|
||||
reserveExceeded -> ButtonState.DISABLED
|
||||
!consentSatisfied -> ButtonState.DISABLED
|
||||
else -> ButtonState.NORMAL
|
||||
}
|
||||
}
|
||||
@@ -357,7 +451,11 @@ class BridgeViewModel(
|
||||
fun refreshSignerState() {
|
||||
launch {
|
||||
val state = bridgeMultisigInteractor.getSignerState()
|
||||
applySignerState(state)
|
||||
applySignerState(state, _signButtonVisible, _signButtonRed, _signButtonEnabled, _signButtonLabel)
|
||||
}
|
||||
launch {
|
||||
val state = bridgeMultisigInteractor.getPolkadotSignerState()
|
||||
applySignerState(state, _polkadotSignButtonVisible, _polkadotSignButtonRed, _polkadotSignButtonEnabled, _polkadotSignButtonLabel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,29 +474,50 @@ class BridgeViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySignerState(state: BridgeSignerState?) {
|
||||
fun polkadotSignClicked() {
|
||||
if (_polkadotSignButtonEnabled.value != true) return
|
||||
|
||||
_polkadotSignButtonEnabled.postValue(false)
|
||||
_polkadotSignButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_in_progress))
|
||||
|
||||
launch {
|
||||
bridgeMultisigInteractor.submitPolkadotRenewalSignature()
|
||||
.onFailure {
|
||||
_polkadotSignButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_error))
|
||||
}
|
||||
refreshSignerState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun applySignerState(
|
||||
state: BridgeSignerState?,
|
||||
visible: MutableLiveData<Boolean>,
|
||||
red: MutableLiveData<Boolean>,
|
||||
enabled: MutableLiveData<Boolean>,
|
||||
label: MutableLiveData<String>,
|
||||
) {
|
||||
if (state == null) {
|
||||
_signButtonVisible.postValue(false)
|
||||
visible.postValue(false)
|
||||
return
|
||||
}
|
||||
|
||||
_signButtonVisible.postValue(true)
|
||||
visible.postValue(true)
|
||||
|
||||
when {
|
||||
!state.needsRenewal -> {
|
||||
_signButtonRed.postValue(false)
|
||||
_signButtonEnabled.postValue(false)
|
||||
_signButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_status_ok, state.signatoryRole))
|
||||
red.postValue(false)
|
||||
enabled.postValue(false)
|
||||
label.postValue(resourceManager.getString(R.string.bridge_sign_status_ok, state.signatoryRole))
|
||||
}
|
||||
state.alreadySignedPendingRenewal -> {
|
||||
_signButtonRed.postValue(true)
|
||||
_signButtonEnabled.postValue(false)
|
||||
_signButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_waiting_others))
|
||||
red.postValue(true)
|
||||
enabled.postValue(false)
|
||||
label.postValue(resourceManager.getString(R.string.bridge_sign_waiting_others))
|
||||
}
|
||||
else -> {
|
||||
_signButtonRed.postValue(true)
|
||||
_signButtonEnabled.postValue(true)
|
||||
_signButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_button, state.signatoryRole))
|
||||
red.postValue(true)
|
||||
enabled.postValue(true)
|
||||
label.postValue(resourceManager.getString(R.string.bridge_sign_button, state.signatoryRole))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,7 +170,9 @@
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<!-- Warning for USDT liquidity -->
|
||||
<!-- Warning for USDT liquidity, or the consent message when the amount exceeds the
|
||||
automation key's current approval (resolvable by 3-of-5 review, unlike the
|
||||
reserve case above which isn't - see BridgeViewModel.updateWarningState). -->
|
||||
<io.novafoundation.nova.common.view.AlertView
|
||||
android:id="@+id/bridgeWarningAlert"
|
||||
android:layout_width="match_parent"
|
||||
@@ -179,8 +181,21 @@
|
||||
android:visibility="gone"
|
||||
app:alertMode="warning" />
|
||||
|
||||
<!-- Multisig signatory-only bridge allowance sign button. Only visible to a wallet
|
||||
that is one of the bridge multisig's 5 known signatories. -->
|
||||
<!-- Only shown alongside the consent message above (consentRequired) - explicit
|
||||
opt-in required before the Swap button re-enables, never enabled by default. -->
|
||||
<io.novafoundation.nova.common.view.WarningCheckBox
|
||||
android:id="@+id/bridgeConsentCheckbox"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:icon="@drawable/ic_warning_filled"
|
||||
android:text="@string/bridge_consent_checkbox_label"
|
||||
android:visibility="gone"
|
||||
app:iconTint="@color/icon_warning" />
|
||||
|
||||
<!-- Multisig signatory-only bridge allowance sign buttons. Only visible to a wallet
|
||||
that is one of the bridge multisig's 5 known signatories - one row per leg, since
|
||||
a signatory may need to renew one chain's approval without the other. -->
|
||||
<io.novafoundation.nova.common.view.PrimaryButton
|
||||
android:id="@+id/bridgeSignButton"
|
||||
style="@style/Widget.Nova.Button.Primary"
|
||||
@@ -190,6 +205,15 @@
|
||||
android:text="@string/bridge_sign_button"
|
||||
android:visibility="gone" />
|
||||
|
||||
<io.novafoundation.nova.common.view.PrimaryButton
|
||||
android:id="@+id/bridgePolkadotSignButton"
|
||||
style="@style/Widget.Nova.Button.Primary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/bridge_sign_button"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
Reference in New Issue
Block a user