From 92297a951bf350d7841aec0c84f52b151618b14c Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 02:42:38 -0400 Subject: [PATCH 01/21] feat: make request retries explicit and safe --- scripts/generate-all-client-methods.ts | 81 +- .../operations/v2/commands/completions.ts | 4 +- .../v2/events/get-events-by-contract-id.ts | 1 + .../get-preferred-packages.ts | 1 + .../v2/interactive-submission/prepare.ts | 1 + .../v2/parties/aggregate-parties.ts | 52 +- .../v2/parties/external/generate-topology.ts | 1 + .../operations/v2/parties/get.ts | 10 +- .../operations/v2/parties/list.ts | 10 +- .../v2/updates/get-transaction-by-id.ts | 1 + .../v2/updates/get-transaction-by-offset.ts | 1 + .../operations/v2/updates/get-update-by-id.ts | 1 + .../v2/updates/get-update-by-offset.ts | 1 + src/clients/scan-api/ScanApiClient.ts | 148 ++- .../get-allocation-factory-from-registry.ts | 1 + src/clients/scan-api/operations/v0/scan.ts | 17 + .../admin/generate-external-party-topology.ts | 1 + ...re-accept-external-party-setup-proposal.ts | 1 + .../prepare-transfer-preapproval-send.ts | 1 + .../operations/v0/ans/get-rules.ts | 1 + .../scan-proxy/get-member-traffic-status.ts | 75 +- .../v1/get-allocation-factory.ts | 1 + .../v1/get-allocation-cancel-context.ts | 1 + .../v1/get-allocation-transfer-context.ts | 1 + .../v1/get-allocation-withdraw-context.ts | 1 + .../v1/get-transfer-factory.ts | 1 + ...get-transfer-instruction-accept-context.ts | 1 + ...get-transfer-instruction-reject-context.ts | 1 + ...t-transfer-instruction-withdraw-context.ts | 1 + .../wallet/buy-traffic-requests/get-status.ts | 1 + .../v0/wallet/transfer-offers/get-status.ts | 1 + src/core/BaseClient.ts | 47 +- src/core/errors.ts | 62 +- src/core/http/HttpClient.ts | 717 +++++++++++---- src/core/http/abort.ts | 55 ++ src/core/http/index.ts | 1 + src/core/http/request-retry.ts | 174 ++++ src/core/http/request-value.ts | 36 + src/core/operations/ApiOperation.ts | 46 +- src/core/operations/ApiOperationFactory.ts | 97 +- src/core/operations/index.ts | 2 + .../operations/operation-execute-options.ts | 113 +++ .../operations/operation-request-options.ts | 146 +++ src/utils/mining/mining-rounds.ts | 19 +- test/typecheck/operation-retry.typecheck.ts | 139 +++ test/unit/clients/scan-api.test.ts | 335 ++++++- test/unit/core/errors.test.ts | 38 +- test/unit/core/http-client-retry.test.ts | 852 ++++++++++++++++++ .../operations/api-operation-retry.test.ts | 360 ++++++++ .../custom-api-operation-options.test.ts | 128 +++ 50 files changed, 3485 insertions(+), 302 deletions(-) create mode 100644 src/core/http/abort.ts create mode 100644 src/core/http/request-retry.ts create mode 100644 src/core/http/request-value.ts create mode 100644 src/core/operations/operation-execute-options.ts create mode 100644 src/core/operations/operation-request-options.ts create mode 100644 test/typecheck/operation-retry.typecheck.ts create mode 100644 test/unit/core/http-client-retry.test.ts create mode 100644 test/unit/operations/api-operation-retry.test.ts create mode 100644 test/unit/operations/custom-api-operation-options.test.ts diff --git a/scripts/generate-all-client-methods.ts b/scripts/generate-all-client-methods.ts index 6df97f0e..14003819 100644 --- a/scripts/generate-all-client-methods.ts +++ b/scripts/generate-all-client-methods.ts @@ -2,6 +2,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import ts from 'typescript'; interface ClientConfig { name: string; @@ -54,6 +55,7 @@ type OperationInfo = paramsType: string; responseType: string; methodName?: string | undefined; + paramsOptional?: boolean | undefined; jsdoc?: string | undefined; } | { @@ -140,20 +142,7 @@ function extractOperationInfos(fileContent: string): OperationInfo[] { } } - // Class-based operations: export class OperationName { ... execute(...) } - const classRegex = /export class (\w+)[^{]*{[\s\S]*?public async (execute|connect)\(/s; - const classMatch = classRegex.exec(fileContent); - if (classMatch?.[1]) { - const methodName = classMatch[2]; // 'execute' or 'connect' - results.push({ - kind: 'api', - operationName: classMatch[1], - paramsType: 'unknown', - responseType: 'unknown', - ...(methodName && { methodName }), // Only include if defined - jsdoc: extractJsDoc(fileContent, classMatch[1]), - }); - } + results.push(...extractClassOperationInfos(fileContent)); // WebSocket operations: createWebSocketOperation // Note: Generic types can contain nested angle brackets (e.g., z.infer<...>), @@ -175,6 +164,54 @@ function extractOperationInfos(fileContent: string): OperationInfo[] { return results; } +/** Extract class operations with the TypeScript AST so generic/optional method signatures remain intact. */ +function extractClassOperationInfos(fileContent: string): OperationInfo[] { + const sourceFile = ts.createSourceFile('operation.ts', fileContent, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const results: OperationInfo[] = []; + + for (const statement of sourceFile.statements) { + if (!ts.isClassDeclaration(statement) || !statement.name || !hasModifier(statement, ts.SyntaxKind.ExportKeyword)) { + continue; + } + + const method = statement.members.find( + (member): member is ts.MethodDeclaration => + ts.isMethodDeclaration(member) && + ts.isIdentifier(member.name) && + (member.name.text === 'execute' || member.name.text === 'connect') && + hasModifier(member, ts.SyntaxKind.AsyncKeyword) + ); + if (!method || !ts.isIdentifier(method.name)) continue; + + const apiOperationHeritage = statement.heritageClauses + ?.flatMap((clause) => [...clause.types]) + .find((heritageType) => { + const expressionParts = heritageType.expression.getText(sourceFile).split('.'); + return expressionParts[expressionParts.length - 1] === 'ApiOperation'; + }); + const [paramsTypeNode, responseTypeNode] = apiOperationHeritage?.typeArguments ?? []; + const firstParameter = method.parameters[0]; + + results.push({ + kind: 'api', + operationName: statement.name.text, + paramsType: paramsTypeNode?.getText(sourceFile) ?? 'unknown', + responseType: responseTypeNode?.getText(sourceFile) ?? 'unknown', + methodName: method.name.text, + ...(firstParameter?.questionToken !== undefined || firstParameter?.initializer !== undefined + ? { paramsOptional: true } + : {}), + jsdoc: extractJsDoc(fileContent, statement.name.text), + }); + } + + return results; +} + +function hasModifier(node: ts.Node, kind: ts.SyntaxKind): boolean { + return ts.canHaveModifiers(node) && (ts.getModifiers(node)?.some((modifier) => modifier.kind === kind) ?? false); +} + function relativeImportPath(from: string, to: string): string { let rel = path.relative(path.dirname(from), to).replace(/\\/g, '/'); if (!rel.startsWith('.')) rel = `./${rel}`; @@ -211,9 +248,14 @@ function generateMethodDeclarations(ops: Array['${opMethodName}']>[0]`; + const methodOptionsType = `Parameters['${opMethodName}']>[1]`; const methodReturnType = `ReturnType['${opMethodName}']>`; if (op.paramsType === 'void') { - return `${jsdocComment}\n public ${methodName}!: () => ${methodReturnType};`; + return `${jsdocComment}\n public ${methodName}!: (options?: ${methodOptionsType}) => ${methodReturnType};`; + } + if (op.paramsType !== 'unknown' && opMethodName === 'execute') { + const optionalMarker = op.paramsOptional ? '?' : ''; + return `${jsdocComment}\n public ${methodName}!: (params${optionalMarker}: ${methodParamsType}, options?: ${methodOptionsType}) => ${methodReturnType};`; } return `${jsdocComment}\n public ${methodName}!: (params: ${methodParamsType}) => ${methodReturnType};`; } @@ -230,9 +272,14 @@ function generateMethodImplementations(ops: Array { const methodName = operationNameToMethodName(op.operationName); if (op.kind === 'api') { - const params = op.paramsType === 'void' ? '' : 'params'; const opMethodName = op.methodName ?? 'execute'; - return ` this.${methodName} = (${params}) => new ${op.operationName}(this).${opMethodName}(${params});`; + if (op.paramsType === 'void') { + return ` this.${methodName} = (options) => new ${op.operationName}(this).${opMethodName}(undefined, options);`; + } + if (op.paramsType !== 'unknown' && opMethodName === 'execute') { + return ` this.${methodName} = (params, options) => new ${op.operationName}(this).${opMethodName}(params, options);`; + } + return ` this.${methodName} = (params) => new ${op.operationName}(this).${opMethodName}(params);`; } return ` this.${methodName} = (params, handlers) => new ${op.operationName}(this).subscribe(params, handlers);`; }) diff --git a/src/clients/ledger-json-api/operations/v2/commands/completions.ts b/src/clients/ledger-json-api/operations/v2/commands/completions.ts index a1e9beb8..e93d1a53 100644 --- a/src/clients/ledger-json-api/operations/v2/commands/completions.ts +++ b/src/clients/ledger-json-api/operations/v2/commands/completions.ts @@ -19,12 +19,12 @@ export type CompletionsResponse = paths[typeof endpoint]['post']['responses']['2 /** * Polls command completions over **HTTP** POST `/v2/commands/completions` (cursor batch). * - * For waiting on a **specific submission**, prefer `waitForCompletion` / `subscribeToCompletions` - * streaming instead. + * For waiting on a **specific submission**, prefer `waitForCompletion` / `subscribeToCompletions` streaming instead. */ export const Completions = createApiOperation({ paramsSchema: CompletionsParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params, apiUrl) => { const url = new URL(`${apiUrl}${endpoint}`); if (params.limit !== undefined) { diff --git a/src/clients/ledger-json-api/operations/v2/events/get-events-by-contract-id.ts b/src/clients/ledger-json-api/operations/v2/events/get-events-by-contract-id.ts index c83a32e2..9f7051f9 100644 --- a/src/clients/ledger-json-api/operations/v2/events/get-events-by-contract-id.ts +++ b/src/clients/ledger-json-api/operations/v2/events/get-events-by-contract-id.ts @@ -29,6 +29,7 @@ export type EventsByContractIdResponse = export const GetEventsByContractId = createApiOperation({ paramsSchema: EventsByContractIdParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}${endpoint}`, buildRequestData: (params, client): EventsByContractIdRequest => { const readParties = [...new Set([client.getPartyId(), ...(params.readAs ?? [])])]; 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 249d4856..65838275 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 @@ -28,6 +28,7 @@ export const InteractiveSubmissionGetPreferredPackages = createApiOperation< >({ paramsSchema: InteractiveSubmissionGetPreferredPackagesParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params: InteractiveSubmissionGetPreferredPackagesParams, apiUrl: string) => `${apiUrl}/v2/interactive-submission/preferred-packages`, buildRequestData: (params: InteractiveSubmissionGetPreferredPackagesParams) => ({ 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 c6355758..d52cc48a 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 @@ -12,6 +12,7 @@ export const InteractiveSubmissionPrepare = createApiOperation< >({ paramsSchema: InteractiveSubmissionPrepareRequestSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params: InteractiveSubmissionPrepareRequest, apiUrl: string) => `${apiUrl}/v2/interactive-submission/prepare`, buildRequestData: (params) => params, diff --git a/src/clients/ledger-json-api/operations/v2/parties/aggregate-parties.ts b/src/clients/ledger-json-api/operations/v2/parties/aggregate-parties.ts index cc0a3085..b706f65a 100644 --- a/src/clients/ledger-json-api/operations/v2/parties/aggregate-parties.ts +++ b/src/clients/ledger-json-api/operations/v2/parties/aggregate-parties.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; -import type { ApiOperation } from '../../../../../core'; +import { + createOperationHttpRequestOptions, + type ApiOperation, + type OperationExecuteOptions, +} from '../../../../../core'; import { ApiError } from '../../../../../core/errors'; import type { paths } from '../../../../../generated/canton/community/ledger/ledger-json-api/src/test/resources/json-api-docs/openapi'; @@ -17,7 +21,8 @@ type PartyDetail = NonNullable[numbe export async function fetchAllParties( operation: ApiOperation, - params: PartiesAggregationParams + params: PartiesAggregationParams, + options: OperationExecuteOptions | undefined ): Promise { const validatedParams = operation.validateParams(params, PartiesAggregationParamsSchema); @@ -26,16 +31,30 @@ export async function fetchAllParties( // Loop until the API stops returning nextPageToken (or fails to advance) for (;;) { - const url = new URL(`${operation.getApiUrl()}${endpoint}`); - url.searchParams.set('pageSize', DEFAULT_PAGE_SIZE.toString()); - if (currentPageToken.length > 0) { - url.searchParams.set('pageToken', currentPageToken); - } + const pageParams: PartiesAggregationParams = currentPageToken.length > 0 ? { pageToken: currentPageToken } : {}; + const url = buildPartiesPageUrl(operation, pageParams); + const httpOptions = + options === undefined + ? undefined + : createOperationHttpRequestOptions({ + initialParams: pageParams, + options, + requestSemantics: 'read', + initialUrl: url, + validateParams: (derivedParams): PartiesAggregationParams => + operation.validateParams(derivedParams, PartiesAggregationParamsSchema), + buildUrl: (derivedParams): string => buildPartiesPageUrl(operation, derivedParams), + buildBody: (): undefined => undefined, + }); - const response = await operation.makeGetRequest(url.toString(), { - contentType: 'application/json', - includeBearerToken: true, - }); + const response = await operation.makeGetRequest( + url, + { + contentType: 'application/json', + includeBearerToken: true, + }, + httpOptions + ); const rawPartyDetails: unknown = response.partyDetails; if (!Array.isArray(rawPartyDetails)) { @@ -63,3 +82,14 @@ export async function fetchAllParties( nextPageToken: '', }; } + +function buildPartiesPageUrl( + operation: ApiOperation, + params: PartiesAggregationParams +): string { + const url = new URL(`${operation.getApiUrl()}${endpoint}`); + url.searchParams.set('pageSize', DEFAULT_PAGE_SIZE.toString()); + const pageToken = params.pageToken?.trim() ?? ''; + if (pageToken.length > 0) url.searchParams.set('pageToken', pageToken); + return url.toString(); +} diff --git a/src/clients/ledger-json-api/operations/v2/parties/external/generate-topology.ts b/src/clients/ledger-json-api/operations/v2/parties/external/generate-topology.ts index d78d585e..37e9103f 100644 --- a/src/clients/ledger-json-api/operations/v2/parties/external/generate-topology.ts +++ b/src/clients/ledger-json-api/operations/v2/parties/external/generate-topology.ts @@ -66,6 +66,7 @@ export const GenerateExternalPartyTopology = createApiOperation< >({ paramsSchema: GenerateExternalPartyTopologyParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params: GenerateExternalPartyTopologyParams, apiUrl: string) => `${apiUrl}/v2/parties/external/generate-topology`, buildRequestData: (params: GenerateExternalPartyTopologyParams, _client: BaseClient): GeneratedRequest => ({ diff --git a/src/clients/ledger-json-api/operations/v2/parties/get.ts b/src/clients/ledger-json-api/operations/v2/parties/get.ts index 2ba95baf..d097c428 100644 --- a/src/clients/ledger-json-api/operations/v2/parties/get.ts +++ b/src/clients/ledger-json-api/operations/v2/parties/get.ts @@ -1,4 +1,4 @@ -import { ApiOperation } from '../../../../../core'; +import { ApiOperation, snapshotOperationExecuteOptions, type OperationExecuteOptions } from '../../../../../core'; import { fetchAllParties, PartiesAggregationParamsSchema, @@ -11,7 +11,11 @@ export type { GetPartiesParams, GetPartiesResponse }; /** List all parties known to the participant (legacy alias). */ export class GetParties extends ApiOperation { - public async execute(params: GetPartiesParams): Promise { - return fetchAllParties(this, params); + public async execute( + params: GetPartiesParams, + options?: OperationExecuteOptions + ): Promise { + const operationOptions = snapshotOperationExecuteOptions(options); + return fetchAllParties(this, params, operationOptions); } } diff --git a/src/clients/ledger-json-api/operations/v2/parties/list.ts b/src/clients/ledger-json-api/operations/v2/parties/list.ts index d8898556..aa9965c3 100644 --- a/src/clients/ledger-json-api/operations/v2/parties/list.ts +++ b/src/clients/ledger-json-api/operations/v2/parties/list.ts @@ -1,4 +1,4 @@ -import { ApiOperation } from '../../../../../core'; +import { ApiOperation, snapshotOperationExecuteOptions, type OperationExecuteOptions } from '../../../../../core'; import { fetchAllParties, PartiesAggregationParamsSchema, @@ -11,7 +11,11 @@ export type { ListPartiesParams, ListPartiesResponse }; /** List all parties known to the participant and automatically paginate through all results. */ export class ListParties extends ApiOperation { - public async execute(params: ListPartiesParams): Promise { - return fetchAllParties(this, params); + public async execute( + params: ListPartiesParams, + options?: OperationExecuteOptions + ): Promise { + const operationOptions = snapshotOperationExecuteOptions(options); + return fetchAllParties(this, params, operationOptions); } } diff --git a/src/clients/ledger-json-api/operations/v2/updates/get-transaction-by-id.ts b/src/clients/ledger-json-api/operations/v2/updates/get-transaction-by-id.ts index a99b22e1..bed71213 100644 --- a/src/clients/ledger-json-api/operations/v2/updates/get-transaction-by-id.ts +++ b/src/clients/ledger-json-api/operations/v2/updates/get-transaction-by-id.ts @@ -12,6 +12,7 @@ export type GetTransactionByIdResponse = export const GetTransactionById = createApiOperation({ paramsSchema: GetTransactionByIdParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}${endpoint}`, buildRequestData: (params) => params, }); diff --git a/src/clients/ledger-json-api/operations/v2/updates/get-transaction-by-offset.ts b/src/clients/ledger-json-api/operations/v2/updates/get-transaction-by-offset.ts index 30cd635c..d05961a0 100644 --- a/src/clients/ledger-json-api/operations/v2/updates/get-transaction-by-offset.ts +++ b/src/clients/ledger-json-api/operations/v2/updates/get-transaction-by-offset.ts @@ -12,6 +12,7 @@ export type GetTransactionByOffsetResponse = export const GetTransactionByOffset = createApiOperation({ paramsSchema: GetTransactionByOffsetParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}${endpoint}`, buildRequestData: (params) => params, }); diff --git a/src/clients/ledger-json-api/operations/v2/updates/get-update-by-id.ts b/src/clients/ledger-json-api/operations/v2/updates/get-update-by-id.ts index f8f9e872..b5ef3402 100644 --- a/src/clients/ledger-json-api/operations/v2/updates/get-update-by-id.ts +++ b/src/clients/ledger-json-api/operations/v2/updates/get-update-by-id.ts @@ -20,6 +20,7 @@ export const GetUpdateById = createApiOperation `${apiUrl}${endpoint}`, buildRequestData: (params) => { // Validate updateId parameter diff --git a/src/clients/ledger-json-api/operations/v2/updates/get-update-by-offset.ts b/src/clients/ledger-json-api/operations/v2/updates/get-update-by-offset.ts index 499ee499..08fc0e17 100644 --- a/src/clients/ledger-json-api/operations/v2/updates/get-update-by-offset.ts +++ b/src/clients/ledger-json-api/operations/v2/updates/get-update-by-offset.ts @@ -12,6 +12,7 @@ export type GetUpdateByOffsetResponse = export const GetUpdateByOffset = createApiOperation({ paramsSchema: GetUpdateByOffsetParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}${endpoint}`, buildRequestData: (params) => params, }); diff --git a/src/clients/scan-api/ScanApiClient.ts b/src/clients/scan-api/ScanApiClient.ts index f73e61d7..9321ab6a 100644 --- a/src/clients/scan-api/ScanApiClient.ts +++ b/src/clients/scan-api/ScanApiClient.ts @@ -1,4 +1,13 @@ -import { ApiError, ConfigurationError, NetworkError, type CantonRuntime, type RequestConfig } from '../../core'; +import { + ApiError, + ConfigurationError, + NetworkError, + snapshotHttpRequestOptions, + type CantonRuntime, + type HttpReadRequestOptions, + type HttpRequestOptions, + type RequestConfig, +} from '../../core'; import { resolveScanApiUrls } from './scan-endpoints'; import { ScanApiClient as ScanApiClientGenerated } from './ScanApiClient.generated'; @@ -62,7 +71,7 @@ export class ScanApiClient extends ScanApiClientGenerated { constructor(runtime: CantonRuntime, options: ScanApiClientOptions = {}) { const resolvedUrls = options.scanApiUrls ?? resolveScanApiUrls(runtime.getNetwork(), runtime.getProvider()); const fallbackUrl = resolvedUrls.length > 0 ? undefined : resolveConfiguredScanApiUrl(runtime); - const scanApiUrls = resolvedUrls.length > 0 ? resolvedUrls : fallbackUrl ? [fallbackUrl] : []; + const scanApiUrls = Object.freeze([...(resolvedUrls.length > 0 ? resolvedUrls : fallbackUrl ? [fallbackUrl] : [])]); const [firstApiUrl] = scanApiUrls; if (!firstApiUrl) { @@ -80,11 +89,14 @@ export class ScanApiClient extends ScanApiClientGenerated { auth: { grantType: 'client_credentials', clientId: '' }, }, }, - }), + }) ); this.scanApiUrls = scanApiUrls; this.maxEndpointAttempts = options.maxEndpointAttempts ?? scanApiUrls.length; + if (!Number.isInteger(this.maxEndpointAttempts) || this.maxEndpointAttempts < 1) { + throw new ConfigurationError('maxEndpointAttempts must be a positive integer'); + } this.activeBaseUrlIndex = 0; // Prefer rotating quickly across endpoints rather than waiting on per-endpoint retries. @@ -135,55 +147,117 @@ export class ScanApiClient extends ScanApiClientGenerated { return null; } - private async rotateRequest(fullUrl: string, doRequest: (url: string) => Promise): Promise { - if (this.scanApiUrls.length <= 1) { - return doRequest(fullUrl); + /** Run Scan failover inside one HttpClient retry loop so attempt budgets, bodies, and hook history stay coherent. */ + private async makeReadRequestWithFailover( + fullUrl: string, + options: Readonly>, + doRequest: (requestOptions: HttpReadRequestOptions) => Promise + ): Promise { + const readOptions = snapshotHttpRequestOptions({ ...options, requestSemantics: 'read' }); + // An explicit resolver is the caller's failover policy and takes precedence over Scan's built-in endpoint list. + if ( + this.scanApiUrls.length <= 1 || + readOptions.retry?.kind === 'none' || + readOptions.resolveReadAttemptUrl !== undefined + ) { + return doRequest(readOptions); } const rotatableUrl = this.getRotatableUrl(fullUrl); if (rotatableUrl === null) { - return doRequest(fullUrl); + return doRequest(readOptions); } - const maxAttempts = Math.min(this.maxEndpointAttempts, this.scanApiUrls.length); - let lastError: unknown; - - for (let attempt = 0; attempt < maxAttempts; attempt += 1) { - const index = (this.activeBaseUrlIndex + attempt) % this.scanApiUrls.length; - const base = this.scanApiUrls[index]; - if (!base) { - continue; - } - const url = rotatableUrl.buildUrl(base); - - try { - const result = await doRequest(url); - this.setActiveBaseUrl(index); - return result; - } catch (error) { - if (!shouldRotateOnError(error)) { - throw error; + const maxDistinctEndpoints = Math.min(this.maxEndpointAttempts, this.scanApiUrls.length); + const startingBaseUrlIndex = this.activeBaseUrlIndex; + let selectedBaseUrlIndex = startingBaseUrlIndex; + const retry = + readOptions.retry ?? + Object.freeze({ + kind: 'exact-body' as const, + maxAttempts: maxDistinctEndpoints, + backoffMs: 0, + shouldRetry: ({ error }: { readonly error: Error }): boolean => shouldRotateOnError(error), + }); + const failoverOptions = snapshotHttpRequestOptions({ + ...readOptions, + retry, + requestSemantics: 'read', + resolveReadAttemptUrl: ({ attempt }): string => { + const endpointOffset = (attempt - 1) % maxDistinctEndpoints; + selectedBaseUrlIndex = (startingBaseUrlIndex + endpointOffset) % this.scanApiUrls.length; + const baseUrl = this.scanApiUrls[selectedBaseUrlIndex]; + if (!baseUrl) { + throw new ConfigurationError(`Missing Scan endpoint at index ${selectedBaseUrlIndex}`); } - lastError = error; - } - } + return rotatableUrl.buildUrl(baseUrl); + }, + }); - throw lastError instanceof Error ? lastError : new NetworkError(`Scan request failed: ${String(lastError)}`); + const result = await doRequest(failoverOptions); + this.setActiveBaseUrl(selectedBaseUrlIndex); + return result; } - public override async makeGetRequest(url: string, config: RequestConfig = {}): Promise { - return this.rotateRequest(url, async (u) => super.makeGetRequest(u, config)); + public override async makeGetRequest( + url: string, + config: RequestConfig = {}, + options: HttpReadRequestOptions = {} + ): Promise { + const requestConfig: RequestConfig = Object.freeze({ ...config }); + const requestOptions = snapshotHttpRequestOptions(options); + if (requestOptions.retry?.kind === 'none') { + return super.makeGetRequest(url, requestConfig, requestOptions); + } + return this.makeReadRequestWithFailover(url, requestOptions, async (failoverOptions) => + super.makeGetRequest(url, requestConfig, failoverOptions) + ); } - public override async makePostRequest(url: string, data: unknown, config: RequestConfig = {}): Promise { - return this.rotateRequest(url, async (u) => super.makePostRequest(u, data, config)); + public override async makePostRequest( + url: string, + data: Body, + config: RequestConfig = {}, + options: HttpRequestOptions = {} + ): Promise { + const requestConfig: RequestConfig = Object.freeze({ ...config }); + const requestOptions = snapshotHttpRequestOptions(options); + if (requestOptions.requestSemantics !== 'read' || requestOptions.retry?.kind === 'none') { + return super.makePostRequest(url, data, requestConfig, requestOptions); + } + return this.makeReadRequestWithFailover(url, requestOptions, async (failoverOptions) => + super.makePostRequest(url, data, requestConfig, failoverOptions) + ); } - public override async makeDeleteRequest(url: string, config: RequestConfig = {}): Promise { - return this.rotateRequest(url, async (u) => super.makeDeleteRequest(u, config)); + public override async makeDeleteRequest( + url: string, + config: RequestConfig = {}, + options: HttpRequestOptions = {} + ): Promise { + const requestConfig: RequestConfig = Object.freeze({ ...config }); + const requestOptions = snapshotHttpRequestOptions(options); + if (requestOptions.requestSemantics !== 'read' || requestOptions.retry?.kind === 'none') { + return super.makeDeleteRequest(url, requestConfig, requestOptions); + } + return this.makeReadRequestWithFailover(url, requestOptions, async (failoverOptions) => + super.makeDeleteRequest(url, requestConfig, failoverOptions) + ); } - public override async makePatchRequest(url: string, data: unknown, config: RequestConfig = {}): Promise { - return this.rotateRequest(url, async (u) => super.makePatchRequest(u, data, config)); + public override async makePatchRequest( + url: string, + data: Body, + config: RequestConfig = {}, + options: HttpRequestOptions = {} + ): Promise { + const requestConfig: RequestConfig = Object.freeze({ ...config }); + const requestOptions = snapshotHttpRequestOptions(options); + if (requestOptions.requestSemantics !== 'read' || requestOptions.retry?.kind === 'none') { + return super.makePatchRequest(url, data, requestConfig, requestOptions); + } + return this.makeReadRequestWithFailover(url, requestOptions, async (failoverOptions) => + super.makePatchRequest(url, data, requestConfig, failoverOptions) + ); } } diff --git a/src/clients/scan-api/operations/v0/registry/allocation-instruction/v1/get-allocation-factory-from-registry.ts b/src/clients/scan-api/operations/v0/registry/allocation-instruction/v1/get-allocation-factory-from-registry.ts index 7e091aac..0c88663f 100644 --- a/src/clients/scan-api/operations/v0/registry/allocation-instruction/v1/get-allocation-factory-from-registry.ts +++ b/src/clients/scan-api/operations/v0/registry/allocation-instruction/v1/get-allocation-factory-from-registry.ts @@ -56,6 +56,7 @@ export const GetAllocationFactoryFromRegistry = createApiOperation< >({ paramsSchema: GetAllocationFactoryFromRegistryParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params) => buildRegistryEndpoint(params.registryUrl), buildRequestData: (params) => ({ choiceArguments: params.choiceArguments, diff --git a/src/clients/scan-api/operations/v0/scan.ts b/src/clients/scan-api/operations/v0/scan.ts index e592a2a2..50df5696 100644 --- a/src/clients/scan-api/operations/v0/scan.ts +++ b/src/clients/scan-api/operations/v0/scan.ts @@ -180,6 +180,7 @@ export const GetOpenAndIssuingMiningRounds = createApiOperation< >({ paramsSchema: GetOpenAndIssuingMiningRoundsParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/open-and-issuing-mining-rounds`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -194,6 +195,7 @@ export const GetUpdateHistoryV2 = createApiOperation< >({ paramsSchema: GetUpdateHistoryV2ParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v2/updates`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -227,6 +229,7 @@ export const GetUpdateHistoryV1 = createApiOperation< >({ paramsSchema: GetUpdateHistoryV1ParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v1/updates`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -280,6 +283,7 @@ export const GetAcsSnapshotAt = createApiOperation< >({ paramsSchema: GetAcsSnapshotAtParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/state/acs`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -305,6 +309,7 @@ export const GetHoldingsStateAt = createApiOperation< >({ paramsSchema: GetHoldingsStateAtParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/holdings/state`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -319,6 +324,7 @@ export const GetHoldingsSummaryAt = createApiOperation< >({ paramsSchema: GetHoldingsSummaryAtParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/holdings/summary`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -387,6 +393,7 @@ export const GetAmuletRules = createApiOperation< >({ paramsSchema: GetAmuletRulesParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/amulet-rules`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -402,6 +409,7 @@ export const GetExternalPartyAmuletRules = createApiOperation< >({ paramsSchema: GetExternalPartyAmuletRulesParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/external-party-amulet-rules`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -416,6 +424,7 @@ export const GetAnsRules = createApiOperation< >({ paramsSchema: GetAnsRulesParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/ans-rules`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -554,6 +563,7 @@ export const ListVoteRequestsByTrackingCid = createApiOperation< >({ paramsSchema: ListVoteRequestsByTrackingCidParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/voterequest`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -590,6 +600,7 @@ export const ListVoteRequestResults = createApiOperation< >({ paramsSchema: ListVoteRequestResultsParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/admin/sv/voteresults`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -604,6 +615,7 @@ export const GetMigrationInfo = createApiOperation< >({ paramsSchema: GetMigrationInfoParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/backfilling/migration-info`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -618,6 +630,7 @@ export const GetUpdatesBefore = createApiOperation< >({ paramsSchema: GetUpdatesBeforeParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/backfilling/updates-before`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -671,6 +684,7 @@ export const ListTransactionHistory = createApiOperation< >({ paramsSchema: ListTransactionHistoryParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/transactions`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -685,6 +699,7 @@ export const GetUpdateHistory = createApiOperation< >({ paramsSchema: GetUpdateHistoryParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/updates`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -725,6 +740,7 @@ export const GetImportUpdates = createApiOperation< >({ paramsSchema: GetImportUpdatesParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/backfilling/import-updates`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, @@ -739,6 +755,7 @@ export const GetEventHistory = createApiOperation< >({ paramsSchema: GetEventHistoryParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl) => `${apiUrl}/v0/events`, buildRequestData: (params) => params.body, requestConfig: publicRequestConfig, diff --git a/src/clients/validator-api/operations/v0/admin/generate-external-party-topology.ts b/src/clients/validator-api/operations/v0/admin/generate-external-party-topology.ts index 9d70241e..52f4aa18 100644 --- a/src/clients/validator-api/operations/v0/admin/generate-external-party-topology.ts +++ b/src/clients/validator-api/operations/v0/admin/generate-external-party-topology.ts @@ -31,6 +31,7 @@ export const GenerateExternalPartyTopology = createApiOperation< >({ paramsSchema: GenerateExternalPartyTopologyParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl: string) => `${apiUrl}/api/validator/v0/admin/external-party/topology/generate`, buildRequestData: (params) => params, }); diff --git a/src/clients/validator-api/operations/v0/admin/prepare-accept-external-party-setup-proposal.ts b/src/clients/validator-api/operations/v0/admin/prepare-accept-external-party-setup-proposal.ts index bd070eea..95977ec6 100644 --- a/src/clients/validator-api/operations/v0/admin/prepare-accept-external-party-setup-proposal.ts +++ b/src/clients/validator-api/operations/v0/admin/prepare-accept-external-party-setup-proposal.ts @@ -22,6 +22,7 @@ export const PrepareAcceptExternalPartySetupProposal = createApiOperation< >({ paramsSchema: PrepareAcceptExternalPartySetupProposalParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl: string): string => `${apiUrl}/api/validator/v0/admin/external-party/setup-proposal/prepare-accept`, buildRequestData: (params): PrepareAcceptExternalPartySetupProposalRequest => params, diff --git a/src/clients/validator-api/operations/v0/admin/prepare-transfer-preapproval-send.ts b/src/clients/validator-api/operations/v0/admin/prepare-transfer-preapproval-send.ts index 1c031ad7..bcf3e2eb 100644 --- a/src/clients/validator-api/operations/v0/admin/prepare-transfer-preapproval-send.ts +++ b/src/clients/validator-api/operations/v0/admin/prepare-transfer-preapproval-send.ts @@ -28,6 +28,7 @@ export const PrepareTransferPreapprovalSend = createApiOperation< >({ paramsSchema: PrepareTransferPreapprovalSendParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl: string) => `${apiUrl}/api/validator/v0/admin/external-party/transfer-preapproval/prepare-send`, buildRequestData: (params) => params, diff --git a/src/clients/validator-api/operations/v0/ans/get-rules.ts b/src/clients/validator-api/operations/v0/ans/get-rules.ts index c1656ec8..30747396 100644 --- a/src/clients/validator-api/operations/v0/ans/get-rules.ts +++ b/src/clients/validator-api/operations/v0/ans/get-rules.ts @@ -28,6 +28,7 @@ export const GetAnsRules = createApiOperation< >({ paramsSchema: GetAnsRulesParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params, apiUrl: string) => `${apiUrl}/api/validator/v0/scan-proxy/ans-rules`, buildRequestData: (params) => params, }); diff --git a/src/clients/validator-api/operations/v0/scan-proxy/get-member-traffic-status.ts b/src/clients/validator-api/operations/v0/scan-proxy/get-member-traffic-status.ts index e3920d7d..e27f21dd 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/get-member-traffic-status.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/get-member-traffic-status.ts @@ -1,4 +1,10 @@ -import { type BaseClient } from '../../../../../core'; +import { + createOperationHttpRequestOptions, + snapshotOperationExecuteOptions, + type BaseClient, + type OperationExecuteOptions, +} from '../../../../../core'; +import { awaitWithAbort } from '../../../../../core/http/abort'; import { ApiOperation } from '../../../../../core/operations/ApiOperation'; import { type operations } from '../../../../../generated/apps/scan/src/main/openapi/scan'; import { getCurrentMiningRoundDomainId, type MiningRoundClient } from '../../../../../utils/mining/mining-rounds'; @@ -39,25 +45,64 @@ function hasMiningRoundMethods(client: BaseClient): client is BaseClient & Minin * ```; */ export class GetMemberTrafficStatus extends ApiOperation { - public async execute(params: GetMemberTrafficStatusParams = {}): Promise { + public async execute( + params: GetMemberTrafficStatusParams = {}, + options?: OperationExecuteOptions + ): Promise { + const operationOptions = snapshotOperationExecuteOptions(options); const validatedParams = this.validateParams(params, GetMemberTrafficStatusParamsSchema); + const discoveryOptions: OperationExecuteOptions | undefined = + operationOptions === undefined + ? undefined + : Object.freeze({ + ...(operationOptions.signal !== undefined ? { signal: operationOptions.signal } : {}), + // Discovery is a separate semantic read. Honor an explicit retry disable; hooks whose params describe the + // traffic-status request remain scoped to that final request. + ...(operationOptions.retry?.kind === 'none' ? { retry: Object.freeze({ kind: 'none' as const }) } : {}), + }); + const buildUrl = async (attemptParams: GetMemberTrafficStatusParams): Promise => { + const validatedAttemptParams = this.validateParams(attemptParams, GetMemberTrafficStatusParamsSchema); - // Auto-determine domainId if not provided - let { domainId } = validatedParams; - if (!domainId) { - if (!hasMiningRoundMethods(this.client)) { - throw new Error('Client does not support getOpenAndIssuingMiningRounds - use ValidatorApiClient'); + // Auto-determine domainId if not provided + let { domainId } = validatedAttemptParams; + if (!domainId) { + if (!hasMiningRoundMethods(this.client)) { + throw new Error('Client does not support getOpenAndIssuingMiningRounds - use ValidatorApiClient'); + } + const miningRoundClient = this.client; + domainId = await awaitWithAbort( + async (): Promise => getCurrentMiningRoundDomainId(miningRoundClient, discoveryOptions), + operationOptions?.signal + ); } - domainId = await getCurrentMiningRoundDomainId(this.client); - } - // Auto-determine memberId if not provided - const memberId = validatedParams.memberId ?? this.client.getPartyId(); + // Auto-determine memberId if not provided + const memberId = validatedAttemptParams.memberId ?? this.client.getPartyId(); - const url = `${this.getApiUrl()}/api/validator/v0/scan-proxy/domains/${encodeURIComponent(domainId)}/members/${encodeURIComponent(memberId)}/traffic-status`; + return `${this.getApiUrl()}/api/validator/v0/scan-proxy/domains/${encodeURIComponent(domainId)}/members/${encodeURIComponent(memberId)}/traffic-status`; + }; - return this.makeGetRequest(url, { - includeBearerToken: true, - }); + const url = await buildUrl(validatedParams); + const httpOptions = + operationOptions === undefined + ? undefined + : createOperationHttpRequestOptions({ + initialParams: validatedParams, + options: operationOptions, + requestSemantics: 'read', + initialUrl: url, + validateParams: (derivedParams): GetMemberTrafficStatusParams => + this.validateParams(derivedParams, GetMemberTrafficStatusParamsSchema), + buildUrl, + buildBody: (): undefined => undefined, + }); + + return this.makeGetRequest( + url, + { + includeBearerToken: true, + }, + httpOptions + ); } } diff --git a/src/clients/validator-api/operations/v0/scan-proxy/registry/allocation-instruction/v1/get-allocation-factory.ts b/src/clients/validator-api/operations/v0/scan-proxy/registry/allocation-instruction/v1/get-allocation-factory.ts index b250faff..eee0b768 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/registry/allocation-instruction/v1/get-allocation-factory.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/registry/allocation-instruction/v1/get-allocation-factory.ts @@ -35,6 +35,7 @@ export type GetAllocationFactoryResponse = paths[ApiPath]['post']['responses'][' export const GetAllocationFactory = createApiOperation({ paramsSchema: GetAllocationFactoryParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params: GetAllocationFactoryParams, apiUrl: string) => `${apiUrl}${endpoint}`, buildRequestData: (params: GetAllocationFactoryParams) => ({ choiceArguments: params.choiceArguments, diff --git a/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-cancel-context.ts b/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-cancel-context.ts index 3cc87b1e..f4a012cf 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-cancel-context.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-cancel-context.ts @@ -38,6 +38,7 @@ export const GetAllocationCancelContext = createApiOperation< >({ paramsSchema: GetAllocationCancelContextParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params: GetAllocationCancelContextParams, apiUrl: string) => `${apiUrl}${endpoint}/${params.allocationId}/choice-contexts/cancel`, buildRequestData: (params: GetAllocationCancelContextParams): GetAllocationCancelContextRequest => { diff --git a/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-transfer-context.ts b/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-transfer-context.ts index 188e19b1..70b8245f 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-transfer-context.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-transfer-context.ts @@ -38,6 +38,7 @@ export const GetAllocationTransferContext = createApiOperation< >({ paramsSchema: GetAllocationTransferContextParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params: GetAllocationTransferContextParams, apiUrl: string) => `${apiUrl}${endpoint}/${params.allocationId}/choice-contexts/execute-transfer`, buildRequestData: (params: GetAllocationTransferContextParams): GetAllocationTransferContextRequest => { diff --git a/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-withdraw-context.ts b/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-withdraw-context.ts index b98e0ca3..2a8a1525 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-withdraw-context.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/registry/allocations/v1/get-allocation-withdraw-context.ts @@ -38,6 +38,7 @@ export const GetAllocationWithdrawContext = createApiOperation< >({ paramsSchema: GetAllocationWithdrawContextParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params: GetAllocationWithdrawContextParams, apiUrl: string) => `${apiUrl}${endpoint}/${params.allocationId}/choice-contexts/withdraw`, buildRequestData: (params: GetAllocationWithdrawContextParams): GetAllocationWithdrawContextRequest => { diff --git a/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-factory.ts b/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-factory.ts index a02c3a51..068c817e 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-factory.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-factory.ts @@ -33,6 +33,7 @@ export type GetTransferFactoryResponse = paths[ApiPath]['post']['responses']['20 export const GetTransferFactory = createApiOperation({ paramsSchema: GetTransferFactoryParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (_params: GetTransferFactoryParams, apiUrl: string) => `${apiUrl}${endpoint}`, buildRequestData: (params: GetTransferFactoryParams) => ({ choiceArguments: params.choiceArguments, diff --git a/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-accept-context.ts b/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-accept-context.ts index 369bee40..5bdd8a45 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-accept-context.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-accept-context.ts @@ -31,6 +31,7 @@ export const GetTransferInstructionAcceptContext = createApiOperation< >({ paramsSchema: GetTransferInstructionAcceptContextParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params: GetTransferInstructionAcceptContextParams, apiUrl: string) => `${apiUrl}/api/validator/v0/scan-proxy/registry/transfer-instruction/v1/${params.transferInstructionId}/choice-contexts/accept`, buildRequestData: ( diff --git a/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-reject-context.ts b/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-reject-context.ts index 2089109b..06d5bae1 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-reject-context.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-reject-context.ts @@ -31,6 +31,7 @@ export const GetTransferInstructionRejectContext = createApiOperation< >({ paramsSchema: GetTransferInstructionRejectContextParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params: GetTransferInstructionRejectContextParams, apiUrl: string) => `${apiUrl}/api/validator/v0/scan-proxy/registry/transfer-instruction/v1/${params.transferInstructionId}/choice-contexts/reject`, buildRequestData: ( diff --git a/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-withdraw-context.ts b/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-withdraw-context.ts index 476c2976..67641be8 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-withdraw-context.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/registry/transfer-instruction/v1/get-transfer-instruction-withdraw-context.ts @@ -33,6 +33,7 @@ export const GetTransferInstructionWithdrawContext = createApiOperation< >({ paramsSchema: GetTransferInstructionWithdrawContextParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params: GetTransferInstructionWithdrawContextParams, apiUrl: string) => `${apiUrl}/api/validator/v0/scan-proxy/registry/transfer-instruction/v1/${params.transferInstructionId}/choice-contexts/withdraw`, buildRequestData: ( diff --git a/src/clients/validator-api/operations/v0/wallet/buy-traffic-requests/get-status.ts b/src/clients/validator-api/operations/v0/wallet/buy-traffic-requests/get-status.ts index 1cf1c8c3..47bb38ba 100644 --- a/src/clients/validator-api/operations/v0/wallet/buy-traffic-requests/get-status.ts +++ b/src/clients/validator-api/operations/v0/wallet/buy-traffic-requests/get-status.ts @@ -18,6 +18,7 @@ export const GetBuyTrafficRequestStatus = createApiOperation< >({ paramsSchema: GetBuyTrafficRequestStatusParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params, apiUrl: string) => `${apiUrl}/api/validator/v0/wallet/buy-traffic-requests/${params.trackingId}/status`, buildRequestData: () => ({}), diff --git a/src/clients/validator-api/operations/v0/wallet/transfer-offers/get-status.ts b/src/clients/validator-api/operations/v0/wallet/transfer-offers/get-status.ts index aa762643..b00e91a7 100644 --- a/src/clients/validator-api/operations/v0/wallet/transfer-offers/get-status.ts +++ b/src/clients/validator-api/operations/v0/wallet/transfer-offers/get-status.ts @@ -18,6 +18,7 @@ export const GetTransferOfferStatus = createApiOperation< >({ paramsSchema: GetTransferOfferStatusParamsSchema, method: 'POST', + requestSemantics: 'read', buildUrl: (params, apiUrl: string) => `${apiUrl}/api/validator/v0/wallet/transfer-offers/${params.trackingId}/status`, buildRequestData: () => ({}), }); diff --git a/src/core/BaseClient.ts b/src/core/BaseClient.ts index 3100a944..af98ea05 100644 --- a/src/core/BaseClient.ts +++ b/src/core/BaseClient.ts @@ -1,8 +1,16 @@ import { type PartyId, type UserId } from './branded-types'; import { ConfigurationError } from './errors'; import { HttpClient } from './http/HttpClient'; +import { type HttpReadRequestOptions, type HttpRequestOptions } from './http/request-retry'; import { type CantonRuntime } from './runtime'; -import { type ApiType, type ClientConfig, type NetworkType, type PartialProviderConfig, type ProviderType, type RequestConfig } from './types'; +import { + type ApiType, + type ClientConfig, + type NetworkType, + type PartialProviderConfig, + type ProviderType, + type RequestConfig, +} from './types'; /** Abstract base class providing common functionality for all API clients */ export abstract class BaseClient { @@ -40,7 +48,7 @@ export abstract class BaseClient { } this.httpClient = new HttpClient(this.clientConfig.logger, async () => - this.runtime.getAuthenticationManager(this.config.authUrl, apiConfig.auth).authenticate(), + this.runtime.getAuthenticationManager(this.config.authUrl, apiConfig.auth).authenticate() ); } @@ -97,20 +105,38 @@ export abstract class BaseClient { return this.runtime.getAuthenticationManager(this.config.authUrl, apiConfig.auth).getTokenLifetimeMs(); } - public async makeGetRequest(url: string, config: RequestConfig = {}): Promise { - return this.httpClient.makeGetRequest(url, config); + public async makeGetRequest( + url: string, + config: RequestConfig = {}, + options: HttpReadRequestOptions = {} + ): Promise { + return this.httpClient.makeGetRequest(url, config, options); } - public async makePostRequest(url: string, data: unknown, config: RequestConfig = {}): Promise { - return this.httpClient.makePostRequest(url, data, config); + public async makePostRequest( + url: string, + data: Body, + config: RequestConfig = {}, + options: HttpRequestOptions = {} + ): Promise { + return this.httpClient.makePostRequest(url, data, config, options); } - public async makeDeleteRequest(url: string, config: RequestConfig = {}): Promise { - return this.httpClient.makeDeleteRequest(url, config); + public async makeDeleteRequest( + url: string, + config: RequestConfig = {}, + options: HttpRequestOptions = {} + ): Promise { + return this.httpClient.makeDeleteRequest(url, config, options); } - public async makePatchRequest(url: string, data: unknown, config: RequestConfig = {}): Promise { - return this.httpClient.makePatchRequest(url, data, config); + public async makePatchRequest( + url: string, + data: Body, + config: RequestConfig = {}, + options: HttpRequestOptions = {} + ): Promise { + return this.httpClient.makePatchRequest(url, data, config, options); } public getLogger(): import('./logging').Logger | undefined { @@ -196,5 +222,4 @@ export abstract class BaseClient { public getApiType(): ApiType { return this.apiType; } - } diff --git a/src/core/errors.ts b/src/core/errors.ts index 2832f759..4e8d34f1 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -27,6 +27,7 @@ export const ErrorCode = { API_ERROR: 'API_ERROR', VALIDATION_ERROR: 'VALIDATION_ERROR', NETWORK_ERROR: 'NETWORK_ERROR', + UNKNOWN_MUTATION_OUTCOME: 'UNKNOWN_MUTATION_OUTCOME', } as const; export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; @@ -108,6 +109,55 @@ export class NetworkError extends CantonError { } } +/** HTTP methods whose requests may have changed server state before the response was lost. */ +export type MutationHttpMethod = 'POST' | 'PATCH' | 'DELETE'; + +/** Safe, redacted details for a mutation whose server-side outcome cannot be determined. */ +export interface UnknownMutationOutcomeDetails { + readonly method: MutationHttpMethod; + readonly endpoint: string; + readonly attempts: number; + readonly attemptIdentifiers?: readonly string[]; +} + +/** + * Error thrown when a mutation was dispatched but the transport could not determine whether the server applied it. + * + * Request bodies, signatures, and tokens are deliberately excluded from the message and serializable context. Callers + * may opt into a retry strategy and supply explicitly redacted attempt identifiers when an operation is safe to retry. + */ +export class UnknownMutationOutcomeError extends CantonError { + public override readonly name: string; + public readonly method: MutationHttpMethod; + public readonly endpoint: string; + public readonly attempts: number; + public readonly attemptIdentifiers: readonly string[]; + public readonly cause: Error; + + constructor(details: UnknownMutationOutcomeDetails, cause: Error) { + const attemptIdentifiers = Object.freeze([...(details.attemptIdentifiers ?? [])]); + const context: ErrorContext = { + method: details.method, + endpoint: details.endpoint, + attempts: details.attempts, + ...(attemptIdentifiers.length > 0 ? { attemptIdentifiers } : {}), + }; + + super( + `The outcome of ${details.method} ${details.endpoint} is unknown after ${details.attempts} request attempt${details.attempts === 1 ? '' : 's'}`, + ErrorCode.UNKNOWN_MUTATION_OUTCOME, + context + ); + this.name = 'UnknownMutationOutcomeError'; + this.method = details.method; + this.endpoint = details.endpoint; + this.attempts = details.attempts; + this.attemptIdentifiers = attemptIdentifiers; + this.cause = cause; + Object.defineProperty(this, 'cause', { enumerable: false }); + } +} + /** Error codes for operation-specific errors. */ export const OperationErrorCode = { MISSING_CONTRACT: 'MISSING_CONTRACT', @@ -139,6 +189,7 @@ const CANTON_SDK_ERROR_NAMES = new Set([ 'ConfigurationError', 'NetworkError', 'OperationError', + 'UnknownMutationOutcomeError', 'ValidationError', ]); @@ -165,23 +216,14 @@ export function normalizeCantonError(error: unknown): NormalizedCantonErrorDetai }; } -/** Return true when a Canton error represents a non-retryable 4xx mutation rejection. */ +/** Return true when a Canton response proves that a mutation was rejected, independently from retryability. */ export function isDefiniteCantonMutationRejection(error: unknown): boolean { const details = normalizeCantonError(error); const status = details?.status; if (status === undefined) return false; - if (isRetryableCantonApiCode(status, details?.code)) return false; return status >= 400 && status < 500 && status !== 408 && status !== 425 && status !== 429; } -/** Return true when a Canton API status/code pair is known to be transient. */ -function isRetryableCantonApiCode(status: number, code: string | undefined): boolean { - return ( - (status === 400 && code === 'UNKNOWN_CONTRACT_SYNCHRONIZERS') || - (status === 409 && code === 'SEQUENCER_BACKPRESSURE') - ); -} - /** Read normalized error context from current and legacy SDK error fields. */ function readErrorContext(source: Record): unknown { const { context } = source; diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index df6529db..8af7ae11 100644 --- a/src/core/http/HttpClient.ts +++ b/src/core/http/HttpClient.ts @@ -1,16 +1,49 @@ -import axios, { type AxiosInstance } from 'axios'; -import { ApiError, AuthenticationError, NetworkError } from '../errors'; +import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'; +import { + ApiError, + AuthenticationError, + CantonError, + ConfigurationError, + NetworkError, + UnknownMutationOutcomeError, + isDefiniteCantonMutationRejection, + type MutationHttpMethod, +} from '../errors'; import { type Logger } from '../logging'; import { type RequestConfig } from '../types'; +import { awaitWithAbort, createAbortError, isAbortError, throwIfAborted } from './abort'; +import { + snapshotHttpRequestOptions, + type DeepReadonly, + type HttpReadAttemptUrlContext, + type HttpReadRequestOptions, + type HttpRequestAttemptContext, + type HttpRequestOptions, + type HttpRequestRetryContext, + type HttpRequestRetryStrategy, + type RequestAttemptSummary, + type RequestErrorClassification, + type RequestOutcomeCertainty, + type RequestSemantics, +} from './request-retry'; +import { cloneRequestValue, deepFreezeRequestValue } from './request-value'; export interface HttpClientRetryConfig { - /** Number of retries after the initial attempt. */ + /** Number of retries after the initial attempt for requests explicitly classified as semantic reads. */ readonly maxRetries: number; - /** Delay between retries in milliseconds. */ + /** Delay between read-only retries in milliseconds. */ readonly delayMs: number; } -/** Handles HTTP requests with authentication, logging, and error handling */ +type HttpMethod = 'GET' | MutationHttpMethod; + +interface AttemptFailure { + readonly error: Error; + readonly retryContext: HttpRequestRetryContext; + readonly summary: RequestAttemptSummary; +} + +/** Handles HTTP requests with authentication, logging, cancellation, and explicit retry safety. */ export class HttpClient { private readonly axiosInstance: AxiosInstance; private readonly bearerTokenProvider: (() => Promise) | undefined; @@ -21,6 +54,7 @@ export class HttpClient { private static readonly CAUSE_TRUNCATE_LENGTH = 200; private static readonly CONTEXT_VALUE_TRUNCATE_LENGTH = 50; private static readonly MAX_CONTEXT_KEYS = 3; + private static readonly MAX_ATTEMPT_IDENTIFIER_LENGTH = 200; constructor(logger?: Logger, bearerTokenProvider?: () => Promise) { this.axiosInstance = axios.create(); @@ -28,127 +62,384 @@ export class HttpClient { this.logger = logger; } + /** Configure the default retry policy used only by requests classified as semantic reads. */ public setRetryConfig(config: HttpClientRetryConfig): void { - this.retryConfig = config; + if (!Number.isInteger(config.maxRetries) || config.maxRetries < 0) { + throw new ConfigurationError('HTTP maxRetries must be a non-negative integer'); + } + if (!Number.isFinite(config.delayMs) || config.delayMs < 0) { + throw new ConfigurationError('HTTP retry delayMs must be a non-negative finite number'); + } + this.retryConfig = { ...config }; } - public async makeGetRequest(url: string, config: RequestConfig = {}, _retryCount = 0): Promise { - try { - const headers = await this.buildHeaders(config); - const response = await this.axiosInstance.get(url, { headers }); + public async makeGetRequest( + url: string, + config: RequestConfig = {}, + options: HttpReadRequestOptions = {} + ): Promise { + return this.makeRequest('GET', url, undefined, config, options); + } - await this.logRequestResponse(url, { method: 'GET', headers }, response.data); - return response.data; - } catch (error) { - // Attempt up to 3 retries for transient errors - if (_retryCount < this.retryConfig.maxRetries && this.isRetryableError(error)) { - await this.logRequestResponse( - url, - { method: 'GET', retry: _retryCount + 1 }, - `Retrying after error (attempt ${_retryCount + 1}/${this.retryConfig.maxRetries}): ${ - axios.isAxiosError(error) ? (error.response?.status ?? 'network error') : String(error) - }` - ); - await this.sleep(this.retryConfig.delayMs); - return this.makeGetRequest(url, config, _retryCount + 1); - } + public async makePostRequest( + url: string, + data: Body, + config: RequestConfig = {}, + options: HttpRequestOptions = {} + ): Promise { + return this.makeRequest('POST', url, data, config, options); + } - // Log the error response before throwing - if (axios.isAxiosError(error)) { - await this.logRequestResponse(url, { method: 'GET' }, error.response?.data ?? error.message); - } - throw this.handleRequestError(error); - } + public async makeDeleteRequest( + url: string, + config: RequestConfig = {}, + options: HttpRequestOptions = {} + ): Promise { + return this.makeRequest('DELETE', url, undefined, config, options); } - public async makePostRequest(url: string, data: unknown, config: RequestConfig = {}, _retryCount = 0): Promise { - try { - const headers = await this.buildHeaders(config); - const response = await this.axiosInstance.post(url, data, { headers }); + public async makePatchRequest( + url: string, + data: Body, + config: RequestConfig = {}, + options: HttpRequestOptions = {} + ): Promise { + return this.makeRequest('PATCH', url, data, config, options); + } - await this.logRequestResponse(url, { method: 'POST', headers, data }, response.data); - return response.data; - } catch (error) { - if (_retryCount < this.retryConfig.maxRetries && this.isRetryableError(error)) { - await this.logRequestResponse( - url, - { method: 'POST', retry: _retryCount + 1, data }, - `Retrying after error (attempt ${_retryCount + 1}/${this.retryConfig.maxRetries}): ${ - axios.isAxiosError(error) ? (error.response?.status ?? 'network error') : String(error) - }` + private async makeRequest( + method: HttpMethod, + url: string, + initialBody: Body, + config: RequestConfig, + options: HttpRequestOptions + ): Promise { + // Snapshot every caller-owned option before the first asynchronous boundary. In particular, never let replacing an + // options signal or retry object while a token/hook is pending change the in-flight request contract. + const requestOptions = snapshotHttpRequestOptions(options); + const requestConfig: RequestConfig = Object.freeze({ ...config }); + const { + signal, + retry: requestedRetry, + requestSemantics: requestedSemantics, + resolveReadAttemptUrl, + } = requestOptions; + const requestSemantics = requestedSemantics ?? (method === 'GET' ? 'read' : 'mutation'); + if (method === 'GET' && requestSemantics !== 'read') { + throw new ConfigurationError('HTTP GET requests must use read semantics'); + } + if (resolveReadAttemptUrl !== undefined && requestSemantics !== 'read') { + throw new ConfigurationError('Endpoint failover is only supported for semantic reads'); + } + const strategy = this.resolveRetryStrategy(requestSemantics, requestedRetry); + const maxAttempts = strategy.kind === 'none' ? 1 : strategy.maxAttempts; + this.validateMaxAttempts(maxAttempts); + + const requiresImmutableBody = strategy.kind !== 'none'; + let currentBody = this.cloneBody(initialBody, requiresImmutableBody); + let lastFailure: AttemptFailure | undefined; + const failedAttempts: RequestAttemptSummary[] = []; + + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + let attemptContext: HttpRequestAttemptContext; + let attemptUrl = url; + let identifier: string | undefined; + try { + throwIfAborted(signal); + + if (attempt > 1 && strategy.kind === 'derived-body') { + const priorFailure = lastFailure; + if (!priorFailure) { + throw new ConfigurationError('Cannot derive a retry body without a failed request attempt'); + } + const derivedBody = await awaitWithAbort(async (): Promise> => { + const body = await strategy.deriveBody(priorFailure.retryContext); + return body; + }, signal); + currentBody = this.cloneDerivedBody(derivedBody); + } + + attemptContext = this.createAttemptContext(attempt, currentBody, failedAttempts, signal, requiresImmutableBody); + if (resolveReadAttemptUrl !== undefined) { + const urlContext: HttpReadAttemptUrlContext = Object.freeze({ + ...attemptContext, + originalUrl: url, + }); + const resolvedAttemptUrl: unknown = await awaitWithAbort( + async (): Promise => resolveReadAttemptUrl(urlContext), + signal + ); + if (typeof resolvedAttemptUrl !== 'string' || resolvedAttemptUrl.length === 0) { + throw new ConfigurationError('Read endpoint resolver must return a non-empty URL'); + } + attemptUrl = resolvedAttemptUrl; + } + const getAttemptIdentifier = strategy.kind === 'none' ? undefined : strategy.getAttemptIdentifier; + identifier = getAttemptIdentifier + ? this.redactAttemptIdentifier( + await awaitWithAbort(async (): Promise => { + const attemptIdentifier = await getAttemptIdentifier(attemptContext); + return attemptIdentifier; + }, signal) + ) + : undefined; + + const beforeAttempt = strategy.kind === 'none' ? undefined : strategy.beforeAttempt; + if (beforeAttempt) { + await awaitWithAbort(async (): Promise => { + await beforeAttempt(attemptContext); + }, signal); + } + throwIfAborted(signal); + } catch (preDispatchError) { + throw this.preservePriorAmbiguity( + requestSemantics, + method, + attemptUrl, + this.normalizeCallbackError(preDispatchError), + failedAttempts ); - await this.sleep(this.retryConfig.delayMs); - const retryData = this.prepareDataForRetry(data); - return this.makePostRequest(url, retryData, config, _retryCount + 1); } - // Log the error response before throwing - if (axios.isAxiosError(error)) { - await this.logRequestResponse(url, { method: 'POST', data }, error.response?.data ?? error.message); + let dispatched = false; + try { + const headers = await awaitWithAbort(async (): Promise> => { + const builtHeaders = await this.buildHeaders(requestConfig); + return builtHeaders; + }, signal); + throwIfAborted(signal); + const requestBody = this.cloneBody(currentBody, requiresImmutableBody); + dispatched = true; + const response = await this.dispatchRequest(method, attemptUrl, requestBody, headers, signal); + + this.observeLog( + attemptUrl, + method === 'GET' || method === 'DELETE' ? { method, headers } : { method, headers, data: requestBody }, + response + ); + return response; + } catch (rawError) { + if (this.isCanceledError(rawError) || isAbortError(rawError)) { + const abortError = createAbortError(signal, rawError); + if (requestSemantics === 'read' || (!dispatched && !this.hasAmbiguousAttempt(failedAttempts))) { + throw abortError; + } + if (!dispatched) { + throw this.toTerminalError(requestSemantics, method, attemptUrl, abortError, failedAttempts); + } + const canceledAttempt: RequestAttemptSummary = Object.freeze({ + attempt, + ...(identifier !== undefined ? { identifier } : {}), + errorClassification: 'ambiguous-mutation-outcome', + outcomeCertainty: 'ambiguous', + retryable: false, + }); + throw this.toTerminalError(requestSemantics, method, attemptUrl, abortError, [ + ...failedAttempts, + canceledAttempt, + ]); + } + + const error = this.handleRequestError(rawError); + const retryable = this.isRetryableError(rawError); + const { errorClassification, outcomeCertainty } = this.classifyRequestError( + requestSemantics, + error, + dispatched, + retryable + ); + const summary: RequestAttemptSummary = Object.freeze({ + attempt, + ...(identifier !== undefined ? { identifier } : {}), + errorClassification, + outcomeCertainty, + retryable, + }); + const retryContext: HttpRequestRetryContext = Object.freeze({ + ...attemptContext, + error, + errorClassification, + outcomeCertainty, + retryable, + }); + const failure: AttemptFailure = { + error, + retryContext, + summary, + }; + lastFailure = failure; + + this.logFailedRequest(method, attemptUrl, currentBody, rawError); + + let retryRequest: boolean; + try { + retryRequest = await this.shouldRetry(strategy, failure, attempt, maxAttempts, requestSemantics, signal); + } catch (retryHookError) { + throw this.toTerminalError( + requestSemantics, + method, + attemptUrl, + this.normalizeCallbackError(retryHookError), + [...failedAttempts, summary] + ); + } + + if (retryRequest) { + failedAttempts.push(summary); + this.logRetry(method, attemptUrl, attempt, maxAttempts, summary, rawError); + try { + await this.waitForRetry(strategy, retryContext, signal); + } catch (backoffError) { + const normalizedBackoffError = this.normalizeCallbackError(backoffError); + throw this.toTerminalError(requestSemantics, method, attemptUrl, normalizedBackoffError, failedAttempts); + } + continue; + } + + throw this.toTerminalError(requestSemantics, method, attemptUrl, error, [...failedAttempts, summary]); } - throw this.handleRequestError(error); } + + // The loop either returns or throws for every attempt. + throw new ConfigurationError('HTTP retry loop completed without a response or error'); } - public async makeDeleteRequest(url: string, config: RequestConfig = {}, _retryCount = 0): Promise { - try { - const headers = await this.buildHeaders(config); - const response = await this.axiosInstance.delete(url, { headers }); + private resolveRetryStrategy( + requestSemantics: RequestSemantics, + retry: HttpRequestRetryStrategy | undefined + ): HttpRequestRetryStrategy { + if (retry) { + if (retry.kind === 'none') return Object.freeze({ kind: 'none' }); + if (retry.kind === 'exact-body') return Object.freeze({ ...retry }); + return Object.freeze({ ...retry }); + } - await this.logRequestResponse(url, { method: 'DELETE', headers }, response.data); - return response.data; - } catch (error) { - if (_retryCount < this.retryConfig.maxRetries && this.isRetryableError(error)) { - await this.logRequestResponse( - url, - { method: 'DELETE', retry: _retryCount + 1 }, - `Retrying after error (attempt ${_retryCount + 1}/${this.retryConfig.maxRetries}): ${ - axios.isAxiosError(error) ? (error.response?.status ?? 'network error') : String(error) - }` - ); - await this.sleep(this.retryConfig.delayMs); - return this.makeDeleteRequest(url, config, _retryCount + 1); - } + // Semantic reads retain transient retry behavior regardless of HTTP verb. Mutations must explicitly opt in because + // their server outcome may be unknown after a response is lost. + if (requestSemantics === 'read') { + return Object.freeze({ + kind: 'exact-body', + maxAttempts: this.retryConfig.maxRetries + 1, + backoffMs: this.retryConfig.delayMs, + }); + } + return Object.freeze({ kind: 'none' }); + } - // Log the error response before throwing - if (axios.isAxiosError(error)) { - await this.logRequestResponse(url, { method: 'DELETE' }, error.response?.data ?? error.message); - } - throw this.handleRequestError(error); + private validateMaxAttempts(maxAttempts: number): void { + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { + throw new ConfigurationError('HTTP retry maxAttempts must be a positive integer'); } } - public async makePatchRequest( - url: string, - data: unknown, - config: RequestConfig = {}, - _retryCount = 0 - ): Promise { + private async shouldRetry( + strategy: HttpRequestRetryStrategy, + failure: AttemptFailure, + attempt: number, + maxAttempts: number, + requestSemantics: RequestSemantics, + signal: AbortSignal | undefined + ): Promise { + if (strategy.kind === 'none' || attempt >= maxAttempts) return false; + const { shouldRetry } = strategy; + if (shouldRetry) { + return awaitWithAbort(async (): Promise => { + const retry = await shouldRetry(failure.retryContext); + return retry; + }, signal); + } + if (requestSemantics === 'read') return failure.summary.retryable; + return failure.summary.outcomeCertainty !== 'ambiguous' && failure.summary.retryable; + } + + private async waitForRetry( + strategy: HttpRequestRetryStrategy, + context: HttpRequestRetryContext, + signal: AbortSignal | undefined + ): Promise { + if (strategy.kind === 'none') return; + const configuredBackoff = strategy.backoffMs ?? this.retryConfig.delayMs; + const delayMs = + typeof configuredBackoff === 'function' + ? await awaitWithAbort(async (): Promise => { + const backoff = await configuredBackoff(context); + return backoff; + }, signal) + : configuredBackoff; + if (!Number.isFinite(delayMs) || delayMs < 0) { + throw new ConfigurationError('HTTP retry backoffMs must resolve to a non-negative finite number'); + } + if (delayMs === 0) { + throwIfAborted(signal); + return; + } + await this.abortableSleep(delayMs, signal); + } + + private createAttemptContext( + attempt: number, + body: Body, + previousAttempts: readonly RequestAttemptSummary[], + signal: AbortSignal | undefined, + immutableBody: boolean + ): HttpRequestAttemptContext { + const context = { + attempt, + body: immutableBody ? this.createReadonlyBody(body) : (body as DeepReadonly), + previousAttempts: Object.freeze([...previousAttempts]), + ...(signal !== undefined ? { signal } : {}), + }; + return Object.freeze(context); + } + + private createReadonlyBody(body: Body): DeepReadonly { + const cloned = this.cloneBody(body, true); + return deepFreezeRequestValue(cloned) as DeepReadonly; + } + + private cloneBody(body: Body, required: boolean): Body { try { - const headers = await this.buildHeaders(config); - const response = await this.axiosInstance.patch(url, data, { headers }); + return cloneRequestValue(body); + } catch (error) { + if (!required) return body; + throw new ConfigurationError('Retryable HTTP request bodies must be structured-cloneable', { + cause: error instanceof Error ? error.message : 'unknown clone failure', + }); + } + } - await this.logRequestResponse(url, { method: 'PATCH', headers, data }, response.data); - return response.data; + /** Convert a deeply readonly callback result back into an isolated mutable transport snapshot. */ + private cloneDerivedBody(body: DeepReadonly): Body { + try { + return cloneRequestValue(body) as Body; } catch (error) { - if (_retryCount < this.retryConfig.maxRetries && this.isRetryableError(error)) { - await this.logRequestResponse( - url, - { method: 'PATCH', retry: _retryCount + 1, data }, - `Retrying after error (attempt ${_retryCount + 1}/${this.retryConfig.maxRetries}): ${ - axios.isAxiosError(error) ? (error.response?.status ?? 'network error') : String(error) - }` - ); - await this.sleep(this.retryConfig.delayMs); - const retryData = this.prepareDataForRetry(data); - return this.makePatchRequest(url, retryData, config, _retryCount + 1); - } + throw new ConfigurationError('Derived HTTP request bodies must be structured-cloneable', { + cause: error instanceof Error ? error.message : 'unknown clone failure', + }); + } + } - // Log the error response before throwing - if (axios.isAxiosError(error)) { - await this.logRequestResponse(url, { method: 'PATCH', data }, error.response?.data ?? error.message); - } - throw this.handleRequestError(error); + private async dispatchRequest( + method: HttpMethod, + url: string, + body: Body, + headers: Record, + signal: AbortSignal | undefined + ): Promise { + const axiosConfig: AxiosRequestConfig = { + headers, + ...(signal !== undefined ? { signal } : {}), + }; + + switch (method) { + case 'GET': + return (await this.axiosInstance.get(url, axiosConfig)).data; + case 'POST': + return (await this.axiosInstance.post(url, body, axiosConfig)).data; + case 'DELETE': + return (await this.axiosInstance.delete(url, axiosConfig)).data; + case 'PATCH': + return (await this.axiosInstance.patch(url, body, axiosConfig)).data; } } @@ -175,13 +466,74 @@ export class HttpClient { return headers; } - private async logRequestResponse(url: string, request: unknown, response: unknown): Promise { - if (this.logger) { - await this.logger.logRequestResponse(url, request, response); + /** Invoke logging as a detached observer. Logger behavior can never affect request control flow. */ + private observeLog(url: string, request: unknown, response: unknown): void { + if (!this.logger) return; + try { + const requestSnapshot = this.createLogSnapshot(request, true); + const responseSnapshot = this.createLogSnapshot(response); + void Promise.resolve(this.logger.logRequestResponse(url, requestSnapshot, responseSnapshot)).catch( + () => undefined + ); + } catch { + // Logging is deliberately observational, including for synchronous logger failures. + } + } + + /** Isolate logger-owned values so a custom logger cannot mutate request or response control-flow state. */ + private createLogSnapshot(value: unknown, redactRequestHeaders = false): unknown { + try { + const snapshot: unknown = cloneRequestValue(value); + if (redactRequestHeaders) this.redactSensitiveLogHeaders(snapshot); + return deepFreezeRequestValue(snapshot); + } catch { + return '[log value unavailable]'; + } + } + + /** Remove credentials from logger-owned header snapshots without changing dispatch headers. */ + private redactSensitiveLogHeaders(request: unknown): void { + if (typeof request !== 'object' || request === null || !('headers' in request)) return; + const { headers } = request; + if (typeof headers !== 'object' || headers === null) return; + + for (const headerName of Object.keys(headers)) { + const normalizedHeaderName = headerName.toLowerCase(); + if (normalizedHeaderName === 'authorization' || normalizedHeaderName === 'proxy-authorization') { + Reflect.set(headers, headerName, '[REDACTED]'); + } } } + private logFailedRequest(method: HttpMethod, url: string, body: Body, error: unknown): void { + if (!axios.isAxiosError(error)) return; + const request = method === 'GET' || method === 'DELETE' ? { method } : { method, data: body }; + this.observeLog(url, request, error.response?.data ?? error.message); + } + + private logRetry( + method: HttpMethod, + url: string, + attempt: number, + maxAttempts: number, + summary: RequestAttemptSummary, + error: unknown + ): void { + this.observeLog( + url, + { + method, + nextAttempt: attempt + 1, + ...(summary.identifier !== undefined ? { attemptIdentifier: summary.identifier } : {}), + }, + `Retrying after ${summary.errorClassification} (next attempt ${attempt + 1}/${maxAttempts}): ${ + axios.isAxiosError(error) ? (error.response?.status ?? 'network error') : 'request error' + }` + ); + } + private handleRequestError(error: unknown): Error { + if (error instanceof CantonError) return error; if (axios.isAxiosError(error)) { const status = error.response?.status; const data = (error.response?.data ?? {}) as Record; @@ -228,10 +580,7 @@ export class HttpClient { return new NetworkError(`Request failed: ${error instanceof Error ? error.message : String(error)}`); } - /** - * Formats a context object into a summary string for error messages. Shows up to MAX_CONTEXT_KEYS keys with truncated - * values. - */ + /** Formats a context object into a summary string. Shows up to MAX_CONTEXT_KEYS keys with truncated values. */ private formatContextSummary(contextObj: Record): string | undefined { const contextKeys = Object.keys(contextObj); if (contextKeys.length === 0) { @@ -252,10 +601,7 @@ export class HttpClient { .join(', '); } - /** - * Safely converts a context value to a string representation. Handles null, undefined, strings, and objects (with - * circular reference protection). - */ + /** Safely converts a context value to a string representation. */ private stringifyContextValue(v: unknown): string { if (typeof v === 'string') { return v; @@ -267,74 +613,127 @@ export class HttpClient { return 'undefined'; } try { - // JSON.stringify can return undefined for functions, Symbols, or objects with toJSON() returning undefined const result = JSON.stringify(v); return typeof result === 'string' ? result : '[Object]'; } catch { - // Handle circular references, BigInt, or other non-serializable values return '[Object]'; } } - /** - * Determines whether a request error is retryable. Retries on: - * - * - HTTP 404 (transient during Canton node restarts) - * - HTTP 5xx server errors - * - Network errors - * - Canton-specific transient errors: UNKNOWN_CONTRACT_SYNCHRONIZERS (400), SEQUENCER_BACKPRESSURE (409), HTTP 503 - */ + /** Return true for transport failures that the SDK has historically classified as transient. */ private isRetryableError(error: unknown): boolean { if (axios.isAxiosError(error)) { const status = error.response?.status; const data = (error.response?.data ?? {}) as Record; const code = typeof data['code'] === 'string' ? data['code'] : undefined; - // Retry on undefined status (network error) - if (status === undefined) { - return true; - } + if (status === undefined || status === 404 || (status >= 500 && status < 600)) return true; + if (status === 400 && code === 'UNKNOWN_CONTRACT_SYNCHRONIZERS') return true; + if (status === 409 && code === 'SEQUENCER_BACKPRESSURE') return true; + return false; + } + return error instanceof NetworkError; + } - // Retry on 404 - Canton nodes may return transient 404s during restarts - if (status === 404) { - return true; - } + private classifyRequestError( + requestSemantics: RequestSemantics, + error: Error, + dispatched: boolean, + retryable: boolean + ): { + readonly errorClassification: RequestErrorClassification; + readonly outcomeCertainty: RequestOutcomeCertainty; + } { + if (!dispatched) { + return { errorClassification: 'pre-dispatch-failure', outcomeCertainty: 'not-dispatched' }; + } + if (requestSemantics === 'read') { + return { + errorClassification: retryable ? 'transient-read-failure' : 'definite-rejection', + outcomeCertainty: 'definite', + }; + } + if (isDefiniteCantonMutationRejection(error)) { + return { errorClassification: 'definite-rejection', outcomeCertainty: 'definite' }; + } + return { errorClassification: 'ambiguous-mutation-outcome', outcomeCertainty: 'ambiguous' }; + } - // Retry on 5xx server errors - if (status >= 500 && status < 600) { - return true; - } + private toTerminalError( + requestSemantics: RequestSemantics, + method: HttpMethod, + url: string, + error: Error, + attempts: readonly RequestAttemptSummary[] + ): Error { + if (requestSemantics === 'read' || method === 'GET' || !this.hasAmbiguousAttempt(attempts)) { + return error; + } - // Retry on Canton-specific transient errors - if (status === 400 && code === 'UNKNOWN_CONTRACT_SYNCHRONIZERS') { - return true; - } + const attemptIdentifiers = attempts.flatMap((attempt) => + attempt.identifier === undefined ? [] : [attempt.identifier] + ); + return new UnknownMutationOutcomeError( + { + method, + endpoint: this.redactEndpoint(url), + attempts: attempts.length, + ...(attemptIdentifiers.length > 0 ? { attemptIdentifiers } : {}), + }, + error + ); + } - if (status === 409 && code === 'SEQUENCER_BACKPRESSURE') { - return true; - } + private preservePriorAmbiguity( + requestSemantics: RequestSemantics, + method: HttpMethod, + url: string, + error: Error, + previousAttempts: readonly RequestAttemptSummary[] + ): Error { + return this.toTerminalError(requestSemantics, method, url, error, previousAttempts); + } - return false; - } - // Only retry non-Axios errors that are instances of NetworkError - return error instanceof NetworkError; + private hasAmbiguousAttempt(attempts: readonly RequestAttemptSummary[]): boolean { + return attempts.some((attempt) => attempt.outcomeCertainty === 'ambiguous'); } - /** Sleep for the specified number of milliseconds */ - private async sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); + private normalizeCallbackError(error: unknown): Error { + return error instanceof Error ? error : new NetworkError(`Request callback failed: ${String(error)}`); } - /** - * Prepares request data for retry by updating commandId fields to avoid duplicate command rejection. If the data - * contains a commandId field, appends a retry suffix with timestamp to make it unique. - */ - private prepareDataForRetry(data: unknown): unknown { - if (data && typeof data === 'object' && 'commandId' in data) { - const originalCommandId = (data as { commandId: string }).commandId; - const retryCommandId = `${originalCommandId}-retry`; - return { ...data, commandId: retryCommandId }; + private redactEndpoint(url: string): string { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + const queryIndex = url.search(/[?#]/u); + return queryIndex < 0 ? url : url.slice(0, queryIndex); } - return data; + } + + private redactAttemptIdentifier(identifier: string | undefined): string | undefined { + if (identifier === undefined) return undefined; + return identifier.slice(0, HttpClient.MAX_ATTEMPT_IDENTIFIER_LENGTH); + } + + private isCanceledError(error: unknown): boolean { + return axios.isCancel(error) || (axios.isAxiosError(error) && error.code === 'ERR_CANCELED'); + } + + private async abortableSleep(ms: number, signal: AbortSignal | undefined): Promise { + throwIfAborted(signal); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + const onAbort = (): void => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + reject(createAbortError(signal)); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); } } diff --git a/src/core/http/abort.ts b/src/core/http/abort.ts new file mode 100644 index 00000000..47654caf --- /dev/null +++ b/src/core/http/abort.ts @@ -0,0 +1,55 @@ +/** Return true when an error has the standard cancellation shape exposed by the SDK. */ +export function isAbortError(error: unknown): error is Error { + return error instanceof Error && error.name === 'AbortError'; +} + +/** Create a stable AbortError while retaining the original reason as a non-enumerable cause. */ +export function createAbortError(signal: AbortSignal | undefined, canceledError?: unknown): Error { + const reason: unknown = signal?.reason ?? canceledError; + const message = + reason instanceof Error + ? reason.message + : typeof reason === 'string' && reason.length > 0 + ? reason + : 'The operation was aborted'; + const abortError = new Error(message); + abortError.name = 'AbortError'; + if (reason !== undefined) { + Object.defineProperty(abortError, 'cause', { value: reason, enumerable: false }); + } + return abortError; +} + +/** Throw a stable AbortError when the supplied signal is already canceled. */ +export function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw createAbortError(signal); +} + +/** Race any synchronous or asynchronous pre-dispatch work against cancellation. */ +export async function awaitWithAbort( + work: () => Value | Promise, + signal: AbortSignal | undefined +): Promise { + throwIfAborted(signal); + const workPromise = Promise.resolve().then(work); + if (!signal) return workPromise; + + return new Promise((resolve, reject) => { + const onAbort = (): void => { + signal.removeEventListener('abort', onAbort); + reject(createAbortError(signal)); + }; + signal.addEventListener('abort', onAbort, { once: true }); + void workPromise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', onAbort); + reject(error); + } + ); + if (signal.aborted) onAbort(); + }); +} diff --git a/src/core/http/index.ts b/src/core/http/index.ts index b6744c9c..685445ea 100644 --- a/src/core/http/index.ts +++ b/src/core/http/index.ts @@ -1 +1,2 @@ export * from './HttpClient'; +export * from './request-retry'; diff --git a/src/core/http/request-retry.ts b/src/core/http/request-retry.ts new file mode 100644 index 00000000..6f5673aa --- /dev/null +++ b/src/core/http/request-retry.ts @@ -0,0 +1,174 @@ +/** A value that can be returned synchronously or asynchronously. */ +export type MaybePromise = T | Promise; + +/** Recursively marks a request value as readonly while it is exposed to retry callbacks. */ +export type DeepReadonly = T extends (...args: never[]) => unknown + ? T + : T extends readonly unknown[] + ? { readonly [Index in keyof T]: DeepReadonly } + : T extends object + ? { readonly [Key in keyof T]: DeepReadonly } + : T; + +/** How a failed request attempt was classified by the transport. */ +export type RequestErrorClassification = + | 'pre-dispatch-failure' + | 'definite-rejection' + | 'ambiguous-mutation-outcome' + | 'transient-read-failure'; + +/** Whether a request can change server state from the SDK caller's perspective. */ +export type RequestSemantics = 'read' | 'mutation'; + +/** Whether the SDK can determine the server-side outcome of a failed request attempt. */ +export type RequestOutcomeCertainty = 'not-dispatched' | 'definite' | 'ambiguous'; + +/** Redacted information about a completed request attempt. Request bodies are deliberately excluded. */ +export interface RequestAttemptSummary { + /** One-based request attempt number. */ + readonly attempt: number; + /** Optional caller-supplied, redacted identifier such as a command or submission ID. */ + readonly identifier?: string; + /** Transport classification for the failed attempt. */ + readonly errorClassification: RequestErrorClassification; + /** Certainty of the server-side outcome, independently from whether the error is transient. */ + readonly outcomeCertainty: RequestOutcomeCertainty; + /** Whether the transport error is normally transient. */ + readonly retryable: boolean; +} + +/** Context supplied before an HTTP request attempt is dispatched. */ +export interface HttpRequestAttemptContext { + /** One-based request attempt number. */ + readonly attempt: number; + /** Immutable snapshot of the body that will be used for this attempt. */ + readonly body: DeepReadonly; + /** Redacted summaries of failed attempts that preceded this one. */ + readonly previousAttempts: readonly RequestAttemptSummary[]; + /** Cancellation signal supplied for the operation, when present. */ + readonly signal?: AbortSignal; +} + +/** Context supplied after a failed HTTP request attempt. */ +export interface HttpRequestRetryContext extends HttpRequestAttemptContext { + /** Normalized transport error for the failed attempt. */ + readonly error: Error; + /** Classification of the failed attempt. */ + readonly errorClassification: RequestErrorClassification; + /** Certainty of the server-side outcome, independently from whether the error is transient. */ + readonly outcomeCertainty: RequestOutcomeCertainty; + /** Whether the transport error is normally transient. */ + readonly retryable: boolean; +} + +/** Context used to select an equivalent endpoint for one semantic-read attempt. */ +export interface HttpReadAttemptUrlContext extends HttpRequestAttemptContext { + /** The URL supplied when the logical request began. */ + readonly originalUrl: string; +} + +/** Shared hooks for an explicitly retryable HTTP request. */ +export interface HttpRequestRetryHooks { + /** Maximum number of attempts, including the initial request. */ + readonly maxAttempts: number; + /** + * Optional retry predicate. Without one, reads retry transient failures while mutations retry only definite transient + * rejections. Ambiguous mutation outcomes require an explicit predicate override. + */ + readonly shouldRetry?: (context: HttpRequestRetryContext) => MaybePromise; + /** Optional delay, or delay calculator, evaluated between attempts. */ + readonly backoffMs?: number | ((context: HttpRequestRetryContext) => MaybePromise); + /** Hook awaited immediately before every attempt, including the first. */ + readonly beforeAttempt?: (context: HttpRequestAttemptContext) => MaybePromise; + /** + * Returns a redacted identifier that is safe to expose in {@link UnknownMutationOutcomeError}. The request body is + * never inferred or serialized as an identifier. + */ + readonly getAttemptIdentifier?: (context: HttpRequestAttemptContext) => MaybePromise; +} + +/** Disable automatic retry for this request. This is the default for every mutating method. */ +export interface NoHttpRequestRetryStrategy { + readonly kind: 'none'; +} + +/** Replay the exact immutable request body when a retry is explicitly authorized. */ +export interface ExactBodyHttpRequestRetryStrategy extends HttpRequestRetryHooks { + readonly kind: 'exact-body'; +} + +/** Derive and validate a new request body before each retry. */ +export interface DerivedBodyHttpRequestRetryStrategy extends HttpRequestRetryHooks { + readonly kind: 'derived-body'; + /** Called after a failed attempt to produce the body for the next attempt. */ + readonly deriveBody: (context: HttpRequestRetryContext) => MaybePromise>; +} + +/** Explicit retry behavior for one HTTP request. */ +export type HttpRequestRetryStrategy = + | NoHttpRequestRetryStrategy + | ExactBodyHttpRequestRetryStrategy + | DerivedBodyHttpRequestRetryStrategy; + +interface HttpRequestControlOptions { + readonly signal?: AbortSignal; + readonly retry?: HttpRequestRetryStrategy; +} + +/** Options for an HTTP GET, whose semantics are always read-only. */ +export type HttpReadRequestOptions = HttpRequestControlOptions & { + readonly requestSemantics?: 'read'; + /** + * Select an equivalent URL for each semantic-read attempt while retaining one retry state and attempt budget. + * Mutations cannot use endpoint failover. + */ + readonly resolveReadAttemptUrl?: (context: HttpReadAttemptUrlContext) => MaybePromise; +}; + +/** Transport controls for POST/PATCH/DELETE, discriminated so failover is available only to explicit reads. */ +export type HttpRequestOptions = + | (HttpRequestControlOptions & { + readonly requestSemantics: 'read'; + readonly resolveReadAttemptUrl?: (context: HttpReadAttemptUrlContext) => MaybePromise; + }) + | (HttpRequestControlOptions & { + /** POST/PATCH/DELETE default to mutation when semantics are omitted. */ + readonly requestSemantics?: 'mutation'; + readonly resolveReadAttemptUrl?: never; + }); + +/** HTTP options with an explicit semantic discriminator. */ +export type HttpRequestOptionsForSemantics = Semantics extends 'read' + ? HttpRequestControlOptions & { + readonly requestSemantics: 'read'; + readonly resolveReadAttemptUrl?: (context: HttpReadAttemptUrlContext) => MaybePromise; + } + : HttpRequestControlOptions & { + readonly requestSemantics: 'mutation'; + readonly resolveReadAttemptUrl?: never; + }; + +/** + * Capture caller-owned HTTP options before a request crosses an asynchronous boundary. + * + * The signal and callback references intentionally remain live, but replacing properties on either the options object + * or its retry strategy cannot change an in-flight request or a later Scan failover attempt. + */ +export function snapshotHttpRequestOptions( + options: HttpReadRequestOptions +): Readonly>; +export function snapshotHttpRequestOptions(options: HttpRequestOptions): Readonly>; +export function snapshotHttpRequestOptions( + options: HttpReadRequestOptions | HttpRequestOptions +): Readonly | HttpRequestOptions> { + const { signal, retry, requestSemantics, resolveReadAttemptUrl } = options; + const retrySnapshot: HttpRequestRetryStrategy | undefined = + retry === undefined ? undefined : Object.freeze({ ...retry }); + + return Object.freeze({ + ...(signal !== undefined ? { signal } : {}), + ...(retrySnapshot !== undefined ? { retry: retrySnapshot } : {}), + ...(requestSemantics !== undefined ? { requestSemantics } : {}), + ...(resolveReadAttemptUrl !== undefined ? { resolveReadAttemptUrl } : {}), + }) as Readonly | HttpRequestOptions>; +} diff --git a/src/core/http/request-value.ts b/src/core/http/request-value.ts new file mode 100644 index 00000000..bdd93e95 --- /dev/null +++ b/src/core/http/request-value.ts @@ -0,0 +1,36 @@ +/** Clone request data without converting Node.js Buffers into Uint8Arrays. */ +export function cloneRequestValue(value: Value): Value { + if (value === null || typeof value !== 'object') return value; + if (Buffer.isBuffer(value)) return Buffer.from(value) as Value; + if (Array.isArray(value)) { + const items = value as unknown[]; + return items.map((item) => cloneRequestValue(item)) as Value; + } + if (value instanceof Date) return new Date(value.getTime()) as Value; + if (value instanceof ArrayBuffer) return value.slice(0) as Value; + if (ArrayBuffer.isView(value)) return structuredClone(value); + + const prototype: unknown = Object.getPrototypeOf(value); + if (prototype === Object.prototype || prototype === null) { + const cloned: Record = {}; + for (const [key, nested] of Object.entries(value)) cloned[key] = cloneRequestValue(nested); + return cloned as Value; + } + + return structuredClone(value); +} + +/** Recursively freeze cloned request data while leaving typed-array storage intact. */ +export function deepFreezeRequestValue(value: Value): Value { + if ( + value === null || + typeof value !== 'object' || + ArrayBuffer.isView(value) || + value instanceof ArrayBuffer || + Object.isFrozen(value) + ) { + return value; + } + for (const nested of Object.values(value)) deepFreezeRequestValue(nested); + return Object.freeze(value); +} diff --git a/src/core/operations/ApiOperation.ts b/src/core/operations/ApiOperation.ts index 358c221d..f058e9ac 100644 --- a/src/core/operations/ApiOperation.ts +++ b/src/core/operations/ApiOperation.ts @@ -2,13 +2,15 @@ import { z } from 'zod'; import { type BaseClient } from '../BaseClient'; import { type PartyId } from '../branded-types'; import { ValidationError } from '../errors'; +import { type HttpReadRequestOptions, type HttpRequestOptions } from '../http/request-retry'; 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 { constructor(public readonly client: BaseClient) {} - abstract execute(params: Params): Promise; + abstract execute(params: Params, options?: OperationExecuteOptions): Promise; public validateParams(params: T, schema: z.ZodSchema): T { try { @@ -23,20 +25,46 @@ export abstract class ApiOperation { } } - public async makeGetRequest(url: string, config: RequestConfig = {}): Promise { - return this.client.makeGetRequest(url, config); + public async makeGetRequest( + url: string, + config: RequestConfig = {}, + options?: HttpReadRequestOptions + ): Promise { + return options === undefined + ? this.client.makeGetRequest(url, config) + : this.client.makeGetRequest(url, config, options); } - public async makePostRequest(url: string, data: unknown, config: RequestConfig = {}): Promise { - return this.client.makePostRequest(url, data, config); + public async makePostRequest( + url: string, + data: Body, + config: RequestConfig = {}, + options?: HttpRequestOptions + ): Promise { + return options === undefined + ? this.client.makePostRequest(url, data, config) + : this.client.makePostRequest(url, data, config, options); } - public async makeDeleteRequest(url: string, config: RequestConfig = {}): Promise { - return this.client.makeDeleteRequest(url, config); + public async makeDeleteRequest( + url: string, + config: RequestConfig = {}, + options?: HttpRequestOptions + ): Promise { + return options === undefined + ? this.client.makeDeleteRequest(url, config) + : this.client.makeDeleteRequest(url, config, options); } - public async makePatchRequest(url: string, data: unknown, config: RequestConfig = {}): Promise { - return this.client.makePatchRequest(url, data, config); + public async makePatchRequest( + url: string, + data: Body, + config: RequestConfig = {}, + options?: HttpRequestOptions + ): Promise { + return options === undefined + ? this.client.makePatchRequest(url, data, config) + : this.client.makePatchRequest(url, data, config, options); } public getManagedParties(): readonly string[] { diff --git a/src/core/operations/ApiOperationFactory.ts b/src/core/operations/ApiOperationFactory.ts index e1e0b17a..8f4181ad 100644 --- a/src/core/operations/ApiOperationFactory.ts +++ b/src/core/operations/ApiOperationFactory.ts @@ -1,7 +1,12 @@ import { z } from 'zod'; import { type BaseClient } from '../BaseClient'; +import { ConfigurationError } from '../errors'; +import { awaitWithAbort } from '../http/abort'; +import { 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 { createOperationHttpRequestOptions } from './operation-request-options'; /** * Exact Zod object shape for a generated request type. @@ -60,12 +65,12 @@ export type RequestDataBuilder = ( | undefined | Promise | Buffer | string | undefined>; -/** Configuration for a factory-created API operation. */ -export interface ApiOperationConfig { +/** HTTP request body supported by factory-created operations. */ +export type OperationRequestData = Record | Buffer | string | undefined; + +interface ApiOperationConfigBase { /** Zod schema used to validate params before the request. */ readonly paramsSchema: z.ZodSchema; - /** HTTP method. */ - readonly method: 'GET' | 'POST' | 'DELETE' | 'PATCH'; /** Builds the full request URL from validated params and the client's base API URL. */ readonly buildUrl: (params: Params, apiUrl: string, client: BaseClient) => string; /** Builds the request body. Only called for POST and PATCH. */ @@ -76,6 +81,23 @@ export interface ApiOperationConfig { readonly transformResponse?: (response: Response) => Response; } +/** Configuration for a factory-created API operation. GET is read-only by construction. */ +export type ApiOperationConfig = ApiOperationConfigBase & + ( + | { + readonly method: 'GET'; + readonly requestSemantics?: 'read'; + } + | { + readonly method: 'POST' | 'DELETE' | 'PATCH'; + /** + * Explicitly classify the operation as a semantic read or mutation. Required for read-only POST endpoints that + * may be safely retried or rotated across equivalent Scan nodes. + */ + readonly requestSemantics?: RequestSemantics; + } + ); + /** * Creates an {@link ApiOperation} class from a declarative configuration object. * @@ -92,29 +114,80 @@ export function createApiOperation( config: ApiOperationConfig ): new (client: BaseClient) => ApiOperation { return class extends ApiOperation { - async execute(params: Params): Promise { + async execute(params: Params, options?: OperationExecuteOptions): Promise { + const operationOptions = snapshotOperationExecuteOptions(options); + const effectiveOperationOptions: OperationExecuteOptions = operationOptions ?? Object.freeze({}); + // Validate parameters - const validatedParams = this.validateParams(params, config.paramsSchema); + const currentParams = this.validateParams(params, config.paramsSchema); - const url = config.buildUrl(validatedParams, this.getApiUrl(), this.client); + const apiUrl = this.getApiUrl(); + const url = config.buildUrl(currentParams, apiUrl, this.client); + const requestSemantics = config.requestSemantics ?? (config.method === 'GET' ? 'read' : 'mutation'); const requestConfig: RequestConfig = config.requestConfig ?? { contentType: 'application/json', includeBearerToken: true, }; + const buildRequestData = async (attemptParams: Params): Promise => { + if (config.method !== 'POST' && config.method !== 'PATCH') return undefined; + return config.buildRequestData?.(attemptParams, this.client); + }; + + const buildHttpRequestOptions = ( + semantics: Semantics, + snapshottedOptions: OperationExecuteOptions, + buildBody: (attemptParams: Params) => Body | Promise + ): HttpRequestOptionsForSemantics => + createOperationHttpRequestOptions({ + initialParams: currentParams, + options: snapshottedOptions, + requestSemantics: semantics, + initialUrl: url, + validateParams: (derivedParams): Params => this.validateParams(derivedParams, config.paramsSchema), + buildUrl: (derivedParams): string => config.buildUrl(derivedParams, apiUrl, this.client), + buildBody, + }); + let response: Response; if (config.method === 'GET') { - response = await this.makeGetRequest(url, requestConfig); + if (requestSemantics !== 'read') { + throw new ConfigurationError('Factory-created GET operations must use read semantics'); + } + const httpOptions = + operationOptions === undefined && config.requestSemantics === undefined + ? undefined + : buildHttpRequestOptions('read', effectiveOperationOptions, (): undefined => undefined); + response = await this.makeGetRequest(url, requestConfig, httpOptions); } else if (config.method === 'DELETE') { - response = await this.makeDeleteRequest(url, requestConfig); + const httpOptions = + operationOptions === undefined && config.requestSemantics === undefined + ? undefined + : buildHttpRequestOptions( + requestSemantics, + effectiveOperationOptions, + (): undefined => undefined + ); + response = await this.makeDeleteRequest(url, requestConfig, httpOptions); } else { - const data = await config.buildRequestData?.(validatedParams, this.client); + const data = await awaitWithAbort(async (): Promise => { + const requestData = await buildRequestData(currentParams); + return requestData; + }, operationOptions?.signal); + const httpOptions = + operationOptions === undefined && config.requestSemantics === undefined + ? undefined + : buildHttpRequestOptions( + requestSemantics, + effectiveOperationOptions, + buildRequestData + ); if (config.method === 'POST') { - response = await this.makePostRequest(url, data, requestConfig); + response = await this.makePostRequest(url, data, requestConfig, httpOptions); } else { // config.method === 'PATCH' - response = await this.makePatchRequest(url, data, requestConfig); + response = await this.makePatchRequest(url, data, requestConfig, httpOptions); } } diff --git a/src/core/operations/index.ts b/src/core/operations/index.ts index 7b62eb63..b6a243ae 100644 --- a/src/core/operations/index.ts +++ b/src/core/operations/index.ts @@ -1,3 +1,5 @@ export * from './ApiOperation'; export * from './ApiOperationFactory'; +export * from './operation-execute-options'; +export * from './operation-request-options'; export * from './WebSocketOperationFactory'; diff --git a/src/core/operations/operation-execute-options.ts b/src/core/operations/operation-execute-options.ts new file mode 100644 index 00000000..d5f4a3ff --- /dev/null +++ b/src/core/operations/operation-execute-options.ts @@ -0,0 +1,113 @@ +import { + type DeepReadonly, + type MaybePromise, + type RequestAttemptSummary, + type RequestErrorClassification, + type RequestOutcomeCertainty, +} from '../http/request-retry'; + +/** Context supplied before an operation request attempt is dispatched. */ +export interface OperationAttemptContext { + /** One-based request attempt number. */ + readonly attempt: number; + /** Immutable, validated operation parameters for the current attempt. */ + readonly params: DeepReadonly; + /** Redacted summaries of failed attempts that preceded this one. */ + readonly previousAttempts: readonly RequestAttemptSummary[]; + /** Cancellation signal supplied for the operation, when present. */ + readonly signal?: AbortSignal; +} + +/** Context supplied after an operation request attempt fails. */ +export interface OperationRetryContext extends OperationAttemptContext { + /** Normalized transport error for the failed attempt. */ + readonly error: Error; + /** Classification of the failed attempt. */ + readonly errorClassification: RequestErrorClassification; + /** Certainty of the server-side outcome, independently from whether the error is transient. */ + readonly outcomeCertainty: RequestOutcomeCertainty; + /** Whether the transport error is normally transient. */ + readonly retryable: boolean; +} + +/** Shared hooks for an operation that explicitly opts into retry. */ +export interface RequestRetryHooks { + /** Maximum number of attempts, including the initial request. */ + readonly maxAttempts: number; + /** + * Optional retry predicate. Without one, reads retry transient failures while mutations retry only definite transient + * rejections. Ambiguous mutation outcomes require an explicit predicate override. + */ + readonly shouldRetry?: (context: OperationRetryContext) => MaybePromise; + /** Optional delay, or delay calculator, evaluated between attempts. */ + readonly backoffMs?: number | ((context: OperationRetryContext) => MaybePromise); + /** Hook awaited immediately before every attempt, including the first. */ + readonly beforeAttempt?: (context: OperationAttemptContext) => MaybePromise; + /** Returns a redacted identifier, such as a command ID, that is safe to expose if the mutation outcome is unknown. */ + readonly getAttemptIdentifier?: (context: OperationAttemptContext) => MaybePromise; +} + +/** Disable automatic retry. This is the default for every mutating operation. */ +export interface NoRequestRetryStrategy { + readonly kind: 'none'; +} + +/** Replay the exact immutable HTTP body built for the first operation attempt. */ +export interface ExactBodyRequestRetryStrategy extends RequestRetryHooks { + readonly kind: 'exact-body'; +} + +/** Revalidate parameters and rebuild the HTTP body before each retry. */ +export interface DerivedBodyRequestRetryStrategy extends RequestRetryHooks { + readonly kind: 'derived-body'; + /** Produce the operation parameters for the next attempt. The operation endpoint may not change. */ + readonly deriveParams: (context: OperationRetryContext) => MaybePromise>; +} + +/** Immutable retry behavior for one operation execution. */ +export type RequestRetryStrategy = + | NoRequestRetryStrategy + | ExactBodyRequestRetryStrategy + | DerivedBodyRequestRetryStrategy; + +/** Transport and retry controls for one operation execution. */ +export interface OperationExecuteOptions { + readonly signal?: AbortSignal; + readonly retry?: RequestRetryStrategy; +} + +/** + * Capture caller-owned execution options before an operation crosses an asynchronous boundary. + * + * The returned objects retain the original signal and hook function references but cannot be redirected by replacing + * properties on the caller's options or retry objects while request data is being built. + */ +export function snapshotOperationExecuteOptions( + options: OperationExecuteOptions | undefined +): Readonly> | undefined { + if (options === undefined) return undefined; + + const { signal, retry } = options; + const retrySnapshot = retry === undefined ? undefined : snapshotRequestRetryStrategy(retry); + return Object.freeze({ + ...(signal !== undefined ? { signal } : {}), + ...(retrySnapshot !== undefined ? { retry: retrySnapshot } : {}), + }); +} + +function snapshotRequestRetryStrategy(retry: RequestRetryStrategy): RequestRetryStrategy { + if (retry.kind === 'none') return Object.freeze({ kind: 'none' }); + + const { maxAttempts, shouldRetry, backoffMs, beforeAttempt, getAttemptIdentifier } = retry; + const hooks = { + maxAttempts, + ...(shouldRetry !== undefined ? { shouldRetry } : {}), + ...(backoffMs !== undefined ? { backoffMs } : {}), + ...(beforeAttempt !== undefined ? { beforeAttempt } : {}), + ...(getAttemptIdentifier !== undefined ? { getAttemptIdentifier } : {}), + }; + if (retry.kind === 'exact-body') return Object.freeze({ kind: 'exact-body', ...hooks }); + + const { deriveParams } = retry; + return Object.freeze({ kind: 'derived-body', ...hooks, deriveParams }); +} diff --git a/src/core/operations/operation-request-options.ts b/src/core/operations/operation-request-options.ts new file mode 100644 index 00000000..52da6b2e --- /dev/null +++ b/src/core/operations/operation-request-options.ts @@ -0,0 +1,146 @@ +import { ConfigurationError, ValidationError } from '../errors'; +import { + type DeepReadonly, + type HttpRequestAttemptContext, + type HttpRequestOptionsForSemantics, + type HttpRequestRetryContext, + type HttpRequestRetryHooks, + type HttpRequestRetryStrategy, + type MaybePromise, + type RequestSemantics, +} from '../http/request-retry'; +import { cloneRequestValue, deepFreezeRequestValue } from '../http/request-value'; +import { + type OperationAttemptContext, + type OperationExecuteOptions, + type OperationRetryContext, + type RequestRetryHooks, + type RequestRetryStrategy, +} from './operation-execute-options'; + +/** Inputs needed to map one operation's typed retry controls onto a fixed HTTP endpoint. */ +export interface OperationRequestOptionsConfig { + readonly initialParams: Params; + readonly options: OperationExecuteOptions; + readonly requestSemantics: Semantics; + readonly initialUrl: string; + readonly validateParams: (params: Params) => Params; + readonly buildUrl: (params: Params) => MaybePromise; + readonly buildBody: (params: Params) => MaybePromise; +} + +/** Map operation-level params/hooks to transport-level body/hooks without exposing unsound caller subtypes. */ +export function createOperationHttpRequestOptions( + config: OperationRequestOptionsConfig +): HttpRequestOptionsForSemantics { + let currentParams = config.initialParams; + + const toOperationAttemptContext = (context: HttpRequestAttemptContext): OperationAttemptContext => + Object.freeze({ + attempt: context.attempt, + params: createReadonlyParams(currentParams), + previousAttempts: context.previousAttempts, + ...(context.signal !== undefined ? { signal: context.signal } : {}), + }); + + const toOperationRetryContext = (context: HttpRequestRetryContext): OperationRetryContext => + Object.freeze({ + ...toOperationAttemptContext(context), + error: context.error, + errorClassification: context.errorClassification, + outcomeCertainty: context.outcomeCertainty, + retryable: context.retryable, + }); + + const operationRetry = config.options.retry; + const deriveBody: ((context: HttpRequestRetryContext) => Promise>) | undefined = + operationRetry?.kind === 'derived-body' + ? async (context: HttpRequestRetryContext): Promise> => { + const readonlyDerivedParams = await operationRetry.deriveParams(toOperationRetryContext(context)); + const derivedParams = cloneRequestValue(readonlyDerivedParams) as Params; + const validatedDerivedParams = config.validateParams(derivedParams); + const derivedUrl = await config.buildUrl(validatedDerivedParams); + if (derivedUrl !== config.initialUrl) { + throw new ValidationError( + 'Derived retry parameters must resolve to the same HTTP endpoint as the initial request' + ); + } + currentParams = validatedDerivedParams; + return (await config.buildBody(currentParams)) as DeepReadonly; + } + : undefined; + const retry = + operationRetry === undefined + ? undefined + : mapOperationRetryStrategy(operationRetry, toOperationAttemptContext, toOperationRetryContext, deriveBody); + + return { + ...(config.options.signal !== undefined ? { signal: config.options.signal } : {}), + ...(retry !== undefined ? { retry } : {}), + requestSemantics: config.requestSemantics, + } as HttpRequestOptionsForSemantics; +} + +function mapOperationRetryStrategy( + retry: RequestRetryStrategy, + toAttemptContext: (context: HttpRequestAttemptContext) => OperationAttemptContext, + toRetryContext: (context: HttpRequestRetryContext) => OperationRetryContext, + deriveBody: ((context: HttpRequestRetryContext) => Promise>) | undefined +): HttpRequestRetryStrategy { + if (retry.kind === 'none') return Object.freeze({ kind: 'none' }); + + const hooks = mapRetryHooks(retry, toAttemptContext, toRetryContext); + if (retry.kind === 'exact-body') return Object.freeze({ kind: 'exact-body', ...hooks }); + if (deriveBody === undefined) { + throw new ConfigurationError('A derived-body retry strategy requires a body derivation callback'); + } + return Object.freeze({ kind: 'derived-body', ...hooks, deriveBody }); +} + +function mapRetryHooks( + hooks: RequestRetryHooks, + toAttemptContext: (context: HttpRequestAttemptContext) => OperationAttemptContext, + toRetryContext: (context: HttpRequestRetryContext) => OperationRetryContext +): HttpRequestRetryHooks { + const { shouldRetry, backoffMs, beforeAttempt, getAttemptIdentifier } = hooks; + return { + maxAttempts: hooks.maxAttempts, + ...(shouldRetry !== undefined + ? { + shouldRetry: async (context: HttpRequestRetryContext): Promise => + shouldRetry(toRetryContext(context)), + } + : {}), + ...(typeof backoffMs === 'function' + ? { + backoffMs: async (context: HttpRequestRetryContext): Promise => + backoffMs(toRetryContext(context)), + } + : backoffMs !== undefined + ? { backoffMs } + : {}), + ...(beforeAttempt !== undefined + ? { + beforeAttempt: async (context: HttpRequestAttemptContext): Promise => + beforeAttempt(toAttemptContext(context)), + } + : {}), + ...(getAttemptIdentifier !== undefined + ? { + getAttemptIdentifier: async (context: HttpRequestAttemptContext): Promise => + getAttemptIdentifier(toAttemptContext(context)), + } + : {}), + }; +} + +function createReadonlyParams(params: Params): DeepReadonly { + if (params === null || typeof params !== 'object') return params as DeepReadonly; + try { + return deepFreezeRequestValue(cloneRequestValue(params)) as DeepReadonly; + } catch (error) { + throw new ConfigurationError('Retryable operation parameters must be structured-cloneable', { + cause: error instanceof Error ? error.message : 'unknown clone failure', + }); + } +} diff --git a/src/utils/mining/mining-rounds.ts b/src/utils/mining/mining-rounds.ts index 103c4ae4..57cfcd80 100644 --- a/src/utils/mining/mining-rounds.ts +++ b/src/utils/mining/mining-rounds.ts @@ -5,10 +5,13 @@ import { type OpenMiningRound, } from '../../clients/validator-api/schemas/api'; import { OperationError, OperationErrorCode } from '../../core/errors'; +import { type OperationExecuteOptions } from '../../core/operations'; /** Minimal Validator/client operations dependency surface required by mining helper utilities. */ export interface MiningRoundClient { - getOpenAndIssuingMiningRounds: () => Promise; + getOpenAndIssuingMiningRounds: ( + options?: OperationExecuteOptions + ) => Promise; } /** Sleep utility function */ @@ -51,9 +54,12 @@ export interface MiningRoundContext { * * @throws Error if no mining round satisfies the criteria */ -export async function getCurrentMiningRoundContext(validatorClient: MiningRoundClient): Promise { +export async function getCurrentMiningRoundContext( + validatorClient: MiningRoundClient, + options?: OperationExecuteOptions +): Promise { const miningRoundsResponse: GetOpenAndIssuingMiningRoundsResponse = - await validatorClient.getOpenAndIssuingMiningRounds(); + await validatorClient.getOpenAndIssuingMiningRounds(options); // Filter for rounds that have opened already (opensAt <= now) const now = new Date(); @@ -112,8 +118,11 @@ export async function getCurrentMiningRoundContext(validatorClient: MiningRoundC * @returns Promise resolving to the domain ID string * @throws Error if no mining round satisfies the criteria */ -export async function getCurrentMiningRoundDomainId(validatorClient: MiningRoundClient): Promise { - const miningRoundContext = await getCurrentMiningRoundContext(validatorClient); +export async function getCurrentMiningRoundDomainId( + validatorClient: MiningRoundClient, + options?: OperationExecuteOptions +): Promise { + const miningRoundContext = await getCurrentMiningRoundContext(validatorClient, options); return miningRoundContext.openMiningRoundContract.synchronizerId; } diff --git a/test/typecheck/operation-retry.typecheck.ts b/test/typecheck/operation-retry.typecheck.ts new file mode 100644 index 00000000..d5a737cb --- /dev/null +++ b/test/typecheck/operation-retry.typecheck.ts @@ -0,0 +1,139 @@ +import { z } from 'zod'; +import type { LedgerJsonApiClient } from '../../src/clients/ledger-json-api/LedgerJsonApiClient.generated'; +import type { AllocateExternalPartyParams } from '../../src/clients/ledger-json-api/operations/v2/parties/external/allocate-external-party'; +import type { ScanApiClient } from '../../src/clients/scan-api/ScanApiClient.generated'; +import type { GetAcsSnapshotAtParams } from '../../src/clients/scan-api/operations/v0/scan'; +import type { ValidatorApiClient } from '../../src/clients/validator-api/ValidatorApiClient.generated'; +import type { ApiOperationConfig, DeepReadonly } from '../../src/core'; + +declare const client: LedgerJsonApiClient; +declare const scanClient: ScanApiClient; +declare const validatorClient: ValidatorApiClient; + +const signedRequest = { + synchronizer: 'synchronizer::id', + identityProviderId: 'default', + onboardingTransactions: [ + { + transaction: 'serialized-transaction', + signatures: [ + { + format: 'SIGNATURE_FORMAT_RAW', + signature: 'signature', + signedBy: 'fingerprint', + signingAlgorithmSpec: 'SIGNING_ALGORITHM_SPEC_ED25519', + }, + ], + }, + ], +} satisfies AllocateExternalPartyParams; + +interface NonEmptyTuplePayload { + readonly values: readonly [string, ...string[]]; +} +const validTuplePayload: DeepReadonly = { values: ['required'] }; +const invalidTuplePayload: DeepReadonly = { + // @ts-expect-error DeepReadonly must preserve non-empty tuple constraints. + values: [], +}; +void validTuplePayload; +void invalidTuplePayload; + +void client.allocateExternalParty(signedRequest, { + retry: { + kind: 'derived-body', + maxAttempts: 2, + deriveParams: ({ params }) => { + // Generated methods expose the declared union and preserve sound narrowing through its normal discriminants. + const [transaction] = params.onboardingTransactions; + if (transaction && 'signatures' in transaction) { + const signature: string = transaction.signatures[0]?.signature ?? ''; + void signature; + } + return signedRequest; + }, + }, +}); + +void client.allocateExternalParty(signedRequest, { + retry: { + kind: 'derived-body', + maxAttempts: 2, + deriveParams: ({ params }) => ({ + ...params, + // Natural shallow copies of deeply readonly retry params remain valid even when nested arrays are unchanged. + synchronizer: 'replacement-synchronizer::id', + }), + }, +}); + +const requestWithCallerOnlyMetadata = { ...signedRequest, callerOnlyMetadata: 'not-on-the-wire' }; +void client.allocateExternalParty(requestWithCallerOnlyMetadata, { + retry: { + kind: 'exact-body', + maxAttempts: 1, + beforeAttempt: ({ params }) => { + // @ts-expect-error Retry hooks expose validated declared params, not caller-only structural extensions. + void params.callerOnlyMetadata; + }, + }, +}); + +void client.allocateExternalParty(signedRequest, { + retry: { + kind: 'derived-body', + maxAttempts: 2, + // @ts-expect-error Derived params must satisfy the operation's declared request type. + deriveParams: () => ({ synchronizer: 'missing-required-fields' }), + }, +}); + +// Generated methods for bodyless endpoints accept transport options without a synthetic request payload. +void client.getVersion({ signal: new AbortController().signal }); + +// Representative POST reads in every generated client accept the same typed retry/cancellation controls. +void client.completions( + { userId: 'user', parties: ['party'], beginExclusive: 0 }, + { retry: { kind: 'exact-body', maxAttempts: 2 } } +); +void validatorClient.getTransferOfferStatus( + { trackingId: 'tracking-id' }, + { retry: { kind: 'exact-body', maxAttempts: 2 } } +); +void scanClient.getAcsSnapshotAt( + { body: {} as GetAcsSnapshotAtParams['body'] }, + { retry: { kind: 'exact-body', maxAttempts: 2 } } +); + +// @ts-expect-error HTTP GET requests cannot be classified as mutations. +void scanClient.makeGetRequest('https://scan.example/api/scan/v0/read', {}, { requestSemantics: 'mutation' }); + +// @ts-expect-error POST failover must be explicitly classified as a semantic read. +void client.makePostRequest('https://ledger.example/v2/read', {}, {}, { resolveReadAttemptUrl: () => 'next' }); + +void client.makePostRequest( + 'https://ledger.example/v2/mutate', + {}, + {}, + // @ts-expect-error Mutation requests cannot install a read endpoint resolver. + { requestSemantics: 'mutation', resolveReadAttemptUrl: () => 'next' } +); + +const invalidGetOperationConfig = { + paramsSchema: z.void(), + method: 'GET', + buildUrl: (): string => 'https://api.example/read', + requestSemantics: 'mutation', + // @ts-expect-error Factory-created GET operations cannot use mutation semantics. +} satisfies ApiOperationConfig; +void invalidGetOperationConfig; + +// Custom ApiOperation subclasses retain their declared params while forwarding the same typed options. +void client.getParties({}, { signal: new AbortController().signal }); +void client.listParties({}, { retry: { kind: 'exact-body', maxAttempts: 2 } }); +void validatorClient.getMemberTrafficStatus( + { domainId: 'domain', memberId: 'member' }, + { retry: { kind: 'exact-body', maxAttempts: 2 } } +); +void validatorClient.getMemberTrafficStatus(); +void validatorClient.getMemberTrafficStatus(undefined, { signal: new AbortController().signal }); diff --git a/test/unit/clients/scan-api.test.ts b/test/unit/clients/scan-api.test.ts index 857a0a31..19cd46aa 100644 --- a/test/unit/clients/scan-api.test.ts +++ b/test/unit/clients/scan-api.test.ts @@ -16,7 +16,14 @@ jest.mock('axios', () => { }); import { ScanApiClient, type ScanApiClientOptions } from '../../../src/clients/scan-api'; -import { CantonRuntime, type ClientConfig } from '../../../src/core'; +import { + ApiError, + CantonRuntime, + NetworkError, + UnknownMutationOutcomeError, + type ClientConfig, + type HttpRequestOptions, +} from '../../../src/core'; interface MockAxiosInstance { get: jest.Mock; @@ -25,6 +32,13 @@ interface MockAxiosInstance { patch: jest.Mock; } +function createAxiosNetworkError(): Error { + return Object.assign(new Error('connection reset'), { + isAxiosError: true, + code: 'ECONNRESET', + }); +} + function createClient( config: ClientConfig, options: ScanApiClientOptions = {} @@ -106,6 +120,325 @@ describe('ScanApiClient', () => { ]); }); + it('rotates semantic-read POST operations across Scan endpoints', async () => { + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + mockAxiosInstance.post + .mockRejectedValueOnce(createAxiosNetworkError()) + .mockResolvedValueOnce({ data: { created_events: [] } }); + + await expect( + client.getAcsSnapshotAt({ + body: { + migration_id: 0, + record_time: '2026-07-10T00:00:00Z', + record_time_match: 'exact', + page_size: 10, + }, + }) + ).resolves.toEqual({ created_events: [] }); + + expect(mockAxiosInstance.post.mock.calls.map((call) => call[0])).toEqual([ + 'https://scan-a.example/api/scan/v0/state/acs', + 'https://scan-b.example/api/scan/v0/state/acs', + ]); + }); + + it('uses one exact-body attempt budget and history across Scan endpoints', async () => { + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + mockAxiosInstance.post + .mockRejectedValueOnce(createAxiosNetworkError()) + .mockRejectedValueOnce(createAxiosNetworkError()) + .mockResolvedValueOnce({ data: { mustNotBeReached: true } }); + const attempts: Array<{ readonly attempt: number; readonly previousAttempts: number }> = []; + const params = { + body: { + migration_id: 0, + record_time: '2026-07-10T00:00:00Z', + record_time_match: 'exact' as const, + page_size: 10, + }, + }; + + await expect( + client.getAcsSnapshotAt(params, { + retry: { + kind: 'exact-body', + maxAttempts: 2, + backoffMs: 0, + beforeAttempt: ({ attempt, previousAttempts }) => { + attempts.push({ attempt, previousAttempts: previousAttempts.length }); + }, + }, + }) + ).rejects.toBeInstanceOf(ApiError); + + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(2); + expect(mockAxiosInstance.post.mock.calls.map((call) => call[0])).toEqual([ + 'https://scan-a.example/api/scan/v0/state/acs', + 'https://scan-b.example/api/scan/v0/state/acs', + ]); + expect(mockAxiosInstance.post.mock.calls.map((call) => call[1])).toEqual([params.body, params.body]); + expect(attempts).toEqual([ + { attempt: 1, previousAttempts: 0 }, + { attempt: 2, previousAttempts: 1 }, + ]); + }); + + it('keeps derived params and bodies aligned while failing over between Scan endpoints', async () => { + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + mockAxiosInstance.post + .mockRejectedValueOnce(createAxiosNetworkError()) + .mockResolvedValueOnce({ data: { created_events: [] } }); + const observedPageSizes: number[] = []; + + await expect( + client.getAcsSnapshotAt( + { + body: { + migration_id: 0, + record_time: '2026-07-10T00:00:00Z', + record_time_match: 'exact', + page_size: 10, + }, + }, + { + retry: { + kind: 'derived-body', + maxAttempts: 2, + backoffMs: 0, + deriveParams: ({ params }) => ({ + ...params, + body: { ...params.body, page_size: 20 }, + }), + beforeAttempt: ({ params }) => { + observedPageSizes.push(params.body.page_size); + }, + }, + } + ) + ).resolves.toEqual({ created_events: [] }); + + expect(mockAxiosInstance.post.mock.calls.map((call) => call[0])).toEqual([ + 'https://scan-a.example/api/scan/v0/state/acs', + 'https://scan-b.example/api/scan/v0/state/acs', + ]); + expect(mockAxiosInstance.post.mock.calls.map((call) => call[1]?.page_size)).toEqual([10, 20]); + expect(observedPageSizes).toEqual([10, 20]); + }); + + it('keeps a derived operation endpoint stable when a nested request changes the active Scan', async () => { + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + mockAxiosInstance.post + .mockRejectedValueOnce(createAxiosNetworkError()) + .mockResolvedValueOnce({ data: { created_events: [] } }); + mockAxiosInstance.get + .mockRejectedValueOnce(createAxiosNetworkError()) + .mockResolvedValueOnce({ data: { endpoint: 'scan-b' } }); + + await expect( + client.getAcsSnapshotAt( + { + body: { + migration_id: 0, + record_time: '2026-07-10T00:00:00Z', + record_time_match: 'exact', + page_size: 10, + }, + }, + { + retry: { + kind: 'derived-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: async ({ retryable }): Promise => { + await client.makeGetRequest('https://scan-a.example/api/scan/v0/read'); + return retryable; + }, + deriveParams: ({ params }) => ({ + ...params, + body: { ...params.body, page_size: 20 }, + }), + }, + } + ) + ).resolves.toEqual({ created_events: [] }); + + expect(client.getApiUrl()).toBe('https://scan-b.example/api/scan'); + expect(mockAxiosInstance.post.mock.calls.map((call) => call[0])).toEqual([ + 'https://scan-a.example/api/scan/v0/state/acs', + 'https://scan-b.example/api/scan/v0/state/acs', + ]); + expect(mockAxiosInstance.post.mock.calls.map((call) => call[1]?.page_size)).toEqual([10, 20]); + }); + + it('keeps one immutable request policy across Scan failover attempts', async () => { + const scanApiUrls = ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan']; + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls, + } + ); + let rejectFirstAttempt: ((error: Error) => void) | undefined; + let markFirstAttemptStarted: (() => void) | undefined; + const firstAttemptStarted = new Promise((resolve) => { + markFirstAttemptStarted = resolve; + }); + mockAxiosInstance.post + .mockImplementationOnce( + async (): Promise => + new Promise((_resolve, reject) => { + rejectFirstAttempt = reject; + markFirstAttemptStarted?.(); + }) + ) + .mockResolvedValueOnce({ data: { ok: true } }); + const entryController = new AbortController(); + const replacementController = new AbortController(); + const config: { + includeBearerToken: boolean; + contentType: 'application/json' | 'application/octet-stream'; + } = { includeBearerToken: false, contentType: 'application/json' }; + const options: { + signal: AbortSignal; + requestSemantics: 'read' | 'mutation'; + retry: Exclude>['retry'], undefined>; + } = { + signal: entryController.signal, + requestSemantics: 'read', + retry: { kind: 'exact-body', maxAttempts: 2, backoffMs: 0 }, + }; + + const request = client.makePostRequest('https://scan-a.example/api/scan/v0/read', {}, config, options); + await firstAttemptStarted; + config.contentType = 'application/octet-stream'; + options.signal = replacementController.signal; + options.requestSemantics = 'mutation'; + options.retry = { kind: 'none' }; + scanApiUrls[1] = 'https://attacker.example/api/scan'; + scanApiUrls.push('https://unexpected.example/api/scan'); + replacementController.abort(new Error('replacement policy must not affect failover')); + rejectFirstAttempt?.(createAxiosNetworkError()); + + await expect(request).resolves.toEqual({ ok: true }); + expect(mockAxiosInstance.post.mock.calls.map((call) => call[0])).toEqual([ + 'https://scan-a.example/api/scan/v0/read', + 'https://scan-b.example/api/scan/v0/read', + ]); + expect(mockAxiosInstance.post.mock.calls[1]?.[2]).toEqual( + expect.objectContaining({ + signal: entryController.signal, + headers: { 'Content-Type': 'application/json' }, + }) + ); + }); + + it('lets a caller-supplied read endpoint resolver take precedence over built-in Scan failover', async () => { + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + mockAxiosInstance.get.mockResolvedValueOnce({ data: { endpoint: 'custom' } }); + + await expect( + client.makeGetRequest( + 'https://scan-a.example/api/scan/v0/read', + {}, + { + retry: { kind: 'exact-body', maxAttempts: 1 }, + resolveReadAttemptUrl: () => 'https://custom.example/api/scan/v0/read', + } + ) + ).resolves.toEqual({ endpoint: 'custom' }); + + expect(mockAxiosInstance.get.mock.calls[0]?.[0]).toBe('https://custom.example/api/scan/v0/read'); + }); + + it('uses a stable starting endpoint while concurrent requests update the active Scan', async () => { + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + let rejectSlowRequest: ((error: Error) => void) | undefined; + let markSlowRequestStarted: (() => void) | undefined; + const slowRequestStarted = new Promise((resolve) => { + markSlowRequestStarted = resolve; + }); + mockAxiosInstance.get + .mockImplementationOnce( + async (): Promise => + new Promise((_resolve, reject) => { + rejectSlowRequest = reject; + markSlowRequestStarted?.(); + }) + ) + .mockRejectedValueOnce(createAxiosNetworkError()) + .mockResolvedValueOnce({ data: { request: 'fast', endpoint: 'scan-b' } }) + .mockResolvedValueOnce({ data: { request: 'slow', endpoint: 'scan-b' } }); + + const slowRequest = client.makeGetRequest<{ request: string; endpoint: string }>( + 'https://scan-a.example/api/scan/v0/read' + ); + await slowRequestStarted; + await expect( + client.makeGetRequest<{ request: string; endpoint: string }>('https://scan-a.example/api/scan/v0/read') + ).resolves.toEqual({ request: 'fast', endpoint: 'scan-b' }); + rejectSlowRequest?.(createAxiosNetworkError()); + await expect(slowRequest).resolves.toEqual({ request: 'slow', endpoint: 'scan-b' }); + + expect(mockAxiosInstance.get.mock.calls.map((call) => call[0])).toEqual([ + 'https://scan-a.example/api/scan/v0/read', + 'https://scan-a.example/api/scan/v0/read', + 'https://scan-b.example/api/scan/v0/read', + 'https://scan-b.example/api/scan/v0/read', + ]); + }); + + it('never rotates a semantic mutation when transport and logger both fail', async () => { + const { client, mockAxiosInstance } = createClient( + { + network: 'mainnet', + logger: { + logRequestResponse: async (): Promise => { + throw new NetworkError('logger failed'); + }, + }, + }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + mockAxiosInstance.post.mockRejectedValueOnce(createAxiosNetworkError()); + + await expect(client.forceAcsSnapshotNow()).rejects.toBeInstanceOf(UnknownMutationOutcomeError); + expect(mockAxiosInstance.post).toHaveBeenCalledTimes(1); + expect(mockAxiosInstance.post.mock.calls[0]?.[0]).toBe('https://scan-a.example/api/scan/v0/state/acs/force'); + }); + it('gets token metadata instruments from the direct scan endpoint', async () => { const { client, mockAxiosInstance } = createClient( { network: 'mainnet' }, diff --git a/test/unit/core/errors.test.ts b/test/unit/core/errors.test.ts index 59d1e850..37316604 100644 --- a/test/unit/core/errors.test.ts +++ b/test/unit/core/errors.test.ts @@ -6,6 +6,7 @@ import { NetworkError, OperationError, OperationErrorCode, + UnknownMutationOutcomeError, ValidationError, isDefiniteCantonMutationRejection, normalizeCantonError, @@ -120,6 +121,29 @@ describe('CantonError hierarchy', () => { }); }); + describe('UnknownMutationOutcomeError', () => { + it('exposes only redacted mutation metadata', () => { + const error = new UnknownMutationOutcomeError( + { + method: 'POST', + endpoint: 'https://ledger.example/v2/commands', + attempts: 2, + attemptIdentifiers: ['command-1', 'command-2'], + }, + new NetworkError('connection reset') + ); + + expect(error.code).toBe('UNKNOWN_MUTATION_OUTCOME'); + expect(error.context).toEqual({ + method: 'POST', + endpoint: 'https://ledger.example/v2/commands', + attempts: 2, + attemptIdentifiers: ['command-1', 'command-2'], + }); + expect(error.cause).toBeInstanceOf(NetworkError); + }); + }); + describe('OperationError', () => { it('creates error with specific operation code', () => { const error = new OperationError('No contract found', OperationErrorCode.MISSING_CONTRACT, { @@ -262,7 +286,7 @@ describe('normalizeCantonError', () => { context: { code: 'UNKNOWN_CONTRACT_SYNCHRONIZERS' }, response: { code: 'UNKNOWN_CONTRACT_SYNCHRONIZERS' }, }); - expect(isDefiniteCantonMutationRejection(error)).toBe(false); + expect(isDefiniteCantonMutationRejection(error)).toBe(true); }); it('ignores plain errors without SDK markers', () => { @@ -271,7 +295,7 @@ describe('normalizeCantonError', () => { }); describe('isDefiniteCantonMutationRejection', () => { - it('treats non-transient 4xx API responses as definite rejections', () => { + it('treats received 4xx responses as definite independently from retryability', () => { expect(isDefiniteCantonMutationRejection(new ApiError('bad request', 400))).toBe(true); expect(isDefiniteCantonMutationRejection(new ApiError('conflict', 409))).toBe(true); expect(isDefiniteCantonMutationRejection(new ApiError('not found', 404))).toBe(true); @@ -283,19 +307,19 @@ describe('isDefiniteCantonMutationRejection', () => { expect( isDefiniteCantonMutationRejection(new ApiError('conflict', 409, 'Conflict', { code: 'ALREADY_EXISTS' })) ).toBe(true); - }); - - it('does not treat retryable or non-HTTP errors as definite rejections', () => { expect( isDefiniteCantonMutationRejection( new ApiError('unknown contract synchronizers', 400, 'Bad Request', { code: 'UNKNOWN_CONTRACT_SYNCHRONIZERS' }) ) - ).toBe(false); + ).toBe(true); expect( isDefiniteCantonMutationRejection( new ApiError('sequencer backpressure', 409, 'Conflict', { code: 'SEQUENCER_BACKPRESSURE' }) ) - ).toBe(false); + ).toBe(true); + }); + + it('does not treat ambiguous or non-HTTP errors as definite rejections', () => { expect(isDefiniteCantonMutationRejection(new ApiError('timeout', 408))).toBe(false); expect(isDefiniteCantonMutationRejection(new ApiError('too early', 425))).toBe(false); expect(isDefiniteCantonMutationRejection(new ApiError('rate limited', 429))).toBe(false); diff --git a/test/unit/core/http-client-retry.test.ts b/test/unit/core/http-client-retry.test.ts new file mode 100644 index 00000000..cf34e192 --- /dev/null +++ b/test/unit/core/http-client-retry.test.ts @@ -0,0 +1,852 @@ +import axios from 'axios'; +import { ApiError, NetworkError, UnknownMutationOutcomeError } from '../../../src/core/errors'; +import { HttpClient } from '../../../src/core/http/HttpClient'; +import { type Logger } from '../../../src/core/logging'; + +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + return { + ...actual, + create: jest.fn(() => ({ + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + patch: jest.fn(), + defaults: { headers: { common: {} } }, + })), + isAxiosError: actual.isAxiosError, + isCancel: actual.isCancel, + }; +}); + +interface MockAxiosInstance { + readonly get: jest.Mock; + readonly post: jest.Mock; + readonly delete: jest.Mock; + readonly patch: jest.Mock; +} + +function createAxiosError(status?: number, data: Record = {}): Error { + const error = new Error('Request failed'); + Object.assign(error, { + isAxiosError: true, + code: status === undefined ? 'ECONNRESET' : 'ERR_BAD_RESPONSE', + ...(status === undefined + ? {} + : { + response: { + status, + statusText: status >= 500 ? 'Server Error' : 'Bad Request', + data, + }, + }), + }); + return error; +} + +function createClient( + logger?: Logger, + bearerTokenProvider?: () => Promise +): { readonly client: HttpClient; readonly axiosInstance: MockAxiosInstance } { + const client = new HttpClient(logger, bearerTokenProvider); + const { results } = (axios.create as jest.Mock).mock; + const axiosInstance = results[results.length - 1]?.value as MockAxiosInstance; + return { client, axiosInstance }; +} + +const loggerCases: ReadonlyArray Promise]> = [ + [ + 'synchronous throw', + (): never => { + throw new Error('logger failed synchronously'); + }, + ], + ['rejected promise', async (): Promise => Promise.reject(new Error('logger rejected'))], + ['never-settling promise', async (): Promise => new Promise(() => undefined)], +]; + +describe('HttpClient mutation retry safety', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not automatically retry or mutate a mutating request body', async () => { + const { client, axiosInstance } = createClient(); + const body = { + commandId: 'command-original', + nested: { signature: 'secret-signature' }, + }; + axiosInstance.post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { ok: true } }); + + await expect(client.makePostRequest('https://ledger.example/v2/commands', body)).rejects.toBeInstanceOf( + UnknownMutationOutcomeError + ); + + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + expect(axiosInstance.post.mock.calls[0]?.[1]).toEqual(body); + expect(body).toEqual({ + commandId: 'command-original', + nested: { signature: 'secret-signature' }, + }); + }); + + it('does not retry an ambiguous mutation by default even with an explicit exact-body strategy', async () => { + const { client, axiosInstance } = createClient(); + axiosInstance.post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { ok: true } }); + + await expect( + client.makePostRequest( + 'https://ledger.example/v2/commands', + { commandId: 'command-1' }, + {}, + { retry: { kind: 'exact-body', maxAttempts: 2, backoffMs: 0 } } + ) + ).rejects.toBeInstanceOf(UnknownMutationOutcomeError); + + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it.each([ + [400, 'UNKNOWN_CONTRACT_SYNCHRONIZERS'], + [409, 'SEQUENCER_BACKPRESSURE'], + ] as const)( + 'retries definite transient Canton rejection %i %s when an explicit strategy exists', + async (status, code) => { + const { client, axiosInstance } = createClient(); + axiosInstance.post + .mockRejectedValueOnce(createAxiosError(status, { code })) + .mockResolvedValueOnce({ data: { ok: true } }); + const previousAttempts: unknown[] = []; + + await expect( + client.makePostRequest( + 'https://ledger.example/v2/commands', + { commandId: 'command-1' }, + {}, + { + retry: { + kind: 'exact-body', + maxAttempts: 2, + backoffMs: 0, + beforeAttempt: ({ attempt, previousAttempts: previous }) => { + if (attempt === 2) previousAttempts.push(...previous); + }, + }, + } + ) + ).resolves.toEqual({ ok: true }); + + expect(axiosInstance.post).toHaveBeenCalledTimes(2); + expect(previousAttempts).toEqual([ + expect.objectContaining({ + errorClassification: 'definite-rejection', + outcomeCertainty: 'definite', + retryable: true, + }), + ]); + } + ); + + it('retries a POST explicitly classified as a semantic read', async () => { + const { client, axiosInstance } = createClient(); + client.setRetryConfig({ maxRetries: 1, delayMs: 0 }); + axiosInstance.post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { page: [] } }); + + await expect( + client.makePostRequest( + 'https://scan.example/api/scan/v2/updates', + { pageSize: 10 }, + {}, + { requestSemantics: 'read' } + ) + ).resolves.toEqual({ page: [] }); + expect(axiosInstance.post).toHaveBeenCalledTimes(2); + }); + + it('replays the exact immutable prepare body when explicitly authorized', async () => { + const { client, axiosInstance } = createClient(); + const prepareBody = { + commandId: 'prepare-command', + commands: [{ CreateCommand: { templateId: 'pkg:Module:Template' } }], + }; + axiosInstance.post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ + data: { preparedTransaction: 'prepared' }, + }); + + await expect( + client.makePostRequest( + 'https://ledger.example/v2/interactive-submission/prepare', + prepareBody, + {}, + { + retry: { + kind: 'exact-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: ({ errorClassification, outcomeCertainty, retryable }) => { + expect(errorClassification).toBe('ambiguous-mutation-outcome'); + expect(outcomeCertainty).toBe('ambiguous'); + expect(retryable).toBe(true); + return true; + }, + getAttemptIdentifier: ({ body }) => body.commandId, + }, + } + ) + ).resolves.toEqual({ preparedTransaction: 'prepared' }); + + expect(axiosInstance.post).toHaveBeenCalledTimes(2); + expect(axiosInstance.post.mock.calls[0]?.[1]).toEqual(prepareBody); + expect(axiosInstance.post.mock.calls[1]?.[1]).toEqual(prepareBody); + expect(axiosInstance.post.mock.calls[0]?.[1]).not.toBe(prepareBody); + expect(axiosInstance.post.mock.calls[1]?.[1]).not.toBe(axiosInstance.post.mock.calls[0]?.[1]); + }); + + it('derives a fresh submission ID without changing the caller body', async () => { + const { client, axiosInstance } = createClient(); + const body = { submissionId: 'submission-1', payload: 'prepared-transaction' }; + axiosInstance.post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { ok: true } }); + + await expect( + client.makePostRequest( + 'https://ledger.example/v2/interactive-submission/execute', + body, + {}, + { + retry: { + kind: 'derived-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: () => true, + deriveBody: ({ attempt, body: attemptedBody }) => ({ + ...attemptedBody, + submissionId: `submission-${attempt + 1}`, + }), + getAttemptIdentifier: ({ body: attemptedBody }) => attemptedBody.submissionId, + }, + } + ) + ).resolves.toEqual({ ok: true }); + + expect(axiosInstance.post.mock.calls.map((call) => call[1])).toEqual([ + { submissionId: 'submission-1', payload: 'prepared-transaction' }, + { submissionId: 'submission-2', payload: 'prepared-transaction' }, + ]); + expect(body).toEqual({ submissionId: 'submission-1', payload: 'prepared-transaction' }); + }); + + it('preserves definite mutation rejections and redacts ambiguous outcomes', async () => { + const definite = createClient(); + definite.axiosInstance.post.mockRejectedValueOnce(createAxiosError(400, { code: 'INVALID_ARGUMENT' })); + + await expect( + definite.client.makePostRequest('https://validator.example/api/mutate?token=secret', { + signature: 'must-not-leak', + }) + ).rejects.toBeInstanceOf(ApiError); + + const ambiguous = createClient(); + ambiguous.axiosInstance.post.mockRejectedValueOnce(createAxiosError()); + + let caught: unknown; + try { + await ambiguous.client.makePostRequest( + 'https://validator.example/api/mutate?token=secret', + { signature: 'must-not-leak' }, + {}, + { + retry: { + kind: 'exact-body', + maxAttempts: 1, + getAttemptIdentifier: () => 'submission-redacted-1', + }, + } + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(UnknownMutationOutcomeError); + const unknownOutcome = caught as UnknownMutationOutcomeError; + expect(unknownOutcome.endpoint).toBe('https://validator.example/api/mutate'); + expect(unknownOutcome.attemptIdentifiers).toEqual(['submission-redacted-1']); + expect(JSON.stringify(unknownOutcome)).not.toContain('must-not-leak'); + expect(JSON.stringify(unknownOutcome)).not.toContain('token=secret'); + }); + + it.each(loggerCases)('keeps POST success independent from a logger %s', async (_name, logRequestResponse) => { + const logger: Logger = { logRequestResponse: jest.fn(logRequestResponse) }; + const { client, axiosInstance } = createClient(logger); + axiosInstance.post.mockResolvedValueOnce({ data: { ok: true } }); + + await expect(client.makePostRequest('https://validator.example/api/mutate', { value: 1 })).resolves.toEqual({ + ok: true, + }); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it.each(loggerCases)( + 'keeps ambiguous POST failure independent from a logger %s', + async (_name, logRequestResponse) => { + const logger: Logger = { logRequestResponse: jest.fn(logRequestResponse) }; + const { client, axiosInstance } = createClient(logger); + axiosInstance.post.mockRejectedValueOnce(createAxiosError()); + + await expect(client.makePostRequest('https://validator.example/api/mutate', { value: 1 })).rejects.toBeInstanceOf( + UnknownMutationOutcomeError + ); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + } + ); + + it('does not let a never-settling logger block an authorized retry', async () => { + const logger: Logger = { + logRequestResponse: async (): Promise => new Promise(() => undefined), + }; + const { client, axiosInstance } = createClient(logger); + axiosInstance.post + .mockRejectedValueOnce(createAxiosError(409, { code: 'SEQUENCER_BACKPRESSURE' })) + .mockResolvedValueOnce({ data: { ok: true } }); + + await expect( + client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + {}, + { retry: { kind: 'exact-body', maxAttempts: 2, backoffMs: 0 } } + ) + ).resolves.toEqual({ ok: true }); + expect(axiosInstance.post).toHaveBeenCalledTimes(2); + }); + + it('isolates retry bodies and successful responses from logger mutation', async () => { + const logger: Logger = { + logRequestResponse: async (_url, request, response): Promise => { + if (typeof request === 'object' && request !== null && 'data' in request) { + const { data } = request; + if (typeof data === 'object' && data !== null) Reflect.set(data, 'commandId', 'logger-mutated'); + } + if (typeof response === 'object' && response !== null) Reflect.set(response, 'ok', false); + }, + }; + const { client, axiosInstance } = createClient(logger); + const body = { commandId: 'command-original' }; + axiosInstance.post + .mockRejectedValueOnce(createAxiosError(409, { code: 'SEQUENCER_BACKPRESSURE' })) + .mockResolvedValueOnce({ data: { ok: true } }); + + await expect( + client.makePostRequest( + 'https://validator.example/api/mutate', + body, + {}, + { retry: { kind: 'exact-body', maxAttempts: 2, backoffMs: 0 } } + ) + ).resolves.toEqual({ ok: true }); + + expect(axiosInstance.post.mock.calls.map((call) => call[1])).toEqual([ + { commandId: 'command-original' }, + { commandId: 'command-original' }, + ]); + expect(body).toEqual({ commandId: 'command-original' }); + }); + + it('redacts authorization headers from logger snapshots without changing dispatch headers', async () => { + const loggedRequests: unknown[] = []; + const logger: Logger = { + logRequestResponse: async (_url, request): Promise => { + loggedRequests.push(request); + }, + }; + const { client, axiosInstance } = createClient(logger, async (): Promise => 'super-secret-token'); + axiosInstance.post.mockResolvedValueOnce({ data: { ok: true } }); + + await expect( + client.makePostRequest('https://validator.example/api/mutate', {}, { includeBearerToken: true }) + ).resolves.toEqual({ ok: true }); + + expect(axiosInstance.post.mock.calls[0]?.[2]?.headers).toEqual( + expect.objectContaining({ Authorization: 'Bearer super-secret-token' }) + ); + expect(loggedRequests).toEqual([ + expect.objectContaining({ headers: expect.objectContaining({ Authorization: '[REDACTED]' }) }), + ]); + expect(JSON.stringify(loggedRequests)).not.toContain('super-secret-token'); + }); + + it('forwards AbortSignal and rejects a pre-aborted request before dispatch', async () => { + const forwarded = createClient(); + const activeController = new AbortController(); + forwarded.axiosInstance.post.mockResolvedValueOnce({ data: { ok: true } }); + + await forwarded.client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + {}, + { + signal: activeController.signal, + } + ); + + expect(forwarded.axiosInstance.post.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ signal: activeController.signal }) + ); + + const preAborted = createClient(); + const abortedController = new AbortController(); + abortedController.abort(); + await expect( + preAborted.client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + {}, + { + signal: abortedController.signal, + } + ) + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(preAborted.axiosInstance.post).not.toHaveBeenCalled(); + }); + + it('normalizes a custom abort reason to AbortError and retains the reason as a non-enumerable cause', async () => { + const { client, axiosInstance } = createClient(); + const controller = new AbortController(); + const reason = new Error('caller stopped the request'); + controller.abort(reason); + + let caught: unknown; + try { + await client.makePostRequest('https://validator.example/api/mutate', {}, {}, { signal: controller.signal }); + } catch (error) { + caught = error; + } + + expect(caught).toMatchObject({ name: 'AbortError', message: reason.message, cause: reason }); + expect(Object.keys(caught as object)).not.toContain('cause'); + expect(axiosInstance.post).not.toHaveBeenCalled(); + }); + + it('cancels hung bearer-token acquisition before dispatch', async () => { + const tokenGate = new Promise(() => undefined); + const { client, axiosInstance } = createClient(undefined, async () => tokenGate); + const controller = new AbortController(); + + const request = client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + { includeBearerToken: true }, + { signal: controller.signal } + ); + await Promise.resolve(); + controller.abort(new Error('stop waiting for token')); + + await expect(request).rejects.toMatchObject({ name: 'AbortError', message: 'stop waiting for token' }); + expect(axiosInstance.post).not.toHaveBeenCalled(); + }); + + it('keeps the entry-time signal when the caller replaces options during token acquisition', async () => { + let releaseToken: ((token: string) => void) | undefined; + const tokenGate = new Promise((resolve) => { + releaseToken = resolve; + }); + const { client, axiosInstance } = createClient(undefined, async () => tokenGate); + const initialController = new AbortController(); + const replacementController = new AbortController(); + const options = { signal: initialController.signal }; + axiosInstance.post.mockResolvedValueOnce({ data: { ok: true } }); + + const request = client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + { includeBearerToken: true }, + options + ); + await Promise.resolve(); + options.signal = replacementController.signal; + replacementController.abort(new Error('replacement signal must be ignored')); + releaseToken?.('token'); + + await expect(request).resolves.toEqual({ ok: true }); + expect(axiosInstance.post.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ signal: initialController.signal }) + ); + }); + + it('snapshots request headers before asynchronous pre-dispatch work', async () => { + let releaseAttempt: (() => void) | undefined; + const attemptGate = new Promise((resolve) => { + releaseAttempt = resolve; + }); + let markAttemptStarted: (() => void) | undefined; + const attemptStarted = new Promise((resolve) => { + markAttemptStarted = resolve; + }); + const { client, axiosInstance } = createClient(undefined, async () => 'entry-token'); + const config: { + includeBearerToken: boolean; + contentType: 'application/json' | 'application/octet-stream'; + } = { + includeBearerToken: true, + contentType: 'application/json', + }; + axiosInstance.get.mockResolvedValueOnce({ data: { ok: true } }); + + const request = client.makeGetRequest('https://validator.example/api/read', config, { + retry: { + kind: 'exact-body', + maxAttempts: 1, + beforeAttempt: async (): Promise => { + markAttemptStarted?.(); + await attemptGate; + }, + }, + }); + await attemptStarted; + config.includeBearerToken = false; + config.contentType = 'application/octet-stream'; + releaseAttempt?.(); + + await expect(request).resolves.toEqual({ ok: true }); + expect(axiosInstance.get.mock.calls[0]?.[1]?.headers).toEqual({ + Authorization: 'Bearer entry-token', + 'Content-Type': 'application/json', + }); + }); + + it('treats a canceled Axios mutation after dispatch as an unknown outcome', async () => { + const { client, axiosInstance } = createClient(); + axiosInstance.post.mockRejectedValueOnce(new axios.CanceledError('request canceled')); + + await expect(client.makePostRequest('https://validator.example/api/mutate', {})).rejects.toMatchObject({ + name: 'UnknownMutationOutcomeError', + cause: { name: 'AbortError' }, + }); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it('surfaces a canceled read request as an AbortError', async () => { + const { client, axiosInstance } = createClient(); + axiosInstance.get.mockRejectedValueOnce(new axios.CanceledError('request canceled')); + + await expect(client.makeGetRequest('https://validator.example/api/read')).rejects.toMatchObject({ + name: 'AbortError', + }); + }); + + it('awaits beforeAttempt before dispatching the request', async () => { + const { client, axiosInstance } = createClient(); + axiosInstance.post.mockResolvedValueOnce({ data: { ok: true } }); + let releaseHook: (() => void) | undefined; + const hookGate = new Promise((resolve) => { + releaseHook = resolve; + }); + + const request = client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + {}, + { + retry: { + kind: 'exact-body', + maxAttempts: 1, + beforeAttempt: async () => hookGate, + }, + } + ); + await Promise.resolve(); + expect(axiosInstance.post).not.toHaveBeenCalled(); + + releaseHook?.(); + await expect(request).resolves.toEqual({ ok: true }); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it('cancels a hung pre-dispatch retry hook', async () => { + const { client, axiosInstance } = createClient(); + const controller = new AbortController(); + const hookGate = new Promise(() => undefined); + + const request = client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + {}, + { + signal: controller.signal, + retry: { kind: 'exact-body', maxAttempts: 1, beforeAttempt: async () => hookGate }, + } + ); + await Promise.resolve(); + controller.abort(new Error('stop waiting for hook')); + + await expect(request).rejects.toMatchObject({ name: 'AbortError', message: 'stop waiting for hook' }); + expect(axiosInstance.post).not.toHaveBeenCalled(); + }); + + it('preserves an ambiguous attempt when body derivation fails', async () => { + const { client, axiosInstance } = createClient(); + axiosInstance.post.mockRejectedValueOnce(createAxiosError(503)); + + await expect( + client.makePostRequest( + 'https://validator.example/api/mutate', + { submissionId: 'submission-1' }, + {}, + { + retry: { + kind: 'derived-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: () => true, + getAttemptIdentifier: ({ body }) => body.submissionId, + deriveBody: async () => { + throw new Error('derivation failed'); + }, + }, + } + ) + ).rejects.toMatchObject({ + name: 'UnknownMutationOutcomeError', + attemptIdentifiers: ['submission-1'], + cause: { message: 'derivation failed' }, + }); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it('cancels hung body derivation while preserving the prior ambiguous attempt', async () => { + const { client, axiosInstance } = createClient(); + const controller = new AbortController(); + axiosInstance.post.mockRejectedValueOnce(createAxiosError()); + + await expect( + client.makePostRequest( + 'https://validator.example/api/mutate', + { submissionId: 'submission-1' }, + {}, + { + signal: controller.signal, + retry: { + kind: 'derived-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: () => true, + deriveBody: async () => { + controller.abort(new Error('stop derivation')); + return new Promise<{ submissionId: string }>(() => undefined); + }, + }, + } + ) + ).rejects.toMatchObject({ + name: 'UnknownMutationOutcomeError', + cause: { name: 'AbortError', message: 'stop derivation' }, + }); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it('preserves an ambiguous attempt when a later pre-dispatch hook fails', async () => { + const { client, axiosInstance } = createClient(); + axiosInstance.post.mockRejectedValueOnce(createAxiosError()); + + await expect( + client.makePostRequest( + 'https://validator.example/api/mutate', + { submissionId: 'submission-1' }, + {}, + { + retry: { + kind: 'exact-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: () => true, + beforeAttempt: ({ attempt }) => { + if (attempt === 2) throw new Error('hook failed'); + }, + }, + } + ) + ).rejects.toMatchObject({ name: 'UnknownMutationOutcomeError', cause: { message: 'hook failed' } }); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it('preserves an ambiguous attempt when backoff calculation fails', async () => { + const { client, axiosInstance } = createClient(); + axiosInstance.post.mockRejectedValueOnce(createAxiosError()); + + await expect( + client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + {}, + { + retry: { + kind: 'exact-body', + maxAttempts: 2, + shouldRetry: () => true, + backoffMs: () => { + throw new Error('backoff failed'); + }, + }, + } + ) + ).rejects.toMatchObject({ name: 'UnknownMutationOutcomeError', cause: { message: 'backoff failed' } }); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it('surfaces a first-attempt pre-dispatch hook failure directly', async () => { + const { client, axiosInstance } = createClient(); + const hookError = new Error('hook failed before dispatch'); + + await expect( + client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + {}, + { + retry: { + kind: 'exact-body', + maxAttempts: 2, + beforeAttempt: () => { + throw hookError; + }, + }, + } + ) + ).rejects.toBe(hookError); + expect(axiosInstance.post).not.toHaveBeenCalled(); + }); + + it('preserves an ambiguous mutation outcome when the signal aborts during retry backoff', async () => { + const { client, axiosInstance } = createClient(); + const controller = new AbortController(); + let markBackoffStarted: (() => void) | undefined; + const backoffStarted = new Promise((resolve) => { + markBackoffStarted = resolve; + }); + axiosInstance.post.mockRejectedValueOnce(createAxiosError()).mockResolvedValueOnce({ data: { ok: true } }); + + const request = client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + {}, + { + signal: controller.signal, + retry: { + kind: 'exact-body', + maxAttempts: 2, + backoffMs: () => { + markBackoffStarted?.(); + return 60_000; + }, + shouldRetry: () => true, + }, + } + ); + await backoffStarted; + controller.abort(); + + await expect(request).rejects.toMatchObject({ + name: 'UnknownMutationOutcomeError', + cause: { name: 'AbortError' }, + }); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it('surfaces AbortError when a definite mutation rejection is canceled during retry backoff', async () => { + const { client, axiosInstance } = createClient(); + const controller = new AbortController(); + let markBackoffStarted: (() => void) | undefined; + const backoffStarted = new Promise((resolve) => { + markBackoffStarted = resolve; + }); + axiosInstance.post.mockRejectedValueOnce(createAxiosError(409, { code: 'SEQUENCER_BACKPRESSURE' })); + + const request = client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + {}, + { + signal: controller.signal, + retry: { + kind: 'exact-body', + maxAttempts: 2, + backoffMs: () => { + markBackoffStarted?.(); + return 60_000; + }, + }, + } + ); + await backoffStarted; + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(axiosInstance.post).toHaveBeenCalledTimes(1); + }); + + it('surfaces AbortError when a read is canceled during retry backoff', async () => { + const { client, axiosInstance } = createClient(); + const controller = new AbortController(); + let markBackoffStarted: (() => void) | undefined; + const backoffStarted = new Promise((resolve) => { + markBackoffStarted = resolve; + }); + axiosInstance.get.mockRejectedValueOnce(createAxiosError(503)); + + const request = client.makeGetRequest( + 'https://validator.example/api/read', + {}, + { + signal: controller.signal, + retry: { + kind: 'exact-body', + maxAttempts: 2, + backoffMs: () => { + markBackoffStarted?.(); + return 60_000; + }, + }, + } + ); + await backoffStarted; + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(axiosInstance.get).toHaveBeenCalledTimes(1); + }); + + it('surfaces AbortError when a pre-dispatch failure is canceled during retry backoff', async () => { + let tokenAttempts = 0; + const { client, axiosInstance } = createClient(undefined, async (): Promise => { + tokenAttempts += 1; + if (tokenAttempts === 1) throw new NetworkError('token unavailable'); + return 'token'; + }); + const controller = new AbortController(); + let markBackoffStarted: (() => void) | undefined; + const backoffStarted = new Promise((resolve) => { + markBackoffStarted = resolve; + }); + + const request = client.makePostRequest( + 'https://validator.example/api/mutate', + {}, + { includeBearerToken: true }, + { + signal: controller.signal, + retry: { + kind: 'exact-body', + maxAttempts: 2, + backoffMs: () => { + markBackoffStarted?.(); + return 60_000; + }, + }, + } + ); + await backoffStarted; + controller.abort(); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(axiosInstance.post).not.toHaveBeenCalled(); + }); +}); diff --git a/test/unit/operations/api-operation-retry.test.ts b/test/unit/operations/api-operation-retry.test.ts new file mode 100644 index 00000000..92395988 --- /dev/null +++ b/test/unit/operations/api-operation-retry.test.ts @@ -0,0 +1,360 @@ +import axios from 'axios'; +import { z } from 'zod'; +import { Completions } from '../../../src/clients/ledger-json-api/operations/v2/commands/completions'; +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 { 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 { + type BaseClient, + HttpClient, + type OperationRetryContext, + UnknownMutationOutcomeError, + ValidationError, + createApiOperation, +} from '../../../src/core'; + +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + return { + ...actual, + create: jest.fn(() => ({ + get: jest.fn(), + post: jest.fn(), + delete: jest.fn(), + patch: jest.fn(), + defaults: { headers: { common: {} } }, + })), + isAxiosError: actual.isAxiosError, + isCancel: actual.isCancel, + }; +}); + +interface RetryOperationParams { + readonly resourceId: string; + readonly submissionId: string; + readonly payload: string; +} + +interface RetryOperationResponse { + readonly accepted: boolean; +} + +const RetryOperationParamsSchema = z.object({ + resourceId: z.string(), + submissionId: z.string(), + payload: z.string(), +}); + +function createAxiosError(status: number): Error { + const error = new Error('Request failed'); + Object.assign(error, { + isAxiosError: true, + code: 'ERR_BAD_RESPONSE', + response: { status, statusText: 'Server Error', data: {} }, + }); + return error; +} + +function createOperation(buildCounter: { count: number }): { + readonly operation: InstanceType>; + readonly post: jest.Mock; +} { + const Operation = createRetryOperation(buildCounter); + const httpClient = new HttpClient(undefined, async (): Promise => 'test-token'); + const { results } = (axios.create as jest.Mock).mock; + const axiosInstance = results[results.length - 1]?.value as { readonly post: jest.Mock }; + const client = { + getApiUrl: (): string => 'https://ledger.example', + makePostRequest: httpClient.makePostRequest.bind(httpClient), + } as unknown as BaseClient; + return { operation: new Operation(client), post: axiosInstance.post }; +} + +function createRetryOperation(buildCounter: { + count: number; +}): ReturnType> { + return createApiOperation({ + paramsSchema: RetryOperationParamsSchema, + method: 'POST', + buildUrl: (params, apiUrl): string => `${apiUrl}/resources/${params.resourceId}/submit`, + buildRequestData: (params) => { + buildCounter.count += 1; + return { + submission_id: params.submissionId, + payload: params.payload, + build_number: buildCounter.count, + }; + }, + }); +} + +function createSemanticPostClient(): { + readonly client: BaseClient; + readonly post: jest.Mock; +} { + const httpClient = new HttpClient(undefined, async (): Promise => 'test-token'); + httpClient.setRetryConfig({ maxRetries: 1, delayMs: 0 }); + const { results } = (axios.create as jest.Mock).mock; + const axiosInstance = results[results.length - 1]?.value as { readonly post: jest.Mock }; + const client = { + getApiUrl: (): string => 'https://api.example', + makePostRequest: httpClient.makePostRequest.bind(httpClient), + } as unknown as BaseClient; + return { client, post: axiosInstance.post }; +} + +describe('factory-created operation retry plumbing', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('builds once and replays the exact request body for exact-body retries', async () => { + const buildCounter = { count: 0 }; + const { operation, post } = createOperation(buildCounter); + post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { accepted: true } }); + + await expect( + operation.execute( + { resourceId: 'resource-1', submissionId: 'prepare-1', payload: 'commands' }, + { + retry: { + kind: 'exact-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: () => true, + getAttemptIdentifier: ({ params }) => params.submissionId, + }, + } + ) + ).resolves.toEqual({ accepted: true }); + + expect(buildCounter.count).toBe(1); + expect(post.mock.calls.map((call) => call[1])).toEqual([ + { submission_id: 'prepare-1', payload: 'commands', build_number: 1 }, + { submission_id: 'prepare-1', payload: 'commands', build_number: 1 }, + ]); + }); + + it('revalidates derived params and rebuilds fresh submission IDs', async () => { + const buildCounter = { count: 0 }; + const { operation, post } = createOperation(buildCounter); + post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { accepted: true } }); + + await expect( + operation.execute( + { resourceId: 'resource-1', submissionId: 'submission-1', payload: 'signed-transaction' }, + { + retry: { + kind: 'derived-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: () => true, + deriveParams: ({ attempt, params }) => ({ + resourceId: params.resourceId, + submissionId: `submission-${attempt + 1}`, + payload: params.payload, + }), + getAttemptIdentifier: ({ params }) => params.submissionId, + }, + } + ) + ).resolves.toEqual({ accepted: true }); + + expect(buildCounter.count).toBe(2); + expect(post.mock.calls.map((call) => call[1])).toEqual([ + { submission_id: 'submission-1', payload: 'signed-transaction', build_number: 1 }, + { submission_id: 'submission-2', payload: 'signed-transaction', build_number: 2 }, + ]); + }); + + it('rejects derived parameters that would silently change the endpoint', async () => { + const buildCounter = { count: 0 }; + const { operation, post } = createOperation(buildCounter); + post.mockRejectedValueOnce(createAxiosError(503)); + + await expect( + operation.execute( + { resourceId: 'resource-1', submissionId: 'submission-1', payload: 'signed-transaction' }, + { + retry: { + kind: 'derived-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: () => true, + deriveParams: ({ params }) => ({ + resourceId: 'resource-2', + submissionId: 'submission-2', + payload: params.payload, + }), + }, + } + ) + ).rejects.toMatchObject({ + name: 'UnknownMutationOutcomeError', + cause: expect.any(ValidationError), + }); + + expect(post).toHaveBeenCalledTimes(1); + expect(buildCounter.count).toBe(1); + }); + + it('cancels a hung initial request-body builder before dispatch', async () => { + const bodyGate = new Promise>(() => undefined); + const Operation = createApiOperation({ + paramsSchema: RetryOperationParamsSchema, + method: 'POST', + buildUrl: (_params, apiUrl): string => `${apiUrl}/submit`, + buildRequestData: async () => bodyGate, + }); + const httpClient = new HttpClient(undefined, async (): Promise => 'test-token'); + const { results } = (axios.create as jest.Mock).mock; + const axiosInstance = results[results.length - 1]?.value as { readonly post: jest.Mock }; + const client = { + getApiUrl: (): string => 'https://ledger.example', + makePostRequest: httpClient.makePostRequest.bind(httpClient), + } as unknown as BaseClient; + const controller = new AbortController(); + + const request = new Operation(client).execute( + { resourceId: 'resource-1', submissionId: 'submission-1', payload: 'payload' }, + { signal: controller.signal } + ); + await Promise.resolve(); + controller.abort(new Error('stop building request')); + + await expect(request).rejects.toMatchObject({ name: 'AbortError', message: 'stop building request' }); + expect(axiosInstance.post).not.toHaveBeenCalled(); + }); + + it('snapshots signal and retry hook references before an asynchronous initial body build', async () => { + let markBuildStarted: (() => void) | undefined; + let releaseBuild: (() => void) | undefined; + const buildStarted = new Promise((resolve) => { + markBuildStarted = resolve; + }); + const buildGate = new Promise((resolve) => { + releaseBuild = resolve; + }); + const Operation = createApiOperation({ + paramsSchema: RetryOperationParamsSchema, + method: 'POST', + buildUrl: (params, apiUrl): string => `${apiUrl}/resources/${params.resourceId}/submit`, + buildRequestData: async (params) => { + markBuildStarted?.(); + await buildGate; + return { submissionId: params.submissionId, payload: params.payload }; + }, + }); + const { client, post } = createSemanticPostClient(); + const initialController = new AbortController(); + const replacementController = new AbortController(); + const originalShouldRetry = jest.fn(async (): Promise => true); + const replacementShouldRetry = jest.fn(async (): Promise => false); + const originalDeriveParams = jest.fn( + async (context: OperationRetryContext): Promise => ({ + ...context.params, + submissionId: 'submission-2', + }) + ); + const replacementDeriveParams = jest.fn( + async (_context: OperationRetryContext): Promise => ({ + resourceId: 'replacement-resource', + submissionId: 'replacement-submission', + payload: 'replacement-payload', + }) + ); + const retry = { + kind: 'derived-body' as const, + maxAttempts: 2, + backoffMs: 0, + shouldRetry: originalShouldRetry, + deriveParams: originalDeriveParams, + }; + const options = { signal: initialController.signal, retry }; + post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { accepted: true } }); + + const request = new Operation(client).execute( + { resourceId: 'resource-1', submissionId: 'submission-1', payload: 'payload' }, + options + ); + await buildStarted; + options.signal = replacementController.signal; + retry.shouldRetry = replacementShouldRetry; + retry.deriveParams = replacementDeriveParams; + replacementController.abort(new Error('replacement signal must be ignored')); + releaseBuild?.(); + + await expect(request).resolves.toEqual({ accepted: true }); + expect(originalShouldRetry).toHaveBeenCalledTimes(1); + expect(originalDeriveParams).toHaveBeenCalledTimes(1); + expect(replacementShouldRetry).not.toHaveBeenCalled(); + expect(replacementDeriveParams).not.toHaveBeenCalled(); + expect(post.mock.calls.map((call) => call[1])).toEqual([ + { submissionId: 'submission-1', payload: 'payload' }, + { submissionId: 'submission-2', payload: 'payload' }, + ]); + expect(post.mock.calls[0]?.[2]).toEqual(expect.objectContaining({ signal: initialController.signal })); + }); +}); + +describe('semantic POST operation coverage matrix', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + { + clientFamily: 'Ledger', + execute: async (client: BaseClient): Promise => + new Completions(client).execute({ userId: 'user', parties: ['party'], beginExclusive: 0 }), + }, + { + clientFamily: 'Validator', + execute: async (client: BaseClient): Promise => + new GetTransferOfferStatus(client).execute({ trackingId: 'tracking-id' }), + }, + { + clientFamily: 'Ledger interactive prepare', + execute: async (client: BaseClient): Promise => + new InteractiveSubmissionPrepare(client).execute({ + commands: [], + commandId: 'command-id', + userId: 'user', + actAs: [], + readAs: [], + synchronizerId: 'synchronizer', + }), + }, + { + clientFamily: 'Ledger preferred packages', + 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 }) => { + const { client, post } = createSemanticPostClient(); + post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { ok: true } }); + + await expect(execute(client)).resolves.toEqual({ ok: true }); + expect(post).toHaveBeenCalledTimes(2); + }); + + it('does not retry an unannotated Validator mutation POST', async () => { + const { client, post } = createSemanticPostClient(); + post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { ok: true } }); + + await expect( + new CreateTransferOffer(client).execute({ + receiver_party_id: 'receiver', + amount: '10', + description: 'test transfer', + expires_at: 1_800_000_000_000, + tracking_id: 'tracking-id', + }) + ).rejects.toBeInstanceOf(UnknownMutationOutcomeError); + expect(post).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/unit/operations/custom-api-operation-options.test.ts b/test/unit/operations/custom-api-operation-options.test.ts new file mode 100644 index 00000000..6d7fa9f4 --- /dev/null +++ b/test/unit/operations/custom-api-operation-options.test.ts @@ -0,0 +1,128 @@ +import { LedgerJsonApiClient } from '../../../src/clients/ledger-json-api/LedgerJsonApiClient.generated'; +import { GetParties } from '../../../src/clients/ledger-json-api/operations/v2/parties/get'; +import { ListParties } from '../../../src/clients/ledger-json-api/operations/v2/parties/list'; +import { ValidatorApiClient } from '../../../src/clients/validator-api/ValidatorApiClient.generated'; +import { GetMemberTrafficStatus } from '../../../src/clients/validator-api/operations/v0/scan-proxy/get-member-traffic-status'; +import { type BaseClient, CantonRuntime, type ClientConfig, type OperationExecuteOptions } from '../../../src/core'; + +function createFakeClient(): { readonly client: BaseClient; readonly makeGetRequest: jest.Mock } { + const makeGetRequest = jest.fn().mockResolvedValue({ partyDetails: [], nextPageToken: '' }); + const client = { + getApiUrl: (): string => 'https://api.example', + getPartyId: (): string => 'party', + makeGetRequest, + } as unknown as BaseClient; + return { client, makeGetRequest }; +} + +function createRuntimeConfig(apiType: 'LEDGER_JSON_API' | 'VALIDATOR_API'): ClientConfig { + return { + network: 'localnet', + apis: { + [apiType]: { + apiUrl: 'https://api.example', + auth: { grantType: 'client_credentials', clientId: '' }, + }, + }, + }; +} + +describe('custom ApiOperation execution options', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it.each([ + { + name: 'GetParties', + execute: async (client: BaseClient, signal: AbortSignal): Promise => + new GetParties(client).execute({}, { signal, retry: { kind: 'exact-body', maxAttempts: 2 } }), + }, + { + name: 'ListParties', + execute: async (client: BaseClient, signal: AbortSignal): Promise => + new ListParties(client).execute({}, { signal, retry: { kind: 'exact-body', maxAttempts: 2 } }), + }, + { + name: 'GetMemberTrafficStatus', + execute: async (client: BaseClient, signal: AbortSignal): Promise => + new GetMemberTrafficStatus(client).execute( + { domainId: 'domain', memberId: 'member' }, + { signal, retry: { kind: 'exact-body', maxAttempts: 2 } } + ), + }, + ])('forwards signal and retry controls from $name to its HTTP request', async ({ execute }) => { + const { client, makeGetRequest } = createFakeClient(); + const { signal } = new AbortController(); + + await execute(client, signal); + + expect(makeGetRequest).toHaveBeenCalledTimes(1); + expect(makeGetRequest.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ + signal, + requestSemantics: 'read', + retry: expect.objectContaining({ kind: 'exact-body', maxAttempts: 2 }), + }) + ); + }); + + it('forwards options through all three generated client methods', async () => { + const getPartiesResult = { partyDetails: [], nextPageToken: '' }; + const getParties = jest.spyOn(GetParties.prototype, 'execute').mockResolvedValue(getPartiesResult); + const listParties = jest.spyOn(ListParties.prototype, 'execute').mockResolvedValue(getPartiesResult); + const getMemberTrafficStatus = jest + .spyOn(GetMemberTrafficStatus.prototype, 'execute') + .mockResolvedValue({} as never); + const ledgerClient = new LedgerJsonApiClient(new CantonRuntime(createRuntimeConfig('LEDGER_JSON_API'))); + const validatorClient = new ValidatorApiClient(new CantonRuntime(createRuntimeConfig('VALIDATOR_API'))); + const options = { signal: new AbortController().signal }; + + await ledgerClient.getParties({}, options); + await ledgerClient.listParties({}, options); + await validatorClient.getMemberTrafficStatus({ domainId: 'domain', memberId: 'member' }, options); + + expect(getParties).toHaveBeenCalledWith({}, options); + expect(listParties).toHaveBeenCalledWith({}, options); + expect(getMemberTrafficStatus).toHaveBeenCalledWith({ domainId: 'domain', memberId: 'member' }, options); + }); + + it('propagates cancellation and retry disable through member-traffic domain discovery', async () => { + const controller = new AbortController(); + const getOpenAndIssuingMiningRounds = jest.fn( + async (options?: OperationExecuteOptions): Promise => + new Promise((_resolve, reject) => { + options?.signal?.addEventListener( + 'abort', + () => { + const error = new Error('discovery aborted'); + error.name = 'AbortError'; + reject(error); + }, + { once: true } + ); + }) + ); + const makeGetRequest = jest.fn(); + const client = { + getApiUrl: (): string => 'https://api.example', + getPartyId: (): string => 'party', + getOpenAndIssuingMiningRounds, + makeGetRequest, + } as unknown as BaseClient; + + const request = new GetMemberTrafficStatus(client).execute( + {}, + { signal: controller.signal, retry: { kind: 'none' } } + ); + await Promise.resolve(); + controller.abort(new Error('stop discovery')); + + await expect(request).rejects.toMatchObject({ name: 'AbortError' }); + expect(getOpenAndIssuingMiningRounds).toHaveBeenCalledWith({ + signal: controller.signal, + retry: { kind: 'none' }, + }); + expect(makeGetRequest).not.toHaveBeenCalled(); + }); +}); From c21f3907e0b7616423bea377d4d9bb85e4c700e3 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 03:14:58 -0400 Subject: [PATCH 02/21] fix: align Scan failover retryability --- src/clients/scan-api/ScanApiClient.ts | 3 ++- test/unit/clients/scan-api.test.ts | 28 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/clients/scan-api/ScanApiClient.ts b/src/clients/scan-api/ScanApiClient.ts index 9321ab6a..b90829b9 100644 --- a/src/clients/scan-api/ScanApiClient.ts +++ b/src/clients/scan-api/ScanApiClient.ts @@ -177,7 +177,8 @@ export class ScanApiClient extends ScanApiClientGenerated { kind: 'exact-body' as const, maxAttempts: maxDistinctEndpoints, backoffMs: 0, - shouldRetry: ({ error }: { readonly error: Error }): boolean => shouldRotateOnError(error), + shouldRetry: ({ error, retryable }: { readonly error: Error; readonly retryable: boolean }): boolean => + retryable || shouldRotateOnError(error), }); const failoverOptions = snapshotHttpRequestOptions({ ...readOptions, diff --git a/test/unit/clients/scan-api.test.ts b/test/unit/clients/scan-api.test.ts index 19cd46aa..1154bec1 100644 --- a/test/unit/clients/scan-api.test.ts +++ b/test/unit/clients/scan-api.test.ts @@ -39,6 +39,14 @@ function createAxiosNetworkError(): Error { }); } +function createAxiosHttpError(status: number): Error { + return Object.assign(new Error(`HTTP ${status}`), { + isAxiosError: true, + code: 'ERR_BAD_RESPONSE', + response: { status, statusText: status === 404 ? 'Not Found' : 'Request Failed', data: {} }, + }); +} + function createClient( config: ClientConfig, options: ScanApiClientOptions = {} @@ -120,6 +128,26 @@ describe('ScanApiClient', () => { ]); }); + it('rotates a retryable 404 across Scan endpoints', async () => { + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + mockAxiosInstance.get + .mockRejectedValueOnce(createAxiosHttpError(404)) + .mockResolvedValueOnce({ data: { endpoint: 'scan-b' } }); + + await expect( + client.makeGetRequest<{ endpoint: string }>('https://scan-a.example/api/scan/v0/scan/health') + ).resolves.toEqual({ endpoint: 'scan-b' }); + expect(mockAxiosInstance.get.mock.calls.map((call) => call[0])).toEqual([ + 'https://scan-a.example/api/scan/v0/scan/health', + 'https://scan-b.example/api/scan/v0/scan/health', + ]); + }); + it('rotates semantic-read POST operations across Scan endpoints', async () => { const { client, mockAxiosInstance } = createClient( { network: 'mainnet' }, From bc008763ad869258367729ab66c83891681b2360 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 03:43:20 -0400 Subject: [PATCH 03/21] fix: preserve read option types internally --- src/clients/scan-api/ScanApiClient.ts | 2 +- src/core/http/HttpClient.ts | 2 +- src/core/http/request-retry.ts | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/clients/scan-api/ScanApiClient.ts b/src/clients/scan-api/ScanApiClient.ts index b90829b9..fdaaae04 100644 --- a/src/clients/scan-api/ScanApiClient.ts +++ b/src/clients/scan-api/ScanApiClient.ts @@ -150,7 +150,7 @@ export class ScanApiClient extends ScanApiClientGenerated { /** Run Scan failover inside one HttpClient retry loop so attempt budgets, bodies, and hook history stay coherent. */ private async makeReadRequestWithFailover( fullUrl: string, - options: Readonly>, + options: Readonly | HttpRequestOptions>, doRequest: (requestOptions: HttpReadRequestOptions) => Promise ): Promise { const readOptions = snapshotHttpRequestOptions({ ...options, requestSemantics: 'read' }); diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index 8af7ae11..e96cffd2 100644 --- a/src/core/http/HttpClient.ts +++ b/src/core/http/HttpClient.ts @@ -112,7 +112,7 @@ export class HttpClient { url: string, initialBody: Body, config: RequestConfig, - options: HttpRequestOptions + options: HttpReadRequestOptions | HttpRequestOptions ): Promise { // Snapshot every caller-owned option before the first asynchronous boundary. In particular, never let replacing an // options signal or retry object while a token/hook is pending change the in-flight request contract. diff --git a/src/core/http/request-retry.ts b/src/core/http/request-retry.ts index 6f5673aa..c1c0dac9 100644 --- a/src/core/http/request-retry.ts +++ b/src/core/http/request-retry.ts @@ -158,6 +158,9 @@ export function snapshotHttpRequestOptions( options: HttpReadRequestOptions ): Readonly>; export function snapshotHttpRequestOptions(options: HttpRequestOptions): Readonly>; +export function snapshotHttpRequestOptions( + options: HttpReadRequestOptions | HttpRequestOptions +): Readonly | HttpRequestOptions>; export function snapshotHttpRequestOptions( options: HttpReadRequestOptions | HttpRequestOptions ): Readonly | HttpRequestOptions> { From 8eeca3334c29356a16d83f1a99f85df1a3aee0d9 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 03:57:31 -0400 Subject: [PATCH 04/21] fix: preserve Scan rotation predicates --- src/clients/scan-api/ScanApiClient.ts | 26 ++++++++++++++++++-------- test/unit/clients/scan-api.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/clients/scan-api/ScanApiClient.ts b/src/clients/scan-api/ScanApiClient.ts index fdaaae04..4f72bdf7 100644 --- a/src/clients/scan-api/ScanApiClient.ts +++ b/src/clients/scan-api/ScanApiClient.ts @@ -171,15 +171,25 @@ export class ScanApiClient extends ScanApiClientGenerated { const maxDistinctEndpoints = Math.min(this.maxEndpointAttempts, this.scanApiUrls.length); const startingBaseUrlIndex = this.activeBaseUrlIndex; let selectedBaseUrlIndex = startingBaseUrlIndex; + const shouldRetryScanFailure = ({ + error, + retryable, + }: { + readonly error: Error; + readonly retryable: boolean; + }): boolean => retryable || shouldRotateOnError(error); + const configuredRetry = readOptions.retry; const retry = - readOptions.retry ?? - Object.freeze({ - kind: 'exact-body' as const, - maxAttempts: maxDistinctEndpoints, - backoffMs: 0, - shouldRetry: ({ error, retryable }: { readonly error: Error; readonly retryable: boolean }): boolean => - retryable || shouldRotateOnError(error), - }); + configuredRetry === undefined + ? Object.freeze({ + kind: 'exact-body' as const, + maxAttempts: maxDistinctEndpoints, + backoffMs: 0, + shouldRetry: shouldRetryScanFailure, + }) + : configuredRetry.shouldRetry === undefined + ? Object.freeze({ ...configuredRetry, shouldRetry: shouldRetryScanFailure }) + : configuredRetry; const failoverOptions = snapshotHttpRequestOptions({ ...readOptions, retry, diff --git a/test/unit/clients/scan-api.test.ts b/test/unit/clients/scan-api.test.ts index 1154bec1..b30ae4b7 100644 --- a/test/unit/clients/scan-api.test.ts +++ b/test/unit/clients/scan-api.test.ts @@ -148,6 +148,32 @@ describe('ScanApiClient', () => { ]); }); + it.each([408, 429])('preserves Scan-specific %i rotation with an explicit retry budget', async (status) => { + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + mockAxiosInstance.get + .mockRejectedValueOnce(createAxiosHttpError(status)) + .mockResolvedValueOnce({ data: { endpoint: 'scan-b' } }); + + await expect( + client.makeGetRequest<{ endpoint: string }>( + 'https://scan-a.example/api/scan/v0/scan/health', + {}, + { + retry: { kind: 'exact-body', maxAttempts: 2, backoffMs: 0 }, + } + ) + ).resolves.toEqual({ endpoint: 'scan-b' }); + expect(mockAxiosInstance.get.mock.calls.map((call) => call[0])).toEqual([ + 'https://scan-a.example/api/scan/v0/scan/health', + 'https://scan-b.example/api/scan/v0/scan/health', + ]); + }); + it('rotates semantic-read POST operations across Scan endpoints', async () => { const { client, mockAxiosInstance } = createClient( { network: 'mainnet' }, From c7a9c412cc688ee8e2e5e696595e83520c834af8 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 04:09:11 -0400 Subject: [PATCH 05/21] fix: stabilize derived retry parameters --- .../scan-proxy/get-member-traffic-status.ts | 56 ++++++++------- .../operations/operation-request-options.ts | 12 +++- .../operations/api-operation-retry.test.ts | 33 +++++++++ .../custom-api-operation-options.test.ts | 69 +++++++++++++++++++ 4 files changed, 146 insertions(+), 24 deletions(-) diff --git a/src/clients/validator-api/operations/v0/scan-proxy/get-member-traffic-status.ts b/src/clients/validator-api/operations/v0/scan-proxy/get-member-traffic-status.ts index e27f21dd..482b8489 100644 --- a/src/clients/validator-api/operations/v0/scan-proxy/get-member-traffic-status.ts +++ b/src/clients/validator-api/operations/v0/scan-proxy/get-member-traffic-status.ts @@ -60,39 +60,49 @@ export class GetMemberTrafficStatus extends ApiOperation => { - const validatedAttemptParams = this.validateParams(attemptParams, GetMemberTrafficStatusParamsSchema); - - // Auto-determine domainId if not provided - let { domainId } = validatedAttemptParams; - if (!domainId) { - if (!hasMiningRoundMethods(this.client)) { - throw new Error('Client does not support getOpenAndIssuingMiningRounds - use ValidatorApiClient'); - } - const miningRoundClient = this.client; - domainId = await awaitWithAbort( - async (): Promise => getCurrentMiningRoundDomainId(miningRoundClient, discoveryOptions), - operationOptions?.signal - ); + // Resolve implicit endpoint components exactly once. Retry hooks should observe the same concrete params used by the + // request URL, and derived retries that omit defaults must not repeat discovery or drift to a different endpoint. + let initialDomainId = validatedParams.domainId; + if (!initialDomainId) { + if (!hasMiningRoundMethods(this.client)) { + throw new Error('Client does not support getOpenAndIssuingMiningRounds - use ValidatorApiClient'); } - - // Auto-determine memberId if not provided - const memberId = validatedAttemptParams.memberId ?? this.client.getPartyId(); - - return `${this.getApiUrl()}/api/validator/v0/scan-proxy/domains/${encodeURIComponent(domainId)}/members/${encodeURIComponent(memberId)}/traffic-status`; + const miningRoundClient = this.client; + initialDomainId = await awaitWithAbort( + async (): Promise => getCurrentMiningRoundDomainId(miningRoundClient, discoveryOptions), + operationOptions?.signal + ); + } + const initialMemberId = validatedParams.memberId ?? this.client.getPartyId(); + const apiUrl = this.getApiUrl(); + const resolvedInitialParams = Object.freeze({ domainId: initialDomainId, memberId: initialMemberId }); + const normalizeAttemptParams = ( + attemptParams: GetMemberTrafficStatusParams + ): { readonly domainId: string; readonly memberId: string } => { + const validatedAttemptParams = this.validateParams(attemptParams, GetMemberTrafficStatusParamsSchema); + return Object.freeze({ + domainId: + validatedAttemptParams.domainId === undefined || validatedAttemptParams.domainId.length === 0 + ? resolvedInitialParams.domainId + : validatedAttemptParams.domainId, + memberId: validatedAttemptParams.memberId ?? resolvedInitialParams.memberId, + }); + }; + const buildUrl = (attemptParams: GetMemberTrafficStatusParams): string => { + const { domainId, memberId } = normalizeAttemptParams(attemptParams); + return `${apiUrl}/api/validator/v0/scan-proxy/domains/${encodeURIComponent(domainId)}/members/${encodeURIComponent(memberId)}/traffic-status`; }; - const url = await buildUrl(validatedParams); + const url = buildUrl(resolvedInitialParams); const httpOptions = operationOptions === undefined ? undefined : createOperationHttpRequestOptions({ - initialParams: validatedParams, + initialParams: resolvedInitialParams, options: operationOptions, requestSemantics: 'read', initialUrl: url, - validateParams: (derivedParams): GetMemberTrafficStatusParams => - this.validateParams(derivedParams, GetMemberTrafficStatusParamsSchema), + validateParams: normalizeAttemptParams, buildUrl, buildBody: (): undefined => undefined, }); diff --git a/src/core/operations/operation-request-options.ts b/src/core/operations/operation-request-options.ts index 52da6b2e..7907d20b 100644 --- a/src/core/operations/operation-request-options.ts +++ b/src/core/operations/operation-request-options.ts @@ -57,7 +57,7 @@ export function createOperationHttpRequestOptions): Promise> => { const readonlyDerivedParams = await operationRetry.deriveParams(toOperationRetryContext(context)); - const derivedParams = cloneRequestValue(readonlyDerivedParams) as Params; + const derivedParams = cloneDerivedParams(readonlyDerivedParams); const validatedDerivedParams = config.validateParams(derivedParams); const derivedUrl = await config.buildUrl(validatedDerivedParams); if (derivedUrl !== config.initialUrl) { @@ -144,3 +144,13 @@ function createReadonlyParams(params: Params): DeepReadonly { }); } } + +function cloneDerivedParams(params: DeepReadonly): Params { + try { + return cloneRequestValue(params) as Params; + } catch (error) { + throw new ConfigurationError('Derived retry parameters must be structured-cloneable', { + cause: error instanceof Error ? error.message : 'unknown clone failure', + }); + } +} diff --git a/test/unit/operations/api-operation-retry.test.ts b/test/unit/operations/api-operation-retry.test.ts index 92395988..71d225d3 100644 --- a/test/unit/operations/api-operation-retry.test.ts +++ b/test/unit/operations/api-operation-retry.test.ts @@ -199,6 +199,39 @@ describe('factory-created operation retry plumbing', () => { expect(buildCounter.count).toBe(1); }); + it('reports non-cloneable derived parameters as a configuration error', async () => { + const buildCounter = { count: 0 }; + const { operation, post } = createOperation(buildCounter); + const nonCloneableParams = new Map([ + ['callback', () => undefined], + ]) as unknown as RetryOperationParams; + post.mockRejectedValueOnce(createAxiosError(503)); + + await expect( + operation.execute( + { resourceId: 'resource-1', submissionId: 'submission-1', payload: 'signed-transaction' }, + { + retry: { + kind: 'derived-body', + maxAttempts: 2, + backoffMs: 0, + shouldRetry: () => true, + deriveParams: () => nonCloneableParams, + }, + } + ) + ).rejects.toMatchObject({ + name: 'UnknownMutationOutcomeError', + cause: expect.objectContaining({ + name: 'ConfigurationError', + message: 'Derived retry parameters must be structured-cloneable', + }), + }); + + expect(post).toHaveBeenCalledTimes(1); + expect(buildCounter.count).toBe(1); + }); + it('cancels a hung initial request-body builder before dispatch', async () => { const bodyGate = new Promise>(() => undefined); const Operation = createApiOperation({ diff --git a/test/unit/operations/custom-api-operation-options.test.ts b/test/unit/operations/custom-api-operation-options.test.ts index 6d7fa9f4..c180ff95 100644 --- a/test/unit/operations/custom-api-operation-options.test.ts +++ b/test/unit/operations/custom-api-operation-options.test.ts @@ -125,4 +125,73 @@ describe('custom ApiOperation execution options', () => { }); expect(makeGetRequest).not.toHaveBeenCalled(); }); + + it('resolves member-traffic defaults once and exposes the concrete endpoint params to retry hooks', async () => { + const observedParams: unknown[] = []; + const getOpenAndIssuingMiningRounds = jest.fn().mockResolvedValue({ + open_mining_rounds: [ + { + contract: { + contract_id: 'round-contract', + template_id: 'round-template', + created_event_blob: 'round-blob', + payload: { opensAt: '2000-01-01T00:00:00Z', round_number: 1 }, + }, + domain_id: 'discovered-domain', + }, + ], + issuing_mining_rounds: [], + }); + const makeGetRequest = jest.fn( + async (_url: string, _config: unknown, httpOptions: NonNullable[2]>) => { + const { retry } = httpOptions; + if (retry?.kind !== 'derived-body') throw new Error('Expected derived-body retry options'); + await retry.beforeAttempt?.({ attempt: 1, body: undefined, previousAttempts: [] }); + await retry.deriveBody({ + attempt: 1, + body: undefined, + previousAttempts: [], + error: new Error('retry'), + errorClassification: 'transient-read-failure', + outcomeCertainty: 'definite', + retryable: true, + }); + await retry.beforeAttempt?.({ attempt: 2, body: undefined, previousAttempts: [] }); + return {}; + } + ); + const getPartyId = jest.fn((): string => 'default-member'); + const client = { + getApiUrl: (): string => 'https://api.example', + getPartyId, + getOpenAndIssuingMiningRounds, + makeGetRequest, + } as unknown as BaseClient; + + await new GetMemberTrafficStatus(client).execute( + {}, + { + retry: { + kind: 'derived-body', + maxAttempts: 2, + beforeAttempt: ({ params }) => { + observedParams.push(params); + }, + deriveParams: () => ({}), + }, + } + ); + + expect(getOpenAndIssuingMiningRounds).toHaveBeenCalledTimes(1); + expect(getPartyId).toHaveBeenCalledTimes(1); + expect(makeGetRequest).toHaveBeenCalledWith( + 'https://api.example/api/validator/v0/scan-proxy/domains/discovered-domain/members/default-member/traffic-status', + { includeBearerToken: true }, + expect.any(Object) + ); + expect(observedParams).toEqual([ + { domainId: 'discovered-domain', memberId: 'default-member' }, + { domainId: 'discovered-domain', memberId: 'default-member' }, + ]); + }); }); From 852e5ba3e7b192612cbe4975d7a32b3dcd5394e6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 04:49:11 -0400 Subject: [PATCH 06/21] fix: reject unsafe retry snapshot values --- src/clients/scan-api/ScanApiClient.ts | 2 + src/core/http/request-value.ts | 42 ++++++++++++++----- test/unit/clients/scan-api.test.ts | 25 +++++++++++ test/unit/core/request-value.test.ts | 26 ++++++++++++ .../custom-api-operation-options.test.ts | 9 ++-- 5 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 test/unit/core/request-value.test.ts diff --git a/src/clients/scan-api/ScanApiClient.ts b/src/clients/scan-api/ScanApiClient.ts index 4f72bdf7..fde5d4ad 100644 --- a/src/clients/scan-api/ScanApiClient.ts +++ b/src/clients/scan-api/ScanApiClient.ts @@ -179,6 +179,8 @@ export class ScanApiClient extends ScanApiClientGenerated { readonly retryable: boolean; }): boolean => retryable || shouldRotateOnError(error); const configuredRetry = readOptions.retry; + // A caller-supplied predicate is authoritative, matching HttpClient semantics. Scan contributes its transport and + // rotation classification only when the caller supplies an attempt budget without a custom predicate. const retry = configuredRetry === undefined ? Object.freeze({ diff --git a/src/core/http/request-value.ts b/src/core/http/request-value.ts index bdd93e95..f315f9da 100644 --- a/src/core/http/request-value.ts +++ b/src/core/http/request-value.ts @@ -1,27 +1,47 @@ /** Clone request data without converting Node.js Buffers into Uint8Arrays. */ export function cloneRequestValue(value: Value): Value { + return cloneRequestValueInternal(value, new WeakSet()); +} + +function cloneRequestValueInternal(value: Value, ancestors: WeakSet): Value { if (value === null || typeof value !== 'object') return value; if (Buffer.isBuffer(value)) return Buffer.from(value) as Value; - if (Array.isArray(value)) { - const items = value as unknown[]; - return items.map((item) => cloneRequestValue(item)) as Value; - } if (value instanceof Date) return new Date(value.getTime()) as Value; if (value instanceof ArrayBuffer) return value.slice(0) as Value; if (ArrayBuffer.isView(value)) return structuredClone(value); + if (ancestors.has(value)) { + throw new TypeError('Cyclic request values are not supported'); + } + ancestors.add(value); + const prototype: unknown = Object.getPrototypeOf(value); - if (prototype === Object.prototype || prototype === null) { - const cloned: Record = {}; - for (const [key, nested] of Object.entries(value)) cloned[key] = cloneRequestValue(nested); - return cloned as Value; + try { + if (Array.isArray(value)) { + const items = value as unknown[]; + return items.map((item) => cloneRequestValueInternal(item, ancestors)) as Value; + } + if (prototype === Object.prototype || prototype === null) { + const cloned: Record = {}; + for (const [key, nested] of Object.entries(value)) { + cloned[key] = cloneRequestValueInternal(nested, ancestors); + } + return cloned as Value; + } + } finally { + ancestors.delete(value); } - return structuredClone(value); + const constructorName = Object.prototype.toString.call(value).slice(8, -1); + throw new TypeError(`Unsupported request value type: ${constructorName}`); } /** Recursively freeze cloned request data while leaving typed-array storage intact. */ export function deepFreezeRequestValue(value: Value): Value { + return deepFreezeRequestValueInternal(value, new WeakSet()); +} + +function deepFreezeRequestValueInternal(value: Value, visited: WeakSet): Value { if ( value === null || typeof value !== 'object' || @@ -31,6 +51,8 @@ export function deepFreezeRequestValue(value: Value): Value { ) { return value; } - for (const nested of Object.values(value)) deepFreezeRequestValue(nested); + if (visited.has(value)) return value; + visited.add(value); + for (const nested of Object.values(value)) deepFreezeRequestValueInternal(nested, visited); return Object.freeze(value); } diff --git a/test/unit/clients/scan-api.test.ts b/test/unit/clients/scan-api.test.ts index b30ae4b7..2ee668be 100644 --- a/test/unit/clients/scan-api.test.ts +++ b/test/unit/clients/scan-api.test.ts @@ -174,6 +174,31 @@ describe('ScanApiClient', () => { ]); }); + it('treats a caller-supplied retry predicate as authoritative for Scan rotation', async () => { + const { client, mockAxiosInstance } = createClient( + { network: 'mainnet' }, + { + scanApiUrls: ['https://scan-a.example/api/scan', 'https://scan-b.example/api/scan'], + } + ); + const shouldRetry = jest.fn((): boolean => false); + mockAxiosInstance.get.mockRejectedValueOnce(createAxiosHttpError(429)); + + await expect( + client.makeGetRequest( + 'https://scan-a.example/api/scan/v0/scan/health', + {}, + { + retry: { kind: 'exact-body', maxAttempts: 2, backoffMs: 0, shouldRetry }, + } + ) + ).rejects.toBeInstanceOf(ApiError); + expect(shouldRetry).toHaveBeenCalledTimes(1); + expect(mockAxiosInstance.get.mock.calls.map((call) => call[0])).toEqual([ + 'https://scan-a.example/api/scan/v0/scan/health', + ]); + }); + it('rotates semantic-read POST operations across Scan endpoints', async () => { const { client, mockAxiosInstance } = createClient( { network: 'mainnet' }, diff --git a/test/unit/core/request-value.test.ts b/test/unit/core/request-value.test.ts new file mode 100644 index 00000000..3079bf8d --- /dev/null +++ b/test/unit/core/request-value.test.ts @@ -0,0 +1,26 @@ +import { cloneRequestValue, deepFreezeRequestValue } from '../../../src/core/http/request-value'; + +describe('request value snapshots', () => { + it('rejects cyclic plain request values without overflowing the stack', () => { + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + + expect(() => cloneRequestValue(cyclic)).toThrow('Cyclic request values are not supported'); + }); + + it.each([ + ['custom class', new (class RequestValue {})()], + ['Map', new Map([['key', 'value']])], + ['Set', new Set(['value'])], + ])('rejects unsupported %s instances instead of silently changing their representation', (_name, value) => { + expect(() => cloneRequestValue(value)).toThrow('Unsupported request value type'); + }); + + it('guards recursive freezing when given an already-cyclic object', () => { + const cyclic: { self?: unknown } = {}; + cyclic.self = cyclic; + + expect(deepFreezeRequestValue(cyclic)).toBe(cyclic); + expect(Object.isFrozen(cyclic)).toBe(true); + }); +}); diff --git a/test/unit/operations/custom-api-operation-options.test.ts b/test/unit/operations/custom-api-operation-options.test.ts index c180ff95..523dadbe 100644 --- a/test/unit/operations/custom-api-operation-options.test.ts +++ b/test/unit/operations/custom-api-operation-options.test.ts @@ -71,9 +71,12 @@ describe('custom ApiOperation execution options', () => { const getPartiesResult = { partyDetails: [], nextPageToken: '' }; const getParties = jest.spyOn(GetParties.prototype, 'execute').mockResolvedValue(getPartiesResult); const listParties = jest.spyOn(ListParties.prototype, 'execute').mockResolvedValue(getPartiesResult); - const getMemberTrafficStatus = jest - .spyOn(GetMemberTrafficStatus.prototype, 'execute') - .mockResolvedValue({} as never); + const getMemberTrafficStatus = jest.spyOn(GetMemberTrafficStatus.prototype, 'execute').mockResolvedValue({ + traffic_status: { + actual: { total_consumed: 0, total_limit: 0 }, + target: { total_purchased: 0 }, + }, + }); const ledgerClient = new LedgerJsonApiClient(new CantonRuntime(createRuntimeConfig('LEDGER_JSON_API'))); const validatorClient = new ValidatorApiClient(new CantonRuntime(createRuntimeConfig('VALIDATOR_API'))); const options = { signal: new AbortController().signal }; From 9d21bf6dcbf1306472aff8187ab76e9ad2a9c9cb Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 05:20:56 -0400 Subject: [PATCH 07/21] fix: validate derived retry configuration --- src/core/http/HttpClient.ts | 3 +++ src/core/http/request-retry.ts | 2 +- test/typecheck/operation-retry.typecheck.ts | 4 ++++ test/unit/core/http-client-retry.test.ts | 25 ++++++++++++++++++++- 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index e96cffd2..d0bcd9bb 100644 --- a/src/core/http/HttpClient.ts +++ b/src/core/http/HttpClient.ts @@ -311,6 +311,9 @@ export class HttpClient { if (retry) { if (retry.kind === 'none') return Object.freeze({ kind: 'none' }); if (retry.kind === 'exact-body') return Object.freeze({ ...retry }); + if (typeof retry.deriveBody !== 'function') { + throw new ConfigurationError('HTTP derived-body retry requires a deriveBody function'); + } return Object.freeze({ ...retry }); } diff --git a/src/core/http/request-retry.ts b/src/core/http/request-retry.ts index c1c0dac9..c5690cd8 100644 --- a/src/core/http/request-retry.ts +++ b/src/core/http/request-retry.ts @@ -2,7 +2,7 @@ export type MaybePromise = T | Promise; /** Recursively marks a request value as readonly while it is exposed to retry callbacks. */ -export type DeepReadonly = T extends (...args: never[]) => unknown +export type DeepReadonly = T extends (...args: infer _Arguments) => infer _Return ? T : T extends readonly unknown[] ? { readonly [Index in keyof T]: DeepReadonly } diff --git a/test/typecheck/operation-retry.typecheck.ts b/test/typecheck/operation-retry.typecheck.ts index d5a737cb..abb39450 100644 --- a/test/typecheck/operation-retry.typecheck.ts +++ b/test/typecheck/operation-retry.typecheck.ts @@ -31,6 +31,9 @@ const signedRequest = { interface NonEmptyTuplePayload { readonly values: readonly [string, ...string[]]; } +type ParameterizedRequestCallback = (value: string, count: number) => boolean; +const parameterizedRequestCallback: DeepReadonly = (value, count) => + value.length === count; const validTuplePayload: DeepReadonly = { values: ['required'] }; const invalidTuplePayload: DeepReadonly = { // @ts-expect-error DeepReadonly must preserve non-empty tuple constraints. @@ -38,6 +41,7 @@ const invalidTuplePayload: DeepReadonly = { }; void validTuplePayload; void invalidTuplePayload; +void parameterizedRequestCallback('value', 5); void client.allocateExternalParty(signedRequest, { retry: { diff --git a/test/unit/core/http-client-retry.test.ts b/test/unit/core/http-client-retry.test.ts index cf34e192..4f1d2865 100644 --- a/test/unit/core/http-client-retry.test.ts +++ b/test/unit/core/http-client-retry.test.ts @@ -1,6 +1,7 @@ import axios from 'axios'; -import { ApiError, NetworkError, UnknownMutationOutcomeError } from '../../../src/core/errors'; +import { ApiError, ConfigurationError, NetworkError, UnknownMutationOutcomeError } from '../../../src/core/errors'; import { HttpClient } from '../../../src/core/http/HttpClient'; +import { type HttpRequestOptions } from '../../../src/core/http/request-retry'; import { type Logger } from '../../../src/core/logging'; jest.mock('axios', () => { @@ -235,6 +236,28 @@ describe('HttpClient mutation retry safety', () => { expect(body).toEqual({ submissionId: 'submission-1', payload: 'prepared-transaction' }); }); + it('rejects a malformed derived-body strategy before dispatch', async () => { + const { client, axiosInstance } = createClient(); + const malformedOptions = { + retry: { kind: 'derived-body', maxAttempts: 2 }, + } as unknown as HttpRequestOptions<{ submissionId: string }>; + + await expect( + client.makePostRequest( + 'https://ledger.example/v2/interactive-submission/execute', + { submissionId: 'submission-1' }, + {}, + malformedOptions + ) + ).rejects.toEqual( + expect.objectContaining({ + name: ConfigurationError.name, + message: 'HTTP derived-body retry requires a deriveBody function', + }) + ); + expect(axiosInstance.post).not.toHaveBeenCalled(); + }); + it('preserves definite mutation rejections and redacts ambiguous outcomes', async () => { const definite = createClient(); definite.axiosInstance.post.mockRejectedValueOnce(createAxiosError(400, { code: 'INVALID_ARGUMENT' })); From f3c5141fe9d153955164fb35f45ddaa5036d0b85 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 05:49:16 -0400 Subject: [PATCH 08/21] feat: harden interactive submissions end to end --- .../interactive-submission/allocate-party.ts | 34 - .../v2/interactive-submission/create-user.ts | 39 - .../execute-and-wait-for-transaction.ts | 19 + .../execute-and-wait.ts | 19 + .../v2/interactive-submission/execute.ts | 4 +- .../get-preferred-package-version.ts | 6 +- .../get-preferred-packages.ts | 9 +- .../v2/interactive-submission/index.ts | 5 +- .../v2/interactive-submission/prepare.ts | 2 + .../v2/interactive-submission/upload-dar.ts | 30 - .../schemas/api/interactive-submission.ts | 958 ++++++++++--- .../ledger-json-api/schemas/api/packages.ts | 47 +- src/clients/ledger-json-api/schemas/index.ts | 3 + .../operations/interactive-submission.ts | 96 +- src/clients/ledger-json-api/schemas/wire.ts | 35 + src/core/operations/ApiOperation.ts | 21 +- src/core/operations/ApiOperationFactory.ts | 74 +- .../operations/operation-execute-options.ts | 5 + .../amulet/external-party-transfer-offer.ts | 13 +- .../execute-external-transaction.ts | 73 +- .../external-signing/external-party-wallet.ts | 7 +- .../prepare-external-transaction.ts | 39 +- src/utils/traffic/estimate-traffic-cost.ts | 13 +- .../traffic/get-estimated-traffic-cost.ts | 2 +- src/utils/traffic/types.ts | 2 +- .../ledger-api/interactive-submission.test.ts | 380 +++++ test/integration/localnet/ledger-api/setup.ts | 37 + ...ledger-interactive-submission.typecheck.ts | 524 +++++++ .../external-party-transfer-offer.test.ts | 63 +- ...er-json-api-interactive-submission.test.ts | 1246 +++++++++++++++++ .../execute-external-transaction.test.ts | 183 ++- .../external-party-wallet.test.ts | 18 +- .../prepare-external-transaction.test.ts | 48 +- .../operations/api-operation-retry.test.ts | 241 +++- .../traffic/estimate-traffic-cost.test.ts | 79 +- .../get-estimated-traffic-cost.test.ts | 15 +- 36 files changed, 3662 insertions(+), 727 deletions(-) delete mode 100644 src/clients/ledger-json-api/operations/v2/interactive-submission/allocate-party.ts delete mode 100644 src/clients/ledger-json-api/operations/v2/interactive-submission/create-user.ts create mode 100644 src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait-for-transaction.ts create mode 100644 src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait.ts delete mode 100644 src/clients/ledger-json-api/operations/v2/interactive-submission/upload-dar.ts create mode 100644 src/clients/ledger-json-api/schemas/wire.ts create mode 100644 test/integration/localnet/ledger-api/interactive-submission.test.ts create mode 100644 test/typecheck/ledger-interactive-submission.typecheck.ts create mode 100644 test/unit/clients/ledger-json-api-interactive-submission.test.ts 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..f9d80a30 --- /dev/null +++ b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait-for-transaction.ts @@ -0,0 +1,19 @@ +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, + 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..a2449784 --- /dev/null +++ b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait.ts @@ -0,0 +1,19 @@ +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, + 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..819b67e2 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,20 @@ 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, + 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..24714860 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,6 +12,7 @@ export const InteractiveSubmissionPrepare = createApiOperation< InteractiveSubmissionPrepareResponse >({ paramsSchema: InteractiveSubmissionPrepareRequestSchema, + responseSchema: InteractiveSubmissionPrepareResponseSchema, method: 'POST', requestSemantics: 'read', buildUrl: (_params: InteractiveSubmissionPrepareRequest, apiUrl: string) => 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..716a2ec5 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,752 @@ 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 } from '../../../../core'; +import type { + components, + paths, +} from '../../../../generated/canton/community/ledger/ledger-json-api/src/test/resources/json-api-docs/openapi'; +import { + CantonSha256HashHexSchema, + LedgerBase64BytesSchema, + LedgerNonEmptyBase64BytesSchema, + LedgerRfc3339TimestampSchema, +} 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; + +export type InteractiveSubmissionTransaction = Omit & { + events: InteractiveSubmissionEvent[]; +}; + +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' } +); + +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({ +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, +}); + +const DeduplicationOffsetSchema = createRequestSchema()({ + value: NonNegativeInt64Schema, +}); + +const DeduplicationPeriodSchema: z.ZodType = z.union([ + createRequestSchema>()({ + DeduplicationDuration: DeduplicationDurationSchema, + }), + createRequestSchema>()({ + DeduplicationOffset: DeduplicationOffsetSchema, + }), + createRequestSchema>()({ + Empty: EmptyObjectSchema, + }), +]); + +const MinLedgerTimeAbsoluteSchema = createRequestSchema()({ + value: LedgerRfc3339TimestampSchema, +}); + +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(), + createArguments: RequiredJsonValueSchema, +}); + +const ExerciseCommandContentSchema = createRequestSchema()({ + templateId: z.string(), contractId: z.string(), + choice: z.string(), + choiceArgument: RequiredJsonValueSchema, +}); + +const CreateAndExerciseCommandContentSchema = createRequestSchema()({ templateId: z.string(), - createdEventBlob: z.string().optional(), - synchronizerId: z.string(), - /** Optional metadata for the disclosed contract */ - metadata: RecordSchema.optional(), + createArguments: RequiredJsonValueSchema, + choice: z.string(), + choiceArgument: RequiredJsonValueSchema, }); -const PackagePreferenceSchema = z.object({ - packageId: z.string().optional(), - packageName: z.string().optional(), +const ExerciseByKeyCommandContentSchema = createRequestSchema()({ + templateId: z.string(), + contractKey: RequiredJsonValueSchema, + choice: z.string(), + choiceArgument: RequiredJsonValueSchema, }); -/** Interactive submission prepare request. */ -export const InteractiveSubmissionPrepareRequestSchema = z.object({ - commands: z.array(CommandSchema), +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(), + 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: z.string(), - userId: z.string(), - actAs: z.array(z.string()), - readAs: z.array(z.string()), + 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: z.string().min(1), + 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: LedgerNonEmptyBase64BytesSchema.optional(), + createArgument: RequiredJsonValueSchema, + createdEventBlob: LedgerNonEmptyBase64BytesSchema.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: z.string().optional(), + tracestate: z.string().optional(), }); -/** Interactive submission execute response. */ -export const InteractiveSubmissionExecuteResponseSchema = z.object({}); +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(CantonSha256HashHexSchema), + paidTrafficCost: nullableOptionalResponseField(NonNegativeInt64Schema), +}); -// 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; +/** 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..aac8219e --- /dev/null +++ b/src/clients/ledger-json-api/schemas/wire.ts @@ -0,0 +1,35 @@ +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', +}); + +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 8f4181ad..c8009968 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. */ @@ -98,6 +105,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. * @@ -110,12 +127,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 @@ -193,10 +220,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 b3200445..2e9e3df9 100644 --- a/src/utils/external-signing/execute-external-transaction.ts +++ b/src/utils/external-signing/execute-external-transaction.ts @@ -1,32 +1,42 @@ 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']; 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 @@ -43,36 +53,47 @@ 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 ?? { DeduplicationDuration: { - value: { duration: '30s' }, + value: { seconds: 30, nanos: 0 }, }, }, 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/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..132de19b 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] : undefined; + 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..32995739 --- /dev/null +++ b/test/integration/localnet/ledger-api/interactive-submission.test.ts @@ -0,0 +1,380 @@ +/** End-to-end validation for Ledger JSON API interactive submission response formats. */ + +import { Keypair } from '@stellar/stellar-base'; +import { + 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, resolveWalletAppInstallContext } from './setup'; + +const APP_INSTALL_REQUEST_TEMPLATE = '#quickstart-licensing:Licensing.AppInstall:AppInstallRequest'; +const APP_INSTALL_REQUEST_TEMPLATE_SUFFIX = 'Licensing.AppInstall:AppInstallRequest'; +const WALLET_APP_INSTALL_TEMPLATE = '#splice-wallet:Splice.Wallet.Install:WalletAppInstall'; + +interface PreparedSignedAppInstall { + readonly request: InteractiveSubmissionExecuteRequest; + readonly preparedTransactionHashHex: string; + readonly expectedPayload: { + readonly provider: string; + readonly user: string; + readonly meta: { readonly values: { readonly test: 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 expectSubmittedAppInstall( + transaction: InteractiveSubmissionTransaction | LookupTransaction, + prepared: PreparedSignedAppInstall +): void { + expect(transaction.externalTransactionHash).toBe(prepared.preparedTransactionHashHex); + const createdEvent = transaction.events + .map((event) => ('CreatedEvent' in event ? event.CreatedEvent : undefined)) + .find((event) => event?.templateId.includes(APP_INSTALL_REQUEST_TEMPLATE_SUFFIX)); + expect(createdEvent).toBeDefined(); + expect(createdEvent?.createArgument).toEqual(prepared.expectedPayload); +} + +async function prepareSignedAppInstall(options: { + readonly client: LedgerJsonApiClient; + readonly keypair: Keypair; + 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 = { + provider: options.validatorParty, + user: options.externalParty, + meta: { values: { test: uniqueId } }, + } as const; + const prepared = await options.client.interactiveSubmissionPrepare({ + userId: options.userId, + commandId: uniqueId, + commands: [ + { + CreateCommand: { + templateId: APP_INSTALL_REQUEST_TEMPLATE, + createArguments: expectedPayload, + }, + }, + ], + actAs: [options.externalParty], + synchronizerId: options.synchronizerId, + packageIdSelectionPreference: [options.packageId], + estimateTrafficCost: { disabled: true }, + 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('prepare validates the live endpoint and normalizes nullable response options', async () => { + const client = getClient(); + const validatorClient = new ValidatorApiClient(new CantonRuntime(buildIntegrationTestClientConfig())); + const validatorInfo = await validatorClient.getValidatorUserInfo(); + const partyId = validatorInfo.party_id; + if (!partyId) { + throw new Error('getValidatorUserInfo returned empty party_id'); + } + client.setPartyId(partyId); + + const partiesResponse = await client.listParties({}); + const receiverParty = partiesResponse.partyDetails + .map((entry: { party: string }) => entry.party) + .find((id: string) => id !== partyId); + if (!receiverParty) { + throw new Error( + 'Integration precondition failed: need at least two distinct parties on the ledger (transfer offer cannot use self as receiver)' + ); + } + + const { contractId: walletInstallCid, synchronizerId } = await resolveWalletAppInstallContext(client, partyId); + const commandId = `interactive-prepare-it-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const response = await client.interactiveSubmissionPrepare({ + commandId, + ...(synchronizerId !== undefined ? { synchronizerId } : {}), + commands: [ + { + ExerciseCommand: { + templateId: '#splice-wallet:Splice.Wallet.Install:WalletAppInstall', + contractId: walletInstallCid, + choice: 'WalletAppInstall_CreateTransferOffer', + choiceArgument: { + receiver: receiverParty, + amount: { amount: '0.0000001', unit: 'AmuletUnit' }, + description: 'interactive submission prepare integration test', + expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + trackingId: commandId, + }, + }, + }, + ], + actAs: [partyId], + verboseHashing: false, + estimateTrafficCost: { disabled: true }, + }); + + expect(response).toEqual({ + preparedTransaction: expect.any(String) as string, + preparedTransactionHash: expect.any(String) as string, + hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', + }); + expect(response.preparedTransaction).not.toHaveLength(0); + expect(response.preparedTransactionHash).not.toHaveLength(0); + }, 120_000); + + 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 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: 'quickstart-licensing', + parties: [external.partyId], + synchronizerId, + }); + const preferredPackages = await client.interactiveSubmissionGetPreferredPackages({ + packageVettingRequirements: [{ packageName: 'quickstart-licensing', parties: [external.partyId] }], + synchronizerId, + }); + const packageReference = preferredPackages.packageReferences[0]; + if (!packageReference) { + throw new Error('preferred-packages returned no quickstart-licensing reference'); + } + expect(packageReference.packageName).toBe('quickstart-licensing'); + expect(preferredVersion.packagePreference?.packageReference).toEqual(packageReference); + + const asyncPrepared = await prepareSignedAppInstall({ + client, + keypair, + 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); + expectSubmittedAppInstall(asyncTransaction.transaction, asyncPrepared); + + const waitPrepared = await prepareSignedAppInstall({ + client, + keypair, + 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); + expectSubmittedAppInstall(waitedTransaction.transaction, waitPrepared); + + const transactionPrepared = await prepareSignedAppInstall({ + client, + keypair, + 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+/); + expectSubmittedAppInstall(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..c1b6b932 --- /dev/null +++ b/test/typecheck/ledger-interactive-submission.typecheck.ts @@ -0,0 +1,524 @@ +import type { LedgerJsonApiClient } from '../../src/clients/ledger-json-api'; +import type { + InteractiveSubmissionCommand, + InteractiveSubmissionEvent, + InteractiveSubmissionIdentifierFilter, + InteractiveSubmissionProtoAny, + InteractiveSubmissionSignature, +} 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 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'); + +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..ce060447 --- /dev/null +++ b/test/unit/clients/ledger-json-api-interactive-submission.test.ts @@ -0,0 +1,1246 @@ +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 = `1220${'ab'.repeat(32)}`; + +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, + createArgument: { owner: 'party::fingerprint' }, + 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, + { + 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(); + 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', + }); + }); + + 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(); + + await expect(client.interactiveSubmissionExecute(request)).resolves.toEqual(response); + + expect(post).toHaveBeenCalledTimes(1); + expect(post).toHaveBeenCalledWith('https://ledger.example.test/v2/interactive-submission/execute', request, { + 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(); + + await expect(client.interactiveSubmissionExecuteAndWait(request)).resolves.toEqual(response); + + expect(post).toHaveBeenCalledWith('https://ledger.example.test/v2/interactive-submission/executeAndWait', request, { + 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', + }, + }; + + await expect(client.interactiveSubmissionExecuteAndWaitForTransaction(request)).resolves.toEqual(response); + + expect(post).toHaveBeenCalledWith( + 'https://ledger.example.test/v2/interactive-submission/executeAndWaitForTransaction', + request, + { + 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.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('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-canton-hash'], + ['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: [] }], + [ + '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 } }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'fractional deduplication offsets', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationOffset: { value: 1.5 } }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'unsafe deduplication offsets', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationOffset: { value: Number.MAX_SAFE_INTEGER + 1 } }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'malformed prepared transaction Base64', + { + ...createExecuteAndWaitRequest(), + preparedTransaction: 'not base64!', + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'base64url prepared transaction bytes', + { + ...createExecuteAndWaitRequest(), + preparedTransaction: '-_8=', + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'unpadded prepared transaction Base64', + { + ...createExecuteAndWaitRequest(), + preparedTransaction: 'YWJjZA', + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'noncanonical prepared transaction Base64 padding', + { + ...createExecuteAndWaitRequest(), + preparedTransaction: 'YQ====', + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + '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' } } }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'an empty submission ID', + { + ...createExecuteAndWaitRequest(), + submissionId: '', + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + '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 42ecf2a3..082fb180 100644 --- a/test/unit/external-signing/execute-external-transaction.test.ts +++ b/test/unit/external-signing/execute-external-transaction.test.ts @@ -2,17 +2,30 @@ import type { LedgerJsonApiClient } from '../../../src/clients/ledger-json-api'; import { 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; @@ -29,39 +42,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, @@ -69,6 +58,7 @@ describe('executeExternalTransaction', () => { preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', partySignatures, + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionExecute).toHaveBeenCalledWith({ @@ -78,7 +68,7 @@ describe('executeExternalTransaction', () => { hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', deduplicationPeriod: { DeduplicationDuration: { - value: { duration: '30s' }, + value: { seconds: 30, nanos: 0 }, }, }, partySignatures: { @@ -87,19 +77,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', }) ); }); @@ -107,7 +97,7 @@ describe('executeExternalTransaction', () => { it('uses custom deduplicationPeriod when provided', async () => { const customDeduplication = { DeduplicationDuration: { - value: { duration: '60s' }, + value: { seconds: 60, nanos: 0 }, }, }; @@ -116,7 +106,8 @@ describe('executeExternalTransaction', () => { userId: 'user-123', preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', - partySignatures: [], + partySignatures: PARTY_SIGNATURES, + hashingSchemeVersion: HASHING_SCHEME_VERSION, deduplicationPeriod: customDeduplication, }); @@ -127,13 +118,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( @@ -149,14 +141,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 }, }, }, }) @@ -187,7 +180,7 @@ describe('executeExternalTransaction', () => { }, ], }, - ]; + ] satisfies NonEmptyPartySignatures; await executeExternalTransaction({ ledgerClient: mockClient, @@ -195,6 +188,7 @@ describe('executeExternalTransaction', () => { preparedTransaction: 'prepared-tx-base64', submissionId: 'submission-123', partySignatures, + hashingSchemeVersion: HASHING_SCHEME_VERSION, }); expect(mockClient.interactiveSubmissionExecute).toHaveBeenCalledWith( @@ -206,76 +200,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 59f6635f..54a60486 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; @@ -383,8 +385,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', @@ -402,13 +403,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/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 71d225d3..d4108105 100644 --- a/test/unit/operations/api-operation-retry.test.ts +++ b/test/unit/operations/api-operation-retry.test.ts @@ -1,19 +1,28 @@ 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 { type BaseClient, 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 { @@ -104,6 +113,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(); @@ -339,39 +426,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); }); @@ -390,4 +488,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 From a06669d40bb3b072cc42163edb459b857a389ca1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 07:10:59 -0400 Subject: [PATCH 09/21] fix: align interactive submissions with live Ledger --- .../execute-and-wait-for-transaction.ts | 5 +- .../execute-and-wait.ts | 5 +- .../v2/interactive-submission/execute.ts | 5 +- .../v2/interactive-submission/prepare.ts | 7 +- .../schemas/api/interactive-submission.ts | 51 ++++--- src/clients/ledger-json-api/schemas/wire.ts | 14 ++ .../ledger-api/interactive-submission.test.ts | 109 +++++---------- ...er-json-api-interactive-submission.test.ts | 132 ++++++++++++++++-- 8 files changed, 220 insertions(+), 108 deletions(-) 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 index f9d80a30..a2e0b81a 100644 --- 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 @@ -14,6 +14,9 @@ export const InteractiveSubmissionExecuteAndWaitForTransaction = createApiOperat responseSchema: InteractiveSubmissionExecuteAndWaitForTransactionResponseSchema, method: 'POST', buildUrl: (_params, apiUrl) => `${apiUrl}/v2/interactive-submission/executeAndWaitForTransaction`, - 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/execute-and-wait.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute-and-wait.ts index a2449784..eb4e091a 100644 --- 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 @@ -14,6 +14,9 @@ export const InteractiveSubmissionExecuteAndWait = createApiOperation< responseSchema: InteractiveSubmissionExecuteAndWaitResponseSchema, method: 'POST', buildUrl: (_params, apiUrl) => `${apiUrl}/v2/interactive-submission/executeAndWait`, - 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/execute.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/execute.ts index 819b67e2..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 @@ -15,6 +15,9 @@ export const InteractiveSubmissionExecute = createApiOperation< 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/prepare.ts b/src/clients/ledger-json-api/operations/v2/interactive-submission/prepare.ts index 24714860..27a40ee5 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 @@ -17,5 +17,10 @@ export const InteractiveSubmissionPrepare = createApiOperation< 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 ?? [], + }), }); 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 716a2ec5..066bda16 100644 --- a/src/clients/ledger-json-api/schemas/api/interactive-submission.ts +++ b/src/clients/ledger-json-api/schemas/api/interactive-submission.ts @@ -7,8 +7,10 @@ import type { import { CantonSha256HashHexSchema, LedgerBase64BytesSchema, + LedgerNameSchema, LedgerNonEmptyBase64BytesSchema, LedgerRfc3339TimestampSchema, + LedgerStringSchema, } from '../wire'; type LedgerSchemas = components['schemas']; @@ -303,10 +305,21 @@ function isJsonValue(value: unknown, ancestors: Set = new Set()) } } -const RequiredJsonValueSchema: z.ZodType = z.custom( - (value) => isJsonValue(value), - { message: 'Expected a JSON-serializable 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( @@ -362,7 +375,9 @@ const DurationSchema = createRequestSchema()({ }); const DeduplicationDurationSchema = createRequestSchema()({ - value: DurationSchema, + value: DurationSchema.refine((duration) => duration.seconds > 0 || (duration.seconds === 0 && duration.nanos >= 0), { + message: 'Deduplication duration must be non-negative', + }), }); const DeduplicationOffsetSchema = createRequestSchema()({ @@ -406,28 +421,28 @@ const MinLedgerTimeSchema = createRequestSchema()({ - templateId: z.string(), + templateId: z.string().min(1), createArguments: RequiredJsonValueSchema, }); const ExerciseCommandContentSchema = createRequestSchema()({ - templateId: z.string(), - contractId: z.string(), - choice: z.string(), + templateId: z.string().min(1), + contractId: z.string().min(1), + choice: LedgerNameSchema, choiceArgument: RequiredJsonValueSchema, }); const CreateAndExerciseCommandContentSchema = createRequestSchema()({ - templateId: z.string(), + templateId: z.string().min(1), createArguments: RequiredJsonValueSchema, - choice: z.string(), + choice: LedgerNameSchema, choiceArgument: RequiredJsonValueSchema, }); const ExerciseByKeyCommandContentSchema = createRequestSchema()({ - templateId: z.string(), + templateId: z.string().min(1), contractKey: RequiredJsonValueSchema, - choice: z.string(), + choice: LedgerNameSchema, choiceArgument: RequiredJsonValueSchema, }); @@ -466,7 +481,7 @@ const DisclosedContractSchema = createRequestSchema()({ - templateId: z.string(), + templateId: z.string().min(1), contractKey: RequiredJsonValueSchema, limit: PositiveInt32Schema.optional(), }); @@ -479,7 +494,7 @@ const CostEstimationHintsSchema = createRequestSchema()({ userId: z.string().optional(), - commandId: z.string(), + commandId: LedgerStringSchema, commands: SingleCommandSchema, minLedgerTime: MinLedgerTimeSchema.optional(), actAs: NonEmptyActAsSchema, @@ -609,7 +624,7 @@ const ExecuteRequestShape = { preparedTransaction: LedgerNonEmptyBase64BytesSchema, partySignatures: PartySignaturesSchema, deduplicationPeriod: DeduplicationPeriodSchema.optional(), - submissionId: z.string().min(1), + submissionId: LedgerStringSchema, userId: z.string().optional(), hashingSchemeVersion: HashingSchemeVersionSchema, minLedgerTime: MinLedgerTimeSchema.optional(), @@ -669,9 +684,9 @@ const CreatedEventSchema = createRequestSchema ('CreatedEvent' in event ? event.CreatedEvent : undefined)) - .find((event) => event?.templateId.includes(APP_INSTALL_REQUEST_TEMPLATE_SUFFIX)); + .find((event) => event?.templateId.includes(WALLET_APP_INSTALL_TEMPLATE_SUFFIX)); expect(createdEvent).toBeDefined(); expect(createdEvent?.createArgument).toEqual(prepared.expectedPayload); } @@ -119,6 +119,7 @@ function expectSubmittedAppInstall( async function prepareSignedAppInstall(options: { readonly client: LedgerJsonApiClient; readonly keypair: Keypair; + readonly dsoParty: string; readonly externalParty: string; readonly publicKeyFingerprint: string; readonly validatorParty: string; @@ -126,12 +127,13 @@ async function prepareSignedAppInstall(options: { readonly synchronizerId: string; readonly packageId: string; readonly label: string; -}): Promise { +}): Promise { const uniqueId = `${options.label}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const expectedPayload = { - provider: options.validatorParty, - user: options.externalParty, - meta: { values: { test: uniqueId } }, + dsoParty: options.dsoParty, + validatorParty: options.validatorParty, + endUserName: uniqueId, + endUserParty: options.externalParty, } as const; const prepared = await options.client.interactiveSubmissionPrepare({ userId: options.userId, @@ -139,17 +141,22 @@ async function prepareSignedAppInstall(options: { commands: [ { CreateCommand: { - templateId: APP_INSTALL_REQUEST_TEMPLATE, + templateId: WALLET_APP_INSTALL_TEMPLATE, createArguments: expectedPayload, }, }, ], - actAs: [options.externalParty], + actAs: [options.externalParty, options.validatorParty], synchronizerId: options.synchronizerId, packageIdSelectionPreference: [options.packageId], estimateTrafficCost: { disabled: true }, hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', }); + 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 { @@ -180,61 +187,6 @@ async function prepareSignedAppInstall(options: { } describe('LedgerJsonApiClient / Interactive submission', () => { - test('prepare validates the live endpoint and normalizes nullable response options', async () => { - const client = getClient(); - const validatorClient = new ValidatorApiClient(new CantonRuntime(buildIntegrationTestClientConfig())); - const validatorInfo = await validatorClient.getValidatorUserInfo(); - const partyId = validatorInfo.party_id; - if (!partyId) { - throw new Error('getValidatorUserInfo returned empty party_id'); - } - client.setPartyId(partyId); - - const partiesResponse = await client.listParties({}); - const receiverParty = partiesResponse.partyDetails - .map((entry: { party: string }) => entry.party) - .find((id: string) => id !== partyId); - if (!receiverParty) { - throw new Error( - 'Integration precondition failed: need at least two distinct parties on the ledger (transfer offer cannot use self as receiver)' - ); - } - - const { contractId: walletInstallCid, synchronizerId } = await resolveWalletAppInstallContext(client, partyId); - const commandId = `interactive-prepare-it-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - const response = await client.interactiveSubmissionPrepare({ - commandId, - ...(synchronizerId !== undefined ? { synchronizerId } : {}), - commands: [ - { - ExerciseCommand: { - templateId: '#splice-wallet:Splice.Wallet.Install:WalletAppInstall', - contractId: walletInstallCid, - choice: 'WalletAppInstall_CreateTransferOffer', - choiceArgument: { - receiver: receiverParty, - amount: { amount: '0.0000001', unit: 'AmuletUnit' }, - description: 'interactive submission prepare integration test', - expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), - trackingId: commandId, - }, - }, - }, - ], - actAs: [partyId], - verboseHashing: false, - estimateTrafficCost: { disabled: true }, - }); - - expect(response).toEqual({ - preparedTransaction: expect.any(String) as string, - preparedTransactionHash: expect.any(String) as string, - hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', - }); - expect(response.preparedTransaction).not.toHaveLength(0); - expect(response.preparedTransactionHash).not.toHaveLength(0); - }, 120_000); - test('externally signed Ed25519 submissions validate every execute variant end to end', async () => { const client = getClient(); const validatorClient = new ValidatorApiClient(new CantonRuntime(buildIntegrationTestClientConfig())); @@ -248,6 +200,10 @@ describe('LedgerJsonApiClient / Interactive submission', () => { 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, @@ -276,24 +232,25 @@ describe('LedgerJsonApiClient / Interactive submission', () => { }); const preferredVersion = await client.interactiveSubmissionGetPreferredPackageVersion({ - packageName: 'quickstart-licensing', - parties: [external.partyId], + packageName: 'splice-wallet', + parties: [external.partyId, validatorParty], synchronizerId, }); const preferredPackages = await client.interactiveSubmissionGetPreferredPackages({ - packageVettingRequirements: [{ packageName: 'quickstart-licensing', parties: [external.partyId] }], + packageVettingRequirements: [{ packageName: 'splice-wallet', parties: [external.partyId, validatorParty] }], synchronizerId, }); const packageReference = preferredPackages.packageReferences[0]; if (!packageReference) { - throw new Error('preferred-packages returned no quickstart-licensing reference'); + throw new Error('preferred-packages returned no splice-wallet reference'); } - expect(packageReference.packageName).toBe('quickstart-licensing'); + expect(packageReference.packageName).toBe('splice-wallet'); expect(preferredVersion.packagePreference?.packageReference).toEqual(packageReference); const asyncPrepared = await prepareSignedAppInstall({ client, keypair, + dsoParty, externalParty: external.partyId, publicKeyFingerprint: external.publicKeyFingerprint, validatorParty, @@ -326,6 +283,7 @@ describe('LedgerJsonApiClient / Interactive submission', () => { const waitPrepared = await prepareSignedAppInstall({ client, keypair, + dsoParty, externalParty: external.partyId, publicKeyFingerprint: external.publicKeyFingerprint, validatorParty, @@ -347,6 +305,7 @@ describe('LedgerJsonApiClient / Interactive submission', () => { const transactionPrepared = await prepareSignedAppInstall({ client, keypair, + dsoParty, externalParty: external.partyId, publicKeyFingerprint: external.publicKeyFingerprint, validatorParty, diff --git a/test/unit/clients/ledger-json-api-interactive-submission.test.ts b/test/unit/clients/ledger-json-api-interactive-submission.test.ts index ce060447..fa2effea 100644 --- a/test/unit/clients/ledger-json-api-interactive-submission.test.ts +++ b/test/unit/clients/ledger-json-api-interactive-submission.test.ts @@ -85,7 +85,9 @@ function createWireTransactionResponse(transactionOffset = 124, eventOffset = 12 contractId: 'contract-1', templateId: 'pkg:Module:Template', contractKey: null, + contractKeyHash: '', createArgument: { owner: 'party::fingerprint' }, + createdEventBlob: '', interfaceViews: [ { interfaceId: 'pkg:Module:Interface', @@ -198,7 +200,7 @@ describe('LedgerJsonApiClient interactive submission execution', () => { expect(post).toHaveBeenCalledWith( 'https://ledger.example.test/v2/interactive-submission/prepare', - request, + { ...request, synchronizerId: '' }, { contentType: 'application/json', includeBearerToken: true, @@ -209,7 +211,7 @@ describe('LedgerJsonApiClient interactive submission execution', () => { it('accepts wire-null optional prepare fields and normalizes them to omitted properties', async () => { const client = createClient(); - jest.spyOn(client, 'makePostRequest').mockResolvedValue({ + const post = jest.spyOn(client, 'makePostRequest').mockResolvedValue({ preparedTransaction: PREPARED_TRANSACTION_BASE64, preparedTransactionHash: PREPARED_HASH_BASE64, hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', @@ -224,6 +226,30 @@ describe('LedgerJsonApiClient interactive submission execution', () => { preparedTransactionHash: PREPARED_HASH_BASE64, hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', }); + expect(post.mock.calls[0]?.[1]).toEqual({ + ...createPrepareRequest(), + synchronizerId: '', + packageIdSelectionPreference: [], + }); + }); + + 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 () => { @@ -231,14 +257,19 @@ describe('LedgerJsonApiClient interactive submission execution', () => { 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, { - contentType: 'application/json', - includeBearerToken: true, - }); + 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 () => { @@ -246,13 +277,18 @@ describe('LedgerJsonApiClient interactive submission execution', () => { 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, { - contentType: 'application/json', - includeBearerToken: true, - }); + 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 () => { @@ -301,12 +337,13 @@ describe('LedgerJsonApiClient interactive submission execution', () => { 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, + { ...request, deduplicationPeriod: { Empty: {} } }, { contentType: 'application/json', includeBearerToken: true, @@ -324,6 +361,8 @@ describe('LedgerJsonApiClient interactive submission execution', () => { 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', @@ -638,6 +677,63 @@ describe('LedgerJsonApiClient interactive submission execution', () => { 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', { @@ -815,6 +911,20 @@ describe('LedgerJsonApiClient interactive submission execution', () => { deduplicationPeriod: { DeduplicationOffset: { value: Number.MAX_SAFE_INTEGER + 1 } }, } as unknown as InteractiveSubmissionExecuteAndWaitRequest, ], + [ + 'negative deduplication duration seconds', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationDuration: { value: { seconds: -1, nanos: 0 } } }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], + [ + 'negative deduplication duration nanos', + { + ...createExecuteAndWaitRequest(), + deduplicationPeriod: { DeduplicationDuration: { value: { seconds: 0, nanos: -1 } } }, + } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + ], [ 'malformed prepared transaction Base64', { From 2549f60cd2cc74dcb44ed4ec9cc22ab530a579d1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 07:30:08 -0400 Subject: [PATCH 10/21] test: surface interactive submission errors --- .../ledger-api/interactive-submission.test.ts | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/test/integration/localnet/ledger-api/interactive-submission.test.ts b/test/integration/localnet/ledger-api/interactive-submission.test.ts index 27720100..3c79b742 100644 --- a/test/integration/localnet/ledger-api/interactive-submission.test.ts +++ b/test/integration/localnet/ledger-api/interactive-submission.test.ts @@ -2,6 +2,7 @@ import { Keypair } from '@stellar/stellar-base'; import { + ApiError, CantonRuntime, type LedgerJsonApiClient, ValidatorApiClient, @@ -135,23 +136,31 @@ async function prepareSignedAppInstall(options: { endUserName: uniqueId, endUserParty: options.externalParty, } as const; - const prepared = await options.client.interactiveSubmissionPrepare({ - userId: options.userId, - commandId: uniqueId, - commands: [ - { - CreateCommand: { - templateId: WALLET_APP_INSTALL_TEMPLATE, - createArguments: expectedPayload, + let prepared: Awaited>; + try { + prepared = await options.client.interactiveSubmissionPrepare({ + userId: options.userId, + commandId: uniqueId, + commands: [ + { + CreateCommand: { + templateId: WALLET_APP_INSTALL_TEMPLATE, + createArguments: expectedPayload, + }, }, - }, - ], - actAs: [options.externalParty, options.validatorParty], - synchronizerId: options.synchronizerId, - packageIdSelectionPreference: [options.packageId], - estimateTrafficCost: { disabled: true }, - hashingSchemeVersion: 'HASHING_SCHEME_VERSION_V2', - }); + ], + actAs: [options.externalParty, options.validatorParty], + 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, From 65aa08386c21e3e6eacf001aaaccdaaeec615021 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 07:39:17 -0400 Subject: [PATCH 11/21] test: align interactive E2E with pinned Splice --- .github/workflows/test-cn-quickstart.yml | 4 +- .../ledger-api/interactive-submission.test.ts | 46 +++++++++---------- 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/.github/workflows/test-cn-quickstart.yml b/.github/workflows/test-cn-quickstart.yml index cb8fc80c..c91e595c 100644 --- a/.github/workflows/test-cn-quickstart.yml +++ b/.github/workflows/test-cn-quickstart.yml @@ -83,7 +83,9 @@ jobs: - name: Start CN-Quickstart run: | - npm run localnet:start + splice_version="$(git -C libs/splice describe --tags --exact-match)" + 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/test/integration/localnet/ledger-api/interactive-submission.test.ts b/test/integration/localnet/ledger-api/interactive-submission.test.ts index 3c79b742..b02bf338 100644 --- a/test/integration/localnet/ledger-api/interactive-submission.test.ts +++ b/test/integration/localnet/ledger-api/interactive-submission.test.ts @@ -18,16 +18,17 @@ import { buildIntegrationTestClientConfig, retry } from '../../../utils/testConf import { getClient } from './setup'; const WALLET_APP_INSTALL_TEMPLATE = '#splice-wallet:Splice.Wallet.Install:WalletAppInstall'; -const WALLET_APP_INSTALL_TEMPLATE_SUFFIX = '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 PreparedSignedWalletInstall { +interface PreparedSignedTransferPreapprovalProposal { readonly request: InteractiveSubmissionExecuteRequest; readonly preparedTransactionHashHex: string; readonly expectedPayload: { - readonly dsoParty: string; - readonly validatorParty: string; - readonly endUserName: string; - readonly endUserParty: string; + readonly receiver: string; + readonly provider: string; + readonly expectedDso: string; }; } @@ -105,19 +106,19 @@ function lookupTransactionFormatFor(partyId: string): LookupTransactionFormat { }; } -function expectSubmittedAppInstall( +function expectSubmittedTransferPreapprovalProposal( transaction: InteractiveSubmissionTransaction | LookupTransaction, - prepared: PreparedSignedWalletInstall + 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(WALLET_APP_INSTALL_TEMPLATE_SUFFIX)); + .find((event) => event?.templateId.includes(TRANSFER_PREAPPROVAL_PROPOSAL_TEMPLATE_SUFFIX)); expect(createdEvent).toBeDefined(); expect(createdEvent?.createArgument).toEqual(prepared.expectedPayload); } -async function prepareSignedAppInstall(options: { +async function prepareSignedTransferPreapprovalProposal(options: { readonly client: LedgerJsonApiClient; readonly keypair: Keypair; readonly dsoParty: string; @@ -128,13 +129,12 @@ async function prepareSignedAppInstall(options: { readonly synchronizerId: string; readonly packageId: string; readonly label: string; -}): Promise { +}): Promise { const uniqueId = `${options.label}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const expectedPayload = { - dsoParty: options.dsoParty, - validatorParty: options.validatorParty, - endUserName: uniqueId, - endUserParty: options.externalParty, + receiver: options.externalParty, + provider: options.validatorParty, + expectedDso: options.dsoParty, } as const; let prepared: Awaited>; try { @@ -144,12 +144,12 @@ async function prepareSignedAppInstall(options: { commands: [ { CreateCommand: { - templateId: WALLET_APP_INSTALL_TEMPLATE, + templateId: TRANSFER_PREAPPROVAL_PROPOSAL_TEMPLATE, createArguments: expectedPayload, }, }, ], - actAs: [options.externalParty, options.validatorParty], + actAs: [options.externalParty], synchronizerId: options.synchronizerId, packageIdSelectionPreference: [options.packageId], estimateTrafficCost: { disabled: true }, @@ -256,7 +256,7 @@ describe('LedgerJsonApiClient / Interactive submission', () => { expect(packageReference.packageName).toBe('splice-wallet'); expect(preferredVersion.packagePreference?.packageReference).toEqual(packageReference); - const asyncPrepared = await prepareSignedAppInstall({ + const asyncPrepared = await prepareSignedTransferPreapprovalProposal({ client, keypair, dsoParty, @@ -287,9 +287,9 @@ describe('LedgerJsonApiClient / Interactive submission', () => { transactionFormat: lookupTransactionFormat, }); expect(asyncTransaction.transaction.updateId).toBe(completion.updateId); - expectSubmittedAppInstall(asyncTransaction.transaction, asyncPrepared); + expectSubmittedTransferPreapprovalProposal(asyncTransaction.transaction, asyncPrepared); - const waitPrepared = await prepareSignedAppInstall({ + const waitPrepared = await prepareSignedTransferPreapprovalProposal({ client, keypair, dsoParty, @@ -309,9 +309,9 @@ describe('LedgerJsonApiClient / Interactive submission', () => { transactionFormat: lookupTransactionFormat, }); expect(waitedTransaction.transaction.updateId).toBe(waitResult.updateId); - expectSubmittedAppInstall(waitedTransaction.transaction, waitPrepared); + expectSubmittedTransferPreapprovalProposal(waitedTransaction.transaction, waitPrepared); - const transactionPrepared = await prepareSignedAppInstall({ + const transactionPrepared = await prepareSignedTransferPreapprovalProposal({ client, keypair, dsoParty, @@ -328,7 +328,7 @@ describe('LedgerJsonApiClient / Interactive submission', () => { transactionFormat: interactiveTransactionFormatFor(external.partyId), }); expect(transactionResult.transaction.updateId).toMatch(/\S+/); - expectSubmittedAppInstall(transactionResult.transaction, transactionPrepared); + expectSubmittedTransferPreapprovalProposal(transactionResult.transaction, transactionPrepared); expect( new Set([ From 21764a340e29e791be65de33c58dad55081b81e4 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 07:40:45 -0400 Subject: [PATCH 12/21] ci: read pinned Splice version from source --- .github/workflows/test-cn-quickstart.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-cn-quickstart.yml b/.github/workflows/test-cn-quickstart.yml index c91e595c..820d6e4d 100644 --- a/.github/workflows/test-cn-quickstart.yml +++ b/.github/workflows/test-cn-quickstart.yml @@ -83,7 +83,11 @@ jobs: - name: Start CN-Quickstart run: | - splice_version="$(git -C libs/splice describe --tags --exact-match)" + 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" From 8f7e8e6a04072dd8ed081b27b7093f4be91d63c6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 07:48:28 -0400 Subject: [PATCH 13/21] test: align Quickstart fixture with pinned Splice --- bin/canton-localnet | 2 +- libs/cn-quickstart | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/canton-localnet b/bin/canton-localnet index 1b7f08bb..37ff70cc 100755 --- a/bin/canton-localnet +++ b/bin/canton-localnet @@ -19,7 +19,7 @@ resolve_script_dir() { PACKAGE_ROOT="$(cd "$(resolve_script_dir "${BASH_SOURCE[0]}")/.." && pwd)" LOCALNET_SCRIPT="${PACKAGE_ROOT}/scripts/localnet-cloud.sh" DEFAULT_QUICKSTART_REPO="https://github.com/digital-asset/cn-quickstart.git" -DEFAULT_QUICKSTART_REF="46201cd27ee16c99f78ab97b7e6513c56f28e004" +DEFAULT_QUICKSTART_REF="2f4edfc17621a7dfb6d44357050c22f4b3914c89" log() { printf '[canton-localnet] %s\n' "$*" diff --git a/libs/cn-quickstart b/libs/cn-quickstart index 46201cd2..2f4edfc1 160000 --- a/libs/cn-quickstart +++ b/libs/cn-quickstart @@ -1 +1 @@ -Subproject commit 46201cd27ee16c99f78ab97b7e6513c56f28e004 +Subproject commit 2f4edfc17621a7dfb6d44357050c22f4b3914c89 From bed904dff8a6f4ed6c6a3d52517cf14899d12bcb Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 07:58:13 -0400 Subject: [PATCH 14/21] fix: materialize cost estimation wire defaults --- .../v2/interactive-submission/prepare.ts | 8 ++++++ ...er-json-api-interactive-submission.test.ts | 25 +++++++++++++++++++ 2 files changed, 33 insertions(+) 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 27a40ee5..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 @@ -22,5 +22,13 @@ export const InteractiveSubmissionPrepare = createApiOperation< ...params, synchronizerId: params.synchronizerId ?? '', packageIdSelectionPreference: params.packageIdSelectionPreference ?? [], + ...(params.estimateTrafficCost === undefined + ? {} + : { + estimateTrafficCost: { + ...params.estimateTrafficCost, + expectedSignatures: params.estimateTrafficCost.expectedSignatures ?? [], + }, + }), }), }); diff --git a/test/unit/clients/ledger-json-api-interactive-submission.test.ts b/test/unit/clients/ledger-json-api-interactive-submission.test.ts index fa2effea..c6d00de8 100644 --- a/test/unit/clients/ledger-json-api-interactive-submission.test.ts +++ b/test/unit/clients/ledger-json-api-interactive-submission.test.ts @@ -233,6 +233,31 @@ describe('LedgerJsonApiClient interactive submission execution', () => { }); }); + 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' } }; From de5c3ae7cbd618fde631ae2c8067bdf5e57b94e6 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:10:58 -0400 Subject: [PATCH 15/21] fix: preserve interactive hashing scheme types --- src/utils/external-signing/external-signing-client.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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, From a743fd8201a00348c68d8e7fde6033530abe09b4 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:17:10 -0400 Subject: [PATCH 16/21] fix: make mutating operation semantics unrepresentable --- src/core/operations/ApiOperationFactory.ts | 11 ++++++++--- test/typecheck/operation-retry.typecheck.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/core/operations/ApiOperationFactory.ts b/src/core/operations/ApiOperationFactory.ts index 8f4181ad..e42db3d4 100644 --- a/src/core/operations/ApiOperationFactory.ts +++ b/src/core/operations/ApiOperationFactory.ts @@ -89,13 +89,18 @@ export type ApiOperationConfig = ApiOperationConfigBase; void invalidGetOperationConfig; +const invalidDeleteOperationConfig = { + paramsSchema: z.void(), + method: 'DELETE', + buildUrl: (): string => 'https://api.example/mutation', + requestSemantics: 'read', + // @ts-expect-error Factory-created DELETE operations cannot use read semantics. +} satisfies ApiOperationConfig; +void invalidDeleteOperationConfig; + +const invalidPatchOperationConfig = { + paramsSchema: z.void(), + method: 'PATCH', + buildUrl: (): string => 'https://api.example/mutation', + requestSemantics: 'read', + // @ts-expect-error Factory-created PATCH operations cannot use read semantics. +} satisfies ApiOperationConfig; +void invalidPatchOperationConfig; + // Custom ApiOperation subclasses retain their declared params while forwarding the same typed options. void client.getParties({}, { signal: new AbortController().signal }); void client.listParties({}, { retry: { kind: 'exact-body', maxAttempts: 2 } }); From 6f3af6e8405785e28e36b49e4dedb9508e1b159f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:38:25 -0400 Subject: [PATCH 17/21] test: align external signing fixtures with strict APIs --- .../external-signing-client.test.ts | 79 ++++++++++--------- 1 file changed, 40 insertions(+), 39 deletions(-) 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', From be32b7ec6af1df1be304634bd730e53658d31a95 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:55:25 -0400 Subject: [PATCH 18/21] fix: match interactive transaction wire formats --- .../schemas/api/interactive-submission.ts | 39 ++++++++++++--- ...ledger-interactive-submission.typecheck.ts | 23 +++++++++ ...er-json-api-interactive-submission.test.ts | 47 ++++++++++++++----- 3 files changed, 89 insertions(+), 20 deletions(-) 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 066bda16..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,11 +1,10 @@ import { z } from 'zod'; -import { createRequestSchema } from '../../../../core'; +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 { - CantonSha256HashHexSchema, LedgerBase64BytesSchema, LedgerNameSchema, LedgerNonEmptyBase64BytesSchema, @@ -234,8 +233,25 @@ type InteractiveSubmissionEventVariants = | { ExercisedEvent: InteractiveSubmissionExercisedEvent }; export type InteractiveSubmissionEvent = ExactOneOf; -export type InteractiveSubmissionTransaction = Omit & { +/** 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< @@ -727,11 +743,20 @@ const EventSchema: z.ZodType = z.union([ }), ]); -const TraceContextSchema = createRequestSchema()({ - traceparent: z.string().optional(), - tracestate: z.string().optional(), +const TraceContextSchema = createRequestSchema()({ + traceparent: nullableOptionalResponseField(z.string()), + tracestate: nullableOptionalResponseField(z.string()), }); +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 + ); + const TransactionSchema = createRequestSchema()({ updateId: z.string(), commandId: z.string().optional(), @@ -742,7 +767,7 @@ const TransactionSchema = createRequestSchema( synchronizerId: z.string().min(1), traceContext: nullableOptionalResponseField(TraceContextSchema), recordTime: LedgerRfc3339TimestampSchema, - externalTransactionHash: nullableOptionalResponseField(CantonSha256HashHexSchema), + externalTransactionHash: nullableOptionalResponseField(ExternalTransactionHashHexSchema), paidTrafficCost: nullableOptionalResponseField(NonNegativeInt64Schema), }); diff --git a/test/typecheck/ledger-interactive-submission.typecheck.ts b/test/typecheck/ledger-interactive-submission.typecheck.ts index c1b6b932..ea3635f0 100644 --- a/test/typecheck/ledger-interactive-submission.typecheck.ts +++ b/test/typecheck/ledger-interactive-submission.typecheck.ts @@ -2,9 +2,11 @@ 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, @@ -223,6 +225,21 @@ const unspecifiedSigningAlgorithm: InteractiveSubmissionSignature = { 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]; @@ -239,6 +256,12 @@ const invalidExerciseChoiceArgument: ExercisedTransactionEvent['choiceArgument'] // @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']; diff --git a/test/unit/clients/ledger-json-api-interactive-submission.test.ts b/test/unit/clients/ledger-json-api-interactive-submission.test.ts index c6d00de8..19c12bfe 100644 --- a/test/unit/clients/ledger-json-api-interactive-submission.test.ts +++ b/test/unit/clients/ledger-json-api-interactive-submission.test.ts @@ -25,7 +25,8 @@ const PREPARED_TRANSACTION_BASE64 = Buffer.from('prepared-transaction').toString 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 = `1220${'ab'.repeat(32)}`; +const EXTERNAL_TRANSACTION_HASH = 'ab'.repeat(32); +const TRACEPARENT = '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'; function createClient(): LedgerJsonApiClient { return new LedgerJsonApiClient(new CantonRuntime(config)); @@ -403,6 +404,24 @@ describe('LedgerJsonApiClient interactive submission execution', () => { 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(); @@ -610,7 +629,9 @@ describe('LedgerJsonApiClient interactive submission execution', () => { }); it.each([ - ['a malformed hash', 'not-a-canton-hash'], + ['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(); @@ -920,63 +941,63 @@ describe('LedgerJsonApiClient interactive submission execution', () => { { ...createExecuteAndWaitRequest(), deduplicationPeriod: { DeduplicationOffset: { value: -1 } }, - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'fractional deduplication offsets', { ...createExecuteAndWaitRequest(), deduplicationPeriod: { DeduplicationOffset: { value: 1.5 } }, - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'unsafe deduplication offsets', { ...createExecuteAndWaitRequest(), deduplicationPeriod: { DeduplicationOffset: { value: Number.MAX_SAFE_INTEGER + 1 } }, - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'negative deduplication duration seconds', { ...createExecuteAndWaitRequest(), deduplicationPeriod: { DeduplicationDuration: { value: { seconds: -1, nanos: 0 } } }, - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'negative deduplication duration nanos', { ...createExecuteAndWaitRequest(), deduplicationPeriod: { DeduplicationDuration: { value: { seconds: 0, nanos: -1 } } }, - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'malformed prepared transaction Base64', { ...createExecuteAndWaitRequest(), preparedTransaction: 'not base64!', - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'base64url prepared transaction bytes', { ...createExecuteAndWaitRequest(), preparedTransaction: '-_8=', - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'unpadded prepared transaction Base64', { ...createExecuteAndWaitRequest(), preparedTransaction: 'YWJjZA', - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'noncanonical prepared transaction Base64 padding', { ...createExecuteAndWaitRequest(), preparedTransaction: 'YQ====', - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'malformed signature Base64', @@ -1002,14 +1023,14 @@ describe('LedgerJsonApiClient interactive submission execution', () => { { ...createExecuteAndWaitRequest(), minLedgerTime: { time: { MinLedgerTimeAbs: { value: '2026-07-09 12:00:00' } } }, - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'an empty submission ID', { ...createExecuteAndWaitRequest(), submissionId: '', - } as unknown as InteractiveSubmissionExecuteAndWaitRequest, + }, ], [ 'empty party signature collections', From 587985d4447b9d04f88ff81d136d83b2a71e1b7f Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 08:59:20 -0400 Subject: [PATCH 19/21] fix: enforce mutation-only operation semantics --- src/core/operations/ApiOperationFactory.ts | 3 ++ .../operations/api-operation-retry.test.ts | 38 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/core/operations/ApiOperationFactory.ts b/src/core/operations/ApiOperationFactory.ts index e42db3d4..38677f1f 100644 --- a/src/core/operations/ApiOperationFactory.ts +++ b/src/core/operations/ApiOperationFactory.ts @@ -129,6 +129,9 @@ export function createApiOperation( const apiUrl = this.getApiUrl(); const url = config.buildUrl(currentParams, apiUrl, this.client); const requestSemantics = config.requestSemantics ?? (config.method === 'GET' ? 'read' : 'mutation'); + if ((config.method === 'DELETE' || config.method === 'PATCH') && requestSemantics !== 'mutation') { + throw new ConfigurationError(`Factory-created ${config.method} operations must use mutation semantics`); + } const requestConfig: RequestConfig = config.requestConfig ?? { contentType: 'application/json', includeBearerToken: true, diff --git a/test/unit/operations/api-operation-retry.test.ts b/test/unit/operations/api-operation-retry.test.ts index 71d225d3..9f33e715 100644 --- a/test/unit/operations/api-operation-retry.test.ts +++ b/test/unit/operations/api-operation-retry.test.ts @@ -6,7 +6,9 @@ import { InteractiveSubmissionPrepare } from '../../../src/clients/ledger-json-a 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 { + type ApiOperationConfig, type BaseClient, + ConfigurationError, HttpClient, type OperationRetryContext, UnknownMutationOutcomeError, @@ -331,6 +333,42 @@ describe('factory-created operation retry plumbing', () => { }); }); +describe('factory-created operation runtime semantics', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each(['DELETE', 'PATCH'] as const)( + 'rejects an untyped-JavaScript read semantic on mutation-only %s before dispatch', + async (method) => { + const makeDeleteRequest = jest.fn(); + const makePatchRequest = jest.fn(); + const buildRequestData = jest.fn(() => ({ value: 'request-body' })); + const client = { + getApiUrl: (): string => 'https://api.example', + makeDeleteRequest, + makePatchRequest, + } as unknown as BaseClient; + const unsafeConfig = { + paramsSchema: z.void(), + method, + requestSemantics: 'read', + buildUrl: (_params: void, apiUrl: string): string => `${apiUrl}/mutation`, + buildRequestData, + } as unknown as ApiOperationConfig; + const UnsafeOperation = createApiOperation(unsafeConfig); + + const execution = new UnsafeOperation(client).execute(); + + await expect(execution).rejects.toBeInstanceOf(ConfigurationError); + await expect(execution).rejects.toThrow(`Factory-created ${method} operations must use mutation semantics`); + expect(buildRequestData).not.toHaveBeenCalled(); + expect(makeDeleteRequest).not.toHaveBeenCalled(); + expect(makePatchRequest).not.toHaveBeenCalled(); + } + ); +}); + describe('semantic POST operation coverage matrix', () => { beforeEach(() => { jest.clearAllMocks(); From edf1c114720c8e7535c23d2442e6b5110b9f8b11 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 09:17:41 -0400 Subject: [PATCH 20/21] fix(http): require request body snapshots --- src/core/http/HttpClient.ts | 11 +++--- test/unit/core/http-client-retry.test.ts | 48 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/core/http/HttpClient.ts b/src/core/http/HttpClient.ts index d0bcd9bb..8a8998c8 100644 --- a/src/core/http/HttpClient.ts +++ b/src/core/http/HttpClient.ts @@ -136,7 +136,7 @@ export class HttpClient { this.validateMaxAttempts(maxAttempts); const requiresImmutableBody = strategy.kind !== 'none'; - let currentBody = this.cloneBody(initialBody, requiresImmutableBody); + let currentBody = this.cloneBody(initialBody); let lastFailure: AttemptFailure | undefined; const failedAttempts: RequestAttemptSummary[] = []; @@ -208,7 +208,7 @@ export class HttpClient { return builtHeaders; }, signal); throwIfAborted(signal); - const requestBody = this.cloneBody(currentBody, requiresImmutableBody); + const requestBody = this.cloneBody(currentBody); dispatched = true; const response = await this.dispatchRequest(method, attemptUrl, requestBody, headers, signal); @@ -396,16 +396,15 @@ export class HttpClient { } private createReadonlyBody(body: Body): DeepReadonly { - const cloned = this.cloneBody(body, true); + const cloned = this.cloneBody(body); return deepFreezeRequestValue(cloned) as DeepReadonly; } - private cloneBody(body: Body, required: boolean): Body { + private cloneBody(body: Body): Body { try { return cloneRequestValue(body); } catch (error) { - if (!required) return body; - throw new ConfigurationError('Retryable HTTP request bodies must be structured-cloneable', { + throw new ConfigurationError('HTTP request bodies must be structured-cloneable', { cause: error instanceof Error ? error.message : 'unknown clone failure', }); } diff --git a/test/unit/core/http-client-retry.test.ts b/test/unit/core/http-client-retry.test.ts index 4f1d2865..4863601a 100644 --- a/test/unit/core/http-client-retry.test.ts +++ b/test/unit/core/http-client-retry.test.ts @@ -91,6 +91,54 @@ describe('HttpClient mutation retry safety', () => { }); }); + it('rejects an unsupported mutation body before asynchronous pre-dispatch work', async () => { + const bearerTokenProvider = jest.fn(async (): Promise => 'unused-token'); + const { client, axiosInstance } = createClient(undefined, bearerTokenProvider); + + await expect( + client.makePostRequest( + 'https://ledger.example/v2/commands', + new Map([['commandId', 'command-1']]), + { includeBearerToken: true } + ) + ).rejects.toEqual( + expect.objectContaining({ + name: ConfigurationError.name, + message: 'HTTP request bodies must be structured-cloneable', + }) + ); + + expect(bearerTokenProvider).not.toHaveBeenCalled(); + expect(axiosInstance.post).not.toHaveBeenCalled(); + }); + + it('snapshots a single-attempt mutation body before asynchronous authentication', async () => { + let releaseToken: ((token: string) => void) | undefined; + const token = new Promise((resolve) => { + releaseToken = resolve; + }); + const { client, axiosInstance } = createClient(undefined, async (): Promise => token); + const body = { + commandId: 'command-original', + nested: { signature: 'signature-original' }, + }; + axiosInstance.post.mockResolvedValueOnce({ data: { ok: true } }); + + const request = client.makePostRequest('https://ledger.example/v2/commands', body, { + includeBearerToken: true, + }); + await Promise.resolve(); + body.commandId = 'command-mutated'; + body.nested.signature = 'signature-mutated'; + releaseToken?.('token'); + + await expect(request).resolves.toEqual({ ok: true }); + expect(axiosInstance.post.mock.calls[0]?.[1]).toEqual({ + commandId: 'command-original', + nested: { signature: 'signature-original' }, + }); + }); + it('does not retry an ambiguous mutation by default even with an explicit exact-body strategy', async () => { const { client, axiosInstance } = createClient(); axiosInstance.post.mockRejectedValueOnce(createAxiosError(503)).mockResolvedValueOnce({ data: { ok: true } }); From e48c1cadefd82a42c7e8ca5250ef164bbe4a1cb1 Mon Sep 17 00:00:00 2001 From: HardlyDifficult Date: Fri, 10 Jul 2026 09:59:04 -0400 Subject: [PATCH 21/21] fix(traffic): make actor fallback total --- src/utils/traffic/estimate-traffic-cost.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/utils/traffic/estimate-traffic-cost.ts b/src/utils/traffic/estimate-traffic-cost.ts index 132de19b..6bda1426 100644 --- a/src/utils/traffic/estimate-traffic-cost.ts +++ b/src/utils/traffic/estimate-traffic-cost.ts @@ -89,8 +89,8 @@ export async function estimateTrafficCost( } const resolvedPartyId = ledgerClient.getPartyId(); - const resolvedActAsValues = actAs ? [...actAs] : resolvedPartyId ? [resolvedPartyId] : undefined; - const firstActAs = resolvedActAsValues?.[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'); }