Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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"
}
}
21 changes: 21 additions & 0 deletions packages/sources/canton-functions/src/config/index.ts
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: {
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?

Copy link
Collaborator Author

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.

description:
'The amount of time the background execute should sleep before performing the next request',
type: 'number',
default: 1_000,
},
})
38 changes: 38 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,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(
{
Copy link
Collaborator

Choose a reason for hiding this comment

The 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,
})
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({
Copy link
Collaborator

Choose a reason for hiding this comment

The 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)
64 changes: 64 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,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
if (response.response?.status !== 200) {
throw new Error(`Failed to query contracts: ${response.response?.statusText}`)
}

return response.response.data.result
}
}
93 changes: 93 additions & 0 deletions packages/sources/canton-functions/src/transport/canton-data.ts
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()
10 changes: 10 additions & 0 deletions packages/sources/canton-functions/test-payload.json
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
}
Loading
Loading