- 
                Notifications
    You must be signed in to change notification settings 
- Fork 323
Add Canton EA to read from Canton participant node #4103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
3b1f95f
              cc31167
              c6b9360
              63ae031
              d652378
              File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@chainlink/canton-functions-adapter': major | ||
| --- | ||
|  | ||
| This EA enables us to read data from Canton participant nodes via the Ledger API | 
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -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`. | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -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" | ||
| } | ||
| } | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { AdapterConfig } from '@chainlink/external-adapter-framework/config' | ||
|  | ||
| export const config = new AdapterConfig({ | ||
| JSON_API: { | ||
| description: 'The Canton JSON API URL', | ||
| type: 'string', | ||
| required: true, | ||
| }, | ||
| 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, | ||
| }, | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| 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( | ||
| { | ||
| There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As noted: these should be ENV variables specified in the implementing project, most of these are fixed and do not need to be in the request | ||
| templateId: { | ||
| description: 'The template ID to query contracts for (format: packageId:Module:Template)', | ||
| type: 'string', | ||
| required: true, | ||
| }, | ||
| }, | ||
| [ | ||
| { | ||
| templateId: 'example-package-id:Main:Asset', | ||
| }, | ||
| ], | ||
| ) | ||
|  | ||
| export type BaseEndpointTypes = { | ||
| Parameters: typeof inputParameters.definition | ||
| Response: { | ||
| Data: { | ||
| result: string | ||
| contracts: any[] | ||
| } | ||
| Result: string | ||
| } | ||
| Settings: typeof config.settings | ||
| } | ||
|  | ||
| export const endpoint = new AdapterEndpoint({ | ||
| name: 'canton-data', | ||
| aliases: [], | ||
| transport: cantonDataTransport, | ||
| inputParameters, | ||
| }) | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { endpoint as cantonData } from './canton-data' | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -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({ | ||
| There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Base contract should not have an instance, instance should be configured within implementing contracts as discussed | ||
| defaultEndpoint: cantonData.name, | ||
| name: 'CANTON_FUNCTIONS', | ||
| config, | ||
| endpoints: [cantonData], | ||
| }) | ||
|  | ||
| export const server = (): Promise<ServerInstance | undefined> => expose(adapter) | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { Requester } from '@chainlink/external-adapter-framework/util/requester' | ||
|  | ||
| export interface CantonClientConfig { | ||
| JSON_API: string | ||
| AUTH_TOKEN: string | ||
| } | ||
|  | ||
| export interface QueryContractRequest { | ||
| templateIds: string[] | ||
| } | ||
|  | ||
| export interface Contract { | ||
| contractId: string | ||
| templateId: string | ||
| payload: Record<string, any> | ||
| signatories: string[] | ||
| observers: string[] | ||
| agreementText: string | ||
| } | ||
|  | ||
| 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 | ||
| */ | ||
| async queryContracts(request: QueryContractRequest): Promise<Contract[]> { | ||
| const baseURL = `${this.config.JSON_API}/v1/query` | ||
|  | ||
| const requestConfig = { | ||
| method: 'POST', | ||
| baseURL, | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${this.config.AUTH_TOKEN}`, | ||
| }, | ||
| data: request, | ||
| } | ||
|  | ||
| const response = await this.requester.request<{ result: Contract[] }>(baseURL, requestConfig) | ||
|  | ||
| //todo: check for other error codes | ||
|         
                  cl-mayowa marked this conversation as resolved.
              Outdated
          
            Show resolved
            Hide resolved | ||
| if (response.response?.status !== 200) { | ||
| throw new Error(`Failed to query contracts: ${response.response?.statusText}`) | ||
| } | ||
|  | ||
| return response.response.data.result | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| 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<BaseEndpointTypes> { | ||
| cantonClient!: CantonClient | ||
|  | ||
| async initialize( | ||
| dependencies: TransportDependencies<BaseEndpointTypes>, | ||
| adapterSettings: BaseEndpointTypes['Settings'], | ||
| endpointName: string, | ||
| transportName: string, | ||
| ): Promise<void> { | ||
| await super.initialize(dependencies, adapterSettings, endpointName, transportName) | ||
| this.cantonClient = CantonClient.getInstance(dependencies.requester, { | ||
| JSON_API: adapterSettings.JSON_API, | ||
| AUTH_TOKEN: adapterSettings.AUTH_TOKEN, | ||
| }) | ||
| } | ||
|  | ||
| async backgroundHandler(context: EndpointContext<BaseEndpointTypes>, 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<BaseEndpointTypes['Response']> | ||
| 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<AdapterResponse<BaseEndpointTypes['Response']>> { | ||
| const providerDataRequestedUnixMs = Date.now() | ||
|  | ||
| const contracts = await this.cantonClient.queryContracts({ | ||
| templateIds: [params.templateId], | ||
| }) | ||
|  | ||
| if (!contracts || contracts.length === 0) { | ||
| throw new AdapterInputError({ | ||
| message: `No contracts found for template ID '${params.templateId}'`, | ||
| statusCode: 404, | ||
| }) | ||
| } | ||
|  | ||
| const result = JSON.stringify(contracts) | ||
|  | ||
| return { | ||
| data: { | ||
| result, | ||
| contracts, | ||
| }, | ||
| statusCode: 200, | ||
| result, | ||
| timestamps: { | ||
| providerDataRequestedUnixMs, | ||
| providerDataReceivedUnixMs: Date.now(), | ||
| providerIndicatedTimeUnixMs: undefined, | ||
| }, | ||
| } | ||
| } | ||
|  | ||
| getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number { | ||
| return adapterSettings.WARMUP_SUBSCRIPTION_TTL | ||
| } | ||
| } | ||
|  | ||
| export const cantonDataTransport = new CantonDataTransport() | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "requests": [ | ||
| { | ||
| "endpoint": "canton-data", | ||
| "data": { | ||
| "templateId": "example-package-id:Main:Asset" | ||
| } | ||
| } | ||
| ] | ||
| } | 
| Original file line number | Diff line number | Diff line change | 
|---|---|---|
| @@ -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 | ||
| } | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to override the default here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, we can always export a different BACKGROUND_EXECUTE_MS env variable. But if we want to do that programmatically, we might need to create in input param for it.