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?

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(
{
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({
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

todo

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