mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 14:55:48 +00:00
Add signatory-only sign button to Bridge screen
Any of the USDT bridge multisig's 5 known signatories now sees a status button on the Bridge screen: green when the automation key's spending allowance is healthy, red with a Sign action once it drops below threshold. Signing submits the renewal's as_multi approval directly from the wallet, mirroring the same on-chain action already available via pwap-web and pezbridge-sign.pex.mom - a third independent channel for the same operation, none of which is a single point of failure.
This commit is contained in:
@@ -371,6 +371,12 @@
|
|||||||
<string name="bridge_hez_to_dot_blocked">HEZ→DOT swaps are temporarily unavailable. Please try again when DOT liquidity is sufficient.</string>
|
<string name="bridge_hez_to_dot_blocked">HEZ→DOT swaps are temporarily unavailable. Please try again when DOT liquidity is sufficient.</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">USDT(Pez)→USDT(Pol) swaps are temporarily unavailable. Waiting for 1:1 liquidity to be established.</string>
|
||||||
|
|
||||||
|
<string name="bridge_sign_status_ok">Bridge allowance OK (%s)</string>
|
||||||
|
<string name="bridge_sign_button">Sign renewal (%s)</string>
|
||||||
|
<string name="bridge_sign_waiting_others">Signed — waiting for other signers</string>
|
||||||
|
<string name="bridge_sign_in_progress">Signing…</string>
|
||||||
|
<string name="bridge_sign_error">Signing failed, tap to retry</string>
|
||||||
|
|
||||||
<string name="wallet_asset_buy_sell">Buy/Sell</string>
|
<string name="wallet_asset_buy_sell">Buy/Sell</string>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package io.novafoundation.nova.feature_assets.domain.bridge.multisig
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.address.AccountIdKey
|
||||||
|
import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2
|
||||||
|
import io.novafoundation.nova.common.utils.Modules
|
||||||
|
import io.novafoundation.nova.common.utils.argumentType
|
||||||
|
import io.novafoundation.nova.common.utils.composeCall
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigTimePoint
|
||||||
|
import io.novafoundation.nova.runtime.util.constructAccountLookupInstance
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.RuntimeSnapshot
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.metadata.call
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Assets.approve_transfer(id, delegate, amount)` - field names verified against the actual
|
||||||
|
* pallet_assets source this ecosystem runs (bizinikiwi/pezframe/assets/src/lib.rs), not guessed:
|
||||||
|
* `pub fn approve_transfer(origin, id: T::AssetIdParameter, delegate: AccountIdLookupOf<T>,
|
||||||
|
* #[compact] amount: T::Balance)`. `delegate` is a lookup/MultiAddress field, so it goes through
|
||||||
|
* `constructAccountLookupInstance` - this chain uses non-standard numeric MultiAddress variant
|
||||||
|
* names in places (see runtime/util/AccountLookup.kt's own comment), so hand-rolling this
|
||||||
|
* wrapping instead of reusing the app's existing helper would be a real risk of a silently wrong
|
||||||
|
* encoding.
|
||||||
|
*/
|
||||||
|
fun RuntimeSnapshot.composeAssetsApproveTransfer(
|
||||||
|
assetId: Int,
|
||||||
|
delegate: AccountIdKey,
|
||||||
|
amount: BigInteger,
|
||||||
|
): GenericCall.Instance {
|
||||||
|
val delegateType = metadata.module(Modules.ASSETS).call("approve_transfer").argumentType("delegate")
|
||||||
|
|
||||||
|
return composeCall(
|
||||||
|
moduleName = Modules.ASSETS,
|
||||||
|
callName = "approve_transfer",
|
||||||
|
arguments = mapOf(
|
||||||
|
"id" to assetId.toBigInteger(),
|
||||||
|
"delegate" to delegateType.constructAccountLookupInstance(delegate.value),
|
||||||
|
"amount" to amount,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `Multisig.as_multi(threshold, other_signatories, maybe_timepoint, call, max_weight)` - a plain
|
||||||
|
* threshold/signatory-list version of feature-account-api's `composeMultisigAsMulti`, which
|
||||||
|
* requires a full `MultisigMetaAccount` (only ever reads its `.threshold`/`.otherSignatories`,
|
||||||
|
* but constructing one would mean either building a throwaway implementation of the whole
|
||||||
|
* `MetaAccount` interface chain or going through this app's "add multisig wallet" import flow -
|
||||||
|
* neither is needed here since this screen's multisig is fully known/hardcoded ahead of time).
|
||||||
|
* Passing the full call (never just its hash) is always correct regardless of whether this
|
||||||
|
* signature is the first, an intermediate, or the final approval - pallet_multisig's own
|
||||||
|
* `operate()` only actually dispatches the call once threshold is reached with this vote,
|
||||||
|
* otherwise it just records the approval, so there is no "hash-only" branch needed on this side.
|
||||||
|
*/
|
||||||
|
fun RuntimeSnapshot.composeBridgeMultisigAsMulti(
|
||||||
|
threshold: Int,
|
||||||
|
otherSignatories: List<AccountIdKey>,
|
||||||
|
maybeTimePoint: MultisigTimePoint?,
|
||||||
|
call: GenericCall.Instance,
|
||||||
|
maxWeight: WeightV2,
|
||||||
|
): GenericCall.Instance {
|
||||||
|
return composeCall(
|
||||||
|
moduleName = Modules.MULTISIG,
|
||||||
|
callName = "as_multi",
|
||||||
|
arguments = mapOf(
|
||||||
|
"threshold" to threshold.toBigInteger(),
|
||||||
|
"other_signatories" to otherSignatories.map { it.value },
|
||||||
|
"maybe_timepoint" to maybeTimePoint?.toEncodableInstance(),
|
||||||
|
"call" to call,
|
||||||
|
"max_weight" to maxWeight.toEncodableInstance(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
package io.novafoundation.nova.feature_assets.domain.bridge.multisig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything needed to identify the USDT bridge's 3-of-5 multisig and let any of its 5 real
|
||||||
|
* signatories renew the automation key's spending allowance directly from this app's Bridge
|
||||||
|
* screen - a third independent channel alongside pwap-web's `/multisig/pending` and
|
||||||
|
* pezbridge-sign.pex.mom, all three ultimately doing the same on-chain thing (a signer's own
|
||||||
|
* wallet contributing one `Multisig.as_multi` approval), so none of them is a single point of
|
||||||
|
* failure for "how do signers actually sign."
|
||||||
|
*
|
||||||
|
* See res/validators-tiki.md "USDT Bridge Custody Multisig" (not in this repo) for how these
|
||||||
|
* were derived/verified on-chain.
|
||||||
|
*/
|
||||||
|
object BridgeMultisigConstants {
|
||||||
|
|
||||||
|
const val WUSDT_ASSET_ID = 1000
|
||||||
|
|
||||||
|
const val MULTISIG_ADDRESS = "5GvwxmCDp3PC33KHoeWSgj3S7ocE7nzk1jiCCZMPSDBFeNcj"
|
||||||
|
const val AUTOMATION_KEY_ADDRESS = "5GQu4PFUb1f3MTJ7i7c1CtLgDk3TVvpSW1VbQCRmfkMoC8cM"
|
||||||
|
|
||||||
|
const val THRESHOLD = 3
|
||||||
|
|
||||||
|
/** Renewal is offered once the remaining allowance drops below this (6 decimals). */
|
||||||
|
const val RENEWAL_THRESHOLD = 3_000_000_000L // 3,000 wUSDT
|
||||||
|
|
||||||
|
/** The standard amount a renewal tops the allowance back up to (6 decimals). */
|
||||||
|
const val TOPUP_AMOUNT = 10_000_000_000L // 10,000 wUSDT
|
||||||
|
|
||||||
|
data class Signatory(val role: String, val address: String)
|
||||||
|
|
||||||
|
val SIGNATORIES = listOf(
|
||||||
|
Signatory("Serok", "5EfUVZ4HXG65WuqeG24z4Pmt11cNQTUacR3CKymKcsv1UTFU"),
|
||||||
|
Signatory("SerokiMeclise", "5CrB5BWJfLNWEZAsAXDKXdJUGzFMXKvYnwRX4DVMcgBwxSdx"),
|
||||||
|
Signatory("Xezinedar", "5GipBJs2uNWTCazyZQ2vG3DEqLz4tXNmNZtBAT1Mtm1orZ5i"),
|
||||||
|
Signatory("Berdevk", "5HWFZbhkZuTUySXu6ZXYKrTHBnWXHvWRKLozE22zhnwXGGxk"),
|
||||||
|
Signatory("Noter", "5ELgySrX5ZyK7EWXjj6bAedyTCcTNWDANbiiipsT5gnpoCEp"),
|
||||||
|
)
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
package io.novafoundation.nova.feature_assets.domain.bridge.multisig
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.address.AccountIdKey
|
||||||
|
import io.novafoundation.nova.common.address.intoKey
|
||||||
|
import io.novafoundation.nova.common.address.toHexWithPrefix
|
||||||
|
import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2
|
||||||
|
import io.novafoundation.nova.common.di.scope.FeatureScope
|
||||||
|
import io.novafoundation.nova.common.utils.callHash
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.TransactionOrigin
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.ExtrinsicExecutionResult
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.model.accountIdIn
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.multisig.intoCallHash
|
||||||
|
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
|
||||||
|
import io.novafoundation.nova.runtime.ext.ChainGeneses
|
||||||
|
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
||||||
|
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
|
||||||
|
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
|
||||||
|
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
|
||||||
|
import java.math.BigInteger
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Named
|
||||||
|
|
||||||
|
data class BridgeSignerState(
|
||||||
|
val signatoryRole: String,
|
||||||
|
val remainingAllowance: BigInteger,
|
||||||
|
val needsRenewal: Boolean,
|
||||||
|
val alreadySignedPendingRenewal: Boolean,
|
||||||
|
val approvalsSoFar: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface BridgeMultisigInteractor {
|
||||||
|
|
||||||
|
/** Null if the currently selected wallet isn't one of the 5 known bridge signatories - the
|
||||||
|
* Bridge screen shows nothing in that case, this feature doesn't exist for anyone else. */
|
||||||
|
suspend fun getSignerState(): BridgeSignerState?
|
||||||
|
|
||||||
|
suspend fun submitRenewalSignature(): Result<ExtrinsicExecutionResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
@FeatureScope
|
||||||
|
class RealBridgeMultisigInteractor @Inject constructor(
|
||||||
|
private val chainRegistry: ChainRegistry,
|
||||||
|
private val selectedAccountUseCase: SelectedAccountUseCase,
|
||||||
|
@Named(REMOTE_STORAGE_SOURCE) private val storageDataSource: StorageDataSource,
|
||||||
|
private val extrinsicService: ExtrinsicService,
|
||||||
|
) : BridgeMultisigInteractor {
|
||||||
|
|
||||||
|
override suspend fun getSignerState(): BridgeSignerState? {
|
||||||
|
val chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB)
|
||||||
|
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
|
||||||
|
val myAccountId = metaAccount.accountIdIn(chain)?.intoKey() ?: return null
|
||||||
|
|
||||||
|
val signatory = BridgeMultisigConstants.SIGNATORIES.firstOrNull {
|
||||||
|
it.address.toAccountId().intoKey() == myAccountId
|
||||||
|
} ?: return null
|
||||||
|
|
||||||
|
val remaining = queryRemainingAllowance(chain)
|
||||||
|
val needsRenewal = remaining < BigInteger.valueOf(BridgeMultisigConstants.RENEWAL_THRESHOLD)
|
||||||
|
|
||||||
|
var alreadySigned = false
|
||||||
|
var approvalsSoFar = 0
|
||||||
|
if (needsRenewal) {
|
||||||
|
val pending = queryPendingRenewal(chain)
|
||||||
|
if (pending != null) {
|
||||||
|
approvalsSoFar = pending.approvals.size
|
||||||
|
alreadySigned = pending.approvals.contains(myAccountId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BridgeSignerState(
|
||||||
|
signatoryRole = signatory.role,
|
||||||
|
remainingAllowance = remaining,
|
||||||
|
needsRenewal = needsRenewal,
|
||||||
|
alreadySignedPendingRenewal = alreadySigned,
|
||||||
|
approvalsSoFar = approvalsSoFar,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun submitRenewalSignature(): Result<ExtrinsicExecutionResult> = runCatching {
|
||||||
|
val chain = chainRegistry.getChain(ChainGeneses.PEZKUWI_ASSET_HUB)
|
||||||
|
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
|
||||||
|
val myAccountId = requireNotNull(metaAccount.accountIdIn(chain)?.intoKey()) {
|
||||||
|
"Selected account has no address on Pezkuwi Asset Hub"
|
||||||
|
}
|
||||||
|
|
||||||
|
val otherSignatories = BridgeMultisigConstants.SIGNATORIES
|
||||||
|
.map { it.address.toAccountId().intoKey() }
|
||||||
|
.filter { it != myAccountId }
|
||||||
|
.sortedBy { it.toHexWithPrefix() }
|
||||||
|
|
||||||
|
val pending = queryPendingRenewal(chain)
|
||||||
|
check(pending == null || !pending.approvals.contains(myAccountId)) {
|
||||||
|
"Already signed this renewal - waiting for other signers"
|
||||||
|
}
|
||||||
|
|
||||||
|
extrinsicService.submitExtrinsicAndAwaitExecution(
|
||||||
|
chain = chain,
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
val multisigCall = runtime.composeBridgeMultisigAsMulti(
|
||||||
|
threshold = BridgeMultisigConstants.THRESHOLD,
|
||||||
|
otherSignatories = otherSignatories,
|
||||||
|
maybeTimePoint = pending?.timePoint,
|
||||||
|
call = approveTransferCall,
|
||||||
|
maxWeight = WeightV2(BigInteger.valueOf(1_000_000_000L), BigInteger.valueOf(200_000L)),
|
||||||
|
)
|
||||||
|
|
||||||
|
call(multisigCall)
|
||||||
|
}.requireOk()
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryRemainingAllowance(chain: Chain): BigInteger {
|
||||||
|
val multisigAccountId = BridgeMultisigConstants.MULTISIG_ADDRESS.toAccountId().intoKey()
|
||||||
|
val delegateAccountId = BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS.toAccountId().intoKey()
|
||||||
|
|
||||||
|
return storageDataSource.query(chain.id) {
|
||||||
|
runtime.metadata.bridgeAssets().approvalAmount.query(
|
||||||
|
BridgeMultisigConstants.WUSDT_ASSET_ID.toBigInteger(),
|
||||||
|
multisigAccountId,
|
||||||
|
delegateAccountId,
|
||||||
|
)
|
||||||
|
} ?: BigInteger.ZERO
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun queryPendingRenewal(chain: Chain): BridgeOnChainMultisig? {
|
||||||
|
val multisigAccountId = BridgeMultisigConstants.MULTISIG_ADDRESS.toAccountId().intoKey()
|
||||||
|
val callHash = renewalCallHash(chain)
|
||||||
|
|
||||||
|
return storageDataSource.query(chain.id) {
|
||||||
|
runtime.metadata.bridgeMultisig().multisigs.query(multisigAccountId, callHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun renewalCallHash(chain: Chain): AccountIdKey {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
return call.callHash(runtime).intoCallHash()
|
||||||
|
}
|
||||||
|
}
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
package io.novafoundation.nova.feature_assets.domain.bridge.multisig
|
||||||
|
|
||||||
|
import io.novafoundation.nova.common.address.AccountIdKey
|
||||||
|
import io.novafoundation.nova.common.data.network.runtime.binding.bindAccountIdKey
|
||||||
|
import io.novafoundation.nova.common.data.network.runtime.binding.bindList
|
||||||
|
import io.novafoundation.nova.common.data.network.runtime.binding.bindNumber
|
||||||
|
import io.novafoundation.nova.common.data.network.runtime.binding.castToStruct
|
||||||
|
import io.novafoundation.nova.common.utils.Modules
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigTimePoint
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.query.StorageQueryContext
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.query.api.QueryableModule
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry2
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.query.api.QueryableStorageEntry3
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.query.api.converters.scaleDecoder
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.query.api.converters.scaleEncoder
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.query.api.storage2
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.query.api.storage3
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.metadata.RuntimeMetadata
|
||||||
|
import io.novasama.substrate_sdk_android.runtime.metadata.module.Module
|
||||||
|
import java.math.BigInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal, Bridge-screen-local mirrors of the equivalents already used by the multisig-operations
|
||||||
|
* feature (feature-account-impl's `MultisigRuntimeApi`/`OnChainMultisig`) - duplicated here rather
|
||||||
|
* than imported because feature-assets depends on feature-account-api, not feature-account-impl
|
||||||
|
* (standard module-boundary convention in this app: features don't reach into each other's impl
|
||||||
|
* modules). Only what this screen actually needs is modeled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class BridgeAssetsApi(override val module: Module) : QueryableModule
|
||||||
|
|
||||||
|
context(StorageQueryContext)
|
||||||
|
fun RuntimeMetadata.bridgeAssets(): BridgeAssetsApi = BridgeAssetsApi(module(Modules.ASSETS))
|
||||||
|
|
||||||
|
/** `Assets.Approvals(asset_id, owner, delegate) -> Approval { amount, deposit }` - only `amount`
|
||||||
|
* (the remaining spendable allowance) is needed here. */
|
||||||
|
context(StorageQueryContext)
|
||||||
|
val BridgeAssetsApi.approvalAmount: QueryableStorageEntry3<BigInteger, AccountIdKey, AccountIdKey, BigInteger>
|
||||||
|
get() = storage3(
|
||||||
|
name = "Approvals",
|
||||||
|
binding = { decoded, _, _, _ -> bindNumber(decoded.castToStruct()["amount"]) },
|
||||||
|
key2ToInternalConverter = AccountIdKey.scaleEncoder,
|
||||||
|
key3ToInternalConverter = AccountIdKey.scaleEncoder,
|
||||||
|
key2FromInternalConverter = AccountIdKey.scaleDecoder,
|
||||||
|
key3FromInternalConverter = AccountIdKey.scaleDecoder,
|
||||||
|
)
|
||||||
|
|
||||||
|
@JvmInline
|
||||||
|
value class BridgeMultisigApi(override val module: Module) : QueryableModule
|
||||||
|
|
||||||
|
context(StorageQueryContext)
|
||||||
|
fun RuntimeMetadata.bridgeMultisig(): BridgeMultisigApi = BridgeMultisigApi(module(Modules.MULTISIG))
|
||||||
|
|
||||||
|
class BridgeOnChainMultisig(
|
||||||
|
val approvals: List<AccountIdKey>,
|
||||||
|
val timePoint: MultisigTimePoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `Multisig.Multisigs(multisig_account, call_hash) -> Multisig { approvals, when, ... }` -
|
||||||
|
* mirrors `OnChainMultisig` but only carries what this screen needs (not deposit/depositor). */
|
||||||
|
context(StorageQueryContext)
|
||||||
|
val BridgeMultisigApi.multisigs: QueryableStorageEntry2<AccountIdKey, CallHash, BridgeOnChainMultisig>
|
||||||
|
get() = storage2(
|
||||||
|
name = "Multisigs",
|
||||||
|
binding = { decoded, _, _ ->
|
||||||
|
val struct = decoded.castToStruct()
|
||||||
|
BridgeOnChainMultisig(
|
||||||
|
approvals = bindList(struct["approvals"], ::bindAccountIdKey),
|
||||||
|
timePoint = MultisigTimePoint.bind(struct["when"]),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
key1ToInternalConverter = AccountIdKey.scaleEncoder,
|
||||||
|
key1FromInternalConverter = AccountIdKey.scaleDecoder,
|
||||||
|
key2ToInternalConverter = CallHash.scaleEncoder,
|
||||||
|
key2FromInternalConverter = CallHash.scaleDecoder,
|
||||||
|
)
|
||||||
+32
@@ -50,6 +50,17 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
binder.bridgeSwapButton.setOnClickListener {
|
binder.bridgeSwapButton.setOnClickListener {
|
||||||
viewModel.swapClicked()
|
viewModel.swapClicked()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Multisig signatory-only sign button
|
||||||
|
binder.bridgeSignButton.setOnClickListener {
|
||||||
|
viewModel.signClicked()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
|
||||||
|
viewModel.refreshBridgeStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun inject() {
|
override fun inject() {
|
||||||
@@ -104,6 +115,27 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
|
|||||||
binder.bridgeHezToDotWarning.text = text
|
binder.bridgeHezToDotWarning.text = text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewModel.signButtonVisible.observe { visible ->
|
||||||
|
binder.bridgeSignButton.visibility = if (visible) View.VISIBLE else View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.signButtonRed.observe { red ->
|
||||||
|
val color = if (red) {
|
||||||
|
resources.getColor(R.color.error_border, null)
|
||||||
|
} else {
|
||||||
|
resources.getColor(R.color.text_positive, null)
|
||||||
|
}
|
||||||
|
binder.bridgeSignButton.setButtonColor(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.signButtonEnabled.observe { enabled ->
|
||||||
|
binder.bridgeSignButton.isEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModel.signButtonLabel.observe { label ->
|
||||||
|
binder.bridgeSignButton.text = label
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePairUI(pair: BridgePair) {
|
private fun updatePairUI(pair: BridgePair) {
|
||||||
|
|||||||
+67
-1
@@ -6,6 +6,8 @@ import io.novafoundation.nova.common.base.BaseViewModel
|
|||||||
import io.novafoundation.nova.common.resources.ResourceManager
|
import io.novafoundation.nova.common.resources.ResourceManager
|
||||||
import io.novafoundation.nova.common.view.ButtonState
|
import io.novafoundation.nova.common.view.ButtonState
|
||||||
import io.novafoundation.nova.feature_assets.R
|
import io.novafoundation.nova.feature_assets.R
|
||||||
|
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigInteractor
|
||||||
|
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeSignerState
|
||||||
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
|
||||||
@@ -24,7 +26,8 @@ import java.net.URL
|
|||||||
class BridgeViewModel(
|
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 bridgeMultisigInteractor: BridgeMultisigInteractor
|
||||||
) : BaseViewModel() {
|
) : BaseViewModel() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -76,6 +79,18 @@ class BridgeViewModel(
|
|||||||
private val _warningText = MutableLiveData<String>()
|
private val _warningText = MutableLiveData<String>()
|
||||||
val warningText: LiveData<String> = _warningText
|
val warningText: LiveData<String> = _warningText
|
||||||
|
|
||||||
|
private val _signButtonVisible = MutableLiveData(false)
|
||||||
|
val signButtonVisible: LiveData<Boolean> = _signButtonVisible
|
||||||
|
|
||||||
|
private val _signButtonRed = MutableLiveData(false)
|
||||||
|
val signButtonRed: LiveData<Boolean> = _signButtonRed
|
||||||
|
|
||||||
|
private val _signButtonEnabled = MutableLiveData(false)
|
||||||
|
val signButtonEnabled: LiveData<Boolean> = _signButtonEnabled
|
||||||
|
|
||||||
|
private val _signButtonLabel = MutableLiveData("")
|
||||||
|
val signButtonLabel: LiveData<String> = _signButtonLabel
|
||||||
|
|
||||||
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
|
||||||
@@ -84,6 +99,7 @@ class BridgeViewModel(
|
|||||||
init {
|
init {
|
||||||
fetchExchangeRate()
|
fetchExchangeRate()
|
||||||
fetchBridgeStatus()
|
fetchBridgeStatus()
|
||||||
|
refreshSignerState()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setPair(newPair: BridgePair) {
|
fun setPair(newPair: BridgePair) {
|
||||||
@@ -321,5 +337,55 @@ class BridgeViewModel(
|
|||||||
|
|
||||||
fun refreshBridgeStatus() {
|
fun refreshBridgeStatus() {
|
||||||
fetchBridgeStatus()
|
fetchBridgeStatus()
|
||||||
|
refreshSignerState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshSignerState() {
|
||||||
|
launch {
|
||||||
|
val state = bridgeMultisigInteractor.getSignerState()
|
||||||
|
applySignerState(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun signClicked() {
|
||||||
|
if (_signButtonEnabled.value != true) return
|
||||||
|
|
||||||
|
_signButtonEnabled.postValue(false)
|
||||||
|
_signButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_in_progress))
|
||||||
|
|
||||||
|
launch {
|
||||||
|
bridgeMultisigInteractor.submitRenewalSignature()
|
||||||
|
.onFailure {
|
||||||
|
_signButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_error))
|
||||||
|
}
|
||||||
|
refreshSignerState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applySignerState(state: BridgeSignerState?) {
|
||||||
|
if (state == null) {
|
||||||
|
_signButtonVisible.postValue(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_signButtonVisible.postValue(true)
|
||||||
|
|
||||||
|
when {
|
||||||
|
!state.needsRenewal -> {
|
||||||
|
_signButtonRed.postValue(false)
|
||||||
|
_signButtonEnabled.postValue(false)
|
||||||
|
_signButtonLabel.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))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
_signButtonRed.postValue(true)
|
||||||
|
_signButtonEnabled.postValue(true)
|
||||||
|
_signButtonLabel.postValue(resourceManager.getString(R.string.bridge_sign_button, state.signatoryRole))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-2
@@ -9,22 +9,40 @@ import dagger.multibindings.IntoMap
|
|||||||
import io.novafoundation.nova.common.di.viewmodel.ViewModelKey
|
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.resources.ResourceManager
|
import io.novafoundation.nova.common.resources.ResourceManager
|
||||||
|
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
|
||||||
|
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
|
||||||
|
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigInteractor
|
||||||
|
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.RealBridgeMultisigInteractor
|
||||||
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.di.REMOTE_STORAGE_SOURCE
|
||||||
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
|
||||||
|
import io.novafoundation.nova.runtime.storage.source.StorageDataSource
|
||||||
|
import javax.inject.Named
|
||||||
|
|
||||||
@Module(includes = [ViewModelModule::class])
|
@Module(includes = [ViewModelModule::class])
|
||||||
class BridgeModule {
|
class BridgeModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun provideBridgeMultisigInteractor(
|
||||||
|
chainRegistry: ChainRegistry,
|
||||||
|
selectedAccountUseCase: SelectedAccountUseCase,
|
||||||
|
@Named(REMOTE_STORAGE_SOURCE) storageDataSource: StorageDataSource,
|
||||||
|
extrinsicService: ExtrinsicService
|
||||||
|
): BridgeMultisigInteractor {
|
||||||
|
return RealBridgeMultisigInteractor(chainRegistry, selectedAccountUseCase, storageDataSource, extrinsicService)
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@IntoMap
|
@IntoMap
|
||||||
@ViewModelKey(BridgeViewModel::class)
|
@ViewModelKey(BridgeViewModel::class)
|
||||||
fun provideViewModel(
|
fun provideViewModel(
|
||||||
router: AssetsRouter,
|
router: AssetsRouter,
|
||||||
resourceManager: ResourceManager,
|
resourceManager: ResourceManager,
|
||||||
chainRegistry: ChainRegistry
|
chainRegistry: ChainRegistry,
|
||||||
|
bridgeMultisigInteractor: BridgeMultisigInteractor
|
||||||
): ViewModel {
|
): ViewModel {
|
||||||
return BridgeViewModel(router, resourceManager, chainRegistry)
|
return BridgeViewModel(router, resourceManager, chainRegistry, bridgeMultisigInteractor)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -330,6 +330,17 @@
|
|||||||
android:textSize="12sp"
|
android:textSize="12sp"
|
||||||
android:visibility="gone" />
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Multisig signatory-only bridge allowance sign button. Only visible to a wallet
|
||||||
|
that is one of the bridge multisig's 5 known signatories. -->
|
||||||
|
<io.novafoundation.nova.common.view.PrimaryButton
|
||||||
|
android:id="@+id/bridgeSignButton"
|
||||||
|
style="@style/Widget.Nova.Button.Primary"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="@string/bridge_sign_button"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|||||||
Reference in New Issue
Block a user