diff --git a/sdk/canton/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v30/inspection_service.proto b/sdk/canton/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v30/inspection_service.proto index 161e610146..161cf9f125 100644 --- a/sdk/canton/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v30/inspection_service.proto +++ b/sdk/canton/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v30/inspection_service.proto @@ -25,6 +25,13 @@ service InspectionService { rpc LookupOffsetByTime(LookupOffsetByTime.Request) returns (LookupOffsetByTime.Response); // Look up the ledger offset by an index, e.g. 1 returns the first offset, 2 the second, etc. rpc LookupOffsetByIndex(LookupOffsetByIndex.Request) returns (LookupOffsetByIndex.Response); + // TODO(#18452) R5 + // Look up the ACS commitments computed and sent by a participant + rpc LookupSentAcsCommitments(LookupSentAcsCommitments.Request) returns (LookupSentAcsCommitments.Response); + // TODO(#18452) R5 + // List the counter-participants and their ACS commitments together with the match status + // TODO(#18749) R1 Can also be used for R1, to fetch commitments that a counter participant received from myself + rpc LookupReceivedAcsCommitments(LookupReceivedAcsCommitments.Request) returns (LookupReceivedAcsCommitments.Response); // Configure metrics for slow counter-participants (i.e., that are behind in sending commitments) and // configure thresholds for when a counter-participant is deemed slow. // TODO(#10436) R7 @@ -90,6 +97,149 @@ message LookupOffsetByIndex { } } +// list the commitments received from counter-participants +// optional filtering by domain, time ranges, counter participants, commitment state and verbosity +message LookupReceivedAcsCommitments { + message Request { + // filter specific time ranges per domain + // a domain can appear multiple times with various time ranges, in which case we consider the union of the time ranges. + // return only the received commitments with an interval overlapping any of the given time ranges per domain + // defaults: if empty, all domains known to the participant are considered + repeated DomainTimeRange time_ranges = 1; + // retrieve commitments received from specific counter participants + // if a specified counter participant is not a counter-participant in some domain, we do not return it in the response + // an empty set means we return commitments received from all counter participants on the domains matching the domain filter. + repeated string counter_participant_uids = 2; + // filter by commitment state: only return commitments with the states below + // if no state is given, we return all commitments + repeated ReceivedCommitmentState commitment_state = 3; + // include the actual commitment in the response + bool verbose = 4; + } + + // Returns a sequence of commitments for each domain. + // Domains should not repeat in the response, otherwise the caller considers the response invalid. + // If all commitments received on a domain have been pruned, we return an error. + // No streaming, because a response with verbose mode on contains around 1kb to 3kb of data (depending on whether + // we ship the LtHash16 bytes directly or just a hash thereof). + // Therefore, 1000 commitments fit into a couple of MBs, and we can expect the gRPC admin API to handle messages of + // a couple of MBs. + // It is the application developer's job to find suitable filter ranges. + message Response { + repeated ReceivedAcsCommitmentPerDomain received = 1; + } +} + +// timestamps *do not* have to fall on commitment period boundaries/ticks +message TimeRange { + google.protobuf.Timestamp from_exclusive = 1; + google.protobuf.Timestamp to_inclusive = 2; +} + +message DomainTimeRange { + string domain_id = 1; + // optional; if not given, the latest reconciliation period the participant knows of for that domain is considered + optional TimeRange interval = 2; +} + +// timestamps *do fall* on commitment period boundaries/ticks +message Interval { + google.protobuf.Timestamp start_tick_exclusive = 1; + google.protobuf.Timestamp end_tick_inclusive = 2; +} + +message ReceivedAcsCommitment { + Interval interval = 1; + // the counter participant that computed and sent the commitment, from whom the current participant received the commitment + string origin_counter_participant_uid = 2; + // the commitment received from the counter participant, unsigned because the admin trusts own participant's reply + // populated only if verbose mode is on + optional bytes received_commitment = 3; + // own commitment of participant that was compared with the received commitment, unsigned because the admin trusts own participant's reply + // populated only in case there is a mismatch and verbose mode is on + // might not correspond to the same interval as the received commitment, however, the matching timestamp is the end of + // the returned interval + optional bytes own_commitment = 4; + ReceivedCommitmentState state = 5; +} + +message ReceivedAcsCommitmentPerDomain { + string domain_id = 1; + repeated ReceivedAcsCommitment received = 2; +} + +enum ReceivedCommitmentState { + RECEIVED_COMMITMENT_STATE_UNSPECIFIED = 0; + RECEIVED_COMMITMENT_STATE_MATCH = 1; + RECEIVED_COMMITMENT_STATE_MISMATCH = 2; + // buffered commitments were not compared yet with the participant's commitments + RECEIVED_COMMITMENT_STATE_BUFFERED = 3; + // outstanding commitments were not received yet + RECEIVED_COMMITMENT_STATE_OUTSTANDING = 4; +} + +message SentAcsCommitment { + Interval interval = 1; + // the counter participant to whom we sent the commitment + string dest_counter_participant_uid = 2; + // own computed commitment sent to counter participant, unsigned because the admin trusts own participant's reply + // populated only if verbose mode is on + optional bytes own_commitment = 3; + // commitment of the counter participant that was compared with own commitment, unsigned because the admin trusts own participant's reply + // populated only in case there is a mismatch and verbose mode is on + // might not correspond to the same interval as the sent commitment, however, the mismatch timestamp is the end of + // the returned interval + optional bytes received_commitment = 4; + SentCommitmentState state = 5; +} + +message SentAcsCommitmentPerDomain { + string domain_id = 1; + repeated SentAcsCommitment sent = 2; +} + +enum SentCommitmentState { + SENT_COMMITMENT_STATE_UNSPECIFIED = 0; + SENT_COMMITMENT_STATE_MATCH = 1; + SENT_COMMITMENT_STATE_MISMATCH = 2; + // commitment was not compared yet with the counter-participant's commitments, because, e.g., the counter-participant + // commitment has not been received yet + SENT_COMMITMENT_STATE_NOT_COMPARED = 3; +} + +// list the commitments computed and sent to counter-participants +// optional filtering by domain, time ranges, counter participants, commitment state and verbosity +message LookupSentAcsCommitments { + message Request { + // filter specific time ranges per domain + // a domain can appear multiple times with various time ranges, in which case we consider the union of the time ranges. + // return only the sent commitments with an interval overlapping any of the given time ranges per domain + // defaults: if empty, all domains known to the participant are considered + repeated DomainTimeRange time_ranges = 1; + // retrieve commitments sent to specific counter participants + // if a specified counter participant is not a counter-participant in some domain, we do not return it in the response + // an empty set means we return commitments sent to all counter participants on the domains matching the domain filter. + repeated string counter_participant_uids = 2; + // filter by commitment state: only return commitments with the states below + // if no state is given, we return all commitments + repeated SentCommitmentState commitment_state = 3; + // include the actual commitment in the response + bool verbose = 4; + } + + // Returns a sequence of commitments for each domain. + // Domains should not repeat in the response, otherwise the caller considers the response invalid. + // If all commitments sent on a domain have been pruned, we return an error. + // No streaming, because a response with verbose mode on contains around 1kb to 3kb of data (depending on whether + // we ship the LtHash16 bytes directly or just a hash thereof). + // Therefore, 1000 commitments fit into a couple of MBs, and we can expect the gRPC admin API to handle messages of + // a couple of MBs. + // It is the application developer's job to find suitable filter ranges. + message Response { + repeated SentAcsCommitmentPerDomain sent = 1; + } +} + /* The configuration concerns the following metrics, and each of the metrics is issued per domain: - The maximum number of intervals that a distiguished participant falls behind @@ -100,7 +250,6 @@ The configuration concerns the following metrics, and each of the metrics is iss reconciliation intervals. - Selected participants for which we publish independent metrics counting how many intervals they are behind */ - message SlowCounterParticipantDomainConfig { // the domains for which we apply the settings below repeated string domain_ids = 1; diff --git a/sdk/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/ParticipantAdminCommands.scala b/sdk/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/ParticipantAdminCommands.scala index a111d5d3e0..70fd79ce16 100644 --- a/sdk/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/ParticipantAdminCommands.scala +++ b/sdk/canton/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/ParticipantAdminCommands.scala @@ -31,7 +31,7 @@ import com.digitalasset.canton.admin.participant.v30.{ResourceLimits as _, *} import com.digitalasset.canton.admin.pruning import com.digitalasset.canton.admin.pruning.v30.{NoWaitCommitmentsSetup, WaitCommitmentsSetup} import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt} -import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.data.{CantonTimestamp, CantonTimestampSecond} import com.digitalasset.canton.logging.TracedLogger import com.digitalasset.canton.participant.admin.ResourceLimits import com.digitalasset.canton.participant.admin.grpc.{ @@ -40,13 +40,19 @@ import com.digitalasset.canton.participant.admin.grpc.{ } import com.digitalasset.canton.participant.admin.traffic.TrafficStateAdmin import com.digitalasset.canton.participant.domain.DomainConnectionConfig as CDomainConnectionConfig -import com.digitalasset.canton.participant.pruning.AcsCommitmentProcessor.SharedContractsState +import com.digitalasset.canton.participant.pruning.AcsCommitmentProcessor.{ + ReceivedCmtState, + SentCmtState, + SharedContractsState, +} import com.digitalasset.canton.participant.sync.UpstreamOffsetConvert import com.digitalasset.canton.protocol.LfContractId +import com.digitalasset.canton.protocol.messages.{AcsCommitment, CommitmentPeriod} import com.digitalasset.canton.sequencing.SequencerConnectionValidation import com.digitalasset.canton.sequencing.protocol.TrafficState import com.digitalasset.canton.serialization.ProtoConverter import com.digitalasset.canton.serialization.ProtoConverter.InstantConverter +import com.digitalasset.canton.time.PositiveSeconds import com.digitalasset.canton.topology.{DomainId, ParticipantId, PartyId} import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.BinaryFileUtil @@ -1106,6 +1112,228 @@ object ParticipantAdminCommands { Right(response.offset) } + // TODO(#18451) R5: The code below should be sufficient. + final case class LookupReceivedAcsCommitments( + domainTimeRanges: Seq[DomainTimeRange], + counterParticipants: Seq[ParticipantId], + commitmentState: Seq[ReceivedCmtState], + verboseMode: Boolean, + ) extends Base[ + v30.LookupReceivedAcsCommitments.Request, + v30.LookupReceivedAcsCommitments.Response, + Map[DomainId, Seq[ReceivedAcsCmt]], + ] { + + override def createRequest() = Right( + v30.LookupReceivedAcsCommitments + .Request( + domainTimeRanges.map { case domainTimeRange => + v30.DomainTimeRange( + domainTimeRange.domain.toProtoPrimitive, + domainTimeRange.timeRange.map { timeRange => + v30.TimeRange( + Some(timeRange.startExclusive.toProtoTimestamp), + Some(timeRange.endInclusive.toProtoTimestamp), + ) + }, + ) + }, + counterParticipants.map(_.toProtoPrimitive), + commitmentState.map(_.toProtoV30), + verboseMode, + ) + ) + + override def submitRequest( + service: InspectionServiceStub, + request: v30.LookupReceivedAcsCommitments.Request, + ): Future[v30.LookupReceivedAcsCommitments.Response] = + service.lookupReceivedAcsCommitments(request) + + override def handleResponse( + response: v30.LookupReceivedAcsCommitments.Response + ): Either[ + String, + Map[DomainId, Seq[ReceivedAcsCmt]], + ] = { + if (response.received.size != response.received.map(_.domainId).toSet.size) + Left( + s"Some domains are not unique in the response: ${response.received}" + ) + else + response.received + .traverse(receivedCmtPerDomain => + for { + domainId <- DomainId.fromString(receivedCmtPerDomain.domainId) + receivedCmts <- receivedCmtPerDomain.received + .map(fromProtoToReceivedAcsCmt) + .sequence + } yield domainId -> receivedCmts + ) + .map(_.toMap) + } + } + + final case class TimeRange(startExclusive: CantonTimestamp, endInclusive: CantonTimestamp) + + final case class DomainTimeRange(domain: DomainId, timeRange: Option[TimeRange]) + + final case class ReceivedAcsCmt( + receivedCmtPeriod: CommitmentPeriod, + originCounterParticipant: ParticipantId, + receivedCommitment: Option[AcsCommitment.CommitmentType], + localCommitment: Option[AcsCommitment.CommitmentType], + state: ReceivedCmtState, + ) + + private def fromIntervalToCommitmentPeriod( + interval: Option[v30.Interval] + ): Either[String, CommitmentPeriod] = { + interval match { + case None => Left("Interval is missing") + case Some(v) => + for { + from <- v.startTickExclusive + .traverse(CantonTimestamp.fromProtoTimestamp) + .leftMap(_.toString) + fromSecond <- CantonTimestampSecond.fromCantonTimestamp( + from.getOrElse(CantonTimestamp.MinValue) + ) + to <- v.endTickInclusive + .traverse(CantonTimestamp.fromProtoTimestamp) + .leftMap(_.toString) + toSecond <- CantonTimestampSecond.fromCantonTimestamp( + to.getOrElse(CantonTimestamp.MinValue) + ) + len <- PositiveSeconds.create( + java.time.Duration.ofSeconds( + toSecond.minusSeconds(fromSecond.getEpochSecond).getEpochSecond + ) + ) + } yield CommitmentPeriod(fromSecond, len) + } + } + + private def fromProtoToReceivedAcsCmt( + cmt: v30.ReceivedAcsCommitment + ): Either[String, ReceivedAcsCmt] = { + for { + state <- ReceivedCmtState.fromProtoV30(cmt.state).leftMap(_.toString) + period <- fromIntervalToCommitmentPeriod(cmt.interval) + participantId <- ParticipantId + .fromProtoPrimitive(cmt.originCounterParticipantUid, "") + .leftMap(_.toString) + } yield ReceivedAcsCmt( + period, + participantId, + Option + .when(cmt.receivedCommitment.isDefined)( + cmt.receivedCommitment.map(AcsCommitment.commitmentTypeFromByteString) + ) + .flatten, + Option + .when(cmt.ownCommitment.isDefined)( + cmt.ownCommitment.map(AcsCommitment.commitmentTypeFromByteString) + ) + .flatten, + state, + ) + } + + // TODO(#18451) R5: The code below should be sufficient. + final case class LookupSentAcsCommitments( + domainTimeRanges: Seq[DomainTimeRange], + counterParticipants: Seq[ParticipantId], + commitmentState: Seq[SentCmtState], + verboseMode: Boolean, + ) extends Base[ + v30.LookupSentAcsCommitments.Request, + v30.LookupSentAcsCommitments.Response, + Map[DomainId, Seq[SentAcsCmt]], + ] { + + override def createRequest() = Right( + v30.LookupSentAcsCommitments + .Request( + domainTimeRanges.map { case domainTimeRange => + v30.DomainTimeRange( + domainTimeRange.domain.toProtoPrimitive, + domainTimeRange.timeRange.map { timeRange => + v30.TimeRange( + Some(timeRange.startExclusive.toProtoTimestamp), + Some(timeRange.endInclusive.toProtoTimestamp), + ) + }, + ) + }, + counterParticipants.map(_.toProtoPrimitive), + commitmentState.map(_.toProtoV30), + verboseMode, + ) + ) + + override def submitRequest( + service: InspectionServiceStub, + request: v30.LookupSentAcsCommitments.Request, + ): Future[v30.LookupSentAcsCommitments.Response] = + service.lookupSentAcsCommitments(request) + + override def handleResponse( + response: v30.LookupSentAcsCommitments.Response + ): Either[ + String, + Map[DomainId, Seq[SentAcsCmt]], + ] = { + if (response.sent.size != response.sent.map(_.domainId).toSet.size) + Left( + s"Some domains are not unique in the response: ${response.sent}" + ) + else + response.sent + .traverse(sentCmtPerDomain => + for { + domainId <- DomainId.fromString(sentCmtPerDomain.domainId) + sentCmts <- sentCmtPerDomain.sent.map(fromProtoToSentAcsCmt).sequence + } yield domainId -> sentCmts + ) + .map(_.toMap) + } + } + + final case class SentAcsCmt( + receivedCmtPeriod: CommitmentPeriod, + destCounterParticipant: ParticipantId, + sentCommitment: Option[AcsCommitment.CommitmentType], + receivedCommitment: Option[AcsCommitment.CommitmentType], + state: SentCmtState, + ) + + private def fromProtoToSentAcsCmt( + cmt: v30.SentAcsCommitment + ): Either[String, SentAcsCmt] = { + for { + state <- SentCmtState.fromProtoV30(cmt.state).leftMap(_.toString) + period <- fromIntervalToCommitmentPeriod(cmt.interval) + participantId <- ParticipantId + .fromProtoPrimitive(cmt.destCounterParticipantUid, "") + .leftMap(_.toString) + } yield SentAcsCmt( + period, + participantId, + Option + .when(cmt.ownCommitment.isDefined)( + cmt.ownCommitment.map(AcsCommitment.commitmentTypeFromByteString) + ) + .flatten, + Option + .when(cmt.receivedCommitment.isDefined)( + cmt.receivedCommitment.map(AcsCommitment.commitmentTypeFromByteString) + ) + .flatten, + state, + ) + } + // TODO(#10436) R7: The code below should be sufficient. final case class SetConfigForSlowCounterParticipants( configs: Seq[SlowCounterParticipantDomainConfig] @@ -1243,6 +1471,9 @@ object ParticipantAdminCommands { response.intervalsBehind.map { info => for { domainId <- DomainId.fromString(info.domainId) + participantId <- ParticipantId + .fromProtoPrimitive(info.counterParticipantUid, "") + .leftMap(_.toString) asOf <- ProtoConverter .parseRequired( CantonTimestamp.fromProtoTimestamp, @@ -1250,10 +1481,11 @@ object ParticipantAdminCommands { info.asOfSequencingTimestamp, ) .leftMap(_.toString) + intervalsBehind <- PositiveInt.create(info.intervalsBehind.toInt).leftMap(_.toString) } yield CounterParticipantInfo( - ParticipantId.tryFromProtoPrimitive(info.counterParticipantUid), + participantId, domainId, - PositiveInt.tryCreate(info.intervalsBehind.toInt), + intervalsBehind, asOf.toInstant, ) }.sequence @@ -1447,8 +1679,11 @@ object ParticipantAdminCommands { state <- SharedContractsState .fromProtoV30(setup.counterParticipantState) .leftMap(_.toString) + participantId <- ParticipantId + .fromProtoPrimitive(setup.counterParticipantUid, "") + .leftMap(_.toString) } yield NoWaitCommitments( - ParticipantId.tryFromProtoPrimitive(setup.counterParticipantUid), + participantId, setup.timestampOrOffsetActive match { case NoWaitCommitmentsSetup.TimestampOrOffsetActive.SequencingTimestamp(_) => ts.toInstant.asLeft[ParticipantOffset] @@ -1463,7 +1698,7 @@ object ParticipantAdminCommands { s.map( _.getOrElse( NoWaitCommitments( - ParticipantId.tryFromProtoPrimitive("error"), + ParticipantId.tryFromProtoPrimitive("PAR::participant::error"), Left(Instant.EPOCH), Seq.empty, SharedContractsState.NoSharedContracts, @@ -1543,7 +1778,7 @@ object ParticipantAdminCommands { s.map( _.getOrElse( WaitCommitments( - ParticipantId.tryFromProtoPrimitive("error"), + ParticipantId.tryFromProtoPrimitive("PAR::participant::error"), Seq.empty, SharedContractsState.NoSharedContracts, ) diff --git a/sdk/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantAdministration.scala b/sdk/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantAdministration.scala index 527fffee80..a460e9c41f 100644 --- a/sdk/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantAdministration.scala +++ b/sdk/canton/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantAdministration.scala @@ -10,8 +10,13 @@ import com.daml.ledger.api.v2.participant_offset.ParticipantOffset import com.daml.nonempty.NonEmpty import com.digitalasset.canton.admin.api.client.commands.ParticipantAdminCommands.Inspection.{ CounterParticipantInfo, + DomainTimeRange, GetConfigForSlowCounterParticipants, GetIntervalsBehindForCounterParticipants, + LookupReceivedAcsCommitments, + LookupSentAcsCommitments, + ReceivedAcsCmt, + SentAcsCmt, SetConfigForSlowCounterParticipants, SlowCounterParticipantDomainConfig, } @@ -64,6 +69,10 @@ import com.digitalasset.canton.participant.admin.ResourceLimits import com.digitalasset.canton.participant.admin.grpc.TransferSearchResult import com.digitalasset.canton.participant.admin.inspection.SyncStateInspection import com.digitalasset.canton.participant.domain.DomainConnectionConfig +import com.digitalasset.canton.participant.pruning.AcsCommitmentProcessor.{ + ReceivedCmtState, + SentCmtState, +} import com.digitalasset.canton.participant.sync.TimestampedEvent import com.digitalasset.canton.protocol.messages.{ AcsCommitment, @@ -767,6 +776,94 @@ class LocalCommitmentsAdministrationGroup( ) } + // TODO(#18451) R5 + @Help.Summary( + "List the counter-participants of a participant and the ACS commitments received from them together with" + + "the commitment state." + ) + @Help.Description( + """Optional filtering through the arguments: + | domainTimeRanges: Lists commitments received on the given domains whose period overlaps with any of the given + | time ranges per domain. + | If the list is empty, considers all domains the participant is connected to. + | For domains with an empty time range, considers the latest period the participant knows of for that domain. + | Domains can appear multiple times in the list with various time ranges, in which case we consider the + | union of the time ranges. + |counterParticipants: Lists commitments received only from the given counter-participants. If a counter-participant + | is not a counter-participant on some domain, no commitments appear in the reply from that counter-participant + | on that domain. + |commitmentState: Lists commitments that are in one of the given states. By default considers all states: + | - MATCH: the remote commitment matches the local commitment + | - MISMATCH: the remote commitment does not match the local commitment + | - BUFFERED: the remote commitment is buffered because the corresponding local commitment has not been computed yet + | - OUTSTANDING: we expect a remote commitment that has not yet been received + |verboseMode: If false, the reply does not contain the commitment bytes. If true, the reply contains: + | - In case of a mismatch, the reply contains both the received and the locally computed commitment that do not match. + | - In case of outstanding, the reply does not contain any commitment. + | - In all other cases (match and buffered), the reply contains the received commitment. + """ + ) + def lookup_received_acs_commitments( + domainTimeRanges: Seq[DomainTimeRange], + counterParticipants: Seq[ParticipantId], + commitmentState: Seq[ReceivedCmtState], + verboseMode: Boolean, + ): Map[DomainId, Seq[ReceivedAcsCmt]] = + consoleEnvironment.run( + runner.adminCommand( + LookupReceivedAcsCommitments( + domainTimeRanges, + counterParticipants, + commitmentState, + verboseMode, + ) + ) + ) + + // TODO(#18451) R5 + @Help.Summary( + "List the counter-participants of a participant and the ACS commitments that the participant computed and sent to" + + "them, together with the commitment state." + ) + @Help.Description( + """Optional filtering through the arguments: + | domainTimeRanges: Lists commitments received on the given domains whose period overlap with any of the + | given time ranges per domain. + | If the list is empty, considers all domains the participant is connected to. + | For domains with an empty time range, considers the latest period the participant knows of for that domain. + | Domains can appear multiple times in the list with various time ranges, in which case we consider the + | union of the time ranges. + |counterParticipants: Lists commitments sent only to the given counter-participants. If a counter-participant + | is not a counter-participant on some domain, no commitments appear in the reply for that counter-participant + | on that domain. + |commitmentState: Lists sent commitments that are in one of the given states. By default considers all states: + | - MATCH: the local commitment matches the remote commitment + | - MISMATCH: the local commitment does not match the remote commitment + | - NOT_COMPARED: the local commitment has been computed and sent but no corresponding remote commitment has + | been received + |verboseMode: If false, the reply does not contain the commitment bytes. If true, the reply contains: + | - In case of a mismatch, the reply contains both the received and the locally computed commitment that + | do not match. + | - In all other cases (match and not compared), the reply contains the sent commitment. + """ + ) + def lookup_sent_acs_commitments( + domainTimeRanges: Seq[DomainTimeRange], + counterParticipants: Seq[ParticipantId], + commitmentState: Seq[SentCmtState], + verboseMode: Boolean, + ): Map[DomainId, Seq[SentAcsCmt]] = + consoleEnvironment.run( + runner.adminCommand( + LookupSentAcsCommitments( + domainTimeRanges, + counterParticipants, + commitmentState, + verboseMode, + ) + ) + ) + // TODO(#18453) R6: The code below should be sufficient. @Help.Summary( "Disable waiting for commitments from the given counter-participants." @@ -779,7 +876,7 @@ class LocalCommitmentsAdministrationGroup( |Returns an error if `startingAt` does not translate to an existing offset. |If the participant set is empty, the command does nothing.""" ) - def setNoWaitCommitmentsFrom( + def set_no_wait_commitments_from( counterParticipants: Seq[ParticipantId], domainIds: Seq[DomainId], startingAt: Either[Instant, ParticipantOffset], @@ -806,7 +903,7 @@ class LocalCommitmentsAdministrationGroup( |The command returns a map of counter-participants and the domains for which the setting was changed. |If the participant set is empty or the domain set is empty, the command does nothing.""" ) - def setWaitCommitmentsFrom( + def set_wait_commitments_from( counterParticipants: Seq[ParticipantId], domainIds: Seq[DomainId], ): Map[ParticipantId, Seq[DomainId]] = { @@ -834,7 +931,7 @@ class LocalCommitmentsAdministrationGroup( |Even if some participants may not be connected to some domains at the time the query executes, the response still |includes them if they are known to the participant or specified in the arguments.""" ) - def getNoWaitCommitmentsFrom( + def get_no_wait_commitments_from( domains: Seq[DomainId], counterParticipants: Seq[ParticipantId], ): (Seq[NoWaitCommitments], Seq[WaitCommitments]) = @@ -863,7 +960,7 @@ class LocalCommitmentsAdministrationGroup( | reconciliation intervals. | - Separate metric for each participant in `individualMetrics` argument tracking how many intervals that |participant is behind""") - def setConfigForSlowCounterParticipants( + def set_config_for_slow_counter_participants( configs: Seq[SlowCounterParticipantDomainConfig] ): Unit = { consoleEnvironment.run( @@ -876,25 +973,25 @@ class LocalCommitmentsAdministrationGroup( } // TODO(#10436) R7 - def addConfigForSlowCounterParticipants( + def add_config_for_slow_counter_participants( counterParticipantsDistinguished: Seq[ParticipantId], domains: Seq[DomainId], ) = ??? // TODO(#10436) R7 - def removeConfigForSlowCounterParticipants( + def remove_config_for_slow_counter_participants( counterParticipantsDistinguished: Seq[ParticipantId], domains: Seq[DomainId], ) = ??? // TODO(#10436) R7 - def addParticipanttoIndividualMetrics( + def add_participant_to_individual_metrics( individualMetrics: Seq[ParticipantId], domains: Seq[DomainId], ) = ??? // TODO(#10436) R7 - def removeParticipantFromIndividualMetrics( + def remove_participant_from_individual_metrics( individualMetrics: Seq[ParticipantId], domains: Seq[DomainId], ) = ??? @@ -914,7 +1011,7 @@ class LocalCommitmentsAdministrationGroup( | - Parameters `thresholdDistinguished` and `thresholdDefault` | - The participants in `individualMetrics`, which have individual metrics per participant showing how many reconciliation intervals that participant is behind""") - def getConfigForSlowCounterParticipants( + def get_config_for_slow_counter_participants( domains: Seq[DomainId] ): Seq[SlowCounterParticipantDomainConfig] = { consoleEnvironment.run( @@ -937,7 +1034,7 @@ class LocalCommitmentsAdministrationGroup( // TODO(#10436) R7: Return the slow counter participant config for the given domains and counterParticipants // Filter the gRPC response of `getConfigForSlowCounterParticipants` with `counterParticipants` - def getConfigForSlowCounterParticipant( + def get_config_for_slow_counter_participant( domains: Seq[DomainId], counterParticipants: Seq[ParticipantId], ): Seq[SlowCounterParticipantInfo] = Seq.empty @@ -953,7 +1050,7 @@ class LocalCommitmentsAdministrationGroup( |Counter-participants that never sent a commitment appear in the output only if they're explicitly given in |`counterParticipants`. For such counter-participant that never sent a commitment, the output shows they are |behind by MaxInt""") - def getIntervalsBehindForCounterParticipants( + def get_intervals_behind_for_counter_participants( counterParticipants: Seq[ParticipantId], domains: Seq[DomainId], threshold: Option[NonNegativeInt], diff --git a/sdk/canton/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/participant_transaction.proto b/sdk/canton/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/participant_transaction.proto index 6b1552643c..77073f51b2 100644 --- a/sdk/canton/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/participant_transaction.proto +++ b/sdk/canton/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v30/participant_transaction.proto @@ -200,6 +200,7 @@ message ActionDescription { string input_contract_id = 1; repeated string actors = 2; bool by_key = 3; + string template_id = 4; } message LookupByKeyActionDescription { diff --git a/sdk/canton/community/base/src/main/scala/com/digitalasset/canton/data/ActionDescription.scala b/sdk/canton/community/base/src/main/scala/com/digitalasset/canton/data/ActionDescription.scala index 0c826a18fe..2e41825713 100644 --- a/sdk/canton/community/base/src/main/scala/com/digitalasset/canton/data/ActionDescription.scala +++ b/sdk/canton/community/base/src/main/scala/com/digitalasset/canton/data/ActionDescription.scala @@ -155,7 +155,7 @@ object ActionDescription extends HasProtocolVersionedCompanion[ActionDescription case LfNodeFetch( inputContract, _packageName, - _templateId, + templateId, actingParties, _signatories, _stakeholders, @@ -174,7 +174,7 @@ object ActionDescription extends HasProtocolVersionedCompanion[ActionDescription actingParties, InvalidActionDescription("Fetch node without acting parties"), ) - } yield FetchActionDescription(inputContract, actors, byKey)( + } yield FetchActionDescription(inputContract, actors, byKey, templateId)( protocolVersionRepresentativeFor(protocolVersion) ) @@ -275,11 +275,17 @@ object ActionDescription extends HasProtocolVersionedCompanion[ActionDescription f: v30.ActionDescription.FetchActionDescription, pv: RepresentativeProtocolVersion[ActionDescription.type], ): ParsingResult[FetchActionDescription] = { - val v30.ActionDescription.FetchActionDescription(inputContractIdP, actorsP, byKey) = f + val v30.ActionDescription.FetchActionDescription( + inputContractIdP, + actorsP, + byKey, + templateIdP, + ) = f for { inputContractId <- ProtoConverter.parseLfContractId(inputContractIdP) actors <- actorsP.traverse(ProtoConverter.parseLfPartyId).map(_.toSet) - } yield FetchActionDescription(inputContractId, actors, byKey)(pv) + templateId <- RefIdentifierSyntax.fromProtoPrimitive(templateIdP) + } yield FetchActionDescription(inputContractId, actors, byKey, templateId)(pv) } private[data] def fromProtoV30( @@ -445,6 +451,7 @@ object ActionDescription extends HasProtocolVersionedCompanion[ActionDescription inputContractId: LfContractId, actors: Set[LfPartyId], override val byKey: Boolean, + templateId: LfTemplateId, )( override val representativeProtocolVersion: RepresentativeProtocolVersion[ ActionDescription.type @@ -460,6 +467,7 @@ object ActionDescription extends HasProtocolVersionedCompanion[ActionDescription inputContractId = inputContractId.toProtoPrimitive, actors = actors.toSeq, byKey = byKey, + templateId = new RefIdentifierSyntax(templateId).toProtoPrimitive, ) ) diff --git a/sdk/canton/community/base/src/main/scala/com/digitalasset/canton/data/ViewParticipantData.scala b/sdk/canton/community/base/src/main/scala/com/digitalasset/canton/data/ViewParticipantData.scala index 5a0c70d1d0..4a0010c46d 100644 --- a/sdk/canton/community/base/src/main/scala/com/digitalasset/canton/data/ViewParticipantData.scala +++ b/sdk/canton/community/base/src/main/scala/com/digitalasset/canton/data/ViewParticipantData.scala @@ -219,14 +219,14 @@ final case class ViewParticipantData private ( } RootAction(cmd, actors, failed, packagePreference) - case FetchActionDescription(inputContractId, actors, byKey) => + case FetchActionDescription(inputContractId, actors, byKey, templateId) => val inputContract = coreInputs.getOrElse( inputContractId, throw InvalidViewParticipantData( show"Input contract $inputContractId of the Fetch root action is not declared as core input." ), ) - val templateId = inputContract.contract.contractInstance.unversioned.template + val cmd = if (byKey) { val key = inputContract.contract.metadata.maybeKey .map(_.key) diff --git a/sdk/canton/community/common/src/test/scala/com/digitalasset/canton/data/GeneratorsData.scala b/sdk/canton/community/common/src/test/scala/com/digitalasset/canton/data/GeneratorsData.scala index 39e09caef4..3e7e01bfb0 100644 --- a/sdk/canton/community/common/src/test/scala/com/digitalasset/canton/data/GeneratorsData.scala +++ b/sdk/canton/community/common/src/test/scala/com/digitalasset/canton/data/GeneratorsData.scala @@ -220,7 +220,8 @@ final class GeneratorsData( inputContractId <- Arbitrary.arbitrary[LfContractId] actors <- Gen.containerOf[Set, LfPartyId](Arbitrary.arbitrary[LfPartyId]) byKey <- Gen.oneOf(true, false) - } yield FetchActionDescription(inputContractId, actors, byKey)(rpv) + templateId <- Arbitrary.arbitrary[LfTemplateId] + } yield FetchActionDescription(inputContractId, actors, byKey, templateId)(rpv) private def lookupByKeyActionDescriptionGenFor( rpv: RepresentativeProtocolVersion[ActionDescription.type] diff --git a/sdk/canton/community/common/src/test/scala/com/digitalasset/canton/protocol/ExampleTransactionFactory.scala b/sdk/canton/community/common/src/test/scala/com/digitalasset/canton/protocol/ExampleTransactionFactory.scala index 5754d00799..9378137bac 100644 --- a/sdk/canton/community/common/src/test/scala/com/digitalasset/canton/protocol/ExampleTransactionFactory.scala +++ b/sdk/canton/community/common/src/test/scala/com/digitalasset/canton/protocol/ExampleTransactionFactory.scala @@ -71,6 +71,7 @@ object ExampleTransactionFactory { // Helper methods for Daml-LF types val languageVersion = LfTransactionBuilder.defaultLanguageVersion val packageId = LfTransactionBuilder.defaultPackageId + val upgradePackageId = LfPackageId.assertFromString("upgraded-pkg-id") val templateId = LfTransactionBuilder.defaultTemplateId val packageName = LfTransactionBuilder.defaultPackageName val someOptUsedPackages = Some(Set(packageId)) @@ -140,6 +141,7 @@ object ExampleTransactionFactory { key: Option[LfGlobalKeyWithMaintainers] = None, byKey: Boolean = false, version: LfTransactionVersion = transactionVersion, + templateId: LfTemplateId = templateId, ): LfNodeFetch = LfNodeFetch( coid = cid, @@ -371,7 +373,7 @@ object ExampleTransactionFactory { packages = Seq(submittingParticipant, signatoryParticipant, observerParticipant, extraParticipant) .map( - _ -> Seq(ExampleTransactionFactory.packageId) + _ -> Seq(ExampleTransactionFactory.packageId, upgradePackageId) ) .toMap, ) @@ -593,6 +595,7 @@ class ExampleTransactionFactory( resolvedKeys: Map[LfGlobalKey, SerializableKeyResolution], seed: Option[LfHash], isRoot: Boolean, + packagePreference: Set[LfPackageId], subviews: TransactionView* ): TransactionView = { @@ -637,7 +640,7 @@ class ExampleTransactionFactory( ActionDescription.tryFromLfActionNode( LfTransactionUtil.lightWeight(node), seed, - packagePreference = Set.empty, + packagePreference = packagePreference, protocolVersion, ) @@ -904,7 +907,7 @@ class ExampleTransactionFactory( ) lazy val view0: TransactionView = - view(node, 0, consumed, used, created, Map.empty, nodeSeed, isRoot = true) + view(node, 0, consumed, used, created, Map.empty, nodeSeed, isRoot = true, Set.empty) override lazy val rootViews: Seq[TransactionView] = Seq(view0) @@ -1136,7 +1139,7 @@ class ExampleTransactionFactory( consuming: Boolean = true, ) extends SingleNode(Some(seed)) { val upgradedTemplateId: canton.protocol.LfTemplateId = - templateId.copy(packageId = LfPackageId.assertFromString("upgraded")) + templateId.copy(packageId = upgradePackageId) private def genNode(id: LfContractId): LfNodeExercises = exerciseNode(targetCoid = id, templateId = upgradedTemplateId, signatories = Set(submitter)) override def node: LfNodeExercises = genNode(contractId) @@ -1254,6 +1257,7 @@ class ExampleTransactionFactory( Map.empty, ex.nodeSeed, isRoot = true, + Set.empty, ) } @@ -1572,6 +1576,7 @@ class ExampleTransactionFactory( Map.empty, Some(create0seed), isRoot = true, + Set.empty, ) val view10: TransactionView = @@ -1584,6 +1589,7 @@ class ExampleTransactionFactory( Map.empty, Some(create12seed), isRoot = false, + Set.empty, ) // TODO(#19611): Merge the informees and quorums of the child action nodes to this view and enable test in standardHappyCases @@ -1604,6 +1610,7 @@ class ExampleTransactionFactory( Map.empty, Some(deriveNodeSeed(1)), isRoot = true, + Set.empty, view10, ) @@ -2004,6 +2011,7 @@ class ExampleTransactionFactory( Map.empty, Some(create0seed), isRoot = true, + Set.empty, ) val view10: TransactionView = view( @@ -2015,6 +2023,7 @@ class ExampleTransactionFactory( Map.empty, Some(create130seed), isRoot = false, + Set.empty, ) val view110: TransactionView = view( @@ -2026,6 +2035,7 @@ class ExampleTransactionFactory( Map.empty, Some(create1310seed), isRoot = false, + Set.empty, ) val view11: TransactionView = @@ -2045,6 +2055,7 @@ class ExampleTransactionFactory( Map.empty, Some(deriveNodeSeed(1, 3, 1)), isRoot = false, + Set.empty, view110, ) @@ -2068,6 +2079,7 @@ class ExampleTransactionFactory( Map.empty, Some(deriveNodeSeed(1)), isRoot = true, + Set.empty, view10, view11, ) @@ -2561,6 +2573,7 @@ class ExampleTransactionFactory( Map.empty, Some(create0seed), isRoot = true, + Set.empty, ) val view100: TransactionView = @@ -2573,6 +2586,7 @@ class ExampleTransactionFactory( Map.empty, Some(create100seed), isRoot = false, + Set.empty, ) val view10: TransactionView = view( @@ -2591,6 +2605,7 @@ class ExampleTransactionFactory( Map.empty, Some(deriveNodeSeed(1, 0)), isRoot = false, + Set.empty, view100, ) @@ -2604,6 +2619,7 @@ class ExampleTransactionFactory( Map.empty, Some(create120seed), isRoot = false, + Set.empty, ) val view11: TransactionView = @@ -2623,6 +2639,7 @@ class ExampleTransactionFactory( Map.empty, Some(deriveNodeSeed(1, 2)), isRoot = false, + Set.empty, view110, ) @@ -2646,6 +2663,7 @@ class ExampleTransactionFactory( Map.empty, Some(deriveNodeSeed(1)), isRoot = true, + Set.empty, view10, view11, ) @@ -2660,6 +2678,7 @@ class ExampleTransactionFactory( Map.empty, Some(create2seed), isRoot = true, + Set.empty, ) override lazy val rootViews: Seq[TransactionView] = Seq(view0, view1, view2) @@ -3053,6 +3072,7 @@ class ExampleTransactionFactory( Map.empty, Some(create0seed), isRoot = true, + Set.empty, ) val view10: TransactionView = view( @@ -3071,6 +3091,7 @@ class ExampleTransactionFactory( Map.empty, Some(deriveNodeSeed(1, 1)), isRoot = false, + Set.empty, ) val view1: TransactionView = view( @@ -3089,6 +3110,7 @@ class ExampleTransactionFactory( Map.empty, Some(deriveNodeSeed(1)), isRoot = true, + Set.empty, view10, ) diff --git a/sdk/canton/community/domain/src/main/scala/com/digitalasset/canton/domain/block/update/SubmissionRequestValidator.scala b/sdk/canton/community/domain/src/main/scala/com/digitalasset/canton/domain/block/update/SubmissionRequestValidator.scala index e24cd12553..562700550e 100644 --- a/sdk/canton/community/domain/src/main/scala/com/digitalasset/canton/domain/block/update/SubmissionRequestValidator.scala +++ b/sdk/canton/community/domain/src/main/scala/com/digitalasset/canton/domain/block/update/SubmissionRequestValidator.scala @@ -480,22 +480,6 @@ private[update] final class SubmissionRequestValidator( mapping <- EitherT.right[SubmissionRequestOutcome]( topologyOrSequencingSnapshot.ipsSnapshot.activeParticipantsOfParties(parties.toSeq) ) - _ <- mapping.toList.parTraverse { case (party, participants) => - val nonRegistered = participants.filterNot(isMemberRegistered(state)) - EitherT.cond[Future]( - nonRegistered.isEmpty, - (), - // TODO(#14322): review if still applicable and consider an error code (SequencerDeliverError) - invalidSubmissionRequest( - state, - submissionRequest, - sequencingTimestamp, - SequencerErrors.SubmissionRequestRefused( - s"The party $party is hosted on non registered participants $nonRegistered" - ), - ), - ) - } } yield GroupAddressResolver.asGroupRecipientsToMembers(mapping) }.mapK(FutureUnlessShutdown.outcomeK) diff --git a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcInspectionService.scala b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcInspectionService.scala index 1bbcbebbfa..476c97302e 100644 --- a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcInspectionService.scala +++ b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcInspectionService.scala @@ -13,6 +13,8 @@ import com.digitalasset.canton.admin.participant.v30.{ LookupContractDomain, LookupOffsetByIndex, LookupOffsetByTime, + LookupReceivedAcsCommitments, + LookupSentAcsCommitments, LookupTransactionDomain, SetConfigForSlowCounterParticipants, } @@ -137,4 +139,19 @@ class GrpcInspectionService(syncStateInspection: SyncStateInspection)(implicit override def getIntervalsBehindForCounterParticipants( request: GetIntervalsBehindForCounterParticipants.Request ): Future[GetIntervalsBehindForCounterParticipants.Response] = ??? + + /** TODO(#18452) R5 + * Look up the ACS commitments computed and sent by a participant + */ + override def lookupSentAcsCommitments( + request: LookupSentAcsCommitments.Request + ): Future[LookupSentAcsCommitments.Response] = ??? + + /** TODO(#18452) R5 + * List the counter-participants of a participant and their ACS commitments together with the match status + * TODO(#18749) R1 Can also be used for R1, to fetch commitments that a counter participant received from myself + */ + override def lookupReceivedAcsCommitments( + request: LookupReceivedAcsCommitments.Request + ): Future[LookupReceivedAcsCommitments.Response] = ??? } diff --git a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/ContractEnrichmentFactory.scala b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/ContractEnrichmentFactory.scala deleted file mode 100644 index 527b8766e7..0000000000 --- a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/ContractEnrichmentFactory.scala +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.participant.protocol.submission - -import com.digitalasset.canton.participant.protocol.submission.ContractEnrichmentFactory.ContractEnrichment -import com.digitalasset.canton.protocol.RollbackContext.RollbackScope -import com.digitalasset.canton.protocol.* - -/** Prior to protocol version 5 the metadata used in the [[ViewParticipantData.coreInputs]] contract map - * was derived from `coreOtherNodes` passed to the `createViewParticipantData` method. - * - * In the situation where a node is looked up by key but then not resolved into a contract the core other nodes - * will only contain a [[LfNodeLookupByKey]]. As this does not have signatories or stakeholder fields the global - * key maintainers where (incorrectly) used. - * - * For v5 we have a fully formed [[SerializableContract]] available so do not need to enrich with metadata. Protocol - * v4 participants should continue to use the maintainer based metadata to avoid model conformance problems. - */ -private[submission] trait ContractEnrichmentFactory { - def apply(coreOtherNodes: List[(LfActionNode, RollbackScope)]): ContractEnrichment -} - -private[submission] object ContractEnrichmentFactory { - - private type ContractEnrichment = SerializableContract => SerializableContract - - def apply(): ContractEnrichmentFactory = PV5 - - private object PV5 extends ContractEnrichmentFactory { - override def apply(coreOtherNodes: List[(LfActionNode, RollbackScope)]): ContractEnrichment = - identity - } - -} diff --git a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/TransactionTreeFactoryImpl.scala b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/TransactionTreeFactoryImpl.scala index 8df7c3517a..b8d8f406c9 100644 --- a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/TransactionTreeFactoryImpl.scala +++ b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/TransactionTreeFactoryImpl.scala @@ -6,17 +6,24 @@ package com.digitalasset.canton.participant.protocol.submission import cats.data.EitherT import cats.syntax.bifunctor.* import cats.syntax.either.* +import cats.syntax.functor.* import cats.syntax.functorFilter.* import cats.syntax.parallel.* +import cats.syntax.traverse.* import com.daml.lf.data.Ref.PackageId +import com.daml.lf.transaction.ContractStateMachine.KeyInactive +import com.daml.lf.transaction.Transaction.{KeyActive, KeyCreate, KeyInput, NegativeKeyLookup} +import com.daml.lf.transaction.{ContractKeyUniquenessMode, ContractStateMachine} import com.digitalasset.canton.* import com.digitalasset.canton.crypto.{HashOps, HmacOps, Salt, SaltSeed} +import com.digitalasset.canton.data.TransactionViewDecomposition.{NewView, SameView} import com.digitalasset.canton.data.ViewConfirmationParameters.InvalidViewConfirmationParameters import com.digitalasset.canton.data.* import com.digitalasset.canton.ledger.participant.state.SubmitterInfo import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.{ErrorLoggingContext, NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.participant.protocol.submission.TransactionTreeFactory.* +import com.digitalasset.canton.participant.protocol.submission.TransactionTreeFactoryImpl.* import com.digitalasset.canton.protocol.ContractIdSyntax.* import com.digitalasset.canton.protocol.RollbackContext.RollbackScope import com.digitalasset.canton.protocol.SerializableContract.LedgerCreateTime @@ -27,6 +34,7 @@ import com.digitalasset.canton.topology.client.TopologySnapshot import com.digitalasset.canton.topology.{DomainId, ParticipantId} import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.FutureInstances.* +import com.digitalasset.canton.util.ShowUtil.* import com.digitalasset.canton.util.{ErrorUtil, LfTransactionUtil, MapsUtil, MonadUtil} import com.digitalasset.canton.version.ProtocolVersion import io.scalaland.chimney.dsl.* @@ -45,7 +53,7 @@ import scala.concurrent.{ExecutionContext, Future} * @param cryptoOps is used to derive Merkle hashes and contract ids [[com.digitalasset.canton.crypto.HashOps]] * as well as salts and contract ids [[com.digitalasset.canton.crypto.HmacOps]] */ -abstract class TransactionTreeFactoryImpl( +class TransactionTreeFactoryImpl( participantId: ParticipantId, domainId: DomainId, protocolVersion: ProtocolVersion, @@ -59,26 +67,6 @@ abstract class TransactionTreeFactoryImpl( private val unicumGenerator = new UnicumGenerator(cryptoOps) private val cantonContractIdVersion = AuthenticatedContractIdVersionV10 private val transactionViewDecompositionFactory = TransactionViewDecompositionFactory - private val contractEnrichmentFactory = ContractEnrichmentFactory() - - protected type State <: TransactionTreeFactoryImpl.State - - protected def stateForSubmission( - transactionSeed: SaltSeed, - mediator: MediatorGroupRecipient, - transactionUUID: UUID, - ledgerTime: CantonTimestamp, - keyResolver: LfKeyResolver, - nextSaltIndex: Int, - ): State - - protected def stateForValidation( - mediator: MediatorGroupRecipient, - transactionUUID: UUID, - ledgerTime: CantonTimestamp, - salts: Iterable[Salt], - keyResolver: LfKeyResolver, - ): State override def createTransactionTree( transaction: WellFormedTransaction[WithoutSuffixes], @@ -103,7 +91,6 @@ abstract class TransactionTreeFactoryImpl( transactionUuid, metadata.ledgerTime, keyResolver, - nextSaltIndex = 0, ) // Create salts @@ -190,112 +177,17 @@ abstract class TransactionTreeFactoryImpl( } yield rootViews } - @SuppressWarnings(Array("org.wartremover.warts.IterableOps")) - override def tryReconstruct( - subaction: WellFormedTransaction[WithoutSuffixes], - rootPosition: ViewPosition, - confirmationPolicy: ConfirmationPolicy, + private def stateForSubmission( + transactionSeed: SaltSeed, mediator: MediatorGroupRecipient, - submittingParticipantO: Option[ParticipantId], - viewSalts: Iterable[Salt], - transactionUuid: UUID, - topologySnapshot: TopologySnapshot, - contractOfId: SerializableContractOfId, - rbContext: RollbackContext, + transactionUUID: UUID, + ledgerTime: CantonTimestamp, keyResolver: LfKeyResolver, - )(implicit traceContext: TraceContext): EitherT[ - Future, - TransactionTreeConversionError, - (TransactionView, WellFormedTransaction[WithSuffixes]), - ] = { - /* We ship the root node of the view with suffixed contract IDs. - * If this is a create node, reinterpretation will allocate an unsuffixed contract id instead of the one given in the node. - * If this is an exercise node, the exercise result may contain unsuffixed contract ids created in the body of the exercise. - * Accordingly, the reinterpreted transaction must first be suffixed before we can compare it against - * the shipped views. - * Ideally we'd ship only the inputs needed to reconstruct the transaction rather than computed data - * such as exercise results and created contract IDs. - */ - - ErrorUtil.requireArgument( - subaction.unwrap.roots.length == 1, - s"Subaction must have a single root node, but has ${subaction.unwrap.roots.iterator.mkString(", ")}", - ) - - val metadata = subaction.metadata - val state = stateForValidation( - mediator, - transactionUuid, - metadata.ledgerTime, - viewSalts, - keyResolver, - ) - - val decompositionsF = - transactionViewDecompositionFactory.fromTransaction( - confirmationPolicy, - topologySnapshot, - subaction, - rbContext, - submittingParticipantO.map(_.adminParty.toLf), - ) - for { - decompositions <- EitherT.liftF(decompositionsF) - decomposition = checked(decompositions.head) - view <- createView(decomposition, rootPosition, state, contractOfId) - } yield { - val suffixedNodes = state.suffixedNodes() transform { - // Recover the children - case (nodeId, ne: LfNodeExercises) => - checked(subaction.unwrap.nodes(nodeId)) match { - case ne2: LfNodeExercises => - ne.copy(children = ne2.children) - case _: LfNode => - throw new IllegalStateException( - "Node type changed while constructing the transaction tree" - ) - } - case (_, nl: LfLeafOnlyActionNode) => nl - } - - // keep around the rollback nodes (not suffixed as they don't have a contract id), so that we don't orphan suffixed nodes. - val rollbackNodes = subaction.unwrap.nodes.collect { case tuple @ (_, _: LfNodeRollback) => - tuple - } - - val suffixedTx = LfVersionedTransaction( - subaction.unwrap.version, - suffixedNodes ++ rollbackNodes, - subaction.unwrap.roots, - ) - view -> checked(WellFormedTransaction.normalizeAndAssert(suffixedTx, metadata, WithSuffixes)) - } - } - - override def saltsFromView(view: TransactionView): Iterable[Salt] = { - val salts = Iterable.newBuilder[Salt] - - def addSaltsFrom(subview: TransactionView): Unit = { - // Salts must be added in the same order as they are requested by checkView - salts += checked(subview.viewCommonData.tryUnwrap).salt - salts += checked(subview.viewParticipantData.tryUnwrap).salt - } - - @tailrec - @nowarn("msg=match may not be exhaustive") - def go(stack: Seq[TransactionView]): Unit = stack match { - case Seq() => - case subview +: toVisit => - addSaltsFrom(subview) - subview.subviews.assertAllUnblinded(hash => - s"View ${subview.viewHash} contains an unexpected blinded subview $hash" - ) - go(subview.subviews.unblindedElements ++ toVisit) - } - - go(Seq(view)) - - salts.result() + ): State = { + val salts = LazyList + .from(0) + .map(index => Salt.tryDeriveSalt(transactionSeed, index, cryptoOps)) + new State(mediator, transactionUUID, ledgerTime, salts.iterator, keyResolver) } /** compute set of required packages for each party */ @@ -387,16 +279,191 @@ abstract class TransactionTreeFactoryImpl( } } - protected def createView( + private def createView( view: TransactionViewDecomposition.NewView, viewPosition: ViewPosition, state: State, contractOfId: SerializableContractOfId, )(implicit traceContext: TraceContext - ): EitherT[Future, TransactionTreeConversionError, TransactionView] + ): EitherT[Future, TransactionTreeConversionError, TransactionView] = { + state.signalRollbackScope(view.rbContext.rollbackScope) - protected def updateStateWithContractCreation( + // reset to a fresh state with projected resolver before visiting the subtree + val previousCsmState = state.csmState + val previousResolver = state.currentResolver + state.currentResolver = state.csmState.projectKeyResolver(previousResolver) + state.csmState = initialCsmState + + // Process core nodes and subviews + val coreCreatedBuilder = + List.newBuilder[(LfNodeCreate, RollbackScope)] // contract IDs have already been suffixed + val coreOtherBuilder = // contract IDs have not yet been suffixed + List.newBuilder[((LfNodeId, LfActionNode), RollbackScope)] + val childViewsBuilder = Seq.newBuilder[TransactionView] + val subViewKeyResolutions = + mutable.Map.empty[LfGlobalKey, LfVersioned[SerializableKeyResolution]] + + @SuppressWarnings(Array("org.wartremover.warts.Var")) + var createIndex = 0 + + val nbSubViews = view.allNodes.count { + case _: TransactionViewDecomposition.NewView => true + case _ => false + } + val subviewIndex = TransactionSubviews.indices(nbSubViews).iterator + val createdInView = mutable.Set.empty[LfContractId] + + def fromEither[A <: TransactionTreeConversionError, B]( + either: Either[A, B] + ): EitherT[Future, TransactionTreeConversionError, B] = + EitherT.fromEither(either.leftWiden[TransactionTreeConversionError]) + + for { + // Compute salts + viewCommonDataSalt <- fromEither(state.nextSalt()) + viewParticipantDataSalt <- fromEither(state.nextSalt()) + _ <- MonadUtil.sequentialTraverse_(view.allNodes) { + case childView: TransactionViewDecomposition.NewView => + // Compute subviews, recursively + createView(childView, subviewIndex.next() +: viewPosition, state, contractOfId) + .map { v => + childViewsBuilder += v + val createdInSubview = state.createdContractsInView + createdInView ++= createdInSubview + val keyResolutionsInSubview = v.globalKeyInputs.fmap(_.map(_.asSerializable)) + MapsUtil.extendMapWith(subViewKeyResolutions, keyResolutionsInSubview) { + (accRes, _) => accRes + } + } + + case TransactionViewDecomposition.SameView(lfActionNode, nodeId, rbContext) => + val rbScope = rbContext.rollbackScope + val suffixedNode = lfActionNode match { + case createNode: LfNodeCreate => + val suffixedNode = updateStateWithContractCreation( + nodeId, + createNode, + viewParticipantDataSalt, + viewPosition, + createIndex, + state, + ) + coreCreatedBuilder += (suffixedNode -> rbScope) + createdInView += suffixedNode.coid + createIndex += 1 + suffixedNode + case lfNode: LfActionNode => + val suffixedNode = trySuffixNode(state)(nodeId -> lfNode) + coreOtherBuilder += ((nodeId, lfNode) -> rbScope) + suffixedNode + } + + suffixedNode.keyOpt.foreach { case LfGlobalKeyWithMaintainers(gkey, maintainers) => + state.keyVersionAndMaintainers += (gkey -> (suffixedNode.version -> maintainers)) + } + + state.signalRollbackScope(rbScope) + + EitherT.fromEither[Future]({ + for { + resolutionForModeOff <- suffixedNode match { + case lookupByKey: LfNodeLookupByKey + if state.csmState.mode == ContractKeyUniquenessMode.Off => + val gkey = lookupByKey.key.globalKey + state.currentResolver.get(gkey).toRight(MissingContractKeyLookupError(gkey)) + case _ => Right(KeyInactive) // dummy value, as resolution is not used + } + nextState <- state.csmState + .handleNode((), suffixedNode, resolutionForModeOff) + .leftMap(ContractKeyResolutionError) + } yield { + state.csmState = nextState + } + }) + } + _ = state.signalRollbackScope(view.rbContext.rollbackScope) + + coreCreatedNodes = coreCreatedBuilder.result() + // Translate contract ids in untranslated core nodes + // This must be done only after visiting the whole action (rather than just the node) + // because an Exercise result may contain an unsuffixed contract ID of a contract + // that was created in the consequences of the exercise, i.e., we know the suffix only + // after we have visited the create node. + coreOtherNodes = coreOtherBuilder.result().map { case (nodeInfo, rbc) => + (checked(trySuffixNode(state)(nodeInfo)), rbc) + } + childViews = childViewsBuilder.result() + + suffixedRootNode = coreOtherNodes.headOption + .orElse(coreCreatedNodes.headOption) + .map { case (node, _) => node } + .getOrElse( + throw new IllegalArgumentException(s"The received view has no core nodes. $view") + ) + + // Compute the parameters of the view + seed = view.rootSeed + packagePreference <- EitherT.fromEither[Future](buildPackagePreference(view)) + actionDescription = createActionDescription(suffixedRootNode, seed, packagePreference) + viewCommonData = createViewCommonData(view, viewCommonDataSalt).fold( + ErrorUtil.internalError, + identity, + ) + viewKeyInputs = state.csmState.globalKeyInputs + resolvedK <- EitherT.fromEither[Future]( + resolvedKeys( + viewKeyInputs, + state.keyVersionAndMaintainers, + subViewKeyResolutions, + ) + ) + viewParticipantData <- createViewParticipantData( + coreCreatedNodes, + coreOtherNodes, + childViews, + state.createdContractInfo, + resolvedK, + actionDescription, + viewParticipantDataSalt, + contractOfId, + view.rbContext, + ) + + // fast-forward the former state over the subtree + nextCsmState <- EitherT.fromEither[Future]( + previousCsmState + .advance( + // advance ignores the resolver in mode Strict + if (state.csmState.mode == ContractKeyUniquenessMode.Strict) Map.empty + else previousResolver, + state.csmState, + ) + .leftMap(ContractKeyResolutionError(_): TransactionTreeConversionError) + ) + } yield { + // Compute the result + val subviews = TransactionSubviews(childViews)(protocolVersion, cryptoOps) + val transactionView = + TransactionView.tryCreate(cryptoOps)( + viewCommonData, + viewParticipantData, + subviews, + protocolVersion, + ) + + checkCsmStateMatchesView(state.csmState, transactionView, viewPosition) + + // Update the out parameters in the `State` + state.createdContractsInView = createdInView + state.csmState = nextCsmState + state.currentResolver = previousResolver + + transactionView + } + } + + private def updateStateWithContractCreation( nodeId: LfNodeId, createNode: LfNodeCreate, viewParticipantDataSalt: Salt, @@ -461,7 +528,7 @@ abstract class TransactionTreeFactoryImpl( suffixedNode } - protected def trySuffixNode( + private def trySuffixNode( state: State )(idAndNode: (LfNodeId, LfActionNode)): LfActionNode = { val (nodeId, node) = idAndNode @@ -475,7 +542,41 @@ abstract class TransactionTreeFactoryImpl( suffixedNode } - protected def createActionDescription( + private[submission] def buildPackagePreference( + decomposition: TransactionViewDecomposition + ): Either[ConflictingPackagePreferenceError, Set[LfPackageId]] = { + + def nodePref(n: LfActionNode): Set[(LfPackageName, LfPackageId)] = n match { + case ex: LfNodeExercises if ex.interfaceId.isDefined => + Set(ex.packageName -> ex.templateId.packageId) + case _ => Set.empty + } + + @tailrec + def go( + decompositions: List[TransactionViewDecomposition], + resolved: Set[(LfPackageName, LfPackageId)], + ): Set[(LfPackageName, LfPackageId)] = { + decompositions match { + case Nil => + resolved + case (v: SameView) :: others => + go(others, resolved ++ nodePref(v.lfNode)) + case (v: NewView) :: others => + go(v.tailNodes.toList ::: others, resolved ++ nodePref(v.lfNode)) + } + } + + val preferences = go(List(decomposition), Set.empty) + MapsUtil + .toNonConflictingMap(preferences) + .bimap( + conflicts => ConflictingPackagePreferenceError(conflicts), + map => map.values.toSet, + ) + } + + private def createActionDescription( actionNode: LfActionNode, seed: Option[LfHash], packagePreference: Set[LfPackageId], @@ -484,7 +585,7 @@ abstract class TransactionTreeFactoryImpl( ActionDescription.tryFromLfActionNode(actionNode, seed, packagePreference, protocolVersion) ) - protected def createViewCommonData( + private def createViewCommonData( rootView: TransactionViewDecomposition.NewView, salt: Salt, ): Either[InvalidViewConfirmationParameters, ViewCommonData] = @@ -494,7 +595,77 @@ abstract class TransactionTreeFactoryImpl( protocolVersion, ) - protected def createViewParticipantData( + /** The difference between `viewKeyInputs: Map[LfGlobalKey, KeyInput]` and + * `subviewKeyResolutions: Map[LfGlobalKey, SerializableKeyResolution]`, computed as follows: + * + * + * Note: The following argument depends on how this method is used. + * It just sits here because we can then use scaladoc referencing. + * + * All resolved contract IDs in the map difference are core input contracts by the following argument: + * Suppose that the map difference resolves a key `k` to a contract ID `cid`. + * - In mode [[com.daml.lf.transaction.ContractKeyUniquenessMode.Strict]], + * the first node (in execution order) involving the key `k` determines the key's resolution for the view. + * So the first node `n` in execution order involving `k` is an Exercise, Fetch, or positive LookupByKey node. + * - In mode [[com.daml.lf.transaction.ContractKeyUniquenessMode.Off]], + * the first by-key node (in execution order, including Creates) determines the global key input of the view. + * So the first by-key node `n` is an ExerciseByKey, FetchByKey, or positive LookupByKey node. + * In particular, `n` cannot be a Create node because then the resolution for the view + * would be [[com.daml.lf.transaction.ContractStateMachine.KeyInactive]]. + * If this node `n` is in the core of the view, then `cid` is a core input and we are done. + * If this node `n` is in a proper subview, then the aggregated global key inputs + * [[com.digitalasset.canton.data.TransactionView.globalKeyInputs]] + * of the subviews resolve `k` to `cid` (as resolutions from earlier subviews are preferred) + * and therefore the map difference does not resolve `k` at all. + * + * @return `Left(...)` if `viewKeyInputs` contains a key not in the `keyVersionAndMaintainers.keySet` + * @throws java.lang.IllegalArgumentException if `subviewKeyResolutions.keySet` is not a subset of `viewKeyInputs.keySet` + */ + private def resolvedKeys( + viewKeyInputs: Map[LfGlobalKey, KeyInput], + keyVersionAndMaintainers: collection.Map[LfGlobalKey, (LfTransactionVersion, Set[LfPartyId])], + subviewKeyResolutions: collection.Map[LfGlobalKey, LfVersioned[SerializableKeyResolution]], + )(implicit + traceContext: TraceContext + ): Either[TransactionTreeConversionError, Map[LfGlobalKey, LfVersioned[ + SerializableKeyResolution + ]]] = { + ErrorUtil.requireArgument( + subviewKeyResolutions.keySet.subsetOf(viewKeyInputs.keySet), + s"Global key inputs of subview not part of the global key inputs of the parent view. Missing keys: ${subviewKeyResolutions.keySet + .diff(viewKeyInputs.keySet)}", + ) + + def resolutionFor( + key: LfGlobalKey, + keyInput: KeyInput, + ): Either[MissingContractKeyLookupError, LfVersioned[SerializableKeyResolution]] = { + keyVersionAndMaintainers.get(key).toRight(MissingContractKeyLookupError(key)).map { + case (lfVersion, maintainers) => + val resolution = keyInput match { + case KeyActive(cid) => AssignedKey(cid) + case KeyCreate | NegativeKeyLookup => FreeKey(maintainers) + } + LfVersioned(lfVersion, resolution) + } + } + + for { + viewKeyResolutionSeq <- viewKeyInputs.toSeq + .traverse { case (gkey, keyInput) => + resolutionFor(gkey, keyInput).map(gkey -> _) + } + } yield { + MapsUtil.mapDiff(viewKeyResolutionSeq.toMap, subviewKeyResolutions) + } + } + + private def createViewParticipantData( coreCreatedNodes: List[(LfNodeCreate, RollbackScope)], coreOtherNodes: List[(LfActionNode, RollbackScope)], childViews: Seq[TransactionView], @@ -544,8 +715,6 @@ abstract class TransactionTreeFactoryImpl( val coreInputs = usedCore -- createdInSameViewOrSubviews val createdInSubviewArchivedInCore = consumedInCore intersect createdInSubviews - val contractEnricher = contractEnrichmentFactory(coreOtherNodes) - def withInstance( contractId: LfContractId ): EitherT[Future, ContractLookupError, InputContract] = { @@ -554,7 +723,7 @@ abstract class TransactionTreeFactoryImpl( case Some(info) => EitherT.pure(InputContract(info, cons)) case None => - contractOfId(contractId).map { c => InputContract(contractEnricher(c), cons) } + contractOfId(contractId).map { c => InputContract(c, cons) } } } @@ -579,6 +748,152 @@ abstract class TransactionTreeFactoryImpl( .leftMap[TransactionTreeConversionError](ViewParticipantDataError) } yield viewParticipantData } + + /** Check that we correctly reconstruct the csm state machine + * Canton does not distinguish between the different com.daml.lf.transaction.Transaction.KeyInactive forms right now + */ + private def checkCsmStateMatchesView( + csmState: ContractStateMachine.State[Unit], + transactionView: TransactionView, + viewPosition: ViewPosition, + )(implicit traceContext: TraceContext): Unit = { + val viewGki = transactionView.globalKeyInputs.fmap(_.unversioned.resolution) + val stateGki = csmState.globalKeyInputs.fmap(_.toKeyMapping) + ErrorUtil.requireState( + viewGki == stateGki, + show"""Failed to reconstruct the global key inputs for the view at position $viewPosition. + | Reconstructed: $viewGki + | Expected: $stateGki""".stripMargin, + ) + val viewLocallyCreated = transactionView.createdContracts.keySet + val stateLocallyCreated = csmState.locallyCreated + ErrorUtil.requireState( + viewLocallyCreated == stateLocallyCreated, + show"Failed to reconstruct created contracts for the view at position $viewPosition.\n Reconstructed: $viewLocallyCreated\n Expected: $stateLocallyCreated", + ) + val viewInputContractIds = transactionView.inputContracts.keySet + val stateInputContractIds = csmState.inputContractIds + ErrorUtil.requireState( + viewInputContractIds == stateInputContractIds, + s"Failed to reconstruct input contracts for the view at position $viewPosition.\n Reconstructed: $viewInputContractIds\n Expected: $stateInputContractIds", + ) + } + + @SuppressWarnings(Array("org.wartremover.warts.IterableOps")) + override def tryReconstruct( + subaction: WellFormedTransaction[WithoutSuffixes], + rootPosition: ViewPosition, + confirmationPolicy: ConfirmationPolicy, + mediator: MediatorGroupRecipient, + submittingParticipantO: Option[ParticipantId], + viewSalts: Iterable[Salt], + transactionUuid: UUID, + topologySnapshot: TopologySnapshot, + contractOfId: SerializableContractOfId, + rbContext: RollbackContext, + keyResolver: LfKeyResolver, + )(implicit traceContext: TraceContext): EitherT[ + Future, + TransactionTreeConversionError, + (TransactionView, WellFormedTransaction[WithSuffixes]), + ] = { + /* We ship the root node of the view with suffixed contract IDs. + * If this is a create node, reinterpretation will allocate an unsuffixed contract id instead of the one given in the node. + * If this is an exercise node, the exercise result may contain unsuffixed contract ids created in the body of the exercise. + * Accordingly, the reinterpreted transaction must first be suffixed before we can compare it against + * the shipped views. + * Ideally we'd ship only the inputs needed to reconstruct the transaction rather than computed data + * such as exercise results and created contract IDs. + */ + + ErrorUtil.requireArgument( + subaction.unwrap.roots.length == 1, + s"Subaction must have a single root node, but has ${subaction.unwrap.roots.iterator.mkString(", ")}", + ) + + val metadata = subaction.metadata + val state = stateForValidation( + mediator, + transactionUuid, + metadata.ledgerTime, + viewSalts, + keyResolver, + ) + + val decompositionsF = + transactionViewDecompositionFactory.fromTransaction( + confirmationPolicy, + topologySnapshot, + subaction, + rbContext, + submittingParticipantO.map(_.adminParty.toLf), + ) + for { + decompositions <- EitherT.liftF(decompositionsF) + decomposition = checked(decompositions.head) + view <- createView(decomposition, rootPosition, state, contractOfId) + } yield { + val suffixedNodes = state.suffixedNodes() transform { + // Recover the children + case (nodeId, ne: LfNodeExercises) => + checked(subaction.unwrap.nodes(nodeId)) match { + case ne2: LfNodeExercises => + ne.copy(children = ne2.children) + case _: LfNode => + throw new IllegalStateException( + "Node type changed while constructing the transaction tree" + ) + } + case (_, nl: LfLeafOnlyActionNode) => nl + } + + // keep around the rollback nodes (not suffixed as they don't have a contract id), so that we don't orphan suffixed nodes. + val rollbackNodes = subaction.unwrap.nodes.collect { case tuple @ (_, _: LfNodeRollback) => + tuple + } + + val suffixedTx = LfVersionedTransaction( + subaction.unwrap.version, + suffixedNodes ++ rollbackNodes, + subaction.unwrap.roots, + ) + view -> checked(WellFormedTransaction.normalizeAndAssert(suffixedTx, metadata, WithSuffixes)) + } + } + + private def stateForValidation( + mediator: MediatorGroupRecipient, + transactionUUID: UUID, + ledgerTime: CantonTimestamp, + salts: Iterable[Salt], + keyResolver: LfKeyResolver, + ): State = new State(mediator, transactionUUID, ledgerTime, salts.iterator, keyResolver) + + override def saltsFromView(view: TransactionView): Iterable[Salt] = { + val salts = Iterable.newBuilder[Salt] + + def addSaltsFrom(subview: TransactionView): Unit = { + // Salts must be added in the same order as they are requested by checkView + salts += checked(subview.viewCommonData.tryUnwrap).salt + salts += checked(subview.viewParticipantData.tryUnwrap).salt + } + + @tailrec + @nowarn("msg=match may not be exhaustive") + def go(stack: Seq[TransactionView]): Unit = stack match { + case Seq() => + case subview +: toVisit => + addSaltsFrom(subview) + subview.subviews.assertAllUnblinded(hash => + s"View ${subview.viewHash} contains an unexpected blinded subview $hash" + ) + go(subview.subviews.unblindedElements ++ toVisit) + } + + go(Seq(view)) + + salts.result() + } } object TransactionTreeFactoryImpl { @@ -591,7 +906,7 @@ object TransactionTreeFactoryImpl { cryptoOps: HashOps & HmacOps, loggerFactory: NamedLoggerFactory, )(implicit ex: ExecutionContext): TransactionTreeFactoryImpl = - new TransactionTreeFactoryImplV3( + new TransactionTreeFactoryImpl( submittingParticipant, domainId, protocolVersion, @@ -612,12 +927,16 @@ object TransactionTreeFactoryImpl { } .merge - trait State { - def mediator: MediatorGroupRecipient - def transactionUUID: UUID - def ledgerTime: CantonTimestamp + private val initialCsmState: ContractStateMachine.State[Unit] = + ContractStateMachine.initial[Unit](ContractKeyUniquenessMode.Off) - protected def salts: Iterator[Salt] + private class State( + val mediator: MediatorGroupRecipient, + val transactionUUID: UUID, + val ledgerTime: CantonTimestamp, + val salts: Iterator[Salt], + initialResolver: LfKeyResolver, + ) { def nextSalt(): Either[TransactionTreeFactory.TooFewSalts, Salt] = Either.cond(salts.hasNext, salts.next(), TooFewSalts) @@ -671,5 +990,40 @@ object TransactionTreeFactoryImpl { /** Out parameter for contracts created in the view (including subviews). */ @SuppressWarnings(Array("org.wartremover.warts.Var")) var createdContractsInView: collection.Set[LfContractId] = Set.empty + + /** An [[com.digitalasset.canton.protocol.LfGlobalKey]] stores neither the + * [[com.digitalasset.canton.protocol.LfTransactionVersion]] to be used during serialization + * nor the maintainers, which we need to cache in case no contract is found. + * + * Out parameter that stores version and maintainers for all keys + * that have been referenced by an already-processed node. + */ + val keyVersionAndMaintainers: mutable.Map[LfGlobalKey, (LfTransactionVersion, Set[LfPartyId])] = + mutable.Map.empty + + /** Out parameter for the [[com.daml.lf.transaction.ContractStateMachine.State]] + * + * The state of the [[com.daml.lf.transaction.ContractStateMachine]] + * after iterating over the following nodes in execution order: + * 1. The iteration starts at the root node of the current view. + * 2. The iteration includes all processed nodes of the view. This includes the nodes of fully processed subviews. + */ + @SuppressWarnings(Array("org.wartremover.warts.Var")) + var csmState: ContractStateMachine.State[Unit] = initialCsmState + + /** This resolver is used to feed [[com.daml.lf.transaction.ContractStateMachine.State.handleLookupWith]]. + */ + @SuppressWarnings(Array("org.wartremover.warts.Var")) + var currentResolver: LfKeyResolver = initialResolver + + @SuppressWarnings(Array("org.wartremover.warts.Var")) + private var rollbackScope: RollbackScope = RollbackScope.empty + + def signalRollbackScope(target: RollbackScope): Unit = { + val (pops, pushes) = RollbackScope.popsAndPushes(rollbackScope, target) + for (_ <- 1 to pops) { csmState = csmState.endRollback() } + for (_ <- 1 to pushes) { csmState = csmState.beginRollback() } + rollbackScope = target + } } } diff --git a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/TransactionTreeFactoryImplV3.scala b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/TransactionTreeFactoryImplV3.scala deleted file mode 100644 index 1d66c2d262..0000000000 --- a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/submission/TransactionTreeFactoryImplV3.scala +++ /dev/null @@ -1,478 +0,0 @@ -// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.participant.protocol.submission - -import cats.data.EitherT -import cats.syntax.bifunctor.* -import cats.syntax.functor.* -import cats.syntax.traverse.* -import com.daml.lf.transaction.ContractStateMachine.KeyInactive -import com.daml.lf.transaction.Transaction.{KeyActive, KeyCreate, KeyInput, NegativeKeyLookup} -import com.daml.lf.transaction.{ContractKeyUniquenessMode, ContractStateMachine} -import com.digitalasset.canton.crypto.{HashOps, HmacOps, Salt, SaltSeed} -import com.digitalasset.canton.data.TransactionViewDecomposition.{NewView, SameView} -import com.digitalasset.canton.data.* -import com.digitalasset.canton.logging.NamedLoggerFactory -import com.digitalasset.canton.participant.protocol.submission.TransactionTreeFactory.{ - ConflictingPackagePreferenceError, - ContractKeyResolutionError, - MissingContractKeyLookupError, - SerializableContractOfId, - TransactionTreeConversionError, -} -import com.digitalasset.canton.protocol.RollbackContext.RollbackScope -import com.digitalasset.canton.protocol.* -import com.digitalasset.canton.sequencing.protocol.MediatorGroupRecipient -import com.digitalasset.canton.topology.{DomainId, ParticipantId} -import com.digitalasset.canton.tracing.TraceContext -import com.digitalasset.canton.util.ShowUtil.* -import com.digitalasset.canton.util.{ErrorUtil, MapsUtil, MonadUtil} -import com.digitalasset.canton.version.ProtocolVersion -import com.digitalasset.canton.{ - LfKeyResolver, - LfPackageId, - LfPackageName, - LfPartyId, - LfVersioned, - checked, -} - -import java.util.UUID -import scala.annotation.tailrec -import scala.collection.mutable -import scala.concurrent.{ExecutionContext, Future} - -/** Generate transaction trees as used from protocol version [[com.digitalasset.canton.version.ProtocolVersion.v31]] on - */ -class TransactionTreeFactoryImplV3( - submittingParticipant: ParticipantId, - domainId: DomainId, - protocolVersion: ProtocolVersion, - contractSerializer: LfContractInst => SerializableRawContractInstance, - cryptoOps: HashOps & HmacOps, - override protected val loggerFactory: NamedLoggerFactory, -)(implicit ec: ExecutionContext) - extends TransactionTreeFactoryImpl( - submittingParticipant, - domainId, - protocolVersion, - contractSerializer, - cryptoOps, - loggerFactory, - ) { - - private val initialCsmState: ContractStateMachine.State[Unit] = - ContractStateMachine.initial[Unit](ContractKeyUniquenessMode.Off) - - private[submission] def buildPackagePreference( - decomposition: TransactionViewDecomposition - ): Either[ConflictingPackagePreferenceError, Set[LfPackageId]] = { - - def nodePref(n: LfActionNode): Set[(LfPackageName, LfPackageId)] = n match { - case ex: LfNodeExercises if ex.interfaceId.isDefined => - Set(ex.packageName -> ex.templateId.packageId) - case _ => Set.empty - } - - @tailrec - def go( - decompositions: List[TransactionViewDecomposition], - resolved: Set[(LfPackageName, LfPackageId)], - ): Set[(LfPackageName, LfPackageId)] = { - decompositions match { - case Nil => - resolved - case (v: SameView) :: others => - go(others, resolved ++ nodePref(v.lfNode)) - case (v: NewView) :: others => - go(v.tailNodes.toList ::: others, resolved ++ nodePref(v.lfNode)) - } - } - - val preferences = go(List(decomposition), Set.empty) - MapsUtil - .toNonConflictingMap(preferences) - .bimap( - conflicts => ConflictingPackagePreferenceError(conflicts), - map => map.values.toSet, - ) - } - - protected[submission] class State private ( - override val mediator: MediatorGroupRecipient, - override val transactionUUID: UUID, - override val ledgerTime: CantonTimestamp, - override protected val salts: Iterator[Salt], - initialResolver: LfKeyResolver, - ) extends TransactionTreeFactoryImpl.State { - - /** An [[com.digitalasset.canton.protocol.LfGlobalKey]] stores neither the - * [[com.digitalasset.canton.protocol.LfTransactionVersion]] to be used during serialization - * nor the maintainers, which we need to cache in case no contract is found. - * - * Out parameter that stores version and maintainers for all keys - * that have been referenced by an already-processed node. - */ - val keyVersionAndMaintainers: mutable.Map[LfGlobalKey, (LfTransactionVersion, Set[LfPartyId])] = - mutable.Map.empty - - /** Out parameter for the [[com.daml.lf.transaction.ContractStateMachine.State]] - * - * The state of the [[com.daml.lf.transaction.ContractStateMachine]] - * after iterating over the following nodes in execution order: - * 1. The iteration starts at the root node of the current view. - * 2. The iteration includes all processed nodes of the view. This includes the nodes of fully processed subviews. - */ - @SuppressWarnings(Array("org.wartremover.warts.Var")) - var csmState: ContractStateMachine.State[Unit] = initialCsmState - - /** This resolver is used to feed [[com.daml.lf.transaction.ContractStateMachine.State.handleLookupWith]]. - */ - @SuppressWarnings(Array("org.wartremover.warts.Var")) - var currentResolver: LfKeyResolver = initialResolver - - @SuppressWarnings(Array("org.wartremover.warts.Var")) - private var rollbackScope: RollbackScope = RollbackScope.empty - - def signalRollbackScope(target: RollbackScope): Unit = { - val (pops, pushes) = RollbackScope.popsAndPushes(rollbackScope, target) - for (_ <- 1 to pops) { csmState = csmState.endRollback() } - for (_ <- 1 to pushes) { csmState = csmState.beginRollback() } - rollbackScope = target - } - } - - private[submission] object State { - private[submission] def submission( - transactionSeed: SaltSeed, - mediator: MediatorGroupRecipient, - transactionUUID: UUID, - ledgerTime: CantonTimestamp, - nextSaltIndex: Int, - keyResolver: LfKeyResolver, - ): State = { - val salts = LazyList - .from(nextSaltIndex) - .map(index => Salt.tryDeriveSalt(transactionSeed, index, cryptoOps)) - new State(mediator, transactionUUID, ledgerTime, salts.iterator, keyResolver) - } - - private[submission] def validation( - mediator: MediatorGroupRecipient, - transactionUUID: UUID, - ledgerTime: CantonTimestamp, - salts: Iterable[Salt], - keyResolver: LfKeyResolver, - ): State = new State(mediator, transactionUUID, ledgerTime, salts.iterator, keyResolver) - } - - override protected def stateForSubmission( - transactionSeed: SaltSeed, - mediator: MediatorGroupRecipient, - transactionUUID: UUID, - ledgerTime: CantonTimestamp, - keyResolver: LfKeyResolver, - nextSaltIndex: Int, - ): State = - State.submission( - transactionSeed, - mediator, - transactionUUID, - ledgerTime, - nextSaltIndex, - keyResolver, - ) - - override protected def stateForValidation( - mediator: MediatorGroupRecipient, - transactionUUID: UUID, - ledgerTime: CantonTimestamp, - salts: Iterable[Salt], - keyResolver: LfKeyResolver, - ): State = State.validation(mediator, transactionUUID, ledgerTime, salts, keyResolver) - - override protected def createView( - view: TransactionViewDecomposition.NewView, - viewPosition: ViewPosition, - state: State, - contractOfId: SerializableContractOfId, - )(implicit - traceContext: TraceContext - ): EitherT[Future, TransactionTreeConversionError, TransactionView] = { - state.signalRollbackScope(view.rbContext.rollbackScope) - - // reset to a fresh state with projected resolver before visiting the subtree - val previousCsmState = state.csmState - val previousResolver = state.currentResolver - state.currentResolver = state.csmState.projectKeyResolver(previousResolver) - state.csmState = initialCsmState - - // Process core nodes and subviews - val coreCreatedBuilder = - List.newBuilder[(LfNodeCreate, RollbackScope)] // contract IDs have already been suffixed - val coreOtherBuilder = // contract IDs have not yet been suffixed - List.newBuilder[((LfNodeId, LfActionNode), RollbackScope)] - val childViewsBuilder = Seq.newBuilder[TransactionView] - val subViewKeyResolutions = - mutable.Map.empty[LfGlobalKey, LfVersioned[SerializableKeyResolution]] - - @SuppressWarnings(Array("org.wartremover.warts.Var")) - var createIndex = 0 - - val nbSubViews = view.allNodes.count { - case _: TransactionViewDecomposition.NewView => true - case _ => false - } - val subviewIndex = TransactionSubviews.indices(nbSubViews).iterator - val createdInView = mutable.Set.empty[LfContractId] - - def fromEither[A <: TransactionTreeConversionError, B]( - either: Either[A, B] - ): EitherT[Future, TransactionTreeConversionError, B] = - EitherT.fromEither(either.leftWiden[TransactionTreeConversionError]) - - for { - // Compute salts - viewCommonDataSalt <- fromEither(state.nextSalt()) - viewParticipantDataSalt <- fromEither(state.nextSalt()) - _ <- MonadUtil.sequentialTraverse_(view.allNodes) { - case childView: TransactionViewDecomposition.NewView => - // Compute subviews, recursively - createView(childView, subviewIndex.next() +: viewPosition, state, contractOfId) - .map { v => - childViewsBuilder += v - val createdInSubview = state.createdContractsInView - createdInView ++= createdInSubview - val keyResolutionsInSubview = v.globalKeyInputs.fmap(_.map(_.asSerializable)) - MapsUtil.extendMapWith(subViewKeyResolutions, keyResolutionsInSubview) { - (accRes, _) => accRes - } - } - - case TransactionViewDecomposition.SameView(lfActionNode, nodeId, rbContext) => - val rbScope = rbContext.rollbackScope - val suffixedNode = lfActionNode match { - case createNode: LfNodeCreate => - val suffixedNode = updateStateWithContractCreation( - nodeId, - createNode, - viewParticipantDataSalt, - viewPosition, - createIndex, - state, - ) - coreCreatedBuilder += (suffixedNode -> rbScope) - createdInView += suffixedNode.coid - createIndex += 1 - suffixedNode - case lfNode: LfActionNode => - val suffixedNode = trySuffixNode(state)(nodeId -> lfNode) - coreOtherBuilder += ((nodeId, lfNode) -> rbScope) - suffixedNode - } - - suffixedNode.keyOpt.foreach { case LfGlobalKeyWithMaintainers(gkey, maintainers) => - state.keyVersionAndMaintainers += (gkey -> (suffixedNode.version -> maintainers)) - } - - state.signalRollbackScope(rbScope) - - EitherT.fromEither[Future]({ - for { - resolutionForModeOff <- suffixedNode match { - case lookupByKey: LfNodeLookupByKey - if state.csmState.mode == ContractKeyUniquenessMode.Off => - val gkey = lookupByKey.key.globalKey - state.currentResolver.get(gkey).toRight(MissingContractKeyLookupError(gkey)) - case _ => Right(KeyInactive) // dummy value, as resolution is not used - } - nextState <- state.csmState - .handleNode((), suffixedNode, resolutionForModeOff) - .leftMap(ContractKeyResolutionError) - } yield { - state.csmState = nextState - } - }) - } - _ = state.signalRollbackScope(view.rbContext.rollbackScope) - - coreCreatedNodes = coreCreatedBuilder.result() - // Translate contract ids in untranslated core nodes - // This must be done only after visiting the whole action (rather than just the node) - // because an Exercise result may contain an unsuffixed contract ID of a contract - // that was created in the consequences of the exercise, i.e., we know the suffix only - // after we have visited the create node. - coreOtherNodes = coreOtherBuilder.result().map { case (nodeInfo, rbc) => - (checked(trySuffixNode(state)(nodeInfo)), rbc) - } - childViews = childViewsBuilder.result() - - suffixedRootNode = coreOtherNodes.headOption - .orElse(coreCreatedNodes.headOption) - .map { case (node, _) => node } - .getOrElse( - throw new IllegalArgumentException(s"The received view has no core nodes. $view") - ) - - // Compute the parameters of the view - seed = view.rootSeed - packagePreference <- EitherT.fromEither[Future](buildPackagePreference(view)) - actionDescription = createActionDescription(suffixedRootNode, seed, packagePreference) - viewCommonData = createViewCommonData(view, viewCommonDataSalt).fold( - ErrorUtil.internalError, - identity, - ) - viewKeyInputs = state.csmState.globalKeyInputs - resolvedK <- EitherT.fromEither[Future]( - resolvedKeys( - viewKeyInputs, - state.keyVersionAndMaintainers, - subViewKeyResolutions, - ) - ) - viewParticipantData <- createViewParticipantData( - coreCreatedNodes, - coreOtherNodes, - childViews, - state.createdContractInfo, - resolvedK, - actionDescription, - viewParticipantDataSalt, - contractOfId, - view.rbContext, - ) - - // fast-forward the former state over the subtree - nextCsmState <- EitherT.fromEither[Future]( - previousCsmState - .advance( - // advance ignores the resolver in mode Strict - if (state.csmState.mode == ContractKeyUniquenessMode.Strict) Map.empty - else previousResolver, - state.csmState, - ) - .leftMap(ContractKeyResolutionError(_): TransactionTreeConversionError) - ) - } yield { - // Compute the result - val subviews = TransactionSubviews(childViews)(protocolVersion, cryptoOps) - val transactionView = - TransactionView.tryCreate(cryptoOps)( - viewCommonData, - viewParticipantData, - subviews, - protocolVersion, - ) - - checkCsmStateMatchesView(state.csmState, transactionView, viewPosition) - - // Update the out parameters in the `State` - state.createdContractsInView = createdInView - state.csmState = nextCsmState - state.currentResolver = previousResolver - - transactionView - } - } - - /** Check that we correctly reconstruct the csm state machine - * Canton does not distinguish between the different com.daml.lf.transaction.Transaction.KeyInactive forms right now - */ - private def checkCsmStateMatchesView( - csmState: ContractStateMachine.State[Unit], - transactionView: TransactionView, - viewPosition: ViewPosition, - )(implicit traceContext: TraceContext): Unit = { - val viewGki = transactionView.globalKeyInputs.fmap(_.unversioned.resolution) - val stateGki = csmState.globalKeyInputs.fmap(_.toKeyMapping) - ErrorUtil.requireState( - viewGki == stateGki, - show"""Failed to reconstruct the global key inputs for the view at position $viewPosition. - | Reconstructed: $viewGki - | Expected: $stateGki""".stripMargin, - ) - val viewLocallyCreated = transactionView.createdContracts.keySet - val stateLocallyCreated = csmState.locallyCreated - ErrorUtil.requireState( - viewLocallyCreated == stateLocallyCreated, - show"Failed to reconstruct created contracts for the view at position $viewPosition.\n Reconstructed: $viewLocallyCreated\n Expected: $stateLocallyCreated", - ) - val viewInputContractIds = transactionView.inputContracts.keySet - val stateInputContractIds = csmState.inputContractIds - ErrorUtil.requireState( - viewInputContractIds == stateInputContractIds, - s"Failed to reconstruct input contracts for the view at position $viewPosition.\n Reconstructed: $viewInputContractIds\n Expected: $stateInputContractIds", - ) - } - - /** The difference between `viewKeyInputs: Map[LfGlobalKey, KeyInput]` and - * `subviewKeyResolutions: Map[LfGlobalKey, SerializableKeyResolution]`, computed as follows: - * - * - * Note: The following argument depends on how this method is used. - * It just sits here because we can then use scaladoc referencing. - * - * All resolved contract IDs in the map difference are core input contracts by the following argument: - * Suppose that the map difference resolves a key `k` to a contract ID `cid`. - * - In mode [[com.daml.lf.transaction.ContractKeyUniquenessMode.Strict]], - * the first node (in execution order) involving the key `k` determines the key's resolution for the view. - * So the first node `n` in execution order involving `k` is an Exercise, Fetch, or positive LookupByKey node. - * - In mode [[com.daml.lf.transaction.ContractKeyUniquenessMode.Off]], - * the first by-key node (in execution order, including Creates) determines the global key input of the view. - * So the first by-key node `n` is an ExerciseByKey, FetchByKey, or positive LookupByKey node. - * In particular, `n` cannot be a Create node because then the resolution for the view - * would be [[com.daml.lf.transaction.ContractStateMachine.KeyInactive]]. - * If this node `n` is in the core of the view, then `cid` is a core input and we are done. - * If this node `n` is in a proper subview, then the aggregated global key inputs - * [[com.digitalasset.canton.data.TransactionView.globalKeyInputs]] - * of the subviews resolve `k` to `cid` (as resolutions from earlier subviews are preferred) - * and therefore the map difference does not resolve `k` at all. - * - * @return `Left(...)` if `viewKeyInputs` contains a key not in the `keyVersionAndMaintainers.keySet` - * @throws java.lang.IllegalArgumentException if `subviewKeyResolutions.keySet` is not a subset of `viewKeyInputs.keySet` - */ - private def resolvedKeys( - viewKeyInputs: Map[LfGlobalKey, KeyInput], - keyVersionAndMaintainers: collection.Map[LfGlobalKey, (LfTransactionVersion, Set[LfPartyId])], - subviewKeyResolutions: collection.Map[LfGlobalKey, LfVersioned[SerializableKeyResolution]], - )(implicit - traceContext: TraceContext - ): Either[TransactionTreeConversionError, Map[LfGlobalKey, LfVersioned[ - SerializableKeyResolution - ]]] = { - ErrorUtil.requireArgument( - subviewKeyResolutions.keySet.subsetOf(viewKeyInputs.keySet), - s"Global key inputs of subview not part of the global key inputs of the parent view. Missing keys: ${subviewKeyResolutions.keySet - .diff(viewKeyInputs.keySet)}", - ) - - def resolutionFor( - key: LfGlobalKey, - keyInput: KeyInput, - ): Either[MissingContractKeyLookupError, LfVersioned[SerializableKeyResolution]] = { - keyVersionAndMaintainers.get(key).toRight(MissingContractKeyLookupError(key)).map { - case (lfVersion, maintainers) => - val resolution = keyInput match { - case KeyActive(cid) => AssignedKey(cid) - case KeyCreate | NegativeKeyLookup => FreeKey(maintainers) - } - LfVersioned(lfVersion, resolution) - } - } - - for { - viewKeyResolutionSeq <- viewKeyInputs.toSeq - .traverse { case (gkey, keyInput) => - resolutionFor(gkey, keyInput).map(gkey -> _) - } - } yield { - MapsUtil.mapDiff(viewKeyResolutionSeq.toMap, subviewKeyResolutions) - } - } -} diff --git a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/validation/ModelConformanceChecker.scala b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/validation/ModelConformanceChecker.scala index cf4b02f35f..8fea90db20 100644 --- a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/validation/ModelConformanceChecker.scala +++ b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/protocol/validation/ModelConformanceChecker.scala @@ -7,7 +7,6 @@ import cats.data.EitherT import cats.implicits.toTraverseOps import cats.syntax.alternative.* import cats.syntax.bifunctor.* -import cats.syntax.functor.* import cats.syntax.parallel.* import com.daml.lf.data.Ref.{Identifier, PackageId, PackageName} import com.daml.nonempty.NonEmpty @@ -36,12 +35,11 @@ import com.digitalasset.canton.participant.protocol.validation.ModelConformanceC } import com.digitalasset.canton.participant.store.{ ContractLookup, - ContractLookupAndVerification, ExtendedContractLookup, StoredContract, } import com.digitalasset.canton.participant.util.DAMLe -import com.digitalasset.canton.participant.util.DAMLe.PackageResolver +import com.digitalasset.canton.participant.util.DAMLe.{HasReinterpret, PackageResolver} import com.digitalasset.canton.protocol.WellFormedTransaction.{ WithSuffixes, WithSuffixesAndMerged, @@ -54,14 +52,7 @@ import com.digitalasset.canton.topology.client.TopologySnapshot import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.FutureInstances.* import com.digitalasset.canton.util.{ErrorUtil, MapsUtil} -import com.digitalasset.canton.{ - LfCommand, - LfCreateCommand, - LfKeyResolver, - LfPartyId, - RequestCounter, - checked, -} +import com.digitalasset.canton.{LfCreateCommand, LfKeyResolver, RequestCounter, checked} import java.util.UUID import scala.concurrent.{ExecutionContext, Future} @@ -69,34 +60,14 @@ import scala.concurrent.{ExecutionContext, Future} /** Allows for checking model conformance of a list of transaction view trees. * If successful, outputs the received transaction as LfVersionedTransaction along with TransactionMetadata. * - * @param reinterpret reinterprets the lf command to a transaction. + * @param reinterpreter reinterprets the lf command to a transaction. * @param transactionTreeFactory reconstructs a transaction view from the reinterpreted action description. */ class ModelConformanceChecker( - val reinterpret: ( - ContractLookupAndVerification, - Set[LfPartyId], - LfCommand, - CantonTimestamp, - CantonTimestamp, - Option[LfHash], - Boolean, - ViewHash, - TraceContext, - Map[PackageName, PackageId], - GetEngineAbortStatus, - ) => EitherT[ - Future, - DAMLeError, - (LfVersionedTransaction, TransactionMetadata, LfKeyResolver), - ], - val validateContract: ( - SerializableContract, - GetEngineAbortStatus, - TraceContext, - ) => EitherT[Future, ContractValidationFailure, Unit], + val reinterpreter: HasReinterpret, + val validateContract: SerializableContractValidation, val transactionTreeFactory: TransactionTreeFactory, - participantId: ParticipantId, + val participantId: ParticipantId, val serializableContractAuthenticator: SerializableContractAuthenticator, val packageResolver: PackageResolver, override protected val loggerFactory: NamedLoggerFactory, @@ -230,7 +201,7 @@ class ModelConformanceChecker( getEngineAbortStatus: GetEngineAbortStatus, )(implicit traceContext: TraceContext - ): EitherT[Future, Error, Map[LfContractId, StoredContract]] = { + ): EitherT[FutureUnlessShutdown, Error, Map[LfContractId, StoredContract]] = { view.tryFlattenToParticipantViews .flatMap(_.viewParticipantData.coreInputs) .parTraverse { case (cid, InputContract(contract, _)) => @@ -244,13 +215,14 @@ class ModelConformanceChecker( .map(_ => cid -> StoredContract(contract, requestCounter, None)) } .map(_.toMap) + .mapK(FutureUnlessShutdown.outcomeK) } private def buildPackageNameMap( packageIds: Set[PackageId] )(implicit traceContext: TraceContext - ): EitherT[Future, Error, Map[PackageName, PackageId]] = { + ): EitherT[FutureUnlessShutdown, Error, Map[PackageName, PackageId]] = { EitherT(for { resolvedE <- packageIds.toSeq.parTraverse(pId => @@ -264,14 +236,15 @@ class ModelConformanceChecker( for { resolved <- resolvedE.separate match { case (Seq(), resolved) => Right(resolved) - case (unresolved, _) => Left(PackageNotFound(Map(participantId -> unresolved.toSet))) + case (unresolved, _) => + Left(PackageNotFound(Map(participantId -> unresolved.toSet)): Error) } resolvedNameBindings = resolved.map({ case (pId, name) => name -> pId }) nameBindings <- MapsUtil.toNonConflictingMap(resolvedNameBindings) leftMap { conflicts => ConflictingNameBindings(Map(participantId -> conflicts)) } } yield nameBindings - }) + }).mapK(FutureUnlessShutdown.outcomeK) } private def checkView( @@ -300,12 +273,7 @@ class ModelConformanceChecker( val rbContext = viewParticipantData.rollbackContext val seed = viewParticipantData.actionDescription.seedOption for { - - viewInputContracts <- validateInputContracts(view, requestCounter, getEngineAbortStatus).mapK( - FutureUnlessShutdown.outcomeK - ) - - _ <- validatePackageVettings(view, topologySnapshot) + viewInputContracts <- validateInputContracts(view, requestCounter, getEngineAbortStatus) contractLookupAndVerification = new ExtendedContractLookup( @@ -316,25 +284,27 @@ class ModelConformanceChecker( serializableContractAuthenticator, ) - packagePreference <- buildPackageNameMap(packageIdPreference).mapK( - FutureUnlessShutdown.outcomeK - ) + packagePreference <- buildPackageNameMap(packageIdPreference) - lfTxAndMetadata <- reinterpret( - contractLookupAndVerification, - authorizers, - cmd, - ledgerTime, - submissionTime, - seed, - failed, - view.viewHash, - traceContext, - packagePreference, - getEngineAbortStatus, - ).leftWiden[Error].mapK(FutureUnlessShutdown.outcomeK) + lfTxAndMetadata <- reinterpreter + .reinterpret( + contractLookupAndVerification, + authorizers, + cmd, + ledgerTime, + submissionTime, + seed, + packagePreference, + failed, + getEngineAbortStatus, + )(traceContext) + .leftMap(DAMLeError(_, view.viewHash)) + .leftWiden[Error] + .mapK(FutureUnlessShutdown.outcomeK) - (lfTx, metadata, resolverFromReinterpretation) = lfTxAndMetadata + (lfTx, metadata, resolverFromReinterpretation, usedPackages) = lfTxAndMetadata + + _ <- checkPackageVetting(view, topologySnapshot, usedPackages) // For transaction views of protocol version 3 or higher, // the `resolverFromReinterpretation` is the same as the `resolverFromView`. @@ -378,17 +348,11 @@ class ModelConformanceChecker( } yield WithRollbackScope(rbContext.rollbackScope, suffixedTx) } - private def validatePackageVettings(view: TransactionView, snapshot: TopologySnapshot)(implicit - traceContext: TraceContext - ): EitherT[FutureUnlessShutdown, Error, Unit] = { - val referencedContracts = - (view.inputContracts.fmap(_.contract) ++ view.createdContracts.fmap(_.contract)).values.toSet - val packageIdsOfContracts = - referencedContracts.map(_.contractInstance.unversioned.template.packageId) - - val packageIdsOfKeys = view.globalKeyInputs.keySet.flatMap(_.packageId) - - val packageIds = packageIdsOfContracts ++ packageIdsOfKeys + private def checkPackageVetting( + view: TransactionView, + snapshot: TopologySnapshot, + packageIds: Set[PackageId], + )(implicit traceContext: TraceContext): EitherT[FutureUnlessShutdown, Error, Unit] = { val informees = view.viewCommonData.tryUnwrap.viewConfirmationParameters.informees @@ -420,48 +384,19 @@ class ModelConformanceChecker( } object ModelConformanceChecker { + def apply( - damle: DAMLe, + damlE: DAMLe, transactionTreeFactory: TransactionTreeFactory, serializableContractAuthenticator: SerializableContractAuthenticator, participantId: ParticipantId, packageResolver: PackageResolver, loggerFactory: NamedLoggerFactory, )(implicit executionContext: ExecutionContext): ModelConformanceChecker = { - def reinterpret( - contracts: ContractLookupAndVerification, - submitters: Set[LfPartyId], - command: LfCommand, - ledgerTime: CantonTimestamp, - submissionTime: CantonTimestamp, - rootSeed: Option[LfHash], - expectFailure: Boolean, - viewHash: ViewHash, - traceContext: TraceContext, - packageResolution: Map[PackageName, PackageId], - getEngineAbortStatus: GetEngineAbortStatus, - ): EitherT[ - Future, - DAMLeError, - (LfVersionedTransaction, TransactionMetadata, LfKeyResolver), - ] = - damle - .reinterpret( - contracts, - submitters, - command, - ledgerTime, - submissionTime, - rootSeed, - expectFailure, - packageResolution, - getEngineAbortStatus, - )(traceContext) - .leftMap(DAMLeError(_, viewHash)) new ModelConformanceChecker( - reinterpret, - validateSerializedContract(damle), + damlE, + validateSerializedContract(damlE), transactionTreeFactory, participantId, serializableContractAuthenticator, @@ -478,7 +413,14 @@ object ModelConformanceChecker { expected: LfNodeCreate, ) extends ContractValidationFailure - private def validateSerializedContract(damle: DAMLe)( + private type SerializableContractValidation = + ( + SerializableContract, + GetEngineAbortStatus, + TraceContext, + ) => EitherT[Future, ContractValidationFailure, Unit] + + private def validateSerializedContract(damlE: DAMLe)( contract: SerializableContract, getEngineAbortStatus: GetEngineAbortStatus, traceContext: TraceContext, @@ -489,7 +431,7 @@ object ModelConformanceChecker { val metadata = contract.metadata for { - actual <- damle + actual <- damlE .replayCreate( metadata.signatories, LfCreateCommand(unversioned.template, unversioned.arg), @@ -552,7 +494,7 @@ object ModelConformanceChecker { } } - /** Indicates that [[ModelConformanceChecker.reinterpret]] has failed. */ + /** Indicates that [[ModelConformanceChecker.reinterpreter]] has failed. */ final case class DAMLeError(cause: DAMLe.ReinterpretationError, viewHash: ViewHash) extends Error { override def pretty: Pretty[DAMLeError] = prettyOfClass( diff --git a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/AcsCommitmentProcessor.scala b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/AcsCommitmentProcessor.scala index 79292ec0ee..5e3536efca 100644 --- a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/AcsCommitmentProcessor.scala +++ b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/AcsCommitmentProcessor.scala @@ -12,6 +12,7 @@ import cats.syntax.traverse.* import cats.syntax.validated.* import com.daml.error.* import com.daml.nameof.NameOf.functionFullName +import com.digitalasset.canton.admin.participant.v30.{ReceivedCommitmentState, SentCommitmentState} import com.digitalasset.canton.admin.pruning import com.digitalasset.canton.concurrent.{FutureSupervisor, Threading} import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt, PositiveNumeric} @@ -2268,4 +2269,83 @@ object AcsCommitmentProcessor extends HasLoggerName { } } + sealed trait ReceivedCmtState { + def toProtoV30: ReceivedCommitmentState + } + + object ReceivedCmtState { + object Match extends ReceivedCmtState { + override val toProtoV30: ReceivedCommitmentState = + ReceivedCommitmentState.RECEIVED_COMMITMENT_STATE_MATCH + } + + object Mismatch extends ReceivedCmtState { + override val toProtoV30: ReceivedCommitmentState = + ReceivedCommitmentState.RECEIVED_COMMITMENT_STATE_MISMATCH + } + + object Buffered extends ReceivedCmtState { + override val toProtoV30: ReceivedCommitmentState = + ReceivedCommitmentState.RECEIVED_COMMITMENT_STATE_BUFFERED + } + + object Outstanding extends ReceivedCmtState { + override val toProtoV30: ReceivedCommitmentState = + ReceivedCommitmentState.RECEIVED_COMMITMENT_STATE_OUTSTANDING + } + + def fromProtoV30( + proto: ReceivedCommitmentState + ): ParsingResult[ReceivedCmtState] = + proto match { + case ReceivedCommitmentState.RECEIVED_COMMITMENT_STATE_MATCH => Right(Match) + case ReceivedCommitmentState.RECEIVED_COMMITMENT_STATE_MISMATCH => Right(Mismatch) + case ReceivedCommitmentState.RECEIVED_COMMITMENT_STATE_BUFFERED => Right(Buffered) + case ReceivedCommitmentState.RECEIVED_COMMITMENT_STATE_OUTSTANDING => Right(Outstanding) + case _ => + Left( + ProtoDeserializationError.ValueConversionError( + "received commitment state", + s"Unknown value: $proto", + ) + ) + } + } + + sealed trait SentCmtState { + def toProtoV30: SentCommitmentState + } + + object SentCmtState { + object Match extends SentCmtState { + override val toProtoV30: SentCommitmentState = + SentCommitmentState.SENT_COMMITMENT_STATE_MATCH + } + + object Mismatch extends SentCmtState { + override val toProtoV30: SentCommitmentState = + SentCommitmentState.SENT_COMMITMENT_STATE_MISMATCH + } + + object NotCompared extends SentCmtState { + override val toProtoV30: SentCommitmentState = + SentCommitmentState.SENT_COMMITMENT_STATE_NOT_COMPARED + } + + def fromProtoV30( + proto: SentCommitmentState + ): ParsingResult[SentCmtState] = + proto match { + case SentCommitmentState.SENT_COMMITMENT_STATE_MATCH => Right(Match) + case SentCommitmentState.SENT_COMMITMENT_STATE_MISMATCH => Right(Mismatch) + case SentCommitmentState.SENT_COMMITMENT_STATE_NOT_COMPARED => Right(NotCompared) + case _ => + Left( + ProtoDeserializationError.ValueConversionError( + "sent commitment state", + s"Unknown value: $proto", + ) + ) + } + } } diff --git a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/util/DAMLe.scala b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/util/DAMLe.scala index 05e3429972..072a711037 100644 --- a/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/util/DAMLe.scala +++ b/sdk/canton/community/participant/src/main/scala/com/digitalasset/canton/participant/util/DAMLe.scala @@ -6,7 +6,7 @@ package com.digitalasset.canton.participant.util import cats.data.EitherT import cats.syntax.either.* import com.daml.lf.VersionRange -import com.daml.lf.data.Ref.PackageId +import com.daml.lf.data.Ref.{PackageId, PackageName} import com.daml.lf.data.{ImmArray, Ref, Time} import com.daml.lf.engine.* import com.daml.lf.interpretation.Error as LfInterpretationError @@ -19,7 +19,11 @@ import com.digitalasset.canton.logging.{LoggingContextUtil, NamedLoggerFactory, import com.digitalasset.canton.participant.admin.PackageService import com.digitalasset.canton.participant.protocol.EngineController.GetEngineAbortStatus import com.digitalasset.canton.participant.store.ContractLookupAndVerification -import com.digitalasset.canton.participant.util.DAMLe.{ContractWithMetadata, PackageResolver} +import com.digitalasset.canton.participant.util.DAMLe.{ + ContractWithMetadata, + HasReinterpret, + PackageResolver, +} import com.digitalasset.canton.platform.apiserver.configuration.EngineLoggingConfig import com.digitalasset.canton.platform.apiserver.execution.AuthorityResolver import com.digitalasset.canton.protocol.SerializableContract.LedgerCreateTime @@ -103,6 +107,25 @@ object DAMLe { packageService: PackageService ): PackageId => TraceContext => Future[Option[Package]] = pkgId => traceContext => packageService.getPackage(pkgId)(traceContext) + + trait HasReinterpret { + def reinterpret( + contracts: ContractLookupAndVerification, + submitters: Set[LfPartyId], + command: LfCommand, + ledgerTime: CantonTimestamp, + submissionTime: CantonTimestamp, + rootSeed: Option[LfHash], + packageResolution: Map[Ref.PackageName, Ref.PackageId] = Map.empty, + expectFailure: Boolean, + getEngineAbortStatus: GetEngineAbortStatus, + )(implicit traceContext: TraceContext): EitherT[ + Future, + ReinterpretationError, + (LfVersionedTransaction, TransactionMetadata, LfKeyResolver, Set[PackageId]), + ] + } + } /** Represents a Daml runtime instance for interpreting commands. Provides an abstraction for the Daml engine @@ -120,27 +143,27 @@ class DAMLe( engineLoggingConfig: EngineLoggingConfig, protected val loggerFactory: NamedLoggerFactory, )(implicit ec: ExecutionContext) - extends NamedLogging { + extends NamedLogging + with HasReinterpret { + import DAMLe.{ReinterpretationError, EngineError, EngineAborted} logger.debug(engine.info.show)(TraceContext.empty) - def reinterpret( + override def reinterpret( contracts: ContractLookupAndVerification, submitters: Set[LfPartyId], command: LfCommand, ledgerTime: CantonTimestamp, submissionTime: CantonTimestamp, rootSeed: Option[LfHash], + packageResolution: Map[PackageName, PackageId], expectFailure: Boolean, - packageResolution: Map[Ref.PackageName, Ref.PackageId] = Map.empty, getEngineAbortStatus: GetEngineAbortStatus, - )(implicit - traceContext: TraceContext - ): EitherT[ + )(implicit traceContext: TraceContext): EitherT[ Future, ReinterpretationError, - (LfVersionedTransaction, TransactionMetadata, LfKeyResolver), + (LfVersionedTransaction, TransactionMetadata, LfKeyResolver, Set[PackageId]), ] = { def peelAwayRootLevelRollbackNode( @@ -210,6 +233,7 @@ class DAMLe( txNoRootRollback, TransactionMetadata.fromLf(ledgerTime, metadata), metadata.globalKeyMapping, + metadata.usedPackages, ) } @@ -273,7 +297,7 @@ class DAMLe( expectFailure = false, getEngineAbortStatus = getEngineAbortStatus, ) - (transaction, _, _) = transactionWithMetadata + (transaction, _, _, _) = transactionWithMetadata md = transaction.nodes(transaction.roots(0)) match { case nc: LfNodeCreate => ContractWithMetadata( @@ -404,4 +428,5 @@ class DAMLe( handleResultInternal(contracts, result) } + } diff --git a/sdk/canton/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExtractUsedAndCreatedTest.scala b/sdk/canton/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExtractUsedAndCreatedTest.scala index ed58ceccb9..c58b2072f1 100644 --- a/sdk/canton/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExtractUsedAndCreatedTest.scala +++ b/sdk/canton/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ExtractUsedAndCreatedTest.scala @@ -47,7 +47,7 @@ class ExtractUsedAndCreatedTest extends BaseTestWordSpec with HasExecutionContex val tree = etf.rootTransactionViewTree(singleCreate.view0) val transactionViewTrees = NonEmpty(Seq, (tree, Option.empty[Signature])) - val transactionViews = transactionViewTrees.map { case (viewTree, _signature) => viewTree.view } + val transactionViews = transactionViewTrees.map { case (viewTree, _) => viewTree.view } val actual = underTest.usedAndCreated(transactionViews) @@ -172,6 +172,7 @@ class ExtractUsedAndCreatedTest extends BaseTestWordSpec with HasExecutionContex resolvedKeys = Map.empty, seed = singleCreate.nodeSeed, isRoot = true, + packagePreference = Set.empty, ) val viewData = ViewData( diff --git a/sdk/canton/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ModelConformanceCheckerTest.scala b/sdk/canton/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ModelConformanceCheckerTest.scala index 4619f093ff..d9d71c964d 100644 --- a/sdk/canton/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ModelConformanceCheckerTest.scala +++ b/sdk/canton/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/validation/ModelConformanceCheckerTest.scala @@ -7,16 +7,11 @@ import cats.data.EitherT import cats.syntax.parallel.* import com.daml.lf.data.ImmArray import com.daml.lf.data.Ref.{PackageId, PackageName} -import com.daml.lf.engine +import com.daml.lf.engine.Error as LfError import com.daml.lf.language.Ast.{Expr, GenPackage, PackageMetadata} import com.daml.lf.language.LanguageVersion import com.daml.nonempty.{NonEmpty, NonEmptyUtil} -import com.digitalasset.canton.data.{ - CantonTimestamp, - FreeKey, - FullTransactionViewTree, - TransactionView, -} +import com.digitalasset.canton.data.* import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.pretty.Pretty import com.digitalasset.canton.participant.protocol.EngineController.{ @@ -29,14 +24,13 @@ import com.digitalasset.canton.participant.protocol.{ SerializableContractAuthenticator, TransactionProcessingSteps, } -import com.digitalasset.canton.participant.store.ContractLookup -import com.digitalasset.canton.participant.util.DAMLe.{EngineError, PackageResolver} -import com.digitalasset.canton.protocol.ExampleTransactionFactory.{lfHash, submittingParticipant} +import com.digitalasset.canton.participant.store.ContractLookupAndVerification +import com.digitalasset.canton.participant.util.DAMLe +import com.digitalasset.canton.participant.util.DAMLe.{EngineError, HasReinterpret, PackageResolver} +import com.digitalasset.canton.protocol.ExampleTransactionFactory.* import com.digitalasset.canton.protocol.* import com.digitalasset.canton.topology.client.TopologySnapshot import com.digitalasset.canton.topology.store.PackageDependencyResolverUS -import com.digitalasset.canton.topology.transaction.VettedPackages -import com.digitalasset.canton.topology.{TestingIdentityFactory, TestingTopology} import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.FutureInstances.* import com.digitalasset.canton.{ @@ -48,11 +42,11 @@ import com.digitalasset.canton.{ LfPartyId, RequestCounter, } -import org.scalatest.Assertion import org.scalatest.wordspec.AsyncWordSpec import pprint.Tree import java.time.Duration +import scala.annotation.unused import scala.concurrent.{ExecutionContext, Future} class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { @@ -66,60 +60,66 @@ class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { val ledgerTimeRecordTimeTolerance: Duration = Duration.ofSeconds(10) def validateContractOk( - contract: SerializableContract, - getEngineAbortStatus: GetEngineAbortStatus, - context: TraceContext, + @unused _contract: SerializableContract, + @unused _getEngineAbortStatus: GetEngineAbortStatus, + @unused _context: TraceContext, ): EitherT[Future, ContractValidationFailure, Unit] = EitherT.pure(()) - def reinterpret(example: ExampleTransaction)( - _contracts: ContractLookup, - _submitters: Set[LfPartyId], - cmd: LfCommand, - ledgerTime: CantonTimestamp, - submissionTime: CantonTimestamp, - rootSeed: Option[LfHash], - _inRollback: Boolean, - _viewHash: ViewHash, - _traceContext: TraceContext, - _packageResolution: Map[PackageName, PackageId], - _getEngineAbortStatus: GetEngineAbortStatus, - ): EitherT[ - Future, - DAMLeError, - (LfVersionedTransaction, TransactionMetadata, LfKeyResolver), - ] = { + def reinterpretExample( + example: ExampleTransaction, + usedPackages: Set[PackageId] = Set.empty, + ): HasReinterpret = new HasReinterpret { - ledgerTime shouldEqual factory.ledgerTime - submissionTime shouldEqual factory.submissionTime + override def reinterpret( + contracts: ContractLookupAndVerification, + submitters: Set[LfPartyId], + command: LfCommand, + ledgerTime: CantonTimestamp, + submissionTime: CantonTimestamp, + rootSeed: Option[LfHash], + packageResolution: Map[PackageName, PackageId], + expectFailure: Boolean, + getEngineAbortStatus: GetEngineAbortStatus, + )(implicit traceContext: TraceContext): EitherT[ + Future, + DAMLe.ReinterpretationError, + (LfVersionedTransaction, TransactionMetadata, LfKeyResolver, Set[PackageId]), + ] = { + ledgerTime shouldEqual factory.ledgerTime + submissionTime shouldEqual factory.submissionTime - val (_viewTree, (reinterpretedTx, metadata, keyResolver), _witnesses) = - example.reinterpretedSubtransactions.find { case (viewTree, (tx, md, keyResolver), _) => - viewTree.viewParticipantData.rootAction.command == cmd && - // Commands are otherwise not sufficiently unique (whereas with nodes, we can produce unique nodes). - rootSeed == md.seeds.get(tx.roots(0)) - }.value + val (_, (reinterpretedTx, metadata, keyResolver), _) = { + // The code below assumes that for reinterpretedSubtransactions the combination + // of command and root-seed wil be unique. In the examples used to date this is + // the case. A limitation of this approach is that only one LookupByKey transaction + // can be returned as the root seed is unset in the ReplayCommand. + example.reinterpretedSubtransactions.find { case (viewTree, (tx, md, _), _) => + viewTree.viewParticipantData.rootAction.command == command && + md.seeds.get(tx.roots(0)) == rootSeed + }.value + } - EitherT.rightT[Future, DAMLeError]((reinterpretedTx, metadata, keyResolver)) + EitherT.rightT((reinterpretedTx, metadata, keyResolver, usedPackages)) + } } - def failOnReinterpret( - _contracts: ContractLookup, - _submitters: Set[LfPartyId], - cmd: LfCommand, - ledgerTime: CantonTimestamp, - submissionTime: CantonTimestamp, - rootSeed: Option[LfHash], - _inRollback: Boolean, - _viewHash: ViewHash, - _traceContext: TraceContext, - _packageResolution: Map[PackageName, PackageId], - _getEngineAbortStatus: GetEngineAbortStatus, - ): EitherT[ - Future, - DAMLeError, - (LfVersionedTransaction, TransactionMetadata, LfKeyResolver), - ] = - fail("Reinterpret should not be called by this test case.") + val failOnReinterpret: HasReinterpret = new HasReinterpret { + override def reinterpret( + contracts: ContractLookupAndVerification, + submitters: Set[LfPartyId], + command: LfCommand, + ledgerTime: CantonTimestamp, + submissionTime: CantonTimestamp, + rootSeed: Option[LfHash], + packageResolution: Map[PackageName, PackageId], + expectFailure: Boolean, + getEngineAbortStatus: GetEngineAbortStatus, + )(implicit traceContext: TraceContext): EitherT[ + Future, + DAMLe.ReinterpretationError, + (LfVersionedTransaction, TransactionMetadata, LfKeyResolver, Set[PackageId]), + ] = fail("Reinterpret should not be called by this test case.") + } def viewsWithNoInputKeys( rootViews: Seq[FullTransactionViewTree] @@ -127,7 +127,7 @@ class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { NonEmptyUtil.fromUnsafe(rootViews.map { viewTree => // Include resolvers for all the subviews val resolvers = - viewTree.view.allSubviewsWithPosition(viewTree.viewPosition).map { case (view, _viewPos) => + viewTree.view.allSubviewsWithPosition(viewTree.viewPosition).map { case (view, _) => view -> (Map.empty: LfKeyResolver) } (viewTree, resolvers) @@ -158,7 +158,7 @@ class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { ): EitherT[Future, ErrorWithSubTransaction, Result] = { val rootViewTrees = views.map(_._1) val commonData = TransactionProcessingSteps.tryCommonData(rootViewTrees) - val keyResolvers = views.forgetNE.flatMap { case (_vt, resolvers) => resolvers }.toMap + val keyResolvers = views.forgetNE.flatMap { case (_, resolvers) => resolvers }.toMap mcc .check( rootViewTrees, @@ -176,8 +176,18 @@ class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { val packageMetadata: PackageMetadata = PackageMetadata(packageName, packageVersion, None) val genPackage: GenPackage[Expr] = GenPackage(Map.empty, Set.empty, LanguageVersion.default, packageMetadata) - val packageResolver: PackageResolver = pkgId => - traceContext => Future.successful(Some(genPackage)) + val packageResolver: PackageResolver = _ => _ => Future.successful(Some(genPackage)) + + def buildUnderTest(reinterpretCommand: HasReinterpret): ModelConformanceChecker = + new ModelConformanceChecker( + reinterpretCommand, + validateContractOk, + transactionTreeFactory, + submittingParticipant, + dummyAuthenticator, + packageResolver, + loggerFactory, + ) "A model conformance checker" when { val relevantExamples = factory.standardHappyCases.filter { @@ -189,16 +199,7 @@ class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { forEvery(relevantExamples) { example => s"checking $example" must { - val sut = - new ModelConformanceChecker( - reinterpret(example), - validateContractOk, - transactionTreeFactory, - submittingParticipant, - dummyAuthenticator, - packageResolver, - loggerFactory, - ) + val sut = buildUnderTest(reinterpretExample(example)) "yield the correct result" in { for { @@ -240,15 +241,7 @@ class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { } "transaction id is inconsistent" must { - val sut = new ModelConformanceChecker( - failOnReinterpret, - validateContractOk, - transactionTreeFactory, - submittingParticipant, - dummyAuthenticator, - packageResolver, - loggerFactory, - ) + val sut = buildUnderTest(failOnReinterpret) val singleCreate = factory.SingleCreate(seed = ExampleTransactionFactory.lfHash(0)) val viewTreesWithInconsistentTransactionIds = Seq( @@ -272,59 +265,53 @@ class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { override def treeOf(t: ViewHash): Tree = Apply("[ViewHash]", Seq.empty.iterator) }) - val error = DAMLeError(EngineError(mock[engine.Error]), mockViewHash) + val lfError = mock[LfError] + val error = EngineError(lfError) + + val sut = buildUnderTest(new HasReinterpret { + override def reinterpret( + contracts: ContractLookupAndVerification, + submitters: Set[LfPartyId], + command: LfCommand, + ledgerTime: CantonTimestamp, + submissionTime: CantonTimestamp, + rootSeed: Option[LfHash], + packageResolution: Map[PackageName, PackageId], + expectFailure: Boolean, + getEngineAbortStatus: GetEngineAbortStatus, + )(implicit traceContext: TraceContext): EitherT[ + Future, + DAMLe.ReinterpretationError, + (LfVersionedTransaction, TransactionMetadata, LfKeyResolver, Set[PackageId]), + ] = EitherT.leftT(error) + }) - val sut = new ModelConformanceChecker( - (_, _, _, _, _, _, _, _, _, _, _) => - EitherT.leftT[Future, (LfVersionedTransaction, TransactionMetadata, LfKeyResolver)]( - error - ), - validateContractOk, - transactionTreeFactory, - submittingParticipant, - dummyAuthenticator, - packageResolver, - loggerFactory, - ) val example = factory.MultipleRootsAndViewNestings - def countLeaves(views: NonEmpty[Seq[TransactionView]]): Int = - views.foldLeft(0)((count, view) => { - NonEmpty.from(view.subviews.unblindedElements) match { - case Some(subviewsNE) => count + countLeaves(subviewsNE) - case None => count + 1 - } - }) - val nbLeafViews = countLeaves(NonEmptyUtil.fromUnsafe(example.rootViews)) - "yield an error" in { + val exampleTree = example.transactionViewTree0 for { failure <- leftOrFail( check( sut, - viewsWithNoInputKeys(example.rootTransactionViewTrees), + viewsWithNoInputKeys(Seq(exampleTree)), ) )("reinterpretation fails") - } yield failure.errors shouldBe Seq.fill(nbLeafViews)(error) // One error per leaf + } yield { + failure.errors.forgetNE shouldBe Seq( + DAMLeError(EngineError(lfError), exampleTree.viewHash) + ) + } } } - "contract upgrading is enabled" should { + "checking an upgraded contract" should { val example: factory.UpgradedSingleExercise = factory.UpgradedSingleExercise(lfHash(0)) - "the choice package may differ from the contract package" in { + "allow the choice package to differ from the contract package" in { - val sut = - new ModelConformanceChecker( - reinterpret(example), - validateContractOk, - transactionTreeFactory, - submittingParticipant, - dummyAuthenticator, - packageResolver, - loggerFactory, - ) + val sut = buildUnderTest(reinterpretExample(example)) valueOrFail( check(sut, viewsWithNoInputKeys(example.rootTransactionViewTrees)) @@ -347,18 +334,27 @@ class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { signatories = Set(submitter, extra), ), ) - val sut = new ModelConformanceChecker( - (_, _, _, _, _, _, _, _, _, _, _) => - EitherT.pure[Future, DAMLeError]( - (reinterpreted, subviewMissing.metadata, subviewMissing.keyResolver) - ), - validateContractOk, - transactionTreeFactory, - submittingParticipant, - dummyAuthenticator, - packageResolver, - loggerFactory, - ) + + val sut = buildUnderTest(new HasReinterpret { + override def reinterpret( + contracts: ContractLookupAndVerification, + submitters: Set[LfPartyId], + command: LfCommand, + ledgerTime: CantonTimestamp, + submissionTime: CantonTimestamp, + rootSeed: Option[LfHash], + packageResolution: Map[PackageName, PackageId], + expectFailure: Boolean, + getEngineAbortStatus: GetEngineAbortStatus, + )(implicit traceContext: TraceContext): EitherT[ + Future, + DAMLe.ReinterpretationError, + (LfVersionedTransaction, TransactionMetadata, LfKeyResolver, Set[PackageId]), + ] = EitherT.pure( + (reinterpreted, subviewMissing.metadata, subviewMissing.keyResolver, Set.empty) + ) + }) + for { result <- leftOrFail( check(sut, viewsWithNoInputKeys(subviewMissing.rootTransactionViewTrees)) @@ -379,101 +375,82 @@ class ModelConformanceCheckerTest extends AsyncWordSpec with BaseTest { */ } - "a package (referenced by create) is not vetted by some participant" must { - "yield an error" in { - import ExampleTransactionFactory.* - testVettingError( - NonEmpty.from(factory.SingleCreate(lfHash(0)).rootTransactionViewTrees).value, - // The package is not vetted for signatoryParticipant - vettings = Seq(VettedPackages(submittingParticipant, None, Seq(packageId))), - packageDependenciesLookup = new TestPackageResolver(Right(Set.empty)), - expectedError = UnvettedPackages(Map(signatoryParticipant -> Set(packageId))), + "reinterpretation used package vetting" must { + + "fail conformance if an un-vetted package is used" in { + val unexpectedPackageId = PackageId.assertFromString("unexpected-pkg") + val example = factory.SingleCreate(seed = factory.deriveNodeSeed(0)) + val sut = + buildUnderTest(reinterpretExample(example, usedPackages = Set(unexpectedPackageId))) + val expected = Left( + ErrorWithSubTransaction( + NonEmpty( + Seq, + UnvettedPackages( + Map( + submittingParticipant -> Set(unexpectedPackageId), + observerParticipant -> Set(unexpectedPackageId), + ) + ), + ), + None, + Seq.empty, + ) ) + for { + actual <- check(sut, viewsWithNoInputKeys(example.rootTransactionViewTrees)).value + } yield { + actual shouldBe expected + } } } - "a package (referenced by key lookup) is not vetted by some participant" must { - "yield an error" in { - import ExampleTransactionFactory.* - - val key = defaultGlobalKey - val maintainers = Set(submitter) - val view = factory.view( - lookupByKeyNode(key, Set(submitter), None), - 0, - Set.empty, - Seq.empty, - Seq.empty, - Map(key -> FreeKey(maintainers)), - None, - isRoot = true, - ) - val viewTree = factory.rootTransactionViewTree(view) - - testVettingError( - NonEmpty(Seq, viewTree), - // The package is not vetted for submittingParticipant - vettings = Seq.empty, - packageDependenciesLookup = new TestPackageResolver(Right(Set.empty)), - expectedError = UnvettedPackages(Map(submittingParticipant -> Set(key.packageId.value))), - ) - } - } - - def testVettingError( - rootViewTrees: NonEmpty[Seq[FullTransactionViewTree]], - vettings: Seq[VettedPackages], - packageDependenciesLookup: PackageDependencyResolverUS, - expectedError: UnvettedPackages, - ): Future[Assertion] = { - import ExampleTransactionFactory.* - - val sut = new ModelConformanceChecker( - reinterpret = failOnReinterpret, - validateContract = validateContractOk, - transactionTreeFactory = transactionTreeFactory, - participantId = submittingParticipant, - serializableContractAuthenticator = dummyAuthenticator, - packageResolver = packageResolver, - loggerFactory, - ) - - val snapshot = TestingIdentityFactory( - TestingTopology( - ).withTopology(Map(submitter -> submittingParticipant, observer -> signatoryParticipant)) - .withPackages(vettings.map(vetting => vetting.participantId -> vetting.packageIds).toMap), - loggerFactory, - TestDomainParameters.defaultDynamic, - ).topologySnapshot(packageDependencyResolver = packageDependenciesLookup) - - for { - error <- check(sut, viewsWithNoInputKeys(rootViewTrees), snapshot).value - } yield error shouldBe Left( - ErrorWithSubTransaction( - NonEmpty(Seq, expectedError), - None, - Seq.empty, - ) - ) - } - "a package is not found in the package store" must { - "yield an error" in { - import ExampleTransactionFactory.* - testVettingError( - NonEmpty.from(factory.SingleCreate(lfHash(0)).rootTransactionViewTrees).value, - vettings = Seq( - VettedPackages(submittingParticipant, None, Seq(packageId)), - VettedPackages(signatoryParticipant, None, Seq(packageId)), - ), - // Submitter participant is unable to lookup dependencies. - // Therefore, the validation concludes that the package is not in the store - // and thus that the package is not vetted. - packageDependenciesLookup = new TestPackageResolver(Left(packageId)), - expectedError = UnvettedPackages(Map(submittingParticipant -> Set(packageId))), + + "fail with an engine error" in { + + val missingPackageId = PackageId.assertFromString("missing-pkg") + val example = factory.SingleCreate(seed = factory.deriveNodeSeed(0)) + val engineError = + EngineError(new LfError.Package(LfError.Package.MissingPackage(missingPackageId))) + val sut = + buildUnderTest(new HasReinterpret { + override def reinterpret( + contracts: ContractLookupAndVerification, + submitters: Set[LfPartyId], + command: LfCommand, + ledgerTime: CantonTimestamp, + submissionTime: CantonTimestamp, + rootSeed: Option[LfHash], + packageResolution: Map[PackageName, PackageId], + expectFailure: Boolean, + getEngineAbortStatus: GetEngineAbortStatus, + )(implicit traceContext: TraceContext): EitherT[ + Future, + DAMLe.ReinterpretationError, + (LfVersionedTransaction, TransactionMetadata, LfKeyResolver, Set[PackageId]), + ] = EitherT.fromEither(Left(engineError)) + }) + val viewHash = example.transactionViewTrees.head.viewHash + val expected = Left( + ErrorWithSubTransaction( + NonEmpty( + Seq, + DAMLeError(engineError, viewHash), + ), + None, + Seq.empty, + ) ) + for { + actual <- check(sut, viewsWithNoInputKeys(example.rootTransactionViewTrees)).value + } yield { + actual shouldBe expected + } } + } + } class TestPackageResolver(result: Either[PackageId, Set[PackageId]]) diff --git a/sdk/canton/ref b/sdk/canton/ref index 62ccb8f93a..eedc551ebe 100644 --- a/sdk/canton/ref +++ b/sdk/canton/ref @@ -1 +1 @@ -20240617.13482.vc830bc97 +20240618.13495.v4816e0c2 diff --git a/sdk/daml-script/runner/BUILD.bazel b/sdk/daml-script/runner/BUILD.bazel index cb76e0437f..87bfcb8ac3 100644 --- a/sdk/daml-script/runner/BUILD.bazel +++ b/sdk/daml-script/runner/BUILD.bazel @@ -34,6 +34,7 @@ da_scala_library( if copt not in [ # scalapb does not like those "-P:wartremover:traverser:org.wartremover.warts.JavaSerializable", + "-P:wartremover:traverser:org.wartremover.warts.NonUnitStatements", "-P:wartremover:traverser:org.wartremover.warts.Product", "-P:wartremover:traverser:org.wartremover.warts.Serializable", # ProtocolVersionAnnotation.scala violates this warning