Support first-signer case for /open/multisigOperation deep link

The pending-operation model assumed a call already exists on-chain
(Multisig.Multisigs entry with a Timepoint) before it could be
reviewed or signed. A signatory who is the first to see a brand-new
call - reachable via the deep link before anyone has submitted
anything - hit "operation not found" and the screen refused to open,
even though the deep link already carried the call's data in its
callData param (previously decoded only to format Executed/Rejected
dialog text, then discarded for the Active case that needed it).

- PendingMultisigOperation.timePoint/depositor are now nullable to
  represent "not yet submitted"; composeMultisigAsMulti's
  maybeTimePoint parameter already accepted null (used for ordinary
  first-time multisig-origin submissions via MultisigSigner), so the
  pallet-call layer needed no changes.
- New PendingMultisigOperation.notYetSubmitted(...) factory and
  MultisigOperationDetailsInteractor.buildNotYetSubmittedOperation()
  build a synthetic operation from the deep link's callData, hash
  verified against the URL's callHash rather than trusted blindly.
- OperationIsStillPendingValidation now skips its on-chain
  "still pending" check for a not-yet-submitted operation, since it
  would otherwise always fail and block the very first submission.
- MultisigOperationPayload carries the callData through so both the
  details screen and the full-details screen (independently reachable
  via "Call Details", since that button is available whenever call
  data is known) can construct the synthetic operation.
This commit is contained in:
2026-07-13 23:03:36 -07:00
parent 438a4510c8
commit 68a5b3ed9e
8 changed files with 196 additions and 21 deletions
@@ -18,6 +18,7 @@ import io.novafoundation.nova.feature_account_api.data.multisig.composeMultisigC
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.PendingMultisigOperationId
import io.novafoundation.nova.feature_account_api.data.multisig.model.notYetSubmitted
import io.novafoundation.nova.feature_account_api.data.multisig.model.userAction
import io.novafoundation.nova.feature_account_api.data.multisig.repository.MultisigOperationLocalCallRepository
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountRepository
@@ -37,10 +38,12 @@ import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
import io.novafoundation.nova.runtime.multiNetwork.chain.model.ChainId
import io.novafoundation.nova.runtime.multiNetwork.getRuntime
import io.novasama.substrate_sdk_android.extensions.toHexString
import io.novasama.substrate_sdk_android.runtime.definitions.types.fromHex
import io.novasama.substrate_sdk_android.runtime.definitions.types.generics.GenericCall
import io.novasama.substrate_sdk_android.runtime.extrinsic.builder.ExtrinsicBuilder
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
interface MultisigOperationDetailsInteractor {
@@ -67,6 +70,14 @@ interface MultisigOperationDetailsInteractor {
suspend fun callDataAsString(call: GenericCall.Instance, chainId: ChainId): String
suspend fun isOperationAvailable(operationId: PendingMultisigOperationId): Boolean
/**
* Builds a [PendingMultisigOperation] for a call that has never been submitted on-chain yet
* (no `Multisig.Multisigs` entry exists) - the "first signer" deep-link case. [callDataHex]
* must decode to a call whose hash matches [operationId]'s `callHash`; this is verified here
* rather than trusted blindly, since [callDataHex] ultimately comes from a URL.
*/
suspend fun buildNotYetSubmittedOperation(operationId: PendingMultisigOperationId, callDataHex: String): PendingMultisigOperation
}
private const val SKIP_REJECT_CONFIRMATION_KEY = "SKIP_REJECT_CONFIRMATION_KEY"
@@ -118,6 +129,30 @@ class RealMultisigOperationDetailsInteractor @Inject constructor(
return multisigDetailsRepository.hasMultisigOperation(chain, metaAccount.requireAccountIdKeyIn(chain), callHash)
}
override suspend fun buildNotYetSubmittedOperation(
operationId: PendingMultisigOperationId,
callDataHex: String
): PendingMultisigOperation {
val chain = chainRegistry.getChain(operationId.chainId)
val metaAccount = accountRepository.getMetaAccount(operationId.metaId) as MultisigMetaAccount
val runtime = chainRegistry.getRuntime(chain.id)
val call = GenericCall.fromHex(runtime, callDataHex)
val computedHash = call.callHash(runtime)
val expectedHash = operationId.callHash.intoCallHash()
require(computedHash.contentEquals(expectedHash.value)) {
"Call data does not match this operation: decoded hash does not equal the expected call hash"
}
return PendingMultisigOperation.notYetSubmitted(
multisigMetaAccount = metaAccount,
call = call,
callHash = expectedHash,
chain = chain,
timestamp = System.currentTimeMillis().milliseconds
)
}
override suspend fun estimateActionFee(operation: PendingMultisigOperation): Fee? {
val action = operation.userAction().toInternalAction() ?: return null
@@ -223,10 +258,18 @@ class RealMultisigOperationDetailsInteractor @Inject constructor(
private suspend fun ExtrinsicBuilder.reject(operation: PendingMultisigOperation) {
val selectedAccount = accountRepository.getSelectedMetaAccount() as MultisigMetaAccount
// cancel_as_multi can only ever cancel an operation that already exists on-chain (you
// can't cancel something nobody has proposed yet) - userAction() already never returns
// CanReject for a not-yet-submitted operation (its depositor is null), so this should be
// unreachable in practice; checkNotNull fails loudly instead of silently misencoding a
// null timepoint if that invariant is ever violated.
val timePoint = checkNotNull(operation.timePoint) {
"Cannot reject an operation that has not been submitted on-chain yet"
}
val approveCall = runtime.composeMultisigCancelAsMulti(
multisigMetaAccount = selectedAccount,
maybeTimePoint = operation.timePoint,
maybeTimePoint = timePoint,
callHash = operation.callHash
)
@@ -12,6 +12,15 @@ class OperationIsStillPendingValidation @Inject constructor(
) : ApproveMultisigOperationValidation {
override suspend fun validate(value: ApproveMultisigOperationValidationPayload): ValidationStatus<ApproveMultisigOperationValidationFailure> {
// A not-yet-submitted operation (first signer, reached via deep link before anyone has
// signed - see PendingMultisigOperation.notYetSubmitted) has, by definition, no
// Multisig.Multisigs entry yet - that's exactly what this submission is about to
// create. Checking "is it still pending" only makes sense for an operation that was
// already pending; skip it here rather than failing a legitimate first submission.
if (!value.operation.isSubmittedOnChain) {
return ValidationStatus.Valid()
}
val hasPendingCallHash = multisigValidationsRepository.hasPendingCallHash(value.chain.id, value.multisigAccountId, value.operation.callHash)
return hasPendingCallHash isTrueOrError {
@@ -8,16 +8,26 @@ import kotlinx.parcelize.Parcelize
class MultisigOperationPayload(
val chainId: String,
val metaId: Long,
val callHash: String
val callHash: String,
/**
* Call data for a call that may not exist on-chain yet (the "first signer" deep-link case -
* see `MultisigOperationDetailsDeepLinkHandler`). Null for the normal case where the details
* screen sources everything from the chain-storage-driven sync service.
*/
val notSubmittedCallData: String? = null,
) : Parcelable {
companion object;
}
fun MultisigOperationPayload.Companion.fromOperationId(operationId: PendingMultisigOperationId): MultisigOperationPayload {
fun MultisigOperationPayload.Companion.fromOperationId(
operationId: PendingMultisigOperationId,
notSubmittedCallData: String? = null,
): MultisigOperationPayload {
return MultisigOperationPayload(
chainId = operationId.chainId,
metaId = operationId.metaId,
callHash = operationId.callHash
callHash = operationId.callHash,
notSubmittedCallData = notSubmittedCallData,
)
}
@@ -67,7 +67,14 @@ class MultisigOperationDetailsDeepLinkHandler(
null,
MultisigOperationDeepLinkData.State.Active -> {
val operationIdentifier = PendingMultisigOperationId.create(multisigMetaAccount, chain, callHash.removeHexPrefix())
val operationPayload = MultisigOperationPayload.fromOperationId(operationIdentifier)
// Threaded through so the details screen can build a synthetic operation for a
// call that hasn't been submitted on-chain yet (the first-signer case) - it used
// to be decoded here only to format Executed/Rejected dialog text, then silently
// dropped for the Active case that would actually need it.
val operationPayload = MultisigOperationPayload.fromOperationId(
operationIdentifier,
notSubmittedCallData = data.getRawCallData()
)
router.openMultisigOperationDetails(
MultisigOperationDetailsPayload(
operationPayload,
@@ -117,4 +124,8 @@ class MultisigOperationDetailsDeepLinkHandler(
val runtime = chainRegistry.getRuntime(chainId)
return GenericCall.fromHex(runtime, callDataString)
}
private fun Uri.getRawCallData(): String? {
return getQueryParameter(MultisigOperationDeepLinkConfigurator.CALL_DATA_PARAM)
}
}
@@ -11,6 +11,7 @@ import io.novafoundation.nova.common.utils.launchUnit
import io.novafoundation.nova.common.utils.withSafeLoading
import io.novafoundation.nova.common.view.bottomSheet.description.DescriptionBottomSheetLauncher
import io.novafoundation.nova.feature_account_api.data.multisig.MultisigPendingOperationsService
import io.novafoundation.nova.feature_account_api.data.multisig.model.PendingMultisigOperation
import io.novafoundation.nova.feature_account_api.domain.interfaces.AccountUIUseCase
import io.novafoundation.nova.feature_account_api.presenatation.actions.ExternalActions
import io.novafoundation.nova.feature_account_api.presenatation.actions.showAddressActions
@@ -24,10 +25,12 @@ import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.
import io.novafoundation.nova.feature_wallet_api.presentation.formatters.amount.formatAmountToAmountModel
import io.novafoundation.nova.runtime.ext.fullId
import io.novafoundation.nova.runtime.ext.utilityAsset
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
private const val CALL_HASH_SHOWN_SYMBOLS = 9
@@ -52,16 +55,38 @@ class MultisigOperationFullDetailsViewModel(
router.back()
}
private val operationFlow = multisigOperationsService.pendingOperationFlow(payload.toOperationId())
.filterNotNull()
.shareInBackground()
// Mirrors MultisigOperationDetailsViewModel's not-yet-submitted handling - this screen is
// reachable (via "Call Details") for a first-signer deep-link operation too, since
// callDetailsVisible there is based on `call != null`, which is always true here.
private val notYetSubmittedOperationFlow = MutableStateFlow<PendingMultisigOperation?>(null)
private val operationFlow = merge(
multisigOperationsService.pendingOperationFlow(payload.toOperationId()).filterNotNull(),
notYetSubmittedOperationFlow.filterNotNull()
).shareInBackground()
init {
checkOperationAvailability()
}
private fun checkOperationAvailability() = launchUnit {
val operationId = payload.toOperationId()
if (interactor.isOperationAvailable(operationId)) return@launchUnit
val notSubmittedCallData = payload.notSubmittedCallData ?: return@launchUnit
val builtOperation = runCatching {
interactor.buildNotYetSubmittedOperation(operationId, notSubmittedCallData)
}.getOrNull() ?: return@launchUnit
notYetSubmittedOperationFlow.value = builtOperation
}
private val tokenFlow = operationFlow.map {
arbitraryTokenUseCase.getToken(it.chain.utilityAsset.fullId)
}.shareInBackground()
val depositorAccountModel = operationFlow.map {
accountUIUseCase.getAccountModel(it.depositor, it.chain)
val depositorAccountModel = operationFlow.map { operation ->
operation.depositor?.let { accountUIUseCase.getAccountModel(it, operation.chain) }
}.withSafeLoading()
.shareInBackground()
@@ -68,6 +68,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
@@ -99,9 +100,15 @@ class MultisigOperationDetailsViewModel(
val operationNotFoundAwaitableAction = actionAwaitableMixinFactory.confirmingAction<ConfirmationDialogInfo>()
private val operationFlow = multisigOperationsService.pendingOperationFlow(payload.operation.toOperationId())
.filterNotNull()
.shareInBackground()
// Populated only for the "first signer" deep-link case: a call that has no
// Multisig.Multisigs entry on chain yet, built directly from the deep link's callData
// instead of the chain-storage-driven sync service (see checkOperationAvailability()).
private val notYetSubmittedOperationFlow = MutableStateFlow<PendingMultisigOperation?>(null)
private val operationFlow = merge(
multisigOperationsService.pendingOperationFlow(payload.operation.toOperationId()).filterNotNull(),
notYetSubmittedOperationFlow.filterNotNull()
).shareInBackground()
private val isLastOperationFlow = flowOf {
val operationsCount = multisigOperationsService.getPendingOperationsCount()
@@ -226,11 +233,26 @@ class MultisigOperationDetailsViewModel(
}
private fun checkOperationAvailability() = launchUnit {
val isOperationAvailable = interactor.isOperationAvailable(payload.operation.toOperationId())
val operationId = payload.operation.toOperationId()
val isOperationAvailable = interactor.isOperationAvailable(operationId)
if (!isOperationAvailable) {
showErrorAndCloseScreen()
if (isOperationAvailable) return@launchUnit
// Not on chain yet - only a real gap if this isn't the deep-link "first signer" case
// (no callData supplied means it really is an unknown/stale operation, same as before).
val notSubmittedCallData = payload.operation.notSubmittedCallData
if (notSubmittedCallData != null) {
val builtOperation = runCatching {
interactor.buildNotYetSubmittedOperation(operationId, notSubmittedCallData)
}.getOrNull()
if (builtOperation != null) {
notYetSubmittedOperationFlow.value = builtOperation
return@launchUnit
}
}
showErrorAndCloseScreen()
}
fun enterCallDataClicked() {
@@ -251,8 +273,12 @@ class MultisigOperationDetailsViewModel(
}
private suspend fun PendingMultisigOperation.getDepositorName(): String {
val depositorAccount = withContext(Dispatchers.Default) { accountInteractor.findMetaAccount(chain, depositor.value) }
return depositorAccount?.name ?: chain.addressOf(depositor)
// Only called from actionClicked() when userAction() == CanReject, which never happens
// when depositor is null (a not-yet-submitted operation has nothing to reject) - fails
// loudly instead of silently if that invariant is ever violated.
val depositorId = checkNotNull(depositor) { "Cannot reject an operation with no depositor" }
val depositorAccount = withContext(Dispatchers.Default) { accountInteractor.findMetaAccount(chain, depositorId.value) }
return depositorAccount?.name ?: chain.addressOf(depositorId)
}
private suspend fun confirmReject(depositorName: String) = suspendCancellableCoroutine<Unit> {