diff --git a/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/TransactionCommitter.scala b/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/TransactionCommitter.scala index 243c4fbfee47..72334e6ce196 100644 --- a/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/TransactionCommitter.scala +++ b/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/TransactionCommitter.scala @@ -11,9 +11,9 @@ import com.daml.ledger.participant.state.kvutils.DamlKvutils._ import com.daml.ledger.participant.state.kvutils.committer.Committer._ import com.daml.ledger.participant.state.kvutils.committer._ import com.daml.ledger.participant.state.kvutils.committer.transaction.validation.{ - ContractKeysValidator, LedgerTimeValidator, ModelConformanceValidator, + TransactionConsistencyValidator, } import com.daml.ledger.participant.state.kvutils.{Conversions, Err} import com.daml.ledger.participant.state.v1.RejectionReasonV0 @@ -75,10 +75,8 @@ private[kvutils] class TransactionCommitter( "check_informee_parties_allocation" -> checkInformeePartiesAllocation, "deduplicate" -> deduplicateCommand, "validate_ledger_time" -> ledgerTimeValidator.createValidationStep(rejections), - "validate_contract_keys" -> ContractKeysValidator.createValidationStep(rejections), - "validate_model_conformance" -> modelConformanceValidator.createValidationStep( - rejections - ), + "validate_model_conformance" -> modelConformanceValidator.createValidationStep(rejections), + "validate_consistency" -> TransactionConsistencyValidator.createValidationStep(rejections), "blind" -> blind, "trim_unnecessary_nodes" -> trimUnnecessaryNodes, "build_final_log_entry" -> buildFinalLogEntry, @@ -218,9 +216,8 @@ private[kvutils] class TransactionCommitter( case Nil => result case head :: tail => import TransactionOuterClass.Node.NodeTypeCase - val node = nodeMap - .get(head) - .getOrElse(throw Err.InternalError(s"Invalid transaction node id $head")) + val node = + nodeMap.getOrElse(head, throw Err.InternalError(s"Invalid transaction node id $head")) node.getNodeTypeCase match { case NodeTypeCase.CREATE => goNodesToKeep(tail, result + head) diff --git a/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/KeyMonotonicityValidation.scala b/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/KeyMonotonicityValidation.scala deleted file mode 100644 index 4a4ebf4f04a5..000000000000 --- a/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/KeyMonotonicityValidation.scala +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.ledger.participant.state.kvutils.committer.transaction.validation - -import com.daml.ledger.participant.state.kvutils.Conversions -import com.daml.ledger.participant.state.kvutils.DamlKvutils.{DamlStateKey, DamlStateValue} -import com.daml.ledger.participant.state.kvutils.committer.transaction.{ - DamlTransactionEntrySummary, - Rejections, -} -import com.daml.ledger.participant.state.kvutils.committer.{StepContinue, StepResult} -import com.daml.ledger.participant.state.v1.RejectionReasonV0 -import com.daml.lf.data.Time.Timestamp -import com.daml.logging.LoggingContext - -private[validation] object KeyMonotonicityValidation { - - /** LookupByKey nodes themselves don't actually fetch the contract. - * Therefore we need to do an additional check on all contract keys - * to ensure the referred contract satisfies the causal monotonicity invariant. - * This could be reduced to only validate this for keys referred to by - * NodeLookupByKey. - */ - def checkContractKeysCausalMonotonicity( - recordTime: Option[Timestamp], - keys: Set[DamlStateKey], - damlState: Map[DamlStateKey, DamlStateValue], - transactionEntry: DamlTransactionEntrySummary, - rejections: Rejections, - )(implicit loggingContext: LoggingContext): StepResult[DamlTransactionEntrySummary] = { - val causalKeyMonotonicity = keys.forall { key => - val state = damlState(key) - val keyActiveAt = - Conversions - .parseTimestamp(state.getContractKeyState.getActiveAt) - .toInstant - !keyActiveAt.isAfter(transactionEntry.ledgerEffectiveTime.toInstant) - } - if (causalKeyMonotonicity) - StepContinue(transactionEntry) - else - rejections.buildRejectionStep( - transactionEntry, - RejectionReasonV0.InvalidLedgerTime("Causal monotonicity violated"), - recordTime, - ) - } -} diff --git a/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ModelConformanceValidator.scala b/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ModelConformanceValidator.scala index 2d7e4f231e50..141a9ef6dcbe 100644 --- a/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ModelConformanceValidator.scala +++ b/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ModelConformanceValidator.scala @@ -5,15 +5,10 @@ package com.daml.ledger.participant.state.kvutils.committer.transaction.validati import com.daml.ledger.participant.state.kvutils.Conversions.{ contractIdToStateKey, - decodeContractId, packageStateKey, parseTimestamp, } -import com.daml.ledger.participant.state.kvutils.DamlKvutils.{ - DamlContractKey, - DamlContractState, - DamlStateValue, -} +import com.daml.ledger.participant.state.kvutils.DamlKvutils.{DamlContractState, DamlStateValue} import com.daml.ledger.participant.state.kvutils.committer.transaction.{ DamlTransactionEntrySummary, Rejections, @@ -24,121 +19,145 @@ import com.daml.ledger.participant.state.kvutils.{Conversions, Err} import com.daml.ledger.participant.state.v1.RejectionReasonV0 import com.daml.lf.archive import com.daml.lf.data.Ref.PackageId -import com.daml.lf.engine.{Engine, Error => LfError} +import com.daml.lf.data.Time.Timestamp +import com.daml.lf.engine.{Engine, Result} import com.daml.lf.language.Ast -import com.daml.lf.transaction.{ - GlobalKeyWithMaintainers, - Node, - NodeId, - ReplayNodeMismatch, - SubmittedTransaction, - VersionedTransaction, +import com.daml.lf.transaction.Transaction.{ + DuplicateKeys, + InconsistentKeys, + KeyActive, + KeyInput, + KeyInputError, } +import com.daml.lf.transaction.{GlobalKey, GlobalKeyWithMaintainers, SubmittedTransaction} import com.daml.lf.value.Value -import com.daml.lf.value.Value.ContractId import com.daml.logging.LoggingContext.withEnrichedLoggingContext import com.daml.logging.{ContextualizedLogger, LoggingContext} import com.daml.metrics.Metrics +/** Validates the submission's conformance to the Daml model. + * + * @param engine An [[Engine]] instance to reinterpret and validate the transaction. + * @param metrics A [[Metrics]] instance to record metrics. + */ private[transaction] class ModelConformanceValidator(engine: Engine, metrics: Metrics) extends TransactionValidator { import ModelConformanceValidator._ private final val logger = ContextualizedLogger.get(getClass) - /** Creates a committer step that validates the submission's conformance to the Daml model. */ + /** Validates model conformance based on the transaction itself (where it's possible). + * Because fetch nodes don't contain contracts, we still need to get them from the current state ([[CommitContext]]). + * It first reinterprets the transaction to detect a potentially malicious participant or bugs. + * Then, checks the causal monotonicity. + * + * @param rejections A helper object for creating rejection [[Step]]s. + * @return A committer [[Step]] that performs validation. + */ override def createValidationStep(rejections: Rejections): Step = new Step { def apply( commitContext: CommitContext, transactionEntry: DamlTransactionEntrySummary, )(implicit loggingContext: LoggingContext): StepResult[DamlTransactionEntrySummary] = metrics.daml.kvutils.committer.transaction.interpretTimer.time(() => { - // Pull all keys from referenced contracts. We require this for 'fetchByKey' calls - // which are not evidenced in the transaction itself and hence the contract key state is - // not included in the inputs. - lazy val knownKeys: Map[DamlContractKey, Value.ContractId] = - commitContext.collectInputs { - case (key, Some(value)) - if value.getContractState.hasContractKey - && contractIsActive(transactionEntry, value.getContractState) => - value.getContractState.getContractKey -> Conversions - .stateKeyToContractId(key) - } + val validationResult = engine.validate( + transactionEntry.submitters.toSet, + SubmittedTransaction(transactionEntry.transaction), + transactionEntry.ledgerEffectiveTime, + commitContext.participantId, + transactionEntry.submissionTime, + transactionEntry.submissionSeed, + ) + + for { + stepResult <- consumeValidationResult( + validationResult, + transactionEntry, + commitContext, + rejections, + ) + finalStepResult <- validateCausalMonotonicity(stepResult, commitContext, rejections) + } yield finalStepResult + }) + } - try { - engine - .validate( - transactionEntry.submitters.toSet, - SubmittedTransaction(transactionEntry.transaction), - transactionEntry.ledgerEffectiveTime, - commitContext.participantId, - transactionEntry.submissionTime, - transactionEntry.submissionSeed, - ) - .consume( - lookupContract(transactionEntry, commitContext), - lookupPackage(commitContext), - lookupKey(commitContext, knownKeys), - ) - .fold( - err => - rejections.buildRejectionStep( - transactionEntry, - rejectionReasonForValidationError(err), - commitContext.recordTime, - ), - _ => StepContinue[DamlTransactionEntrySummary](transactionEntry), - ) - } catch { - case err: Err.MissingInputState => - logger.warn( - "Model conformance validation failed due to a missing input state (most likely due to invalid state on the participant)." - ) + private def consumeValidationResult( + validationResult: Result[Unit], + transactionEntry: DamlTransactionEntrySummary, + commitContext: CommitContext, + rejections: Rejections, + )(implicit loggingContext: LoggingContext): StepResult[DamlTransactionEntrySummary] = { + try { + val stepResult = for { + contractKeyInputs <- transactionEntry.transaction.contractKeyInputs.left + .map(rejectionForKeyInputError(transactionEntry, commitContext.recordTime, rejections)) + _ <- validationResult + .consume( + lookupContract(commitContext), + lookupPackage(commitContext), + lookupKey(contractKeyInputs), + ) + .left + .map(error => rejections.buildRejectionStep( transactionEntry, - RejectionReasonV0.Disputed(err.getMessage), + RejectionReasonV0.Disputed(error.msg), commitContext.recordTime, ) - } - }) - } - - private def contractIsActive( - transactionEntry: DamlTransactionEntrySummary, - contractState: DamlContractState, - ): Boolean = { - val activeAt = Option(contractState.getActiveAt).map(parseTimestamp) - !contractState.hasArchivedAt && activeAt.exists(transactionEntry.ledgerEffectiveTime >= _) + ) + } yield () + stepResult.fold(identity, _ => StepContinue(transactionEntry)) + } catch { + case missingInputErr: Err.MissingInputState => + logger.error( + "Model conformance validation failed due to a missing input state (most likely due to invalid state on the participant).", + missingInputErr, + ) + rejections.buildRejectionStep( + transactionEntry, + RejectionReasonV0.Inconsistent(missingInputErr.getMessage), + commitContext.recordTime, + ) + case err: Err => + logger.error( + "Model conformance validation failed most likely due to invalid state on the participant.", + err, + ) + rejections.buildRejectionStep( + transactionEntry, + RejectionReasonV0.Disputed(err.getMessage), + commitContext.recordTime, + ) + } } - // Helper to lookup contract instances. We verify the activeness of - // contract instances here. Since we look up every contract that was + // Helper to lookup contract instances. Since we look up every contract that was // an input to a transaction, we do not need to verify the inputs separately. - private def lookupContract( - transactionEntry: DamlTransactionEntrySummary, - commitContext: CommitContext, + // + // Note that for an honest participant, a contract may not be in the state only if it was archived and pruned + // on the committer. Then, an honest participant is able to produce such a transaction only by using + // a divulged contract that appeared as active, because it didn't learn about the archival. + // On the other hand, using divulged contracts for interpretation is deprecated so we turn it into Inconsistent. + @throws[Err.MissingInputState] + private[validation] def lookupContract( + commitContext: CommitContext )( - coid: Value.ContractId - ): Option[Value.ContractInst[Value.VersionedValue[Value.ContractId]]] = { - val stateKey = contractIdToStateKey(coid) - for { - // Fetch the state of the contract so that activeness can be checked. - // There is the possibility that the reinterpretation of the transaction yields a different - // result in a LookupByKey than the original transaction. This means that the contract state data for the - // contractId pointed to by that contractKey might not have been preloaded into the input state map. - // This is not a problem because after the transaction reinterpretation, we compare the original - // transaction with the reinterpreted one, and the LookupByKey node will not match. - // Additionally, all contract keys are checked to uphold causal monotonicity. - contractState <- commitContext.read(stateKey).map(_.getContractState) - if contractIsActive(transactionEntry, contractState) - contract = Conversions.decodeContractInstance(contractState.getContractInstance) - } yield contract - } - - // Helper to lookup package from the state. The package contents - // are stored in the [[DamlLogEntry]], which we find by looking up - // the Daml state entry at `DamlStateKey(packageId = pkgId)`. - private def lookupPackage( + contractId: Value.ContractId + ): Option[Value.ContractInst[Value.VersionedValue[Value.ContractId]]] = + commitContext + .read(contractIdToStateKey(contractId)) + .map(_.getContractState) + .map(_.getContractInstance) + .map(Conversions.decodeContractInstance) + + // Helper to lookup package from the state. The package contents are stored in the [[DamlLogEntry]], + // which we find by looking up the Daml state entry at `DamlStateKey(packageId = pkgId)`. + // + // Note that there is no committer pruning of packages, so MissingInputState can only arise from a malicious + // or buggy participant. + @throws[Err.MissingInputState] + @throws[Err.ArchiveDecodingFailed] + private[validation] def lookupPackage( commitContext: CommitContext )(pkgId: PackageId)(implicit loggingContext: LoggingContext): Some[Ast.Package] = withEnrichedLoggingContext("packageId" -> pkgId) { implicit loggingContext => @@ -155,96 +174,73 @@ private[transaction] class ModelConformanceValidator(engine: Engine, metrics: Me case Right((_, pkg)) => Some(pkg) case Left(err) => logger.warn("Decoding the archive failed.") - throw Err.DecodeError("Archive", err.getMessage) + throw Err.ArchiveDecodingFailed(pkgId, err.getMessage) } case _ => val msg = "value is not a Daml-LF archive" logger.warn(s"Package lookup failed, $msg.") - throw Err.DecodeError("Archive", msg) + throw Err.ArchiveDecodingFailed(pkgId, msg) } } - private def lookupKey( + private[validation] def lookupKey( + contractKeyInputs: Map[GlobalKey, KeyInput] + )(key: GlobalKeyWithMaintainers): Option[Value.ContractId] = + contractKeyInputs.get(key.globalKey) match { + case Some(KeyActive(cid)) => Some(cid) + case _ => None + } + + // Checks that input contracts have been created before or at the current ledger effective time. + private[validation] def validateCausalMonotonicity( + transactionEntry: DamlTransactionEntrySummary, commitContext: CommitContext, - knownKeys: Map[DamlContractKey, Value.ContractId], - )(key: GlobalKeyWithMaintainers): Option[Value.ContractId] = { - // we don't check whether the contract is active or not, because in we might not have loaded it earlier. - // this is not a problem, because: - // a) if the lookup was negative and we actually found a contract, - // the transaction validation will fail. - // b) if the lookup was positive and its result is a different contract, - // the transaction validation will fail. - // c) if the lookup was positive and its result is the same contract, - // - the authorization check ensures that the submitter is in fact allowed - // to lookup the contract - // - the separate contract keys check ensures that all contracts pointed to by - // contract keys respect causal monotonicity. - val stateKey = Conversions.globalKeyToStateKey(key.globalKey) - val contractId = for { - stateValue <- commitContext.read(stateKey) - if stateValue.getContractKeyState.getContractId.nonEmpty - } yield decodeContractId(stateValue.getContractKeyState.getContractId) - - // If the key was not in state inputs, then we look whether any of the accessed contracts has - // the key we're looking for. This happens with "fetchByKey" where the key lookup is not - // evidenced in the transaction. The activeness of the contract is checked when it is fetched. - contractId.orElse { - knownKeys.get(stateKey.getContractKey) + rejections: Rejections, + )(implicit loggingContext: LoggingContext): StepResult[DamlTransactionEntrySummary] = { + + val inputContracts: Map[Value.ContractId, DamlContractState] = commitContext + .collectInputs { + case (key, Some(value)) if value.hasContractState => + Conversions.stateKeyToContractId(key) -> value.getContractState + } + + val isCausallyMonotonic = transactionEntry.transaction.inputContracts.forall { contractId => + val inputContractState = inputContracts(contractId) + val activeAt = Option(inputContractState.getActiveAt).map(parseTimestamp) + activeAt.exists(transactionEntry.ledgerEffectiveTime >= _) } + + if (isCausallyMonotonic) + StepContinue(transactionEntry) + else + rejections.buildRejectionStep( + transactionEntry, + RejectionReasonV0.InvalidLedgerTime("Causal monotonicity violated"), + commitContext.recordTime, + ) } } private[transaction] object ModelConformanceValidator { - def rejectionReasonForValidationError( - validationError: LfError - ): RejectionReasonV0 = { - def disputed: RejectionReasonV0 = - RejectionReasonV0.Disputed(validationError.msg) - - def resultIsCreatedInTx( - tx: VersionedTransaction[NodeId, ContractId], - result: Option[Value.ContractId], - ): Boolean = - result.exists { contractId => - tx.nodes.exists { - case (_, create: Node.NodeCreate[_]) => create.coid == contractId - case _ => false - } - } - validationError match { - case LfError.Validation( - LfError.Validation.ReplayMismatch( - ReplayNodeMismatch(recordedTx, recordedNodeId, replayedTx, replayedNodeId) - ) - ) => - // If the problem is that a key lookup has changed and the results do not involve contracts created in this transaction, - // then it's a consistency problem. - - (recordedTx.nodes(recordedNodeId), replayedTx.nodes(replayedNodeId)) match { - case ( - Node.NodeLookupByKey( - recordedTemplateId, - recordedKey, - recordedResult, - recordedVersion, - ), - Node.NodeLookupByKey( - replayedTemplateId, - replayedKey, - replayedResult, - replayedVersion, - ), - ) - if recordedVersion == replayedVersion && - recordedTemplateId == replayedTemplateId && recordedKey == replayedKey - && !resultIsCreatedInTx(recordedTx, recordedResult) - && !resultIsCreatedInTx(replayedTx, replayedResult) => - RejectionReasonV0.Inconsistent(validationError.msg) - case _ => disputed - } - case _ => disputed + private def rejectionForKeyInputError( + transactionEntry: DamlTransactionEntrySummary, + recordTime: Option[Timestamp], + rejections: Rejections, + )( + error: KeyInputError + )(implicit loggingContext: LoggingContext): StepResult[DamlTransactionEntrySummary] = { + val description = error match { + case DuplicateKeys(_) => + "DuplicateKeys: the transaction contains a duplicate key" + case InconsistentKeys(_) => + "InconsistentKeys: the transaction is internally inconsistent" } + rejections.buildRejectionStep( + transactionEntry, + RejectionReasonV0.Disputed(description), + recordTime, + ) } } diff --git a/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ContractKeysValidator.scala b/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/TransactionConsistencyValidator.scala similarity index 59% rename from ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ContractKeysValidator.scala rename to ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/TransactionConsistencyValidator.scala index a441ac8898a6..cc5400069da1 100644 --- a/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ContractKeysValidator.scala +++ b/ledger/participant-state/kvutils/src/main/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/TransactionConsistencyValidator.scala @@ -6,10 +6,10 @@ package com.daml.ledger.participant.state.kvutils.committer.transaction.validati import com.daml.ledger.participant.state.kvutils.Conversions import com.daml.ledger.participant.state.kvutils.DamlKvutils.{ DamlContractKey, + DamlContractKeyState, + DamlContractState, DamlStateKey, - DamlStateValue, } -import com.daml.ledger.participant.state.kvutils.committer.transaction.validation.KeyMonotonicityValidation.checkContractKeysCausalMonotonicity import com.daml.ledger.participant.state.kvutils.committer.transaction.{ DamlTransactionEntrySummary, Rejections, @@ -17,7 +17,6 @@ import com.daml.ledger.participant.state.kvutils.committer.transaction.{ } import com.daml.ledger.participant.state.kvutils.committer.{CommitContext, StepContinue, StepResult} import com.daml.ledger.participant.state.v1.RejectionReasonV0 -import com.daml.lf.data.Time.Timestamp import com.daml.lf.transaction.Transaction.{ DuplicateKeys, InconsistentKeys, @@ -25,63 +24,57 @@ import com.daml.lf.transaction.Transaction.{ KeyCreate, NegativeKeyLookup, } +import com.daml.lf.value.Value import com.daml.logging.LoggingContext -private[transaction] object ContractKeysValidator extends TransactionValidator { +private[transaction] object TransactionConsistencyValidator extends TransactionValidator { - /** Creates a committer step that validates casual monotonicity and consistency of contract keys - * against the current ledger state. + /** Validates consistency of contracts and contract keys against the current ledger state. + * For contracts, checks whether all contracts used in the transaction are still active. + * For keys, checks whether they are consistent and there are no duplicates. + * + * @param rejections A helper object for creating rejection [[Step]]s. + * @return A committer [[Step]] that performs validation. */ override def createValidationStep(rejections: Rejections): Step = new Step { def apply( commitContext: CommitContext, transactionEntry: DamlTransactionEntrySummary, )(implicit loggingContext: LoggingContext): StepResult[DamlTransactionEntrySummary] = { - val damlState = commitContext - .collectInputs[(DamlStateKey, DamlStateValue), Map[DamlStateKey, DamlStateValue]] { - case (key, Some(value)) if key.hasContractKey => key -> value - } - val contractKeyDamlStateKeysToContractIds: Map[DamlStateKey, RawContractId] = - damlState.collect { - case (k, v) if k.hasContractKey && v.getContractKeyState.getContractId.nonEmpty => - k -> v.getContractKeyState.getContractId - } - // State before the transaction - val contractKeyDamlStateKeys: Set[DamlStateKey] = - contractKeyDamlStateKeysToContractIds.keySet - val contractKeysToContractIds: Map[DamlContractKey, RawContractId] = - contractKeyDamlStateKeysToContractIds.map(m => m._1.getContractKey -> m._2) - for { - stateAfterMonotonicityCheck <- checkContractKeysCausalMonotonicity( - commitContext.recordTime, - contractKeyDamlStateKeys, - damlState, + stepResult <- validateConsistencyOfKeys( + commitContext, transactionEntry, rejections, ) - finalState <- performTraversalContractKeysChecks( - commitContext.recordTime, - contractKeysToContractIds, - stateAfterMonotonicityCheck, + finalStepResult <- validateConsistencyOfContracts( + commitContext, + stepResult, rejections, ) - } yield finalState + } yield finalStepResult } } - private def performTraversalContractKeysChecks( - recordTime: Option[Timestamp], - contractKeysToContractIds: Map[DamlContractKey, RawContractId], + private def validateConsistencyOfKeys( + commitContext: CommitContext, transactionEntry: DamlTransactionEntrySummary, rejections: Rejections, )(implicit loggingContext: LoggingContext): StepResult[DamlTransactionEntrySummary] = { - import scalaz.std.either._ - import scalaz.std.list._ - import scalaz.syntax.foldable._ + + val contractKeyState: Map[DamlStateKey, DamlContractKeyState] = commitContext.collectInputs { + case (key, Some(value)) if key.hasContractKey => key -> value.getContractKeyState + } + val contractKeysToContractIds: Map[DamlContractKey, RawContractId] = contractKeyState.collect { + case (k, v) if v.getContractId.nonEmpty => + k.getContractKey -> v.getContractId + } val transaction = transactionEntry.transaction + import scalaz.std.either._ + import scalaz.std.list._ + import scalaz.syntax.foldable._ val keysValidationOutcome = for { keyInputs <- transaction.contractKeyInputs.left.map { case DuplicateKeys(_) => Duplicate @@ -116,11 +109,38 @@ private[transaction] object ContractKeysValidator extends TransactionValidator { rejections.buildRejectionStep( transactionEntry, RejectionReasonV0.Inconsistent(message), - recordTime, + commitContext.recordTime, ) } } + def validateConsistencyOfContracts( + commitContext: CommitContext, + transactionEntry: DamlTransactionEntrySummary, + rejections: Rejections, + )(implicit loggingContext: LoggingContext): StepResult[DamlTransactionEntrySummary] = { + val inputContracts: Map[Value.ContractId, DamlContractState] = commitContext + .collectInputs { + case (key, Some(value)) if value.hasContractState => + Conversions.stateKeyToContractId(key) -> value.getContractState + } + + val areContractsConsistent = transactionEntry.transaction.inputContracts.forall(contractId => + !inputContracts(contractId).hasArchivedAt + ) + + if (areContractsConsistent) + StepContinue(transactionEntry) + else + rejections.buildRejectionStep( + transactionEntry, + RejectionReasonV0.Inconsistent( + "InconsistentContracts: at least one contract has been archived since the submission" + ), + commitContext.recordTime, + ) + } + private[validation] type RawContractId = String private[validation] sealed trait KeyValidationError extends Product with Serializable diff --git a/ledger/participant-state/kvutils/src/test/lib/scala/com/daml/ledger/validator/TestHelper.scala b/ledger/participant-state/kvutils/src/test/lib/scala/com/daml/ledger/validator/TestHelper.scala index eb7efa64727f..af8a41f8f256 100644 --- a/ledger/participant-state/kvutils/src/test/lib/scala/com/daml/ledger/validator/TestHelper.scala +++ b/ledger/participant-state/kvutils/src/test/lib/scala/com/daml/ledger/validator/TestHelper.scala @@ -11,7 +11,7 @@ import com.daml.lf.data.Ref import com.daml.lf.value.ValueOuterClass.Identifier import com.google.protobuf.{ByteString, Empty} -private[validator] object TestHelper { +private[ledger] object TestHelper { lazy val aParticipantId: Ref.ParticipantId = Ref.ParticipantId.assertFromString("aParticipantId") diff --git a/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/KeyMonotonicityValidationSpec.scala b/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/KeyMonotonicityValidationSpec.scala deleted file mode 100644 index 211842b99471..000000000000 --- a/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/KeyMonotonicityValidationSpec.scala +++ /dev/null @@ -1,79 +0,0 @@ -// Copyright (c) 2021 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.daml.ledger.participant.state.kvutils.committer.transaction.validation - -import java.time.{Instant, ZoneOffset, ZonedDateTime} - -import com.daml.ledger.participant.state.kvutils.Conversions -import com.daml.ledger.participant.state.kvutils.DamlKvutils._ -import com.daml.ledger.participant.state.kvutils.committer.StepContinue -import com.daml.ledger.participant.state.kvutils.committer.transaction.{ - DamlTransactionEntrySummary, - Rejections, -} -import com.daml.ledger.participant.state.v1.RejectionReasonV0 -import com.daml.logging.LoggingContext -import com.google.protobuf.ByteString -import org.mockito.{ArgumentMatchersSugar, MockitoSugar} -import org.scalatest.matchers.should.Matchers -import org.scalatest.wordspec.AsyncWordSpec - -class KeyMonotonicityValidationSpec - extends AsyncWordSpec - with Matchers - with MockitoSugar - with ArgumentMatchersSugar { - private implicit val loggingContext: LoggingContext = LoggingContext.ForTesting - - private val testKey = DamlStateKey.newBuilder().build() - private val testSubmissionSeed = ByteString.copyFromUtf8("a" * 32) - private val ledgerEffectiveTime = - ZonedDateTime.of(2021, 1, 1, 12, 0, 0, 0, ZoneOffset.UTC).toInstant - private val testTransactionEntry = DamlTransactionEntrySummary( - DamlTransactionEntry.newBuilder - .setSubmissionSeed(testSubmissionSeed) - .setLedgerEffectiveTime(Conversions.buildTimestamp(ledgerEffectiveTime)) - .build - ) - - "checkContractKeysCausalMonotonicity" should { - "create StepContinue in case of correct keys" in { - KeyMonotonicityValidation.checkContractKeysCausalMonotonicity( - None, - Set(testKey), - Map(testKey -> aStateValueActiveAt(ledgerEffectiveTime.minusSeconds(1))), - testTransactionEntry, - mock[Rejections], - ) shouldBe StepContinue(testTransactionEntry) - } - - "reject transaction in case of incorrect keys" in { - val rejections = mock[Rejections] - - KeyMonotonicityValidation - .checkContractKeysCausalMonotonicity( - None, - Set(testKey), - Map(testKey -> aStateValueActiveAt(ledgerEffectiveTime.plusSeconds(1))), - testTransactionEntry, - rejections, - ) - - verify(rejections).buildRejectionStep( - eqTo(testTransactionEntry), - eqTo(RejectionReasonV0.InvalidLedgerTime("Causal monotonicity violated")), - eqTo(None), - )(eqTo(loggingContext)) - - succeed - } - } - - private def aStateValueActiveAt(activeAt: Instant) = - DamlStateValue.newBuilder - .setContractKeyState( - DamlContractKeyState.newBuilder.setActiveAt(Conversions.buildTimestamp(activeAt)) - ) - .build -} diff --git a/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ModelConformanceValidatorSpec.scala b/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ModelConformanceValidatorSpec.scala index 505e6ba782da..ad8125eb9ffc 100644 --- a/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ModelConformanceValidatorSpec.scala +++ b/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ModelConformanceValidatorSpec.scala @@ -3,40 +3,80 @@ package com.daml.ledger.participant.state.kvutils.committer.transaction.validation -import com.daml.ledger.participant.state.kvutils.TestHelpers.lfTuple -import com.daml.ledger.participant.state.v1.{RejectionReason, RejectionReasonV0} -import com.daml.lf.engine.{Error => LfError} -import com.daml.lf.transaction +import java.time.{Instant, ZoneOffset, ZonedDateTime} + +import com.codahale.metrics.MetricRegistry +import com.daml.daml_lf_dev.DamlLf +import com.daml.ledger.participant.state.kvutils.DamlKvutils.{ + DamlContractState, + DamlLogEntry, + DamlStateKey, + DamlStateValue, + DamlSubmitterInfo, + DamlTransactionEntry, + DamlTransactionRejectionEntry, + InvalidLedgerTime, +} +import com.daml.ledger.participant.state.kvutils.TestHelpers.{createCommitContext, lfTuple} +import com.daml.ledger.participant.state.kvutils.committer.transaction.{ + DamlTransactionEntrySummary, + Rejections, +} +import com.daml.ledger.participant.state.kvutils.committer.{StepContinue, StepStop} +import com.daml.ledger.participant.state.kvutils.{Conversions, Err} +import com.daml.ledger.validator.TestHelper.{makeContractIdStateKey, makeContractIdStateValue} +import com.daml.lf.archive.testing.Encode +import com.daml.lf.crypto.Hash +import com.daml.lf.data.Time.Timestamp +import com.daml.lf.data.{ImmArray, Ref} +import com.daml.lf.engine.{Engine, Result, ResultError, Error => LfError} +import com.daml.lf.language.Ast.Expr +import com.daml.lf.language.{Ast, LanguageVersion} +import com.daml.lf.testing.parser.Implicits.defaultParserParameters +import com.daml.lf.transaction.TransactionOuterClass.ContractInstance import com.daml.lf.transaction.test.TransactionBuilder import com.daml.lf.transaction.{ - NodeId, - RecordedNodeMissing, - ReplayNodeMismatch, + GlobalKey, + GlobalKeyWithMaintainers, ReplayedNodeMissing, - Transaction, + SubmittedTransaction, + TransactionVersion, } -import com.daml.lf.value.Value -import org.mockito.MockitoSugar -import org.scalatest.Inspectors.forEvery +import com.daml.lf.value.Value.{ValueRecord, ValueText} +import com.daml.lf.value.{Value, ValueOuterClass} +import com.daml.logging.LoggingContext +import com.daml.metrics.Metrics +import com.google.protobuf.ByteString +import org.mockito.{ArgumentMatchersSugar, MockitoSugar} +import org.scalatest.Inside.inside import org.scalatest.matchers.should.Matchers +import org.scalatest.prop.TableDrivenPropertyChecks import org.scalatest.wordspec.AnyWordSpec -class ModelConformanceValidatorSpec extends AnyWordSpec with Matchers with MockitoSugar { +class ModelConformanceValidatorSpec + extends AnyWordSpec + with Matchers + with MockitoSugar + with ArgumentMatchersSugar + with TableDrivenPropertyChecks { import ModelConformanceValidatorSpec._ - private val txBuilder = TransactionBuilder() + private implicit val loggingContext: LoggingContext = LoggingContext.ForTesting - private val createInput = create("#inputContractId") - private val create1 = create("#someContractId") - private val create2 = create("#otherContractId") - private val otherKeyCreate = create( - contractId = "#contractWithOtherKey", - signatories = Seq(aKeyMaintainer), - keyAndMaintainer = Some("otherKey" -> aKeyMaintainer), + private val metrics = new Metrics(new MetricRegistry) + + private val defaultValidator = new ModelConformanceValidator(mock[Engine], metrics) + private val rejections = new Rejections(metrics) + + private val inputCreate = create( + inputContractId, + keyAndMaintainer = Some(inputContractKey -> inputContractKeyMaintainer), ) + private val aCreate = create(aContractId) + private val anotherCreate = create("#anotherContractId") private val exercise = txBuilder.exercise( - contract = createInput, + contract = inputCreate, choice = "DummyChoice", consuming = false, actingParties = Set(aKeyMaintainer), @@ -44,124 +84,404 @@ class ModelConformanceValidatorSpec extends AnyWordSpec with Matchers with Mocki byKey = false, ) - val lookupNodes @ Seq(lookup1, lookup2, lookupNone, lookupOther @ _) = - Seq(create1 -> true, create2 -> true, create1 -> false, otherKeyCreate -> true) map { - case (create, found) => txBuilder.lookupByKey(create, found) + private val lookupNodes = + Seq(aCreate -> true, anotherCreate -> true) map { case (create, found) => + txBuilder.lookupByKey(create, found) } - val Seq(tx1, tx2, txNone, txOther) = lookupNodes map { node => - val builder = TransactionBuilder() + val Seq(aTransaction, anotherTransaction) = lookupNodes map { node => + val builder = txBuilder val rootId = builder.add(exercise) val lookupId = builder.add(node, rootId) builder.build() -> lookupId } - "rejectionReasonForValidationError" when { - "there is a mismatch in lookupByKey nodes" should { - "report an inconsistency if the contracts are not created in the same transaction" in { - val inconsistentLookups = Seq( - mkMismatch(tx1, tx2), - mkMismatch(tx1, txNone), - mkMismatch(txNone, tx2), + private val aTransactionEntry = DamlTransactionEntrySummary( + DamlTransactionEntry.newBuilder + .setSubmissionSeed(aSubmissionSeed) + .setLedgerEffectiveTime(Conversions.buildTimestamp(ledgerEffectiveTime)) + .setTransaction(Conversions.encodeTransaction(aTransaction._1)) + .build + ) + + "createValidationStep" should { + "create StepContinue in case of correct input" in { + val mockEngine = mock[Engine] + val mockValidationResult = mock[Result[Unit]] + when( + mockValidationResult.consume( + any[Value.ContractId => Option[ + Value.ContractInst[Value.VersionedValue[Value.ContractId]] + ]], + any[Ref.PackageId => Option[Ast.Package]], + any[GlobalKeyWithMaintainers => Option[Value.ContractId]], + ) + ).thenReturn(Right(())) + when( + mockEngine.validate( + any[Set[Ref.Party]], + any[SubmittedTransaction], + any[Timestamp], + any[Ref.ParticipantId], + any[Timestamp], + any[Hash], ) - forEvery(inconsistentLookups)(checkRejectionReason(RejectionReasonV0.Inconsistent)) + ).thenReturn(mockValidationResult) + + val validator = new ModelConformanceValidator(mockEngine, metrics) + + validator.createValidationStep(rejections)( + createCommitContext( + None, + Map( + inputContractIdStateKey -> Some(makeContractIdStateValue()), + contractIdStateKey1 -> Some(makeContractIdStateValue()), + ), + ), + aTransactionEntry, + ) shouldBe StepContinue(aTransactionEntry) + } + + "create StepStop in case of validation error" in { + val mockEngine = mock[Engine] + when( + mockEngine.validate( + any[Set[Ref.Party]], + any[SubmittedTransaction], + any[Timestamp], + any[Ref.ParticipantId], + any[Timestamp], + any[Hash], + ) + ).thenReturn( + ResultError( + LfError.Validation.ReplayMismatch( + ReplayedNodeMissing(aTransaction._1, aTransaction._2, anotherTransaction._1) + ) + ) + ) + + val validator = new ModelConformanceValidator(mockEngine, metrics) + + val step = validator + .createValidationStep(rejections)( + createCommitContext( + None, + Map( + inputContractIdStateKey -> Some(makeContractIdStateValue()), + contractIdStateKey1 -> Some(makeContractIdStateValue()), + ), + ), + aTransactionEntry, + ) + inside(step) { case StepStop(logEntry) => + logEntry.getTransactionRejectionEntry.hasDisputed shouldBe true } + } + + "create StepStop in case of missing input" in { + val validator = createThrowingValidator(Err.MissingInputState(inputContractIdStateKey)) - "report Disputed if one of contracts is created in the same transaction" in { - val Seq(txC1, txC2, txCNone) = Seq(lookup1, lookup2, lookupNone) map { node => - val builder = TransactionBuilder() - val rootId = builder.add(exercise) - builder.add(create1, rootId) - val lookupId = builder.add(node, rootId) - builder.build() -> lookupId - } - val Seq(tx1C, txNoneC) = Seq(lookup1, lookupNone) map { node => - val builder = TransactionBuilder() - val rootId = builder.add(exercise) - val lookupId = builder.add(node, rootId) - builder.add(create1) - builder.build() -> lookupId - } - val recordedKeyInconsistent = Seq( - mkMismatch(txC2, txC1), - mkMismatch(txCNone, txC1), - mkMismatch(txC1, txCNone), - mkMismatch(tx1C, txNoneC), - ) - forEvery(recordedKeyInconsistent)(checkRejectionReason(RejectionReasonV0.Disputed)) + val step = validator + .createValidationStep(rejections)( + createCommitContext(None), + aTransactionEntry, + ) + inside(step) { case StepStop(logEntry) => + logEntry.getTransactionRejectionEntry.hasInconsistent shouldBe true + logEntry.getTransactionRejectionEntry.getInconsistent.getDetails should startWith( + "Missing input state for key contract_id: \"#inputContractId\"" + ) } + } + + "create StepStop in case of decode error" in { + val validator = + createThrowingValidator(Err.ArchiveDecodingFailed(aPackageId, "'test message'")) - "report Disputed if the keys are different" in { - checkRejectionReason(RejectionReasonV0.Disputed)(mkMismatch(txOther, tx1)) + val step = validator + .createValidationStep(rejections)( + createCommitContext(None), + aTransactionEntry, + ) + inside(step) { case StepStop(logEntry) => + logEntry.getTransactionRejectionEntry.hasDisputed shouldBe true + logEntry.getTransactionRejectionEntry.getDisputed.getDetails should be( + "Decoding of Daml-LF archive aPackage failed: 'test message'" + ) } } - "the mismatch is not between two lookup nodes" should { - "report Disputed" in { - val txExerciseOnly = { - val builder = TransactionBuilder() - builder.add(exercise) - builder.build() - } - val txCreate = { - val builder = TransactionBuilder() - val rootId = builder.add(exercise) - val createId = builder.add(create1, rootId) - builder.build() -> createId - } - val miscMismatches = Seq( - mkMismatch(txCreate, tx1), - mkRecordedMissing(txExerciseOnly, tx2), - mkReplayedMissing(tx1, txExerciseOnly), - ) - forEvery(miscMismatches)(checkRejectionReason(RejectionReasonV0.Disputed)) + def createThrowingValidator(consumeError: Err): ModelConformanceValidator = { + val mockEngine = mock[Engine] + val mockValidationResult = mock[Result[Unit]] + + when( + mockValidationResult.consume( + any[Value.ContractId => Option[ + Value.ContractInst[Value.VersionedValue[Value.ContractId]] + ]], + any[Ref.PackageId => Option[Ast.Package]], + any[GlobalKeyWithMaintainers => Option[Value.ContractId]], + ) + ).thenThrow(consumeError) + + when( + mockEngine.validate( + any[Set[Ref.Party]], + any[SubmittedTransaction], + any[Timestamp], + any[Ref.ParticipantId], + any[Timestamp], + any[Hash], + ) + ).thenReturn(mockValidationResult) + + new ModelConformanceValidator(mockEngine, metrics) + } + } + + "lookupContract" should { + "return Some when a contract is present in the current state" in { + val commitContext = createCommitContext( + None, + Map( + inputContractIdStateKey -> Some( + aContractIdStateValue + ) + ), + ) + + val contractInstance = defaultValidator.lookupContract(commitContext)( + Conversions.decodeContractId(inputContractId) + ) + + contractInstance shouldBe Some(aContractInst) + } + + "throw if a contract does not exist in the current state" in { + an[Err.MissingInputState] should be thrownBy defaultValidator.lookupContract( + createCommitContext( + None, + Map.empty, + ) + )(Conversions.decodeContractId(inputContractId)) + } + } + + "lookupKey" should { + val contractKeyInputs = aTransactionEntry.transaction.contractKeyInputs match { + case Left(_) => fail() + case Right(contractKeyInputs) => contractKeyInputs + } + + "return Some when mapping exists" in { + defaultValidator.lookupKey(contractKeyInputs)( + aGlobalKeyWithMaintainers(inputContractKey, inputContractKeyMaintainer) + ) shouldBe Some(Conversions.decodeContractId(inputContractId)) + } + + "return None when mapping does not exist" in { + defaultValidator.lookupKey(contractKeyInputs)( + aGlobalKeyWithMaintainers("nonexistentKey", "nonexistentMaintainer") + ) shouldBe None + } + } + + "lookupPackage" should { + "return the package" in { + val stateKey = DamlStateKey.newBuilder().setPackageId("aPackage").build() + val stateValue = DamlStateValue + .newBuilder() + .setArchive(anArchive) + .build() + val commitContext = createCommitContext( + None, + Map(stateKey -> Some(stateValue)), + ) + + val maybePackage = defaultValidator.lookupPackage(commitContext)(aPackageId) + + maybePackage shouldBe a[Some[_]] + } + + "fail when the package is missing" in { + an[Err.MissingInputState] should be thrownBy defaultValidator.lookupPackage( + createCommitContext( + None, + Map.empty, + ) + )(Ref.PackageId.assertFromString("nonexistentPackageId")) + } + + "fail when the archive is invalid" in { + val stateKey = DamlStateKey.newBuilder().setPackageId("invalidPackage").build() + + val stateValues = Table( + "state values", + DamlStateValue.newBuilder.build(), + DamlStateValue.newBuilder.setArchive(DamlLf.Archive.newBuilder()).build(), + ) + + forAll(stateValues) { stateValue => + an[Err.ArchiveDecodingFailed] should be thrownBy defaultValidator.lookupPackage( + createCommitContext( + None, + Map(stateKey -> Some(stateValue)), + ) + )(Ref.PackageId.assertFromString("invalidPackage")) } } } + "validateCausalMonotonicity" should { + "create StepContinue when causal monotonicity holds" in { + defaultValidator + .validateCausalMonotonicity( + aTransactionEntry, + createCommitContext( + None, + Map( + inputContractIdStateKey -> Some(makeContractIdStateValue()), + contractIdStateKey1 -> Some(aStateValueActiveAt(ledgerEffectiveTime.minusSeconds(1))), + ), + ), + rejections, + ) shouldBe StepContinue(aTransactionEntry) + } + + "reject transaction when causal monotonicity does not hold" in { + val step = defaultValidator + .validateCausalMonotonicity( + aTransactionEntry, + createCommitContext( + None, + Map( + inputContractIdStateKey -> Some(makeContractIdStateValue()), + contractIdStateKey1 -> Some(aStateValueActiveAt(ledgerEffectiveTime.plusSeconds(1))), + ), + ), + rejections, + ) + + val expectedEntry = DamlLogEntry.newBuilder + .setTransactionRejectionEntry( + DamlTransactionRejectionEntry.newBuilder + .setSubmitterInfo(DamlSubmitterInfo.getDefaultInstance) + .setInvalidLedgerTime( + InvalidLedgerTime.newBuilder.setDetails("Causal monotonicity violated") + ) + ) + .build() + step shouldBe StepStop(expectedEntry) + } + } + private def create( contractId: String, signatories: Seq[String] = Seq(aKeyMaintainer), argument: TransactionBuilder.Value = aDummyValue, keyAndMaintainer: Option[(String, String)] = Some(aKey -> aKeyMaintainer), - ): TransactionBuilder.Create = + ): TransactionBuilder.Create = { txBuilder.create( id = contractId, - template = "dummyPackage:DummyModule:DummyTemplate", + template = aTemplateId, argument = argument, signatories = signatories, observers = Seq.empty, key = keyAndMaintainer.map { case (key, maintainer) => lfTuple(maintainer, key) }, ) - - private def checkRejectionReason( - mkReason: String => RejectionReason - )(mismatch: transaction.ReplayMismatch[NodeId, Value.ContractId]) = { - val replayMismatch = LfError.Validation(LfError.Validation.ReplayMismatch(mismatch)) - ModelConformanceValidator.rejectionReasonForValidationError(replayMismatch) shouldBe mkReason( - replayMismatch.msg - ) } } object ModelConformanceValidatorSpec { - private val aKeyMaintainer = "maintainer" + private val inputContractId = "#inputContractId" + private val inputContractIdStateKey = makeContractIdStateKey(inputContractId) + private val aContractId = "#someContractId" + private val contractIdStateKey1 = makeContractIdStateKey(aContractId) + private val inputContractKey = "inputContractKey" + private val inputContractKeyMaintainer = "inputContractKeyMaintainer" private val aKey = "key" + private val aKeyMaintainer = "maintainer" private val aDummyValue = TransactionBuilder.record("field" -> "value") + private val aTemplateId = "dummyPackage:DummyModule:DummyTemplate" + private val aPackageId = Ref.PackageId.assertFromString("aPackage") + + private val aSubmissionSeed = ByteString.copyFromUtf8("a" * 32) + private val ledgerEffectiveTime = + ZonedDateTime.of(2021, 1, 1, 12, 0, 0, 0, ZoneOffset.UTC).toInstant + + private val aContractIdStateValue = { + makeContractIdStateValue().toBuilder + .setContractState( + DamlContractState + .newBuilder() + .setContractInstance( + ContractInstance + .newBuilder() + .setTemplateId( + ValueOuterClass.Identifier + .newBuilder() + .setPackageId("dummyPackage") + .addModuleName("DummyModule") + .addName("DummyTemplate") + ) + .setArgVersioned( + ValueOuterClass.VersionedValue + .newBuilder() + .setVersion(TransactionVersion.VDev.protoValue) + .setValue(ValueOuterClass.Value.newBuilder().setText("dummyValue")) + ) + ) + .build() + ) + .build() + } + + private val aContractInst = Value.ContractInst( + Ref.TypeConName.assertFromString(aTemplateId), + Value.VersionedValue(TransactionVersion.VDev, ValueText("dummyValue")), + "", + ) + + private val anArchive: DamlLf.Archive = { + val pkg = Ast.GenPackage[Expr]( + Map.empty, + Set.empty, + LanguageVersion.default, + Some( + Ast.PackageMetadata( + Ref.PackageName.assertFromString("aPackage"), + Ref.PackageVersion.assertFromString("0.0.0"), + ) + ), + ) + Encode.encodeArchive( + defaultParserParameters.defaultPackageId -> pkg, + defaultParserParameters.languageVersion, + ) + } + + private def txBuilder = TransactionBuilder(TransactionVersion.VDev) + + private def aGlobalKeyWithMaintainers(key: String, maintainer: String) = GlobalKeyWithMaintainers( + GlobalKey.assertBuild( + Ref.TypeConName.assertFromString(aTemplateId), + ValueRecord( + None, + ImmArray( + (None, ValueText(maintainer)), + (None, ValueText(key)), + ), + ), + ), + Set.empty, + ) - private def mkMismatch( - recorded: (Transaction.Transaction, NodeId), - replayed: (Transaction.Transaction, NodeId), - ): ReplayNodeMismatch[NodeId, Value.ContractId] = - ReplayNodeMismatch(recorded._1, recorded._2, replayed._1, replayed._2) - private def mkRecordedMissing( - recorded: Transaction.Transaction, - replayed: (Transaction.Transaction, NodeId), - ): RecordedNodeMissing[NodeId, Value.ContractId] = - RecordedNodeMissing(recorded, replayed._1, replayed._2) - private def mkReplayedMissing( - recorded: (Transaction.Transaction, NodeId), - replayed: Transaction.Transaction, - ): ReplayedNodeMissing[NodeId, Value.ContractId] = - ReplayedNodeMissing(recorded._1, recorded._2, replayed) + private def aStateValueActiveAt(activeAt: Instant) = + DamlStateValue.newBuilder + .setContractState( + DamlContractState.newBuilder.setActiveAt(Conversions.buildTimestamp(activeAt)) + ) + .build } diff --git a/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ContractKeysValidatorSpec.scala b/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/TransactionConsistencyValidatorSpec.scala similarity index 88% rename from ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ContractKeysValidatorSpec.scala rename to ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/TransactionConsistencyValidatorSpec.scala index 3be5e4e39ae4..67effd43b136 100644 --- a/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/ContractKeysValidatorSpec.scala +++ b/ledger/participant-state/kvutils/src/test/suite/scala/com/daml/ledger/participant/state/kvutils/committer/transaction/validation/TransactionConsistencyValidatorSpec.scala @@ -10,6 +10,7 @@ import com.daml.ledger.participant.state.kvutils.Conversions import com.daml.ledger.participant.state.kvutils.DamlKvutils.{ DamlContractKey, DamlContractKeyState, + DamlContractState, DamlStateKey, DamlStateValue, } @@ -29,6 +30,7 @@ import com.daml.ledger.participant.state.kvutils.committer.{ StepResult, StepStop, } +import com.daml.ledger.validator.TestHelper.{makeContractIdStateKey, makeContractIdStateValue} import com.daml.lf.data.ImmArray import com.daml.lf.transaction.SubmittedTransaction import com.daml.lf.transaction.test.TransactionBuilder @@ -36,12 +38,14 @@ import com.daml.lf.transaction.test.TransactionBuilder.{Create, Exercise} import com.daml.lf.value.Value import com.daml.logging.LoggingContext import com.daml.metrics.Metrics +import com.google.protobuf.Timestamp +import org.scalatest.Inside.inside import org.scalatest.matchers.should.Matchers import org.scalatest.prop.TableDrivenPropertyChecks.{forAll, _} import org.scalatest.wordspec.AnyWordSpec -class ContractKeysValidatorSpec extends AnyWordSpec with Matchers { - import ContractKeysValidatorSpec._ +class TransactionConsistencyValidatorSpec extends AnyWordSpec with Matchers { + import TransactionConsistencyValidatorSpec._ private implicit val loggingContext: LoggingContext = LoggingContext.ForTesting @@ -121,7 +125,13 @@ class ContractKeysValidatorSpec extends AnyWordSpec with Matchers { "succeeds when a global contract gets archived before a local contract gets created" in { val globalCid = s"#$freshContractId" val globalCreate = newCreateNodeWithFixedKey(globalCid) - val context = commitContextWithContractStateKeys(conflictingKey -> Some(globalCid)) + val context = createCommitContext( + recordTime = None, + inputs = Map( + makeContractIdStateKey(globalCid) -> Some(makeContractIdStateValue()), + contractStateKey(conflictingKey) -> Some(contractKeyStateValue(globalCid)), + ), + ) val builder = TransactionBuilder() builder.add(archive(globalCreate, Set("Alice"))) builder.add(newCreateNodeWithFixedKey(s"#$freshContractId")) @@ -221,6 +231,30 @@ class ContractKeysValidatorSpec extends AnyWordSpec with Matchers { getTransactionRejectionReason(result).getInconsistent.getDetails rejectionReason should startWith("InconsistentKeys") } + + "fail if a contract is not active anymore" in { + val globalCid = s"#$freshContractId" + val globalCreate = newCreateNodeWithFixedKey(globalCid) + val context = createCommitContext( + recordTime = None, + inputs = Map( + makeContractIdStateKey(globalCid) -> Some( + makeContractIdStateValue().toBuilder + .setContractState( + DamlContractState.newBuilder().setArchivedAt(Timestamp.getDefaultInstance) + ) + .build() + ) + ), + ) + val builder = TransactionBuilder() + builder.add(archive(globalCreate, Set("Alice"))) + val transaction = builder.buildSubmitted() + val result = validate(context, transaction) + inside(result) { case StepStop(logEntry) => + logEntry.getTransactionRejectionEntry.hasInconsistent shouldBe true + } + } } private def newLookupByKeySubmittedTransaction( @@ -274,14 +308,14 @@ class ContractKeysValidatorSpec extends AnyWordSpec with Matchers { ctx: CommitContext, transaction: SubmittedTransaction, )(implicit loggingContext: LoggingContext): StepResult[DamlTransactionEntrySummary] = { - ContractKeysValidator.createValidationStep(rejections)( + TransactionConsistencyValidator.createValidationStep(rejections)( ctx, DamlTransactionEntrySummary(createTransactionEntry(List("Alice"), transaction)), ) } } -object ContractKeysValidatorSpec { +object TransactionConsistencyValidatorSpec { private val aKeyMaintainer = "maintainer" private val aKey = "key" private val aDummyValue = TransactionBuilder.record("field" -> "value")