diff --git a/.github/workflows/test-cn-quickstart.yml b/.github/workflows/test-cn-quickstart.yml index 296459a2..f061dcca 100644 --- a/.github/workflows/test-cn-quickstart.yml +++ b/.github/workflows/test-cn-quickstart.yml @@ -90,7 +90,13 @@ jobs: - name: Start CN-Quickstart run: | - npm run localnet:start + splice_version="$(tr -d '[:space:]' < libs/splice/VERSION)" + if [[ ! "${splice_version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "Invalid pinned Splice version: ${splice_version}" >&2 + exit 1 + fi + echo "Starting CN-Quickstart with pinned Splice ${splice_version}" + CANTON_LOCALNET_SPLICE_VERSION="${splice_version}" npm run localnet:start echo "✓ CN-Quickstart started" timeout-minutes: 15 diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/allocate-party.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/allocate-party.ts deleted file mode 100644 index 69a048f9..00000000 --- a/src/clients/ledger-json-api/operations/v2/interactive-submission/allocate-party.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { createApiOperation } from '../../../../../core'; -import { type InteractiveSubmissionAllocatePartyResponse } from '../../../schemas/api'; -import { - InteractiveSubmissionAllocatePartyParamsSchema, - type InteractiveSubmissionAllocatePartyParams, -} from '../../../schemas/operations'; - -/** - * Allocate party interactively - * - * @example - * ```typescript - * const result = await client.interactiveSubmissionAllocateParty({ - * partyIdHint: 'Alice', - * displayName: 'Alice Party', - * isLocal: true - * }); - * - * ```; - */ -export const InteractiveSubmissionAllocateParty = createApiOperation< - InteractiveSubmissionAllocatePartyParams, - InteractiveSubmissionAllocatePartyResponse ->({ - paramsSchema: InteractiveSubmissionAllocatePartyParamsSchema, - method: 'POST', - buildUrl: (_params: InteractiveSubmissionAllocatePartyParams, apiUrl: string) => - `${apiUrl}/v2/interactive-submission/allocate-party`, - buildRequestData: (params: InteractiveSubmissionAllocatePartyParams) => ({ - partyIdHint: params.partyIdHint, - displayName: params.displayName, - isLocal: params.isLocal, - }), -}); diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/create-user.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/create-user.ts deleted file mode 100644 index 13860f82..00000000 --- a/src/clients/ledger-json-api/operations/v2/interactive-submission/create-user.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { createApiOperation } from '../../../../../core'; -import { type InteractiveSubmissionCreateUserResponse } from '../../../schemas/api'; -import { - InteractiveSubmissionCreateUserParamsSchema, - type InteractiveSubmissionCreateUserParams, -} from '../../../schemas/operations'; - -/** - * Create user interactively - * - * @example - * ```typescript - * const result = await client.interactiveSubmissionCreateUser({ - * user: { - * id: 'alice', - * primaryParty: 'Alice::1220', - * isDeactivated: false, - * identityProviderId: 'default' - * }, - * rights: [ - * { kind: { CanActAs: { party: 'Alice::1220' } } } - * ] - * }); - * - * ```; - */ -export const InteractiveSubmissionCreateUser = createApiOperation< - InteractiveSubmissionCreateUserParams, - InteractiveSubmissionCreateUserResponse ->({ - paramsSchema: InteractiveSubmissionCreateUserParamsSchema, - method: 'POST', - buildUrl: (_params: InteractiveSubmissionCreateUserParams, apiUrl: string) => - `${apiUrl}/v2/interactive-submission/create-user`, - buildRequestData: (params: InteractiveSubmissionCreateUserParams) => ({ - user: params.user, - rights: params.rights, - }), -}); diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait-for-transaction.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait-for-transaction.ts new file mode 100644 index 00000000..a2e0b81a --- /dev/null +++ b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait-for-transaction.ts @@ -0,0 +1,22 @@ +import { createApiOperation } from '../../../../../core'; +import { + InteractiveSubmissionExecuteAndWaitForTransactionRequestSchema, + InteractiveSubmissionExecuteAndWaitForTransactionResponseSchema, + type InteractiveSubmissionExecuteAndWaitForTransactionRequest, + type InteractiveSubmissionExecuteAndWaitForTransactionResponse, +} from '../../../schemas/api/interactive-submission'; +/** Execute an interactive submission and wait for the resulting transaction. */ +export const InteractiveSubmissionExecuteAndWaitForTransaction = createApiOperation< + InteractiveSubmissionExecuteAndWaitForTransactionRequest, + InteractiveSubmissionExecuteAndWaitForTransactionResponse +>({ + paramsSchema: InteractiveSubmissionExecuteAndWaitForTransactionRequestSchema, + responseSchema: InteractiveSubmissionExecuteAndWaitForTransactionResponseSchema, + method: 'POST', + buildUrl: (_params, apiUrl) => `${apiUrl}/v2/interactive-submission/executeAndWaitForTransaction`, + buildRequestData: (params) => ({ + ...params, + deduplicationPeriod: params.deduplicationPeriod ?? { Empty: {} }, + }), + getFreshRetryIdentifier: (params) => params.submissionId, +}); diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait.ts new file mode 100644 index 00000000..eb4e091a --- /dev/null +++ b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait.ts @@ -0,0 +1,22 @@ +import { createApiOperation } from '../../../../../core'; +import { + InteractiveSubmissionExecuteAndWaitRequestSchema, + InteractiveSubmissionExecuteAndWaitResponseSchema, + type InteractiveSubmissionExecuteAndWaitRequest, + type InteractiveSubmissionExecuteAndWaitResponse, +} from '../../../schemas/api/interactive-submission'; +/** Execute an interactive submission and wait for its completion. */ +export const InteractiveSubmissionExecuteAndWait = createApiOperation< + InteractiveSubmissionExecuteAndWaitRequest, + InteractiveSubmissionExecuteAndWaitResponse +>({ + paramsSchema: InteractiveSubmissionExecuteAndWaitRequestSchema, + responseSchema: InteractiveSubmissionExecuteAndWaitResponseSchema, + method: 'POST', + buildUrl: (_params, apiUrl) => `${apiUrl}/v2/interactive-submission/executeAndWait`, + buildRequestData: (params) => ({ + ...params, + deduplicationPeriod: params.deduplicationPeriod ?? { Empty: {} }, + }), + getFreshRetryIdentifier: (params) => params.submissionId, +}); diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/execute.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute.ts index 900ebca1..00536661 100644 --- a/src/clients/ledger-json-api/operations/v2/interactive-submission/execute.ts +++ b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute.ts @@ -1,18 +1,23 @@ import { createApiOperation } from '../../../../../core'; import { InteractiveSubmissionExecuteRequestSchema, + InteractiveSubmissionExecuteResponseSchema, type InteractiveSubmissionExecuteRequest, type InteractiveSubmissionExecuteResponse, } from '../../../schemas/api/interactive-submission'; - /** Execute an interactive submission that has been previously prepared and signed. */ export const InteractiveSubmissionExecute = createApiOperation< InteractiveSubmissionExecuteRequest, InteractiveSubmissionExecuteResponse >({ paramsSchema: InteractiveSubmissionExecuteRequestSchema, + responseSchema: InteractiveSubmissionExecuteResponseSchema, method: 'POST', buildUrl: (_params: InteractiveSubmissionExecuteRequest, apiUrl: string) => `${apiUrl}/v2/interactive-submission/execute`, - buildRequestData: (params) => params, + buildRequestData: (params) => ({ + ...params, + deduplicationPeriod: params.deduplicationPeriod ?? { Empty: {} }, + }), + getFreshRetryIdentifier: (params) => params.submissionId, }); diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/get-preferred-package-version.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/get-preferred-package-version.ts index 7903b472..fdabf967 100644 --- a/src/clients/ledger-json-api/operations/v2/interactive-submission/get-preferred-package-version.ts +++ b/src/clients/ledger-json-api/operations/v2/interactive-submission/get-preferred-package-version.ts @@ -1,5 +1,8 @@ import { createApiOperation } from '../../../../../core'; -import { type GetPreferredPackageVersionResponse } from '../../../schemas/api'; +import { + GetPreferredPackageVersionResponseSchema, + type GetPreferredPackageVersionResponse, +} from '../../../schemas/api'; import { InteractiveSubmissionGetPreferredPackageVersionParamsSchema, type InteractiveSubmissionGetPreferredPackageVersionParams, @@ -23,6 +26,7 @@ export const InteractiveSubmissionGetPreferredPackageVersion = createApiOperatio GetPreferredPackageVersionResponse >({ paramsSchema: InteractiveSubmissionGetPreferredPackageVersionParamsSchema, + responseSchema: GetPreferredPackageVersionResponseSchema, method: 'GET', buildUrl: (params: InteractiveSubmissionGetPreferredPackageVersionParams, apiUrl: string) => { const url = new URL(`${apiUrl}/v2/interactive-submission/preferred-package-version`); diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/get-preferred-packages.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/get-preferred-packages.ts index 65838275..67a5f0d5 100644 --- a/src/clients/ledger-json-api/operations/v2/interactive-submission/get-preferred-packages.ts +++ b/src/clients/ledger-json-api/operations/v2/interactive-submission/get-preferred-packages.ts @@ -1,5 +1,5 @@ import { createApiOperation } from '../../../../../core'; -import { type GetPreferredPackagesResponse } from '../../../schemas/api'; +import { GetPreferredPackagesResponseSchema, type GetPreferredPackagesResponse } from '../../../schemas/api'; import { InteractiveSubmissionGetPreferredPackagesParamsSchema, type InteractiveSubmissionGetPreferredPackagesParams, @@ -27,13 +27,10 @@ export const InteractiveSubmissionGetPreferredPackages = createApiOperation< GetPreferredPackagesResponse >({ paramsSchema: InteractiveSubmissionGetPreferredPackagesParamsSchema, + responseSchema: GetPreferredPackagesResponseSchema, method: 'POST', requestSemantics: 'read', buildUrl: (_params: InteractiveSubmissionGetPreferredPackagesParams, apiUrl: string) => `${apiUrl}/v2/interactive-submission/preferred-packages`, - buildRequestData: (params: InteractiveSubmissionGetPreferredPackagesParams) => ({ - packageVettingRequirements: params.packageVettingRequirements, - synchronizerId: params.synchronizerId, - vettingValidAt: params.vettingValidAt, - }), + buildRequestData: (params: InteractiveSubmissionGetPreferredPackagesParams) => params, }); diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/index.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/index.ts index 184fd176..c6f9c68a 100644 --- a/src/clients/ledger-json-api/operations/v2/interactive-submission/index.ts +++ b/src/clients/ledger-json-api/operations/v2/interactive-submission/index.ts @@ -1,7 +1,6 @@ -export * from './allocate-party'; -export * from './create-user'; export * from './execute'; +export * from './execute-and-wait'; +export * from './execute-and-wait-for-transaction'; export * from './get-preferred-package-version'; export * from './get-preferred-packages'; export * from './prepare'; -export * from './upload-dar'; diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/prepare.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/prepare.ts index d52cc48a..86f64a8b 100644 --- a/src/clients/ledger-json-api/operations/v2/interactive-submission/prepare.ts +++ b/src/clients/ledger-json-api/operations/v2/interactive-submission/prepare.ts @@ -1,6 +1,7 @@ import { createApiOperation } from '../../../../../core'; import { InteractiveSubmissionPrepareRequestSchema, + InteractiveSubmissionPrepareResponseSchema, type InteractiveSubmissionPrepareRequest, type InteractiveSubmissionPrepareResponse, } from '../../../schemas/api/interactive-submission'; @@ -11,9 +12,23 @@ export const InteractiveSubmissionPrepare = createApiOperation< InteractiveSubmissionPrepareResponse >({ paramsSchema: InteractiveSubmissionPrepareRequestSchema, + responseSchema: InteractiveSubmissionPrepareResponseSchema, method: 'POST', requestSemantics: 'read', buildUrl: (_params: InteractiveSubmissionPrepareRequest, apiUrl: string) => `${apiUrl}/v2/interactive-submission/prepare`, - buildRequestData: (params) => params, + // Canton models these OpenAPI-optional fields as non-defaulted Scala values at the JSON boundary. + buildRequestData: (params) => ({ + ...params, + synchronizerId: params.synchronizerId ?? '', + packageIdSelectionPreference: params.packageIdSelectionPreference ?? [], + ...(params.estimateTrafficCost === undefined + ? {} + : { + estimateTrafficCost: { + ...params.estimateTrafficCost, + expectedSignatures: params.estimateTrafficCost.expectedSignatures ?? [], + }, + }), + }), }); diff --git a/src/clients/ledger-json-api/operations/v2/interactive-submission/upload-dar.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/upload-dar.ts deleted file mode 100644 index 475d90b4..00000000 --- a/src/clients/ledger-json-api/operations/v2/interactive-submission/upload-dar.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { createApiOperation } from '../../../../../core'; -import { type InteractiveSubmissionUploadDarResponse } from '../../../schemas/api'; -import { - InteractiveSubmissionUploadDarParamsSchema, - type InteractiveSubmissionUploadDarParams, -} from '../../../schemas/operations'; - -/** - * Upload DAR file interactively - * - * @example - * ```typescript - * const result = await client.interactiveSubmissionUploadDar({ - * darFile: fs.readFileSync('my-package.dar') - * }); - * - * ```; - */ -export const InteractiveSubmissionUploadDar = createApiOperation< - InteractiveSubmissionUploadDarParams, - InteractiveSubmissionUploadDarResponse ->({ - paramsSchema: InteractiveSubmissionUploadDarParamsSchema, - method: 'POST', - buildUrl: (_params: InteractiveSubmissionUploadDarParams, apiUrl: string) => - `${apiUrl}/v2/interactive-submission/upload-dar`, - buildRequestData: (params: InteractiveSubmissionUploadDarParams) => - // Return the DAR file content as the request body - params.darFile, -}); diff --git a/src/clients/ledger-json-api/schemas/api/interactive-submission.ts b/src/clients/ledger-json-api/schemas/api/interactive-submission.ts index b8c430a2..ac2cd3a1 100644 --- a/src/clients/ledger-json-api/schemas/api/interactive-submission.ts +++ b/src/clients/ledger-json-api/schemas/api/interactive-submission.ts @@ -1,252 +1,792 @@ import { z } from 'zod'; -import { RecordSchema } from '../base'; -import { DarFileSchema } from '../common'; - -/** Interactive submission allocate party request. */ -export const InteractiveSubmissionAllocatePartyRequestSchema = z.object({ - /** Party identifier hint (optional). */ - partyIdHint: z.string().optional(), - /** Display name (optional). */ - displayName: z.string().optional(), - /** Is local party flag (optional). */ - isLocal: z.boolean().optional(), -}); - -/** Interactive submission allocate party response. */ -export const InteractiveSubmissionAllocatePartyResponseSchema = z.object({ - /** Allocated party details. */ - party: z.object({ - /** Party identifier. */ - party: z.string(), - /** Display name (optional). */ - displayName: z.string().optional(), - /** Is local party flag. */ - isLocal: z.boolean(), - }), -}); - -/** Interactive submission create user request. */ -export const InteractiveSubmissionCreateUserRequestSchema = z.object({ - /** User to create. */ - user: z.object({ - /** User identifier. */ - id: z.string(), - /** Primary party for the user (optional). */ - primaryParty: z.string().optional(), - /** Whether the user is deactivated. */ - isDeactivated: z.boolean(), - /** User metadata (optional). */ - metadata: z - .object({ - /** Resource version for concurrent change detection. */ - resourceVersion: z.string(), - /** Annotations for the resource. */ - annotations: z.record(z.string(), z.string()), - }) - .optional(), - /** Identity provider ID (optional). */ - identityProviderId: z.string().optional(), - }), - /** Rights to assign to the user (optional). */ - rights: z - .array( - z.object({ - /** The kind of right. */ - kind: z.union([ - z.object({ CanActAs: z.object({ party: z.string() }) }), - z.object({ CanReadAs: z.object({ party: z.string() }) }), - z.object({ CanReadAsAnyParty: z.object({}) }), - z.object({ Empty: z.object({}) }), - z.object({ IdentityProviderAdmin: z.object({}) }), - z.object({ ParticipantAdmin: z.object({}) }), - ]), - }) - ) - .optional(), -}); - -/** Interactive submission create user response. */ -export const InteractiveSubmissionCreateUserResponseSchema = z.object({ - /** Created user. */ - user: z.object({ - /** User identifier. */ - id: z.string(), - /** Primary party for the user (optional). */ - primaryParty: z.string().optional(), - /** Whether the user is deactivated. */ - isDeactivated: z.boolean(), - /** User metadata (optional). */ - metadata: z - .object({ - /** Resource version for concurrent change detection. */ - resourceVersion: z.string(), - /** Annotations for the resource. */ - annotations: z.record(z.string(), z.string()), - }) - .optional(), - /** Identity provider ID (optional). */ - identityProviderId: z.string().optional(), - }), -}); - -/** Interactive submission upload DAR request. */ -export const InteractiveSubmissionUploadDarRequestSchema = z.object({ - /** DAR file content as a binary Buffer. */ - darFile: DarFileSchema, -}); - -/** Interactive submission upload DAR response. */ -export const InteractiveSubmissionUploadDarResponseSchema = z.object({}); - -const CreateCommandSchema = z.object({ - CreateCommand: z.object({ - templateId: z.string(), - createArguments: RecordSchema, - }), -}); - -const ExerciseCommandSchema = z.object({ - ExerciseCommand: z.object({ - templateId: z.string(), - contractId: z.string(), - choice: z.string(), - choiceArgument: RecordSchema, - }), -}); - -const CreateAndExerciseCommandSchema = z.object({ - CreateAndExerciseCommand: z.object({ - templateId: z.string(), - createArguments: RecordSchema, - choice: z.string(), - choiceArgument: RecordSchema, - }), -}); - -const ExerciseByKeyCommandSchema = z.object({ - ExerciseByKeyCommand: z.object({ - templateId: z.string(), - contractKey: RecordSchema, - choice: z.string(), - choiceArgument: RecordSchema, - }), -}); - -const CommandSchema = z.union([ - CreateCommandSchema, - ExerciseCommandSchema, - CreateAndExerciseCommandSchema, - ExerciseByKeyCommandSchema, +import { createRequestSchema, type Brand } from '../../../../core'; +import type { + components, + paths, +} from '../../../../generated/canton/community/ledger/ledger-json-api/src/test/resources/json-api-docs/openapi'; +import { + LedgerBase64BytesSchema, + LedgerNameSchema, + LedgerNonEmptyBase64BytesSchema, + LedgerRfc3339TimestampSchema, + LedgerStringSchema, +} from '../wire'; + +type LedgerSchemas = components['schemas']; +type PrepareEndpoint = '/v2/interactive-submission/prepare'; +type ExecuteEndpoint = '/v2/interactive-submission/execute'; +type ExecuteAndWaitEndpoint = '/v2/interactive-submission/executeAndWait'; +type ExecuteAndWaitForTransactionEndpoint = '/v2/interactive-submission/executeAndWaitForTransaction'; + +type GeneratedInteractiveSubmissionPrepareRequest = + paths[PrepareEndpoint]['post']['requestBody']['content']['application/json']; +type GeneratedInteractiveSubmissionPrepareResponse = + paths[PrepareEndpoint]['post']['responses']['200']['content']['application/json']; +type GeneratedInteractiveSubmissionExecuteRequest = + paths[ExecuteEndpoint]['post']['requestBody']['content']['application/json']; +export type InteractiveSubmissionExecuteResponse = + paths[ExecuteEndpoint]['post']['responses']['200']['content']['application/json']; +type GeneratedInteractiveSubmissionExecuteAndWaitRequest = + paths[ExecuteAndWaitEndpoint]['post']['requestBody']['content']['application/json']; +export type InteractiveSubmissionExecuteAndWaitResponse = + paths[ExecuteAndWaitEndpoint]['post']['responses']['200']['content']['application/json']; +type GeneratedInteractiveSubmissionExecuteAndWaitForTransactionRequest = + paths[ExecuteAndWaitForTransactionEndpoint]['post']['requestBody']['content']['application/json']; +type GeneratedInteractiveSubmissionExecuteAndWaitForTransactionResponse = + paths[ExecuteAndWaitForTransactionEndpoint]['post']['responses']['200']['content']['application/json']; + +/** Any losslessly representable JSON value. */ +export type InteractiveSubmissionJsonValue = + | string + | number + | boolean + | null + | InteractiveSubmissionJsonValue[] + | { [key: string]: InteractiveSubmissionJsonValue }; + +/** A present Daml JSON value; nested JSON nulls remain valid. */ +export type InteractiveSubmissionNonNullJsonValue = Exclude; + +/** A mutable non-empty array matching raw Ledger request shapes. */ +export type InteractiveSubmissionNonEmptyArray = [Value, ...Value[]]; + +type UnionKeys = Union extends Union ? keyof Union : never; +type ExactOneOfHelper = Variant extends Union + ? Variant & Partial, keyof Variant>, never>> + : never; +type ExactOneOf = ExactOneOfHelper; + +type InteractiveSubmissionCreateCommand = Omit & { + createArguments: InteractiveSubmissionJsonValue; +}; + +type InteractiveSubmissionExerciseCommand = Omit & { + choiceArgument: InteractiveSubmissionJsonValue; +}; + +type InteractiveSubmissionCreateAndExerciseCommand = Omit< + LedgerSchemas['CreateAndExerciseCommand'], + 'createArguments' | 'choiceArgument' +> & { + createArguments: InteractiveSubmissionJsonValue; + choiceArgument: InteractiveSubmissionJsonValue; +}; + +type InteractiveSubmissionExerciseByKeyCommand = Omit< + LedgerSchemas['ExerciseByKeyCommand'], + 'choiceArgument' | 'contractKey' +> & { + contractKey: InteractiveSubmissionJsonValue; + choiceArgument: InteractiveSubmissionJsonValue; +}; + +type InteractiveSubmissionCommandVariants = + | { CreateAndExerciseCommand: InteractiveSubmissionCreateAndExerciseCommand } + | { CreateCommand: InteractiveSubmissionCreateCommand } + | { ExerciseByKeyCommand: InteractiveSubmissionExerciseByKeyCommand } + | { ExerciseCommand: InteractiveSubmissionExerciseCommand }; +export type InteractiveSubmissionCommand = ExactOneOf; + +type InteractiveSubmissionDeduplicationPeriodVariants = LedgerSchemas['DeduplicationPeriod2']; +type InteractiveSubmissionDeduplicationPeriod = ExactOneOf; +type InteractiveSubmissionTimeVariants = LedgerSchemas['Time']; +type InteractiveSubmissionTime = ExactOneOf; +type InteractiveSubmissionMinLedgerTime = Omit & { + time?: InteractiveSubmissionTime; +}; + +type InteractiveSubmissionIdentifierFilterVariants = LedgerSchemas['IdentifierFilter']; +export type InteractiveSubmissionIdentifierFilter = ExactOneOf; +type InteractiveSubmissionCumulativeFilter = Omit & { + identifierFilter?: InteractiveSubmissionIdentifierFilter; +}; +type InteractiveSubmissionFilters = Omit & { + cumulative?: InteractiveSubmissionCumulativeFilter[]; +}; +type InteractiveSubmissionEventFormat = Omit & { + filtersByParty?: Record; + filtersForAnyParty?: InteractiveSubmissionFilters; +}; +type InteractiveSubmissionTransactionFormat = Omit< + LedgerSchemas['TransactionFormat'], + 'eventFormat' | 'transactionShape' +> & { + eventFormat: InteractiveSubmissionEventFormat; + transactionShape: 'TRANSACTION_SHAPE_ACS_DELTA' | 'TRANSACTION_SHAPE_LEDGER_EFFECTS'; +}; + +export type InteractiveSubmissionPrefetchContractKey = Omit & { + contractKey: InteractiveSubmissionJsonValue; +}; + +export type InteractiveSubmissionPrepareRequest = Omit< + GeneratedInteractiveSubmissionPrepareRequest, + 'actAs' | 'commands' | 'estimateTrafficCost' | 'hashingSchemeVersion' | 'minLedgerTime' | 'prefetchContractKeys' +> & { + /** Pinned Canton 0.6.8 supports exactly one command per interactive preparation. */ + commands: [InteractiveSubmissionCommand]; + actAs: InteractiveSubmissionNonEmptyArray; + estimateTrafficCost?: InteractiveSubmissionCostEstimationHints; + hashingSchemeVersion?: InteractiveSubmissionHashingSchemeVersion; + minLedgerTime?: InteractiveSubmissionMinLedgerTime; + prefetchContractKeys?: InteractiveSubmissionPrefetchContractKey[]; +}; + +/** Signature formats defined by the pinned Canton Ledger API crypto protobuf. */ +export type InteractiveSubmissionSignatureFormat = + | 'SIGNATURE_FORMAT_RAW' + | 'SIGNATURE_FORMAT_DER' + | 'SIGNATURE_FORMAT_CONCAT' + | 'SIGNATURE_FORMAT_SYMBOLIC'; + +/** Signing algorithms defined by the pinned Canton Ledger API crypto protobuf. */ +export type InteractiveSubmissionSigningAlgorithmSpec = + | 'SIGNING_ALGORITHM_SPEC_ED25519' + | 'SIGNING_ALGORITHM_SPEC_EC_DSA_SHA_256' + | 'SIGNING_ALGORITHM_SPEC_EC_DSA_SHA_384'; + +type InteractiveSubmissionCostEstimationHints = Omit & { + expectedSignatures?: InteractiveSubmissionSigningAlgorithmSpec[]; +}; + +export type InteractiveSubmissionHashingSchemeVersion = Exclude< + GeneratedInteractiveSubmissionExecuteRequest['hashingSchemeVersion'], + 'HASHING_SCHEME_VERSION_UNSPECIFIED' +>; + +export type InteractiveSubmissionPrepareResponse = Omit< + GeneratedInteractiveSubmissionPrepareResponse, + 'hashingSchemeVersion' +> & { + hashingSchemeVersion: InteractiveSubmissionHashingSchemeVersion; +}; + +export type InteractiveSubmissionSignature = Omit & { + format: InteractiveSubmissionSignatureFormat; + signingAlgorithmSpec: InteractiveSubmissionSigningAlgorithmSpec; +}; + +type InteractiveSubmissionSinglePartySignatures = Omit & { + signatures: InteractiveSubmissionNonEmptyArray; +}; + +type InteractiveSubmissionPartySignatures = Omit & { + signatures: InteractiveSubmissionNonEmptyArray; +}; + +type WithExactInteractiveSubmissionRequest = Omit< + Request, + 'deduplicationPeriod' | 'hashingSchemeVersion' | 'minLedgerTime' | 'partySignatures' +> & { + deduplicationPeriod?: InteractiveSubmissionDeduplicationPeriod; + hashingSchemeVersion: InteractiveSubmissionHashingSchemeVersion; + minLedgerTime?: InteractiveSubmissionMinLedgerTime; + partySignatures: InteractiveSubmissionPartySignatures; +}; + +export type InteractiveSubmissionExecuteRequest = + WithExactInteractiveSubmissionRequest; +export type InteractiveSubmissionExecuteAndWaitRequest = + WithExactInteractiveSubmissionRequest; +export type InteractiveSubmissionExecuteAndWaitForTransactionRequest = Omit< + WithExactInteractiveSubmissionRequest, + 'transactionFormat' +> & { + transactionFormat?: InteractiveSubmissionTransactionFormat; +}; + +/** Canton protobuf Any details carry decoded JSON, despite the generated OpenAPI declaring a string. */ +export type InteractiveSubmissionProtoAny = Omit & { + valueDecoded?: InteractiveSubmissionJsonValue; +}; + +type InteractiveSubmissionStatus = Omit & { + details?: InteractiveSubmissionProtoAny[]; +}; + +type InteractiveSubmissionInterfaceView = Omit & { + viewStatus: InteractiveSubmissionStatus; + viewValue?: InteractiveSubmissionJsonValue; +}; + +type InteractiveSubmissionCreatedEvent = Omit< + LedgerSchemas['CreatedEvent'], + 'contractKey' | 'createArgument' | 'interfaceViews' +> & { + contractKey?: InteractiveSubmissionNonNullJsonValue; + createArgument: InteractiveSubmissionJsonValue; + interfaceViews?: InteractiveSubmissionInterfaceView[]; +}; + +type InteractiveSubmissionExercisedEvent = Omit< + LedgerSchemas['ExercisedEvent'], + 'choiceArgument' | 'exerciseResult' +> & { + choiceArgument: InteractiveSubmissionJsonValue; + exerciseResult?: InteractiveSubmissionJsonValue; +}; + +type GeneratedInteractiveSubmissionEvent = LedgerSchemas['Event']; +type InteractiveSubmissionEventVariants = + | Extract + | { CreatedEvent: InteractiveSubmissionCreatedEvent } + | { ExercisedEvent: InteractiveSubmissionExercisedEvent }; +export type InteractiveSubmissionEvent = ExactOneOf; + +/** Normalized Ledger trace context; protobuf option nulls are exposed as omitted properties. */ +export type InteractiveSubmissionTraceContext = Omit & { + traceparent?: string; + tracestate?: string; +}; + +/** Raw 32-byte Daml-LF SHA-256 transaction hash encoded as 64 lowercase hex characters. */ +export type InteractiveSubmissionExternalTransactionHashHex = Brand< + string, + 'InteractiveSubmissionExternalTransactionHashHex' +>; + +export type InteractiveSubmissionTransaction = Omit< + LedgerSchemas['JsTransaction'], + 'events' | 'externalTransactionHash' | 'traceContext' +> & { + events: InteractiveSubmissionEvent[]; + traceContext?: InteractiveSubmissionTraceContext; + externalTransactionHash?: InteractiveSubmissionExternalTransactionHashHex; +}; + +export type InteractiveSubmissionExecuteAndWaitForTransactionResponse = Omit< + GeneratedInteractiveSubmissionExecuteAndWaitForTransactionResponse, + 'transaction' +> & { + transaction: InteractiveSubmissionTransaction; +}; + +const INT32_MIN = -2_147_483_648; +const INT32_MAX = 2_147_483_647; +const DURATION_SECONDS_MIN = -315_576_000_000; +const DURATION_SECONDS_MAX = 315_576_000_000; +const DURATION_NANOS_MIN = -999_999_999; +const DURATION_NANOS_MAX = 999_999_999; + +const Int32Schema = z.number().int().min(INT32_MIN).max(INT32_MAX); +const NonNegativeInt32Schema = z.number().int().min(0).max(INT32_MAX); +const PositiveInt32Schema = z.number().int().min(1).max(INT32_MAX); +const Int64Schema = z.number().int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER); +const NonNegativeInt64Schema = z.number().int().min(0).max(Number.MAX_SAFE_INTEGER); +const PositiveInt64Schema = z.number().int().min(1).max(Number.MAX_SAFE_INTEGER); + +function isJsonValue(value: unknown, ancestors: Set = new Set()): boolean { + if (value === null || typeof value === 'string' || typeof value === 'boolean') { + return true; + } + if (typeof value === 'number') { + return Number.isFinite(value) && !Object.is(value, -0); + } + if (typeof value !== 'object') { + return false; + } + + if (ancestors.has(value)) { + return false; + } + ancestors.add(value); + + try { + if (Array.isArray(value)) { + if (Object.keys(value).length !== value.length || Reflect.ownKeys(value).length !== value.length + 1) { + return false; + } + for (let index = 0; index < value.length; index += 1) { + if (!(index in value) || !isJsonValue(value[index], ancestors)) { + return false; + } + } + return true; + } + + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return false; + } + + const keys = Object.keys(value); + if (Reflect.ownKeys(value).length !== keys.length) { + return false; + } + return keys.every((key) => isJsonValue((value as Record)[key], ancestors)); + } catch { + return false; + } finally { + ancestors.delete(value); + } +} + +const RequiredJsonValueSchema: z.ZodType = z + .custom((value) => isJsonValue(value), { + message: 'Expected a JSON-serializable value', + }) + .transform(cloneJsonValue); + +function cloneJsonValue(value: InteractiveSubmissionJsonValue): InteractiveSubmissionJsonValue { + if (Array.isArray(value)) { + return value.map(cloneJsonValue); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, cloneJsonValue(nested)])); + } + return value; +} + +const NullableOptionalJsonValueSchema: z.ZodType = + RequiredJsonValueSchema.nullish().transform( + (value): InteractiveSubmissionNonNullJsonValue | undefined => value ?? undefined + ); + +/** Accept Scala `Option.None` encoded as JSON null, then expose the generated optional-property shape. */ +const nullableOptionalResponseField = ( + schema: Schema +): z.ZodType | undefined, z.input | null | undefined> => + schema.nullish().transform((value): z.output | undefined => value ?? undefined); + +const EmptyObjectSchema: z.ZodType> = z.record(z.string(), z.never()); + +const HashingSchemeVersionSchema = z.enum(['HASHING_SCHEME_VERSION_V2', 'HASHING_SCHEME_VERSION_V3']); + +const SignatureFormatSchema = z.enum([ + 'SIGNATURE_FORMAT_RAW', + 'SIGNATURE_FORMAT_DER', + 'SIGNATURE_FORMAT_CONCAT', + 'SIGNATURE_FORMAT_SYMBOLIC', ]); -const DisclosedContractSchema = z.object({ - contractId: z.string(), - templateId: z.string(), - createdEventBlob: z.string().optional(), - synchronizerId: z.string(), - /** Optional metadata for the disclosed contract */ - metadata: RecordSchema.optional(), +const SigningAlgorithmSpecSchema = z.enum([ + 'SIGNING_ALGORITHM_SPEC_ED25519', + 'SIGNING_ALGORITHM_SPEC_EC_DSA_SHA_256', + 'SIGNING_ALGORITHM_SPEC_EC_DSA_SHA_384', +]); + +const UnknownFieldSchema = createRequestSchema()({ + varint: z.array(Int64Schema).optional(), + fixed64: z.array(Int64Schema).optional(), + fixed32: z.array(Int32Schema).optional(), + lengthDelimited: z.array(LedgerBase64BytesSchema).optional(), +}); + +const UnknownFieldSetSchema = createRequestSchema()({ + fields: z.record(z.string(), UnknownFieldSchema), +}); + +const DurationSchema = createRequestSchema()({ + seconds: z.number().int().min(DURATION_SECONDS_MIN).max(DURATION_SECONDS_MAX), + nanos: z.number().int().min(DURATION_NANOS_MIN).max(DURATION_NANOS_MAX), + unknownFields: UnknownFieldSetSchema.optional(), +}).superRefine((duration, context) => { + if ((duration.seconds > 0 && duration.nanos < 0) || (duration.seconds < 0 && duration.nanos > 0)) { + context.addIssue({ + code: 'custom', + path: ['nanos'], + message: 'Duration seconds and nanos must have the same sign unless seconds is zero', + }); + } +}); + +const DeduplicationDurationSchema = createRequestSchema()({ + value: DurationSchema.refine((duration) => duration.seconds > 0 || (duration.seconds === 0 && duration.nanos >= 0), { + message: 'Deduplication duration must be non-negative', + }), +}); + +const DeduplicationOffsetSchema = createRequestSchema()({ + value: NonNegativeInt64Schema, }); -const PackagePreferenceSchema = z.object({ - packageId: z.string().optional(), - packageName: z.string().optional(), +const DeduplicationPeriodSchema: z.ZodType = z.union([ + createRequestSchema>()({ + DeduplicationDuration: DeduplicationDurationSchema, + }), + createRequestSchema>()({ + DeduplicationOffset: DeduplicationOffsetSchema, + }), + createRequestSchema>()({ + Empty: EmptyObjectSchema, + }), +]); + +const MinLedgerTimeAbsoluteSchema = createRequestSchema()({ + value: LedgerRfc3339TimestampSchema, }); -/** Interactive submission prepare request. */ -export const InteractiveSubmissionPrepareRequestSchema = z.object({ - commands: z.array(CommandSchema), - commandId: z.string(), - userId: z.string(), - actAs: z.array(z.string()), - readAs: z.array(z.string()), +const MinLedgerTimeRelativeSchema = createRequestSchema()({ + value: DurationSchema, +}); + +const LedgerTimeSchema: z.ZodType = z.union([ + createRequestSchema>()({ + Empty: EmptyObjectSchema, + }), + createRequestSchema>()({ + MinLedgerTimeAbs: MinLedgerTimeAbsoluteSchema, + }), + createRequestSchema>()({ + MinLedgerTimeRel: MinLedgerTimeRelativeSchema, + }), +]); + +const MinLedgerTimeSchema = createRequestSchema()({ + time: LedgerTimeSchema.optional(), +}); + +const CreateCommandContentSchema = createRequestSchema()({ + templateId: z.string().min(1), + createArguments: RequiredJsonValueSchema, +}); + +const ExerciseCommandContentSchema = createRequestSchema()({ + templateId: z.string().min(1), + contractId: z.string().min(1), + choice: LedgerNameSchema, + choiceArgument: RequiredJsonValueSchema, +}); + +const CreateAndExerciseCommandContentSchema = createRequestSchema()({ + templateId: z.string().min(1), + createArguments: RequiredJsonValueSchema, + choice: LedgerNameSchema, + choiceArgument: RequiredJsonValueSchema, +}); + +const ExerciseByKeyCommandContentSchema = createRequestSchema()({ + templateId: z.string().min(1), + contractKey: RequiredJsonValueSchema, + choice: LedgerNameSchema, + choiceArgument: RequiredJsonValueSchema, +}); + +const CommandSchema: z.ZodType = z.union([ + createRequestSchema>()({ + CreateAndExerciseCommand: CreateAndExerciseCommandContentSchema, + }), + createRequestSchema>()({ + CreateCommand: CreateCommandContentSchema, + }), + createRequestSchema>()({ + ExerciseByKeyCommand: ExerciseByKeyCommandContentSchema, + }), + createRequestSchema>()({ + ExerciseCommand: ExerciseCommandContentSchema, + }), +]); + +const SingleCommandSchema = z + .array(CommandSchema) + .length(1) + .transform((commands): [InteractiveSubmissionCommand] => commands as [InteractiveSubmissionCommand]); + +const NonEmptyActAsSchema = z + .array(z.string().min(1)) + .min(1) + .transform( + (parties): InteractiveSubmissionNonEmptyArray => parties as InteractiveSubmissionNonEmptyArray + ); + +const DisclosedContractSchema = createRequestSchema()({ + templateId: z.string().min(1).optional(), + contractId: z.string().min(1).optional(), + createdEventBlob: LedgerNonEmptyBase64BytesSchema, + synchronizerId: z.string().min(1).optional(), +}); + +const PrefetchContractKeySchema = createRequestSchema()({ + templateId: z.string().min(1), + contractKey: RequiredJsonValueSchema, + limit: PositiveInt32Schema.optional(), +}); + +const CostEstimationHintsSchema = createRequestSchema()({ + disabled: z.boolean().optional(), + expectedSignatures: z.array(SigningAlgorithmSpecSchema).optional(), +}); + +/** Exact interactive-submission prepare request from the pinned Ledger OpenAPI. */ +export const InteractiveSubmissionPrepareRequestSchema = createRequestSchema()({ + userId: z.string().optional(), + commandId: LedgerStringSchema, + commands: SingleCommandSchema, + minLedgerTime: MinLedgerTimeSchema.optional(), + actAs: NonEmptyActAsSchema, + readAs: z.array(z.string().min(1)).optional(), disclosedContracts: z.array(DisclosedContractSchema).optional(), - synchronizerId: z.string(), + synchronizerId: z.string().min(1).optional(), + packageIdSelectionPreference: z.array(z.string().min(1)).optional(), verboseHashing: z.boolean().optional(), - packageIdSelectionPreference: z.array(PackagePreferenceSchema).optional(), + prefetchContractKeys: z.array(PrefetchContractKeySchema).optional(), + maxRecordTime: LedgerRfc3339TimestampSchema.optional(), + estimateTrafficCost: CostEstimationHintsSchema.optional(), + tapsMaxPasses: PositiveInt32Schema.optional(), + hashingSchemeVersion: HashingSchemeVersionSchema.optional(), }); /** Traffic cost estimation for a prepared transaction. */ -export const CostEstimationSchema = z.object({ - /** Timestamp at which the estimation was made (ISO 8601). */ - estimationTimestamp: z.string().optional(), - /** Estimated traffic cost of the confirmation request associated with the transaction. */ - confirmationRequestTrafficCostEstimation: z.number(), - /** - * Estimated traffic cost of the confirmation response associated with the transaction. This can also indicate the - * cost that other confirming nodes will incur. - */ - confirmationResponseTrafficCostEstimation: z.number(), - /** Total estimated traffic cost (sum of request and response). */ - totalTrafficCostEstimation: z.number(), -}); - -/** Interactive submission prepare response. */ -export const InteractiveSubmissionPrepareResponseSchema = z.object({ - preparedTransactionHash: z.string(), - preparedTransaction: z.string().optional(), - hashingSchemeVersion: z.enum(['HASHING_SCHEME_VERSION_UNSPECIFIED', 'HASHING_SCHEME_VERSION_V2']).optional(), - hashingDetails: z.string().optional(), - /** Traffic cost estimation of the prepared transaction. */ - costEstimation: CostEstimationSchema.optional(), -}); - -const DeduplicationPeriodSchema = z.union([ - z.object({ Empty: z.object({}) }), - z.object({ - DeduplicationDuration: z.object({ - value: z.object({ - duration: z.string(), - }), - }), - }), - z.object({ - DeduplicationOffset: z.object({ - value: z.object({ - offset: z.string(), - }), - }), +export const CostEstimationSchema = createRequestSchema()({ + estimationTimestamp: LedgerRfc3339TimestampSchema, + confirmationRequestTrafficCostEstimation: NonNegativeInt64Schema, + confirmationResponseTrafficCostEstimation: NonNegativeInt64Schema, + totalTrafficCostEstimation: NonNegativeInt64Schema, +}); + +/** Exact interactive-submission prepare response from the pinned Ledger OpenAPI. */ +export const InteractiveSubmissionPrepareResponseSchema = createRequestSchema()({ + preparedTransaction: LedgerNonEmptyBase64BytesSchema, + preparedTransactionHash: LedgerNonEmptyBase64BytesSchema, + hashingSchemeVersion: HashingSchemeVersionSchema, + hashingDetails: nullableOptionalResponseField(z.string()), + costEstimation: nullableOptionalResponseField(CostEstimationSchema), +}); + +const SignatureSchema = createRequestSchema()({ + format: SignatureFormatSchema, + signature: LedgerNonEmptyBase64BytesSchema, + signedBy: z.string().min(1), + signingAlgorithmSpec: SigningAlgorithmSpecSchema, +}); + +const NonEmptySignaturesSchema = z + .array(SignatureSchema) + .min(1) + .transform( + (signatures): InteractiveSubmissionNonEmptyArray => + signatures as InteractiveSubmissionNonEmptyArray + ); + +const SinglePartySignaturesSchema = createRequestSchema()({ + party: z.string().min(1), + signatures: NonEmptySignaturesSchema, +}); + +const NonEmptySinglePartySignaturesSchema = z + .array(SinglePartySignaturesSchema) + .min(1) + .transform( + (signatures): InteractiveSubmissionNonEmptyArray => + signatures as InteractiveSubmissionNonEmptyArray + ); + +const PartySignaturesSchema = createRequestSchema()({ + signatures: NonEmptySinglePartySignaturesSchema, +}); + +const InterfaceFilterContentSchema = createRequestSchema()({ + interfaceId: z.string(), + includeInterfaceView: z.boolean().optional(), + includeCreatedEventBlob: z.boolean().optional(), +}); + +const InterfaceFilterSchema = createRequestSchema()({ + value: InterfaceFilterContentSchema, +}); + +const TemplateFilterContentSchema = createRequestSchema()({ + templateId: z.string(), + includeCreatedEventBlob: z.boolean().optional(), +}); + +const TemplateFilterSchema = createRequestSchema()({ + value: TemplateFilterContentSchema, +}); + +const WildcardFilterContentSchema = createRequestSchema()({ + includeCreatedEventBlob: z.boolean().optional(), +}); + +const WildcardFilterSchema = createRequestSchema()({ + value: WildcardFilterContentSchema, +}); + +const IdentifierFilterSchema: z.ZodType = z.union([ + createRequestSchema>()({ + Empty: EmptyObjectSchema, + }), + createRequestSchema>()({ + InterfaceFilter: InterfaceFilterSchema, + }), + createRequestSchema>()({ + TemplateFilter: TemplateFilterSchema, + }), + createRequestSchema>()({ + WildcardFilter: WildcardFilterSchema, }), ]); -const PartySignatureSchema = z.object({ - party: z.string(), - signatures: z.array( - z.object({ - signature: z.string(), - signedBy: z.string(), - format: z.string(), - signingAlgorithmSpec: z.string(), - }) - ), -}); - -/** Interactive submission execute request. */ -export const InteractiveSubmissionExecuteRequestSchema = z.object({ - userId: z.string(), - preparedTransaction: z.string(), - hashingSchemeVersion: z.string(), - submissionId: z.string(), +const CumulativeFilterSchema = createRequestSchema()({ + identifierFilter: IdentifierFilterSchema.optional(), +}); + +const FiltersSchema = createRequestSchema()({ + cumulative: z.array(CumulativeFilterSchema).optional(), +}); + +const EventFormatSchema = createRequestSchema()({ + filtersByParty: z.record(z.string(), FiltersSchema).optional(), + filtersForAnyParty: FiltersSchema.optional(), + verbose: z.boolean().optional(), +}); + +const TransactionFormatSchema = createRequestSchema()({ + eventFormat: EventFormatSchema, + transactionShape: z.enum(['TRANSACTION_SHAPE_ACS_DELTA', 'TRANSACTION_SHAPE_LEDGER_EFFECTS']), +}); + +const ExecuteRequestShape = { + preparedTransaction: LedgerNonEmptyBase64BytesSchema, + partySignatures: PartySignaturesSchema, deduplicationPeriod: DeduplicationPeriodSchema.optional(), - partySignatures: z.object({ - signatures: z.array(PartySignatureSchema), + submissionId: LedgerStringSchema, + userId: z.string().optional(), + hashingSchemeVersion: HashingSchemeVersionSchema, + minLedgerTime: MinLedgerTimeSchema.optional(), +} satisfies z.ZodRawShape; + +/** Execute a prepared and signed interactive submission. */ +export const InteractiveSubmissionExecuteRequestSchema = createRequestSchema()({ + ...ExecuteRequestShape, +}); + +/** Execute a prepared submission and wait for its completion. */ +export const InteractiveSubmissionExecuteAndWaitRequestSchema = + createRequestSchema()({ + ...ExecuteRequestShape, + }); + +/** Execute a prepared submission and wait for the resulting transaction. */ +export const InteractiveSubmissionExecuteAndWaitForTransactionRequestSchema = + createRequestSchema()({ + ...ExecuteRequestShape, + transactionFormat: TransactionFormatSchema.optional(), + }); + +const ProtoAnySchema = createRequestSchema()({ + typeUrl: z.string(), + value: LedgerBase64BytesSchema, + unknownFields: UnknownFieldSetSchema, + valueDecoded: RequiredJsonValueSchema.optional(), +}); + +const StatusSchema = createRequestSchema()({ + code: Int32Schema, + message: z.string(), + details: z.array(ProtoAnySchema).optional(), +}); + +const InterfaceViewSchema = createRequestSchema()({ + interfaceId: z.string(), + viewStatus: StatusSchema, + viewValue: RequiredJsonValueSchema.optional(), + implementationPackageId: nullableOptionalResponseField(z.string()), +}); + +const ArchivedEventSchema = createRequestSchema()({ + offset: PositiveInt64Schema, + nodeId: NonNegativeInt32Schema, + contractId: z.string(), + templateId: z.string(), + witnessParties: z.array(z.string()).min(1), + packageName: z.string(), + implementedInterfaces: z.array(z.string()).optional(), +}); + +const CreatedEventSchema = createRequestSchema()({ + offset: PositiveInt64Schema, + nodeId: NonNegativeInt32Schema, + contractId: z.string(), + templateId: z.string(), + contractKey: NullableOptionalJsonValueSchema, + contractKeyHash: LedgerBase64BytesSchema.optional(), + createArgument: RequiredJsonValueSchema, + createdEventBlob: LedgerBase64BytesSchema.optional(), + interfaceViews: z.array(InterfaceViewSchema).optional(), + witnessParties: z.array(z.string()).min(1), + signatories: z.array(z.string()).min(1), + observers: z.array(z.string()).optional(), + createdAt: LedgerRfc3339TimestampSchema, + packageName: z.string().min(1), + representativePackageId: z.string().min(1), + acsDelta: z.boolean(), +}); + +const ExercisedEventSchema = createRequestSchema()({ + offset: PositiveInt64Schema, + nodeId: NonNegativeInt32Schema, + contractId: z.string(), + templateId: z.string(), + interfaceId: nullableOptionalResponseField(z.string()), + choice: z.string(), + choiceArgument: RequiredJsonValueSchema, + actingParties: z.array(z.string()).min(1), + consuming: z.boolean(), + witnessParties: z.array(z.string()).min(1), + lastDescendantNodeId: NonNegativeInt32Schema, + exerciseResult: RequiredJsonValueSchema.optional(), + packageName: z.string(), + implementedInterfaces: z.array(z.string()).optional(), + acsDelta: z.boolean(), +}); + +const EventSchema: z.ZodType = z.union([ + createRequestSchema>()({ + ArchivedEvent: ArchivedEventSchema, }), + createRequestSchema>()({ + CreatedEvent: CreatedEventSchema, + }), + createRequestSchema>()({ + ExercisedEvent: ExercisedEventSchema, + }), +]); + +const TraceContextSchema = createRequestSchema()({ + traceparent: nullableOptionalResponseField(z.string()), + tracestate: nullableOptionalResponseField(z.string()), }); -/** Interactive submission execute response. */ -export const InteractiveSubmissionExecuteResponseSchema = z.object({}); +const ExternalTransactionHashHexSchema: z.ZodType = z + .string() + .regex(/^[0-9a-f]{64}$/, { + message: 'Expected a canonical lowercase 32-byte Daml-LF transaction hash', + }) + .transform( + (value): InteractiveSubmissionExternalTransactionHashHex => value as InteractiveSubmissionExternalTransactionHashHex + ); -// Export types -export type InteractiveSubmissionAllocatePartyRequest = z.infer; -export type InteractiveSubmissionAllocatePartyResponse = z.infer< - typeof InteractiveSubmissionAllocatePartyResponseSchema ->; -export type InteractiveSubmissionCreateUserRequest = z.infer; -export type InteractiveSubmissionCreateUserResponse = z.infer; -export type InteractiveSubmissionUploadDarRequest = z.infer; -export type InteractiveSubmissionUploadDarResponse = z.infer; -export type InteractiveSubmissionPrepareRequest = z.infer; -export type InteractiveSubmissionPrepareResponse = z.infer; -export type InteractiveSubmissionExecuteRequest = z.infer; -export type InteractiveSubmissionExecuteResponse = z.infer; -export type CostEstimation = z.infer; +const TransactionSchema = createRequestSchema()({ + updateId: z.string(), + commandId: z.string().optional(), + workflowId: z.string().optional(), + effectiveAt: LedgerRfc3339TimestampSchema, + events: z.array(EventSchema), + offset: PositiveInt64Schema, + synchronizerId: z.string().min(1), + traceContext: nullableOptionalResponseField(TraceContextSchema), + recordTime: LedgerRfc3339TimestampSchema, + externalTransactionHash: nullableOptionalResponseField(ExternalTransactionHashHexSchema), + paidTrafficCost: nullableOptionalResponseField(NonNegativeInt64Schema), +}); + +/** Exact response for asynchronous execute. */ +export const InteractiveSubmissionExecuteResponseSchema = createRequestSchema()( + {} +); + +/** Exact response for execute-and-wait. */ +export const InteractiveSubmissionExecuteAndWaitResponseSchema = + createRequestSchema()({ + updateId: z.string(), + completionOffset: PositiveInt64Schema, + }); + +/** Exact response for execute-and-wait-for-transaction. */ +export const InteractiveSubmissionExecuteAndWaitForTransactionResponseSchema = + createRequestSchema()({ + transaction: TransactionSchema, + }); + +export type CostEstimation = LedgerSchemas['CostEstimation']; diff --git a/src/clients/ledger-json-api/schemas/api/packages.ts b/src/clients/ledger-json-api/schemas/api/packages.ts index 6bcf1159..3af53a65 100644 --- a/src/clients/ledger-json-api/schemas/api/packages.ts +++ b/src/clients/ledger-json-api/schemas/api/packages.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { LedgerRfc3339TimestampSchema } from '../wire'; /** List packages response. */ export const ListPackagesResponseSchema = z.object({ @@ -16,54 +17,60 @@ export const GetPackageStatusResponseSchema = z.object({ }); /** Package reference details. */ -export const PackageReferenceSchema = z.object({ +export const PackageReferenceSchema = z.strictObject({ /** Package ID. */ - packageId: z.string(), + packageId: z.string().min(1), /** Package name. */ - packageName: z.string(), + packageName: z.string().min(1), /** Package version. */ - packageVersion: z.string(), + packageVersion: z.string().min(1), }); /** Package vetting requirement. */ -export const PackageVettingRequirementSchema = z.object({ +export const PackageVettingRequirementSchema = z.strictObject({ /** Parties whose vetting state should be considered. */ - parties: z.array(z.string()), + parties: z.array(z.string().min(1)).min(1), /** Package name for which to resolve the preferred package. */ - packageName: z.string(), + packageName: z.string().min(1), }); /** Package preference details. */ -export const PackagePreferenceSchema = z.object({ +export const PackagePreferenceSchema = z.strictObject({ /** Package reference. */ packageReference: PackageReferenceSchema, /** Synchronizer ID. */ - synchronizerId: z.string(), + synchronizerId: z.string().min(1), }); /** Get preferred package version request. */ -export const GetPreferredPackagesRequestSchema = z.object({ +export const GetPreferredPackagesRequestSchema = z.strictObject({ /** Package vetting requirements. */ - packageVettingRequirements: z.array(PackageVettingRequirementSchema), + packageVettingRequirements: z.array(PackageVettingRequirementSchema).min(1), /** Synchronizer ID (optional). */ - synchronizerId: z.string().optional(), + synchronizerId: z.string().min(1).optional(), /** Vetting valid at timestamp (optional). */ - vettingValidAt: z.string().optional(), + vettingValidAt: LedgerRfc3339TimestampSchema.optional(), }); /** Get preferred packages response. */ -export const GetPreferredPackagesResponseSchema = z.object({ +export const GetPreferredPackagesResponseSchema = z.strictObject({ /** Package references. */ - packageReferences: z.array(PackageReferenceSchema), + packageReferences: z.array(PackageReferenceSchema).min(1), /** Synchronizer ID. */ - synchronizerId: z.string(), + synchronizerId: z.string().min(1), }); /** Get preferred package version response. */ -export const GetPreferredPackageVersionResponseSchema = z.object({ - /** Package preference (optional). */ - packagePreference: PackagePreferenceSchema.optional(), -}); +export const GetPreferredPackageVersionResponseSchema = z + .strictObject({ + /** Package preference (optional). */ + packagePreference: PackagePreferenceSchema.nullish(), + }) + .transform((response) => + response.packagePreference === null || response.packagePreference === undefined + ? {} + : { packagePreference: response.packagePreference } + ); // Export types export type ListPackagesResponse = z.infer; diff --git a/src/clients/ledger-json-api/schemas/index.ts b/src/clients/ledger-json-api/schemas/index.ts index e488a659..baff3669 100644 --- a/src/clients/ledger-json-api/schemas/index.ts +++ b/src/clients/ledger-json-api/schemas/index.ts @@ -4,6 +4,9 @@ export * from './base'; // Common schemas export * from './common'; +// Strict Ledger JSON wire primitives +export * from './wire'; + // Operations schemas (TypeScript API types for end users) export * from './operations'; diff --git a/src/clients/ledger-json-api/schemas/operations/interactive-submission.ts b/src/clients/ledger-json-api/schemas/operations/interactive-submission.ts index 09a821a4..eade647e 100644 --- a/src/clients/ledger-json-api/schemas/operations/interactive-submission.ts +++ b/src/clients/ledger-json-api/schemas/operations/interactive-submission.ts @@ -1,96 +1,38 @@ import { z } from 'zod'; -import { DarFileSchema } from '../common'; -import { NonEmptyStringSchema } from './base'; - -/** Parameters for interactive submission allocate party. */ -export const InteractiveSubmissionAllocatePartyParamsSchema = z.object({ - /** Party identifier hint (optional). */ - partyIdHint: z.string().optional(), - /** Display name (optional). */ - displayName: z.string().optional(), - /** Is local party flag (optional). */ - isLocal: z.boolean().optional(), -}); - -/** Parameters for interactive submission create user. */ -export const InteractiveSubmissionCreateUserParamsSchema = z.object({ - /** User to create. */ - user: z.object({ - /** User identifier. */ - id: NonEmptyStringSchema, - /** Primary party for the user (optional). */ - primaryParty: z.string().optional(), - /** Whether the user is deactivated. */ - isDeactivated: z.boolean(), - /** User metadata (optional). */ - metadata: z - .object({ - /** Resource version for concurrent change detection. */ - resourceVersion: z.string(), - /** Annotations for the resource. */ - annotations: z.record(z.string(), z.string()), - }) - .optional(), - /** Identity provider ID (optional). */ - identityProviderId: z.string().optional(), - }), - /** Rights to assign to the user (optional). */ - rights: z - .array( - z.object({ - /** The kind of right. */ - kind: z.union([ - z.object({ CanActAs: z.object({ party: z.string() }) }), - z.object({ CanReadAs: z.object({ party: z.string() }) }), - z.object({ CanReadAsAnyParty: z.object({}) }), - z.object({ Empty: z.object({}) }), - z.object({ IdentityProviderAdmin: z.object({}) }), - z.object({ ParticipantAdmin: z.object({}) }), - ]), - }) - ) - .optional(), -}); - -/** Parameters for interactive submission upload DAR. */ -export const InteractiveSubmissionUploadDarParamsSchema = z.object({ - /** DAR file content as a binary Buffer. */ - darFile: DarFileSchema, -}); +import { LedgerRfc3339TimestampSchema } from '../wire'; /** Parameters for interactive submission get preferred package version. */ -export const InteractiveSubmissionGetPreferredPackageVersionParamsSchema = z.object({ +export const InteractiveSubmissionGetPreferredPackageVersionParamsSchema = z.strictObject({ /** Parties whose vetting state should be considered (optional). */ - parties: z.array(z.string()).optional(), + parties: z.array(z.string().min(1)).optional(), /** Package name for which to resolve the preferred package. */ - packageName: z.string(), + packageName: z.string().min(1), /** Vetting valid at timestamp (optional). */ - vettingValidAt: z.string().optional(), + vettingValidAt: LedgerRfc3339TimestampSchema.optional(), /** Synchronizer ID (optional). */ - synchronizerId: z.string().optional(), + synchronizerId: z.string().min(1).optional(), }); /** Parameters for interactive submission get preferred packages. */ -export const InteractiveSubmissionGetPreferredPackagesParamsSchema = z.object({ +export const InteractiveSubmissionGetPreferredPackagesParamsSchema = z.strictObject({ /** Package vetting requirements. */ - packageVettingRequirements: z.array( - z.object({ - /** Parties whose vetting state should be considered. */ - parties: z.array(z.string()), - /** Package name for which to resolve the preferred package. */ - packageName: z.string(), - }) - ), + packageVettingRequirements: z + .array( + z.strictObject({ + /** Parties whose vetting state should be considered. */ + parties: z.array(z.string().min(1)).min(1), + /** Package name for which to resolve the preferred package. */ + packageName: z.string().min(1), + }) + ) + .min(1), /** Synchronizer ID (optional). */ - synchronizerId: z.string().optional(), + synchronizerId: z.string().min(1).optional(), /** Vetting valid at timestamp (optional). */ - vettingValidAt: z.string().optional(), + vettingValidAt: LedgerRfc3339TimestampSchema.optional(), }); // Export types -export type InteractiveSubmissionAllocatePartyParams = z.infer; -export type InteractiveSubmissionCreateUserParams = z.infer; -export type InteractiveSubmissionUploadDarParams = z.infer; export type InteractiveSubmissionGetPreferredPackageVersionParams = z.infer< typeof InteractiveSubmissionGetPreferredPackageVersionParamsSchema >; diff --git a/src/clients/ledger-json-api/schemas/wire.ts b/src/clients/ledger-json-api/schemas/wire.ts new file mode 100644 index 00000000..056ccb8d --- /dev/null +++ b/src/clients/ledger-json-api/schemas/wire.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +/** Canonical padded Base64 used by protobuf JSON `bytes` fields. Base64url and non-canonical padding are rejected. */ +export const LedgerBase64BytesSchema = z.string().refine(isCanonicalStandardBase64, { + message: 'Expected canonical padded standard Base64', +}); + +/** Canonical Base64 for byte fields whose endpoint contract requires meaningful, non-empty bytes. */ +export const LedgerNonEmptyBase64BytesSchema = LedgerBase64BytesSchema.refine((value) => value.length > 0, { + message: 'Expected non-empty Base64 bytes', +}); + +/** RFC 3339 timestamp with an explicit UTC or numeric offset. */ +export const LedgerRfc3339TimestampSchema = z.iso + .datetime({ offset: true }) + .regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/, { + message: 'Expected an RFC 3339 timestamp with seconds and at most 9 fractional digits', + }); + +/** Canonical lowercase Canton SHA-256 multihash (`0x12 0x20` plus 32 digest bytes). */ +export const CantonSha256HashHexSchema = z.string().regex(/^1220[0-9a-f]{64}$/, { + message: 'Expected a canonical lowercase Canton SHA-256 hash', +}); + +/** Non-empty Daml-LF LedgerString (ASCII, at most 255 characters). */ +export const LedgerStringSchema = z + .string() + .min(1) + .max(255) + .regex(/^[A-Za-z0-9._:#/ \-]+$/, { message: 'Expected a valid Daml-LF LedgerString' }); + +/** Daml-LF Name used for choices (Java-like ASCII identifier, at most 1,000 characters). */ +export const LedgerNameSchema = z + .string() + .min(1) + .max(1_000) + .regex(/^[A-Za-z$_][A-Za-z0-9$_]*$/, { message: 'Expected a valid Daml-LF Name' }); + +function isCanonicalStandardBase64(value: string): boolean { + if (value.length === 0) return true; + if (value.length % 4 !== 0) return false; + if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return false; + + try { + return Buffer.from(value, 'base64').toString('base64') === value; + } catch { + return false; + } +} diff --git a/src/core/operations/ApiOperation.ts b/src/core/operations/ApiOperation.ts index f058e9ac..7fa967b7 100644 --- a/src/core/operations/ApiOperation.ts +++ b/src/core/operations/ApiOperation.ts @@ -7,10 +7,14 @@ import { type RequestConfig } from '../types'; import { type OperationExecuteOptions } from './operation-execute-options'; /** Abstract base class for API operations with parameter validation and request handling. */ -export abstract class ApiOperation { +export abstract class ApiOperation< + Params, + Response, + Options extends OperationExecuteOptions = OperationExecuteOptions, +> { constructor(public readonly client: BaseClient) {} - abstract execute(params: Params, options?: OperationExecuteOptions): Promise; + abstract execute(params: Params, options?: Options): Promise; public validateParams(params: T, schema: z.ZodSchema): T { try { @@ -25,6 +29,19 @@ export abstract class ApiOperation { } } + public validateResponse(response: T, schema: z.ZodSchema): T { + try { + return schema.parse(response); + } catch (error) { + if (error instanceof z.ZodError) { + throw new ValidationError( + `Response validation failed: ${error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join(', ')}` + ); + } + throw error; + } + } + public async makeGetRequest( url: string, config: RequestConfig = {}, diff --git a/src/core/operations/ApiOperationFactory.ts b/src/core/operations/ApiOperationFactory.ts index 38677f1f..af11bb17 100644 --- a/src/core/operations/ApiOperationFactory.ts +++ b/src/core/operations/ApiOperationFactory.ts @@ -1,11 +1,16 @@ import { z } from 'zod'; import { type BaseClient } from '../BaseClient'; -import { ConfigurationError } from '../errors'; +import { ConfigurationError, ValidationError } from '../errors'; import { awaitWithAbort } from '../http/abort'; -import { type HttpRequestOptionsForSemantics, type RequestSemantics } from '../http/request-retry'; +import { type DeepReadonly, type HttpRequestOptionsForSemantics, type RequestSemantics } from '../http/request-retry'; import { type RequestConfig } from '../types'; import { ApiOperation } from './ApiOperation'; -import { type OperationExecuteOptions, snapshotOperationExecuteOptions } from './operation-execute-options'; +import { + type OperationAttemptContext, + type OperationExecuteOptions, + type OperationExecuteOptionsWithoutExactBody, + snapshotOperationExecuteOptions, +} from './operation-execute-options'; import { createOperationHttpRequestOptions } from './operation-request-options'; /** @@ -79,6 +84,8 @@ interface ApiOperationConfigBase { readonly requestConfig?: RequestConfig; /** Transform the raw API response before returning it. */ readonly transformResponse?: (response: Response) => Response; + /** Validate the public response after any response transformation and outside the transport retry loop. */ + readonly responseSchema?: z.ZodSchema; } /** Configuration for a factory-created API operation. GET is read-only by construction. */ @@ -103,6 +110,16 @@ export type ApiOperationConfig = ApiOperationConfigBase = ApiOperationConfigBase & { + readonly method: 'POST' | 'DELETE' | 'PATCH'; + readonly requestSemantics?: 'mutation'; +}; + +/** Factory configuration for a mutation whose retry identifier must be unique across every request attempt. */ +export type FreshRetryApiOperationConfig = MutationApiOperationConfig & { + readonly getFreshRetryIdentifier: (params: DeepReadonly) => string; +}; + /** * Creates an {@link ApiOperation} class from a declarative configuration object. * @@ -115,12 +132,22 @@ export type ApiOperationConfig = ApiOperationConfigBase `${apiUrl}/v2/version`, * }); */ +export function createApiOperation( + config: FreshRetryApiOperationConfig +): new (client: BaseClient) => ApiOperation>; export function createApiOperation( config: ApiOperationConfig +): new (client: BaseClient) => ApiOperation; +export function createApiOperation( + config: ApiOperationConfig | FreshRetryApiOperationConfig ): new (client: BaseClient) => ApiOperation { return class extends ApiOperation { async execute(params: Params, options?: OperationExecuteOptions): Promise { - const operationOptions = snapshotOperationExecuteOptions(options); + const capturedOptions = snapshotOperationExecuteOptions(options); + const operationOptions = + 'getFreshRetryIdentifier' in config + ? guardFreshRetryIdentifiers(config.getFreshRetryIdentifier, capturedOptions) + : capturedOptions; const effectiveOperationOptions: OperationExecuteOptions = operationOptions ?? Object.freeze({}); // Validate parameters @@ -201,10 +228,45 @@ export function createApiOperation( // Transform response if needed if (config.transformResponse) { - return config.transformResponse(response); + response = config.transformResponse(response); } - return response; + return config.responseSchema ? this.validateResponse(response, config.responseSchema) : response; } }; } + +function guardFreshRetryIdentifiers( + getFreshRetryIdentifier: (params: DeepReadonly) => string, + options: Readonly> | undefined +): Readonly> | undefined { + const retry = options?.retry; + if (retry === undefined || retry.kind === 'none') return options; + if (retry.kind === 'exact-body') { + throw new ConfigurationError( + 'Operations with fresh retry identifiers cannot use exact-body retry; derive fresh request parameters instead' + ); + } + + const usedIdentifiers = new Set(); + const callerBeforeAttempt = retry.beforeAttempt; + const guardedRetry = Object.freeze({ + ...retry, + beforeAttempt: async (context: OperationAttemptContext): Promise => { + const identifier: unknown = getFreshRetryIdentifier(context.params); + if (typeof identifier !== 'string' || identifier.length === 0) { + throw new ConfigurationError('A fresh retry identifier resolver must return a non-empty string'); + } + if (usedIdentifiers.has(identifier)) { + throw new ValidationError('Retry attempt reused a fresh retry identifier; every attempt requires a new value'); + } + usedIdentifiers.add(identifier); + await callerBeforeAttempt?.(context); + }, + }); + + return Object.freeze({ + ...(options?.signal !== undefined ? { signal: options.signal } : {}), + retry: guardedRetry, + }); +} diff --git a/src/core/operations/operation-execute-options.ts b/src/core/operations/operation-execute-options.ts index d5f4a3ff..0b6a2baa 100644 --- a/src/core/operations/operation-execute-options.ts +++ b/src/core/operations/operation-execute-options.ts @@ -76,6 +76,11 @@ export interface OperationExecuteOptions { readonly retry?: RequestRetryStrategy; } +/** Execute options for mutation endpoints whose request identifier must change on every retry attempt. */ +export type OperationExecuteOptionsWithoutExactBody = Omit, 'retry'> & { + readonly retry?: NoRequestRetryStrategy | DerivedBodyRequestRetryStrategy; +}; + /** * Capture caller-owned execution options before an operation crosses an asynchronous boundary. * diff --git a/src/utils/amulet/external-party-transfer-offer.ts b/src/utils/amulet/external-party-transfer-offer.ts index 54b9e030..c343e479 100644 --- a/src/utils/amulet/external-party-transfer-offer.ts +++ b/src/utils/amulet/external-party-transfer-offer.ts @@ -12,6 +12,7 @@ import { import { executeExternalTransactionAndWait, type ExecuteExternalTransactionAndWaitResult, + type InteractiveSubmissionHashingSchemeVersion, } from '../external-signing/execute-external-transaction'; import { CANTON_ED25519_SIGNATURE_ALGORITHM, @@ -55,7 +56,7 @@ export interface PreparedExternalPartyTransferOfferAcceptance { readonly synchronizerId: string; readonly preparedTransaction: string; readonly preparedTransactionHashHex: string; - readonly hashingSchemeVersion: string; + readonly hashingSchemeVersion: InteractiveSubmissionHashingSchemeVersion; readonly offerDisclosure: TransferOfferDisclosure; readonly raw: Record; } @@ -69,7 +70,7 @@ export interface SubmitExternalPartyTransferOfferAcceptanceOptions { readonly preparedTransaction: string; readonly preparedTransactionHashHex: string; readonly signatureBase64: string; - readonly hashingSchemeVersion?: string; + readonly hashingSchemeVersion?: InteractiveSubmissionHashingSchemeVersion; readonly submissionId?: string; } @@ -145,7 +146,7 @@ export async function prepareExternalPartyTransferOfferAcceptance( readRequiredString(prepared, 'preparedTransactionHash', 'transfer-offer accept prepare'), 'transfer-offer accept prepare' ), - hashingSchemeVersion: readOptionalString(prepared, 'hashingSchemeVersion') ?? 'HASHING_SCHEME_VERSION_V2', + hashingSchemeVersion: prepared.hashingSchemeVersion, offerDisclosure, raw: objectOrEmpty(prepared), }; @@ -389,12 +390,6 @@ function validateRequiredString(name: string, value: string): void { } } -function readOptionalString(source: unknown, key: string): string | null { - if (!isRecord(source) || !(key in source)) return null; - const value = source[key]; - return typeof value === 'string' && value.trim() ? value : null; -} - function readFirstString( records: Record | ReadonlyArray>, keys: readonly string[] diff --git a/src/utils/external-signing/execute-external-transaction.ts b/src/utils/external-signing/execute-external-transaction.ts index 6c2fe432..c5184129 100644 --- a/src/utils/external-signing/execute-external-transaction.ts +++ b/src/utils/external-signing/execute-external-transaction.ts @@ -1,50 +1,63 @@ import { type LedgerJsonApiClient } from '../../clients/ledger-json-api'; import { + type InteractiveSubmissionExecuteAndWaitResponse, type InteractiveSubmissionExecuteRequest, type InteractiveSubmissionExecuteResponse, } from '../../clients/ledger-json-api/schemas/api/interactive-submission'; -import { objectOrEmpty, readRequiredString } from '../canton-response-utils'; +import { ValidationError } from '../../core/errors'; -export type PartySignature = InteractiveSubmissionExecuteRequest['partySignatures']['signatures'][number]; +type GeneratedPartySignature = InteractiveSubmissionExecuteRequest['partySignatures']['signatures'][number]; +type GeneratedSignature = GeneratedPartySignature['signatures'][number]; + +export type NonEmptyReadonlyArray = readonly [Value, ...Value[]]; +export type PartySignature = Omit & { + readonly signatures: NonEmptyReadonlyArray; +}; +export type NonEmptyPartySignatures = NonEmptyReadonlyArray; +export type InteractiveSubmissionHashingSchemeVersion = InteractiveSubmissionExecuteRequest['hashingSchemeVersion']; /** Immutable compatibility value. Use the factory below when building a submission request. */ export const DEFAULT_INTERACTIVE_SUBMISSION_DEDUPLICATION_PERIOD = Object.freeze({ DeduplicationDuration: Object.freeze({ - value: Object.freeze({ duration: '30s' }), + value: Object.freeze({ seconds: 30, nanos: 0 }), }), }) satisfies NonNullable; /** Returns an isolated default deduplication period for one interactive submission. */ export function createDefaultInteractiveSubmissionDeduplicationPeriod(): { - DeduplicationDuration: { value: { duration: string } }; + DeduplicationDuration: { value: { seconds: number; nanos: number } }; } { return { DeduplicationDuration: { - value: { duration: DEFAULT_INTERACTIVE_SUBMISSION_DEDUPLICATION_PERIOD.DeduplicationDuration.value.duration }, + value: { + seconds: DEFAULT_INTERACTIVE_SUBMISSION_DEDUPLICATION_PERIOD.DeduplicationDuration.value.seconds, + nanos: DEFAULT_INTERACTIVE_SUBMISSION_DEDUPLICATION_PERIOD.DeduplicationDuration.value.nanos, + }, }, } satisfies NonNullable; } export interface ExecuteExternalTransactionOptions { readonly ledgerClient: LedgerJsonApiClient; - readonly userId: string; + readonly userId?: string; readonly preparedTransaction: string; readonly submissionId: string; - readonly partySignatures: readonly PartySignature[]; - readonly hashingSchemeVersion?: string; + readonly partySignatures: NonEmptyPartySignatures; + readonly hashingSchemeVersion?: InteractiveSubmissionHashingSchemeVersion; readonly deduplicationPeriod?: InteractiveSubmissionExecuteRequest['deduplicationPeriod']; + readonly minLedgerTime?: InteractiveSubmissionExecuteRequest['minLedgerTime']; } -export interface ExecuteExternalTransactionAndWaitResult { - readonly updateId: string; - readonly raw: Record; -} +export type ExecuteExternalTransactionAndWaitResult = InteractiveSubmissionExecuteAndWaitResponse & { + /** Original validated Ledger response retained for existing helper composition. */ + readonly raw: InteractiveSubmissionExecuteAndWaitResponse; +}; /** * Executes an interactive submission after offline signing (`interactiveSubmissionExecute`). * * @param options - Prepared blob from {@link prepareExternalTransaction}, submission id, per-party signatures - * @returns Validator response payload from interactive submission execute + * @returns Empty success payload from the Ledger interactive submission execute endpoint */ export async function executeExternalTransaction( options: ExecuteExternalTransactionOptions @@ -61,32 +74,43 @@ export async function executeExternalTransaction( export async function executeExternalTransactionAndWait( options: ExecuteExternalTransactionOptions ): Promise { - const raw = await options.ledgerClient.makePostRequest( - `${options.ledgerClient.getApiUrl()}/v2/interactive-submission/executeAndWait`, - buildExecuteExternalTransactionRequest(options), - { - contentType: 'application/json', - includeBearerToken: true, - } + const response = await options.ledgerClient.interactiveSubmissionExecuteAndWait( + buildExecuteExternalTransactionRequest(options) ); - const updateId = readRequiredString(raw, 'updateId', 'interactive submission executeAndWait'); - return { - updateId, - raw: objectOrEmpty(raw), - }; + return { ...response, raw: response }; } function buildExecuteExternalTransactionRequest( options: ExecuteExternalTransactionOptions ): InteractiveSubmissionExecuteRequest { + const [firstPartySignature, ...remainingPartySignatures] = options.partySignatures; + const toRequestPartySignature = ({ party, signatures }: PartySignature): GeneratedPartySignature => ({ + party, + signatures: [...signatures], + }); + return { - userId: options.userId, preparedTransaction: options.preparedTransaction, - hashingSchemeVersion: options.hashingSchemeVersion ?? 'HASHING_SCHEME_VERSION_V2', + hashingSchemeVersion: normalizeHashingSchemeVersion(options.hashingSchemeVersion), submissionId: options.submissionId, deduplicationPeriod: options.deduplicationPeriod ?? createDefaultInteractiveSubmissionDeduplicationPeriod(), partySignatures: { - signatures: [...options.partySignatures], + signatures: [ + toRequestPartySignature(firstPartySignature), + ...remainingPartySignatures.map(toRequestPartySignature), + ], }, + ...(options.userId !== undefined ? { userId: options.userId } : {}), + ...(options.minLedgerTime !== undefined ? { minLedgerTime: options.minLedgerTime } : {}), }; } + +function normalizeHashingSchemeVersion( + value: InteractiveSubmissionHashingSchemeVersion | undefined +): InteractiveSubmissionHashingSchemeVersion { + const resolved: unknown = value ?? 'HASHING_SCHEME_VERSION_V2'; + if (resolved === 'HASHING_SCHEME_VERSION_V2' || resolved === 'HASHING_SCHEME_VERSION_V3') { + return resolved; + } + throw new ValidationError(`Unsupported interactive-submission hashing scheme: ${String(resolved)}`); +} diff --git a/src/utils/external-signing/external-party-wallet.ts b/src/utils/external-signing/external-party-wallet.ts index 2ac458ab..9e333a87 100644 --- a/src/utils/external-signing/external-party-wallet.ts +++ b/src/utils/external-signing/external-party-wallet.ts @@ -33,6 +33,7 @@ import { extractRawEd25519PublicKey, hashPreparedTransaction, } from './canton-protocol'; +import { type InteractiveSubmissionHashingSchemeVersion } from './execute-external-transaction'; import { getExternalPartyIdForHintAndPublicKey, listExternalPartyIdsForPublicKey, @@ -183,7 +184,7 @@ export interface PreparedExternalPartyWalletProviderTransfer { readonly synchronizerId: string; readonly preparedTransaction: string; readonly preparedTransactionHashHex: string; - readonly hashingSchemeVersion: string; + readonly hashingSchemeVersion: InteractiveSubmissionHashingSchemeVersion; readonly prepareToken: string; readonly sourceBalanceBefore: unknown; readonly sourceBalanceAfter: unknown | null; @@ -202,7 +203,7 @@ export interface SubmitExternalPartyWalletProviderTransferInput { readonly synchronizerId: string; readonly preparedTransaction: string; readonly preparedTransactionHashHex: string; - readonly hashingSchemeVersion?: string; + readonly hashingSchemeVersion?: InteractiveSubmissionHashingSchemeVersion; readonly prepareToken: string; readonly signatureBase64: string; readonly tokenContext?: ExternalPartyWalletTokenContext; @@ -916,7 +917,7 @@ export function buildProviderTransferAcceptPrepareTokenPayload(input: { readonly synchronizerId: string; readonly preparedTransaction: string; readonly preparedTransactionHashHex: string; - readonly hashingSchemeVersion: string; + readonly hashingSchemeVersion: InteractiveSubmissionHashingSchemeVersion; }): Record { return { kind: 'provider-transfer-accept', diff --git a/src/utils/external-signing/external-signing-client.ts b/src/utils/external-signing/external-signing-client.ts index cdd1477c..072dac21 100644 --- a/src/utils/external-signing/external-signing-client.ts +++ b/src/utils/external-signing/external-signing-client.ts @@ -24,6 +24,7 @@ import { executeExternalTransactionAndWait, type ExecuteExternalTransactionAndWaitResult, type ExecuteExternalTransactionOptions, + type InteractiveSubmissionHashingSchemeVersion, } from './execute-external-transaction'; import { reconcileExternalPartyAllocationFailure, @@ -262,7 +263,7 @@ export type ExternalTransactionResubmission = Omit< ExecuteExternalTransactionOptions, 'ledgerClient' | 'hashingSchemeVersion' | 'deduplicationPeriod' > & { - readonly hashingSchemeVersion: string; + readonly hashingSchemeVersion: InteractiveSubmissionHashingSchemeVersion; readonly deduplicationPeriod: NonNullable; }; @@ -274,7 +275,7 @@ export class ExternalTransactionSubmissionError extends Error { readonly submissionId: string; readonly preparedTransaction: string; readonly preparedTransactionHashHex: string; - readonly hashingSchemeVersion: string; + readonly hashingSchemeVersion: InteractiveSubmissionHashingSchemeVersion; readonly prepared: PrepareExternalTransactionResult; readonly resubmission: ExternalTransactionResubmission; readonly signingRequest: CantonEd25519SigningRequest; @@ -356,7 +357,7 @@ export async function executeExternalTransactionWithEd25519Signer( prepared.preparedTransactionHash, 'interactive submission prepare' ); - const hashingSchemeVersion = prepared.hashingSchemeVersion ?? 'HASHING_SCHEME_VERSION_V2'; + const { hashingSchemeVersion } = prepared; const signed = await signAndVerifyCantonEd25519Payload({ signer: options.signer, purpose: CantonEd25519SigningPurpose.INTERACTIVE_SUBMISSION, diff --git a/src/utils/external-signing/prepare-external-transaction.ts b/src/utils/external-signing/prepare-external-transaction.ts index 929abe6f..c69f0e7f 100644 --- a/src/utils/external-signing/prepare-external-transaction.ts +++ b/src/utils/external-signing/prepare-external-transaction.ts @@ -5,12 +5,19 @@ import { type InteractiveSubmissionPrepareResponse, } from '../../clients/ledger-json-api/schemas/api/interactive-submission'; +export type PrepareExternalTransactionCommand = InteractiveSubmissionPrepareRequest['commands'][number]; +/** Pinned interactive preparation accepts exactly one command. */ +export type NonEmptyPrepareExternalTransactionCommands = readonly [PrepareExternalTransactionCommand]; +export type NonEmptyActAsParties = readonly [string, ...string[]]; + export interface PrepareExternalTransactionOptions { readonly ledgerClient: LedgerJsonApiClient; - readonly commands: InteractiveSubmissionPrepareRequest['commands']; + readonly commands: NonEmptyPrepareExternalTransactionCommands; readonly userId: string; - readonly actAs: readonly string[]; + readonly actAs: NonEmptyActAsParties; readonly synchronizerId: string; + /** Hashing scheme to request. Omission preserves the pinned Ledger default (V2). */ + readonly hashingSchemeVersion?: NonNullable; readonly commandId?: string; readonly readAs?: readonly string[]; readonly disclosedContracts?: InteractiveSubmissionPrepareRequest['disclosedContracts']; @@ -25,19 +32,20 @@ export interface PrepareExternalTransactionResult extends InteractiveSubmissionP /** * Runs interactive submission **prepare** so payloads can be signed offline (`interactiveSubmissionPrepare`). * + * @example + * ```ts + * const prepared = await prepareExternalTransaction({ + * ledgerClient, + * commands: [{ ExerciseCommand: { … } }], + * userId, + * actAs: [partyId], + * synchronizerId, + * hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', + * }); + * ```; + * * @param options - Ledger client, commands, identity (`userId`, `actAs`), synchronizer scope, optional disclosures * @returns Prepared transaction blob plus echoed `commandId` - * - * @example - * ```ts - * const prepared = await prepareExternalTransaction({ - * ledgerClient, - * commands: [{ ExerciseCommand: { … } }], - * userId, - * actAs: [partyId], - * synchronizerId, - * }); - * ``` */ export async function prepareExternalTransaction( options: PrepareExternalTransactionOptions @@ -45,15 +53,16 @@ export async function prepareExternalTransaction( const commandId = options.commandId ?? randomUUID(); const response = await options.ledgerClient.interactiveSubmissionPrepare({ - commands: options.commands, + commands: [options.commands[0]], commandId, userId: options.userId, actAs: [...options.actAs], readAs: options.readAs ? [...options.readAs] : [], - disclosedContracts: options.disclosedContracts, synchronizerId: options.synchronizerId, + hashingSchemeVersion: options.hashingSchemeVersion ?? 'HASHING_SCHEME_VERSION_V2', verboseHashing: options.verboseHashing ?? false, packageIdSelectionPreference: options.packageIdSelectionPreference ?? [], + ...(options.disclosedContracts !== undefined ? { disclosedContracts: options.disclosedContracts } : {}), }); return { diff --git a/src/utils/traffic/estimate-traffic-cost.ts b/src/utils/traffic/estimate-traffic-cost.ts index 674a0ad6..6bda1426 100644 --- a/src/utils/traffic/estimate-traffic-cost.ts +++ b/src/utils/traffic/estimate-traffic-cost.ts @@ -16,6 +16,8 @@ export interface EstimateTrafficCostOptions { readonly commands: InteractiveSubmissionPrepareRequest['commands']; /** Synchronizer/domain ID where the transaction will be submitted. */ readonly synchronizerId: string; + /** Hashing scheme used to prepare the bytes whose traffic cost is estimated. */ + readonly hashingSchemeVersion: NonNullable; /** Parties to act as. Defaults to the ledger client's configured party. */ readonly actAs?: readonly string[]; /** Parties to read as. */ @@ -52,6 +54,7 @@ export interface EstimateTrafficCostOptions { * }, * ], * synchronizerId: 'global-domain::1234...', + * hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', * }); * * if (estimate) { @@ -71,6 +74,7 @@ export async function estimateTrafficCost( ledgerClient, commands, synchronizerId, + hashingSchemeVersion, actAs, readAs = [], userId, @@ -85,10 +89,12 @@ export async function estimateTrafficCost( } const resolvedPartyId = ledgerClient.getPartyId(); - const resolvedActAs = actAs ? [...actAs] : resolvedPartyId ? [resolvedPartyId] : undefined; - if (!resolvedActAs || resolvedActAs.length === 0) { + const resolvedActAsValues = actAs ? [...actAs] : resolvedPartyId ? [resolvedPartyId] : []; + const firstActAs = resolvedActAsValues[0]; + if (!firstActAs) { throw new Error('actAs is required: provide it in options or configure partyId on the ledger client'); } + const resolvedActAs: InteractiveSubmissionPrepareRequest['actAs'] = [firstActAs, ...resolvedActAsValues.slice(1)]; // Generate a temporary command ID for the prepare call const commandId = randomUUID(); @@ -100,10 +106,11 @@ export async function estimateTrafficCost( userId: resolvedUserId, actAs: resolvedActAs, readAs: [...readAs], - disclosedContracts, synchronizerId, + hashingSchemeVersion, verboseHashing: false, packageIdSelectionPreference, + ...(disclosedContracts !== undefined ? { disclosedContracts } : {}), }); // Extract and return the cost estimation diff --git a/src/utils/traffic/get-estimated-traffic-cost.ts b/src/utils/traffic/get-estimated-traffic-cost.ts index 018aabe4..7a5294ad 100644 --- a/src/utils/traffic/get-estimated-traffic-cost.ts +++ b/src/utils/traffic/get-estimated-traffic-cost.ts @@ -58,6 +58,6 @@ export function getEstimatedTrafficCost( totalCostWithOverhead, costInCents: calculateTrafficCostInCents(totalCostWithOverhead), costInDollars: calculateTrafficCostInDollars(totalCostWithOverhead), - ...(costEstimation.estimationTimestamp !== undefined && { estimatedAt: costEstimation.estimationTimestamp }), + estimatedAt: costEstimation.estimationTimestamp, }; } diff --git a/src/utils/traffic/types.ts b/src/utils/traffic/types.ts index 7f13b01c..89c8202d 100644 --- a/src/utils/traffic/types.ts +++ b/src/utils/traffic/types.ts @@ -24,7 +24,7 @@ export interface TrafficCostEstimate { /** Estimated cost in dollars (based on $60/MB pricing). */ readonly costInDollars: number; /** Timestamp when estimation was made (ISO 8601). */ - readonly estimatedAt?: string; + readonly estimatedAt: string; } /** Current traffic status for a participant/member. */ diff --git a/test/integration/localnet/ledger-api/interactive-submission.test.ts b/test/integration/localnet/ledger-api/interactive-submission.test.ts new file mode 100644 index 00000000..b02bf338 --- /dev/null +++ b/test/integration/localnet/ledger-api/interactive-submission.test.ts @@ -0,0 +1,348 @@ +/** End-to-end validation for Ledger JSON API interactive submission response formats. */ + +import { Keypair } from '@stellar/stellar-base'; +import { + ApiError, + CantonRuntime, + type LedgerJsonApiClient, + ValidatorApiClient, + createExternalPartyWithSigner, + preparedTransactionHashToHex, + signHexWithStellarKeypair, + signWithStellarKeypair, + stellarPublicKeyToBase64, + waitForCompletionWithMetadata, +} from '../../../../src'; +import type { InteractiveSubmissionExecuteRequest } from '../../../../src/clients/ledger-json-api/schemas/api/interactive-submission'; +import { buildIntegrationTestClientConfig, retry } from '../../../utils/testConfig'; +import { getClient } from './setup'; + +const WALLET_APP_INSTALL_TEMPLATE = '#splice-wallet:Splice.Wallet.Install:WalletAppInstall'; +const TRANSFER_PREAPPROVAL_PROPOSAL_TEMPLATE = + '#splice-wallet:Splice.Wallet.TransferPreapproval:TransferPreapprovalProposal'; +const TRANSFER_PREAPPROVAL_PROPOSAL_TEMPLATE_SUFFIX = 'Splice.Wallet.TransferPreapproval:TransferPreapprovalProposal'; + +interface PreparedSignedTransferPreapprovalProposal { + readonly request: InteractiveSubmissionExecuteRequest; + readonly preparedTransactionHashHex: string; + readonly expectedPayload: { + readonly receiver: string; + readonly provider: string; + readonly expectedDso: string; + }; +} + +async function resolveLedgerUserId(client: LedgerJsonApiClient, validatorUserName: string): Promise { + const configured = client.getUserId(); + if (configured) return configured; + + try { + return (await client.getAuthenticatedUser({})).user.id; + } catch { + return validatorUserName; + } +} + +async function resolveSynchronizerId(client: LedgerJsonApiClient, validatorParty: string): Promise { + const snapshot = await client.getActiveContracts({ + parties: [validatorParty], + templateIds: [WALLET_APP_INSTALL_TEMPLATE], + }); + for (const item of snapshot) { + const entry = item.contractEntry; + if ('JsActiveContract' in entry && entry.JsActiveContract.createdEvent.templateId.includes('WalletAppInstall')) { + return entry.JsActiveContract.synchronizerId; + } + } + throw new Error('Could not resolve the LocalNet synchronizer from the validator WalletAppInstall contract'); +} + +type InteractiveTransactionFormat = NonNullable< + Parameters[0]['transactionFormat'] +>; +type LookupTransactionFormat = Parameters[0]['transactionFormat']; +type InteractiveSubmissionTransaction = Awaited< + ReturnType +>['transaction']; +type LookupTransaction = Awaited>['transaction']; + +function interactiveTransactionFormatFor(partyId: string): InteractiveTransactionFormat { + return { + eventFormat: { + filtersByParty: { + [partyId]: { + cumulative: [ + { + identifierFilter: { + WildcardFilter: { value: { includeCreatedEventBlob: true } }, + }, + }, + ], + }, + }, + verbose: true, + }, + transactionShape: 'TRANSACTION_SHAPE_ACS_DELTA', + }; +} + +function lookupTransactionFormatFor(partyId: string): LookupTransactionFormat { + return { + eventFormat: { + filtersByParty: { + [partyId]: { + cumulative: [ + { + identifierFilter: { + WildcardFilter: { value: { includeCreatedEventBlob: true } }, + }, + }, + ], + }, + }, + verbose: true, + }, + transactionShape: 'TRANSACTION_SHAPE_ACS_DELTA', + }; +} + +function expectSubmittedTransferPreapprovalProposal( + transaction: InteractiveSubmissionTransaction | LookupTransaction, + prepared: PreparedSignedTransferPreapprovalProposal +): void { + expect(transaction.externalTransactionHash).toBe(prepared.preparedTransactionHashHex); + const createdEvent = transaction.events + .map((event) => ('CreatedEvent' in event ? event.CreatedEvent : undefined)) + .find((event) => event?.templateId.includes(TRANSFER_PREAPPROVAL_PROPOSAL_TEMPLATE_SUFFIX)); + expect(createdEvent).toBeDefined(); + expect(createdEvent?.createArgument).toEqual(prepared.expectedPayload); +} + +async function prepareSignedTransferPreapprovalProposal(options: { + readonly client: LedgerJsonApiClient; + readonly keypair: Keypair; + readonly dsoParty: string; + readonly externalParty: string; + readonly publicKeyFingerprint: string; + readonly validatorParty: string; + readonly userId: string; + readonly synchronizerId: string; + readonly packageId: string; + readonly label: string; +}): Promise { + const uniqueId = `${options.label}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const expectedPayload = { + receiver: options.externalParty, + provider: options.validatorParty, + expectedDso: options.dsoParty, + } as const; + let prepared: Awaited>; + try { + prepared = await options.client.interactiveSubmissionPrepare({ + userId: options.userId, + commandId: uniqueId, + commands: [ + { + CreateCommand: { + templateId: TRANSFER_PREAPPROVAL_PROPOSAL_TEMPLATE, + createArguments: expectedPayload, + }, + }, + ], + actAs: [options.externalParty], + synchronizerId: options.synchronizerId, + packageIdSelectionPreference: [options.packageId], + estimateTrafficCost: { disabled: true }, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + }); + } catch (error) { + if (error instanceof ApiError) { + throw new Error(`${error.message} [response body: ${JSON.stringify(error.response)}]`); + } + throw error; + } + expect(prepared).toEqual({ + preparedTransaction: expect.any(String) as string, + preparedTransactionHash: expect.any(String) as string, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + }); + const preparedTransactionHashHex = preparedTransactionHashToHex(prepared.preparedTransactionHash); + + return { + preparedTransactionHashHex, + expectedPayload, + request: { + userId: options.userId, + preparedTransaction: prepared.preparedTransaction, + partySignatures: { + signatures: [ + { + party: options.externalParty, + signatures: [ + { + format: 'SIGNATURE_FORMAT_RAW', + signature: signWithStellarKeypair(options.keypair, Buffer.from(preparedTransactionHashHex, 'hex')), + signedBy: options.publicKeyFingerprint, + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, + ], + }, + submissionId: `${uniqueId}-submission`, + hashingSchemeVersion: prepared.hashingSchemeVersion, + }, + }; +} + +describe('LedgerJsonApiClient / Interactive submission', () => { + test('externally signed Ed25519 submissions validate every execute variant end to end', async () => { + const client = getClient(); + const validatorClient = new ValidatorApiClient(new CantonRuntime(buildIntegrationTestClientConfig())); + const validatorInfo = await validatorClient.getValidatorUserInfo(); + const validatorParty = validatorInfo.party_id; + const validatorUserName = validatorInfo.user_name; + if (!validatorParty || !validatorUserName) { + throw new Error('getValidatorUserInfo did not return both party_id and user_name'); + } + client.setPartyId(validatorParty); + + const userId = await resolveLedgerUserId(client, validatorUserName); + const synchronizerId = await resolveSynchronizerId(client, validatorParty); + const { dso_party_id: dsoParty } = await validatorClient.getDsoPartyId(); + if (!dsoParty) { + throw new Error('getDsoPartyId returned an empty dso_party_id'); + } + const keypair = Keypair.random(); + const external = await createExternalPartyWithSigner({ + ledgerClient: client, + synchronizerId, + partyHint: `interactive-e2e-${Date.now()}`, + publicKeyBase64: stellarPublicKeyToBase64(keypair), + identityProviderId: '', + signMultiHash: ({ multiHashHex }) => ({ + signatureHex: signHexWithStellarKeypair(keypair, multiHashHex), + }), + }); + + await retry( + async () => { + const details = await client.getPartyDetails({ party: external.partyId, identityProviderId: '' }); + if (!details.partyDetails.some((party) => party.party === external.partyId)) { + throw new Error(`Party details did not include ${external.partyId}`); + } + }, + { timeoutMs: 120_000, description: 'external party visibility' } + ); + + await client.grantUserRights({ + userId, + rights: [{ kind: { CanActAs: { value: { party: external.partyId } } } }], + }); + + const preferredVersion = await client.interactiveSubmissionGetPreferredPackageVersion({ + packageName: 'splice-wallet', + parties: [external.partyId, validatorParty], + synchronizerId, + }); + const preferredPackages = await client.interactiveSubmissionGetPreferredPackages({ + packageVettingRequirements: [{ packageName: 'splice-wallet', parties: [external.partyId, validatorParty] }], + synchronizerId, + }); + const packageReference = preferredPackages.packageReferences[0]; + if (!packageReference) { + throw new Error('preferred-packages returned no splice-wallet reference'); + } + expect(packageReference.packageName).toBe('splice-wallet'); + expect(preferredVersion.packagePreference?.packageReference).toEqual(packageReference); + + const asyncPrepared = await prepareSignedTransferPreapprovalProposal({ + client, + keypair, + dsoParty, + externalParty: external.partyId, + publicKeyFingerprint: external.publicKeyFingerprint, + validatorParty, + userId, + synchronizerId, + packageId: packageReference.packageId, + label: 'interactive-async', + }); + const ledgerEnd = await client.getLedgerEnd({}); + if (ledgerEnd.offset === undefined) { + throw new Error('getLedgerEnd returned no offset'); + } + const asyncResult = await client.interactiveSubmissionExecute(asyncPrepared.request); + expect(asyncResult).toEqual({}); + const completion = await waitForCompletionWithMetadata(client, { + submissionId: asyncPrepared.request.submissionId, + partyId: external.partyId, + userId, + beginExclusive: ledgerEnd.offset, + timeoutMs: 120_000, + }); + const lookupTransactionFormat = lookupTransactionFormatFor(external.partyId); + const asyncTransaction = await client.getTransactionById({ + updateId: completion.updateId, + transactionFormat: lookupTransactionFormat, + }); + expect(asyncTransaction.transaction.updateId).toBe(completion.updateId); + expectSubmittedTransferPreapprovalProposal(asyncTransaction.transaction, asyncPrepared); + + const waitPrepared = await prepareSignedTransferPreapprovalProposal({ + client, + keypair, + dsoParty, + externalParty: external.partyId, + publicKeyFingerprint: external.publicKeyFingerprint, + validatorParty, + userId, + synchronizerId, + packageId: packageReference.packageId, + label: 'interactive-wait', + }); + const waitResult = await client.interactiveSubmissionExecuteAndWait(waitPrepared.request); + expect(waitResult.updateId).toMatch(/\S+/); + expect(waitResult.completionOffset).toBeGreaterThan(0); + const waitedTransaction = await client.getTransactionById({ + updateId: waitResult.updateId, + transactionFormat: lookupTransactionFormat, + }); + expect(waitedTransaction.transaction.updateId).toBe(waitResult.updateId); + expectSubmittedTransferPreapprovalProposal(waitedTransaction.transaction, waitPrepared); + + const transactionPrepared = await prepareSignedTransferPreapprovalProposal({ + client, + keypair, + dsoParty, + externalParty: external.partyId, + publicKeyFingerprint: external.publicKeyFingerprint, + validatorParty, + userId, + synchronizerId, + packageId: packageReference.packageId, + label: 'interactive-transaction', + }); + const transactionResult = await client.interactiveSubmissionExecuteAndWaitForTransaction({ + ...transactionPrepared.request, + transactionFormat: interactiveTransactionFormatFor(external.partyId), + }); + expect(transactionResult.transaction.updateId).toMatch(/\S+/); + expectSubmittedTransferPreapprovalProposal(transactionResult.transaction, transactionPrepared); + + expect( + new Set([ + asyncPrepared.request.submissionId, + waitPrepared.request.submissionId, + transactionPrepared.request.submissionId, + ]).size + ).toBe(3); + expect( + new Set([ + asyncPrepared.preparedTransactionHashHex, + waitPrepared.preparedTransactionHashHex, + transactionPrepared.preparedTransactionHashHex, + ]).size + ).toBe(3); + }, 600_000); +}); diff --git a/test/integration/localnet/ledger-api/setup.ts b/test/integration/localnet/ledger-api/setup.ts index 151ad155..62c7eaaf 100644 --- a/test/integration/localnet/ledger-api/setup.ts +++ b/test/integration/localnet/ledger-api/setup.ts @@ -1,9 +1,12 @@ /** Shared setup for LedgerJsonApiClient integration tests. */ import { CantonRuntime, LedgerJsonApiClient } from '../../../../src'; +import { EnvLoader } from '../../../../src/core/config/EnvLoader'; +import { ConfigurationError } from '../../../../src/core/errors'; import { buildIntegrationTestClientConfig } from '../../../utils/testConfig'; let client: LedgerJsonApiClient | null = null; +const WALLET_APP_INSTALL_TEMPLATE_SUFFIX = 'Splice.Wallet.Install:WalletAppInstall'; /** * Get the shared LedgerJsonApiClient instance for tests. Creates the client on first call, reuses it for subsequent @@ -16,3 +19,37 @@ export function getClient(): LedgerJsonApiClient { } return client; } + +/** Resolve the validator wallet-install contract from local configuration or the live active-contract snapshot. */ +export async function resolveWalletAppInstallContext( + ledgerClient: LedgerJsonApiClient, + partyId: string +): Promise<{ contractId: string; synchronizerId: string | undefined }> { + try { + const contractId = EnvLoader.getInstance().getValidatorWalletAppInstallContractId('localnet'); + return { contractId, synchronizerId: undefined }; + } catch (error) { + if (!(error instanceof ConfigurationError)) { + throw error; + } + const snapshot = await ledgerClient.getActiveContracts({ + parties: [partyId], + templateIds: [`#splice-wallet:${WALLET_APP_INSTALL_TEMPLATE_SUFFIX}`], + }); + for (const item of snapshot) { + const entry = item.contractEntry; + if ('JsActiveContract' in entry) { + const { contractId, templateId } = entry.JsActiveContract.createdEvent; + if (templateId.includes(WALLET_APP_INSTALL_TEMPLATE_SUFFIX)) { + return { + contractId, + synchronizerId: entry.JsActiveContract.synchronizerId, + }; + } + } + } + throw new Error( + 'Could not find WalletAppInstall contract: set CANTON_VALIDATOR_WALLET_APP_INSTALL_CONTRACT_ID_LOCALNET or ensure the validator party has WalletAppInstall on-ledger' + ); + } +} diff --git a/test/typecheck/ledger-interactive-submission.typecheck.ts b/test/typecheck/ledger-interactive-submission.typecheck.ts new file mode 100644 index 00000000..ea3635f0 --- /dev/null +++ b/test/typecheck/ledger-interactive-submission.typecheck.ts @@ -0,0 +1,547 @@ +import type { LedgerJsonApiClient } from '../../src/clients/ledger-json-api'; +import type { + InteractiveSubmissionCommand, + InteractiveSubmissionEvent, + InteractiveSubmissionExternalTransactionHashHex, + InteractiveSubmissionIdentifierFilter, + InteractiveSubmissionProtoAny, + InteractiveSubmissionSignature, + InteractiveSubmissionTraceContext, +} from '../../src/clients/ledger-json-api/schemas/api/interactive-submission'; +import type { + ExecuteExternalTransactionOptions, + NonEmptyPartySignatures, + PartySignature, +} from '../../src/utils/external-signing/execute-external-transaction'; +import type { + NonEmptyActAsParties, + NonEmptyPrepareExternalTransactionCommands, +} from '../../src/utils/external-signing/prepare-external-transaction'; +import type { EstimateTrafficCostOptions } from '../../src/utils/traffic/estimate-traffic-cost'; +import type { TrafficCostEstimate } from '../../src/utils/traffic/types'; + +declare const ledgerClient: LedgerJsonApiClient; + +type ExecuteAndWaitRequest = Parameters[0]; +type ExecuteRequest = Parameters[0]; +type ExecuteAndWaitResponse = Awaited>; +type PrepareRequest = Parameters[0]; +type PrepareResponse = Awaited>; +type ExecuteAndWaitForTransactionRequest = Parameters< + LedgerJsonApiClient['interactiveSubmissionExecuteAndWaitForTransaction'] +>[0]; +type ExecuteAndWaitForTransactionResponse = Awaited< + ReturnType +>; +type PrepareOptions = Parameters[1]; +type ExecuteOptions = Parameters[1]; +type ExecuteAndWaitOptions = Parameters[1]; +type ExecuteAndWaitForTransactionOptions = Parameters< + LedgerJsonApiClient['interactiveSubmissionExecuteAndWaitForTransaction'] +>[1]; +type PreferredPackageVersionRequest = Parameters< + LedgerJsonApiClient['interactiveSubmissionGetPreferredPackageVersion'] +>[0]; +type PreferredPackageVersionOptions = Parameters< + LedgerJsonApiClient['interactiveSubmissionGetPreferredPackageVersion'] +>[1]; +type PreferredPackagesRequest = Parameters[0]; +type PreferredPackagesOptions = Parameters[1]; +type PreferredPackagesResponse = Awaited>; +type PreferredPackageVersionResponse = Awaited< + ReturnType +>; + +const executeAndWaitRequest: ExecuteAndWaitRequest = { + preparedTransaction: 'prepared-transaction', + partySignatures: { + signatures: [ + { + party: 'party::fingerprint', + signatures: [ + { + format: 'SIGNATURE_FORMAT_RAW', + signature: 'signature', + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, + ], + }, + submissionId: 'submission-1', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', + deduplicationPeriod: { + DeduplicationOffset: { value: 42 }, + }, + minLedgerTime: { + time: { + MinLedgerTimeAbs: { value: '2026-07-09T12:00:00Z' }, + }, + }, +}; + +const invalidHelperHashingScheme: ExecuteExternalTransactionOptions = { + ledgerClient, + preparedTransaction: executeAndWaitRequest.preparedTransaction, + submissionId: executeAndWaitRequest.submissionId, + partySignatures: executeAndWaitRequest.partySignatures.signatures, + // @ts-expect-error External-transaction helpers accept only pinned V2 or V3 hashing schemes. + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_FUTURE', +}; + +const executeAndWaitForTransactionRequest: ExecuteAndWaitForTransactionRequest = { + ...executeAndWaitRequest, + transactionFormat: { + eventFormat: {}, + transactionShape: 'TRANSACTION_SHAPE_ACS_DELTA', + }, +}; + +const unspecifiedTransactionShape: ExecuteAndWaitForTransactionRequest = { + ...executeAndWaitRequest, + transactionFormat: { + eventFormat: {}, + // @ts-expect-error Canton rejects the unspecified transaction-shape sentinel. + transactionShape: 'TRANSACTION_SHAPE_UNSPECIFIED', + }, +}; + +const executeAndWaitResponse: ExecuteAndWaitResponse = { + updateId: 'update-1', + completionOffset: 123, +}; + +const prepareRequest: PrepareRequest = { + commandId: 'command-1', + commands: [{ CreateCommand: { templateId: 'pkg:Module:Template', createArguments: {} } }], + actAs: ['party::fingerprint'], + packageIdSelectionPreference: ['package-id-1'], + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', +}; + +const unspecifiedPrepareHashingScheme: PrepareRequest = { + ...prepareRequest, + // @ts-expect-error Canton rejects the unspecified sentinel for prepare requests. + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_UNSPECIFIED', +}; + +const unspecifiedCostHintSigningAlgorithm: PrepareRequest = { + ...prepareRequest, + estimateTrafficCost: { + expectedSignatures: [ + // @ts-expect-error Canton rejects the unspecified cost-hint signing-algorithm sentinel. + 'SIGNING_ALGORITHM_SPEC_UNSPECIFIED', + ], + }, +}; + +const prepareResponse: PrepareResponse = { + preparedTransaction: 'prepared-transaction', + preparedTransactionHash: 'prepared-hash', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', +}; + +void executeAndWaitForTransactionRequest; +void invalidHelperHashingScheme; +void unspecifiedTransactionShape; +void executeAndWaitResponse; +void prepareRequest; +void unspecifiedPrepareHashingScheme; +void unspecifiedCostHintSigningAlgorithm; +void prepareResponse; + +const invalidOffsetRequest: ExecuteAndWaitRequest = { + ...executeAndWaitRequest, + deduplicationPeriod: { + DeduplicationOffset: { + // @ts-expect-error Generated Ledger offsets are numeric. + value: '42', + }, + }, +}; + +const invalidHashingSchemeRequest: ExecuteAndWaitRequest = { + ...executeAndWaitRequest, + // @ts-expect-error Hashing scheme V1 is not part of the generated contract. + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V1', +}; + +const unspecifiedHashingSchemeRequest: ExecuteAndWaitRequest = { + ...executeAndWaitRequest, + // @ts-expect-error Canton rejects the unspecified sentinel for execute requests. + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_UNSPECIFIED', +}; + +const decodedProtoAny: InteractiveSubmissionProtoAny = { + typeUrl: 'type.googleapis.com/google.rpc.ErrorInfo', + value: 'encoded-protobuf', + unknownFields: { fields: {} }, + valueDecoded: { + reason: 'TEST_REASON', + metadata: { retryable: false, attempts: [1, null] }, + }, +}; + +const invalidDecodedProtoAny: InteractiveSubmissionProtoAny = { + typeUrl: 'type.googleapis.com/google.rpc.ErrorInfo', + value: 'encoded-protobuf', + unknownFields: { fields: {} }, + // @ts-expect-error Decoded protobuf details must remain lossless JSON values. + valueDecoded: { invalid: () => undefined }, +}; + +const invalidSignatureFormat: InteractiveSubmissionSignature = { + // @ts-expect-error Signature formats are the finite pinned Canton enum. + format: 'SIGNATURE_FORMAT_PEM', + signature: 'signature', + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', +}; + +const unspecifiedSignatureFormat: InteractiveSubmissionSignature = { + // @ts-expect-error Canton rejects the unspecified signature-format sentinel. + format: 'SIGNATURE_FORMAT_UNSPECIFIED', + signature: 'signature', + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', +}; + +const invalidSigningAlgorithm: InteractiveSubmissionSignature = { + format: 'SIGNATURE_FORMAT_RAW', + signature: 'signature', + signedBy: 'fingerprint', + // @ts-expect-error Signing algorithms are the finite pinned Canton enum. + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_RSA_SHA_256', +}; + +const unspecifiedSigningAlgorithm: InteractiveSubmissionSignature = { + format: 'SIGNATURE_FORMAT_RAW', + signature: 'signature', + signedBy: 'fingerprint', + // @ts-expect-error Canton rejects the unspecified signing-algorithm sentinel. + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_UNSPECIFIED', +}; + +type TransactionEvents = ExecuteAndWaitForTransactionResponse['transaction']['events']; +const emptyTransactionEvents: TransactionEvents = []; +type TransactionTraceContext = NonNullable; +const normalizedTraceContext: InteractiveSubmissionTraceContext = { + traceparent: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01', +}; +const normalizedTraceState: TransactionTraceContext['tracestate'] = 'vendor=value'; +// @ts-expect-error Wire null trace state is normalized to an omitted property for consumers. +const nullTraceState: TransactionTraceContext['tracestate'] = null; +type TransactionExternalHash = NonNullable< + ExecuteAndWaitForTransactionResponse['transaction']['externalTransactionHash'] +>; +declare const validatedExternalHash: TransactionExternalHash; +const typedExternalHash: InteractiveSubmissionExternalTransactionHashHex = validatedExternalHash; +const rawExternalHashString: string = typedExternalHash; +// @ts-expect-error Transaction hashes are validated and branded before they reach consumers. +const unvalidatedExternalHash: TransactionExternalHash = 'ab'.repeat(32); +type CreatedTransactionEvent = Extract['CreatedEvent']; +type ExercisedTransactionEvent = Extract['ExercisedEvent']; +type InterfaceView = NonNullable[number]; +// @ts-expect-error Wire null contract keys normalize to an absent property. +const nullContractKey: CreatedTransactionEvent['contractKey'] = null; +const nullInterfaceViewValue: InterfaceView['viewValue'] = null; +const createdEventArgument: CreatedTransactionEvent['createArgument'] = { owner: 'Alice', amount: 1 }; +const exerciseChoiceArgument: ExercisedTransactionEvent['choiceArgument'] = { archive: true }; +const exerciseResult: ExercisedTransactionEvent['exerciseResult'] = [null, 'result']; +// @ts-expect-error Created-event arguments are JSON values, not arbitrary objects. +const invalidCreatedEventArgument: CreatedTransactionEvent['createArgument'] = { invalid: () => undefined }; +// @ts-expect-error Exercise choice arguments are JSON values, not arbitrary objects. +const invalidExerciseChoiceArgument: ExercisedTransactionEvent['choiceArgument'] = { invalid: 1n }; +// @ts-expect-error Exercise results are JSON values when present. +const invalidExerciseResult: ExercisedTransactionEvent['exerciseResult'] = Symbol('invalid'); + +void normalizedTraceContext; +void normalizedTraceState; +void nullTraceState; +void rawExternalHashString; +void unvalidatedExternalHash; + +type PrepareCommand = PrepareRequest['commands'][number]; +type CreateArguments = Extract['CreateCommand']['createArguments']; +type ExerciseArguments = Extract['ExerciseCommand']['choiceArgument']; +type CreateAndExerciseArguments = Extract< + PrepareCommand, + { CreateAndExerciseCommand: unknown } +>['CreateAndExerciseCommand']; +type ExerciseByKeyArguments = Extract['ExerciseByKeyCommand']; +type PrefetchContractKey = NonNullable[number]['contractKey']; +const createArguments: CreateArguments = { nested: [true, null] }; +const exerciseArguments: ExerciseArguments = 'choice-argument'; +const createAndExerciseCreateArguments: CreateAndExerciseArguments['createArguments'] = 42; +const createAndExerciseChoiceArguments: CreateAndExerciseArguments['choiceArgument'] = { choice: 'value' }; +const exerciseByKeyContractKey: ExerciseByKeyArguments['contractKey'] = ['key', 1]; +const exerciseByKeyChoiceArgument: ExerciseByKeyArguments['choiceArgument'] = null; +const prefetchContractKey: PrefetchContractKey = { owner: 'Alice', key: [1, null] }; +// @ts-expect-error Prepare create arguments are JSON values. +const invalidCreateArguments: CreateArguments = { invalid: undefined }; +// @ts-expect-error Prepare exercise arguments are JSON values. +const invalidExerciseArguments: ExerciseArguments = { invalid: () => undefined }; +// @ts-expect-error Create-and-exercise create arguments are JSON values. +const invalidCreateAndExerciseCreateArguments: CreateAndExerciseArguments['createArguments'] = { invalid: Symbol() }; +// @ts-expect-error Create-and-exercise choice arguments are JSON values. +const invalidCreateAndExerciseChoiceArguments: CreateAndExerciseArguments['choiceArgument'] = { invalid: 1n }; +// @ts-expect-error Exercise-by-key contract keys are JSON values. +const invalidExerciseByKeyContractKey: ExerciseByKeyArguments['contractKey'] = { invalid: () => undefined }; +// @ts-expect-error Exercise-by-key choice arguments are JSON values. +const invalidExerciseByKeyChoiceArgument: ExerciseByKeyArguments['choiceArgument'] = { invalid: undefined }; +// @ts-expect-error Prefetched contract keys are JSON values. +const invalidPrefetchContractKey: PrefetchContractKey = { invalid: () => undefined }; + +declare const createCommand: Extract['CreateCommand']; +declare const exerciseCommand: Extract['ExerciseCommand']; +// @ts-expect-error Interactive commands contain exactly one command branch. +const invalidMultiBranchCommand: InteractiveSubmissionCommand = { + CreateCommand: createCommand, + ExerciseCommand: exerciseCommand, +}; + +type ExecuteDeduplicationPeriod = NonNullable; +// @ts-expect-error Deduplication periods contain exactly one branch. +const invalidMultiBranchDeduplication: ExecuteDeduplicationPeriod = { + DeduplicationOffset: { value: 42 }, + Empty: {}, +}; + +type ExecuteLedgerTime = NonNullable['time']>; +// @ts-expect-error Minimum ledger time contains exactly one branch. +const invalidMultiBranchTime: ExecuteLedgerTime = { + Empty: {}, + MinLedgerTimeAbs: { value: '2026-07-09T12:00:00Z' }, +}; + +// @ts-expect-error Identifier filters contain exactly one branch. +const invalidMultiBranchIdentifierFilter: InteractiveSubmissionIdentifierFilter = { + Empty: {}, + WildcardFilter: { value: {} }, +}; + +declare const archivedEvent: Extract['ArchivedEvent']; +declare const createdEvent: Extract['CreatedEvent']; +// @ts-expect-error Transaction events contain exactly one event branch. +const invalidMultiBranchEvent: InteractiveSubmissionEvent = { + ArchivedEvent: archivedEvent, + CreatedEvent: createdEvent, +}; + +void invalidOffsetRequest; +void invalidHashingSchemeRequest; +void unspecifiedHashingSchemeRequest; +void decodedProtoAny; +void invalidDecodedProtoAny; +void invalidSignatureFormat; +void unspecifiedSignatureFormat; +void invalidSigningAlgorithm; +void unspecifiedSigningAlgorithm; +void emptyTransactionEvents; +void nullContractKey; +void nullInterfaceViewValue; +void createdEventArgument; +void exerciseChoiceArgument; +void exerciseResult; +void invalidCreatedEventArgument; +void invalidExerciseChoiceArgument; +void invalidExerciseResult; +void createArguments; +void exerciseArguments; +void createAndExerciseCreateArguments; +void createAndExerciseChoiceArguments; +void exerciseByKeyContractKey; +void exerciseByKeyChoiceArgument; +void prefetchContractKey; +void invalidCreateArguments; +void invalidExerciseArguments; +void invalidCreateAndExerciseCreateArguments; +void invalidCreateAndExerciseChoiceArguments; +void invalidExerciseByKeyContractKey; +void invalidExerciseByKeyChoiceArgument; +void invalidPrefetchContractKey; +void invalidMultiBranchCommand; +void invalidMultiBranchDeduplication; +void invalidMultiBranchTime; +void invalidMultiBranchIdentifierFilter; +void invalidMultiBranchEvent; + +const invalidPackagePreference: PrepareRequest = { + ...prepareRequest, + packageIdSelectionPreference: [ + // @ts-expect-error Package preferences are package-id strings in the pinned contract. + { packageId: 'package-id-1' }, + ], +}; + +// @ts-expect-error The prepared transaction is required by the pinned response contract. +const incompletePrepareResponse: PrepareResponse = { + preparedTransactionHash: 'prepared-hash', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', +}; + +void invalidPackagePreference; +void incompletePrepareResponse; + +type Assert = Condition; +type IsRequired = {} extends Pick ? false : true; + +export type TrafficEstimateRequiresHashingScheme = Assert< + IsRequired +>; + +// These methods were invented locally and do not exist in the pinned Ledger API. +// @ts-expect-error Phantom allocate-party method was removed. +ledgerClient.interactiveSubmissionAllocateParty; +// @ts-expect-error Phantom create-user method was removed. +ledgerClient.interactiveSubmissionCreateUser; +// @ts-expect-error Phantom upload-DAR method was removed. +ledgerClient.interactiveSubmissionUploadDar; + +// The exact executeAndWait response has no fabricated raw response field. +// @ts-expect-error No raw field exists in ExecuteSubmissionAndWaitResponse. +executeAndWaitResponse.raw; + +// @ts-expect-error At least one per-party signature group is required by the helper. +const emptyPartySignatures: NonEmptyPartySignatures = []; + +// @ts-expect-error Each party signature group must contain at least one signature. +const partyWithoutSignatures: PartySignature = { party: 'party::fingerprint', signatures: [] }; + +type RawPartySignatureGroups = ExecuteRequest['partySignatures']['signatures']; +// @ts-expect-error The raw execute endpoint requires at least one party-signature group. +const emptyRawPartySignatureGroups: RawPartySignatureGroups = []; +type RawSignatures = RawPartySignatureGroups[number]['signatures']; +// @ts-expect-error Each raw party-signature group requires at least one signature. +const emptyRawSignatures: RawSignatures = []; + +// @ts-expect-error The raw prepare endpoint requires at least one command. +const emptyRawPrepareCommands: PrepareRequest['commands'] = []; +// @ts-expect-error Pinned Canton interactive preparation accepts exactly one command. +const multipleRawPrepareCommands: PrepareRequest['commands'] = [createCommand, createCommand]; +// @ts-expect-error The raw prepare endpoint requires at least one acting party. +const emptyRawActAs: PrepareRequest['actAs'] = []; + +// @ts-expect-error At least one prepare command is required by the helper. +const emptyPrepareCommands: NonEmptyPrepareExternalTransactionCommands = []; +// @ts-expect-error The helper preserves the pinned one-command interactive contract. +const multiplePrepareCommands: NonEmptyPrepareExternalTransactionCommands = [createCommand, createCommand]; + +// @ts-expect-error At least one actAs party is required by the helper. +const emptyActAsParties: NonEmptyActAsParties = []; + +declare const signal: AbortSignal; + +const prepareOptions: PrepareOptions = { + signal, + retry: { kind: 'exact-body', maxAttempts: 2 }, +}; +const executeOptions: ExecuteOptions = { + signal, + retry: { + kind: 'derived-body', + maxAttempts: 2, + deriveParams: ({ params }) => ({ ...params, submissionId: 'submission-2' }), + }, +}; +const executeAndWaitOptions: ExecuteAndWaitOptions = { + signal, + retry: { + kind: 'derived-body', + maxAttempts: 2, + deriveParams: ({ params }) => ({ ...params, submissionId: 'submission-2' }), + }, +}; +const executeAndWaitForTransactionOptions: ExecuteAndWaitForTransactionOptions = { + signal, + retry: { + kind: 'derived-body', + maxAttempts: 2, + deriveParams: ({ params }) => ({ ...params, submissionId: 'submission-2' }), + }, +}; + +const invalidExecuteExactBodyOptions: ExecuteOptions = { + // @ts-expect-error Execute retries must derive a fresh submission ID. + retry: { kind: 'exact-body', maxAttempts: 2 }, +}; +const invalidExecuteAndWaitExactBodyOptions: ExecuteAndWaitOptions = { + // @ts-expect-error Execute-and-wait retries must derive a fresh submission ID. + retry: { kind: 'exact-body', maxAttempts: 2 }, +}; +const invalidExecuteAndWaitForTransactionExactBodyOptions: ExecuteAndWaitForTransactionOptions = { + // @ts-expect-error Transaction-returning execute retries must derive a fresh submission ID. + retry: { kind: 'exact-body', maxAttempts: 2 }, +}; + +void ledgerClient.interactiveSubmissionPrepare(prepareRequest, prepareOptions); +void ledgerClient.interactiveSubmissionExecute(executeAndWaitRequest, executeOptions); +void ledgerClient.interactiveSubmissionExecuteAndWait(executeAndWaitRequest, executeAndWaitOptions); +void ledgerClient.interactiveSubmissionExecuteAndWaitForTransaction( + executeAndWaitForTransactionRequest, + executeAndWaitForTransactionOptions +); + +const preferredPackagesRequest: PreferredPackagesRequest = { + packageVettingRequirements: [{ packageName: 'quickstart-licensing', parties: ['party::fingerprint'] }], +}; +const preferredPackageVersionRequest: PreferredPackageVersionRequest = { + packageName: 'quickstart-licensing', + parties: ['party::fingerprint'], +}; +const preferredPackageVersionOptions: PreferredPackageVersionOptions = { + signal, + retry: { kind: 'none' }, +}; +const preferredPackagesOptions: PreferredPackagesOptions = { + signal, + retry: { + kind: 'derived-body', + maxAttempts: 2, + deriveParams: ({ params }) => params, + }, +}; +const preferredPackagesResponse: PreferredPackagesResponse = { + packageReferences: [{ packageId: 'package-id', packageName: 'quickstart-licensing', packageVersion: '1.0.0' }], + synchronizerId: 'synchronizer::id', +}; +const absentPreferredPackage: PreferredPackageVersionResponse = {}; +// @ts-expect-error Public responses normalize wire null into an absent optional property. +const wireNullPreferredPackage: PreferredPackageVersionResponse = { packagePreference: null }; + +void multipleRawPrepareCommands; +void multiplePrepareCommands; +void ledgerClient.interactiveSubmissionGetPreferredPackageVersion( + preferredPackageVersionRequest, + preferredPackageVersionOptions +); +void ledgerClient.interactiveSubmissionGetPreferredPackages(preferredPackagesRequest, preferredPackagesOptions); +void preferredPackagesRequest; +void preferredPackageVersionRequest; +void preferredPackagesResponse; +void absentPreferredPackage; +void wireNullPreferredPackage; + +// @ts-expect-error Every derived traffic-cost estimate includes its server estimation timestamp. +const trafficEstimateWithoutTimestamp: TrafficCostEstimate = { + requestCost: 100, + responseCost: 25, + totalCost: 125, + totalCostWithOverhead: 5_245, + costInCents: 1, + costInDollars: 0.01, +}; + +void emptyPartySignatures; +void partyWithoutSignatures; +void emptyRawPartySignatureGroups; +void emptyRawSignatures; +void emptyRawPrepareCommands; +void emptyRawActAs; +void emptyPrepareCommands; +void emptyActAsParties; +void trafficEstimateWithoutTimestamp; +void invalidExecuteExactBodyOptions; +void invalidExecuteAndWaitExactBodyOptions; +void invalidExecuteAndWaitForTransactionExactBodyOptions; diff --git a/test/unit/amulet/external-party-transfer-offer.test.ts b/test/unit/amulet/external-party-transfer-offer.test.ts index f64ef249..3ca453ba 100644 --- a/test/unit/amulet/external-party-transfer-offer.test.ts +++ b/test/unit/amulet/external-party-transfer-offer.test.ts @@ -54,8 +54,10 @@ const createMockLedgerClient = (): jest.Mocked => preparedTransactionHash: Buffer.from(PREPARED_TRANSACTION_HASH_HEX, 'hex').toString('base64'), hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', }), - getApiUrl: jest.fn().mockReturnValue('https://ledger.example.test'), - makePostRequest: jest.fn().mockResolvedValue({ updateId: 'update-123' }), + interactiveSubmissionExecuteAndWait: jest.fn().mockResolvedValue({ + updateId: 'update-123', + completionOffset: 456, + }), }) as unknown as jest.Mocked; const createMockValidatorClient = (): jest.Mocked => @@ -289,6 +291,7 @@ describe('external-party transfer-offer helpers', () => { readAs: [fixture.partyId], disclosedContracts: [offerContract], synchronizerId: offerContract.synchronizerId, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', verboseHashing: false, packageIdSelectionPreference: [], }); @@ -410,45 +413,37 @@ describe('external-party transfer-offer helpers', () => { submissionId: 'submission-123', }); - expect(ledgerClient.makePostRequest.mock.calls).toEqual([ - [ - 'https://ledger.example.test/v2/interactive-submission/executeAndWait', - { - userId: 'user-5n', - preparedTransaction: 'prepared-transaction-base64', - hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', - submissionId: 'submission-123', - deduplicationPeriod: { - DeduplicationDuration: { - value: { duration: '30s' }, - }, - }, - partySignatures: { + expect(ledgerClient.interactiveSubmissionExecuteAndWait).toHaveBeenCalledWith({ + userId: 'user-5n', + preparedTransaction: 'prepared-transaction-base64', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + submissionId: 'submission-123', + deduplicationPeriod: { + DeduplicationDuration: { + value: { seconds: 30, nanos: 0 }, + }, + }, + partySignatures: { + signatures: [ + { + party: fixture.partyId, signatures: [ { - party: fixture.partyId, - signatures: [ - { - signature: expect.any(String) as string, - signedBy: fixture.publicKeyFingerprint, - format: 'SIGNATURE_FORMAT_RAW', - signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', - }, - ], + signature: expect.any(String) as string, + signedBy: fixture.publicKeyFingerprint, + format: 'SIGNATURE_FORMAT_RAW', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', }, ], }, - }, - { - contentType: 'application/json', - includeBearerToken: true, - }, - ], - ]); + ], + }, + }); expect(submitted).toEqual({ acceptingPartyId: fixture.partyId, updateId: 'update-123', - raw: { updateId: 'update-123' }, + completionOffset: 456, + raw: { updateId: 'update-123', completionOffset: 456 }, }); }); @@ -469,6 +464,6 @@ describe('external-party transfer-offer helpers', () => { }) ).rejects.toThrow('Invalid Canton hash signature'); - expect(ledgerClient.makePostRequest.mock.calls).toHaveLength(0); + expect(ledgerClient.interactiveSubmissionExecuteAndWait).not.toHaveBeenCalled(); }); }); diff --git a/test/unit/clients/ledger-json-api-interactive-submission.test.ts b/test/unit/clients/ledger-json-api-interactive-submission.test.ts new file mode 100644 index 00000000..19c12bfe --- /dev/null +++ b/test/unit/clients/ledger-json-api-interactive-submission.test.ts @@ -0,0 +1,1402 @@ +import { LedgerJsonApiClient } from '../../../src/clients/ledger-json-api'; +import type { + InteractiveSubmissionExecuteAndWaitForTransactionRequest, + InteractiveSubmissionExecuteAndWaitRequest, + InteractiveSubmissionPrepareRequest, +} from '../../../src/clients/ledger-json-api/schemas/api/interactive-submission'; +import { CantonRuntime, type ClientConfig } from '../../../src/core'; + +const config: ClientConfig = { + network: 'localnet', + authUrl: 'https://auth.example', + apis: { + LEDGER_JSON_API: { + apiUrl: 'https://ledger.example.test', + auth: { + grantType: 'client_credentials', + clientId: 'ledger-client', + clientSecret: 'secret', + }, + }, + }, +}; + +const PREPARED_TRANSACTION_BASE64 = Buffer.from('prepared-transaction').toString('base64'); +const PREPARED_HASH_BASE64 = Buffer.from(`1220${'11'.repeat(32)}`, 'hex').toString('base64'); +const SIGNATURE_BASE64 = Buffer.alloc(64, 1).toString('base64'); +const PROTO_VALUE_BASE64 = Buffer.from('encoded-protobuf').toString('base64'); +const EXTERNAL_TRANSACTION_HASH = 'ab'.repeat(32); +const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; + +function createClient(): LedgerJsonApiClient { + return new LedgerJsonApiClient(new CantonRuntime(config)); +} + +function createExecuteAndWaitRequest(): InteractiveSubmissionExecuteAndWaitRequest { + return { + preparedTransaction: PREPARED_TRANSACTION_BASE64, + partySignatures: { + signatures: [ + { + party: 'party::fingerprint', + signatures: [ + { + format: 'SIGNATURE_FORMAT_RAW', + signature: SIGNATURE_BASE64, + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, + ], + }, + submissionId: 'submission-1', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', + deduplicationPeriod: { + DeduplicationOffset: { value: 42 }, + }, + minLedgerTime: { + time: { + MinLedgerTimeRel: { + value: { seconds: 5, nanos: 0 }, + }, + }, + }, + }; +} + +function createPrepareRequest(): InteractiveSubmissionPrepareRequest { + return { + commandId: 'command-1', + commands: [{ CreateCommand: { templateId: 'pkg:Module:Template', createArguments: { owner: 'Alice' } } }], + actAs: ['Alice::fingerprint'], + }; +} + +function createWireTransactionResponse(transactionOffset = 124, eventOffset = 124): Record { + return { + transaction: { + updateId: 'update-2', + effectiveAt: '2026-07-09T12:00:00Z', + events: [ + { + CreatedEvent: { + offset: eventOffset, + nodeId: 0, + contractId: 'contract-1', + templateId: 'pkg:Module:Template', + contractKey: null, + contractKeyHash: '', + createArgument: { owner: 'party::fingerprint' }, + createdEventBlob: '', + interfaceViews: [ + { + interfaceId: 'pkg:Module:Interface', + viewStatus: { + code: 0, + message: '', + details: [ + { + typeUrl: 'type.googleapis.com/google.rpc.ErrorInfo', + value: PROTO_VALUE_BASE64, + unknownFields: { fields: {} }, + valueDecoded: { + reason: 'TEST_REASON', + metadata: { + retryable: false, + attempts: [1, 2, null], + }, + }, + }, + ], + }, + viewValue: null, + implementationPackageId: null, + }, + ], + witnessParties: ['party::fingerprint'], + signatories: ['party::fingerprint'], + createdAt: '2026-07-09T12:00:00Z', + packageName: 'package-name', + representativePackageId: 'package-id', + acsDelta: true, + }, + }, + { + ExercisedEvent: { + offset: eventOffset, + nodeId: 1, + contractId: 'contract-2', + templateId: 'pkg:Module:Template', + interfaceId: null, + choice: 'Archive', + choiceArgument: {}, + actingParties: ['party::fingerprint'], + consuming: true, + witnessParties: ['party::fingerprint'], + lastDescendantNodeId: 1, + exerciseResult: {}, + packageName: 'package-name', + acsDelta: true, + }, + }, + ], + offset: transactionOffset, + synchronizerId: 'synchronizer::id', + traceContext: null, + recordTime: '2026-07-09T12:00:01Z', + externalTransactionHash: null, + paidTrafficCost: null, + }, + }; +} + +function firstProtoAny(response: Record): { value: string } { + const transaction = response['transaction'] as { events: unknown[] }; + const createdEvent = transaction.events[0] as { + CreatedEvent: { interfaceViews: Array<{ viewStatus: { details: Array<{ value: string }> } }> }; + }; + const interfaceView = createdEvent.CreatedEvent.interfaceViews[0]; + const detail = interfaceView?.viewStatus.details[0]; + if (!detail) throw new Error('Test fixture did not include a protobuf Any detail'); + return detail; +} + +const cyclicJsonValue: Record = {}; +cyclicJsonValue['self'] = cyclicJsonValue; + +describe('LedgerJsonApiClient interactive submission execution', () => { + it('posts and validates the exact current prepare contract, including V3 fields', async () => { + const client = createClient(); + const response = { + preparedTransaction: PREPARED_TRANSACTION_BASE64, + preparedTransactionHash: PREPARED_HASH_BASE64, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3' as const, + costEstimation: { + estimationTimestamp: '2026-07-09T12:00:00Z', + confirmationRequestTrafficCostEstimation: 100, + confirmationResponseTrafficCostEstimation: 25, + totalTrafficCostEstimation: 125, + }, + }; + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + const request: InteractiveSubmissionPrepareRequest = { + commandId: 'command-1', + commands: [{ CreateCommand: { templateId: 'pkg:Module:Template', createArguments: { owner: 'Alice' } } }], + minLedgerTime: { + time: { MinLedgerTimeAbs: { value: '2026-07-09T12:00:00Z' } }, + }, + actAs: ['Alice::fingerprint'], + packageIdSelectionPreference: ['package-id-1'], + prefetchContractKeys: [{ templateId: 'pkg:Module:Template', contractKey: { owner: 'Alice' }, limit: 1 }], + maxRecordTime: '2026-07-09T12:05:00Z', + estimateTrafficCost: { + expectedSignatures: ['SIGNING_ALGORITHM_SPEC_ED25519' as const], + }, + tapsMaxPasses: 2, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3' as const, + }; + + await expect(client.interactiveSubmissionPrepare(request)).resolves.toEqual(response); + + expect(post).toHaveBeenCalledWith( + 'https://ledger.example.test/v2/interactive-submission/prepare', + { ...request, synchronizerId: '' }, + { + contentType: 'application/json', + includeBearerToken: true, + }, + expect.objectContaining({ requestSemantics: 'read' }) + ); + }); + + it('accepts wire-null optional prepare fields and normalizes them to omitted properties', async () => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + preparedTransaction: PREPARED_TRANSACTION_BASE64, + preparedTransactionHash: PREPARED_HASH_BASE64, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + hashingDetails: null, + costEstimation: null, + }); + + const result = await client.interactiveSubmissionPrepare(createPrepareRequest()); + + expect(result).toEqual({ + preparedTransaction: PREPARED_TRANSACTION_BASE64, + preparedTransactionHash: PREPARED_HASH_BASE64, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + }); + expect(post.mock.calls[0]?.[1]).toEqual({ + ...createPrepareRequest(), + synchronizerId: '', + packageIdSelectionPreference: [], + }); + }); + + it('materializes the required empty expected-signatures wire default for cost-estimation hints', async () => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + preparedTransaction: PREPARED_TRANSACTION_BASE64, + preparedTransactionHash: PREPARED_HASH_BASE64, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + }); + const request: InteractiveSubmissionPrepareRequest = { + ...createPrepareRequest(), + estimateTrafficCost: { disabled: true }, + }; + + await client.interactiveSubmissionPrepare(request); + + expect(post.mock.calls[0]?.[1]).toEqual({ + ...request, + synchronizerId: '', + packageIdSelectionPreference: [], + estimateTrafficCost: { + disabled: true, + expectedSignatures: [], + }, + }); + }); + + it('snapshots nested Daml JSON values before asynchronous request construction', async () => { + const client = createClient(); + const createArguments = { owner: { name: 'Alice' } }; + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + preparedTransaction: PREPARED_TRANSACTION_BASE64, + preparedTransactionHash: PREPARED_HASH_BASE64, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + }); + + const pending = client.interactiveSubmissionPrepare({ + ...createPrepareRequest(), + commands: [{ CreateCommand: { templateId: 'pkg:Module:Template', createArguments } }], + }); + createArguments.owner.name = 'Mallory'; + await pending; + + expect(post.mock.calls[0]?.[1]).toHaveProperty('commands.0.CreateCommand.createArguments.owner.name', 'Alice'); + }); + + it('posts asynchronous execute to the exact route and validates the empty response', async () => { + const client = createClient(); + const response = {}; + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + const request = createExecuteAndWaitRequest(); + delete request.deduplicationPeriod; + + await expect(client.interactiveSubmissionExecute(request)).resolves.toEqual(response); + + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith( + 'https://ledger.example.test/v2/interactive-submission/execute', + { ...request, deduplicationPeriod: { Empty: {} } }, + { + contentType: 'application/json', + includeBearerToken: true, + } + ); + }); + + it('posts executeAndWait to the exact case-sensitive route and returns its typed response', async () => { + const client = createClient(); + const response = { updateId: 'update-1', completionOffset: 123 }; + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + const request = createExecuteAndWaitRequest(); + delete request.deduplicationPeriod; + + await expect(client.interactiveSubmissionExecuteAndWait(request)).resolves.toEqual(response); + + expect(post).toHaveBeenCalledWith( + 'https://ledger.example.test/v2/interactive-submission/executeAndWait', + { ...request, deduplicationPeriod: { Empty: {} } }, + { + contentType: 'application/json', + includeBearerToken: true, + } + ); + }); + + it('posts executeAndWaitForTransaction with a generated-contract transaction format', async () => { + const client = createClient(); + const response = { + transaction: { + updateId: 'update-2', + effectiveAt: '2026-07-09T12:00:00Z', + events: [ + { + ArchivedEvent: { + offset: 124, + nodeId: 0, + contractId: 'contract-1', + templateId: 'pkg:Module:Template', + witnessParties: ['party::fingerprint'], + packageName: 'package-name', + }, + }, + ], + offset: 124, + synchronizerId: 'synchronizer::id', + recordTime: '2026-07-09T12:00:01Z', + }, + }; + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + const request: InteractiveSubmissionExecuteAndWaitForTransactionRequest = { + ...createExecuteAndWaitRequest(), + transactionFormat: { + eventFormat: { + filtersByParty: { + 'party::fingerprint': { + cumulative: [ + { + identifierFilter: { + WildcardFilter: { + value: { includeCreatedEventBlob: true }, + }, + }, + }, + ], + }, + }, + verbose: true, + }, + transactionShape: 'TRANSACTION_SHAPE_ACS_DELTA', + }, + }; + delete request.deduplicationPeriod; + + await expect(client.interactiveSubmissionExecuteAndWaitForTransaction(request)).resolves.toEqual(response); + + expect(post).toHaveBeenCalledWith( + 'https://ledger.example.test/v2/interactive-submission/executeAndWaitForTransaction', + { ...request, deduplicationPeriod: { Empty: {} } }, + { + contentType: 'application/json', + includeBearerToken: true, + } + ); + }); + + it('preserves JSON null values while omitting wire-null optional metadata', async () => { + const client = createClient(); + jest.spyOn(client, 'makePostRequest').mockResolvedValue(createWireTransactionResponse()); + + const result = await client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()); + + expect(result.transaction).not.toHaveProperty('traceContext'); + expect(result.transaction).not.toHaveProperty('externalTransactionHash'); + expect(result.transaction).not.toHaveProperty('paidTrafficCost'); + expect(result.transaction.events[0]).not.toHaveProperty('CreatedEvent.contractKey'); + expect(result.transaction.events[0]).toHaveProperty('CreatedEvent.contractKeyHash', ''); + expect(result.transaction.events[0]).toHaveProperty('CreatedEvent.createdEventBlob', ''); + expect(result.transaction.events[0]).toHaveProperty('CreatedEvent.interfaceViews.0.viewValue', null); + expect(result.transaction.events[0]).toHaveProperty( + 'CreatedEvent.interfaceViews.0.viewStatus.details.0.valueDecoded', + { + reason: 'TEST_REASON', + metadata: { + retryable: false, + attempts: [1, 2, null], + }, + } + ); + expect(result.transaction.events[0]).not.toHaveProperty('CreatedEvent.interfaceViews.0.implementationPackageId'); + expect(result.transaction.events[1]).not.toHaveProperty('ExercisedEvent.interfaceId'); + }); + + it('accepts the live raw transaction hash and normalizes a wire-null trace state', async () => { + const client = createClient(); + const response = createWireTransactionResponse(); + const transaction = response['transaction'] as Record; + transaction['traceContext'] = { + traceparent: TRACEPARENT, + tracestate: null, + }; + transaction['externalTransactionHash'] = EXTERNAL_TRANSACTION_HASH; + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + const result = await client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()); + + expect(result.transaction.traceContext).toEqual({ traceparent: TRACEPARENT }); + expect(result.transaction.traceContext).not.toHaveProperty('tracestate'); + expect(result.transaction.externalTransactionHash).toBe(EXTERNAL_TRANSACTION_HASH); + }); + + it('omits JSON-valued transaction fields only when they are absent on the wire', async () => { + const client = createClient(); + const response = createWireTransactionResponse(); + const transaction = response['transaction'] as { events: unknown[] }; + const createdEvent = transaction.events[0] as { + CreatedEvent: { + contractKey?: unknown; + interfaceViews?: Array<{ viewValue?: unknown }>; + }; + }; + delete createdEvent.CreatedEvent.contractKey; + const interfaceView = createdEvent.CreatedEvent.interfaceViews?.[0]; + if (interfaceView) delete interfaceView.viewValue; + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + const result = await client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()); + + expect(result.transaction.events[0]).not.toHaveProperty('CreatedEvent.contractKey'); + expect(result.transaction.events[0]).not.toHaveProperty('CreatedEvent.interfaceViews.0.viewValue'); + }); + + it('normalizes explicit undefined optional fields before sending JSON', async () => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + updateId: 'update-3', + completionOffset: 125, + }); + const request = { + ...createExecuteAndWaitRequest(), + userId: undefined, + minLedgerTime: undefined, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest; + + await client.interactiveSubmissionExecuteAndWait(request); + + const sentBody = post.mock.calls[0]?.[1]; + expect(sentBody).not.toHaveProperty('userId'); + expect(sentBody).not.toHaveProperty('minLedgerTime'); + }); + + it('accepts zero as a deduplication offset', async () => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + updateId: 'update-3', + completionOffset: 1, + }); + const request = createExecuteAndWaitRequest(); + request.deduplicationPeriod = { DeduplicationOffset: { value: 0 } }; + + await client.interactiveSubmissionExecuteAndWait(request); + + expect(post).toHaveBeenCalled(); + }); + + it.each([ + ['maximum positive duration', { seconds: 315_576_000_000, nanos: 999_999_999 }], + ['maximum negative duration', { seconds: -315_576_000_000, nanos: -999_999_999 }], + ['negative nanos with zero seconds', { seconds: 0, nanos: -999_999_999 }], + ])('accepts the protobuf %s boundary', async (_description, duration) => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + updateId: 'update-duration', + completionOffset: 1, + }); + const request = createExecuteAndWaitRequest(); + request.minLedgerTime = { + time: { + MinLedgerTimeRel: { value: duration }, + }, + }; + + await client.interactiveSubmissionExecuteAndWait(request); + + expect(post).toHaveBeenCalled(); + }); + + it('accepts the largest safe numeric Ledger offset', async () => { + const client = createClient(); + jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + updateId: 'update-safe-int', + completionOffset: Number.MAX_SAFE_INTEGER, + }); + + await expect(client.interactiveSubmissionExecuteAndWait(createExecuteAndWaitRequest())).resolves.toEqual({ + updateId: 'update-safe-int', + completionOffset: Number.MAX_SAFE_INTEGER, + }); + }); + + it.each([ + ['missing update id', { completionOffset: 125 }], + ['non-integer completion offset', { updateId: 'update-3', completionOffset: 1.5 }], + ['zero completion offset', { updateId: 'update-3', completionOffset: 0 }], + ['unsafe completion offset', { updateId: 'update-3', completionOffset: Number.MAX_SAFE_INTEGER + 1 }], + ])('rejects an executeAndWait response with %s', async (_description, response) => { + const client = createClient(); + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + await expect(client.interactiveSubmissionExecuteAndWait(createExecuteAndWaitRequest())).rejects.toThrow( + 'Response validation failed' + ); + }); + + it('rejects fabricated fields in the asynchronous execute response', async () => { + const client = createClient(); + jest.spyOn(client, 'makePostRequest').mockResolvedValue({ updateId: 'not-in-contract' }); + + await expect(client.interactiveSubmissionExecute(createExecuteAndWaitRequest())).rejects.toThrow( + 'Response validation failed' + ); + }); + + it('rejects a transaction response missing required generated fields', async () => { + const client = createClient(); + jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + transaction: { + updateId: 'update-4', + effectiveAt: '2026-07-09T12:00:00Z', + events: [], + offset: 126, + recordTime: '2026-07-09T12:00:01Z', + }, + }); + + await expect( + client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()) + ).rejects.toThrow('Response validation failed'); + }); + + it('accepts a filtered transaction response with an empty event list', async () => { + const client = createClient(); + const response = createWireTransactionResponse() as { transaction: { events: unknown[] } }; + response.transaction.events = []; + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + const result = await client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()); + + expect(result.transaction.events).toEqual([]); + }); + + it('accepts empty protobuf Any bytes while preserving decoded JSON nulls', async () => { + const client = createClient(); + const response = createWireTransactionResponse(); + firstProtoAny(response).value = ''; + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + const result = await client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()); + + expect(result.transaction.events[0]).toHaveProperty('CreatedEvent.interfaceViews.0.viewStatus.details.0.value', ''); + }); + + it.each([ + ['created-event time', 'createdAt', '2026-07-09 12:00:00'], + ['created-event blob Base64', 'createdEventBlob', 'not base64!'], + ['contract-key hash Base64', 'contractKeyHash', 'not base64!'], + ])('rejects a transaction response with invalid %s', async (_description, field, value) => { + const client = createClient(); + const response = createWireTransactionResponse(); + const transaction = response['transaction'] as { events: unknown[] }; + const createdEvent = transaction.events[0] as { CreatedEvent: Record }; + createdEvent.CreatedEvent[field] = value; + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + await expect( + client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()) + ).rejects.toThrow('Response validation failed'); + }); + + it('rejects malformed protobuf Any Base64 in a transaction response', async () => { + const client = createClient(); + const response = createWireTransactionResponse(); + firstProtoAny(response).value = 'not base64!'; + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + await expect( + client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()) + ).rejects.toThrow('Response validation failed'); + }); + + it.each([ + ['zero transaction offset', createWireTransactionResponse(0, 124)], + ['zero event offset', createWireTransactionResponse(124, 0)], + ])('rejects a transaction response with %s', async (_description, response) => { + const client = createClient(); + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + await expect( + client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()) + ).rejects.toThrow('Response validation failed'); + }); + + it.each([ + ['effective time', 'effectiveAt'], + ['record time', 'recordTime'], + ])('rejects a transaction response with a non-RFC3339 %s', async (_description, field) => { + const client = createClient(); + const response = createWireTransactionResponse(); + const transaction = response['transaction'] as Record; + transaction[field] = '2026-07-09 12:00:00'; + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + await expect( + client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()) + ).rejects.toThrow('Response validation failed'); + }); + + it.each([ + ['a malformed hash', 'not-a-daml-lf-hash'], + ['a Canton multihash prefix', `1220${EXTERNAL_TRANSACTION_HASH}`], + ['a short hash', 'ab'.repeat(31)], + ['an uppercase hash', EXTERNAL_TRANSACTION_HASH.toUpperCase()], + ])('rejects a transaction response with %s', async (_description, externalTransactionHash) => { + const client = createClient(); + const response = createWireTransactionResponse(); + const transaction = response['transaction'] as Record; + transaction['externalTransactionHash'] = externalTransactionHash; + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + await expect( + client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()) + ).rejects.toThrow('Response validation failed'); + }); + + it.each([ + ['prepared transaction', { preparedTransaction: 'not base64!' }], + ['prepared transaction hash', { preparedTransactionHash: 'not base64!' }], + [ + 'cost-estimation timestamp', + { + costEstimation: { + estimationTimestamp: '2026-07-09 12:00:00', + confirmationRequestTrafficCostEstimation: 100, + confirmationResponseTrafficCostEstimation: 25, + totalTrafficCostEstimation: 125, + }, + }, + ], + ])('rejects a prepare response with an invalid %s', async (_description, override) => { + const client = createClient(); + jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + preparedTransaction: PREPARED_TRANSACTION_BASE64, + preparedTransactionHash: PREPARED_HASH_BASE64, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', + ...override, + }); + + await expect(client.interactiveSubmissionPrepare(createPrepareRequest())).rejects.toThrow( + 'Response validation failed' + ); + }); + + it('rejects a prepare response that omits required transaction data', async () => { + const client = createClient(); + jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + preparedTransactionHash: PREPARED_HASH_BASE64, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', + }); + + await expect( + client.interactiveSubmissionPrepare({ + commandId: 'command-2', + commands: [{ CreateCommand: { templateId: 'pkg:Module:Template', createArguments: {} } }], + actAs: ['Alice::fingerprint'], + }) + ).rejects.toThrow('Response validation failed'); + }); + + it.each([ + ['zero TAPS passes', 0], + ['negative TAPS passes', -1], + ['fractional TAPS passes', 1.5], + ['overflowing TAPS passes', 2_147_483_648], + ])('rejects prepare requests with %s', async (_description, tapsMaxPasses) => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest'); + + await expect(client.interactiveSubmissionPrepare({ ...createPrepareRequest(), tapsMaxPasses })).rejects.toThrow( + 'Parameter validation failed' + ); + expect(post).not.toHaveBeenCalled(); + }); + + it.each([ + ['zero prefetch limit', 0], + ['fractional prefetch limit', 1.5], + ['overflowing prefetch limit', 2_147_483_648], + ])('rejects prepare requests with %s', async (_description, limit) => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest'); + + await expect( + client.interactiveSubmissionPrepare({ + ...createPrepareRequest(), + prefetchContractKeys: [{ templateId: 'pkg:Module:Template', contractKey: { owner: 'Alice' }, limit }], + }) + ).rejects.toThrow('Parameter validation failed'); + expect(post).not.toHaveBeenCalled(); + }); + + it.each([ + ['an empty command list', { ...createPrepareRequest(), commands: [] }], + ['an empty command ID', { ...createPrepareRequest(), commandId: '' }], + ['an invalid command ID', { ...createPrepareRequest(), commandId: 'invalid@command' }], + [ + 'an empty template ID', + { + ...createPrepareRequest(), + commands: [{ CreateCommand: { templateId: '', createArguments: {} } }], + }, + ], + [ + 'an empty contract ID', + { + ...createPrepareRequest(), + commands: [ + { + ExerciseCommand: { + templateId: 'pkg:Module:Template', + contractId: '', + choice: 'Archive', + choiceArgument: {}, + }, + }, + ], + }, + ], + [ + 'an empty choice name', + { + ...createPrepareRequest(), + commands: [ + { + ExerciseCommand: { + templateId: 'pkg:Module:Template', + contractId: 'contract-id', + choice: '', + choiceArgument: {}, + }, + }, + ], + }, + ], + [ + 'an invalid choice name', + { + ...createPrepareRequest(), + commands: [ + { + ExerciseCommand: { + templateId: 'pkg:Module:Template', + contractId: 'contract-id', + choice: 'Archive-Now', + choiceArgument: {}, + }, + }, + ], + }, + ], + [ + 'more than one command', + { + ...createPrepareRequest(), + commands: [ + { CreateCommand: { templateId: 'pkg:Module:Template', createArguments: {} } }, + { CreateCommand: { templateId: 'pkg:Module:Template', createArguments: {} } }, + ], + }, + ], + ['an empty actAs list', { ...createPrepareRequest(), actAs: [] }], + [ + 'the unspecified hashing scheme sentinel', + { ...createPrepareRequest(), hashingSchemeVersion: 'HASHING_SCHEME_VERSION_UNSPECIFIED' }, + ], + [ + 'the unspecified cost-estimation signing algorithm', + { + ...createPrepareRequest(), + estimateTrafficCost: { expectedSignatures: ['SIGNING_ALGORITHM_SPEC_UNSPECIFIED'] }, + }, + ], + [ + 'a non-RFC3339 absolute minimum ledger time', + { + ...createPrepareRequest(), + minLedgerTime: { time: { MinLedgerTimeAbs: { value: '2026-07-09 12:00:00' } } }, + }, + ], + [ + 'an absolute minimum ledger time without seconds', + { + ...createPrepareRequest(), + minLedgerTime: { time: { MinLedgerTimeAbs: { value: '2026-07-09T12:00Z' } } }, + }, + ], + [ + 'an absolute minimum ledger time with excessive fractional precision', + { + ...createPrepareRequest(), + minLedgerTime: { time: { MinLedgerTimeAbs: { value: '2026-07-09T12:00:00.1234567890Z' } } }, + }, + ], + ['a non-RFC3339 maximum record time', { ...createPrepareRequest(), maxRecordTime: '2026-07-09 12:05:00' }], + [ + 'a malformed disclosed-contract blob', + { + ...createPrepareRequest(), + disclosedContracts: [{ createdEventBlob: 'not base64!' }], + }, + ], + ])('rejects prepare requests with %s', async (_description, request) => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest'); + + await expect( + client.interactiveSubmissionPrepare(request as unknown as InteractiveSubmissionPrepareRequest) + ).rejects.toThrow('Parameter validation failed'); + expect(post).not.toHaveBeenCalled(); + }); + + it('rejects lossy JSON values in transaction event payloads', async () => { + const client = createClient(); + const response = createWireTransactionResponse(); + const transaction = response['transaction'] as { events: unknown[] }; + const exercisedEvent = transaction.events[1] as { ExercisedEvent: { exerciseResult?: unknown } }; + exercisedEvent.ExercisedEvent.exerciseResult = -0; + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + await expect( + client.interactiveSubmissionExecuteAndWaitForTransaction(createExecuteAndWaitRequest()) + ).rejects.toThrow('Response validation failed'); + }); + + it.each([ + ['nested undefined', { nested: { invalid: undefined } }], + ['a bigint', { invalid: 1n }], + ['a function', { invalid: () => undefined }], + ['NaN', { invalid: Number.NaN }], + ['negative zero', -0], + ['a circular reference', cyclicJsonValue], + ])('rejects command arguments containing %s', async (_description, createArguments) => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest'); + + await expect( + client.interactiveSubmissionPrepare({ + ...createPrepareRequest(), + commands: [{ CreateCommand: { templateId: 'pkg:Module:Template', createArguments } }], + } as unknown as InteractiveSubmissionPrepareRequest) + ).rejects.toThrow('Parameter validation failed'); + expect(post).not.toHaveBeenCalled(); + }); + + it('rejects lossy JSON values in prefetched contract keys', async () => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest'); + + await expect( + client.interactiveSubmissionPrepare({ + ...createPrepareRequest(), + prefetchContractKeys: [ + { + templateId: 'pkg:Module:Template', + contractKey: { invalid: () => undefined }, + }, + ], + } as unknown as InteractiveSubmissionPrepareRequest) + ).rejects.toThrow('Parameter validation failed'); + expect(post).not.toHaveBeenCalled(); + }); + + it('rejects request one-of values containing multiple branches', async () => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest'); + + await expect( + client.interactiveSubmissionPrepare({ + ...createPrepareRequest(), + commands: [ + { + CreateCommand: { templateId: 'pkg:Module:Template', createArguments: {} }, + ExerciseCommand: { + templateId: 'pkg:Module:Template', + contractId: 'contract-id', + choice: 'Archive', + choiceArgument: {}, + }, + }, + ], + } as unknown as InteractiveSubmissionPrepareRequest) + ).rejects.toThrow('Parameter validation failed'); + await expect( + client.interactiveSubmissionExecuteAndWait({ + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { + DeduplicationOffset: { value: 42 }, + Empty: {}, + }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest) + ).rejects.toThrow('Parameter validation failed'); + expect(post).not.toHaveBeenCalled(); + }); + + it.each([ + [ + 'unknown top-level fields', + { ...createExecuteAndWaitRequest(), typo: true } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'string deduplication offsets', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationOffset: { value: '42' } }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'negative deduplication offsets', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationOffset: { value: -1 } }, + }, + ], + [ + 'fractional deduplication offsets', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationOffset: { value: 1.5 } }, + }, + ], + [ + 'unsafe deduplication offsets', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationOffset: { value: Number.MAX_SAFE_INTEGER + 1 } }, + }, + ], + [ + 'negative deduplication duration seconds', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationDuration: { value: { seconds: -1, nanos: 0 } } }, + }, + ], + [ + 'negative deduplication duration nanos', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationDuration: { value: { seconds: 0, nanos: -1 } } }, + }, + ], + [ + 'malformed prepared transaction Base64', + { + ...createExecuteAndWaitRequest(), + preparedTransaction: 'not base64!', + }, + ], + [ + 'base64url prepared transaction bytes', + { + ...createExecuteAndWaitRequest(), + preparedTransaction: '-_8=', + }, + ], + [ + 'unpadded prepared transaction Base64', + { + ...createExecuteAndWaitRequest(), + preparedTransaction: 'YWJjZA', + }, + ], + [ + 'noncanonical prepared transaction Base64 padding', + { + ...createExecuteAndWaitRequest(), + preparedTransaction: 'YQ====', + }, + ], + [ + 'malformed signature Base64', + { + ...createExecuteAndWaitRequest(), + partySignatures: { + signatures: [ + { + ...createExecuteAndWaitRequest().partySignatures.signatures[0], + signatures: [ + { + ...createExecuteAndWaitRequest().partySignatures.signatures[0].signatures[0], + signature: 'not base64!', + }, + ], + }, + ], + }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'a non-RFC3339 absolute minimum ledger time', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { time: { MinLedgerTimeAbs: { value: '2026-07-09 12:00:00' } } }, + }, + ], + [ + 'an empty submission ID', + { + ...createExecuteAndWaitRequest(), + submissionId: '', + }, + ], + [ + 'empty party signature collections', + { + ...createExecuteAndWaitRequest(), + partySignatures: { signatures: [] }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'empty signatures for one party', + { + ...createExecuteAndWaitRequest(), + partySignatures: { + signatures: [{ party: 'party::fingerprint', signatures: [] }], + }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'unknown nested signature fields', + { + ...createExecuteAndWaitRequest(), + partySignatures: { + signatures: [ + { + ...createExecuteAndWaitRequest().partySignatures.signatures[0], + typo: true, + }, + ], + }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'unknown signature formats', + { + ...createExecuteAndWaitRequest(), + partySignatures: { + signatures: [ + { + party: 'party::fingerprint', + signatures: [ + { + format: 'SIGNATURE_FORMAT_PEM', + signature: SIGNATURE_BASE64, + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, + ], + }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'the unspecified signature format sentinel', + { + ...createExecuteAndWaitRequest(), + partySignatures: { + signatures: [ + { + party: 'party::fingerprint', + signatures: [ + { + format: 'SIGNATURE_FORMAT_UNSPECIFIED', + signature: SIGNATURE_BASE64, + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, + ], + }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'unknown signing algorithms', + { + ...createExecuteAndWaitRequest(), + partySignatures: { + signatures: [ + { + party: 'party::fingerprint', + signatures: [ + { + format: 'SIGNATURE_FORMAT_RAW', + signature: SIGNATURE_BASE64, + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_RSA_SHA_256', + }, + ], + }, + ], + }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'the unspecified signing algorithm sentinel', + { + ...createExecuteAndWaitRequest(), + partySignatures: { + signatures: [ + { + party: 'party::fingerprint', + signatures: [ + { + format: 'SIGNATURE_FORMAT_RAW', + signature: SIGNATURE_BASE64, + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_UNSPECIFIED', + }, + ], + }, + ], + }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'the unspecified hashing scheme sentinel', + { + ...createExecuteAndWaitRequest(), + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_UNSPECIFIED', + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'duration seconds above the protobuf maximum', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { + time: { MinLedgerTimeRel: { value: { seconds: 315_576_000_001, nanos: 0 } } }, + }, + }, + ], + [ + 'duration seconds below the protobuf minimum', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { + time: { MinLedgerTimeRel: { value: { seconds: -315_576_000_001, nanos: 0 } } }, + }, + }, + ], + [ + 'duration nanos above the protobuf maximum', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { + time: { MinLedgerTimeRel: { value: { seconds: 0, nanos: 1_000_000_000 } } }, + }, + }, + ], + [ + 'duration nanos below the protobuf minimum', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { + time: { MinLedgerTimeRel: { value: { seconds: 0, nanos: -1_000_000_000 } } }, + }, + }, + ], + [ + 'a positive duration with negative nanos', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { + time: { MinLedgerTimeRel: { value: { seconds: 1, nanos: -1 } } }, + }, + }, + ], + [ + 'a negative duration with positive nanos', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { + time: { MinLedgerTimeRel: { value: { seconds: -1, nanos: 1 } } }, + }, + }, + ], + [ + 'unsafe protobuf int64 values', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { + time: { + MinLedgerTimeRel: { + value: { + seconds: 5, + nanos: 0, + unknownFields: { fields: { '1': { varint: [Number.MAX_SAFE_INTEGER + 1] } } }, + }, + }, + }, + }, + }, + ], + [ + 'fractional protobuf unknown-field integers', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { + time: { + MinLedgerTimeRel: { + value: { + seconds: 5, + nanos: 0, + unknownFields: { fields: { '1': { varint: [1.5] } } }, + }, + }, + }, + }, + }, + ], + [ + 'out-of-range protobuf fixed32 values', + { + ...createExecuteAndWaitRequest(), + minLedgerTime: { + time: { + MinLedgerTimeRel: { + value: { + seconds: 5, + nanos: 0, + unknownFields: { fields: { '1': { fixed32: [2_147_483_648] } } }, + }, + }, + }, + }, + }, + ], + ])('rejects %s before making a request', async (_description, request) => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest'); + + await expect(client.interactiveSubmissionExecuteAndWait(request)).rejects.toThrow('Parameter validation failed'); + expect(post).not.toHaveBeenCalled(); + }); + + it('rejects the unspecified transaction-shape sentinel before making a request', async () => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest'); + + await expect( + client.interactiveSubmissionExecuteAndWaitForTransaction({ + ...createExecuteAndWaitRequest(), + transactionFormat: { + eventFormat: {}, + transactionShape: 'TRANSACTION_SHAPE_UNSPECIFIED', + }, + } as unknown as InteractiveSubmissionExecuteAndWaitForTransactionRequest) + ).rejects.toThrow('Parameter validation failed'); + expect(post).not.toHaveBeenCalled(); + }); + + it('uses the exact preferred-package-version URL and normalizes a wire-null preference', async () => { + const client = createClient(); + const get = jest.spyOn(client, 'makeGetRequest').mockResolvedValue({ packagePreference: null }); + + await expect( + client.interactiveSubmissionGetPreferredPackageVersion({ + packageName: 'quickstart-licensing', + parties: ['Alice::fingerprint', 'Bob::fingerprint'], + synchronizerId: 'synchronizer::id', + vettingValidAt: '2026-07-09T12:00:00Z', + }) + ).resolves.toEqual({}); + + const requestedUrl = get.mock.calls[0]?.[0]; + expect(requestedUrl).toBe( + 'https://ledger.example.test/v2/interactive-submission/preferred-package-version?parties=Alice%3A%3Afingerprint&parties=Bob%3A%3Afingerprint&package-name=quickstart-licensing&vetting_valid_at=2026-07-09T12%3A00%3A00Z&synchronizer-id=synchronizer%3A%3Aid' + ); + }); + + it.each([ + ['an empty package name', { packageName: '' }], + ['an empty party', { packageName: 'quickstart-licensing', parties: [''] }], + ['a non-RFC3339 vetting time', { packageName: 'quickstart-licensing', vettingValidAt: '2026-07-09 12:00:00' }], + ])('rejects preferred-package-version requests with %s', async (_description, request) => { + const client = createClient(); + const get = jest.spyOn(client, 'makeGetRequest'); + + await expect(client.interactiveSubmissionGetPreferredPackageVersion(request)).rejects.toThrow( + 'Parameter validation failed' + ); + expect(get).not.toHaveBeenCalled(); + }); + + it('posts and validates exact non-empty preferred-package formats', async () => { + const client = createClient(); + const response = { + packageReferences: [{ packageId: 'package-id', packageName: 'quickstart-licensing', packageVersion: '1.0.0' }], + synchronizerId: 'synchronizer::id', + }; + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + const request = { + packageVettingRequirements: [{ packageName: 'quickstart-licensing', parties: ['Alice::fingerprint'] }], + synchronizerId: 'synchronizer::id', + }; + + await expect(client.interactiveSubmissionGetPreferredPackages(request)).resolves.toEqual(response); + + expect(post.mock.calls[0]?.[0]).toBe('https://ledger.example.test/v2/interactive-submission/preferred-packages'); + expect(post.mock.calls[0]?.[1]).toEqual(request); + }); + + it.each([ + ['no package requirements', { packageVettingRequirements: [] }], + [ + 'a requirement with no parties', + { packageVettingRequirements: [{ packageName: 'quickstart-licensing', parties: [] }] }, + ], + ['an empty package name', { packageVettingRequirements: [{ packageName: '', parties: ['Alice'] }] }], + ['an empty party', { packageVettingRequirements: [{ packageName: 'quickstart-licensing', parties: [''] }] }], + [ + 'a non-RFC3339 vetting time', + { + packageVettingRequirements: [{ packageName: 'quickstart-licensing', parties: ['Alice'] }], + vettingValidAt: '2026-07-09 12:00:00', + }, + ], + ])('rejects preferred-package requests with %s', async (_description, request) => { + const client = createClient(); + const post = jest.spyOn(client, 'makePostRequest'); + + await expect(client.interactiveSubmissionGetPreferredPackages(request)).rejects.toThrow( + 'Parameter validation failed' + ); + expect(post).not.toHaveBeenCalled(); + }); + + it.each([ + ['an empty package reference list', { packageReferences: [], synchronizerId: 'synchronizer::id' }], + [ + 'an incomplete package reference', + { + packageReferences: [{ packageId: 'package-id', packageName: 'quickstart-licensing' }], + synchronizerId: 'synchronizer::id', + }, + ], + [ + 'an empty package ID', + { + packageReferences: [{ packageId: '', packageName: 'quickstart-licensing', packageVersion: '1.0.0' }], + synchronizerId: 'synchronizer::id', + }, + ], + [ + 'an empty synchronizer ID', + { + packageReferences: [{ packageId: 'package-id', packageName: 'quickstart-licensing', packageVersion: '1.0.0' }], + synchronizerId: '', + }, + ], + [ + 'an unknown response property', + { + packageReferences: [{ packageId: 'package-id', packageName: 'quickstart-licensing', packageVersion: '1.0.0' }], + synchronizerId: 'synchronizer::id', + typo: true, + }, + ], + ])('rejects preferred-package responses with %s', async (_description, response) => { + const client = createClient(); + jest.spyOn(client, 'makePostRequest').mockResolvedValue(response); + + await expect( + client.interactiveSubmissionGetPreferredPackages({ + packageVettingRequirements: [{ packageName: 'quickstart-licensing', parties: ['Alice::fingerprint'] }], + }) + ).rejects.toThrow('Response validation failed'); + }); +}); diff --git a/test/unit/external-signing/execute-external-transaction.test.ts b/test/unit/external-signing/execute-external-transaction.test.ts index 78e873c9..65b245dc 100644 --- a/test/unit/external-signing/execute-external-transaction.test.ts +++ b/test/unit/external-signing/execute-external-transaction.test.ts @@ -4,17 +4,30 @@ import { DEFAULT_INTERACTIVE_SUBMISSION_DEDUPLICATION_PERIOD, executeExternalTransaction, executeExternalTransactionAndWait, + type NonEmptyPartySignatures, } from '../../../src/utils/external-signing/execute-external-transaction'; +const PARTY_SIGNATURES = [ + { + party: 'party::fingerprint', + signatures: [ + { + format: 'SIGNATURE_FORMAT_RAW', + signature: 'sig-base64', + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, +] satisfies NonEmptyPartySignatures; +const HASHING_SCHEME_VERSION = 'HASHING_SCHEME_VERSION_V2' as const; + const createMockLedgerClient = (): jest.Mocked => ({ - interactiveSubmissionExecute: jest.fn().mockResolvedValue({ - updateId: 'update-123', - completionOffset: 'offset-456', - }), - getApiUrl: jest.fn().mockReturnValue('https://ledger.example.test'), - makePostRequest: jest.fn().mockResolvedValue({ + interactiveSubmissionExecute: jest.fn().mockResolvedValue({}), + interactiveSubmissionExecuteAndWait: jest.fn().mockResolvedValue({ updateId: 'update-wait-123', + completionOffset: 456, }), }) as unknown as jest.Mocked; @@ -32,9 +45,9 @@ describe('executeExternalTransaction', () => { expect(first).toEqual(second); expect(first).not.toBe(second); expect(first.DeduplicationDuration).not.toBe(second.DeduplicationDuration); - first.DeduplicationDuration.value.duration = '60s'; - expect(second.DeduplicationDuration.value.duration).toBe('30s'); - expect(DEFAULT_INTERACTIVE_SUBMISSION_DEDUPLICATION_PERIOD.DeduplicationDuration.value.duration).toBe('30s'); + first.DeduplicationDuration.value.seconds = 60; + expect(second.DeduplicationDuration.value.seconds).toBe(30); + expect(DEFAULT_INTERACTIVE_SUBMISSION_DEDUPLICATION_PERIOD.DeduplicationDuration.value.seconds).toBe(30); expect(Object.isFrozen(DEFAULT_INTERACTIVE_SUBMISSION_DEDUPLICATION_PERIOD)).toBe(true); expect(Object.isFrozen(DEFAULT_INTERACTIVE_SUBMISSION_DEDUPLICATION_PERIOD.DeduplicationDuration)).toBe(true); }); @@ -45,39 +58,15 @@ describe('executeExternalTransaction', () => { userId: 'user-123', preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', - partySignatures: [ - { - party: 'party::fingerprint', - signatures: [ - { - format: 'SIGNATURE_FORMAT_RAW', - signature: 'sig-base64', - signedBy: 'fingerprint', - signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', - }, - ], - }, - ], + partySignatures: PARTY_SIGNATURES, + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); - expect(result['updateId']).toBe('update-123'); - expect(result['completionOffset']).toBe('offset-456'); + expect(result).toEqual({}); }); it('passes required parameters to ledger client', async () => { - const partySignatures = [ - { - party: 'party::fingerprint', - signatures: [ - { - format: 'SIGNATURE_FORMAT_RAW', - signature: 'sig-base64', - signedBy: 'fingerprint', - signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', - }, - ], - }, - ]; + const partySignatures = PARTY_SIGNATURES; await executeExternalTransaction({ ledgerClient: mockClient, @@ -85,6 +74,7 @@ describe('executeExternalTransaction', () => { preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', partySignatures, + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionExecute).toHaveBeenCalledWith({ @@ -94,7 +84,7 @@ describe('executeExternalTransaction', () => { hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', deduplicationPeriod: { DeduplicationDuration: { - value: { duration: '30s' }, + value: { seconds: 30, nanos: 0 }, }, }, partySignatures: { @@ -103,19 +93,19 @@ describe('executeExternalTransaction', () => { }); }); - it('uses custom hashingSchemeVersion when provided', async () => { + it('uses hashing scheme V3 when provided', async () => { await executeExternalTransaction({ ledgerClient: mockClient, userId: 'user-123', preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', - partySignatures: [], - hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V1', + partySignatures: PARTY_SIGNATURES, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', }); expect(mockClient.interactiveSubmissionExecute).toHaveBeenCalledWith( expect.objectContaining({ - hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V1', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', }) ); }); @@ -123,7 +113,7 @@ describe('executeExternalTransaction', () => { it('uses custom deduplicationPeriod when provided', async () => { const customDeduplication = { DeduplicationDuration: { - value: { duration: '60s' }, + value: { seconds: 60, nanos: 0 }, }, }; @@ -132,7 +122,8 @@ describe('executeExternalTransaction', () => { userId: 'user-123', preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', - partySignatures: [], + partySignatures: PARTY_SIGNATURES, + hashingSchemeVersion: HASHING_SCHEME_VERSION, deduplicationPeriod: customDeduplication, }); @@ -143,13 +134,14 @@ describe('executeExternalTransaction', () => { ); }); - it('defaults hashingSchemeVersion to V2', async () => { + it('forwards the explicitly selected hashing scheme', async () => { await executeExternalTransaction({ ledgerClient: mockClient, userId: 'user-123', preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', - partySignatures: [], + partySignatures: PARTY_SIGNATURES, + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionExecute).toHaveBeenCalledWith( @@ -165,14 +157,15 @@ describe('executeExternalTransaction', () => { userId: 'user-123', preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', - partySignatures: [], + partySignatures: PARTY_SIGNATURES, + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionExecute).toHaveBeenCalledWith( expect.objectContaining({ deduplicationPeriod: { DeduplicationDuration: { - value: { duration: '30s' }, + value: { seconds: 30, nanos: 0 }, }, }, }) @@ -203,7 +196,7 @@ describe('executeExternalTransaction', () => { }, ], }, - ]; + ] satisfies NonEmptyPartySignatures; await executeExternalTransaction({ ledgerClient: mockClient, @@ -211,6 +204,7 @@ describe('executeExternalTransaction', () => { preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', partySignatures, + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionExecute).toHaveBeenCalledWith( @@ -222,76 +216,67 @@ describe('executeExternalTransaction', () => { ); }); - it('handles empty party signatures array', async () => { - await executeExternalTransaction({ + it('executes through the typed executeAndWait client method and returns the exact response', async () => { + const result = await executeExternalTransactionAndWait({ ledgerClient: mockClient, userId: 'user-123', preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', - partySignatures: [], + partySignatures: PARTY_SIGNATURES, + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); - expect(mockClient.interactiveSubmissionExecute).toHaveBeenCalledWith( - expect.objectContaining({ - partySignatures: { - signatures: [], + expect(mockClient.interactiveSubmissionExecuteAndWait).toHaveBeenCalledWith({ + userId: 'user-123', + preparedTransaction: 'prepared-tx-base64', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + submissionId: 'submission-123', + deduplicationPeriod: { + DeduplicationDuration: { + value: { seconds: 30, nanos: 0 }, }, - }) - ); + }, + partySignatures: { + signatures: PARTY_SIGNATURES, + }, + }); + expect(result).toEqual({ + updateId: 'update-wait-123', + completionOffset: 456, + raw: { + updateId: 'update-wait-123', + completionOffset: 456, + }, + }); }); - it('executes external transaction with executeAndWait and returns the update id', async () => { - const result = await executeExternalTransactionAndWait({ + it('omits optional user and forwards minimum ledger time', async () => { + await executeExternalTransactionAndWait({ ledgerClient: mockClient, - userId: 'user-123', preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', - partySignatures: [], + partySignatures: PARTY_SIGNATURES, + hashingSchemeVersion: HASHING_SCHEME_VERSION, + minLedgerTime: { + time: { + MinLedgerTimeRel: { + value: { seconds: 5, nanos: 0 }, + }, + }, + }, }); - expect(mockClient.makePostRequest.mock.calls).toEqual([ - [ - 'https://ledger.example.test/v2/interactive-submission/executeAndWait', - { - userId: 'user-123', - preparedTransaction: 'prepared-tx-base64', - hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', - submissionId: 'submission-123', - deduplicationPeriod: { - DeduplicationDuration: { - value: { duration: '30s' }, + expect(mockClient.interactiveSubmissionExecuteAndWait.mock.calls[0]?.[0]).not.toHaveProperty('userId'); + expect(mockClient.interactiveSubmissionExecuteAndWait).toHaveBeenCalledWith( + expect.objectContaining({ + minLedgerTime: { + time: { + MinLedgerTimeRel: { + value: { seconds: 5, nanos: 0 }, }, }, - partySignatures: { - signatures: [], - }, }, - { - contentType: 'application/json', - includeBearerToken: true, - }, - ], - ]); - expect(result).toEqual({ - updateId: 'update-wait-123', - raw: { updateId: 'update-wait-123' }, - }); - }); - - it('throws a typed operation error when executeAndWait does not return an update id', async () => { - mockClient.makePostRequest.mockResolvedValueOnce({ completionOffset: 'offset-only' }); - - await expect( - executeExternalTransactionAndWait({ - ledgerClient: mockClient, - userId: 'user-123', - preparedTransaction: 'prepared-tx-base64', - submissionId: 'submission-123', - partySignatures: [], }) - ).rejects.toMatchObject({ - name: 'OperationError', - code: 'TRANSACTION_FAILED', - }); + ); }); }); diff --git a/test/unit/external-signing/external-party-wallet.test.ts b/test/unit/external-signing/external-party-wallet.test.ts index 50aa1c99..7c422a9d 100644 --- a/test/unit/external-signing/external-party-wallet.test.ts +++ b/test/unit/external-signing/external-party-wallet.test.ts @@ -71,8 +71,10 @@ const createMockLedgerClient = (): jest.Mocked => preparedTransactionHash: PREPARED_TRANSACTION_HASH_BASE64, hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', }), - getApiUrl: jest.fn().mockReturnValue('https://ledger.example.test'), - makePostRequest: jest.fn().mockResolvedValue({ updateId: 'provider-accept-update-1' }), + interactiveSubmissionExecuteAndWait: jest.fn().mockResolvedValue({ + updateId: 'provider-accept-update-1', + completionOffset: 456, + }), listParties: jest.fn().mockResolvedValue({ partyDetails: [] }), getPartyDetails: jest.fn().mockResolvedValue({ partyDetails: [] }), }) as unknown as jest.Mocked; @@ -389,8 +391,7 @@ describe('external-party wallet bridge', (): void => { tokenContext: { userId: 'user-1' }, }); - expect(ledgerClient.makePostRequest.mock.calls[0]).toEqual([ - 'https://ledger.example.test/v2/interactive-submission/executeAndWait', + expect(ledgerClient.interactiveSubmissionExecuteAndWait).toHaveBeenCalledWith( expect.objectContaining({ userId: 'provider-user', preparedTransaction: 'prepared-provider-accept', @@ -408,13 +409,10 @@ describe('external-party wallet bridge', (): void => { }, ], }, - }), - { - contentType: 'application/json', - includeBearerToken: true, - }, - ]); + }) + ); expect(submitted.updateId).toBe('provider-accept-update-1'); + expect(submitted.raw).toEqual({ updateId: 'provider-accept-update-1', completionOffset: 456 }); }); it('prepares and submits transfer preapproval setup through validator endpoints', async (): Promise => { diff --git a/test/unit/external-signing/external-signing-client.test.ts b/test/unit/external-signing/external-signing-client.test.ts index 9343c108..f1f6fd3d 100644 --- a/test/unit/external-signing/external-signing-client.test.ts +++ b/test/unit/external-signing/external-signing-client.test.ts @@ -13,6 +13,7 @@ import { type CantonEd25519Signature, type CantonEd25519Signer, type CantonEd25519SigningRequest, + type NonEmptyPrepareExternalTransactionCommands, } from '../../../src/utils/external-signing'; import { buildExternalPartyId, @@ -23,6 +24,16 @@ import { const SYNCHRONIZER_ID = 'global-domain::sync'; const MULTI_HASH_HEX = `1220${'11'.repeat(32)}`; const NOW_MS = Date.parse('2026-07-09T12:00:00.000Z'); +const ARCHIVE_COMMANDS = [ + { + ExerciseCommand: { + templateId: '#pkg:Module:Template', + contractId: 'contract-1', + choice: 'Archive', + choiceArgument: {}, + }, + }, +] as const satisfies NonEmptyPrepareExternalTransactionCommands; interface SigningFixture { readonly privateKey: KeyObject; @@ -73,8 +84,9 @@ function createMockLedgerClient(fixture: SigningFixture): jest.Mocked { const result = await executeExternalTransactionWithEd25519Signer({ ledgerClient, - commands: [ - { - ExerciseCommand: { - templateId: '#pkg:Module:Template', - contractId: 'contract-1', - choice: 'Archive', - choiceArgument: {}, - }, - }, - ], + commands: ARCHIVE_COMMANDS, commandId: 'command-123', submissionId: 'submission-123', userId: 'user-123', @@ -550,28 +553,24 @@ describe('Canton Ed25519 external signing orchestration', (): void => { }), { signal: expect.any(AbortSignal) as AbortSignal } ); - expect(ledgerClient.makePostRequest.mock.calls).toEqual([ - [ - 'https://ledger.example.test/v2/interactive-submission/executeAndWait', - expect.objectContaining({ - submissionId: 'submission-123', - partySignatures: { - signatures: [ - { - party: fixture.partyId, - signatures: [ - expect.objectContaining({ - signature: expect.any(String) as string, - signedBy: fixture.publicKeyFingerprint, - }), - ], - }, - ], - }, - }), - { contentType: 'application/json', includeBearerToken: true }, - ], - ]); + expect(ledgerClient.interactiveSubmissionExecuteAndWait).toHaveBeenCalledWith( + expect.objectContaining({ + submissionId: 'submission-123', + partySignatures: { + signatures: [ + { + party: fixture.partyId, + signatures: [ + expect.objectContaining({ + signature: expect.any(String) as string, + signedBy: fixture.publicKeyFingerprint, + }), + ], + }, + ], + }, + }) + ); expect(result).toMatchObject({ commandId: 'command-123', submissionId: 'submission-123', @@ -588,7 +587,7 @@ describe('Canton Ed25519 external signing orchestration', (): void => { await expect( executeExternalTransactionWithEd25519Signer({ ledgerClient, - commands: [], + commands: ARCHIVE_COMMANDS, commandId: 'command-mismatch', submissionId: 'submission-mismatch', userId: 'user-123', @@ -603,12 +602,14 @@ describe('Canton Ed25519 external signing orchestration', (): void => { it('preserves caller-stable IDs and signing evidence on an ambiguous submission outcome', async (): Promise => { const fixture = createSigningFixture(); const ledgerClient = createMockLedgerClient(fixture); - ledgerClient.makePostRequest.mockRejectedValueOnce(new NetworkError('connection reset after submit')); + ledgerClient.interactiveSubmissionExecuteAndWait.mockRejectedValueOnce( + new NetworkError('connection reset after submit') + ); try { await executeExternalTransactionWithEd25519Signer({ ledgerClient, - commands: [], + commands: ARCHIVE_COMMANDS, commandId: 'stable-command', submissionId: 'stable-submission', userId: 'user-123', @@ -637,7 +638,7 @@ describe('Canton Ed25519 external signing orchestration', (): void => { submissionId: 'stable-submission', hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', deduplicationPeriod: { - DeduplicationDuration: { value: { duration: '30s' } }, + DeduplicationDuration: { value: { seconds: 30, nanos: 0 } }, }, partySignatures: expect.any(Array) as unknown[], }, @@ -650,14 +651,14 @@ describe('Canton Ed25519 external signing orchestration', (): void => { it('honors structured Canton submission certainty over HTTP status heuristics', async (): Promise => { const fixture = createSigningFixture(); const ledgerClient = createMockLedgerClient(fixture); - ledgerClient.makePostRequest + ledgerClient.interactiveSubmissionExecuteAndWait .mockRejectedValueOnce(new ApiError('committed rejection', 503, 'Unavailable', { definiteAnswer: true })) .mockRejectedValueOnce(new ApiError('uncertain rejection', 400, 'Bad Request', { definiteAnswer: false })); const execute = async (suffix: string): Promise => { await executeExternalTransactionWithEd25519Signer({ ledgerClient, - commands: [], + commands: ARCHIVE_COMMANDS, commandId: `certainty-${suffix}`, submissionId: `certainty-${suffix}`, userId: 'user-123', diff --git a/test/unit/external-signing/prepare-external-transaction.test.ts b/test/unit/external-signing/prepare-external-transaction.test.ts index 52ebd275..19924c24 100644 --- a/test/unit/external-signing/prepare-external-transaction.test.ts +++ b/test/unit/external-signing/prepare-external-transaction.test.ts @@ -1,11 +1,20 @@ import type { LedgerJsonApiClient } from '../../../src/clients/ledger-json-api'; -import { prepareExternalTransaction } from '../../../src/utils/external-signing/prepare-external-transaction'; +import { + type NonEmptyPrepareExternalTransactionCommands, + prepareExternalTransaction, +} from '../../../src/utils/external-signing/prepare-external-transaction'; + +const COMMANDS = [ + { CreateCommand: { templateId: 'pkg:Module:Template', createArguments: {} } }, +] satisfies NonEmptyPrepareExternalTransactionCommands; +const HASHING_SCHEME_VERSION = 'HASHING_SCHEME_VERSION_V2' as const; const createMockLedgerClient = (): jest.Mocked => ({ interactiveSubmissionPrepare: jest.fn().mockResolvedValue({ preparedTransaction: 'prepared-tx-base64', preparedTransactionHash: 'hash-abc123', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', }), }) as unknown as jest.Mocked; @@ -19,10 +28,11 @@ describe('prepareExternalTransaction', () => { it('prepares external transaction and returns result with commandId', async () => { const result = await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [{ CreateCommand: { templateId: 'pkg:Module:Template', createArguments: {} } }], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(result.preparedTransaction).toBe('prepared-tx-base64'); @@ -34,10 +44,11 @@ describe('prepareExternalTransaction', () => { it('uses provided commandId', async () => { const result = await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, commandId: 'custom-command-id', }); @@ -52,10 +63,11 @@ describe('prepareExternalTransaction', () => { it('generates UUID commandId when not provided', async () => { const result = await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx @@ -69,6 +81,7 @@ describe('prepareExternalTransaction', () => { userId: 'user-123', actAs: ['party1::fp', 'party2::fp'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionPrepare).toHaveBeenCalledWith({ @@ -77,8 +90,8 @@ describe('prepareExternalTransaction', () => { userId: 'user-123', actAs: ['party1::fp', 'party2::fp'], readAs: [], - disclosedContracts: undefined, synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, verboseHashing: false, packageIdSelectionPreference: [], }); @@ -87,10 +100,11 @@ describe('prepareExternalTransaction', () => { it('passes optional readAs parameter', async () => { await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, readAs: ['read-party1::fp', 'read-party2::fp'], }); @@ -113,10 +127,11 @@ describe('prepareExternalTransaction', () => { await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, disclosedContracts, }); @@ -130,10 +145,11 @@ describe('prepareExternalTransaction', () => { it('passes optional verboseHashing parameter', async () => { await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, verboseHashing: true, }); @@ -147,16 +163,17 @@ describe('prepareExternalTransaction', () => { it('passes optional packageIdSelectionPreference parameter', async () => { await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', - packageIdSelectionPreference: [{ packageId: 'package-1' }, { packageId: 'package-2' }], + hashingSchemeVersion: HASHING_SCHEME_VERSION, + packageIdSelectionPreference: ['package-1', 'package-2'], }); expect(mockClient.interactiveSubmissionPrepare).toHaveBeenCalledWith( expect.objectContaining({ - packageIdSelectionPreference: [{ packageId: 'package-1' }, { packageId: 'package-2' }], + packageIdSelectionPreference: ['package-1', 'package-2'], }) ); }); @@ -164,10 +181,11 @@ describe('prepareExternalTransaction', () => { it('defaults verboseHashing to false', async () => { await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionPrepare).toHaveBeenCalledWith( @@ -180,10 +198,11 @@ describe('prepareExternalTransaction', () => { it('defaults packageIdSelectionPreference to empty array', async () => { await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionPrepare).toHaveBeenCalledWith( @@ -196,10 +215,11 @@ describe('prepareExternalTransaction', () => { it('defaults readAs to empty array', async () => { await prepareExternalTransaction({ ledgerClient: mockClient, - commands: [], + commands: COMMANDS, userId: 'user-123', actAs: ['party::fingerprint'], synchronizerId: 'sync-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionPrepare).toHaveBeenCalledWith( diff --git a/test/unit/operations/api-operation-retry.test.ts b/test/unit/operations/api-operation-retry.test.ts index 9f33e715..e13b032f 100644 --- a/test/unit/operations/api-operation-retry.test.ts +++ b/test/unit/operations/api-operation-retry.test.ts @@ -1,8 +1,12 @@ import axios from 'axios'; import { z } from 'zod'; import { Completions } from '../../../src/clients/ledger-json-api/operations/v2/commands/completions'; +import { InteractiveSubmissionExecute } from '../../../src/clients/ledger-json-api/operations/v2/interactive-submission/execute'; +import { InteractiveSubmissionExecuteAndWait } from '../../../src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait'; +import { InteractiveSubmissionExecuteAndWaitForTransaction } from '../../../src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait-for-transaction'; import { InteractiveSubmissionGetPreferredPackages } from '../../../src/clients/ledger-json-api/operations/v2/interactive-submission/get-preferred-packages'; import { InteractiveSubmissionPrepare } from '../../../src/clients/ledger-json-api/operations/v2/interactive-submission/prepare'; +import type { InteractiveSubmissionExecuteRequest } from '../../../src/clients/ledger-json-api/schemas/api/interactive-submission'; import { CreateTransferOffer } from '../../../src/clients/validator-api/operations/v0/wallet/transfer-offers/create'; import { GetTransferOfferStatus } from '../../../src/clients/validator-api/operations/v0/wallet/transfer-offers/get-status'; import { @@ -10,12 +14,17 @@ import { type BaseClient, ConfigurationError, HttpClient, + type OperationExecuteOptions, type OperationRetryContext, UnknownMutationOutcomeError, ValidationError, createApiOperation, } from '../../../src/core'; +const PREPARED_TRANSACTION_BASE64 = Buffer.from('prepared-transaction').toString('base64'); +const PREPARED_HASH_BASE64 = Buffer.from(`1220${'11'.repeat(32)}`, 'hex').toString('base64'); +const SIGNATURE_BASE64 = Buffer.alloc(64, 1).toString('base64'); + jest.mock('axios', () => { const actual = jest.requireActual('axios'); return { @@ -106,6 +115,84 @@ function createSemanticPostClient(): { return { client, post: axiosInstance.post }; } +function createInteractiveExecuteRequest(submissionId = 'submission-1'): InteractiveSubmissionExecuteRequest { + return { + preparedTransaction: PREPARED_TRANSACTION_BASE64, + partySignatures: { + signatures: [ + { + party: 'party::fingerprint', + signatures: [ + { + format: 'SIGNATURE_FORMAT_RAW', + signature: SIGNATURE_BASE64, + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, + ], + }, + submissionId, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', + }; +} + +type InteractiveMutationOptions = OperationExecuteOptions; + +const interactiveMutationCases: ReadonlyArray<{ + readonly name: string; + readonly response: unknown; + readonly execute: ( + client: BaseClient, + params: InteractiveSubmissionExecuteRequest, + options: InteractiveMutationOptions + ) => Promise; +}> = [ + { + name: 'execute', + response: {}, + execute: async (client, params, options) => { + const result = await new InteractiveSubmissionExecute(client).execute( + params, + options as Parameters['execute']>[1] + ); + return result; + }, + }, + { + name: 'executeAndWait', + response: { updateId: 'update-1', completionOffset: 1 }, + execute: async (client, params, options) => { + const result = await new InteractiveSubmissionExecuteAndWait(client).execute( + params, + options as Parameters['execute']>[1] + ); + return result; + }, + }, + { + name: 'executeAndWaitForTransaction', + response: { + transaction: { + updateId: 'update-1', + effectiveAt: '2026-07-09T12:00:00Z', + events: [], + offset: 1, + synchronizerId: 'synchronizer::id', + recordTime: '2026-07-09T12:00:01Z', + }, + }, + execute: async (client, params, options) => { + const result = await new InteractiveSubmissionExecuteAndWaitForTransaction(client).execute( + params, + options as Parameters['execute']>[1] + ); + return result; + }, + }, +]; + describe('factory-created operation retry plumbing', () => { beforeEach(() => { jest.clearAllMocks(); @@ -377,39 +464,50 @@ describe('semantic POST operation coverage matrix', () => { it.each([ { clientFamily: 'Ledger', + response: { ok: true }, execute: async (client: BaseClient): Promise => new Completions(client).execute({ userId: 'user', parties: ['party'], beginExclusive: 0 }), }, { clientFamily: 'Validator', + response: { ok: true }, execute: async (client: BaseClient): Promise => new GetTransferOfferStatus(client).execute({ trackingId: 'tracking-id' }), }, { clientFamily: 'Ledger interactive prepare', + response: { + preparedTransaction: PREPARED_TRANSACTION_BASE64, + preparedTransactionHash: PREPARED_HASH_BASE64, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + }, execute: async (client: BaseClient): Promise => new InteractiveSubmissionPrepare(client).execute({ - commands: [], + commands: [{ CreateCommand: { templateId: 'pkg:Module:Template', createArguments: {} } }], commandId: 'command-id', userId: 'user', - actAs: [], + actAs: ['party'], readAs: [], synchronizerId: 'synchronizer', }), }, { clientFamily: 'Ledger preferred packages', + response: { + packageReferences: [{ packageId: 'package-id', packageName: 'package-name', packageVersion: '1.0.0' }], + synchronizerId: 'synchronizer', + }, execute: async (client: BaseClient): Promise => new InteractiveSubmissionGetPreferredPackages(client).execute({ packageVettingRequirements: [{ parties: ['party'], packageName: 'package-name' }], synchronizerId: 'synchronizer', }), }, - ])('retries a transient $clientFamily read-only POST', async ({ execute }) => { + ])('retries a transient $clientFamily read-only POST', async ({ execute, response }) => { const { client, post } = createSemanticPostClient(); - post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { ok: true } }); + post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: response }); - await expect(execute(client)).resolves.toEqual({ ok: true }); + await expect(execute(client)).resolves.toEqual(response); expect(post).toHaveBeenCalledTimes(2); }); @@ -428,4 +526,137 @@ describe('semantic POST operation coverage matrix', () => { ).rejects.toBeInstanceOf(UnknownMutationOutcomeError); expect(post).toHaveBeenCalledTimes(1); }); + + it('validates semantic-read responses after transport retry handling and does not retry schema failures', async () => { + const { client, post } = createSemanticPostClient(); + post.mockResolvedValue({ data: { packageReferences: [], synchronizerId: 'synchronizer::id' } }); + + await expect( + new InteractiveSubmissionGetPreferredPackages(client).execute({ + packageVettingRequirements: [{ parties: ['party'], packageName: 'package-name' }], + synchronizerId: 'synchronizer', + }) + ).rejects.toThrow('Response validation failed'); + expect(post).toHaveBeenCalledTimes(1); + }); +}); + +describe('interactive submission retry freshness', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(interactiveMutationCases)('rejects exact-body retry for $name before dispatch', async ({ execute }) => { + const { client, post } = createSemanticPostClient(); + + await expect( + execute(client, createInteractiveExecuteRequest(), { + retry: { kind: 'exact-body', maxAttempts: 2 }, + }) + ).rejects.toThrow('cannot use exact-body retry'); + expect(post).not.toHaveBeenCalled(); + }); + + it.each(interactiveMutationCases)( + 'rejects nonconsecutive submission ID reuse for $name before the third dispatch', + async ({ execute }) => { + const { client, post } = createSemanticPostClient(); + post.mockRejectedValue(createAxiosError(400)); + + const failure: unknown = await execute(client, createInteractiveExecuteRequest(), { + retry: { + kind: 'derived-body', + maxAttempts: 3, + backoffMs: 0, + shouldRetry: () => true, + deriveParams: ({ attempt, params }) => ({ + ...params, + submissionId: attempt === 1 ? 'submission-2' : 'submission-1', + }), + }, + }).then( + () => new Error('Expected retry identifier reuse to fail'), + (error: unknown) => error + ); + + expect(failure).toBeInstanceOf(Error); + expect((failure as Error).message).toContain('reused a fresh retry identifier'); + expect((failure as Error).message).not.toContain('submission-1'); + expect(post).toHaveBeenCalledTimes(2); + } + ); + + it.each(interactiveMutationCases)( + 'dispatches every fresh derived submission ID for $name', + async ({ execute, response }) => { + const { client, post } = createSemanticPostClient(); + const beforeAttempt = jest.fn(); + post + .mockRejectedValueOnce(createAxiosError(400)) + .mockRejectedValueOnce(createAxiosError(400)) + .mockResolvedValueOnce({ data: response }); + + await expect( + execute(client, createInteractiveExecuteRequest(), { + retry: { + kind: 'derived-body', + maxAttempts: 3, + backoffMs: 0, + shouldRetry: () => true, + beforeAttempt, + deriveParams: ({ attempt, params }) => ({ + ...params, + submissionId: `submission-${attempt + 1}`, + }), + }, + }) + ).resolves.toEqual(response); + expect(post).toHaveBeenCalledTimes(3); + expect(beforeAttempt).toHaveBeenCalledTimes(3); + expect(post.mock.calls.map((call) => (call[1] as InteractiveSubmissionExecuteRequest).submissionId)).toEqual([ + 'submission-1', + 'submission-2', + 'submission-3', + ]); + } + ); + + it('does not treat a successful but invalid execute response as retryable transport failure', async () => { + const { client, post } = createSemanticPostClient(); + const deriveParams = jest.fn(({ params }: OperationRetryContext) => ({ + ...params, + submissionId: 'submission-2', + })); + post.mockResolvedValue({ data: { updateId: 'not-part-of-the-empty-response' } }); + + await expect( + new InteractiveSubmissionExecute(client).execute(createInteractiveExecuteRequest(), { + retry: { + kind: 'derived-body', + maxAttempts: 2, + shouldRetry: () => true, + deriveParams, + }, + }) + ).rejects.toThrow('Response validation failed'); + expect(post).toHaveBeenCalledTimes(1); + expect(deriveParams).not.toHaveBeenCalled(); + }); + + it('isolates used submission IDs between separate executions', async () => { + const { client, post } = createSemanticPostClient(); + const operation = new InteractiveSubmissionExecute(client); + post.mockResolvedValue({ data: {} }); + const options = { + retry: { + kind: 'derived-body' as const, + maxAttempts: 1, + deriveParams: ({ params }: OperationRetryContext) => params, + }, + }; + + await expect(operation.execute(createInteractiveExecuteRequest(), options)).resolves.toEqual({}); + await expect(operation.execute(createInteractiveExecuteRequest(), options)).resolves.toEqual({}); + expect(post).toHaveBeenCalledTimes(2); + }); }); diff --git a/test/unit/traffic/estimate-traffic-cost.test.ts b/test/unit/traffic/estimate-traffic-cost.test.ts index b8e445b2..2e50e5f3 100644 --- a/test/unit/traffic/estimate-traffic-cost.test.ts +++ b/test/unit/traffic/estimate-traffic-cost.test.ts @@ -1,7 +1,9 @@ import type { LedgerJsonApiClient } from '../../../src/clients/ledger-json-api'; -import { estimateTrafficCost } from '../../../src/utils/traffic/estimate-traffic-cost'; +import { estimateTrafficCost, type EstimateTrafficCostOptions } from '../../../src/utils/traffic/estimate-traffic-cost'; import { UPDATE_CONFIRMATION_OVERHEAD_BYTES } from '../../../src/utils/traffic/types'; +const HASHING_SCHEME_VERSION = 'HASHING_SCHEME_VERSION_V2' as const; + describe('estimateTrafficCost', () => { const mockPrepareResponse = { preparedTransactionHash: 'hash-123', @@ -24,7 +26,7 @@ describe('estimateTrafficCost', () => { return { totalCostWithOverhead, costInCents, costInDollars: costInCents / 100 }; }; - const mockCommands = [ + const mockCommands: EstimateTrafficCostOptions['commands'] = [ { CreateCommand: { templateId: 'MyModule:MyTemplate', @@ -48,6 +50,7 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); const expected = calculateExpectedCosts(2000); @@ -69,6 +72,7 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(client.interactiveSubmissionPrepare).toHaveBeenCalledWith( @@ -90,6 +94,7 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-456', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', userId: 'custom-user', actAs: ['party-a::123', 'party-b::456'], readAs: ['party-c::789'], @@ -101,6 +106,7 @@ describe('estimateTrafficCost', () => { actAs: ['party-a::123', 'party-b::456'], readAs: ['party-c::789'], synchronizerId: 'domain-456', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', }) ); }); @@ -116,17 +122,19 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(result).toBeUndefined(); }); - it('should handle cost estimation without timestamp', async () => { + it('should preserve the required estimation timestamp from a V3 prepare response', async () => { const client = createMockLedgerClient({ preparedTransactionHash: 'hash-123', preparedTransaction: 'tx-data', - hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', costEstimation: { + estimationTimestamp: '2026-07-09T12:00:00Z', confirmationRequestTrafficCostEstimation: 800, confirmationResponseTrafficCostEstimation: 200, totalTrafficCostEstimation: 1000, @@ -137,6 +145,7 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', }); const expected = calculateExpectedCosts(1000); @@ -147,7 +156,7 @@ describe('estimateTrafficCost', () => { totalCostWithOverhead: expected.totalCostWithOverhead, costInCents: expected.costInCents, costInDollars: expected.costInDollars, - estimatedAt: undefined, + estimatedAt: '2026-07-09T12:00:00Z', }); }); @@ -158,12 +167,14 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); await estimateTrafficCost({ ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); const { calls } = (client.interactiveSubmissionPrepare as jest.Mock).mock; @@ -176,15 +187,17 @@ describe('estimateTrafficCost', () => { { contractId: 'contract-1', templateId: 'MyModule:MyContract', + createdEventBlob: 'created-event-blob', synchronizerId: 'domain-123', }, ]; - const packageIdSelectionPreference = [{ packageId: 'pkg-123' }]; + const packageIdSelectionPreference = ['pkg-123']; await estimateTrafficCost({ ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, disclosedContracts, packageIdSelectionPreference, }); @@ -197,56 +210,6 @@ describe('estimateTrafficCost', () => { ); }); - it('should handle multiple commands', async () => { - const client = createMockLedgerClient({ - preparedTransactionHash: 'hash-multi', - costEstimation: { - confirmationRequestTrafficCostEstimation: 3000, - confirmationResponseTrafficCostEstimation: 1000, - totalTrafficCostEstimation: 4000, - }, - }); - - const multipleCommands = [ - { - CreateCommand: { - templateId: 'MyModule:Contract1', - createArguments: { fields: {} }, - }, - }, - { - ExerciseCommand: { - templateId: 'MyModule:Contract2', - contractId: 'contract-id-123', - choice: 'Archive', - choiceArgument: { fields: {} }, - }, - }, - ]; - - const result = await estimateTrafficCost({ - ledgerClient: client, - commands: multipleCommands, - synchronizerId: 'domain-123', - }); - - const expected = calculateExpectedCosts(4000); - expect(result).toEqual({ - requestCost: 3000, - responseCost: 1000, - totalCost: 4000, - totalCostWithOverhead: expected.totalCostWithOverhead, - costInCents: expected.costInCents, - costInDollars: expected.costInDollars, - estimatedAt: undefined, - }); - expect(client.interactiveSubmissionPrepare).toHaveBeenCalledWith( - expect.objectContaining({ - commands: multipleCommands, - }) - ); - }); - it('should throw error when userId cannot be resolved', async () => { const client = { interactiveSubmissionPrepare: jest.fn().mockResolvedValue(mockPrepareResponse), @@ -259,6 +222,7 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }) ).rejects.toThrow('userId is required: provide it in options or configure it on the ledger client'); }); @@ -275,6 +239,7 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, }) ).rejects.toThrow('actAs is required: provide it in options or configure partyId on the ledger client'); }); @@ -290,6 +255,7 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, userId: 'explicit-user', }); @@ -310,6 +276,7 @@ describe('estimateTrafficCost', () => { ledgerClient: client, commands: mockCommands, synchronizerId: 'domain-123', + hashingSchemeVersion: HASHING_SCHEME_VERSION, actAs: ['explicit-party::123'], }); diff --git a/test/unit/traffic/get-estimated-traffic-cost.test.ts b/test/unit/traffic/get-estimated-traffic-cost.test.ts index bd69d0a9..52275709 100644 --- a/test/unit/traffic/get-estimated-traffic-cost.test.ts +++ b/test/unit/traffic/get-estimated-traffic-cost.test.ts @@ -45,10 +45,13 @@ describe('getEstimatedTrafficCost', () => { }); }); - it('should handle costEstimation without timestamp', () => { + it('should extract the required timestamp from a V3 prepare response', () => { const preparedTransaction: InteractiveSubmissionPrepareResponse = { preparedTransactionHash: 'abc123', + preparedTransaction: 'tx-data', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V3', costEstimation: { + estimationTimestamp: '2026-07-09T12:00:00Z', confirmationRequestTrafficCostEstimation: 2000, confirmationResponseTrafficCostEstimation: 800, totalTrafficCostEstimation: 2800, @@ -67,14 +70,17 @@ describe('getEstimatedTrafficCost', () => { totalCostWithOverhead: expectedTotalWithOverhead, costInCents: expectedCostInCents, costInDollars: expectedCostInCents / 100, - estimatedAt: undefined, + estimatedAt: '2026-07-09T12:00:00Z', }); }); it('should handle zero traffic costs (still includes overhead)', () => { const preparedTransaction: InteractiveSubmissionPrepareResponse = { preparedTransactionHash: 'abc123', + preparedTransaction: 'tx-data', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', costEstimation: { + estimationTimestamp: '2026-07-09T12:00:00Z', confirmationRequestTrafficCostEstimation: 0, confirmationResponseTrafficCostEstimation: 0, totalTrafficCostEstimation: 0, @@ -94,7 +100,7 @@ describe('getEstimatedTrafficCost', () => { totalCostWithOverhead: expectedTotalWithOverhead, costInCents: expectedCostInCents, costInDollars: expectedCostInCents / 100, - estimatedAt: undefined, + estimatedAt: '2026-07-09T12:00:00Z', }); }); @@ -103,7 +109,10 @@ describe('getEstimatedTrafficCost', () => { // Cost: 6000 * 55 / 1024 = ~322 cents = $3.22 const preparedTransaction: InteractiveSubmissionPrepareResponse = { preparedTransactionHash: 'abc123', + preparedTransaction: 'tx-data', + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', costEstimation: { + estimationTimestamp: '2026-07-09T12:00:00Z', confirmationRequestTrafficCostEstimation: 40 * 1024, // 40KB confirmationResponseTrafficCostEstimation: 10 * 1024, // 10KB totalTrafficCostEstimation: 50 * 1024, // 50KB