mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-07-22 18:25:50 +00:00
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:
+44
-1
@@ -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
|
||||
)
|
||||
|
||||
|
||||
+9
@@ -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 {
|
||||
|
||||
+13
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+12
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-5
@@ -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()
|
||||
|
||||
|
||||
+34
-8
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user