From e716e6e277bdfb8014e896120782849a09cdb6a2 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Fri, 17 Jul 2026 16:42:40 -0700 Subject: [PATCH] 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). --- .../root/navigation/navigators/Navigator.kt | 8 - feature-assets/build.gradle | 1 - .../di/AssetsFeatureComponent.kt | 4 +- .../di/AssetsFeatureDependencies.kt | 6 +- .../feature_assets/di/AssetsFeatureHolder.kt | 2 - .../multisig/BridgeMultisigInteractor.kt | 138 ++++++++++++++++++ .../multisig/BridgeMultisigOperationsApi.kt | 61 ++++++++ .../presentation/AssetsRouter.kt | 3 - .../presentation/bridge/BridgeFragment.kt | 8 +- .../presentation/bridge/BridgeViewModel.kt | 69 +++++---- .../bridge/PendingSignatureModel.kt | 9 +- .../presentation/bridge/di/BridgeModule.kt | 32 ++-- 12 files changed, 270 insertions(+), 71 deletions(-) create mode 100644 feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigOperationsApi.kt diff --git a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt index e5a970b2..93aa25fe 100644 --- a/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt +++ b/app/src/main/java/io/novafoundation/nova/app/root/navigation/navigators/Navigator.kt @@ -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_multisig_operations.presentation.created.MultisigCreatedBottomSheet 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.presentation.welcome.WelcomeFragment import io.novafoundation.nova.feature_swap_api.presentation.model.SwapSettingsPayload @@ -985,12 +983,6 @@ class Navigator( .navigateInFirstAttachedContext() } - override fun openMultisigOperationDetails(payload: MultisigOperationDetailsPayload) { - navigationBuilder().action(R.id.action_multisigOperationDetailsFragment) - .setArgs(MultisigOperationDetailsFragment.createPayload(payload)) - .navigateInFirstAttachedContext() - } - override fun openMainWithFinishMultisigTransaction(accountWasSwitched: Boolean) { val payload = MultisigCreatedBottomSheet.createPayload(MultisigCreatedPayload(accountWasSwitched)) openSplitScreenWithInstantAction(R.id.action_open_multisigCreatedDialog, nestedActionExtras = payload) diff --git a/feature-assets/build.gradle b/feature-assets/build.gradle index eb8ffa47..5d2c584f 100644 --- a/feature-assets/build.gradle +++ b/feature-assets/build.gradle @@ -48,7 +48,6 @@ dependencies { implementation project(':feature-deep-linking') implementation project(':feature-ahm-api') implementation project(':feature-gift-api') - implementation project(':feature-multisig:operations') implementation kotlinDep diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt index d71111c6..2b7f4c82 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureComponent.kt @@ -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_deep_linking.di.DeepLinkingFeatureApi 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_staking_api.di.StakingFeatureApi import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi @@ -176,8 +175,7 @@ interface AssetsFeatureComponent : AssetsFeatureApi { BannersFeatureApi::class, DeepLinkingFeatureApi::class, ChainMigrationFeatureApi::class, - GiftFeatureApi::class, - MultisigOperationsFeatureApi::class + GiftFeatureApi::class ] ) interface AssetsFeatureDependenciesComponent : AssetsFeatureDependencies diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureDependencies.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureDependencies.kt index 90b388c5..7d234f46 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureDependencies.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureDependencies.kt @@ -5,6 +5,7 @@ import coil.ImageLoader import com.google.gson.Gson import io.novafoundation.nova.common.address.AddressIconGenerator 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.network.AppLinksProvider 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_gift_api.domain.GiftsAccountSupportedUseCase 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.presentation.mixin.amountChooser.AmountChooserMixin import io.novafoundation.nova.feature_wallet_api.presentation.mixin.fee.FeeLoaderMixin @@ -241,8 +241,6 @@ interface AssetsFeatureDependencies { val multisigPendingOperationsService: MultisigPendingOperationsService - val multisigCallFormatter: MultisigCallFormatter - val automaticInteractionGate: AutomaticInteractionGate val linkBuilderFactory: LinkBuilderFactory @@ -319,6 +317,8 @@ interface AssetsFeatureDependencies { fun chainRegistry(): ChainRegistry + fun globalConfigDataSource(): GlobalConfigDataSource + @Named(REMOTE_STORAGE_SOURCE) fun remoteStorageSource(): StorageDataSource diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureHolder.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureHolder.kt index 5b176d94..2f0ac8c0 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureHolder.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/di/AssetsFeatureHolder.kt @@ -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_deep_linking.di.DeepLinkingFeatureApi 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_staking_api.di.StakingFeatureApi import io.novafoundation.nova.feature_swap_api.di.SwapFeatureApi @@ -52,7 +51,6 @@ class AssetsFeatureHolder @Inject constructor( .deepLinkingFeatureApi(getFeature(DeepLinkingFeatureApi::class.java)) .chainMigrationFeatureApi(getFeature(ChainMigrationFeatureApi::class.java)) .giftFeatureApi(getFeature(GiftFeatureApi::class.java)) - .multisigOperationsFeatureApi(getFeature(MultisigOperationsFeatureApi::class.java)) .build() return DaggerAssetsFeatureComponent.factory() diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigInteractor.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigInteractor.kt index 83910ef6..a758bfa3 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigInteractor.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigInteractor.kt @@ -1,8 +1,10 @@ package io.novafoundation.nova.feature_assets.domain.bridge.multisig 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.toHexWithPrefix +import io.novafoundation.nova.common.data.config.GlobalConfigDataSource 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 @@ -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.execution.ExtrinsicExecutionResult 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.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.runtime.di.REMOTE_STORAGE_SOURCE 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.getRuntime 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 java.math.BigInteger import javax.inject.Inject @@ -31,6 +38,19 @@ data class BridgeSignerState( 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 { /** 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 * Polkadot Asset Hub - gates wUSDT->USDT withdrawal auto-pay. */ 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 + + /** 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 } @FeatureScope @@ -74,6 +107,8 @@ class RealBridgeMultisigInteractor @Inject constructor( private val selectedAccountUseCase: SelectedAccountUseCase, @Named(REMOTE_STORAGE_SOURCE) private val storageDataSource: StorageDataSource, private val extrinsicService: ExtrinsicService, + private val bridgeMultisigOperationsApi: BridgeMultisigOperationsApi, + private val globalConfigDataSource: GlobalConfigDataSource, ) : BridgeMultisigInteractor { 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) } + override suspend fun getPendingApprovals(): List { + 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 { + 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, + ): Map { + 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 = 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) * 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. */ diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigOperationsApi.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigOperationsApi.kt new file mode 100644 index 00000000..b3155912 --- /dev/null +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/domain/bridge/multisig/BridgeMultisigOperationsApi.kt @@ -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 +} + +class BridgeOffChainCallDataRequest( + accountIdKey: AccountIdKey, + callHashes: Collection, + 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) { + + class BridgeOperationRemote(val callHash: HexString, val callData: HexString?) +} diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt index dadbfede..e9a8f688 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/AssetsRouter.kt @@ -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.trade.webInterface.TradeWebPayload 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_wallet_api.presentation.model.AssetPayload @@ -121,8 +120,6 @@ interface AssetsRouter { fun openPendingMultisigOperations() - fun openMultisigOperationDetails(payload: MultisigOperationDetailsPayload) - fun openAssetDetailsFromDeepLink(payload: AssetPayload) fun openGifts() diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt index 4c8609fe..c45270ae 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeFragment.kt @@ -222,12 +222,8 @@ class BridgeFragment : BaseFragment() { val rowBinding = ItemPendingSignatureRowBinding.inflate(container.inflater(), container, false) rowBinding.pendingSignatureChainIcon.loadChainIcon(model.chain.icon, imageLoader) - // primaryValue (e.g. "100K USDT") is the amount - the whole point of this row - so it - // takes the prominent title line; the call's own title/action name ("Transfer") isn't - // 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.pendingSignatureTitle.text = model.amountText + rowBinding.pendingSignatureSubtitle.text = "${model.destinationText} • ${model.chain.name} • ${model.progress}" rowBinding.pendingSignatureSignButton.setOnClickListener { viewModel.pendingSignatureSignClicked(model) } container.addView(rowBinding.root) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt index 4d7e39b9..e92559a1 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/BridgeViewModel.kt @@ -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.view.ButtonState 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_assets.R 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.BridgeMultisigInteractor 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.bridge.execution.BridgeExecutionPayload -import io.novafoundation.nova.feature_multisig_operations.presentation.callFormatting.MultisigCallFormatter -import io.novafoundation.nova.feature_multisig_operations.presentation.common.MultisigOperationPayload -import io.novafoundation.nova.feature_multisig_operations.presentation.common.fromOperationId -import io.novafoundation.nova.feature_multisig_operations.presentation.details.general.MultisigOperationDetailsPayload +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.AssetSourceRegistry +import io.novafoundation.nova.feature_wallet_api.data.network.blockhain.assets.tranfers.tryParseTransfer +import io.novafoundation.nova.feature_wallet_api.domain.model.amountFromPlanks import io.novafoundation.nova.runtime.ext.ChainGeneses import io.novafoundation.nova.runtime.ext.addressOf import io.novafoundation.nova.runtime.ext.displayNameWithAssetStandard import io.novafoundation.nova.runtime.multiNetwork.ChainRegistry import io.novasama.substrate_sdk_android.ss58.SS58Encoder.toAccountId import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.math.BigDecimal 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 @@ -62,9 +55,7 @@ class BridgeViewModel( private val assetIconProvider: AssetIconProvider, private val walletInteractor: WalletInteractor, private val bridgeMultisigInteractor: BridgeMultisigInteractor, - private val multisigPendingOperationsService: MultisigPendingOperationsService, - private val multisigCallFormatter: MultisigCallFormatter, - private val selectedAccountUseCase: SelectedAccountUseCase, + private val assetSourceRegistry: AssetSourceRegistry, ) : BaseViewModel() { companion object { @@ -483,32 +474,50 @@ class BridgeViewModel( fun refreshPendingSignatures() { launch { - val account = selectedAccountUseCase.getSelectedMetaAccount() - - val models = multisigPendingOperationsService.pendingOperations().first() - .filter { it.userAction() is MultisigAction.CanApprove } - .map { it.toPendingSignatureUi(account) } + val models = bridgeMultisigInteractor.getPendingApprovals() + .mapNotNull { it.toPendingSignatureUiOrNull() } _pendingSignatures.postValue(models) } } + private var pendingApprovalSubmission: Job? = null + fun pendingSignatureSignClicked(model: PendingSignatureModel) { - val operationPayload = MultisigOperationPayload.fromOperationId(model.id) - router.openMultisigOperationDetails(MultisigOperationDetailsPayload(operationPayload)) + if (pendingApprovalSubmission?.isActive == true) return + + 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 { - val initialOrigin = selectedAccount.requireAccountIdKeyIn(chain) - val formattedCall = multisigCallFormatter.formatPreview(call, initialOrigin, chain) + /** Null whenever the call's actual content couldn't be recovered or isn't a plain transfer + * this screen knows how to preview - never shown/approvable in that case (see + * 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( - id = operationId, + approval = this, chain = mapChainToUi(chain), - title = formattedCall.title, - subtitle = formattedCall.subtitle, - primaryValue = formattedCall.primaryValue, - progress = resourceManager.getString(R.string.multisig_operations_progress, approvals.size.format(), threshold.format()) + amountText = amountText, + destinationText = chain.addressOf(parsed.destination), + progress = resourceManager.getString(R.string.multisig_operations_progress, approvalsCount.format(), BridgeMultisigConstants.THRESHOLD.format()) ) } diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/PendingSignatureModel.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/PendingSignatureModel.kt index 4fe4e17e..3ab928e5 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/PendingSignatureModel.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/PendingSignatureModel.kt @@ -1,13 +1,12 @@ 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_assets.domain.bridge.multisig.PendingBridgeApproval data class PendingSignatureModel( - val id: PendingMultisigOperationId, + val approval: PendingBridgeApproval, val chain: ChainUi, - val title: String, - val subtitle: String?, - val primaryValue: CharSequence?, + val amountText: String, + val destinationText: String, val progress: String, ) diff --git a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt index 915711b0..32401cef 100644 --- a/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt +++ b/feature-assets/src/main/java/io/novafoundation/nova/feature_assets/presentation/bridge/di/BridgeModule.kt @@ -6,19 +6,21 @@ import androidx.lifecycle.ViewModelProvider import dagger.Module import dagger.Provides 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.ViewModelModule import io.novafoundation.nova.common.presentation.AssetIconProvider 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.multisig.MultisigPendingOperationsService 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.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.presentation.AssetsRouter 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.multiNetwork.ChainRegistry import io.novafoundation.nova.runtime.storage.source.StorageDataSource @@ -27,14 +29,28 @@ import javax.inject.Named @Module(includes = [ViewModelModule::class]) class BridgeModule { + @Provides + fun provideBridgeMultisigOperationsApi(apiCreator: NetworkApiCreator): BridgeMultisigOperationsApi { + return apiCreator.create(BridgeMultisigOperationsApi::class.java) + } + @Provides fun provideBridgeMultisigInteractor( chainRegistry: ChainRegistry, selectedAccountUseCase: SelectedAccountUseCase, @Named(REMOTE_STORAGE_SOURCE) storageDataSource: StorageDataSource, - extrinsicService: ExtrinsicService + extrinsicService: ExtrinsicService, + bridgeMultisigOperationsApi: BridgeMultisigOperationsApi, + globalConfigDataSource: GlobalConfigDataSource, ): BridgeMultisigInteractor { - return RealBridgeMultisigInteractor(chainRegistry, selectedAccountUseCase, storageDataSource, extrinsicService) + return RealBridgeMultisigInteractor( + chainRegistry, + selectedAccountUseCase, + storageDataSource, + extrinsicService, + bridgeMultisigOperationsApi, + globalConfigDataSource, + ) } @Provides @@ -47,9 +63,7 @@ class BridgeModule { assetIconProvider: AssetIconProvider, walletInteractor: WalletInteractor, bridgeMultisigInteractor: BridgeMultisigInteractor, - multisigPendingOperationsService: MultisigPendingOperationsService, - multisigCallFormatter: MultisigCallFormatter, - selectedAccountUseCase: SelectedAccountUseCase, + assetSourceRegistry: AssetSourceRegistry, ): ViewModel { return BridgeViewModel( router, @@ -58,9 +72,7 @@ class BridgeModule { assetIconProvider, walletInteractor, bridgeMultisigInteractor, - multisigPendingOperationsService, - multisigCallFormatter, - selectedAccountUseCase, + assetSourceRegistry, ) }