diff --git a/.changeset/fair-geese-beam.md b/.changeset/fair-geese-beam.md new file mode 100644 index 0000000000..70bc2f1e94 --- /dev/null +++ b/.changeset/fair-geese-beam.md @@ -0,0 +1,5 @@ +--- +'@chainlink/canton-functions-adapter': major +--- + +This EA enables us to read data from Canton participant nodes via the Ledger API diff --git a/.pnp.cjs b/.pnp.cjs index e994dab804..4f373b024b 100644 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -314,6 +314,10 @@ const RAW_RUNTIME_STATE = "name": "@chainlink/cache.gold-adapter",\ "reference": "workspace:packages/sources/cache.gold"\ },\ + {\ + "name": "@chainlink/canton-functions-adapter",\ + "reference": "workspace:packages/sources/canton-functions"\ + },\ {\ "name": "@chainlink/ceffu-adapter",\ "reference": "workspace:packages/sources/ceffu"\ @@ -1005,6 +1009,7 @@ const RAW_RUNTIME_STATE = ["@chainlink/bsol-price-adapter", ["workspace:packages/composites/bsol-price"]],\ ["@chainlink/btc.com-adapter", ["workspace:packages/sources/btc.com"]],\ ["@chainlink/cache.gold-adapter", ["workspace:packages/sources/cache.gold"]],\ + ["@chainlink/canton-functions-adapter", ["workspace:packages/sources/canton-functions"]],\ ["@chainlink/ceffu-adapter", ["workspace:packages/sources/ceffu"]],\ ["@chainlink/celsius-address-list-adapter", ["workspace:packages/sources/celsius-address-list"]],\ ["@chainlink/cfbenchmarks-adapter", ["workspace:packages/sources/cfbenchmarks"]],\ @@ -5785,6 +5790,23 @@ const RAW_RUNTIME_STATE = "linkType": "SOFT"\ }]\ ]],\ + ["@chainlink/canton-functions-adapter", [\ + ["workspace:packages/sources/canton-functions", {\ + "packageLocation": "./packages/sources/canton-functions/",\ + "packageDependencies": [\ + ["@chainlink/canton-functions-adapter", "workspace:packages/sources/canton-functions"],\ + ["@chainlink/external-adapter-framework", "npm:2.7.1"],\ + ["@sinonjs/fake-timers", "npm:9.1.2"],\ + ["@types/jest", "npm:29.5.14"],\ + ["@types/node", "npm:22.14.1"],\ + ["@types/sinonjs__fake-timers", "npm:8.1.5"],\ + ["nock", "npm:13.5.6"],\ + ["tslib", "npm:2.4.1"],\ + ["typescript", "patch:typescript@npm%3A5.8.3#optional!builtin::version=5.8.3&hash=5786d5"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ ["@chainlink/ceffu-adapter", [\ ["workspace:packages/sources/ceffu", {\ "packageLocation": "./packages/sources/ceffu/",\ diff --git a/packages/sources/canton-functions/CHANGELOG.md b/packages/sources/canton-functions/CHANGELOG.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/sources/canton-functions/README.md b/packages/sources/canton-functions/README.md new file mode 100644 index 0000000000..36e508b35d --- /dev/null +++ b/packages/sources/canton-functions/README.md @@ -0,0 +1,3 @@ +# Chainlink External Adapter for canton-functions + +This README will be generated automatically when code is merged to `main`. If you would like to generate a preview of the README, please run `yarn generate:readme canton-functions`. diff --git a/packages/sources/canton-functions/package.json b/packages/sources/canton-functions/package.json new file mode 100644 index 0000000000..95048c12f9 --- /dev/null +++ b/packages/sources/canton-functions/package.json @@ -0,0 +1,42 @@ +{ + "name": "@chainlink/canton-functions-adapter", + "version": "1.0.0", + "description": "Chainlink canton-functions adapter.", + "keywords": [ + "Chainlink", + "LINK", + "blockchain", + "oracle", + "canton-functions" + ], + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "repository": { + "url": "https://github.com/smartcontractkit/external-adapters-js", + "type": "git" + }, + "license": "MIT", + "scripts": { + "clean": "rm -rf dist && rm -f tsconfig.tsbuildinfo", + "prepack": "yarn build", + "build": "tsc -b", + "server": "node -e 'require(\"./index.js\").server()'", + "server:dist": "node -e 'require(\"./dist/index.js\").server()'", + "start": "yarn server:dist" + }, + "devDependencies": { + "@sinonjs/fake-timers": "9.1.2", + "@types/jest": "^29.5.14", + "@types/node": "22.14.1", + "@types/sinonjs__fake-timers": "8.1.5", + "nock": "13.5.6", + "typescript": "5.8.3" + }, + "dependencies": { + "@chainlink/external-adapter-framework": "2.7.1", + "tslib": "2.4.1" + } +} diff --git a/packages/sources/canton-functions/src/config/index.ts b/packages/sources/canton-functions/src/config/index.ts new file mode 100644 index 0000000000..b7f39ea273 --- /dev/null +++ b/packages/sources/canton-functions/src/config/index.ts @@ -0,0 +1,16 @@ +import { AdapterConfig } from '@chainlink/external-adapter-framework/config' + +export const config = new AdapterConfig({ + AUTH_TOKEN: { + description: 'JWT token for Canton JSON API authentication', + type: 'string', + required: true, + sensitive: true, + }, + BACKGROUND_EXECUTE_MS: { + description: + 'The amount of time the background execute should sleep before performing the next request', + type: 'number', + default: 1_000, + }, +}) diff --git a/packages/sources/canton-functions/src/endpoint/canton-data.ts b/packages/sources/canton-functions/src/endpoint/canton-data.ts new file mode 100644 index 0000000000..e3eee08d7b --- /dev/null +++ b/packages/sources/canton-functions/src/endpoint/canton-data.ts @@ -0,0 +1,67 @@ +import { AdapterEndpoint } from '@chainlink/external-adapter-framework/adapter' +import { InputParameters } from '@chainlink/external-adapter-framework/validation' +import { config } from '../config' +import { cantonDataTransport } from '../transport/canton-data' + +export const inputParameters = new InputParameters( + { + url: { + description: 'The Canton JSON API URL', + type: 'string', + required: true, + }, + templateId: { + description: 'The template ID to query contracts for (format: packageId:Module:Template)', + type: 'string', + required: true, + }, + contractId: { + description: 'The contract ID to exercise choice on', + type: 'string', + required: false, + }, + choice: { + description: 'The non-consuming choice to exercise on the contract', + type: 'string', + required: true, + }, + argument: { + description: 'The argument for the choice (JSON string)', + type: 'string', + required: false, + }, + contractFilter: { + description: 'Filter to query contracts when contractId is not provided (JSON string)', + type: 'string', + required: false, + }, + }, + [ + { + url: 'http://localhost:7575', + templateId: 'example-package-id:Main:Asset', + contractId: '00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0', + choice: 'GetValue', + }, + ], +) + +export type BaseEndpointTypes = { + Parameters: typeof inputParameters.definition + Response: { + Data: { + result: string + exerciseResult: any + contract?: any + } + Result: string + } + Settings: typeof config.settings +} + +export const endpoint = new AdapterEndpoint({ + name: 'canton-data', + aliases: [], + transport: cantonDataTransport, + inputParameters, +}) diff --git a/packages/sources/canton-functions/src/endpoint/index.ts b/packages/sources/canton-functions/src/endpoint/index.ts new file mode 100644 index 0000000000..f5e858bfe7 --- /dev/null +++ b/packages/sources/canton-functions/src/endpoint/index.ts @@ -0,0 +1 @@ +export { endpoint as cantonData } from './canton-data' diff --git a/packages/sources/canton-functions/src/index.ts b/packages/sources/canton-functions/src/index.ts new file mode 100644 index 0000000000..32f2af7e8b --- /dev/null +++ b/packages/sources/canton-functions/src/index.ts @@ -0,0 +1,13 @@ +import { expose, ServerInstance } from '@chainlink/external-adapter-framework' +import { Adapter } from '@chainlink/external-adapter-framework/adapter' +import { config } from './config' +import { cantonData } from './endpoint' + +export const adapter = new Adapter({ + defaultEndpoint: cantonData.name, + name: 'CANTON_FUNCTIONS', + config, + endpoints: [cantonData], +}) + +export const server = (): Promise => expose(adapter) diff --git a/packages/sources/canton-functions/src/shared/canton-client.ts b/packages/sources/canton-functions/src/shared/canton-client.ts new file mode 100644 index 0000000000..fbe49b6ffe --- /dev/null +++ b/packages/sources/canton-functions/src/shared/canton-client.ts @@ -0,0 +1,161 @@ +import { Requester } from '@chainlink/external-adapter-framework/util/requester' + +export interface CantonClientConfig { + AUTH_TOKEN: string +} + +export interface QueryContractByTemplateRequest { + templateIds: string[] + filter?: string | Record +} + +export interface QueryContractByIdRequest { + contractId: string + templateId: string +} + +export interface ExerciseChoiceRequest { + contractId: string + templateId: string + choice: string + argument?: string +} + +export interface Contract { + contractId: string + templateId: string + payload: Record + signatories: string[] + observers: string[] + agreementText: string + createdAt?: string +} + +export interface ExerciseResult { + exerciseResult: any + events: any[] +} + +export class CantonClient { + private requester: Requester + private config: CantonClientConfig + private static instance: CantonClient + + constructor(requester: Requester, config: CantonClientConfig) { + this.requester = requester + this.config = config + } + + static getInstance(requester: Requester, config: CantonClientConfig): CantonClient { + if (!this.instance) { + this.instance = new CantonClient(requester, config) + } + + return this.instance + } + + /** + * Query contracts by template ID with an optional filter + */ + async queryContractsByTemplate( + url: string, + request: QueryContractByTemplateRequest, + ): Promise { + const baseURL = `${url}/v1/query` + + const requestData: any = { + templateIds: request.templateIds, + } + + if (request.filter) { + requestData.query = + typeof request.filter === 'string' ? JSON.parse(request.filter) : request.filter + } + + const requestConfig = { + method: 'POST', + baseURL, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.AUTH_TOKEN}`, + }, + data: requestData, + } + + const response = await this.requester.request<{ result: Contract[] }>(baseURL, requestConfig) + + if (response.response?.status !== 200) { + throw new Error(`Failed to query contracts: ${response.response?.statusText}`) + } + + return response.response.data.result + } + + /** + * Query contract by template ID and contract ID + */ + async queryContractById( + url: string, + request: QueryContractByIdRequest, + ): Promise { + const baseURL = `${url}/v1/query` + + const requestConfig = { + method: 'POST', + baseURL, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.AUTH_TOKEN}`, + }, + data: { + templateIds: [request.templateId], + }, + } + + const response = await this.requester.request<{ result: Contract[] }>(baseURL, requestConfig) + + if (response.response?.status !== 200) { + throw new Error(`Failed to query contracts: ${response.response?.statusText}`) + } + + const contracts = response.response.data.result + const contract = contracts.find((c) => c.contractId === request.contractId) + + return contract || null + } + + /** + * Exercise a non-consuming choice on a contract + */ + async exerciseChoice(url: string, request: ExerciseChoiceRequest): Promise { + const baseURL = `${url}/v1/exercise` + + const requestData: any = { + templateId: request.templateId, + contractId: request.contractId, + choice: request.choice, + } + + if (request.argument) { + requestData.argument = JSON.parse(request.argument) + } + + const requestConfig = { + method: 'POST', + baseURL, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.config.AUTH_TOKEN}`, + }, + data: requestData, + } + + const response = await this.requester.request(baseURL, requestConfig) + + if (response.response?.status !== 200) { + throw new Error(`Failed to exercise choice: ${response.response?.statusText}`) + } + + return response.response.data + } +} diff --git a/packages/sources/canton-functions/src/transport/canton-data.ts b/packages/sources/canton-functions/src/transport/canton-data.ts new file mode 100644 index 0000000000..a77abaa825 --- /dev/null +++ b/packages/sources/canton-functions/src/transport/canton-data.ts @@ -0,0 +1,141 @@ +import { EndpointContext } from '@chainlink/external-adapter-framework/adapter' +import { TransportDependencies } from '@chainlink/external-adapter-framework/transports' +import { SubscriptionTransport } from '@chainlink/external-adapter-framework/transports/abstract/subscription' +import { AdapterResponse, makeLogger, sleep } from '@chainlink/external-adapter-framework/util' +import { AdapterInputError } from '@chainlink/external-adapter-framework/validation/error' +import { BaseEndpointTypes, inputParameters } from '../endpoint/canton-data' +import { CantonClient } from '../shared/canton-client' + +const logger = makeLogger('CantonDataTransport') + +type RequestParams = typeof inputParameters.validated + +export class CantonDataTransport extends SubscriptionTransport { + cantonClient!: CantonClient + + async initialize( + dependencies: TransportDependencies, + adapterSettings: BaseEndpointTypes['Settings'], + endpointName: string, + transportName: string, + ): Promise { + await super.initialize(dependencies, adapterSettings, endpointName, transportName) + this.cantonClient = CantonClient.getInstance(dependencies.requester, { + AUTH_TOKEN: adapterSettings.AUTH_TOKEN, + }) + } + + async backgroundHandler(context: EndpointContext, entries: RequestParams[]) { + await Promise.all(entries.map(async (param) => this.handleRequest(param))) + await sleep(context.adapterSettings.BACKGROUND_EXECUTE_MS) + } + + async handleRequest(param: RequestParams) { + let response: AdapterResponse + try { + response = await this._handleRequest(param) + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred' + logger.error(e, errorMessage) + response = { + statusCode: (e as AdapterInputError)?.statusCode || 502, + errorMessage, + timestamps: { + providerDataRequestedUnixMs: 0, + providerDataReceivedUnixMs: 0, + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + await this.responseCache.write(this.name, [{ params: param, response }]) + } + + async _handleRequest( + params: RequestParams, + ): Promise> { + const providerDataRequestedUnixMs = Date.now() + const url = params.url + const templateId = params.templateId + const choice = params.choice + + let contractId: string + let contract: any + + // If contractId is provided, use it directly + if (params.contractId) { + contractId = params.contractId + } else { + // Query contracts using contractFilter + if (!params.contractFilter) { + throw new AdapterInputError({ + message: 'Either contractId or contractFilter must be provided', + statusCode: 400, + }) + } + + const contracts = await this.cantonClient.queryContractsByTemplate(url, { + templateIds: [templateId], + filter: String(params.contractFilter), + }) + + if (!contracts || contracts.length === 0) { + throw new AdapterInputError({ + message: `No contracts found for template ID '${templateId}' with the provided filter`, + statusCode: 404, + }) + } + + // Find the latest contract by createdAt + contract = this.findLatestContract(contracts) + contractId = contract.contractId + } + + // Exercise the choice on the contract + const exerciseResult = await this.cantonClient.exerciseChoice(url, { + contractId, + templateId, + choice, + argument: params.argument ? String(params.argument) : undefined, + }) + + const result = JSON.stringify(exerciseResult) + + return { + data: { + result, + exerciseResult, + contract, + }, + statusCode: 200, + result, + timestamps: { + providerDataRequestedUnixMs, + providerDataReceivedUnixMs: Date.now(), + providerIndicatedTimeUnixMs: undefined, + }, + } + } + + /** + * Find the latest contract by createdAt date + */ + private findLatestContract(contracts: any[]): any { + if (contracts.length === 1) { + return contracts[0] + } + + // Sort by createdAt in descending order (latest first) + return contracts.sort((a, b) => { + const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0 + const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0 + return dateB - dateA + })[0] + } + + getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { + return adapterSettings.WARMUP_SUBSCRIPTION_TTL + } +} + +export const cantonDataTransport = new CantonDataTransport() diff --git a/packages/sources/canton-functions/test-payload.json b/packages/sources/canton-functions/test-payload.json new file mode 100644 index 0000000000..c772b8e703 --- /dev/null +++ b/packages/sources/canton-functions/test-payload.json @@ -0,0 +1,32 @@ +{ + "requests": [ + { + "endpoint": "canton-data", + "data": { + "url": "http://localhost:7575", + "templateId": "example-package-id:Main:Asset", + "contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0", + "choice": "GetValue" + } + }, + { + "endpoint": "canton-data", + "data": { + "url": "http://localhost:7575", + "templateId": "example-package-id:Main:Asset", + "contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0", + "choice": "UpdateValue", + "argument": "{\"newValue\":2000}" + } + }, + { + "endpoint": "canton-data", + "data": { + "url": "http://localhost:7575", + "templateId": "example-package-id:Main:Asset", + "contractFilter": "{\"owner\":\"Bob\"}", + "choice": "GetValue" + } + } + ] +} diff --git a/packages/sources/canton-functions/test/fixtures/canton-exercise-choice-response.json b/packages/sources/canton-functions/test/fixtures/canton-exercise-choice-response.json new file mode 100644 index 0000000000..8e82505e4e --- /dev/null +++ b/packages/sources/canton-functions/test/fixtures/canton-exercise-choice-response.json @@ -0,0 +1,15 @@ +{ + "exerciseResult": { + "value": "1000", + "currency": "USD" + }, + "events": [ + { + "eventId": "event-123", + "contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0", + "templateId": "example-package-id:Main:Asset", + "eventType": "ChoiceExercised" + } + ], + "status": 200 +} diff --git a/packages/sources/canton-functions/test/fixtures/canton-query-by-id-response.json b/packages/sources/canton-functions/test/fixtures/canton-query-by-id-response.json new file mode 100644 index 0000000000..c6c998ef11 --- /dev/null +++ b/packages/sources/canton-functions/test/fixtures/canton-query-by-id-response.json @@ -0,0 +1,19 @@ +{ + "result": [ + { + "contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0", + "templateId": "example-package-id:Main:Asset", + "payload": { + "issuer": "Alice", + "owner": "Bob", + "amount": "1000", + "currency": "USD", + "isin": "US0378331005" + }, + "signatories": ["Alice"], + "observers": ["Bob"], + "agreementText": "Asset transfer agreement" + } + ], + "status": 200 +} diff --git a/packages/sources/canton-functions/test/fixtures/canton-query-contracts-response.json b/packages/sources/canton-functions/test/fixtures/canton-query-contracts-response.json new file mode 100644 index 0000000000..f3565a16a4 --- /dev/null +++ b/packages/sources/canton-functions/test/fixtures/canton-query-contracts-response.json @@ -0,0 +1,33 @@ +{ + "result": [ + { + "contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0", + "templateId": "example-package-id:Main:Asset", + "payload": { + "issuer": "Alice", + "owner": "Bob", + "amount": "1000", + "currency": "USD", + "isin": "US0378331005" + }, + "signatories": ["Alice"], + "observers": ["Bob"], + "agreementText": "Asset transfer agreement" + }, + { + "contractId": "11f2a6c7d9b0a8f5e4c3d2a1b0c9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1", + "templateId": "example-package-id:Main:Asset", + "payload": { + "issuer": "Alice", + "owner": "Charlie", + "amount": "2500", + "currency": "USD", + "isin": "US0378331005" + }, + "signatories": ["Alice"], + "observers": ["Charlie"], + "agreementText": "Asset transfer agreement" + } + ], + "status": 200 +} diff --git a/packages/sources/canton-functions/test/fixtures/canton-query-with-filter-response.json b/packages/sources/canton-functions/test/fixtures/canton-query-with-filter-response.json new file mode 100644 index 0000000000..e57910788d --- /dev/null +++ b/packages/sources/canton-functions/test/fixtures/canton-query-with-filter-response.json @@ -0,0 +1,35 @@ +{ + "result": [ + { + "contractId": "22a3b7c8d0b1a9f6e5c4d3a2b1c0d9e8f7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2", + "templateId": "example-package-id:Main:Asset", + "payload": { + "issuer": "Alice", + "owner": "Bob", + "amount": "1500", + "currency": "USD", + "isin": "US0378331005" + }, + "signatories": ["Alice"], + "observers": ["Bob"], + "agreementText": "Asset transfer agreement", + "createdAt": "2025-10-16T10:00:00Z" + }, + { + "contractId": "33b4c8d9e1c2b0a7f6e5d4c3b2d1e0a9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3", + "templateId": "example-package-id:Main:Asset", + "payload": { + "issuer": "Alice", + "owner": "Bob", + "amount": "2000", + "currency": "USD", + "isin": "US0378331005" + }, + "signatories": ["Alice"], + "observers": ["Bob"], + "agreementText": "Asset transfer agreement", + "createdAt": "2025-10-17T12:00:00Z" + } + ], + "status": 200 +} diff --git a/packages/sources/canton-functions/test/integration/__snapshots__/canton-data.test.ts.snap b/packages/sources/canton-functions/test/integration/__snapshots__/canton-data.test.ts.snap new file mode 100644 index 0000000000..c01bf1af66 --- /dev/null +++ b/packages/sources/canton-functions/test/integration/__snapshots__/canton-data.test.ts.snap @@ -0,0 +1,107 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`execute canton-data endpoint should query with filter and exercise choice on latest contract 1`] = ` +{ + "data": { + "contract": { + "agreementText": "Asset transfer agreement", + "contractId": "33b4c8d9e1c2b0a7f6e5d4c3b2d1e0a9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3", + "createdAt": "2025-10-17T12:00:00Z", + "observers": [ + "Bob", + ], + "payload": { + "amount": "2000", + "currency": "USD", + "isin": "US0378331005", + "issuer": "Alice", + "owner": "Bob", + }, + "signatories": [ + "Alice", + ], + "templateId": "example-package-id:Main:Asset", + }, + "exerciseResult": { + "events": [ + { + "contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0", + "eventId": "event-123", + "eventType": "ChoiceExercised", + "templateId": "example-package-id:Main:Asset", + }, + ], + "exerciseResult": { + "currency": "USD", + "value": "1000", + }, + "status": 200, + }, + "result": "{"exerciseResult":{"value":"1000","currency":"USD"},"events":[{"eventId":"event-123","contractId":"00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0","templateId":"example-package-id:Main:Asset","eventType":"ChoiceExercised"}],"status":200}", + }, + "result": "{"exerciseResult":{"value":"1000","currency":"USD"},"events":[{"eventId":"event-123","contractId":"00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0","templateId":"example-package-id:Main:Asset","eventType":"ChoiceExercised"}],"status":200}", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1760400000000, + "providerDataRequestedUnixMs": 1760400000000, + }, +} +`; + +exports[`execute canton-data endpoint should return success when exercising a choice with argument 1`] = ` +{ + "data": { + "exerciseResult": { + "events": [ + { + "contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0", + "eventId": "event-123", + "eventType": "ChoiceExercised", + "templateId": "example-package-id:Main:Asset", + }, + ], + "exerciseResult": { + "currency": "USD", + "value": "1000", + }, + "status": 200, + }, + "result": "{"exerciseResult":{"value":"1000","currency":"USD"},"events":[{"eventId":"event-123","contractId":"00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0","templateId":"example-package-id:Main:Asset","eventType":"ChoiceExercised"}],"status":200}", + }, + "result": "{"exerciseResult":{"value":"1000","currency":"USD"},"events":[{"eventId":"event-123","contractId":"00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0","templateId":"example-package-id:Main:Asset","eventType":"ChoiceExercised"}],"status":200}", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1760400000000, + "providerDataRequestedUnixMs": 1760400000000, + }, +} +`; + +exports[`execute canton-data endpoint should return success when exercising a choice without argument 1`] = ` +{ + "data": { + "exerciseResult": { + "events": [ + { + "contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0", + "eventId": "event-123", + "eventType": "ChoiceExercised", + "templateId": "example-package-id:Main:Asset", + }, + ], + "exerciseResult": { + "currency": "USD", + "value": "1000", + }, + "status": 200, + }, + "result": "{"exerciseResult":{"value":"1000","currency":"USD"},"events":[{"eventId":"event-123","contractId":"00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0","templateId":"example-package-id:Main:Asset","eventType":"ChoiceExercised"}],"status":200}", + }, + "result": "{"exerciseResult":{"value":"1000","currency":"USD"},"events":[{"eventId":"event-123","contractId":"00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0","templateId":"example-package-id:Main:Asset","eventType":"ChoiceExercised"}],"status":200}", + "statusCode": 200, + "timestamps": { + "providerDataReceivedUnixMs": 1760400000000, + "providerDataRequestedUnixMs": 1760400000000, + }, +} +`; diff --git a/packages/sources/canton-functions/test/integration/canton-data.test.ts b/packages/sources/canton-functions/test/integration/canton-data.test.ts new file mode 100644 index 0000000000..4d81e7c39a --- /dev/null +++ b/packages/sources/canton-functions/test/integration/canton-data.test.ts @@ -0,0 +1,93 @@ +import { + TestAdapter, + setEnvVariables, +} from '@chainlink/external-adapter-framework/util/testing-utils' +import * as nock from 'nock' +import * as process from 'process' +import { + mockCantonApiExerciseChoiceOnLatestContractResponse, + mockCantonApiExerciseChoiceResponse, + mockCantonApiExerciseChoiceWithArgumentResponse, + mockCantonApiQueryWithFilterResponse, +} from './fixtures' + +describe('execute', () => { + let spy: jest.SpyInstance + let testAdapter: TestAdapter + let oldEnv: NodeJS.ProcessEnv + + beforeAll(async () => { + oldEnv = JSON.parse(JSON.stringify(process.env)) + process.env['AUTH_TOKEN'] = 'test-jwt-token' + process.env['BACKGROUND_EXECUTE_MS'] = '1000' + + const mockDate = new Date('2025-10-14T00:00:00.000Z') + spy = jest.spyOn(Date, 'now').mockReturnValue(mockDate.getTime()) + + const adapter = (await import('../../src')).adapter + adapter.rateLimiting = undefined + testAdapter = await TestAdapter.startWithMockedCache(adapter, { + testAdapter: {} as TestAdapter, + }) + }) + + afterAll(async () => { + setEnvVariables(oldEnv) + await testAdapter.api.close() + nock.restore() + nock.cleanAll() + spy.mockRestore() + }) + + describe('canton-data endpoint', () => { + it('should return success when exercising a choice without argument', async () => { + const data = { + endpoint: 'canton-data', + url: 'http://localhost:7575', + templateId: 'example-package-id:Main:Asset', + contractId: '00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0', + choice: 'GetValue', + } + + mockCantonApiExerciseChoiceResponse() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }, 10000) + + it('should return success when exercising a choice with argument', async () => { + const data = { + endpoint: 'canton-data', + url: 'http://localhost:7575', + templateId: 'example-package-id:Main:Asset', + contractId: '00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0', + choice: 'UpdateValue', + argument: JSON.stringify({ newValue: 2000 }), + } + + mockCantonApiExerciseChoiceWithArgumentResponse() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }, 10000) + + it('should query with filter and exercise choice on latest contract', async () => { + const data = { + endpoint: 'canton-data', + url: 'http://localhost:7575', + templateId: 'example-package-id:Main:Asset', + contractFilter: JSON.stringify({ owner: 'Bob' }), + choice: 'GetValue', + } + + mockCantonApiQueryWithFilterResponse() + mockCantonApiExerciseChoiceOnLatestContractResponse() + + const response = await testAdapter.request(data) + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchSnapshot() + }, 10000) + }) +}) diff --git a/packages/sources/canton-functions/test/integration/fixtures.ts b/packages/sources/canton-functions/test/integration/fixtures.ts new file mode 100644 index 0000000000..7626a0b788 --- /dev/null +++ b/packages/sources/canton-functions/test/integration/fixtures.ts @@ -0,0 +1,113 @@ +import nock from 'nock' +import mockCantonExerciseResponse from '../fixtures/canton-exercise-choice-response.json' +import mockCantonQueryByIdResponse from '../fixtures/canton-query-by-id-response.json' +import mockCantonQueryResponse from '../fixtures/canton-query-contracts-response.json' +import mockCantonQueryWithFilterResponse from '../fixtures/canton-query-with-filter-response.json' + +export function mockCantonApiQueryByTemplateResponse() { + nock('http://localhost:7575', { + reqheaders: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-jwt-token', + }, + }) + .persist() + .post('/v1/query', { + templateIds: ['example-package-id:Main:Asset'], + }) + .reply(200, mockCantonQueryResponse) +} + +export function mockCantonApiQueryByIdResponse() { + nock('http://localhost:7575', { + reqheaders: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-jwt-token', + }, + }) + .persist() + .post('/v1/query', { + templateIds: ['example-package-id:Main:Asset'], + }) + .reply(200, mockCantonQueryByIdResponse) +} + +export function mockCantonApiExerciseChoiceResponse() { + nock('http://localhost:7575', { + reqheaders: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-jwt-token', + }, + }) + .persist() + .post('/v1/exercise', { + templateId: 'example-package-id:Main:Asset', + contractId: '00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0', + choice: 'GetValue', + }) + .reply(200, mockCantonExerciseResponse) +} + +export function mockCantonApiExerciseChoiceWithArgumentResponse() { + nock('http://localhost:7575', { + reqheaders: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-jwt-token', + }, + }) + .persist() + .post('/v1/exercise', { + templateId: 'example-package-id:Main:Asset', + contractId: '00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0', + choice: 'UpdateValue', + argument: { newValue: 2000 }, + }) + .reply(200, mockCantonExerciseResponse) +} + +export function mockCantonApiQueryWithFilterResponse() { + nock('http://localhost:7575', { + reqheaders: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-jwt-token', + }, + }) + .persist() + .post('/v1/query', { + templateIds: ['example-package-id:Main:Asset'], + query: { owner: 'Bob' }, + }) + .reply(200, mockCantonQueryWithFilterResponse) +} + +export function mockCantonApiExerciseChoiceOnLatestContractResponse() { + nock('http://localhost:7575', { + reqheaders: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-jwt-token', + }, + }) + .persist() + .post('/v1/exercise', { + templateId: 'example-package-id:Main:Asset', + contractId: '33b4c8d9e1c2b0a7f6e5d4c3b2d1e0a9f8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3', + choice: 'GetValue', + }) + .reply(200, mockCantonExerciseResponse) +} + +export function mockCantonApiErrorResponse() { + nock('http://localhost:7575', { + reqheaders: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-jwt-token', + }, + }) + .persist() + .post('/v1/query', { + templateIds: ['invalid-template-id'], + }) + .reply(400, { + error: 'Invalid template ID format', + }) +} diff --git a/packages/sources/canton-functions/tsconfig.json b/packages/sources/canton-functions/tsconfig.json new file mode 100644 index 0000000000..f59363fd76 --- /dev/null +++ b/packages/sources/canton-functions/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["dist", "**/*.spec.ts", "**/*.test.ts"] +} diff --git a/packages/sources/canton-functions/tsconfig.test.json b/packages/sources/canton-functions/tsconfig.test.json new file mode 100755 index 0000000000..e3de28cb5c --- /dev/null +++ b/packages/sources/canton-functions/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src/**/*", "**/test", "src/**/*.json"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/yarn.lock b/yarn.lock index 569f56169f..b832338f98 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2995,6 +2995,21 @@ __metadata: languageName: unknown linkType: soft +"@chainlink/canton-functions-adapter@workspace:packages/sources/canton-functions": + version: 0.0.0-use.local + resolution: "@chainlink/canton-functions-adapter@workspace:packages/sources/canton-functions" + dependencies: + "@chainlink/external-adapter-framework": "npm:2.7.1" + "@sinonjs/fake-timers": "npm:9.1.2" + "@types/jest": "npm:^29.5.14" + "@types/node": "npm:22.14.1" + "@types/sinonjs__fake-timers": "npm:8.1.5" + nock: "npm:13.5.6" + tslib: "npm:2.4.1" + typescript: "npm:5.8.3" + languageName: unknown + linkType: soft + "@chainlink/ceffu-adapter@workspace:*, @chainlink/ceffu-adapter@workspace:packages/sources/ceffu": version: 0.0.0-use.local resolution: "@chainlink/ceffu-adapter@workspace:packages/sources/ceffu"