Fix Pending Signatures: query the bridge's own hardcoded multisig, not the generic multisig-account feature

The previous commit reused MultisigPendingOperationsService, but that
service only syncs accounts formally registered as a MultisigMetaAccount
in this wallet - it silently returns an empty NoOpSyncer for every other
account type. The bridge's 5 signatories (Serok etc.) use their own
regular wallets to approve calls against the bridge's separate,
hardcoded multisig (BridgeMultisigConstants) - they never "are" that
multisig account, so the feature was structurally guaranteed to show
nothing, not just empty by coincidence.

Fix queries the bridge's own multisig accounts directly, mirroring the
exact pattern this screen's existing renewal-signing code already uses
(BridgeMultisigRuntimeApi's runtime-metadata accessors, a local mirror
of feature-account-impl's off-chain call-data indexer client - same
module-boundary reasoning already documented in that file) rather than
the generic, differently-scoped service:

- BridgeMultisigInteractor.getPendingApprovals(): enumerates
  Multisig.Multisigs entries for both bridge multisig accounts via
  QueryableStorageEntry2.keys()/entries() (a general runtime-storage
  capability, not multisig-feature-specific), fetches real call content
  from the same off-chain indexer the generic feature uses (also
  general-purpose, keyed by address+hashes - never required the account
  to be a MultisigMetaAccount either), and filters to calls this
  signatory hasn't approved yet.
- BridgeMultisigInteractor.submitApproval(): reuses
  composeBridgeMultisigAsMulti generalized to an arbitrary call+
  timepoint (previously only used for the renewal call) - refuses to
  proceed if the call content is unknown, never blind-signs.
- BridgeViewModel formats the real parsed transfer (amount+destination)
  via AssetSourceRegistry.tryParseTransfer, already used elsewhere for
  the exact same "decode a transfer call" job - no fabricated preview
  data.

Reverts the feature-multisig:operations cross-feature dependency and
FeatureApi wiring added in the previous commit - no longer needed since
everything now lives in this screen's own existing self-contained
domain layer, matching how the rest of BridgeMultisigInteractor already
works (and already explains, in its own doc comments, why a synthetic
MultisigMetaAccount was deliberately avoided for exactly this reason).
This commit is contained in:
2026-07-17 16:42:40 -07:00
parent 6f14c8bf05
commit e716e6e277
12 changed files with 270 additions and 71 deletions
@@ -108,8 +108,6 @@ import io.novafoundation.nova.feature_ledger_impl.presentation.account.addChain.
import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload import io.novafoundation.nova.feature_ledger_impl.presentation.account.common.selectLedger.SelectLedgerPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedBottomSheet import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedBottomSheet
import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedPayload import io.novafoundation.nova.feature_multisig_operations.presentation.created.MultisigCreatedPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsFragment
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload
import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter import io.novafoundation.nova.feature_onboarding_impl.OnboardingRouter
import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.WelcomeFragment import io.novafoundation.nova.feature_onboarding_impl.presentation.welcome.WelcomeFragment
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
@@ -985,12 +983,6 @@ class Navigator(
.navigateInFirstAttachedContext() .navigateInFirstAttachedContext()
} }
override fun openMultisigOperationDetails(payload: MultisigOperationDetailsPayload) {
navigationBuilder().action(R.id.action_multisigOperationDetailsFragment)
.setArgs(MultisigOperationDetailsFragment.createPayload(payload))
.navigateInFirstAttachedContext()
}
override fun openMainWithFinishMultisigTransaction(accountWasSwitched: Boolean) { override fun openMainWithFinishMultisigTransaction(accountWasSwitched: Boolean) {
val payload = MultisigCreatedBottomSheet.createPayload(MultisigCreatedPayload(accountWasSwitched)) val payload = MultisigCreatedBottomSheet.createPayload(MultisigCreatedPayload(accountWasSwitched))
openSplitScreenWithInstantAction(R.id.action_open_multisigCreatedDialog, nestedActionExtras = payload) openSplitScreenWithInstantAction(R.id.action_open_multisigCreatedDialog, nestedActionExtras = payload)
-1
View File
@@ -48,7 +48,6 @@ dependencies {
implementation project(':feature-deep-linking') implementation project(':feature-deep-linking')
implementation project(':feature-ahm-api') implementation project(':feature-ahm-api')
implementation project(':feature-gift-api') implementation project(':feature-gift-api')
implementation project(':feature-multisig:operations')
implementation kotlinDep implementation kotlinDep
@@ -53,7 +53,6 @@ import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi
import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi
import io.novafoundation.nova.feature_nft_api.NftFeatureApi import io.novafoundation.nova.feature_nft_api.NftFeatureApi
import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
@@ -176,8 +175,7 @@ interface AssetsFeatureComponent : AssetsFeatureApi {
BannersFeatureApi::class, BannersFeatureApi::class,
DeepLinkingFeatureApi::class, DeepLinkingFeatureApi::class,
ChainMigrationFeatureApi::class, ChainMigrationFeatureApi::class,
GiftFeatureApi::class, GiftFeatureApi::class
MultisigOperationsFeatureApi::class
] ]
) )
interface AssetsFeatureDependenciesComponent : AssetsFeatureDependencies interface AssetsFeatureDependenciesComponent : AssetsFeatureDependencies
@@ -5,6 +5,7 @@ import coil.ImageLoader
import com.google.gson.Gson import com.google.gson.Gson
import io.novafoundation.nova.common.address.AddressIconGenerator import io.novafoundation.nova.common.address.AddressIconGenerator
import io.novafoundation.nova.common.address.format.EthereumAddressFormat import io.novafoundation.nova.common.address.format.EthereumAddressFormat
import io.novafoundation.nova.common.data.config.GlobalConfigDataSource
import io.novafoundation.nova.common.data.memory.ComputationalCache import io.novafoundation.nova.common.data.memory.ComputationalCache
import io.novafoundation.nova.common.data.network.AppLinksProvider import io.novafoundation.nova.common.data.network.AppLinksProvider
import io.novafoundation.nova.common.data.network.HttpExceptionHandler import io.novafoundation.nova.common.data.network.HttpExceptionHandler
@@ -99,7 +100,6 @@ import io.novafoundation.nova.feature_account_api.data.fee.capability.CustomFeeC
import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase import io.novafoundation.nova.feature_ahm_api.domain.ChainMigrationInfoUseCase
import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase import io.novafoundation.nova.feature_gift_api.domain.GiftsAccountSupportedUseCase
import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase import io.novafoundation.nova.feature_gift_api.domain.AvailableGiftAssetsUseCase
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter
import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase import io.novafoundation.nova.feature_wallet_api.domain.SendUseCase
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin import io.novafoundation.nova.feature_wallet_api.presentation.mixin.amountChooser.AmountChooserMixin
import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin
@@ -241,8 +241,6 @@ interface AssetsFeatureDependencies {
val multisigPendingOperationsService: MultisigPendingOperationsService val multisigPendingOperationsService: MultisigPendingOperationsService
val multisigCallFormatter: MultisigCallFormatter
val automaticInteractionGate: AutomaticInteractionGate val automaticInteractionGate: AutomaticInteractionGate
val linkBuilderFactory: LinkBuilderFactory val linkBuilderFactory: LinkBuilderFactory
@@ -319,6 +317,8 @@ interface AssetsFeatureDependencies {
fun chainRegistry(): ChainRegistry fun chainRegistry(): ChainRegistry
fun globalConfigDataSource(): GlobalConfigDataSource
@Named(REMOTE_STORAGE_SOURCE) @Named(REMOTE_STORAGE_SOURCE)
fun remoteStorageSource(): StorageDataSource fun remoteStorageSource(): StorageDataSource
@@ -15,7 +15,6 @@ import io.novafoundation.nova.feature_crowdloan_api.di.CrowdloanFeatureApi
import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi import io.novafoundation.nova.feature_currency_api.di.CurrencyFeatureApi
import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi import io.novafoundation.nova.feature_deep_linking.di.DeepLinkingFeatureApi
import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi import io.novafoundation.nova.feature_gift_api.di.GiftFeatureApi
import io.novafoundation.nova.feature_multisig_operations.di.MultisigOperationsFeatureApi
import io.novafoundation.nova.feature_nft_api.NftFeatureApi import io.novafoundation.nova.feature_nft_api.NftFeatureApi
import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi import io.novafoundation.nova.feature_staking_api.di.StakingFeatureApi
import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi
@@ -52,7 +51,6 @@ class AssetsFeatureHolder @Inject constructor(
.deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java)) .deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java))
.chainMigrationFeatureApi(getFeature(ChainMigrationFeatureApi::class.java)) .chainMigrationFeatureApi(getFeature(ChainMigrationFeatureApi::class.java))
.giftFeatureApi(getFeature(GiftFeatureApi::class.java)) .giftFeatureApi(getFeature(GiftFeatureApi::class.java))
.multisigOperationsFeatureApi(getFeature(MultisigOperationsFeatureApi::class.java))
.build() .build()
return DaggerAssetsFeatureComponent.factory() return DaggerAssetsFeatureComponent.factory()
@@ -1,8 +1,10 @@
package io.novafoundation.nova.feature_assets.domain.bridge.multisig package io.novafoundation.nova.feature_assets.domain.bridge.multisig
import io.novafoundation.nova.common.address.AccountIdKey import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.address.fromHexOrNull
import io.novafoundation.nova.common.address.intoKey import io.novafoundation.nova.common.address.intoKey
import io.novafoundation.nova.common.address.toHexWithPrefix import io.novafoundation.nova.common.address.toHexWithPrefix
import io.novafoundation.nova.common.data.config.GlobalConfigDataSource
import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2 import io.novafoundation.nova.common.data.network.runtime.binding.WeightV2
import io.novafoundation.nova.common.di.scope.FeatureScope import io.novafoundation.nova.common.di.scope.FeatureScope
import io.novafoundation.nova.common.utils.callHash import io.novafoundation.nova.common.utils.callHash
@@ -10,7 +12,10 @@ import io.novafoundation.nova.feature_account_api.data.ethereum.transaction.Tran
import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService 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.ExtrinsicExecutionResult
import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk import io.novafoundation.nova.feature_account_api.data.extrinsic.execution.requireOk
import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigTimePoint
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash
import io.novafoundation.nova.feature_account_api.domain.multisig.intoCallHash import io.novafoundation.nova.feature_account_api.domain.multisig.intoCallHash
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE
import io.novafoundation.nova.runtime.ext.ChainGeneses import io.novafoundation.nova.runtime.ext.ChainGeneses
@@ -18,6 +23,8 @@ import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.getRuntime import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novafoundation.nova.runtime.storage.source.StorageDataSource import io.novafoundation.nova.runtime.storage.source.StorageDataSource
import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHexOrNull
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId
import java.math.BigInteger import java.math.BigInteger
import javax.inject.Inject import javax.inject.Inject
@@ -31,6 +38,19 @@ data class BridgeSignerState(
val approvalsSoFar: Int, val approvalsSoFar: Int,
) )
/** A pending Multisig.as_multi call against one of the bridge's own multisig accounts (a real
* user swap someone else submitted - NOT this wallet's own renewal signature, see
* BridgeSignerState for that), that this signatory hasn't approved yet. [call] is null when the
* off-chain indexer hasn't got the call content for this hash yet (or lookup failed) - callers
* must treat that as "cannot show/sign this one", never fall back to hash-only approval. */
data class PendingBridgeApproval(
val chain: Chain,
val callHash: CallHash,
val timePoint: MultisigTimePoint,
val approvalsCount: Int,
val call: GenericCall.Instance?,
)
interface BridgeMultisigInteractor { interface BridgeMultisigInteractor {
/** Null if the currently selected wallet isn't one of the 5 known bridge signatories - the /** Null if the currently selected wallet isn't one of the 5 known bridge signatories - the
@@ -66,6 +86,19 @@ interface BridgeMultisigInteractor {
/** Same as getWusdtRemainingAllowance but for the automation key's real USDT approval on /** Same as getWusdtRemainingAllowance but for the automation key's real USDT approval on
* Polkadot Asset Hub - gates wUSDT->USDT withdrawal auto-pay. */ * Polkadot Asset Hub - gates wUSDT->USDT withdrawal auto-pay. */
suspend fun getPolkadotUsdtRemainingAllowance(): BigInteger suspend fun getPolkadotUsdtRemainingAllowance(): BigInteger
/** Every pending swap-approval call (on either chain) against the bridge's own multisig
* accounts that this signatory hasn't approved yet - empty if the selected wallet isn't one
* of the 5 known signatories. Unlike the app's generic multisig-operations feature (which
* only tracks accounts formally added as a `MultisigMetaAccount`), this queries the bridge's
* hardcoded multisig addresses directly, since individual signatories use their own regular
* wallet to sign here - they never "are" the multisig account itself. */
suspend fun getPendingApprovals(): List<PendingBridgeApproval>
/** Approves (contributes this wallet's signature to) an existing pending call. Refuses to
* proceed if [PendingBridgeApproval.call] is null - approving a call whose content this
* device can't verify would be blind-signing, never acceptable for a multisig approval. */
suspend fun submitApproval(approval: PendingBridgeApproval): Result<ExtrinsicExecutionResult>
} }
@FeatureScope @FeatureScope
@@ -74,6 +107,8 @@ class RealBridgeMultisigInteractor @Inject constructor(
private val selectedAccountUseCase: SelectedAccountUseCase, private val selectedAccountUseCase: SelectedAccountUseCase,
@Named(REMOTE_STORAGE_SOURCE) private val storageDataSource: StorageDataSource, @Named(REMOTE_STORAGE_SOURCE) private val storageDataSource: StorageDataSource,
private val extrinsicService: ExtrinsicService, private val extrinsicService: ExtrinsicService,
private val bridgeMultisigOperationsApi: BridgeMultisigOperationsApi,
private val globalConfigDataSource: GlobalConfigDataSource,
) : BridgeMultisigInteractor { ) : BridgeMultisigInteractor {
override suspend fun getSignerState(): BridgeSignerState? { override suspend fun getSignerState(): BridgeSignerState? {
@@ -132,6 +167,109 @@ class RealBridgeMultisigInteractor @Inject constructor(
return queryRemainingAllowance(chain, BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID, BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS_POLKADOT) return queryRemainingAllowance(chain, BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID, BridgeMultisigConstants.AUTOMATION_KEY_ADDRESS_POLKADOT)
} }
override suspend fun getPendingApprovals(): List<PendingBridgeApproval> {
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
return listOf(
ChainGeneses.PEZKUWI_ASSET_HUB to BridgeMultisigConstants.MULTISIG_ADDRESS,
ChainGeneses.POLKADOT_ASSET_HUB to BridgeMultisigConstants.MULTISIG_ADDRESS_POLKADOT,
).flatMap { (chainGenesis, multisigAddress) ->
runCatching { getPendingApprovalsFor(chainGenesis, multisigAddress, metaAccount) }.getOrElse { emptyList() }
}
}
private suspend fun getPendingApprovalsFor(
chainGenesis: String,
multisigAddress: String,
metaAccount: MetaAccount,
): List<PendingBridgeApproval> {
val chain = chainRegistry.getChain(chainGenesis)
val myAccountId = metaAccount.accountIdIn(chain)?.intoKey() ?: return emptyList()
// Only the 5 known bridge signatories can ever have anything to approve here - same gate
// as getSignerStateFor, matching how the rest of this screen already scopes itself.
val isKnownSignatory = BridgeMultisigConstants.SIGNATORIES.any { it.address.toAccountId().intoKey() == myAccountId }
if (!isKnownSignatory) return emptyList()
val multisigAccountId = multisigAddress.toAccountId().intoKey()
val keys = storageDataSource.query(chain.id) {
runtime.metadata.bridgeMultisig().multisigs.keys(multisigAccountId)
}
if (keys.isEmpty()) return emptyList()
val entries = storageDataSource.query(chain.id) {
runtime.metadata.bridgeMultisig().multisigs.entries(keys)
}
val notYetApprovedByMe = entries.filterNot { (_, onChainMultisig) -> myAccountId in onChainMultisig.approvals }
if (notYetApprovedByMe.isEmpty()) return emptyList()
val callHashes = notYetApprovedByMe.keys.map { it.second }
val callDataByHash = fetchCallData(chain, multisigAccountId, callHashes)
return notYetApprovedByMe.map { (key, onChainMultisig) ->
val callHash = key.second
PendingBridgeApproval(
chain = chain,
callHash = callHash,
timePoint = onChainMultisig.timePoint,
approvalsCount = onChainMultisig.approvals.size,
call = callDataByHash[callHash],
)
}
}
private suspend fun fetchCallData(
chain: Chain,
multisigAccountId: AccountIdKey,
callHashes: List<CallHash>,
): Map<CallHash, GenericCall.Instance?> {
return runCatching {
val globalConfig = globalConfigDataSource.getGlobalConfig()
val request = BridgeOffChainCallDataRequest(multisigAccountId, callHashes, chain.id)
val response = bridgeMultisigOperationsApi.getCallDatas(globalConfig.multisigsApiUrl, request)
val runtime = chainRegistry.getRuntime(chain.id)
response.data.multisigOperations.nodes.mapNotNull { node ->
val hash = CallHash.fromHexOrNull(node.callHash) ?: return@mapNotNull null
val call = node.callData?.let { GenericCall.fromHexOrNull(runtime, it) }
hash to call
}.toMap()
}.getOrElse { emptyMap() }
}
override suspend fun submitApproval(approval: PendingBridgeApproval): Result<ExtrinsicExecutionResult> = runCatching {
val call = requireNotNull(approval.call) {
"Cannot approve a call whose content is unknown - refusing to blind-sign"
}
val metaAccount = selectedAccountUseCase.getSelectedMetaAccount()
val myAccountId = requireNotNull(metaAccount.accountIdIn(approval.chain)?.intoKey()) {
"Selected account has no address on ${approval.chain.name}"
}
val otherSignatories = BridgeMultisigConstants.SIGNATORIES
.map { it.address.toAccountId().intoKey() }
.filter { it != myAccountId }
.sortedBy { it.toHexWithPrefix() }
extrinsicService.submitExtrinsicAndAwaitExecution(
chain = approval.chain,
origin = TransactionOrigin.WalletWithId(metaAccount.id)
) {
val multisigCall = runtime.composeBridgeMultisigAsMulti(
threshold = BridgeMultisigConstants.THRESHOLD,
otherSignatories = otherSignatories,
maybeTimePoint = approval.timePoint,
call = call,
maxWeight = WeightV2(BigInteger.valueOf(1_000_000_000L), BigInteger.valueOf(200_000L)),
)
call(multisigCall)
}.getOrThrow().requireOk()
}
/** Shared by both legs - the only differences between the wUSDT (Pezkuwi) and USDT (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 * 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. */ * on-chain call shape (Assets.approve_transfer wrapped in Multisig.as_multi) is identical. */
@@ -0,0 +1,61 @@
package io.novafoundation.nova.feature_assets.domain.bridge.multisig
import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.address.toHexWithPrefix
import io.novafoundation.nova.common.data.network.subquery.SubQueryFilters
import io.novafoundation.nova.common.data.network.subquery.SubQueryNodes
import io.novafoundation.nova.common.data.network.subquery.SubQueryResponse
import io.novafoundation.nova.common.utils.HexString
import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novasama.substrate_sdk_android.extensions.requireHexPrefix
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Url
/**
* Minimal, Bridge-screen-local mirror of feature-account-impl's `FindMultisigsApi.getCallDatas`/
* `OffChainPendingMultisigInfoRequest`/`GetPedingMultisigOperationsResponse` - same module-
* boundary reason as BridgeMultisigRuntimeApi.kt (feature-assets can't depend on
* feature-account-impl). Only `getCallDatas` is mirrored - `findMultisigs` isn't needed here
* since the bridge's multisig account is already known (BridgeMultisigConstants), not discovered.
*/
interface BridgeMultisigOperationsApi {
@POST
suspend fun getCallDatas(
@Url url: String,
@Body body: BridgeOffChainCallDataRequest
): SubQueryResponse<BridgeCallDataResponse>
}
class BridgeOffChainCallDataRequest(
accountIdKey: AccountIdKey,
callHashes: Collection<CallHash>,
chainId: ChainId
) : SubQueryFilters {
@Transient
private val callHashesHex = callHashes.map { it.toHexWithPrefix() }
val query = """
query {
multisigOperations(filter: {
${"accountId" equalTo accountIdKey.toHexWithPrefix() }
${"status" equalToEnum "pending"}
${"callHash" presentIn callHashesHex}
${"chainId" equalTo chainId.requireHexPrefix()}
}) {
nodes {
callHash
callData
}
}
}
""".trimIndent()
}
class BridgeCallDataResponse(val multisigOperations: SubQueryNodes<BridgeOperationRemote>) {
class BridgeOperationRemote(val callHash: HexString, val callData: HexString?)
}
@@ -11,7 +11,6 @@ import io.novafoundation.nova.feature_assets.presentation.tokens.add.enterInfo.A
import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensPayload import io.novafoundation.nova.feature_assets.presentation.tokens.manage.chain.ManageChainTokensPayload
import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebPayload import io.novafoundation.nova.feature_assets.presentation.trade.webInterface.TradeWebPayload
import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterPayload import io.novafoundation.nova.feature_assets.presentation.transaction.filter.TransactionHistoryFilterPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload
import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload
import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload import io.novafoundation.nova.feature_wallet_api.presentation.model.AssetPayload
@@ -121,8 +120,6 @@ interface AssetsRouter {
fun openPendingMultisigOperations() fun openPendingMultisigOperations()
fun openMultisigOperationDetails(payload: MultisigOperationDetailsPayload)
fun openAssetDetailsFromDeepLink(payload: AssetPayload) fun openAssetDetailsFromDeepLink(payload: AssetPayload)
fun openGifts() fun openGifts()
@@ -222,12 +222,8 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
val rowBinding = ItemPendingSignatureRowBinding.inflate(container.inflater(), container, false) val rowBinding = ItemPendingSignatureRowBinding.inflate(container.inflater(), container, false)
rowBinding.pendingSignatureChainIcon.loadChainIcon(model.chain.icon, imageLoader) rowBinding.pendingSignatureChainIcon.loadChainIcon(model.chain.icon, imageLoader)
// primaryValue (e.g. "100K USDT") is the amount - the whole point of this row - so it rowBinding.pendingSignatureTitle.text = model.amountText
// takes the prominent title line; the call's own title/action name ("Transfer") isn't rowBinding.pendingSignatureSubtitle.text = "${model.destinationText}${model.chain.name}${model.progress}"
// shown at all here, only the destination (subtitle) is, to keep the row to two lines.
rowBinding.pendingSignatureTitle.text = model.primaryValue ?: model.title
rowBinding.pendingSignatureSubtitle.text = listOfNotNull(model.subtitle, model.chain.name, model.progress)
.joinToString(separator = "")
rowBinding.pendingSignatureSignButton.setOnClickListener { viewModel.pendingSignatureSignClicked(model) } rowBinding.pendingSignatureSignButton.setOnClickListener { viewModel.pendingSignatureSignClicked(model) }
container.addView(rowBinding.root) container.addView(rowBinding.root)
@@ -10,35 +10,28 @@ import io.novafoundation.nova.common.utils.formatting.format
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.data.mappers.mapChainToUi import io.novafoundation.nova.feature_account_api.data.mappers.mapChainToUi
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.data.multisig.model.MultisigAction
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation
import io.novafoundation.nova.feature_account_api.data.multisig.model.userAction
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.requireAccountIdKeyIn
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.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigConstants import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigConstants
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigInteractor 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.domain.bridge.multisig.BridgeSignerState
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.PendingBridgeApproval
import io.novafoundation.nova.feature_assets.presentation.AssetsRouter import io.novafoundation.nova.feature_assets.presentation.AssetsRouter
import io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionPayload import io.novafoundation.nova.feature_assets.presentation.bridge.execution.BridgeExecutionPayload
import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.tryParseTransfer
import io.novafoundation.nova.feature_multisig_operations.presentation.common.fromOperationId import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks
import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload
import io.novafoundation.nova.runtime.ext.ChainGeneses import io.novafoundation.nova.runtime.ext.ChainGeneses
import io.novafoundation.nova.runtime.ext.addressOf import io.novafoundation.nova.runtime.ext.addressOf
import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard 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.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.text.NumberFormat
/** /**
* DOT<->HEZ used to be a second pair here, retired 2026-07 in favor of the multisig-custodied * DOT<->HEZ used to be a second pair here, retired 2026-07 in favor of the multisig-custodied
@@ -62,9 +55,7 @@ class BridgeViewModel(
private val assetIconProvider: AssetIconProvider, private val assetIconProvider: AssetIconProvider,
private val walletInteractor: WalletInteractor, private val walletInteractor: WalletInteractor,
private val bridgeMultisigInteractor: BridgeMultisigInteractor, private val bridgeMultisigInteractor: BridgeMultisigInteractor,
private val multisigPendingOperationsService: MultisigPendingOperationsService, private val assetSourceRegistry: AssetSourceRegistry,
private val multisigCallFormatter: MultisigCallFormatter,
private val selectedAccountUseCase: SelectedAccountUseCase,
) : BaseViewModel() { ) : BaseViewModel() {
companion object { companion object {
@@ -483,32 +474,50 @@ class BridgeViewModel(
fun refreshPendingSignatures() { fun refreshPendingSignatures() {
launch { launch {
val account = selectedAccountUseCase.getSelectedMetaAccount() val models = bridgeMultisigInteractor.getPendingApprovals()
.mapNotNull { it.toPendingSignatureUiOrNull() }
val models = multisigPendingOperationsService.pendingOperations().first()
.filter { it.userAction() is MultisigAction.CanApprove }
.map { it.toPendingSignatureUi(account) }
_pendingSignatures.postValue(models) _pendingSignatures.postValue(models)
} }
} }
private var pendingApprovalSubmission: Job? = null
fun pendingSignatureSignClicked(model: PendingSignatureModel) { fun pendingSignatureSignClicked(model: PendingSignatureModel) {
val operationPayload = MultisigOperationPayload.fromOperationId(model.id) if (pendingApprovalSubmission?.isActive == true) return
router.openMultisigOperationDetails(MultisigOperationDetailsPayload(operationPayload))
pendingApprovalSubmission = launch {
bridgeMultisigInteractor.submitApproval(model.approval)
.onFailure { showError(it.message ?: resourceManager.getString(R.string.bridge_sign_error)) }
refreshPendingSignatures()
}
} }
private suspend fun PendingMultisigOperation.toPendingSignatureUi(selectedAccount: MetaAccount): PendingSignatureModel { /** Null whenever the call's actual content couldn't be recovered or isn't a plain transfer
val initialOrigin = selectedAccount.requireAccountIdKeyIn(chain) * this screen knows how to preview - never shown/approvable in that case (see
val formattedCall = multisigCallFormatter.formatPreview(call, initialOrigin, chain) * BridgeMultisigInteractor.submitApproval's own refusal to blind-sign for why this isn't
* just a display-only gap: an unparseable row would have no safe "Sign" action anyway). */
private suspend fun PendingBridgeApproval.toPendingSignatureUiOrNull(): PendingSignatureModel? {
val call = call ?: return null
val assetId = if (chain.id == ChainGeneses.POLKADOT_ASSET_HUB) {
BridgeMultisigConstants.POLKADOT_USDT_ASSET_ID
} else {
BridgeMultisigConstants.WUSDT_ASSET_ID
}
val asset = chain.assetsById[assetId] ?: return null
val parsed = assetSourceRegistry.sourceFor(asset).transfers.tryParseTransfer(call, chain) ?: return null
val decimalAmount = asset.amountFromPlanks(parsed.amount.amount)
val amountText = "${NumberFormat.getNumberInstance().format(decimalAmount)} ${asset.symbol.value}"
return PendingSignatureModel( return PendingSignatureModel(
id = operationId, approval = this,
chain = mapChainToUi(chain), chain = mapChainToUi(chain),
title = formattedCall.title, amountText = amountText,
subtitle = formattedCall.subtitle, destinationText = chain.addressOf(parsed.destination),
primaryValue = formattedCall.primaryValue, progress = resourceManager.getString(R.string.multisig_operations_progress, approvalsCount.format(), BridgeMultisigConstants.THRESHOLD.format())
progress = resourceManager.getString(R.string.multisig_operations_progress, approvals.size.format(), threshold.format())
) )
} }
@@ -1,13 +1,12 @@
package io.novafoundation.nova.feature_assets.presentation.bridge package io.novafoundation.nova.feature_assets.presentation.bridge
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperationId
import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi import io.novafoundation.nova.feature_account_api.presenatation.chain.ChainUi
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.PendingBridgeApproval
data class PendingSignatureModel( data class PendingSignatureModel(
val id: PendingMultisigOperationId, val approval: PendingBridgeApproval,
val chain: ChainUi, val chain: ChainUi,
val title: String, val amountText: String,
val subtitle: String?, val destinationText: String,
val primaryValue: CharSequence?,
val progress: String, val progress: String,
) )
@@ -6,19 +6,21 @@ import androidx.lifecycle.ViewModelProvider
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.multibindings.IntoMap import dagger.multibindings.IntoMap
import io.novafoundation.nova.common.data.config.GlobalConfigDataSource
import io.novafoundation.nova.common.data.network.NetworkApiCreator
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.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_account_api.data.extrinsic.ExtrinsicService import io.novafoundation.nova.feature_account_api.data.extrinsic.ExtrinsicService
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase import io.novafoundation.nova.feature_account_api.domain.interfaces.SelectedAccountUseCase
import io.novafoundation.nova.feature_assets.domain.WalletInteractor import io.novafoundation.nova.feature_assets.domain.WalletInteractor
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigInteractor import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigInteractor
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.BridgeMultisigOperationsApi
import io.novafoundation.nova.feature_assets.domain.bridge.multisig.RealBridgeMultisigInteractor 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.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry
import io.novafoundation.nova.runtime.di.REMOTE_STORAGE_SOURCE 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 io.novafoundation.nova.runtime.storage.source.StorageDataSource
@@ -27,14 +29,28 @@ import javax.inject.Named
@Module(includes = [ViewModelModule::class]) @Module(includes = [ViewModelModule::class])
class BridgeModule { class BridgeModule {
@Provides
fun provideBridgeMultisigOperationsApi(apiCreator: NetworkApiCreator): BridgeMultisigOperationsApi {
return apiCreator.create(BridgeMultisigOperationsApi::class.java)
}
@Provides @Provides
fun provideBridgeMultisigInteractor( fun provideBridgeMultisigInteractor(
chainRegistry: ChainRegistry, chainRegistry: ChainRegistry,
selectedAccountUseCase: SelectedAccountUseCase, selectedAccountUseCase: SelectedAccountUseCase,
@Named(REMOTE_STORAGE_SOURCE) storageDataSource: StorageDataSource, @Named(REMOTE_STORAGE_SOURCE) storageDataSource: StorageDataSource,
extrinsicService: ExtrinsicService extrinsicService: ExtrinsicService,
bridgeMultisigOperationsApi: BridgeMultisigOperationsApi,
globalConfigDataSource: GlobalConfigDataSource,
): BridgeMultisigInteractor { ): BridgeMultisigInteractor {
return RealBridgeMultisigInteractor(chainRegistry, selectedAccountUseCase, storageDataSource, extrinsicService) return RealBridgeMultisigInteractor(
chainRegistry,
selectedAccountUseCase,
storageDataSource,
extrinsicService,
bridgeMultisigOperationsApi,
globalConfigDataSource,
)
} }
@Provides @Provides
@@ -47,9 +63,7 @@ class BridgeModule {
assetIconProvider: AssetIconProvider, assetIconProvider: AssetIconProvider,
walletInteractor: WalletInteractor, walletInteractor: WalletInteractor,
bridgeMultisigInteractor: BridgeMultisigInteractor, bridgeMultisigInteractor: BridgeMultisigInteractor,
multisigPendingOperationsService: MultisigPendingOperationsService, assetSourceRegistry: AssetSourceRegistry,
multisigCallFormatter: MultisigCallFormatter,
selectedAccountUseCase: SelectedAccountUseCase,
): ViewModel { ): ViewModel {
return BridgeViewModel( return BridgeViewModel(
router, router,
@@ -58,9 +72,7 @@ class BridgeModule {
assetIconProvider, assetIconProvider,
walletInteractor, walletInteractor,
bridgeMultisigInteractor, bridgeMultisigInteractor,
multisigPendingOperationsService, assetSourceRegistry,
multisigCallFormatter,
selectedAccountUseCase,
) )
} }