Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fair-geese-beam.md
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
22 changes: 22 additions & 0 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Empty file.
3 changes: 3 additions & 0 deletions packages/sources/canton-functions/README.md
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`.
42 changes: 42 additions & 0 deletions packages/sources/canton-functions/package.json
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"
}
}
16 changes: 16 additions & 0 deletions packages/sources/canton-functions/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -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: {
Copy link
Collaborator

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?

description:
'The amount of time the background execute should sleep before performing the next request',
type: 'number',
default: 1_000,
},
})
67 changes: 67 additions & 0 deletions packages/sources/canton-functions/src/endpoint/canton-data.ts
Original file line number Diff line number Diff line change
@@ -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,
})
1 change: 1 addition & 0 deletions packages/sources/canton-functions/src/endpoint/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { endpoint as cantonData } from './canton-data'
13 changes: 13 additions & 0 deletions packages/sources/canton-functions/src/index.ts
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({
defaultEndpoint: cantonData.name,
name: 'CANTON_FUNCTIONS',
config,
endpoints: [cantonData],
})

export const server = (): Promise<ServerInstance | undefined> => expose(adapter)
161 changes: 161 additions & 0 deletions packages/sources/canton-functions/src/shared/canton-client.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>
}

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<string, any>
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<Contract[]> {
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<Contract | null> {
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<ExerciseResult> {
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<ExerciseResult>(baseURL, requestConfig)

if (response.response?.status !== 200) {
throw new Error(`Failed to exercise choice: ${response.response?.statusText}`)
}

return response.response.data
}
}
Loading
Loading