Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .proto_snapshot_image.bin.gz
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,18 @@ message ActionDescription {
optional string interface_id = 9;
string template_id = 10;
repeated string package_preference = 11;
repeated ExternalCallResult external_call_results = 12;
}

// Result of an external call recorded under an exercise node.
// Stores the call identity, config/input bytes, and output bytes for
// deterministic replay and validation.
message ExternalCallResult {
string extension_id = 1; // Extension service identifier
string function_id = 2; // Function identifier within the extension
bytes config = 3; // Extension configuration
bytes input = 4; // Input data sent to the extension
bytes output = 5; // Output data returned by the extension
}

message FetchActionDescription {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult
import com.digitalasset.canton.util.NoCopy
import com.digitalasset.canton.version.ProtocolVersion
import com.digitalasset.canton.{LfChoiceName, LfInterfaceId, LfPackageId, LfPartyId, LfVersioned}
import com.digitalasset.daml.lf.data.{Bytes, ImmArray}
import com.digitalasset.daml.lf.transaction.ExternalCallResult
import com.digitalasset.daml.lf.value.{Value, ValueCoder, ValueOuterClass}
import com.google.common.annotations.VisibleForTesting
import com.google.protobuf.ByteString
Expand Down Expand Up @@ -122,9 +124,7 @@ object ActionDescription {
exerciseResult,
_key,
byKey,
// TODO(https://github.com/digital-asset/canton/issues/513)
// handle external calls
_externalCallResults,
externalCallResults,
version,
) =>
for {
Expand All @@ -140,6 +140,7 @@ object ActionDescription {
byKey,
seed,
failed = exerciseResult.isEmpty, // absence of exercise result indicates failure
externalCallResults,
)
} yield actionDescription

Expand Down Expand Up @@ -202,6 +203,7 @@ object ActionDescription {
interfaceIdP,
templateIdP,
packagePreferenceP,
externalCallResultsP,
) = e
for {
inputContractId <- ProtoConverter.parseLfContractId(inputContractIdP)
Expand All @@ -217,6 +219,15 @@ object ActionDescription {
.leftMap(err => ValueDeserializationError("chosen_value", err.errorMessage))
actors <- actorsP.traverse(ProtoConverter.parseLfPartyId(_, field = "actors")).map(_.toSet)
seed <- LfHash.fromProtoPrimitive("node_seed", seedP)
externalCallResults = ImmArray.from(externalCallResultsP.map { r =>
ExternalCallResult(
extensionId = r.extensionId,
functionId = r.functionId,
config = Bytes.fromByteString(r.config),
input = Bytes.fromByteString(r.input),
output = Bytes.fromByteString(r.output),
)
})
actionDescription <- ExerciseActionDescription
.create(
inputContractId,
Expand All @@ -229,6 +240,7 @@ object ActionDescription {
byKey,
seed,
failed,
externalCallResults,
)
.leftMap(err => OtherError(err.message))
} yield actionDescription
Expand Down Expand Up @@ -342,6 +354,7 @@ object ActionDescription {
override val byKey: Boolean,
seed: LfHash,
failed: Boolean,
externalCallResults: ImmArray[ExternalCallResult],
) extends ActionDescription {

private val serializedChosenValue: ByteString = serializeChosenValue(chosenValue)
Expand All @@ -367,6 +380,15 @@ object ActionDescription {
byKey = byKey,
nodeSeed = seed.toProtoPrimitive,
failed = failed,
externalCallResults = externalCallResults.toSeq.map { r =>
v30.ActionDescription.ExternalCallResult(
extensionId = r.extensionId,
functionId = r.functionId,
config = r.config.toByteString,
input = r.input.toByteString,
output = r.output.toByteString,
)
},
)

override protected def pretty: Pretty[ExerciseActionDescription] = prettyOfClass(
Expand All @@ -393,6 +415,7 @@ object ActionDescription {
byKey: Boolean = this.byKey,
seed: LfHash = this.seed,
failed: Boolean = this.failed,
externalCallResults: ImmArray[ExternalCallResult] = this.externalCallResults,
): ExerciseActionDescription =
ExerciseActionDescription(
inputContractId,
Expand All @@ -405,6 +428,7 @@ object ActionDescription {
byKey,
seed,
failed,
externalCallResults,
)

}
Expand All @@ -421,6 +445,7 @@ object ActionDescription {
byKey: Boolean,
seed: LfHash,
failed: Boolean,
externalCallResults: ImmArray[ExternalCallResult] = ImmArray.Empty,
): ExerciseActionDescription = create(
inputContractId,
templateId,
Expand All @@ -432,6 +457,7 @@ object ActionDescription {
byKey,
seed,
failed,
externalCallResults,
).fold(err => throw err, identity)

def create(
Expand All @@ -445,6 +471,7 @@ object ActionDescription {
byKey: Boolean,
seed: LfHash,
failed: Boolean,
externalCallResults: ImmArray[ExternalCallResult] = ImmArray.Empty,
): Either[InvalidActionDescription, ExerciseActionDescription] =
Either.catchOnly[InvalidActionDescription](
ExerciseActionDescription(
Expand All @@ -458,6 +485,7 @@ object ActionDescription {
byKey,
seed,
failed,
externalCallResults,
)
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,7 @@ final case class ViewParticipantData private (
byKey,
_seed,
failed,
_externalCallResults,
) =>
val inputContract = coreInputs.getOrElse(
inputContractId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import com.digitalasset.canton.data.ActionDescription.*
import com.digitalasset.canton.protocol.*
import com.digitalasset.canton.util.LfTransactionBuilder.{defaultPackageId, defaultTemplateId}
import com.digitalasset.canton.{BaseTest, LfPartyId}
import com.digitalasset.daml.lf.data.Ref
import com.digitalasset.daml.lf.data.{Bytes, ImmArray, Ref}
import com.digitalasset.daml.lf.transaction.ExternalCallResult
import org.scalatest.wordspec.AnyWordSpec

class ActionDescriptionTest extends AnyWordSpec with BaseTest {
Expand Down Expand Up @@ -49,6 +50,36 @@ class ActionDescriptionTest extends AnyWordSpec with BaseTest {
Right(expected)
}

"an exercise node with external call results is presented" in {
val externalCallResults = ImmArray(
ExternalCallResult(
extensionId = "extension",
functionId = "function",
config = Bytes.fromStringUtf8("config"),
input = Bytes.fromStringUtf8("input"),
output = Bytes.fromStringUtf8("output"),
)
)
val node = ExampleTransactionFactory
.exerciseNodeWithoutChildren(
suffixedId,
actingParties = Set(ExampleTransactionFactory.submitter),
)
.copy(externalCallResults = externalCallResults)

val description = ActionDescription
.tryFromLfActionNode(
node,
Some(seed),
Set.empty,
)
.asInstanceOf[ExerciseActionDescription]

description.externalCallResults shouldBe externalCallResults
ActionDescription.fromProtoV30(description.toProtoV30).value shouldBe description
ActionDescription.fromProtoV31(description.toProtoV31).value shouldBe description
}

}

"reject creation" when {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ import java.nio.file.Path
* security consequences.
* @param snapshotDir
* path to the directory used for saving transaction tree snapshots
* @param extensions
* Configuration for external extension services that can be called from Daml contracts. Map from
* extension ID to extension service configuration.
* @param extensionSettings
* Global settings for engine extensions
*/
final case class CantonEngineConfig(
enableEngineStackTraces: Boolean = false,
Expand All @@ -40,4 +45,6 @@ final case class CantonEngineConfig(
enableAdditionalConsistencyChecks: Boolean = false,
profileDir: Option[Path] = None,
snapshotDir: Option[Path] = None,
extensions: Map[String, ExtensionServiceConfig] = Map.empty,
extensionSettings: EngineExtensionsConfig = EngineExtensionsConfig.default,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Copyright (c) 2026 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package com.digitalasset.canton.participant.config

import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, Port}
import com.digitalasset.canton.config.NonNegativeFiniteDuration

import java.nio.file.Path

/** Configuration for a single engine extension service.
*
* Extension services allow Daml contracts to make external calls to deterministic services. The
* participant manages connection pooling and HTTP calls on behalf of the engine.
*
* @param name
* Human-readable name for the extension
* @param host
* Hostname of the extension service
* @param port
* Port of the extension service
* @param version
* API version path segment exposed by the extension service
* @param useTls
* Whether to use TLS for the connection
* @param jwt
* JWT token for authentication
* @param jwtFile
* Path to file containing JWT token
* @param connectTimeout
* Connection timeout
* @param requestTimeout
* Request timeout for individual HTTP requests
* @param maxTotalTimeout
* Maximum total time for the entire operation including retries
* @param maxRetries
* Maximum number of retry attempts
* @param retryInitialDelay
* Initial delay before first retry
* @param retryMaxDelay
* Maximum delay between retries
* @param requestIdHeader
* Name of the request ID header
* @param declaredFunctions
* Functions that this extension provides, for validation
*/
final case class ExtensionServiceConfig(
name: String,
host: String,
port: Port,
version: String = "v1",
useTls: Boolean = true,
jwt: Option[String] = None,
jwtFile: Option[Path] = None,
connectTimeout: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofMillis(500),
requestTimeout: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofSeconds(8),
maxTotalTimeout: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofSeconds(25),
maxRetries: NonNegativeInt = NonNegativeInt.tryCreate(3),
retryInitialDelay: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofMillis(1000),
retryMaxDelay: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofSeconds(10),
requestIdHeader: String = "X-Request-Id",
declaredFunctions: Seq[ExtensionFunctionDeclaration] = Seq.empty,
)

/** Declaration of a function provided by an extension service.
*
* Used for validation on startup to ensure DARs have matching extensions configured.
*
* @param functionId
* The function identifier
* @param configHash
* Expected configuration hash for version validation
*/
final case class ExtensionFunctionDeclaration(
functionId: String,
configHash: String,
)

/** Configuration for engine extensions in the participant.
*
* @param validateExtensionsOnStartup
* Whether to validate extension configurations on startup
* @param failOnExtensionValidationError
* Whether to fail startup if extension validation fails
*/
final case class EngineExtensionsConfig(
validateExtensionsOnStartup: Boolean = true,
failOnExtensionValidationError: Boolean = true,
)

object EngineExtensionsConfig {
val default: EngineExtensionsConfig = EngineExtensionsConfig()
}
Loading
Loading