From 4d4a5ef558fbe31d16a2c2cd2b06315d57db5f92 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Tue, 30 Jun 2026 00:47:18 +0400 Subject: [PATCH 01/16] feat(external-call): external-call response router and routing tests --- .../ExternalCallResponseRouter.scala | 441 ++++++++++++++++ .../ExternalCallResponseRouterTest.scala | 487 ++++++++++++++++++ 2 files changed, 928 insertions(+) create mode 100644 community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouter.scala create mode 100644 community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouterTest.scala 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..2b29af708f --- /dev/null +++ b/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouter.scala @@ -0,0 +1,441 @@ +// 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 + +private[validation] final case class ViewWithHostedParties( + viewPosition: ViewPosition, + validationResult: ViewValidationResult, + hostedConfirmingParties: Set[LfPartyId], +) + +private[validation] final case class ExternalCallValidationOccurrence( + viewPosition: ViewPosition, + result: ViewParticipantData.ViewExternalCallResult, + hostedCheckingParties: Set[LfPartyId], +) + +private[validation] final case class ExternalCallValidationAbstain( + viewPosition: ViewPosition, + reason: String, +) + +private[validation] final case class ExternalCallValidationRoutes( + rejects: Map[LfPartyId, Seq[Inconsistency]], + abstains: Map[LfPartyId, Seq[ExternalCallValidationAbstain]], +) { + 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 + } + + def abstainsForView( + viewPosition: ViewPosition, + hostedConfirmingParties: Set[LfPartyId], + rejectedParties: Set[LfPartyId], + ): Seq[(LfPartyId, String)] = + abstains.toSeq.sortBy { case (party, _) => party }.flatMap { + case (party, abstains) if hostedConfirmingParties(party) && !rejectedParties(party) => + abstains + .filter(_.viewPosition == viewPosition) + .minByOption(_.reason) + .map(abstain => party -> abstain.reason) + case _ => None + } +} + +/** Per-request external-call routing state. + * + * Computes which hosted confirming parties must reject (or whose model-conformance rejections are + * superseded) because of external-call result disagreements, both for results visible across views + * and for disagreements surfaced during replay reinterpretation. + */ +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.supportsExternalCallResults && + viewParticipantData.externalCallResults.nonEmpty + } + + private lazy val recordedReplayDisagreementInconsistencies = + ExternalCallResponseRouter.recordedExternalCallDisagreementInconsistencies( + recordedResultDisagreements, + viewsWithHostedParties, + ) + + private lazy val routableRecordedExternalCallDisagreements = + recordedReplayDisagreementInconsistencies.valuesIterator + .flatMap(_.iterator) + .map(_._1) + .toSet + + lazy val recordedConsistencyResult: ExternalCallConsistencyChecker.Result = + if (hasAnyVisibleExternalCallResults) { + val allHostedConfirmingParties = + viewsWithHostedParties.flatMap(_.hostedConfirmingParties).toSet + ExternalCallConsistencyChecker.check( + viewValidationResults, + allHostedConfirmingParties, + ) + } else ExternalCallConsistencyChecker.Result.empty + + def isRoutableModelConformanceError(error: ModelConformanceChecker.Error): Boolean = + ExternalCallResponseRouter + .externalCallRecordedResultDisagreement(error) + .exists(routableRecordedExternalCallDisagreements) + + def inconsistenciesForView( + viewPosition: ViewPosition, + hostedConfirmingParties: Set[LfPartyId], + ): Seq[(LfPartyId, Inconsistency)] = { + val recordedResultInconsistencies = + recordedConsistencyResult.inconsistencies.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 = + recordedReplayDisagreementInconsistencies.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 result disagreements to per-view, per-party local verdicts. + * + * Holds the external-call validator used to re-run calls during validation. Stateless helpers that + * do not need the validator live on the companion object. + */ +private[validation] class ExternalCallResponseRouter( + externalCallValidator: ExternalCallValidator, + externalCallValidationParallelism: PositiveInt, + protected val loggerFactory: NamedLoggerFactory, +) extends NamedLogging { + + import ExternalCallResponseRouter.* + import com.digitalasset.canton.util.ShowUtil.* + + 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.supportsExternalCallResults || + 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, + ) + ) + } + } + } + } + + 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), + ) + } + } + + def reportVisibleRecordedDisagreementAlarms( + requestId: RequestId, + recordedConsistencyResult: ExternalCallConsistencyChecker.Result, + )(implicit traceContext: TraceContext): Unit = + recordedConsistencyResult.visibleInconsistencies.foreach { inconsistency => + val details = inconsistency.description.limit(maxExternalCallDisagreementDetailsLength) + ExternalCallValidationError.ExternalCallResultDisagreementAlarm + .Warn(s"Observed inconsistent external call results: $details") + .logWithContext(Map("requestId" -> requestId.toString)) + } +} + +private[validation] object ExternalCallResponseRouter { + + val maxExternalCallDisagreementDetailsLength = 1024 + + 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) + + if (orderedDisagreements.isEmpty) Map.empty + else { + 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 => + val viewParticipantData = view.validationResult.view.viewParticipantData + if (!viewParticipantData.supportsExternalCallResults) Seq.empty + else + 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/ExternalCallResponseRouterTest.scala b/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouterTest.scala new file mode 100644 index 0000000000..6b3d0e1ab0 --- /dev/null +++ b/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExternalCallResponseRouterTest.scala @@ -0,0 +1,487 @@ +// 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 = + 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.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, otherExternalCallOutput, 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 = otherExternalCallOutput.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, otherExternalCallOutput.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, rejectedParties = Set.empty) + .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, rejectedParties = Set.empty) + .map(_._1) shouldBe Seq(submitter) + // The abstain does not leak into the unaffected view. + routes + .abstainsForView(rightViewPosition, confirmers, rejectedParties = Set.empty) 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 = otherExternalCallOutput.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 = otherExternalCallOutput.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.inconsistencies.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.inconsistencies.keySet shouldBe Set(submitter) + routing.recordedConsistencyResult.inconsistencies(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, otherExternalCallOutput.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, otherExternalCallOutput.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, otherExternalCallOutput, Set(signatory))), + ) + val viewsWithHostedParties = Seq( + hostedView(leftViewPosition, left, confirmers), + hostedView(rightViewPosition, right, confirmers), + ) + val disagreement = DAMLe.ExternalCallRecordedResultDisagreement( + key = externalCallKey, + outputs = Set(externalCallResult.output, otherExternalCallOutput.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.inconsistencies 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) + } + } +} From e531d1da384e3d77690071ede5aa2d4805cf56b1 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 14:09:32 +0400 Subject: [PATCH 02/16] refactor(external-call): drop redundant empty-guard in recorded-disagreement routing On empty disagreements the general path already flat-maps to nothing and groupMap yields Map.empty, so the isEmpty short-circuit is redundant (the only skipped work is a cheap, pure view sort). --- .../ExternalCallResponseRouter.scala | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) 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 index 2b29af708f..9b3264b0b2 100644 --- 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 @@ -290,24 +290,21 @@ private[validation] object ExternalCallResponseRouter { val orderedDisagreements = disagreements.distinct.sorted(orderRecordedResultDisagreement) - if (orderedDisagreements.isEmpty) Map.empty - else { - 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, - )) - } + 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) - } + routed.groupMap(_._1)(_._2) } /** For a single recorded disagreement, the occurrences of its external call that each hosted From 4ff27145fc4e6b29177e3745e331b59989f6b79b Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 14:13:58 +0400 Subject: [PATCH 03/16] refactor(external-call): drop redundant supports-guard in occurrencesByParty The supportsExternalCallResults guard is dead by the checkExternalCallResults invariant (unsupported => results empty), so filter/flatMap over the results already yields an empty Seq. --- .../ExternalCallResponseRouter.scala | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) 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 index 9b3264b0b2..5562ae36ae 100644 --- 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 @@ -317,22 +317,19 @@ private[validation] object ExternalCallResponseRouter { ): Map[LfPartyId, Set[ExternalCallOccurrence]] = orderedViews .flatMap { view => - val viewParticipantData = view.validationResult.view.viewParticipantData - if (!viewParticipantData.supportsExternalCallResults) Seq.empty - else - 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) - } + 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 From 5a61b24882d4e791fd93342477a33ec4b0253850 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 19:05:47 +0400 Subject: [PATCH 04/16] refactor(external-call): drop redundant supports-guard in hasAnyVisibleExternalCallResults supportsExternalCallResults is redundant with externalCallResults.nonEmpty by the checkExternalCallResults invariant (unsupported => results empty), so the conjunct never changes the predicate. Verified behavior-preserving by adversarial review. --- .../protocol/validation/ExternalCallResponseRouter.scala | 1 - 1 file changed, 1 deletion(-) 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 index 5562ae36ae..028d47d357 100644 --- 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 @@ -83,7 +83,6 @@ private[validation] final class ExternalCallRoutingContext( private lazy val hasAnyVisibleExternalCallResults: Boolean = viewValidationResults.valuesIterator.exists { viewValidationResult => val viewParticipantData = viewValidationResult.view.viewParticipantData - viewParticipantData.supportsExternalCallResults && viewParticipantData.externalCallResults.nonEmpty } From 25b3cb694d9150252783204b8301ed106afcfc00 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 19:07:03 +0400 Subject: [PATCH 05/16] refactor(external-call): drop redundant supports-guard in validationOccurrences !supportsExternalCallResults is redundant with externalCallResults.isEmpty by the checkExternalCallResults invariant (unsupported => results empty), so the disjunct never changes which branch the if takes. Verified behavior-preserving by adversarial review. --- .../protocol/validation/ExternalCallResponseRouter.scala | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) 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 index 028d47d357..65313a60aa 100644 --- 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 @@ -171,10 +171,7 @@ private[validation] class ExternalCallResponseRouter( approvingParties => val viewParticipantData = viewWithHostedParties.validationResult.view.viewParticipantData - if ( - !viewParticipantData.supportsExternalCallResults || - viewParticipantData.externalCallResults.isEmpty - ) Seq.empty + if (viewParticipantData.externalCallResults.isEmpty) Seq.empty else { val alreadyRejectedParties = externalCallRouting From b4d3fd9832bd4d3b12c90de74d6249ad5d0b62be Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 21:15:47 +0400 Subject: [PATCH 06/16] docs(external-call): document rejectsForView --- .../protocol/validation/ExternalCallResponseRouter.scala | 5 +++++ 1 file changed, 5 insertions(+) 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 index 65313a60aa..76bd501d17 100644 --- 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 @@ -41,6 +41,11 @@ 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], From 771493b102c27125a5c914947a51eb5c87c2ea58 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 21:16:15 +0400 Subject: [PATCH 07/16] refactor(external-call): document abstainsForView and merge its party parameters The two party sets were only ever consumed as hosted-and-not-rejecting, so pass that single set instead. --- .../validation/ExternalCallResponseRouter.scala | 12 +++++++++--- .../validation/ExternalCallResponseRouterTest.scala | 6 +++--- 2 files changed, 12 insertions(+), 6 deletions(-) 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 index 76bd501d17..df3d198aee 100644 --- 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 @@ -59,13 +59,19 @@ private[validation] final case class ExternalCallValidationRoutes( 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, - hostedConfirmingParties: Set[LfPartyId], - rejectedParties: Set[LfPartyId], + nonRejectingConfirmingParties: Set[LfPartyId], ): Seq[(LfPartyId, String)] = abstains.toSeq.sortBy { case (party, _) => party }.flatMap { - case (party, abstains) if hostedConfirmingParties(party) && !rejectedParties(party) => + case (party, abstains) if nonRejectingConfirmingParties(party) => abstains .filter(_.viewPosition == viewPosition) .minByOption(_.reason) 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 index 6b3d0e1ab0..7c616325eb 100644 --- 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 @@ -161,7 +161,7 @@ final class ExternalCallResponseRouterTest abstain.reason should include("extension service is not configured") routes - .abstainsForView(leftViewPosition, confirmers, rejectedParties = Set.empty) + .abstainsForView(leftViewPosition, confirmers) .map(_._1) shouldBe Seq(submitter) } @@ -199,11 +199,11 @@ final class ExternalCallResponseRouterTest validator.observed shouldBe Seq(externalCallKey -> externalCallResult.output) routes.abstains(submitter).loneElement.viewPosition shouldBe leftViewPosition routes - .abstainsForView(leftViewPosition, confirmers, rejectedParties = Set.empty) + .abstainsForView(leftViewPosition, confirmers) .map(_._1) shouldBe Seq(submitter) // The abstain does not leak into the unaffected view. routes - .abstainsForView(rightViewPosition, confirmers, rejectedParties = Set.empty) shouldBe empty + .abstainsForView(rightViewPosition, confirmers) shouldBe empty } "not locally validate external calls when no hosted confirmer is a checking party" in { From 6937ed584bf59ea8c6c746d007425f5210b96dd1 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 21:16:33 +0400 Subject: [PATCH 08/16] refactor(external-call): align recorded-disagreement field name with its source --- .../protocol/validation/ExternalCallResponseRouter.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index df3d198aee..a3f8199c26 100644 --- 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 @@ -97,14 +97,14 @@ private[validation] final class ExternalCallRoutingContext( viewParticipantData.externalCallResults.nonEmpty } - private lazy val recordedReplayDisagreementInconsistencies = + private lazy val recordedExternalCallDisagreementInconsistencies = ExternalCallResponseRouter.recordedExternalCallDisagreementInconsistencies( recordedResultDisagreements, viewsWithHostedParties, ) private lazy val routableRecordedExternalCallDisagreements = - recordedReplayDisagreementInconsistencies.valuesIterator + recordedExternalCallDisagreementInconsistencies.valuesIterator .flatMap(_.iterator) .map(_._1) .toSet @@ -141,7 +141,7 @@ private[validation] final class ExternalCallRoutingContext( recordedResultInconsistencies.map(_._1).toSet val recordedReplayInconsistencies = - recordedReplayDisagreementInconsistencies.toSeq.flatMap { + recordedExternalCallDisagreementInconsistencies.toSeq.flatMap { case (party, disagreementInconsistencies) if hostedConfirmingParties(party) && !partiesWithRecordedResultInconsistencies(party) => From 0afa8b1d380e3a9f58bd435e73171c8d1b7f81d6 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 21:17:06 +0400 Subject: [PATCH 09/16] refactor(external-call): drop redundant disagreement-details truncation PrettyPrinting already bounds the rendered description (Pretty's default pprinter), and the Inconsistency rendering only prints payload sizes. --- .../protocol/validation/ExternalCallResponseRouter.scala | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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 index a3f8199c26..dfb5769ce6 100644 --- 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 @@ -168,7 +168,6 @@ private[validation] class ExternalCallResponseRouter( ) extends NamedLogging { import ExternalCallResponseRouter.* - import com.digitalasset.canton.util.ShowUtil.* def validationOccurrences( viewsWithHostedParties: Seq[ViewWithHostedParties], @@ -255,17 +254,14 @@ private[validation] class ExternalCallResponseRouter( recordedConsistencyResult: ExternalCallConsistencyChecker.Result, )(implicit traceContext: TraceContext): Unit = recordedConsistencyResult.visibleInconsistencies.foreach { inconsistency => - val details = inconsistency.description.limit(maxExternalCallDisagreementDetailsLength) ExternalCallValidationError.ExternalCallResultDisagreementAlarm - .Warn(s"Observed inconsistent external call results: $details") + .Warn(s"Observed inconsistent external call results: ${inconsistency.description}") .logWithContext(Map("requestId" -> requestId.toString)) } } private[validation] object ExternalCallResponseRouter { - val maxExternalCallDisagreementDetailsLength = 1024 - private val orderRecordedResultDisagreement : Ordering[DAMLe.ExternalCallRecordedResultDisagreement] = Ordering From 80acd24ea8f84b5d9233ecfb12f6be7a3b059020 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 21:53:30 +0400 Subject: [PATCH 10/16] docs(external-call): document the router and routing-context data flow Requested in the first review pass of #555: specification-level ScalaDocs for the public methods, the intended data flow from occurrence selection to the reject/abstain routes, and the downstream interface. --- .../ExternalCallResponseRouter.scala | 100 ++++++++++++++++-- 1 file changed, 94 insertions(+), 6 deletions(-) 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 index dfb5769ce6..40bf4bf30b 100644 --- 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 @@ -20,23 +20,35 @@ 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]], @@ -82,9 +94,10 @@ private[validation] final case class ExternalCallValidationRoutes( /** Per-request external-call routing state. * - * Computes which hosted confirming parties must reject (or whose model-conformance rejections are - * superseded) because of external-call result disagreements, both for results visible across views - * and for disagreements surfaced during replay reinterpretation. + * 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 supersede). */ private[validation] final class ExternalCallRoutingContext( recordedResultDisagreements: Seq[DAMLe.ExternalCallRecordedResultDisagreement], @@ -109,6 +122,10 @@ private[validation] final class ExternalCallRoutingContext( .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 = @@ -119,11 +136,22 @@ private[validation] final class ExternalCallRoutingContext( ) } else ExternalCallConsistencyChecker.Result.empty + /** Whether `error` is an external-call replay disagreement for which some hosted confirming party + * checks a matching occurrence. Such errors are expected to supersede the generic + * model-conformance rejection of the view: the affected parties reject with the disagreement + * inconsistency provided by [[inconsistenciesForView]] instead. + */ 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], @@ -156,10 +184,39 @@ private[validation] final class ExternalCallRoutingContext( } } -/** Routes external-call result disagreements to per-view, per-party local verdicts. +/** 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 view, surfaced by reinterpretation 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]]). * - * Holds the external-call validator used to re-run calls during validation. Stateless helpers that - * do not need the validator live on the companion object. + * 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, @@ -169,6 +226,15 @@ private[validation] class ExternalCallResponseRouter( 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]], @@ -204,6 +270,24 @@ private[validation] class ExternalCallResponseRouter( } } + /** 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 @@ -249,6 +333,10 @@ private[validation] class ExternalCallResponseRouter( } } + /** 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, From aa89ba5931a145fd4c1d414b50d3315dfd7a8bf9 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 22:11:49 +0400 Subject: [PATCH 11/16] docs(external-call): correct two imprecise claims in the router data flow The replay-disagreement source spans a reinterpreted view together with its subviews, not a single view; and the rejections that replace a suppressed model-conformance rejection cover fewer parties than the blanket rejection did. --- .../validation/ExternalCallResponseRouter.scala | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) 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 index 40bf4bf30b..2b69a1f223 100644 --- 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 @@ -137,9 +137,12 @@ private[validation] final class ExternalCallRoutingContext( } else ExternalCallConsistencyChecker.Result.empty /** Whether `error` is an external-call replay disagreement for which some hosted confirming party - * checks a matching occurrence. Such errors are expected to supersede the generic - * model-conformance rejection of the view: the affected parties reject with the disagreement - * inconsistency provided by [[inconsistenciesForView]] instead. + * 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 fewer parties than the + * suppressed blanket rejection: 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 @@ -190,7 +193,8 @@ private[validation] final class ExternalCallRoutingContext( * - 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 view, surfaced by reinterpretation as + * - 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 From bdd9ba74abc158398991f9d4262f622d7ed2162d Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Fri, 3 Jul 2026 22:21:49 +0400 Subject: [PATCH 12/16] docs(external-call): tighten routed-rejection coverage wording The routed rejections cover at most as many parties as the suppressed blanket rejection (equality is possible), and the routing-context class doc now uses the same suppress-and-partially-replace framing as the method doc. --- .../protocol/validation/ExternalCallResponseRouter.scala | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 index 2b69a1f223..a47bea4508 100644 --- 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 @@ -97,7 +97,8 @@ private[validation] final case class ExternalCallValidationRoutes( * 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 supersede). + * of them (or which model-conformance rejections they suppress and partially replace, see + * [[isRoutableModelConformanceError]]). */ private[validation] final class ExternalCallRoutingContext( recordedResultDisagreements: Seq[DAMLe.ExternalCallRecordedResultDisagreement], @@ -140,9 +141,9 @@ private[validation] final class ExternalCallRoutingContext( * 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 fewer parties than the - * suppressed blanket rejection: views without a matching occurrence and confirming parties that - * do not check the result are not rejected through this path. + * [[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 From 002cca977524c7d13ed9e098eb3667b1b3d03e4d Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Mon, 6 Jul 2026 13:32:44 +0400 Subject: [PATCH 13/16] refactor(external-call): adapt router and shared fixtures to the checker changes Post-rebase adaptations for the #554 review fixes now in this branch's base: - checker Result.inconsistencies -> hostedInconsistencies at the router's inconsistenciesForView and the four router-test assertion sites, - the shared test fixtures that #554 slimmed away are restored for the router test (validator mocks, view positions, withConfirmers, requestId, alarm-assertion helper), with view-position values matching their names as promised in #554 review reply 3521058115; relative ordering of the positions is preserved, so no order-sensitive assertion is affected. --- .../ExternalCallResponseRouter.scala | 2 +- .../ExternalCallResponseRouterTest.scala | 8 +- .../ExternalCallValidationTestUtil.scala | 116 +++++++++++++++++- 3 files changed, 116 insertions(+), 10 deletions(-) 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 index a47bea4508..9895363c88 100644 --- 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 @@ -161,7 +161,7 @@ private[validation] final class ExternalCallRoutingContext( hostedConfirmingParties: Set[LfPartyId], ): Seq[(LfPartyId, Inconsistency)] = { val recordedResultInconsistencies = - recordedConsistencyResult.inconsistencies.toSeq.flatMap { + recordedConsistencyResult.hostedInconsistencies.toSeq.flatMap { case (party, inconsistencies) if hostedConfirmingParties(party) => inconsistencies .find(_.occurrences.exists(_.viewPosition == viewPosition)) 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 index 7c616325eb..e837f1f286 100644 --- 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 @@ -299,7 +299,7 @@ final class ExternalCallResponseRouterTest val sut = router(validator) // The visible recorded disagreement already rejects the checking party. - routing.recordedConsistencyResult.inconsistencies.keySet shouldBe Set(submitter) + routing.recordedConsistencyResult.hostedInconsistencies.keySet shouldBe Set(submitter) routing.recordedConsistencyResult.visibleInconsistencies should have size 1 routing .inconsistenciesForView(leftViewPosition, Set(submitter, signatory)) @@ -380,8 +380,8 @@ final class ExternalCallResponseRouterTest // One visible disagreement per distinct external call, both routed to the checking party. routing.recordedConsistencyResult.visibleInconsistencies should have size 2 - routing.recordedConsistencyResult.inconsistencies.keySet shouldBe Set(submitter) - routing.recordedConsistencyResult.inconsistencies(submitter) 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( @@ -459,7 +459,7 @@ final class ExternalCallResponseRouterTest // 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.inconsistencies shouldBe empty + 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. 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..354b1d9cc0 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 = + 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", @@ -51,6 +132,20 @@ private[validation] trait ExternalCallValidationTestUtil { protected val otherExternalCallOutput: 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) + }* + ) } From b96374f62415e69d01fc31fad814c2525239b067 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Mon, 6 Jul 2026 17:45:25 +0400 Subject: [PATCH 14/16] test(external-call): rename otherExternalCallOutput to otherExternalCallResult Addresses the post-merge MINOR from the #554 approval (discussion r3529116181): the fixture is an ExternalCallResult, not an output; renamed at the declaration and all usage sites. --- .../ExternalCallConsistencyCheckerTest.scala | 10 +++++----- .../ExternalCallResponseRouterTest.scala | 18 +++++++++--------- .../ExternalCallValidationTestUtil.scala | 2 +- 3 files changed, 15 insertions(+), 15 deletions(-) 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..a71b61a507 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 @@ -32,7 +32,7 @@ class ExternalCallConsistencyCheckerTest 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 index e837f1f286..00b47ad465 100644 --- 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 @@ -78,7 +78,7 @@ final class ExternalCallResponseRouterTest ) val right = withExternalCallResults( withConfirmers(example.rootViews(5), confirmers), - Seq(externalCallViewResult(exerciseIndex = 1, otherExternalCallOutput, Set(submitter))), + Seq(externalCallViewResult(exerciseIndex = 1, otherExternalCallResult, Set(submitter))), ) Seq( hostedView(leftViewPosition, left, hostedConfirmingParties), @@ -98,7 +98,7 @@ final class ExternalCallResponseRouterTest val validator = new RecordingExternalCallValidator( Map( externalCallKey -> ExternalCallValidator.Mismatched( - computedOutput = otherExternalCallOutput.output, + computedOutput = otherExternalCallResult.output, recordedOutput = externalCallResult.output, ) ) @@ -122,7 +122,7 @@ final class ExternalCallResponseRouterTest routes.rejects.keySet shouldBe Set(submitter) val inconsistency = routes.rejects(submitter).loneElement inconsistency.key shouldBe externalCallKey - inconsistency.outputs shouldBe Set(externalCallResult.output, otherExternalCallOutput.output) + 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. @@ -217,7 +217,7 @@ final class ExternalCallResponseRouterTest val validator = new RecordingExternalCallValidator( Map( externalCallKey -> ExternalCallValidator.Mismatched( - computedOutput = otherExternalCallOutput.output, + computedOutput = otherExternalCallResult.output, recordedOutput = externalCallResult.output, ) ) @@ -256,7 +256,7 @@ final class ExternalCallResponseRouterTest val validator = new RecordingExternalCallValidator( Map( externalCallKey -> ExternalCallValidator.Mismatched( - computedOutput = otherExternalCallOutput.output, + computedOutput = otherExternalCallResult.output, recordedOutput = externalCallResult.output, ) ) @@ -411,7 +411,7 @@ final class ExternalCallResponseRouterTest viewsWithHostedParties.head.validationResult.view.unwrap.viewHash val disagreement = DAMLe.ExternalCallRecordedResultDisagreement( key = externalCallKey, - outputs = Set(externalCallResult.output, otherExternalCallOutput.output), + outputs = Set(externalCallResult.output, otherExternalCallResult.output), ) val routing = routingContext(viewsWithHostedParties, Seq(disagreement)) @@ -428,7 +428,7 @@ final class ExternalCallResponseRouterTest // An unrelated disagreement is not routable. val unrelatedDisagreement = DAMLe.ExternalCallRecordedResultDisagreement( key = DAMLe.ExternalCallKey.fromResult(externalCallResult.copy(functionId = "unrelated")), - outputs = Set(externalCallResult.output, otherExternalCallOutput.output), + outputs = Set(externalCallResult.output, otherExternalCallResult.output), ) routing.isRoutableModelConformanceError( ModelConformanceChecker.DAMLeError(unrelatedDisagreement, leftViewHash) @@ -445,7 +445,7 @@ final class ExternalCallResponseRouterTest val right = withExternalCallResults( withConfirmers(example.rootViews(5), confirmers), // Same semantic key, different output, seen by a disjoint checking party. - Seq(externalCallViewResult(exerciseIndex = 1, otherExternalCallOutput, Set(signatory))), + Seq(externalCallViewResult(exerciseIndex = 1, otherExternalCallResult, Set(signatory))), ) val viewsWithHostedParties = Seq( hostedView(leftViewPosition, left, confirmers), @@ -453,7 +453,7 @@ final class ExternalCallResponseRouterTest ) val disagreement = DAMLe.ExternalCallRecordedResultDisagreement( key = externalCallKey, - outputs = Set(externalCallResult.output, otherExternalCallOutput.output), + outputs = Set(externalCallResult.output, otherExternalCallResult.output), ) val routing = routingContext(viewsWithHostedParties, Seq(disagreement)) 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 354b1d9cc0..bd023bf799 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 @@ -129,7 +129,7 @@ private[validation] trait ExternalCallValidationTestUtil { self: BaseTest => output = Bytes.fromStringUtf8("output"), ) - protected val otherExternalCallOutput: ExternalCallResult = + protected val otherExternalCallResult: ExternalCallResult = externalCallResult.copy(output = Bytes.fromStringUtf8("other-output")) protected def withConfirmers( From 48318ba22da385b3fe3d4c532465905c98aac705 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Mon, 6 Jul 2026 20:20:33 +0400 Subject: [PATCH 15/16] style(external-call): annotate the remaining top-level declarations Sweep of the PR slice per review request: the routing context's two lazy vals, the router test's factory and key fixtures, and the checker test's party fixtures. --- .../protocol/validation/ExternalCallResponseRouter.scala | 6 ++++-- .../validation/ExternalCallConsistencyCheckerTest.scala | 6 +++--- .../validation/ExternalCallResponseRouterTest.scala | 5 +++-- 3 files changed, 10 insertions(+), 7 deletions(-) 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 index 9895363c88..36a34cb4c4 100644 --- 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 @@ -111,13 +111,15 @@ private[validation] final class ExternalCallRoutingContext( viewParticipantData.externalCallResults.nonEmpty } - private lazy val recordedExternalCallDisagreementInconsistencies = + private lazy val recordedExternalCallDisagreementInconsistencies + : Map[LfPartyId, Seq[(DAMLe.ExternalCallRecordedResultDisagreement, Inconsistency)]] = ExternalCallResponseRouter.recordedExternalCallDisagreementInconsistencies( recordedResultDisagreements, viewsWithHostedParties, ) - private lazy val routableRecordedExternalCallDisagreements = + private lazy val routableRecordedExternalCallDisagreements + : Set[DAMLe.ExternalCallRecordedResultDisagreement] = recordedExternalCallDisagreementInconsistencies.valuesIterator .flatMap(_.iterator) .map(_._1) 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 a71b61a507..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,9 +24,9 @@ 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], 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 index 00b47ad465..b4fc5879bb 100644 --- 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 @@ -28,7 +28,7 @@ final class ExternalCallResponseRouterTest with HasExecutionContext with ExternalCallValidationTestUtil { - protected val factory = + protected val factory: ExampleTransactionFactory = new ExampleTransactionFactory(versionOverride = Some(ProtocolVersion.dev))() private def router( @@ -40,7 +40,8 @@ final class ExternalCallResponseRouterTest loggerFactory, ) - private val externalCallKey = DAMLe.ExternalCallKey.fromResult(externalCallResult) + private val externalCallKey: DAMLe.ExternalCallKey = + DAMLe.ExternalCallKey.fromResult(externalCallResult) private def hostedView( viewPosition: ViewPosition, From 7238315f8f5656f1b3ebebf46b6b2baccee689e0 Mon Sep 17 00:00:00 2001 From: Angelo Laub Date: Mon, 6 Jul 2026 20:50:16 +0400 Subject: [PATCH 16/16] style(external-call): annotate the recording validator's queue field Follow-up to the annotation sweep: the nested RecordingExternalCallValidator's observedKeys val was missed. --- .../protocol/validation/ExternalCallValidationTestUtil.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 bd023bf799..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 @@ -59,7 +59,7 @@ private[validation] trait ExternalCallValidationTestUtil { self: BaseTest => protected final class RecordingExternalCallValidator( results: Map[DAMLe.ExternalCallKey, ExternalCallValidator.Result] ) extends ExternalCallValidator { - private val observedKeys = + private val observedKeys: ConcurrentLinkedQueue[(DAMLe.ExternalCallKey, Bytes)] = new ConcurrentLinkedQueue[(DAMLe.ExternalCallKey, Bytes)] def observed: Seq[(DAMLe.ExternalCallKey, Bytes)] = observedKeys.asScala.toSeq