@caypo/canton-sdk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +26 -0
- package/.turbo/turbo-test.log +23 -0
- package/README.md +120 -0
- package/SPEC.md +223 -0
- package/dist/amount-L2SDLRZT.js +15 -0
- package/dist/amount-L2SDLRZT.js.map +1 -0
- package/dist/chunk-GSDB5FKZ.js +110 -0
- package/dist/chunk-GSDB5FKZ.js.map +1 -0
- package/dist/index.cjs +1158 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +673 -0
- package/dist/index.d.ts +673 -0
- package/dist/index.js +986 -0
- package/dist/index.js.map +1 -0
- package/package.json +50 -0
- package/src/__tests__/agent.test.ts +217 -0
- package/src/__tests__/amount.test.ts +202 -0
- package/src/__tests__/client.test.ts +516 -0
- package/src/__tests__/e2e/canton-client.e2e.test.ts +190 -0
- package/src/__tests__/e2e/mpp-flow.e2e.test.ts +346 -0
- package/src/__tests__/e2e/setup.ts +112 -0
- package/src/__tests__/e2e/usdcx.e2e.test.ts +114 -0
- package/src/__tests__/keystore.test.ts +197 -0
- package/src/__tests__/pay-client.test.ts +257 -0
- package/src/__tests__/safeguards.test.ts +333 -0
- package/src/__tests__/usdcx.test.ts +374 -0
- package/src/accounts/checking.ts +118 -0
- package/src/agent.ts +132 -0
- package/src/canton/amount.ts +167 -0
- package/src/canton/client.ts +218 -0
- package/src/canton/errors.ts +45 -0
- package/src/canton/holdings.ts +90 -0
- package/src/canton/index.ts +51 -0
- package/src/canton/types.ts +214 -0
- package/src/canton/usdcx.ts +166 -0
- package/src/index.ts +97 -0
- package/src/mpp/pay-client.ts +170 -0
- package/src/safeguards/manager.ts +183 -0
- package/src/traffic/manager.ts +95 -0
- package/src/wallet/config.ts +88 -0
- package/src/wallet/keystore.ts +164 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckingAccount — high-level USDCx checking account.
|
|
3
|
+
*
|
|
4
|
+
* Wraps USDCxService with safeguard checks and transaction history.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { CantonClient } from "../canton/client.js";
|
|
8
|
+
import type { USDCxService } from "../canton/usdcx.js";
|
|
9
|
+
import type { TransferResult } from "../canton/usdcx.js";
|
|
10
|
+
import type { SafeguardManager } from "../safeguards/manager.js";
|
|
11
|
+
|
|
12
|
+
export interface SendOptions {
|
|
13
|
+
memo?: string;
|
|
14
|
+
commandId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TransactionRecord {
|
|
18
|
+
updateId: string;
|
|
19
|
+
commandId: string;
|
|
20
|
+
effectiveAt: string;
|
|
21
|
+
offset: number;
|
|
22
|
+
type: "send" | "receive" | "unknown";
|
|
23
|
+
amount?: string;
|
|
24
|
+
counterparty?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class CheckingAccount {
|
|
28
|
+
constructor(
|
|
29
|
+
private readonly usdcx: USDCxService,
|
|
30
|
+
private readonly safeguards: SafeguardManager,
|
|
31
|
+
private readonly client: CantonClient,
|
|
32
|
+
private readonly partyId: string,
|
|
33
|
+
) {}
|
|
34
|
+
|
|
35
|
+
/** Get USDCx balance: total available and number of UTXO holdings. */
|
|
36
|
+
async balance(): Promise<{ available: string; holdingCount: number }> {
|
|
37
|
+
const holdings = await this.usdcx.getHoldings();
|
|
38
|
+
let available = "0";
|
|
39
|
+
|
|
40
|
+
// Use addAmounts from the holdings themselves
|
|
41
|
+
const { addAmounts } = await import("../canton/amount.js");
|
|
42
|
+
for (const h of holdings) {
|
|
43
|
+
available = addAmounts(available, h.amount);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { available, holdingCount: holdings.length };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Send USDCx to a recipient.
|
|
51
|
+
* Checks safeguards before executing the transfer.
|
|
52
|
+
*/
|
|
53
|
+
async send(
|
|
54
|
+
recipient: string,
|
|
55
|
+
amount: string,
|
|
56
|
+
opts?: SendOptions,
|
|
57
|
+
): Promise<TransferResult> {
|
|
58
|
+
// 1. Check safeguards
|
|
59
|
+
const check = this.safeguards.check(amount);
|
|
60
|
+
if (!check.allowed) {
|
|
61
|
+
throw new Error(`Safeguard rejected: ${check.reason}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Execute transfer
|
|
65
|
+
const result = await this.usdcx.transfer({
|
|
66
|
+
recipient,
|
|
67
|
+
amount,
|
|
68
|
+
commandId: opts?.commandId,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// 3. Record spend
|
|
72
|
+
this.safeguards.recordSpend(amount);
|
|
73
|
+
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Party ID for receiving payments. */
|
|
78
|
+
address(): string {
|
|
79
|
+
return this.partyId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Query transaction history via /v2/updates/flats.
|
|
84
|
+
* Returns recent flat transactions involving this party.
|
|
85
|
+
*/
|
|
86
|
+
async history(opts?: { limit?: number }): Promise<TransactionRecord[]> {
|
|
87
|
+
const limit = opts?.limit ?? 20;
|
|
88
|
+
const offset = await this.client.getLedgerEnd();
|
|
89
|
+
|
|
90
|
+
// Query recent transactions using active contracts approach
|
|
91
|
+
// Note: full streaming history requires WebSocket /v2/updates/flats
|
|
92
|
+
// For now, return what we can derive from recent contract state
|
|
93
|
+
const contracts = await this.client.queryActiveContracts({
|
|
94
|
+
filtersByParty: {
|
|
95
|
+
[this.partyId]: {
|
|
96
|
+
cumulative: [
|
|
97
|
+
{
|
|
98
|
+
identifierFilter: {
|
|
99
|
+
WildcardFilter: { value: { includeCreatedEventBlob: false } },
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
activeAtOffset: offset,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Map contracts to transaction records (limited view — full history needs WebSocket streaming)
|
|
109
|
+
return contracts.slice(0, limit).map((c) => ({
|
|
110
|
+
updateId: "",
|
|
111
|
+
commandId: "",
|
|
112
|
+
effectiveAt: c.createdAt,
|
|
113
|
+
offset: 0,
|
|
114
|
+
type: "unknown" as const,
|
|
115
|
+
amount: typeof c.createArgument.amount === "string" ? c.createArgument.amount : undefined,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/agent.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CantonAgent — high-level entry point for the Canton SDK.
|
|
3
|
+
*
|
|
4
|
+
* Creates and wires together all sub-services:
|
|
5
|
+
* - CheckingAccount (USDCx balance, send, history)
|
|
6
|
+
* - SafeguardManager (tx limits, daily limits, lock)
|
|
7
|
+
* - TrafficManager (validator traffic budgets)
|
|
8
|
+
* - MppPayClient (HTTP 402 auto-pay)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { CheckingAccount } from "./accounts/checking.js";
|
|
12
|
+
import { CantonClient } from "./canton/client.js";
|
|
13
|
+
import { USDCxService } from "./canton/usdcx.js";
|
|
14
|
+
import { MppPayClient } from "./mpp/pay-client.js";
|
|
15
|
+
import { SafeguardManager } from "./safeguards/manager.js";
|
|
16
|
+
import { TrafficManager } from "./traffic/manager.js";
|
|
17
|
+
import {
|
|
18
|
+
loadConfig,
|
|
19
|
+
type AgentConfig,
|
|
20
|
+
} from "./wallet/config.js";
|
|
21
|
+
|
|
22
|
+
export interface WalletInfo {
|
|
23
|
+
address: string;
|
|
24
|
+
partyId: string;
|
|
25
|
+
network: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface CantonAgentConfig {
|
|
29
|
+
ledgerUrl?: string;
|
|
30
|
+
token?: string;
|
|
31
|
+
userId?: string;
|
|
32
|
+
partyId?: string;
|
|
33
|
+
network?: "mainnet" | "testnet" | "devnet";
|
|
34
|
+
configPath?: string;
|
|
35
|
+
safeguardsPath?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class CantonAgent {
|
|
39
|
+
readonly checking: CheckingAccount;
|
|
40
|
+
readonly safeguards: SafeguardManager;
|
|
41
|
+
readonly traffic: TrafficManager;
|
|
42
|
+
readonly mpp: MppPayClient;
|
|
43
|
+
readonly wallet: WalletInfo;
|
|
44
|
+
|
|
45
|
+
private readonly client: CantonClient;
|
|
46
|
+
private readonly usdcx: USDCxService;
|
|
47
|
+
|
|
48
|
+
private constructor(
|
|
49
|
+
client: CantonClient,
|
|
50
|
+
usdcx: USDCxService,
|
|
51
|
+
checking: CheckingAccount,
|
|
52
|
+
safeguards: SafeguardManager,
|
|
53
|
+
traffic: TrafficManager,
|
|
54
|
+
mpp: MppPayClient,
|
|
55
|
+
wallet: WalletInfo,
|
|
56
|
+
) {
|
|
57
|
+
this.client = client;
|
|
58
|
+
this.usdcx = usdcx;
|
|
59
|
+
this.checking = checking;
|
|
60
|
+
this.safeguards = safeguards;
|
|
61
|
+
this.traffic = traffic;
|
|
62
|
+
this.mpp = mpp;
|
|
63
|
+
this.wallet = wallet;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Create a new CantonAgent from config.
|
|
68
|
+
*
|
|
69
|
+
* Loads configuration from ~/.caypo/config.json (or overrides),
|
|
70
|
+
* initializes all sub-services, and wires them together.
|
|
71
|
+
*/
|
|
72
|
+
static async create(config?: CantonAgentConfig): Promise<CantonAgent> {
|
|
73
|
+
// 1. Load config (file + overrides)
|
|
74
|
+
const fileConfig = await loadConfig(config?.configPath);
|
|
75
|
+
const merged: AgentConfig = {
|
|
76
|
+
...fileConfig,
|
|
77
|
+
...(config?.ledgerUrl && { ledgerUrl: config.ledgerUrl }),
|
|
78
|
+
...(config?.partyId && { partyId: config.partyId }),
|
|
79
|
+
...(config?.userId && { userId: config.userId }),
|
|
80
|
+
...(config?.network && { network: config.network }),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const token = config?.token ?? "";
|
|
84
|
+
|
|
85
|
+
// 2. Create low-level client
|
|
86
|
+
const client = new CantonClient({
|
|
87
|
+
ledgerUrl: merged.ledgerUrl,
|
|
88
|
+
token,
|
|
89
|
+
userId: merged.userId,
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// 3. Create services
|
|
93
|
+
const usdcx = new USDCxService(client, merged.partyId);
|
|
94
|
+
const safeguards = await SafeguardManager.load(config?.safeguardsPath);
|
|
95
|
+
const traffic = new TrafficManager(client, merged.partyId);
|
|
96
|
+
const checking = new CheckingAccount(usdcx, safeguards, client, merged.partyId);
|
|
97
|
+
const mpp = new MppPayClient(usdcx, safeguards, merged.partyId, merged.network);
|
|
98
|
+
|
|
99
|
+
const wallet: WalletInfo = {
|
|
100
|
+
address: merged.partyId,
|
|
101
|
+
partyId: merged.partyId,
|
|
102
|
+
network: merged.network,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return new CantonAgent(client, usdcx, checking, safeguards, traffic, mpp, wallet);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Create a CantonAgent from explicit parameters (no file I/O).
|
|
110
|
+
* Useful for testing and programmatic setup.
|
|
111
|
+
*/
|
|
112
|
+
static fromParams(params: {
|
|
113
|
+
client: CantonClient;
|
|
114
|
+
partyId: string;
|
|
115
|
+
network: string;
|
|
116
|
+
safeguards?: SafeguardManager;
|
|
117
|
+
}): CantonAgent {
|
|
118
|
+
const safeguards = params.safeguards ?? new SafeguardManager();
|
|
119
|
+
const usdcx = new USDCxService(params.client, params.partyId);
|
|
120
|
+
const traffic = new TrafficManager(params.client, params.partyId);
|
|
121
|
+
const checking = new CheckingAccount(usdcx, safeguards, params.client, params.partyId);
|
|
122
|
+
const mpp = new MppPayClient(usdcx, safeguards, params.partyId, params.network);
|
|
123
|
+
|
|
124
|
+
const wallet: WalletInfo = {
|
|
125
|
+
address: params.partyId,
|
|
126
|
+
partyId: params.partyId,
|
|
127
|
+
network: params.network,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return new CantonAgent(params.client, usdcx, checking, safeguards, traffic, mpp, wallet);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* String-based decimal arithmetic for Canton amounts.
|
|
3
|
+
*
|
|
4
|
+
* Canton uses Numeric 10 (up to 10 decimal places). USDCx has 6 meaningful decimals.
|
|
5
|
+
* ALL arithmetic is pure string manipulation — NO floating point.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const AMOUNT_RE = /^\d+(\.\d+)?$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Validate that a string is a valid non-negative decimal amount.
|
|
12
|
+
*/
|
|
13
|
+
export function isValidAmount(s: string): boolean {
|
|
14
|
+
return AMOUNT_RE.test(s);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse an amount string into integer and fractional digit arrays.
|
|
19
|
+
* Returns [intDigits, fracDigits] where each is a number[].
|
|
20
|
+
*/
|
|
21
|
+
function parse(s: string): [number[], number[]] {
|
|
22
|
+
const dot = s.indexOf(".");
|
|
23
|
+
const intPart = dot === -1 ? s : s.slice(0, dot);
|
|
24
|
+
const fracPart = dot === -1 ? "" : s.slice(dot + 1);
|
|
25
|
+
return [
|
|
26
|
+
intPart.split("").map(Number),
|
|
27
|
+
fracPart.split("").map(Number),
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Normalise two amount strings so their fractional parts have equal length.
|
|
33
|
+
* Returns [aParts, bParts, fracLen].
|
|
34
|
+
*/
|
|
35
|
+
function align(a: string, b: string): [number[], number[], number[], number[], number] {
|
|
36
|
+
const [aInt, aFrac] = parse(a);
|
|
37
|
+
const [bInt, bFrac] = parse(b);
|
|
38
|
+
const fracLen = Math.max(aFrac.length, bFrac.length);
|
|
39
|
+
while (aFrac.length < fracLen) aFrac.push(0);
|
|
40
|
+
while (bFrac.length < fracLen) bFrac.push(0);
|
|
41
|
+
// Also align integer parts to equal length by prepending zeros
|
|
42
|
+
const intLen = Math.max(aInt.length, bInt.length);
|
|
43
|
+
while (aInt.length < intLen) aInt.unshift(0);
|
|
44
|
+
while (bInt.length < intLen) bInt.unshift(0);
|
|
45
|
+
return [aInt, aFrac, bInt, bFrac, fracLen];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Strip trailing zeros from fractional part and leading zeros from integer part.
|
|
50
|
+
*/
|
|
51
|
+
function formatResult(intDigits: number[], fracDigits: number[]): string {
|
|
52
|
+
// Remove trailing zeros from fraction
|
|
53
|
+
while (fracDigits.length > 0 && fracDigits[fracDigits.length - 1] === 0) {
|
|
54
|
+
fracDigits.pop();
|
|
55
|
+
}
|
|
56
|
+
// Remove leading zeros from integer (keep at least one)
|
|
57
|
+
while (intDigits.length > 1 && intDigits[0] === 0) {
|
|
58
|
+
intDigits.shift();
|
|
59
|
+
}
|
|
60
|
+
const intStr = intDigits.join("");
|
|
61
|
+
return fracDigits.length > 0 ? `${intStr}.${fracDigits.join("")}` : intStr;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Compare two decimal amount strings.
|
|
66
|
+
* Returns -1 if a < b, 0 if a === b, 1 if a > b.
|
|
67
|
+
*/
|
|
68
|
+
export function compareAmounts(a: string, b: string): -1 | 0 | 1 {
|
|
69
|
+
const [aInt, aFrac, bInt, bFrac] = align(a, b);
|
|
70
|
+
|
|
71
|
+
// Compare integer parts digit by digit
|
|
72
|
+
for (let i = 0; i < aInt.length; i++) {
|
|
73
|
+
if (aInt[i] < bInt[i]) return -1;
|
|
74
|
+
if (aInt[i] > bInt[i]) return 1;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Compare fractional parts digit by digit
|
|
78
|
+
for (let i = 0; i < aFrac.length; i++) {
|
|
79
|
+
if (aFrac[i] < bFrac[i]) return -1;
|
|
80
|
+
if (aFrac[i] > bFrac[i]) return 1;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Add two decimal amount strings. Returns the sum as a string.
|
|
88
|
+
*/
|
|
89
|
+
export function addAmounts(a: string, b: string): string {
|
|
90
|
+
const [aInt, aFrac, bInt, bFrac, fracLen] = align(a, b);
|
|
91
|
+
|
|
92
|
+
// Combine into single digit arrays (integer + fractional)
|
|
93
|
+
const aAll = [...aInt, ...aFrac];
|
|
94
|
+
const bAll = [...bInt, ...bFrac];
|
|
95
|
+
|
|
96
|
+
const result: number[] = new Array(aAll.length).fill(0);
|
|
97
|
+
let carry = 0;
|
|
98
|
+
|
|
99
|
+
for (let i = aAll.length - 1; i >= 0; i--) {
|
|
100
|
+
const sum = aAll[i] + bAll[i] + carry;
|
|
101
|
+
result[i] = sum % 10;
|
|
102
|
+
carry = Math.floor(sum / 10);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (carry > 0) {
|
|
106
|
+
result.unshift(carry);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Split back into integer and fractional parts
|
|
110
|
+
const intDigits = result.slice(0, result.length - fracLen);
|
|
111
|
+
const fracDigits = result.slice(result.length - fracLen);
|
|
112
|
+
|
|
113
|
+
return formatResult(
|
|
114
|
+
intDigits.length === 0 ? [0] : intDigits,
|
|
115
|
+
fracDigits,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Subtract b from a. Both must be valid amounts and a >= b.
|
|
121
|
+
* Throws if a < b.
|
|
122
|
+
*/
|
|
123
|
+
export function subtractAmounts(a: string, b: string): string {
|
|
124
|
+
if (compareAmounts(a, b) < 0) {
|
|
125
|
+
throw new Error(`Cannot subtract: ${a} < ${b}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const [aInt, aFrac, bInt, bFrac, fracLen] = align(a, b);
|
|
129
|
+
|
|
130
|
+
const aAll = [...aInt, ...aFrac];
|
|
131
|
+
const bAll = [...bInt, ...bFrac];
|
|
132
|
+
|
|
133
|
+
const result: number[] = new Array(aAll.length).fill(0);
|
|
134
|
+
let borrow = 0;
|
|
135
|
+
|
|
136
|
+
for (let i = aAll.length - 1; i >= 0; i--) {
|
|
137
|
+
let diff = aAll[i] - bAll[i] - borrow;
|
|
138
|
+
if (diff < 0) {
|
|
139
|
+
diff += 10;
|
|
140
|
+
borrow = 1;
|
|
141
|
+
} else {
|
|
142
|
+
borrow = 0;
|
|
143
|
+
}
|
|
144
|
+
result[i] = diff;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const intDigits = result.slice(0, result.length - fracLen);
|
|
148
|
+
const fracDigits = result.slice(result.length - fracLen);
|
|
149
|
+
|
|
150
|
+
return formatResult(
|
|
151
|
+
intDigits.length === 0 ? [0] : intDigits,
|
|
152
|
+
fracDigits,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Pad (or truncate) a decimal string to exactly N decimal places.
|
|
158
|
+
* Default: 10 (Canton Numeric 10).
|
|
159
|
+
*/
|
|
160
|
+
export function toCantonAmount(s: string, decimals = 10): string {
|
|
161
|
+
const dot = s.indexOf(".");
|
|
162
|
+
const intPart = dot === -1 ? s : s.slice(0, dot);
|
|
163
|
+
const fracPart = dot === -1 ? "" : s.slice(dot + 1);
|
|
164
|
+
|
|
165
|
+
const padded = fracPart.padEnd(decimals, "0").slice(0, decimals);
|
|
166
|
+
return `${intPart}.${padded}`;
|
|
167
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canton JSON Ledger API v2 — Client
|
|
3
|
+
*
|
|
4
|
+
* All requests target config.ledgerUrl + path.
|
|
5
|
+
* All requests include Authorization: Bearer <token>.
|
|
6
|
+
* Errors are parsed into typed CantonApiError / CantonAuthError / CantonTimeoutError.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { CantonApiError, CantonAuthError, CantonTimeoutError } from "./errors.js";
|
|
10
|
+
import type {
|
|
11
|
+
ActiveContract,
|
|
12
|
+
CantonClientConfig,
|
|
13
|
+
CreatedEvent,
|
|
14
|
+
LedgerError,
|
|
15
|
+
PartyDetails,
|
|
16
|
+
QueryActiveContractsParams,
|
|
17
|
+
SubmitAndWaitResponse,
|
|
18
|
+
SubmitParams,
|
|
19
|
+
TransactionResponse,
|
|
20
|
+
TransactionTree,
|
|
21
|
+
} from "./types.js";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT = 30_000;
|
|
24
|
+
|
|
25
|
+
export class CantonClient {
|
|
26
|
+
private readonly ledgerUrl: string;
|
|
27
|
+
private readonly token: string;
|
|
28
|
+
private readonly userId: string;
|
|
29
|
+
private readonly timeout: number;
|
|
30
|
+
|
|
31
|
+
constructor(config: CantonClientConfig) {
|
|
32
|
+
this.ledgerUrl = config.ledgerUrl.replace(/\/+$/, "");
|
|
33
|
+
this.token = config.token;
|
|
34
|
+
this.userId = config.userId;
|
|
35
|
+
this.timeout = config.timeout ?? DEFAULT_TIMEOUT;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Command Submission
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
async submitAndWait(params: SubmitParams): Promise<SubmitAndWaitResponse> {
|
|
43
|
+
return this.request<SubmitAndWaitResponse>("POST", "/v2/commands/submit-and-wait", {
|
|
44
|
+
commands: params.commands,
|
|
45
|
+
userId: this.userId,
|
|
46
|
+
commandId: params.commandId,
|
|
47
|
+
actAs: params.actAs,
|
|
48
|
+
readAs: params.readAs,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async submitAndWaitForTransaction(params: SubmitParams): Promise<TransactionResponse> {
|
|
53
|
+
return this.request<TransactionResponse>(
|
|
54
|
+
"POST",
|
|
55
|
+
"/v2/commands/submit-and-wait-for-transaction",
|
|
56
|
+
{
|
|
57
|
+
commands: params.commands,
|
|
58
|
+
userId: this.userId,
|
|
59
|
+
commandId: params.commandId,
|
|
60
|
+
actAs: params.actAs,
|
|
61
|
+
readAs: params.readAs,
|
|
62
|
+
},
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Active Contract Queries
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
async queryActiveContracts(params: QueryActiveContractsParams): Promise<ActiveContract[]> {
|
|
71
|
+
const body = {
|
|
72
|
+
eventFormat: {
|
|
73
|
+
filtersByParty: params.filtersByParty,
|
|
74
|
+
filtersForAnyParty: params.filtersForAnyParty,
|
|
75
|
+
verbose: true,
|
|
76
|
+
},
|
|
77
|
+
activeAtOffset: params.activeAtOffset,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const response = await this.request<{
|
|
81
|
+
contractEntry?: Array<{ createdEvent?: CreatedEvent }>;
|
|
82
|
+
}>("POST", "/v2/state/active-contracts", body);
|
|
83
|
+
|
|
84
|
+
if (!response.contractEntry) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return response.contractEntry
|
|
89
|
+
.filter((entry) => entry.createdEvent != null)
|
|
90
|
+
.map((entry) => {
|
|
91
|
+
const evt = entry.createdEvent!;
|
|
92
|
+
return {
|
|
93
|
+
contractId: evt.contractId,
|
|
94
|
+
templateId: evt.templateId,
|
|
95
|
+
createArgument: evt.createArgument,
|
|
96
|
+
createdAt: "",
|
|
97
|
+
signatories: evt.signatories,
|
|
98
|
+
observers: evt.observers,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Transaction Lookup
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
async getTransactionById(updateId: string): Promise<TransactionTree | null> {
|
|
108
|
+
try {
|
|
109
|
+
return await this.request<TransactionTree>(
|
|
110
|
+
"GET",
|
|
111
|
+
`/v2/updates/transaction-by-id/${encodeURIComponent(updateId)}`,
|
|
112
|
+
);
|
|
113
|
+
} catch (err) {
|
|
114
|
+
if (err instanceof CantonApiError && err.code === "NOT_FOUND") {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Ledger State
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
async getLedgerEnd(): Promise<number> {
|
|
126
|
+
const response = await this.request<{ offset: number }>("GET", "/v2/state/ledger-end");
|
|
127
|
+
return response.offset;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Party Management
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
async allocateParty(hint: string): Promise<PartyDetails> {
|
|
135
|
+
const response = await this.request<{ partyDetails: PartyDetails }>("POST", "/v2/parties", {
|
|
136
|
+
partyIdHint: hint,
|
|
137
|
+
identityProviderId: "",
|
|
138
|
+
});
|
|
139
|
+
return response.partyDetails;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async listParties(): Promise<PartyDetails[]> {
|
|
143
|
+
const response = await this.request<{ partyDetails: PartyDetails[] }>("GET", "/v2/parties");
|
|
144
|
+
return response.partyDetails;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// Health Check
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
async isHealthy(): Promise<boolean> {
|
|
152
|
+
try {
|
|
153
|
+
const response = await fetch(`${this.ledgerUrl}/livez`, {
|
|
154
|
+
method: "GET",
|
|
155
|
+
headers: { Authorization: `Bearer ${this.token}` },
|
|
156
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
157
|
+
});
|
|
158
|
+
return response.ok;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
// Internal
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
169
|
+
const url = `${this.ledgerUrl}${path}`;
|
|
170
|
+
|
|
171
|
+
const headers: Record<string, string> = {
|
|
172
|
+
Authorization: `Bearer ${this.token}`,
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
if (body !== undefined) {
|
|
176
|
+
headers["Content-Type"] = "application/json";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let response: Response;
|
|
180
|
+
try {
|
|
181
|
+
response = await fetch(url, {
|
|
182
|
+
method,
|
|
183
|
+
headers,
|
|
184
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
185
|
+
signal: AbortSignal.timeout(this.timeout),
|
|
186
|
+
});
|
|
187
|
+
} catch (err: unknown) {
|
|
188
|
+
if (err instanceof DOMException && err.name === "TimeoutError") {
|
|
189
|
+
throw new CantonTimeoutError(path, this.timeout);
|
|
190
|
+
}
|
|
191
|
+
if (err instanceof DOMException && err.name === "AbortError") {
|
|
192
|
+
throw new CantonTimeoutError(path, this.timeout);
|
|
193
|
+
}
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (response.status === 401 || response.status === 403) {
|
|
198
|
+
const text = await response.text().catch(() => "");
|
|
199
|
+
throw new CantonAuthError(response.status, text || undefined);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (!response.ok) {
|
|
203
|
+
const errorBody = await response.json().catch(() => null);
|
|
204
|
+
if (errorBody && typeof errorBody === "object" && "code" in errorBody) {
|
|
205
|
+
throw new CantonApiError(errorBody as LedgerError);
|
|
206
|
+
}
|
|
207
|
+
throw new Error(`Canton API error: HTTP ${response.status} on ${method} ${path}`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Some endpoints (like livez) may return empty body
|
|
211
|
+
const text = await response.text();
|
|
212
|
+
if (!text) {
|
|
213
|
+
return {} as T;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return JSON.parse(text) as T;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canton SDK — Custom Error Classes
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CantonErrorCode, LedgerError } from "./types.js";
|
|
6
|
+
|
|
7
|
+
export class CantonApiError extends Error {
|
|
8
|
+
readonly code: CantonErrorCode;
|
|
9
|
+
readonly ledgerCause: string;
|
|
10
|
+
readonly grpcCodeValue: number;
|
|
11
|
+
readonly errorCategory: number;
|
|
12
|
+
readonly context: Record<string, string>;
|
|
13
|
+
|
|
14
|
+
constructor(ledgerError: LedgerError) {
|
|
15
|
+
super(`Canton API error [${ledgerError.code}]: ${ledgerError.cause}`);
|
|
16
|
+
this.name = "CantonApiError";
|
|
17
|
+
this.code = ledgerError.code;
|
|
18
|
+
this.ledgerCause = ledgerError.cause;
|
|
19
|
+
this.grpcCodeValue = ledgerError.grpcCodeValue;
|
|
20
|
+
this.errorCategory = ledgerError.errorCategory;
|
|
21
|
+
this.context = ledgerError.context;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class CantonTimeoutError extends Error {
|
|
26
|
+
readonly timeoutMs: number;
|
|
27
|
+
readonly path: string;
|
|
28
|
+
|
|
29
|
+
constructor(path: string, timeoutMs: number) {
|
|
30
|
+
super(`Canton request to ${path} timed out after ${timeoutMs}ms`);
|
|
31
|
+
this.name = "CantonTimeoutError";
|
|
32
|
+
this.timeoutMs = timeoutMs;
|
|
33
|
+
this.path = path;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class CantonAuthError extends Error {
|
|
38
|
+
readonly statusCode: number;
|
|
39
|
+
|
|
40
|
+
constructor(statusCode: number, message?: string) {
|
|
41
|
+
super(message ?? `Canton authentication failed (HTTP ${statusCode})`);
|
|
42
|
+
this.name = "CantonAuthError";
|
|
43
|
+
this.statusCode = statusCode;
|
|
44
|
+
}
|
|
45
|
+
}
|