diff --git a/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouter.scala b/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouter.scala new file mode 100644 index 0000000000..36a34cb4c4 --- /dev/null +++ b/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouter.scala @@ -0,0 +1,533 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.participant.protocol.validation + +import com.digitalasset.canton.LfPartyId +import com.digitalasset.canton.config.RequireTypes.PositiveInt +import com.digitalasset.canton.data.{ViewParticipantData, ViewPosition} +import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.participant.protocol.validation.ExternalCallConsistencyChecker.{ + ExternalCallOccurrence, + Inconsistency, +} +import com.digitalasset.canton.participant.util.DAMLe +import com.digitalasset.canton.protocol.RequestId +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.MonadUtil +import com.digitalasset.daml.lf.data.Bytes + +import scala.concurrent.ExecutionContext + +/** A received view together with the confirming parties of the view that this participant hosts. + */ +private[validation] final case class ViewWithHostedParties( + viewPosition: ViewPosition, + validationResult: ViewValidationResult, + hostedConfirmingParties: Set[LfPartyId], +) + +/** A recorded external-call result selected for re-validation, together with the hosted checking + * parties whose approval of the view depends on it. + */ +private[validation] final case class ExternalCallValidationOccurrence( + viewPosition: ViewPosition, + result: ViewParticipantData.ViewExternalCallResult, + hostedCheckingParties: Set[LfPartyId], +) + +/** An abstention from confirming a view because a recorded external-call result could not be + * re-validated. + */ +private[validation] final case class ExternalCallValidationAbstain( + viewPosition: ViewPosition, + reason: String, +) + +/** Per-party outcome of re-validating recorded external-call results: `rejects` holds the + * re-validation mismatches (as inconsistencies carrying the recorded and the computed output), + * `abstains` holds the per-view abstentions where a result could not be re-validated. + */ +private[validation] final case class ExternalCallValidationRoutes( + rejects: Map[LfPartyId, Seq[Inconsistency]], + abstains: Map[LfPartyId, Seq[ExternalCallValidationAbstain]], +) { + + /** Yields for every party in `hostedConfirmingParties` an inconsistency in `rejects(party)` that + * has the given `viewPosition` (provided it exists). Picks the smallest inconsistency per party + * by [[ExternalCallConsistencyChecker.orderInconsistency]] so that the result is deterministic. + */ + def rejectsForView( + viewPosition: ViewPosition, + hostedConfirmingParties: Set[LfPartyId], + ): Seq[(LfPartyId, Inconsistency)] = + rejects.toSeq.sortBy { case (party, _) => party }.flatMap { + case (party, inconsistencies) if hostedConfirmingParties(party) => + inconsistencies + .filter(_.occurrences.exists(_.viewPosition == viewPosition)) + .minByOption(identity)(ExternalCallConsistencyChecker.orderInconsistency) + .map(party -> _) + case _ => None + } + + /** Yields for every party in `nonRejectingConfirmingParties` the abstain reason in + * `abstains(party)` recorded for the given `viewPosition` (provided it exists). Picks the + * smallest reason per party so that the result is deterministic. + * + * @param nonRejectingConfirmingParties + * The hosted confirming parties that are not already rejecting for this view. + */ + def abstainsForView( + viewPosition: ViewPosition, + nonRejectingConfirmingParties: Set[LfPartyId], + ): Seq[(LfPartyId, String)] = + abstains.toSeq.sortBy { case (party, _) => party }.flatMap { + case (party, abstains) if nonRejectingConfirmingParties(party) => + abstains + .filter(_.viewPosition == viewPosition) + .minByOption(_.reason) + .map(abstain => party -> abstain.reason) + case _ => None + } +} + +/** Per-request external-call routing state. + * + * Aggregates the two disagreement sources of a request, namely the results recorded across the + * received views (checked by [[ExternalCallConsistencyChecker]]) and the disagreements raised + * during replay reinterpretation, and computes which hosted confirming parties must reject because + * of them (or which model-conformance rejections they suppress and partially replace, see + * [[isRoutableModelConformanceError]]). + */ +private[validation] final class ExternalCallRoutingContext( + recordedResultDisagreements: Seq[DAMLe.ExternalCallRecordedResultDisagreement], + viewsWithHostedParties: Seq[ViewWithHostedParties], + viewValidationResults: Map[ViewPosition, ViewValidationResult], +) { + private lazy val hasAnyVisibleExternalCallResults: Boolean = + viewValidationResults.valuesIterator.exists { viewValidationResult => + val viewParticipantData = viewValidationResult.view.viewParticipantData + viewParticipantData.externalCallResults.nonEmpty + } + + private lazy val recordedExternalCallDisagreementInconsistencies + : Map[LfPartyId, Seq[(DAMLe.ExternalCallRecordedResultDisagreement, Inconsistency)]] = + ExternalCallResponseRouter.recordedExternalCallDisagreementInconsistencies( + recordedResultDisagreements, + viewsWithHostedParties, + ) + + private lazy val routableRecordedExternalCallDisagreements + : Set[DAMLe.ExternalCallRecordedResultDisagreement] = + recordedExternalCallDisagreementInconsistencies.valuesIterator + .flatMap(_.iterator) + .map(_._1) + .toSet + + /** Consistency of the external-call results recorded across all received views, checked for the + * union of the hosted confirming parties of all views. See + * [[ExternalCallConsistencyChecker.Result]]. + */ + lazy val recordedConsistencyResult: ExternalCallConsistencyChecker.Result = + if (hasAnyVisibleExternalCallResults) { + val allHostedConfirmingParties = + viewsWithHostedParties.flatMap(_.hostedConfirmingParties).toSet + ExternalCallConsistencyChecker.check( + viewValidationResults, + allHostedConfirmingParties, + ) + } else ExternalCallConsistencyChecker.Result.empty + + /** Whether `error` is an external-call replay disagreement for which some hosted confirming party + * checks a matching occurrence. Such an error is expected to suppress the generic (malformed) + * model-conformance rejection of the request; in its place, the hosted confirming parties that + * check a matching occurrence reject per view with the disagreement inconsistency provided by + * [[inconsistenciesForView]]. Note that the routed rejections cover at most as many parties as + * the suppressed blanket rejection, and in general fewer: views without a matching occurrence + * and confirming parties that do not check the result are not rejected through this path. + */ + def isRoutableModelConformanceError(error: ModelConformanceChecker.Error): Boolean = + ExternalCallResponseRouter + .externalCallRecordedResultDisagreement(error) + .exists(routableRecordedExternalCallDisagreements) + + /** Yields for every party in `hostedConfirmingParties` at most one inconsistency among the + * disagreeing external-call results that the party checks in the given view: from the results + * recorded across views where the party is affected there, otherwise from the replay + * disagreements. Parties in the result are expected to reject the view with the returned + * inconsistency. + */ + def inconsistenciesForView( + viewPosition: ViewPosition, + hostedConfirmingParties: Set[LfPartyId], + ): Seq[(LfPartyId, Inconsistency)] = { + val recordedResultInconsistencies = + recordedConsistencyResult.hostedInconsistencies.toSeq.flatMap { + case (party, inconsistencies) if hostedConfirmingParties(party) => + inconsistencies + .find(_.occurrences.exists(_.viewPosition == viewPosition)) + .map(party -> _) + case _ => None + } + + val partiesWithRecordedResultInconsistencies = + recordedResultInconsistencies.map(_._1).toSet + + val recordedReplayInconsistencies = + recordedExternalCallDisagreementInconsistencies.toSeq.flatMap { + case (party, disagreementInconsistencies) + if hostedConfirmingParties(party) && + !partiesWithRecordedResultInconsistencies(party) => + disagreementInconsistencies + .find(_._2.occurrences.exists(_.viewPosition == viewPosition)) + .map { case (_, inconsistency) => party -> inconsistency } + case _ => None + } + + recordedResultInconsistencies ++ + recordedReplayInconsistencies + } +} + +/** Routes external-call validation outcomes to per-view, per-party local verdicts. + * + * External-call verdicts derive from three sources: + * - disagreements between the results recorded across the views of the transaction, computed by + * [[ExternalCallConsistencyChecker]] (see + * [[ExternalCallRoutingContext.recordedConsistencyResult]]), + * - disagreements within the replay data of a single reinterpretation, that is, of a view + * together with its subviews, surfaced as + * [[com.digitalasset.canton.participant.util.DAMLe.ExternalCallRecordedResultDisagreement]] + * model-conformance errors (see [[ExternalCallRoutingContext.inconsistenciesForView]]), + * - re-validation of undisputed recorded results against the extension service (see + * [[validationOccurrences]] and [[validateExternalCalls]]). + * + * Intended data flow for a request: construct an [[ExternalCallRoutingContext]] from the view + * validation results and the reinterpretation disagreements; alarm on all visible recorded + * disagreements, including those that affect no hosted party + * ([[reportVisibleRecordedDisagreementAlarms]]); obtain the per-party disagreement rejections of + * every view ([[ExternalCallRoutingContext.inconsistenciesForView]]); for the views that would + * otherwise be approved, select the candidate occurrences for re-validation + * ([[validationOccurrences]]) and re-run them against the extension service + * ([[validateExternalCalls]]). The resulting [[ExternalCallValidationRoutes]] are folded into the + * per-view verdicts via [[ExternalCallValidationRoutes.rejectsForView]] and + * [[ExternalCallValidationRoutes.abstainsForView]], with rejections taking precedence: + * [[ExternalCallValidationRoutes.abstainsForView]] is fed only the parties that are not already + * rejecting. Net effect per hosted confirming party: it rejects the disagreements and mismatches + * among the occurrences it checks, abstains where re-validation was not possible, and approves + * otherwise. + * + * Stateless helpers that do not need the validator live on the companion object. + * + * @param externalCallValidator + * The validator used to re-run external calls during validation. + * @param externalCallValidationParallelism + * Bounds the number of concurrent validator calls in [[validateExternalCalls]]. + */ +private[validation] class ExternalCallResponseRouter( + externalCallValidator: ExternalCallValidator, + externalCallValidationParallelism: PositiveInt, + protected val loggerFactory: NamedLoggerFactory, +) extends NamedLogging { + + import ExternalCallResponseRouter.* + + /** Selects the recorded external-call results that a view is expected to re-validate against the + * extension service before its hosted parties approve it. + * + * For every view in `approvingViewPositions` (mapping the views that would otherwise be approved + * to their approving hosted parties), every recorded result of the view is selected for the + * parties that check it and would approve, excluding the parties that already reject the view + * because of a disagreement ([[ExternalCallRoutingContext.inconsistenciesForView]]). Results + * with no such party yield no occurrence. + */ + def validationOccurrences( + viewsWithHostedParties: Seq[ViewWithHostedParties], + approvingViewPositions: Map[ViewPosition, Set[LfPartyId]], + externalCallRouting: ExternalCallRoutingContext, + ): Seq[ExternalCallValidationOccurrence] = + viewsWithHostedParties + .sortBy(_.viewPosition)(ViewPosition.orderViewPosition.toOrdering) + .flatMap { viewWithHostedParties => + approvingViewPositions.get(viewWithHostedParties.viewPosition).toList.flatMap { + approvingParties => + val viewParticipantData = + viewWithHostedParties.validationResult.view.viewParticipantData + if (viewParticipantData.externalCallResults.isEmpty) Seq.empty + else { + val alreadyRejectedParties = + externalCallRouting + .inconsistenciesForView(viewWithHostedParties.viewPosition, approvingParties) + .map(_._1) + .toSet + + viewParticipantData.externalCallResults.flatMap { result => + val affectedParties = + result.checkingParties.intersect(approvingParties) -- alreadyRejectedParties + Option.when(affectedParties.nonEmpty)( + ExternalCallValidationOccurrence( + viewWithHostedParties.viewPosition, + result, + affectedParties, + ) + ) + } + } + } + } + + /** Re-validates the selected occurrences against the extension service and routes the verdicts to + * parties. + * + * Occurrences are grouped by their semantic call + * ([[com.digitalasset.canton.participant.util.DAMLe.ExternalCallKey]]). Only keys whose selected + * occurrences all record the same output are re-validated: once per key, with the number of + * concurrent validator calls bounded by `externalCallValidationParallelism`. Keys with + * disagreeing recorded outputs are not re-validated; those disagreements are covered by + * [[ExternalCallRoutingContext]] (per-party rejections where a hosted party checks conflicting + * occurrences, visible-disagreement alarms otherwise). + * + * Verdict routing, applied to every hosted checking party of every occurrence of the key: + * [[ExternalCallValidator.Mismatched]] yields a rejection (an [[Inconsistency]] carrying the + * recorded and the computed output), [[ExternalCallValidator.UnableToValidate]] yields an + * abstention with the given reason, and [[ExternalCallValidator.Matched]] yields nothing. The + * per-party sequences of the returned [[ExternalCallValidationRoutes]] are deduplicated and + * sorted, so the routes are deterministic. + */ + def validateExternalCalls( + occurrences: Seq[ExternalCallValidationOccurrence] + )(implicit + traceContext: TraceContext, + ec: ExecutionContext, + ): FutureUnlessShutdown[ExternalCallValidationRoutes] = { + val keyedOccurrences = + occurrences.map(occurrence => + KeyedValidationOccurrence( + DAMLe.ExternalCallKey.fromResult(occurrence.result.result), + occurrence, + ) + ) + + val outputByKey = + keyedOccurrences + .groupMap(_.key)(_.output) + .view + .mapValues(_.toSet) + .toMap + + val keysWithSingleOutput = + outputByKey.toSeq + .flatMap { case (key, outputs) => + outputs.toList match { + case recordedOutput :: Nil => Some(key -> recordedOutput) + case _ => None + } + } + .sortBy { case (key, _) => key } + + MonadUtil + .parTraverseWithLimit(externalCallValidationParallelism)(keysWithSingleOutput) { + case (key, recordedOutput) => + externalCallValidator.validate(key, recordedOutput).map(key -> _) + } + .map { validationResults => + val resultsByKey = validationResults.toMap + ExternalCallValidationRoutes( + rejects = rejectsFrom(keyedOccurrences, resultsByKey), + abstains = abstainsFrom(keyedOccurrences, resultsByKey), + ) + } + } + + /** Emits one [[ExternalCallValidationError.ExternalCallResultDisagreementAlarm]] per disagreement + * among the recorded results visible to this participant, independently of whether a hosted + * party is affected. + */ + def reportVisibleRecordedDisagreementAlarms( + requestId: RequestId, + recordedConsistencyResult: ExternalCallConsistencyChecker.Result, + )(implicit traceContext: TraceContext): Unit = + recordedConsistencyResult.visibleInconsistencies.foreach { inconsistency => + ExternalCallValidationError.ExternalCallResultDisagreementAlarm + .Warn(s"Observed inconsistent external call results: ${inconsistency.description}") + .logWithContext(Map("requestId" -> requestId.toString)) + } +} + +private[validation] object ExternalCallResponseRouter { + + private val orderRecordedResultDisagreement + : Ordering[DAMLe.ExternalCallRecordedResultDisagreement] = + Ordering + .by[DAMLe.ExternalCallRecordedResultDisagreement, DAMLe.ExternalCallKey](_.key) + .orElseBy(_.outputs)(ExternalCallConsistencyChecker.orderOutputSets) + + private val orderExternalCallValidationAbstain: Ordering[ExternalCallValidationAbstain] = + Ordering + .by[ExternalCallValidationAbstain, ViewPosition](_.viewPosition)( + ViewPosition.orderViewPosition.toOrdering + ) + .orElseBy(_.reason) + + def externalCallRecordedResultDisagreement( + error: ModelConformanceChecker.Error + ): Option[DAMLe.ExternalCallRecordedResultDisagreement] = error match { + case ModelConformanceChecker.DAMLeError( + disagreement: DAMLe.ExternalCallRecordedResultDisagreement, + _, + ) => + Some(disagreement) + case _ => None + } + + def recordedExternalCallDisagreementInconsistencies( + disagreements: Seq[DAMLe.ExternalCallRecordedResultDisagreement], + viewsWithHostedParties: Seq[ViewWithHostedParties], + ): Map[LfPartyId, Seq[(DAMLe.ExternalCallRecordedResultDisagreement, Inconsistency)]] = { + val orderedDisagreements = + disagreements.distinct.sorted(orderRecordedResultDisagreement) + + val orderedViews = + viewsWithHostedParties.sortBy(_.viewPosition)(ViewPosition.orderViewPosition.toOrdering) + + val routed = + orderedDisagreements.flatMap { disagreement => + occurrencesByParty(disagreement, orderedViews).toSeq.map { case (party, occurrences) => + party -> (disagreement -> Inconsistency( + disagreement.key, + disagreement.outputs, + occurrences, + )) + } + } + + routed.groupMap(_._1)(_._2) + } + + /** For a single recorded disagreement, the occurrences of its external call that each hosted + * confirming party can see across all views. A party sees an occurrence when it is a checking + * party of a matching external-call result in a view it confirms. + */ + private def occurrencesByParty( + disagreement: DAMLe.ExternalCallRecordedResultDisagreement, + orderedViews: Seq[ViewWithHostedParties], + ): Map[LfPartyId, Set[ExternalCallOccurrence]] = + orderedViews + .flatMap { view => + view.validationResult.view.viewParticipantData.externalCallResults + .filter(result => matchesDisagreement(disagreement, result)) + .flatMap { result => + val occurrence = ExternalCallOccurrence( + view.viewPosition, + result.exerciseIndex, + result.callIndex, + ) + result.checkingParties + .intersect(view.hostedConfirmingParties) + .toSeq + .map(_ -> occurrence) + } + } + .groupMap(_._1)(_._2) + .view + .mapValues(_.toSet) + .toMap + + /** Whether `result` records the same external call (and one of the disagreeing outputs) as + * `disagreement`. + */ + private def matchesDisagreement( + disagreement: DAMLe.ExternalCallRecordedResultDisagreement, + result: ViewParticipantData.ViewExternalCallResult, + ): Boolean = + DAMLe.ExternalCallKey.fromResult(result.result) == disagreement.key && + disagreement.outputs(result.result.output) + + /** An external-call occurrence paired with the key it re-validates under. */ + private final case class KeyedValidationOccurrence( + key: DAMLe.ExternalCallKey, + occurrence: ExternalCallValidationOccurrence, + ) { + def output: Bytes = occurrence.result.result.output + def hostedCheckingParties: Set[LfPartyId] = occurrence.hostedCheckingParties + def externalCallOccurrence: ExternalCallOccurrence = ExternalCallOccurrence( + occurrence.viewPosition, + occurrence.result.exerciseIndex, + occurrence.result.callIndex, + ) + } + + private final case class RejectRow( + party: LfPartyId, + key: DAMLe.ExternalCallKey, + outputs: Set[Bytes], + occurrence: ExternalCallOccurrence, + ) + + private final case class AbstainRow( + party: LfPartyId, + abstain: ExternalCallValidationAbstain, + ) + + /** Per-party rejections for occurrences whose re-validation produced a mismatching output. */ + private def rejectsFrom( + keyedOccurrences: Seq[KeyedValidationOccurrence], + resultsByKey: Map[DAMLe.ExternalCallKey, ExternalCallValidator.Result], + ): Map[LfPartyId, Seq[Inconsistency]] = { + val rejectRows = keyedOccurrences.flatMap { keyedOccurrence => + resultsByKey.get(keyedOccurrence.key).toList.flatMap { + case ExternalCallValidator.Mismatched(computedOutput, recordedOutput) => + val outputs = Set(computedOutput, recordedOutput) + keyedOccurrence.hostedCheckingParties.toSeq.sorted.map(party => + RejectRow( + party, + keyedOccurrence.key, + outputs, + keyedOccurrence.externalCallOccurrence, + ) + ) + + case _ => + Seq.empty + } + } + + rejectRows + .groupMap(row => (row.party, row.key, row.outputs))(_.occurrence) + .toSeq + .groupMap { case ((party, _, _), _) => party } { case ((_, key, outputs), occurrences) => + Inconsistency(key, outputs, occurrences.toSet) + } + .view + .mapValues(_.sorted(ExternalCallConsistencyChecker.orderInconsistency)) + .toMap + } + + /** Per-party abstentions for occurrences whose re-validation could not be performed. */ + private def abstainsFrom( + keyedOccurrences: Seq[KeyedValidationOccurrence], + resultsByKey: Map[DAMLe.ExternalCallKey, ExternalCallValidator.Result], + ): Map[LfPartyId, Seq[ExternalCallValidationAbstain]] = { + val abstainRows = keyedOccurrences.flatMap { keyedOccurrence => + resultsByKey.get(keyedOccurrence.key).toList.flatMap { + case ExternalCallValidator.UnableToValidate(reason) => + val abstain = ExternalCallValidationAbstain( + keyedOccurrence.occurrence.viewPosition, + reason, + ) + keyedOccurrence.hostedCheckingParties.toSeq.sorted + .map(party => AbstainRow(party, abstain)) + + case _ => + Seq.empty + } + } + + abstainRows.distinct + .groupMap(_.party)(_.abstain) + .view + .mapValues(_.sorted(orderExternalCallValidationAbstain)) + .toMap + } +} diff --git a/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallConsistencyCheckerTest.scala b/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallConsistencyCheckerTest.scala index d26e38c7b6..8ff3f046a0 100644 --- a/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallConsistencyCheckerTest.scala +++ b/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallConsistencyCheckerTest.scala @@ -24,15 +24,15 @@ class ExternalCallConsistencyCheckerTest protected val factory: ExampleTransactionFactory = new ExampleTransactionFactory()() - private val partyA = ExampleTransactionFactory.signatory - private val partyB = ExampleTransactionFactory.submitter - private val partyC = ExampleTransactionFactory.observer + private val partyA: LfPartyId = ExampleTransactionFactory.signatory + private val partyB: LfPartyId = ExampleTransactionFactory.submitter + private val partyC: LfPartyId = ExampleTransactionFactory.observer private def check( leftCheckingParties: Set[LfPartyId], rightCheckingParties: Set[LfPartyId], hostedParties: Set[LfPartyId], - rightResult: ExternalCallResult = otherExternalCallOutput, + rightResult: ExternalCallResult = otherExternalCallResult, ): ExternalCallConsistencyChecker.Result = { val example = factory.MultipleRoots val left = withExternalCallResults( @@ -102,7 +102,7 @@ class ExternalCallConsistencyCheckerTest val visibleInconsistency = result.visibleInconsistencies.loneElement visibleInconsistency.outputs shouldBe Set( externalCallResult.output, - otherExternalCallOutput.output, + otherExternalCallResult.output, ) } @@ -122,7 +122,7 @@ class ExternalCallConsistencyCheckerTest leftCheckingParties = Set(partyA), rightCheckingParties = Set(partyA), hostedParties = Set(partyA), - rightResult = otherExternalCallOutput.copy(functionId = "other-function"), + rightResult = otherExternalCallResult.copy(functionId = "other-function"), ) result.inconsistentParties shouldBe Set.empty @@ -141,7 +141,7 @@ class ExternalCallConsistencyCheckerTest ), externalCallViewResult( exerciseIndex = 0, - result = otherExternalCallOutput, + result = otherExternalCallResult, checkingParties = Set(partyA), callIndex = 1, ), @@ -155,7 +155,7 @@ class ExternalCallConsistencyCheckerTest result.inconsistentParties shouldBe Set(partyA) val inconsistency = result.hostedInconsistencies(partyA).loneElement - inconsistency.outputs shouldBe Set(externalCallResult.output, otherExternalCallOutput.output) + inconsistency.outputs shouldBe Set(externalCallResult.output, otherExternalCallResult.output) inconsistency.occurrences.map(occurrence => occurrence.exerciseIndex -> occurrence.callIndex ) shouldBe Set( diff --git a/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouterTest.scala b/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouterTest.scala new file mode 100644 index 0000000000..b4fc5879bb --- /dev/null +++ b/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouterTest.scala @@ -0,0 +1,488 @@ +// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.participant.protocol.validation + +import com.digitalasset.canton.config.RequireTypes.PositiveInt +import com.digitalasset.canton.data.{TransactionView, ViewPosition} +import com.digitalasset.canton.participant.util.DAMLe +import com.digitalasset.canton.protocol.* +import com.digitalasset.canton.protocol.ExampleTransactionFactory.{signatory, submitter} +import com.digitalasset.canton.version.ProtocolVersion +import com.digitalasset.canton.{BaseTestWordSpec, HasExecutionContext, LfPartyId} +import com.digitalasset.daml.lf.data.Bytes +import com.digitalasset.daml.lf.transaction.ExternalCallResult + +/** Direct unit tests for [[ExternalCallResponseRouter]] and its routing collaborators + * ([[ExternalCallRoutingContext]], [[ExternalCallValidationRoutes]]). + * + * These exercise the router in isolation -- without going through + * [[TransactionConfirmationResponsesFactory]] -- and assert on the router's own outputs + * ([[ExternalCallValidationOccurrence]]s, [[ExternalCallValidationRoutes]] and per-party + * [[ExternalCallConsistencyChecker.Inconsistency]]s) rather than on assembled + * `ConfirmationResponse`s. Verdict merging / response assembly remains covered by + * [[TransactionConfirmationResponsesFactoryExternalCallTest]]. + */ +final class ExternalCallResponseRouterTest + extends BaseTestWordSpec + with HasExecutionContext + with ExternalCallValidationTestUtil { + + protected val factory: ExampleTransactionFactory = + new ExampleTransactionFactory(versionOverride = Some(ProtocolVersion.dev))() + + private def router( + externalCallValidator: ExternalCallValidator = matchingExternalCallValidator + ): ExternalCallResponseRouter = + new ExternalCallResponseRouter( + externalCallValidator, + PositiveInt.tryCreate(8), + loggerFactory, + ) + + private val externalCallKey: DAMLe.ExternalCallKey = + DAMLe.ExternalCallKey.fromResult(externalCallResult) + + private def hostedView( + viewPosition: ViewPosition, + view: TransactionView, + hostedConfirmingParties: Set[LfPartyId], + ): ViewWithHostedParties = + ViewWithHostedParties(viewPosition, validationResult(view), hostedConfirmingParties) + + private def routingContext( + viewsWithHostedParties: Seq[ViewWithHostedParties], + recordedDisagreements: Seq[DAMLe.ExternalCallRecordedResultDisagreement] = Seq.empty, + ): ExternalCallRoutingContext = + new ExternalCallRoutingContext( + recordedDisagreements, + viewsWithHostedParties, + viewsWithHostedParties.map(view => view.viewPosition -> view.validationResult).toMap, + ) + + /** Mirrors the factory's `approvingViewPositions`: every hosted confirming party approves. */ + private def approvingAll( + viewsWithHostedParties: Seq[ViewWithHostedParties] + ): Map[ViewPosition, Set[LfPartyId]] = + viewsWithHostedParties.map(view => view.viewPosition -> view.hostedConfirmingParties).toMap + + /** Two views recording the same external call with disagreeing outputs, both seen by `submitter`. + */ + private def conflictingViews( + hostedConfirmingParties: Set[LfPartyId] + ): Seq[ViewWithHostedParties] = { + val example = factory.MultipleRoots + val confirmers = Set(submitter, signatory) + val left = withExternalCallResults( + withConfirmers(example.rootViews(4), confirmers), + Seq(externalCallViewResult(exerciseIndex = 0, externalCallResult, Set(submitter))), + ) + val right = withExternalCallResults( + withConfirmers(example.rootViews(5), confirmers), + Seq(externalCallViewResult(exerciseIndex = 1, otherExternalCallResult, Set(submitter))), + ) + Seq( + hostedView(leftViewPosition, left, hostedConfirmingParties), + hostedView(rightViewPosition, right, hostedConfirmingParties), + ) + } + + "ExternalCallResponseRouter local validation" should { + "reject locally validated output mismatches for otherwise approving hosted checking parties" in { + val example = factory.MultipleRoots + val confirmers = Set(submitter, signatory) + val view = withExternalCallResults( + withConfirmers(example.rootViews(4), confirmers), + Seq(externalCallViewResult(exerciseIndex = 0, externalCallResult, Set(submitter))), + ) + val viewsWithHostedParties = Seq(hostedView(leftViewPosition, view, confirmers)) + val validator = new RecordingExternalCallValidator( + Map( + externalCallKey -> ExternalCallValidator.Mismatched( + computedOutput = otherExternalCallResult.output, + recordedOutput = externalCallResult.output, + ) + ) + ) + val sut = router(validator) + + val occurrences = sut.validationOccurrences( + viewsWithHostedParties, + approvingAll(viewsWithHostedParties), + routingContext(viewsWithHostedParties), + ) + // The checking party is the only affected party for the single recorded call. + val occurrence = occurrences.loneElement + occurrence.viewPosition shouldBe leftViewPosition + occurrence.result.result shouldBe externalCallResult + occurrence.hostedCheckingParties shouldBe Set(submitter) + + val routes = sut.validateExternalCalls(occurrences).futureValueUS + + validator.observed shouldBe Seq(externalCallKey -> externalCallResult.output) + routes.rejects.keySet shouldBe Set(submitter) + val inconsistency = routes.rejects(submitter).loneElement + inconsistency.key shouldBe externalCallKey + inconsistency.outputs shouldBe Set(externalCallResult.output, otherExternalCallResult.output) + routes.abstains shouldBe empty + + // Routed to the affected view for the checking party only; the co-confirmer is left to approve. + routes.rejectsForView(leftViewPosition, confirmers).map(_._1) shouldBe Seq(submitter) + } + + "abstain when locally responsible validation cannot obtain comparable output bytes" in { + val example = factory.MultipleRoots + val confirmers = Set(submitter, signatory) + val view = withExternalCallResults( + withConfirmers(example.rootViews(4), confirmers), + Seq(externalCallViewResult(exerciseIndex = 0, externalCallResult, Set(submitter))), + ) + val viewsWithHostedParties = Seq(hostedView(leftViewPosition, view, confirmers)) + val validator = new RecordingExternalCallValidator( + Map( + externalCallKey -> ExternalCallValidator.UnableToValidate( + "extension service is not configured" + ) + ) + ) + val sut = router(validator) + + val occurrences = sut.validationOccurrences( + viewsWithHostedParties, + approvingAll(viewsWithHostedParties), + routingContext(viewsWithHostedParties), + ) + val routes = sut.validateExternalCalls(occurrences).futureValueUS + + validator.observed shouldBe Seq(externalCallKey -> externalCallResult.output) + routes.rejects shouldBe empty + routes.abstains.keySet shouldBe Set(submitter) + val abstain = routes.abstains(submitter).loneElement + abstain.viewPosition shouldBe leftViewPosition + abstain.reason should include("extension service is not configured") + + routes + .abstainsForView(leftViewPosition, confirmers) + .map(_._1) shouldBe Seq(submitter) + } + + "scope local external-call validation abstains to the affected view" in { + val example = factory.MultipleRoots + val confirmers = Set(submitter) + val left = withExternalCallResults( + withConfirmers(example.rootViews(4), confirmers), + Seq(externalCallViewResult(exerciseIndex = 0, externalCallResult, Set(submitter))), + ) + val right = withConfirmers(example.rootViews(5), confirmers) + val viewsWithHostedParties = Seq( + hostedView(leftViewPosition, left, confirmers), + hostedView(rightViewPosition, right, confirmers), + ) + val validator = new RecordingExternalCallValidator( + Map( + externalCallKey -> ExternalCallValidator.UnableToValidate( + "extension service is not configured" + ) + ) + ) + val sut = router(validator) + + val occurrences = sut.validationOccurrences( + viewsWithHostedParties, + approvingAll(viewsWithHostedParties), + routingContext(viewsWithHostedParties), + ) + // Only the left view carries an external call result. + occurrences.loneElement.viewPosition shouldBe leftViewPosition + + val routes = sut.validateExternalCalls(occurrences).futureValueUS + + validator.observed shouldBe Seq(externalCallKey -> externalCallResult.output) + routes.abstains(submitter).loneElement.viewPosition shouldBe leftViewPosition + routes + .abstainsForView(leftViewPosition, confirmers) + .map(_._1) shouldBe Seq(submitter) + // The abstain does not leak into the unaffected view. + routes + .abstainsForView(rightViewPosition, confirmers) shouldBe empty + } + + "not locally validate external calls when no hosted confirmer is a checking party" in { + val example = factory.MultipleRoots + val view = withExternalCallResults( + withConfirmers(example.rootViews(4), Set(submitter, signatory)), + Seq(externalCallViewResult(exerciseIndex = 0, externalCallResult, Set(signatory))), + ) + // signatory is the checking party but is not hosted as a confirmer here. + val viewsWithHostedParties = Seq(hostedView(leftViewPosition, view, Set(submitter))) + val validator = new RecordingExternalCallValidator( + Map( + externalCallKey -> ExternalCallValidator.Mismatched( + computedOutput = otherExternalCallResult.output, + recordedOutput = externalCallResult.output, + ) + ) + ) + val sut = router(validator) + + val occurrences = sut.validationOccurrences( + viewsWithHostedParties, + approvingAll(viewsWithHostedParties), + routingContext(viewsWithHostedParties), + ) + occurrences shouldBe empty + + val routes = sut.validateExternalCalls(occurrences).futureValueUS + validator.observed shouldBe empty + routes.rejects shouldBe empty + routes.abstains shouldBe empty + } + + "deduplicate local external-call validation by semantic key and route the result to all occurrences" in { + val example = factory.MultipleRoots + val confirmers = Set(submitter, signatory) + val left = withExternalCallResults( + withConfirmers(example.rootViews(4), confirmers), + Seq(externalCallViewResult(exerciseIndex = 0, externalCallResult, Set(submitter))), + ) + val right = withExternalCallResults( + withConfirmers(example.rootViews(5), confirmers), + // Same semantic key and output as `left`, but a distinct occurrence (exerciseIndex 1). + Seq(externalCallViewResult(exerciseIndex = 1, externalCallResult, Set(submitter))), + ) + val viewsWithHostedParties = Seq( + hostedView(leftViewPosition, left, confirmers), + hostedView(rightViewPosition, right, confirmers), + ) + val validator = new RecordingExternalCallValidator( + Map( + externalCallKey -> ExternalCallValidator.Mismatched( + computedOutput = otherExternalCallResult.output, + recordedOutput = externalCallResult.output, + ) + ) + ) + val sut = router(validator) + + val occurrences = sut.validationOccurrences( + viewsWithHostedParties, + approvingAll(viewsWithHostedParties), + routingContext(viewsWithHostedParties), + ) + occurrences.map(_.viewPosition).toSet shouldBe Set(leftViewPosition, rightViewPosition) + + val routes = sut.validateExternalCalls(occurrences).futureValueUS + + // The validator is consulted exactly once for the shared semantic key. + validator.observed shouldBe Seq(externalCallKey -> externalCallResult.output) + routes.rejects.keySet shouldBe Set(submitter) + val inconsistency = routes.rejects(submitter).loneElement + inconsistency.occurrences.map(_.viewPosition) shouldBe Set( + leftViewPosition, + rightViewPosition, + ) + + // The single validation result is routed to every affected view. + routes.rejectsForView(leftViewPosition, confirmers).map(_._1) shouldBe Seq(submitter) + routes.rejectsForView(rightViewPosition, confirmers).map(_._1) shouldBe Seq(submitter) + } + + "prefer recorded external-call disagreements over local external-call validation" in { + val viewsWithHostedParties = conflictingViews(Set(submitter, signatory)) + val routing = routingContext(viewsWithHostedParties) + val validator = new RecordingExternalCallValidator( + Map( + externalCallKey -> ExternalCallValidator.UnableToValidate( + "extension service is not configured" + ) + ) + ) + val sut = router(validator) + + // The visible recorded disagreement already rejects the checking party. + routing.recordedConsistencyResult.hostedInconsistencies.keySet shouldBe Set(submitter) + routing.recordedConsistencyResult.visibleInconsistencies should have size 1 + routing + .inconsistenciesForView(leftViewPosition, Set(submitter, signatory)) + .map(_._1) shouldBe Seq(submitter) + + // Hence the locally-responsible party is already rejected and produces no validation work... + val occurrences = sut.validationOccurrences( + viewsWithHostedParties, + approvingAll(viewsWithHostedParties), + routing, + ) + occurrences shouldBe empty + + val routes = sut.validateExternalCalls(occurrences).futureValueUS + // ...so the local external-call validator is never invoked. + validator.observed shouldBe empty + routes.rejects shouldBe empty + routes.abstains shouldBe empty + + // The visible disagreement is still alarmed. + assertRecordedDisagreementAlarms() { + sut.reportVisibleRecordedDisagreementAlarms(requestId, routing.recordedConsistencyResult) + } + } + } + + "ExternalCallResponseRouter recorded-disagreement routing" should { + "not attribute external-call disagreements to unrelated hosted views" in { + val example = factory.MultipleRoots + val confirmers = Set(submitter, signatory) + val unrelatedView = withConfirmers(example.rootViews(3), confirmers) + val viewsWithHostedParties = + conflictingViews(confirmers) :+ + hostedView(unrelatedViewPosition, unrelatedView, confirmers) + val routing = routingContext(viewsWithHostedParties) + + // The conflicting views attribute the disagreement to the checking party. + routing + .inconsistenciesForView(leftViewPosition, confirmers) + .map(_._1) shouldBe Seq(submitter) + // The unrelated view (no external call results) is not attributed any disagreement. + routing.inconsistenciesForView(unrelatedViewPosition, confirmers) shouldBe empty + } + + "emit external-call disagreement responses for every affected view" in { + val example = factory.MultipleRoots + val confirmers = Set(submitter, signatory) + val firstCall = externalCallResult.copy(functionId = "function-a") + val secondCall = externalCallResult.copy(functionId = "function-b") + val firstKey = DAMLe.ExternalCallKey.fromResult(firstCall) + val secondKey = DAMLe.ExternalCallKey.fromResult(secondCall) + + def view( + baseView: TransactionView, + result: ExternalCallResult, + exerciseIndex: Int, + ): TransactionView = + withExternalCallResults( + withConfirmers(baseView, confirmers), + Seq(externalCallViewResult(exerciseIndex = exerciseIndex, result, Set(submitter))), + ) + + val viewsWithHostedParties = Seq( + hostedView(leftViewPosition, view(example.rootViews(4), firstCall, 0), confirmers), + hostedView( + rightViewPosition, + view(example.rootViews(5), firstCall.copy(output = Bytes.fromStringUtf8("other-a")), 1), + confirmers, + ), + hostedView(unrelatedViewPosition, view(example.rootViews(4), secondCall, 2), confirmers), + hostedView( + secondRightViewPosition, + view(example.rootViews(5), secondCall.copy(output = Bytes.fromStringUtf8("other-b")), 3), + confirmers, + ), + ) + val routing = routingContext(viewsWithHostedParties) + + // One visible disagreement per distinct external call, both routed to the checking party. + routing.recordedConsistencyResult.visibleInconsistencies should have size 2 + routing.recordedConsistencyResult.hostedInconsistencies.keySet shouldBe Set(submitter) + routing.recordedConsistencyResult.hostedInconsistencies(submitter) should have size 2 + + // Each affected view rejects the checking party with the inconsistency for its own call. + val expectedKeyByView = Map( + leftViewPosition -> firstKey, + rightViewPosition -> firstKey, + unrelatedViewPosition -> secondKey, + secondRightViewPosition -> secondKey, + ) + expectedKeyByView.foreach { case (viewPosition, expectedKey) => + val routed = routing.inconsistenciesForView(viewPosition, confirmers) + routed.map(_._1) shouldBe Seq(submitter) + routed.loneElement._2.key shouldBe expectedKey + } + + assertRecordedDisagreementAlarms(count = 2) { + router().reportVisibleRecordedDisagreementAlarms( + requestId, + routing.recordedConsistencyResult, + ) + } + } + + "route recorded external-call result disagreements by checking party" in { + val confirmers = Set(submitter, signatory) + val viewsWithHostedParties = conflictingViews(confirmers) + val leftViewHash = + viewsWithHostedParties.head.validationResult.view.unwrap.viewHash + val disagreement = DAMLe.ExternalCallRecordedResultDisagreement( + key = externalCallKey, + outputs = Set(externalCallResult.output, otherExternalCallResult.output), + ) + val routing = routingContext(viewsWithHostedParties, Seq(disagreement)) + + // The recorded disagreement is attributed to the checking party only. + routing + .inconsistenciesForView(leftViewPosition, confirmers) + .map(_._1) shouldBe Seq(submitter) + + // A model-conformance error carrying this disagreement is routable, so the factory + // suppresses the would-be malformed model-conformance reject. + val routableError = ModelConformanceChecker.DAMLeError(disagreement, leftViewHash) + routing.isRoutableModelConformanceError(routableError) shouldBe true + + // An unrelated disagreement is not routable. + val unrelatedDisagreement = DAMLe.ExternalCallRecordedResultDisagreement( + key = DAMLe.ExternalCallKey.fromResult(externalCallResult.copy(functionId = "unrelated")), + outputs = Set(externalCallResult.output, otherExternalCallResult.output), + ) + routing.isRoutableModelConformanceError( + ModelConformanceChecker.DAMLeError(unrelatedDisagreement, leftViewHash) + ) shouldBe false + } + + "route recorded external-call replay ambiguity for disjoint checking parties" in { + val example = factory.MultipleRoots + val confirmers = Set(submitter, signatory) + val left = withExternalCallResults( + withConfirmers(example.rootViews(4), confirmers), + Seq(externalCallViewResult(exerciseIndex = 0, externalCallResult, Set(submitter))), + ) + val right = withExternalCallResults( + withConfirmers(example.rootViews(5), confirmers), + // Same semantic key, different output, seen by a disjoint checking party. + Seq(externalCallViewResult(exerciseIndex = 1, otherExternalCallResult, Set(signatory))), + ) + val viewsWithHostedParties = Seq( + hostedView(leftViewPosition, left, confirmers), + hostedView(rightViewPosition, right, confirmers), + ) + val disagreement = DAMLe.ExternalCallRecordedResultDisagreement( + key = externalCallKey, + outputs = Set(externalCallResult.output, otherExternalCallResult.output), + ) + val routing = routingContext(viewsWithHostedParties, Seq(disagreement)) + + // No single hosted party sees both outputs, so the visible (hosted) consistency check finds + // nothing -- but the disagreement is still globally visible and therefore alarmed. + routing.recordedConsistencyResult.hostedInconsistencies shouldBe empty + routing.recordedConsistencyResult.visibleInconsistencies should have size 1 + + // The replay disagreement routes to each checking party for the occurrence they can see. + val routed = ExternalCallResponseRouter.recordedExternalCallDisagreementInconsistencies( + Seq(disagreement), + viewsWithHostedParties, + ) + routed.keySet shouldBe Set(submitter, signatory) + routed(submitter).loneElement._2.occurrences.map(_.viewPosition) shouldBe Set( + leftViewPosition + ) + routed(signatory).loneElement._2.occurrences.map(_.viewPosition) shouldBe Set( + rightViewPosition + ) + + // Integrated per-view routing: left -> submitter, right -> signatory. + routing + .inconsistenciesForView(leftViewPosition, confirmers) + .map(_._1) shouldBe Seq(submitter) + routing + .inconsistenciesForView(rightViewPosition, confirmers) + .map(_._1) shouldBe Seq(signatory) + } + } +} diff --git a/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallValidationTestUtil.scala b/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallValidationTestUtil.scala index 5e01748fc8..5acb066d58 100644 --- a/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallValidationTestUtil.scala +++ b/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallValidationTestUtil.scala @@ -3,24 +3,40 @@ package com.digitalasset.canton.participant.protocol.validation -import com.digitalasset.canton.LfPartyId import com.digitalasset.canton.config.RequireTypes.NonNegativeInt import com.digitalasset.canton.data.{ + CantonTimestamp, ParticipantTransactionView, TransactionView, + ViewConfirmationParameters, ViewParticipantData, + ViewPosition, } +import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.logging.LogEntry +import com.digitalasset.canton.participant.util.DAMLe import com.digitalasset.canton.protocol.* +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.{BaseTest, LfPartyId} import com.digitalasset.daml.lf.data.Bytes import com.digitalasset.daml.lf.transaction.ExternalCallResult -/** Shared fixtures for the external-call validation tests. */ -private[validation] trait ExternalCallValidationTestUtil { +import java.util.concurrent.ConcurrentLinkedQueue +import scala.jdk.CollectionConverters.* + +/** Shared fixtures for the external-call validation tests. + * + * Mixed into the test bases so that fixtures depending on `loggerFactory` (and other [[BaseTest]] + * facilities) can live alongside the pure builders. + */ +private[validation] trait ExternalCallValidationTestUtil { self: BaseTest => + + protected val requestId: RequestId = RequestId(CantonTimestamp.Epoch) /** Provided by the mixing test, where an implicit `ExecutionContext` is available to build it. */ protected def factory: ExampleTransactionFactory - def externalCallViewResult( + protected def externalCallViewResult( exerciseIndex: Int, result: ExternalCallResult, checkingParties: Set[LfPartyId], @@ -33,13 +49,78 @@ private[validation] trait ExternalCallValidationTestUtil { checkingParties = checkingParties, ) - def withExternalCallResults( + protected def withExternalCallResults( view: TransactionView, results: Seq[ViewParticipantData.ViewExternalCallResult], ): TransactionView = TransactionView.Optics.viewParticipantDataUnsafe .modify(vpd => vpd.tryUnwrap.copy(externalCallResults = results))(view) + protected final class RecordingExternalCallValidator( + results: Map[DAMLe.ExternalCallKey, ExternalCallValidator.Result] + ) extends ExternalCallValidator { + private val observedKeys: ConcurrentLinkedQueue[(DAMLe.ExternalCallKey, Bytes)] = + new ConcurrentLinkedQueue[(DAMLe.ExternalCallKey, Bytes)] + + def observed: Seq[(DAMLe.ExternalCallKey, Bytes)] = observedKeys.asScala.toSeq + + override def validate( + key: DAMLe.ExternalCallKey, + recordedOutput: Bytes, + )(implicit + traceContext: TraceContext + ): FutureUnlessShutdown[ExternalCallValidator.Result] = { + observedKeys.add(key -> recordedOutput) + FutureUnlessShutdown.pure( + results.getOrElse(key, ExternalCallValidator.Matched) + ) + } + } + + protected val matchingExternalCallValidator: ExternalCallValidator = new ExternalCallValidator { + override def validate( + key: DAMLe.ExternalCallKey, + recordedOutput: Bytes, + )(implicit + traceContext: TraceContext + ): FutureUnlessShutdown[ExternalCallValidator.Result] = + FutureUnlessShutdown.pure(ExternalCallValidator.Matched) + } + + /** Distinct view positions with values matching their names; ordered `left < unrelated < right < + * secondRight` under [[ViewPosition.orderViewPosition]]. + */ + protected val leftViewPosition: ViewPosition = + ViewPosition( + List(ViewPosition.MerkleSeqIndex(List(ViewPosition.MerkleSeqIndex.Direction.Left))) + ) + protected val rightViewPosition: ViewPosition = + ViewPosition( + List(ViewPosition.MerkleSeqIndex(List(ViewPosition.MerkleSeqIndex.Direction.Right))) + ) + protected val unrelatedViewPosition: ViewPosition = + ViewPosition( + List( + ViewPosition.MerkleSeqIndex( + List( + ViewPosition.MerkleSeqIndex.Direction.Left, + ViewPosition.MerkleSeqIndex.Direction.Left, + ) + ) + ) + ) + protected val secondRightViewPosition: ViewPosition = + ViewPosition( + List( + ViewPosition.MerkleSeqIndex( + List( + ViewPosition.MerkleSeqIndex.Direction.Left, + ViewPosition.MerkleSeqIndex.Direction.Right, + ) + ) + ) + ) + protected val externalCallResult: ExternalCallResult = ExternalCallResult( extensionId = "extension", functionId = "function", @@ -48,9 +129,23 @@ private[validation] trait ExternalCallValidationTestUtil { output = Bytes.fromStringUtf8("output"), ) - protected val otherExternalCallOutput: ExternalCallResult = + protected val otherExternalCallResult: ExternalCallResult = externalCallResult.copy(output = Bytes.fromStringUtf8("other-output")) + protected def withConfirmers( + view: TransactionView, + confirmers: Set[LfPartyId], + ): TransactionView = { + val confirmationParameters = ViewConfirmationParameters.create( + informees = confirmers.map(_ -> NonNegativeInt.one).toMap, + threshold = NonNegativeInt.tryCreate(confirmers.size), + ) + TransactionView.Optics.viewCommonDataUnsafe + .modify(commonData => + commonData.tryUnwrap.copy(viewConfirmationParameters = confirmationParameters) + )(view) + } + protected def validationResult( view: TransactionView, activenessResult: ViewActivenessResult = ViewActivenessResult( @@ -63,4 +158,15 @@ private[validation] trait ExternalCallValidationTestUtil { ParticipantTransactionView.tryCreate(view), activenessResult, ) + + protected def assertRecordedDisagreementAlarms[A](count: Int = 1)(within: => A): A = + loggerFactory.assertLogs( + within, + Seq.fill(count) { (logEntry: LogEntry) => + logEntry.shouldBeCantonErrorCode( + ExternalCallValidationError.ExternalCallResultDisagreementAlarm + ) + logEntry.mdc should contain("requestId" -> requestId.toString) + }* + ) }