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
@@ -4,6 +4,7 @@ import io.novafoundation.nova.common.address.AccountIdKey
import io.novafoundation.nova.common.address.toHex
import io.novafoundation.nova.common.utils.Identifiable
import io.novafoundation.nova.feature_account_api.domain.model.MetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.MultisigMetaAccount
import io.novafoundation.nova.feature_account_api.domain.model.addressIn
import io.novafoundation.nova.feature_account_api.domain.multisig.CallHash
import io.novafoundation.nova.runtime.multiNetwork.chain.model.Chain
@@ -18,9 +19,24 @@ class PendingMultisigOperation(
val call: GenericCall.Instance?,
val callHash: CallHash,
val chain: Chain,
val timePoint: MultisigTimePoint,
/**
* Null means this call has never been submitted on-chain yet (no `Multisig.Multisigs` entry
* exists) - the "first signer" case reachable via a `/open/multisigOperation` deep link
* before anyone has signed. `composeMultisigAsMulti`'s `maybeTimePoint` parameter already
* accepts null for exactly this (see `MultisigSigner.wrapCallsInAsMulti`, which uses the
* same null-timepoint-for-first-submission pattern for ordinary multisig-origin calls) -
* this model just didn't have a way to represent that state before.
*/
val timePoint: MultisigTimePoint?,
val approvals: List<AccountIdKey>,
val depositor: AccountIdKey,
/**
* Null for the same not-yet-submitted case as [timePoint] - nobody has deposited/proposed
* this call yet, so there is no depositor. [userAction] already handles this correctly:
* comparing [signatoryAccountId] to a null depositor is simply never true, so a signatory
* viewing a not-yet-submitted call is never offered "Reject" (rejecting something that was
* never proposed makes no sense - only cancel_as_multi on an *existing* operation does).
*/
val depositor: AccountIdKey?,
val deposit: BigInteger,
val signatoryAccountId: AccountIdKey,
val signatoryMetaId: Long,
@@ -30,6 +46,9 @@ class PendingMultisigOperation(
val operationId = PendingMultisigOperationId(multisigMetaId, chain.id, callHash.toHex())
val isSubmittedOnChain: Boolean
get() = timePoint != null
override val identifier: String = operationId.identifier()
override fun toString(): String {
@@ -78,3 +97,35 @@ fun PendingMultisigOperation.Companion.createOperationHash(metaAccount: MetaAcco
fun PendingMultisigOperationId.Companion.create(metaAccount: MetaAccount, chain: Chain, callHash: String): PendingMultisigOperationId {
return PendingMultisigOperationId(metaAccount.id, chain.id, callHash)
}
/**
* Builds a synthetic [PendingMultisigOperation] for a call that has never been submitted
* on-chain - the deep-link "first signer" case (see `MultisigOperationDetailsDeepLinkHandler`
* and `RealMultisigOperationDetailsInteractor.buildNotYetSubmittedOperation`). Unlike
* `PendingMultisigOperation.from` (used by the chain-storage-driven syncer), this never touches
* chain state - everything it needs (threshold, other signatories) is already known locally
* from the already-added [MultisigMetaAccount], and [call] comes from the deep link's `callData`
* param, whose hash the caller must already have verified matches the link's `callHash`.
*/
fun PendingMultisigOperation.Companion.notYetSubmitted(
multisigMetaAccount: MultisigMetaAccount,
call: GenericCall.Instance,
callHash: CallHash,
chain: Chain,
timestamp: Duration,
): PendingMultisigOperation {
return PendingMultisigOperation(
multisigMetaId = multisigMetaAccount.id,
call = call,
callHash = callHash,
chain = chain,
timePoint = null,
approvals = emptyList(),
depositor = null,
deposit = BigInteger.ZERO,
signatoryAccountId = multisigMetaAccount.signatoryAccountId,
signatoryMetaId = multisigMetaAccount.signatoryMetaId,
threshold = multisigMetaAccount.threshold,
timestamp = timestamp,
)
}
@@ -53,7 +53,7 @@ fun TableCellView.showWallet(walletModel: WalletModel) {
walletModel.icon?.let(::setImage)
}
fun TableCellView.showAccountWithLoading(loadingState: ExtendedLoadingState<AccountModel>) {
fun TableCellView.showAccountWithLoading(loadingState: ExtendedLoadingState<AccountModel?>) {
showLoadingState(loadingState) {
showAccount(it)
}