-
Notifications
You must be signed in to change notification settings - Fork 327
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 all commits
e7337ba
18bd27c
c742a59
511d86e
7740b18
56c9ac7
406352a
21b971f
af69997
75afbec
31b221c
2372d13
acb7400
ba364e6
bf71600
f9a90a7
cb56ec8
5fecd29
a77e0c9
66e56f3
9f040d4
da9910d
6894d6f
7d7eca5
eb0f8a0
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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| root = true | ||
|
|
||
| [*] | ||
| end_of_line = lf | ||
| insert_final_newline = true | ||
|
|
||
| [*.{js,json,yml}] | ||
| charset = utf-8 | ||
| indent_style = space | ||
| indent_size = 2 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| /.yarn/** linguist-vendored | ||
| /.yarn/releases/* binary | ||
| /.yarn/plugins/**/* binary | ||
| /.pnp.* binary linguist-generated |
Large diffs are not rendered by default.
| 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", | ||
|
Contributor
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. 0.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.11.4", | ||
| "tslib": "2.4.1" | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,41 @@ | ||
| 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: { | ||
cl-mayowa marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| description: | ||
| 'The amount of time the background execute should sleep before performing the next request', | ||
| type: 'number', | ||
| default: 1_000, | ||
| }, | ||
| URL: { | ||
| description: 'The Canton JSON API URL', | ||
| type: 'string', | ||
| required: true, | ||
| }, | ||
| TEMPLATE_ID: { | ||
| description: 'The template ID to query contracts for (format: packageId:Module:Template)', | ||
| type: 'string', | ||
| required: true, | ||
| }, | ||
| CHOICE: { | ||
|
Contributor
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. Is this/are these env vars unique to each contract or to the chain itself? Reason i'm asking is if we intend to reuse this EA for multiple separate contract calls, you'd either want these to be variable & prefixed with a selector name or set in the request params rather than a single env var here. |
||
| 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, | ||
| }, | ||
| CONTRACT_FILTER: { | ||
| description: 'Filter to query contracts when contractId is not provided (JSON string)', | ||
| type: 'string', | ||
| required: false, | ||
| }, | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| 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( | ||
| { | ||
cl-mayowa marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| contractId: { | ||
| description: 'The contract ID to exercise choice on', | ||
| type: 'string', | ||
| required: false, | ||
| }, | ||
| }, | ||
| [ | ||
| { | ||
| contractId: '00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0', | ||
| }, | ||
| ], | ||
| ) | ||
|
|
||
| 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: [], | ||
|
Contributor
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. can remove this line |
||
| 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,24 @@ | ||
| 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({ | ||
Fletch153 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| defaultEndpoint: cantonData.name, | ||
| name: 'CANTON_FUNCTIONS', | ||
| config, | ||
| endpoints: [cantonData], | ||
| }) | ||
|
|
||
| export const server = (): Promise<ServerInstance | undefined> => expose(adapter) | ||
|
|
||
| // Export types and utilities for secondary adapters | ||
| export { BaseEndpointTypes, inputParameters } from './endpoint/canton-data' | ||
| export type { | ||
| Contract, | ||
| ExerciseChoiceRequest, | ||
| ExerciseResponse, | ||
| QueryContractByTemplateRequest, | ||
| } from './shared/canton-client' | ||
| export { CantonDataTransport, ResultHandler } from './transport/canton-data' | ||
| export { config as cantonConfig } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import { Requester } from '@chainlink/external-adapter-framework/util/requester' | ||
|
|
||
| export interface CantonClientConfig { | ||
| AUTH_TOKEN: string | ||
| URL: string | ||
| } | ||
|
|
||
| export interface QueryContractByTemplateRequest { | ||
| templateIds: string[] | ||
| filter?: string | Record<string, any> | ||
| } | ||
|
|
||
| export interface ExerciseChoiceRequest { | ||
| contractId: string | ||
| templateId: string | ||
| choice: string | ||
| argument: Record<string, any> | ||
| } | ||
|
|
||
| export interface Contract { | ||
|
Contributor
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. is this true for every contract? |
||
| contractId: string | ||
| templateId: string | ||
| payload: Record<string, any> | ||
| signatories: string[] | ||
| observers: string[] | ||
| agreementText: string | ||
| createdAt?: string | ||
| } | ||
|
|
||
| export interface ExerciseResult { | ||
| completionOffset: string | ||
| events: any[] | ||
| exerciseResult: any | ||
| } | ||
|
|
||
| export interface ExerciseResponse { | ||
| result: any | ||
| status: number | ||
| } | ||
|
|
||
| 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(request: QueryContractByTemplateRequest): Promise<Contract[]> { | ||
| const baseURL = `${this.config.URL}/v1/query` | ||
|
|
||
| const requestData: any = { | ||
| templateIds: request.templateIds, | ||
| } | ||
|
|
||
| if (request.filter) { | ||
Fletch153 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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}`) | ||
| } | ||
|
|
||
| const contracts = response.response.data.result | ||
|
|
||
| // When a filter is provided, it should return exactly one contract | ||
| if (request.filter && contracts.length > 1) { | ||
| throw new Error( | ||
| `Filter query returned ${contracts.length} contracts, but expected exactly 1. `, | ||
| ) | ||
| } | ||
|
|
||
| return contracts | ||
| } | ||
|
|
||
| /** | ||
| * Exercise a non-consuming choice on a contract | ||
| */ | ||
| async exerciseChoice(payload: ExerciseChoiceRequest): Promise<ExerciseResult> { | ||
| const baseURL = `${this.config.URL}/v1/exercise` | ||
|
|
||
| const requestConfig = { | ||
| method: 'POST', | ||
| baseURL, | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${this.config.AUTH_TOKEN}`, | ||
| }, | ||
| data: payload, | ||
| } | ||
|
|
||
| const response = await this.requester.request<ExerciseResponse>(baseURL, requestConfig) | ||
|
|
||
| if (response.response?.status !== 200) { | ||
| throw new Error(`Failed to exercise choice: ${response.response?.statusText}`) | ||
| } | ||
|
|
||
| return response.response.data.result | ||
| } | ||
| } | ||
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.
not sure what these new toplevel files are, maybe add them to the gitignore file.