diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/PendingMultisigOperation.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/PendingMultisigOperation.kt index 79fc0780..6d92844d 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/PendingMultisigOperation.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/data/multisig/model/PendingMultisigOperation.kt @@ -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, - 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, + ) +} diff --git a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/TableCellViewExt.kt b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/TableCellViewExt.kt index a7d89543..0cf7e14e 100644 --- a/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/TableCellViewExt.kt +++ b/feature-account-api/src/main/java/io/novafoundation/nova/feature_account_api/view/TableCellViewExt.kt @@ -53,7 +53,7 @@ fun TableCellView.showWallet(walletModel: WalletModel) { walletModel.icon?.let(::setImage) } -fun TableCellView.showAccountWithLoading(loadingState: ExtendedLoadingState) { +fun TableCellView.showAccountWithLoading(loadingState: ExtendedLoadingState) { showLoadingState(loadingState) { showAccount(it) } diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/MultisigOperationDetailsInteractor.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/MultisigOperationDetailsInteractor.kt index 6f656e5e..f23f3e8b 100644 --- a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/MultisigOperationDetailsInteractor.kt +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/MultisigOperationDetailsInteractor.kt @@ -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 ) diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/OperationIsStillPendingValidation.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/OperationIsStillPendingValidation.kt index 2980c0cf..e7fb5dd7 100644 --- a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/OperationIsStillPendingValidation.kt +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/domain/details/validations/OperationIsStillPendingValidation.kt @@ -12,6 +12,15 @@ class OperationIsStillPendingValidation @Inject constructor( ) : ApproveMultisigOperationValidation { override suspend fun validate(value: ApproveMultisigOperationValidationPayload): ValidationStatus { + // 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 { diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/common/MultisigOperationPayload.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/common/MultisigOperationPayload.kt index 069e5412..f051f33f 100644 --- a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/common/MultisigOperationPayload.kt +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/common/MultisigOperationPayload.kt @@ -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, ) } diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDetailsDeepLinkHandler.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDetailsDeepLinkHandler.kt index e61f7a31..9ae4803a 100644 --- a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDetailsDeepLinkHandler.kt +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/deeplink/MultisigOperationDetailsDeepLinkHandler.kt @@ -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) + } } diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsViewModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsViewModel.kt index a2f61fef..7a08eaed 100644 --- a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsViewModel.kt +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/full/MultisigOperationFullDetailsViewModel.kt @@ -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(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() diff --git a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsViewModel.kt b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsViewModel.kt index eb75407e..db63ba56 100644 --- a/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsViewModel.kt +++ b/feature-multisig/operations/src/main/java/io/novafoundation/nova/feature_multisig_operations/presentation/details/general/MultisigOperationDetailsViewModel.kt @@ -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() - 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(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 {