Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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: 0 additions & 5 deletions packages/sources/canton-functions/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,6 @@
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',
Expand Down
31 changes: 30 additions & 1 deletion packages/sources/canton-functions/src/endpoint/canton-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,43 @@ 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

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',
},
],
)
Expand All @@ -23,7 +51,8 @@ export type BaseEndpointTypes = {
Response: {
Data: {
result: string
contracts: any[]
exerciseResult: any
contract?: any
}
Result: string
}
Expand Down
111 changes: 104 additions & 7 deletions packages/sources/canton-functions/src/shared/canton-client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,24 @@
import { Requester } from '@chainlink/external-adapter-framework/util/requester'

export interface CantonClientConfig {
JSON_API: string
AUTH_TOKEN: string
}

export interface QueryContractRequest {
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 {
Expand All @@ -16,6 +28,12 @@ export interface Contract {
signatories: string[]
observers: string[]
agreementText: string
createdAt?: string
}

export interface ExerciseResult {
exerciseResult: any
events: any[]
}

export class CantonClient {
Expand All @@ -37,10 +55,22 @@ export class CantonClient {
}

/**
* Query contracts by template ID
* Query contracts by template ID with an optional filter
*/
async queryContracts(request: QueryContractRequest): Promise<Contract[]> {
const baseURL = `${this.config.JSON_API}/v1/query`
async queryContractsByTemplate(
url: string,
request: QueryContractByTemplateRequest,
): Promise<Contract[]> {
const baseURL = `${url}/v1/query`

const requestData: any = {
templateIds: request.templateIds,
}

if (request.filter) {
Copy link
Collaborator

@Fletch153 Fletch153 Oct 24, 2025

Choose a reason for hiding this comment

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

We can either

  • Return an error if duplicates are found
  • We could also return the full response (all the contracts) and pass this down to the implementing project to handle the return values. This might be preferable

requestData.query =
typeof request.filter === 'string' ? JSON.parse(request.filter) : request.filter
}

const requestConfig = {
method: 'POST',
Expand All @@ -49,16 +79,83 @@ export class CantonClient {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.config.AUTH_TOKEN}`,
},
data: request,
data: requestData,
}

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
}

/**
* 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
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can this actually return multiple contracts? i thought contractIds were unique

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, the query did not factor in the contract Id. But the function has been removed now because there is no use case for it in the code any longer

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
}
}
68 changes: 58 additions & 10 deletions packages/sources/canton-functions/src/transport/canton-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ export class CantonDataTransport extends SubscriptionTransport<BaseEndpointTypes
): 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,
})
}
Expand Down Expand Up @@ -56,24 +55,57 @@ export class CantonDataTransport extends SubscriptionTransport<BaseEndpointTypes
params: RequestParams,
): Promise<AdapterResponse<BaseEndpointTypes['Response']>> {
const providerDataRequestedUnixMs = Date.now()
const url = params.url
const templateId = params.templateId
const choice = params.choice

const contracts = await this.cantonClient.queryContracts({
templateIds: [params.templateId],
})
let contractId: string
let contract: any

// If contractId is provided, use it directly
if (params.contractId) {
contractId = params.contractId
} else {
// Query contracts using contractFilter
if (!params.contractFilter) {
throw new AdapterInputError({
message: 'Either contractId or contractFilter must be provided',
statusCode: 400,
})
}

if (!contracts || contracts.length === 0) {
throw new AdapterInputError({
message: `No contracts found for template ID '${params.templateId}'`,
statusCode: 404,
const contracts = await this.cantonClient.queryContractsByTemplate(url, {
templateIds: [templateId],
filter: String(params.contractFilter),
})

if (!contracts || contracts.length === 0) {
throw new AdapterInputError({
message: `No contracts found for template ID '${templateId}' with the provided filter`,
statusCode: 404,
})
}

// Find the latest contract by createdAt
contract = this.findLatestContract(contracts)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think each eturned contract may have a flag associated with it, which says where it's "ACTIVE" or "ARCHIVED" - it might be useful to check this and log an error

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The doc for this query is actually for active contracts. So all the contracts returned are active by default.

contractId = contract.contractId
}

const result = JSON.stringify(contracts)
// Exercise the choice on the contract
const exerciseResult = await this.cantonClient.exerciseChoice(url, {
contractId,
templateId,
choice,
argument: params.argument ? String(params.argument) : undefined,
})

const result = JSON.stringify(exerciseResult)

return {
data: {
result,
contracts,
exerciseResult,
contract,
},
statusCode: 200,
result,
Expand All @@ -85,6 +117,22 @@ export class CantonDataTransport extends SubscriptionTransport<BaseEndpointTypes
}
}

/**
* Find the latest contract by createdAt date
*/
private findLatestContract(contracts: any[]): any {
if (contracts.length === 1) {
return contracts[0]
}

// Sort by createdAt in descending order (latest first)
return contracts.sort((a, b) => {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does Canton not have an API to return the latest contract? And also perhaps even filter by flag ACTIVE as i mentioned above

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Canton does not have the API for that. The only thing that comes close to this is the created_at property that is attached to a query response from a gRPC call. However, when you query active contracts via the JSON api, it returns the active contracts in order of creation. So the first contract In the list is the latest, while the last contract in the list is the oldest.

const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0
return dateB - dateA
})[0]
}

getSubscriptionTtlFromConfig(adapterSettings: BaseEndpointTypes['Settings']): number {
return adapterSettings.WARMUP_SUBSCRIPTION_TTL
}
Expand Down
24 changes: 23 additions & 1 deletion packages/sources/canton-functions/test-payload.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,29 @@
{
"endpoint": "canton-data",
"data": {
"templateId": "example-package-id:Main:Asset"
"url": "http://localhost:7575",
"templateId": "example-package-id:Main:Asset",
"contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0",
"choice": "GetValue"
}
},
{
"endpoint": "canton-data",
"data": {
"url": "http://localhost:7575",
"templateId": "example-package-id:Main:Asset",
"contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0",
"choice": "UpdateValue",
"argument": "{\"newValue\":2000}"
}
},
{
"endpoint": "canton-data",
"data": {
"url": "http://localhost:7575",
"templateId": "example-package-id:Main:Asset",
"contractFilter": "{\"owner\":\"Bob\"}",
"choice": "GetValue"
}
}
]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"exerciseResult": {
"value": "1000",
"currency": "USD"
},
"events": [
{
"eventId": "event-123",
"contractId": "00e1f5c6d8b9a7f4e3c2d1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0",
"templateId": "example-package-id:Main:Asset",
"eventType": "ChoiceExercised"
}
],
"status": 200
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"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"
}
],
"status": 200
}
Loading
Loading