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
-1
View File
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -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<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
@@ -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<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)
* 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. */
@@ -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.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()
@@ -222,12 +222,8 @@ class BridgeFragment : BaseFragment<BridgeViewModel, FragmentBridgeBinding>() {
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)
@@ -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())
)
}
@@ -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,
)
@@ -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,
)
}