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, ) }