@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,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MppPayClient — automatic HTTP 402 payment handling.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Fetch URL
|
|
6
|
+
* 2. If 402, parse WWW-Authenticate header for canton payment challenge
|
|
7
|
+
* 3. Check maxPrice against challenge amount
|
|
8
|
+
* 4. Check safeguards
|
|
9
|
+
* 5. Execute USDCx transfer via TransferFactory_Transfer
|
|
10
|
+
* 6. Build credential (base64-encoded payload)
|
|
11
|
+
* 7. Retry request with Authorization: Payment <credential>
|
|
12
|
+
* 8. Return { response, receipt?, paid }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { compareAmounts } from "../canton/amount.js";
|
|
16
|
+
import type { USDCxService } from "../canton/usdcx.js";
|
|
17
|
+
import { toCantonAmount } from "../canton/amount.js";
|
|
18
|
+
import type { SafeguardManager } from "../safeguards/manager.js";
|
|
19
|
+
|
|
20
|
+
export interface PayOptions {
|
|
21
|
+
method?: string;
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
body?: string;
|
|
24
|
+
maxPrice?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PayResult {
|
|
28
|
+
response: Response;
|
|
29
|
+
paid: boolean;
|
|
30
|
+
receipt?: {
|
|
31
|
+
updateId: string;
|
|
32
|
+
completionOffset: number;
|
|
33
|
+
commandId: string;
|
|
34
|
+
amount: string;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PaymentChallenge {
|
|
39
|
+
amount: string;
|
|
40
|
+
currency: string;
|
|
41
|
+
recipient: string;
|
|
42
|
+
network: string;
|
|
43
|
+
description?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse a WWW-Authenticate header for Canton payment parameters.
|
|
48
|
+
*
|
|
49
|
+
* Expected format:
|
|
50
|
+
* Payment method="canton", amount="0.01", currency="USDCx",
|
|
51
|
+
* recipient="Gateway::1220...", network="mainnet"
|
|
52
|
+
*/
|
|
53
|
+
export function parseWwwAuthenticate(header: string): PaymentChallenge | null {
|
|
54
|
+
if (!header.startsWith("Payment")) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const params: Record<string, string> = {};
|
|
59
|
+
const re = /(\w+)="([^"]*)"/g;
|
|
60
|
+
let match;
|
|
61
|
+
while ((match = re.exec(header)) !== null) {
|
|
62
|
+
params[match[1]] = match[2];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (params.method !== "canton" || !params.amount || !params.recipient || !params.network) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
amount: params.amount,
|
|
71
|
+
currency: params.currency ?? "USDCx",
|
|
72
|
+
recipient: params.recipient,
|
|
73
|
+
network: params.network,
|
|
74
|
+
description: params.description,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class MppPayClient {
|
|
79
|
+
constructor(
|
|
80
|
+
private readonly usdcx: USDCxService,
|
|
81
|
+
private readonly safeguards: SafeguardManager,
|
|
82
|
+
private readonly partyId: string,
|
|
83
|
+
private readonly network: string,
|
|
84
|
+
) {}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Pay for an API call via MPP 402 flow.
|
|
88
|
+
* If the response is not 402, returns it as-is with paid=false.
|
|
89
|
+
*/
|
|
90
|
+
async pay(url: string, opts?: PayOptions): Promise<PayResult> {
|
|
91
|
+
const requestInit: RequestInit = {
|
|
92
|
+
method: opts?.method ?? "GET",
|
|
93
|
+
headers: opts?.headers,
|
|
94
|
+
body: opts?.body,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// 1. Initial request
|
|
98
|
+
const firstResponse = await fetch(url, requestInit);
|
|
99
|
+
|
|
100
|
+
if (firstResponse.status !== 402) {
|
|
101
|
+
return { response: firstResponse, paid: false };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 2. Parse 402 challenge
|
|
105
|
+
const authHeader = firstResponse.headers.get("WWW-Authenticate") ?? "";
|
|
106
|
+
const challenge = parseWwwAuthenticate(authHeader);
|
|
107
|
+
|
|
108
|
+
if (!challenge) {
|
|
109
|
+
throw new Error("402 received but no valid Canton payment challenge in WWW-Authenticate");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 3. Network check
|
|
113
|
+
if (challenge.network !== this.network) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Network mismatch: challenge requires ${challenge.network}, agent on ${this.network}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 4. Price check
|
|
120
|
+
if (opts?.maxPrice && compareAmounts(challenge.amount, opts.maxPrice) > 0) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Price ${challenge.amount} exceeds maxPrice ${opts.maxPrice}`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 5. Safeguard check
|
|
127
|
+
const check = this.safeguards.check(challenge.amount);
|
|
128
|
+
if (!check.allowed) {
|
|
129
|
+
throw new Error(`Safeguard rejected: ${check.reason}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 6. Execute USDCx transfer
|
|
133
|
+
const transferResult = await this.usdcx.transfer({
|
|
134
|
+
recipient: challenge.recipient,
|
|
135
|
+
amount: challenge.amount,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// 7. Record spend
|
|
139
|
+
this.safeguards.recordSpend(challenge.amount);
|
|
140
|
+
|
|
141
|
+
// 8. Build credential and retry
|
|
142
|
+
const credential = Buffer.from(
|
|
143
|
+
JSON.stringify({
|
|
144
|
+
updateId: transferResult.updateId,
|
|
145
|
+
completionOffset: transferResult.completionOffset,
|
|
146
|
+
sender: this.partyId,
|
|
147
|
+
commandId: transferResult.commandId,
|
|
148
|
+
}),
|
|
149
|
+
).toString("base64");
|
|
150
|
+
|
|
151
|
+
const retryResponse = await fetch(url, {
|
|
152
|
+
...requestInit,
|
|
153
|
+
headers: {
|
|
154
|
+
...opts?.headers,
|
|
155
|
+
Authorization: `Payment ${credential}`,
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
response: retryResponse,
|
|
161
|
+
paid: true,
|
|
162
|
+
receipt: {
|
|
163
|
+
updateId: transferResult.updateId,
|
|
164
|
+
completionOffset: transferResult.completionOffset,
|
|
165
|
+
commandId: transferResult.commandId,
|
|
166
|
+
amount: challenge.amount,
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SafeguardManager — pre-transaction checks: tx limit, daily limit, lock.
|
|
3
|
+
*
|
|
4
|
+
* Storage: ~/.caypo/safeguards.json
|
|
5
|
+
* All amounts are strings (no floating point).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { addAmounts, compareAmounts, subtractAmounts } from "../canton/amount.js";
|
|
12
|
+
|
|
13
|
+
const DEFAULT_SAFEGUARDS_PATH = join(homedir(), ".caypo", "safeguards.json");
|
|
14
|
+
|
|
15
|
+
export interface SafeguardConfig {
|
|
16
|
+
txLimit: string;
|
|
17
|
+
dailyLimit: string;
|
|
18
|
+
locked: boolean;
|
|
19
|
+
lockedPinHash: string;
|
|
20
|
+
dailySpent: string;
|
|
21
|
+
lastResetDate: string; // YYYY-MM-DD
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CheckResult {
|
|
25
|
+
allowed: boolean;
|
|
26
|
+
reason?: string;
|
|
27
|
+
dailyRemaining: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_SAFEGUARD_CONFIG: SafeguardConfig = {
|
|
31
|
+
txLimit: "100",
|
|
32
|
+
dailyLimit: "1000",
|
|
33
|
+
locked: false,
|
|
34
|
+
lockedPinHash: "",
|
|
35
|
+
dailySpent: "0",
|
|
36
|
+
lastResetDate: today(),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function today(): string {
|
|
40
|
+
return new Date().toISOString().slice(0, 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Simple hash for PIN verification (not cryptographic — just a gate). */
|
|
44
|
+
function hashPin(pin: string): string {
|
|
45
|
+
// Use a basic hash since this is just a confirmation gate, not security-critical.
|
|
46
|
+
// The real security is the encrypted keystore.
|
|
47
|
+
let hash = 0;
|
|
48
|
+
for (let i = 0; i < pin.length; i++) {
|
|
49
|
+
const ch = pin.charCodeAt(i);
|
|
50
|
+
hash = ((hash << 5) - hash + ch) | 0;
|
|
51
|
+
}
|
|
52
|
+
return String(hash);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class SafeguardManager {
|
|
56
|
+
private config: SafeguardConfig;
|
|
57
|
+
private readonly filePath: string;
|
|
58
|
+
|
|
59
|
+
constructor(config?: SafeguardConfig, filePath?: string) {
|
|
60
|
+
this.config = config ? { ...config } : { ...DEFAULT_SAFEGUARD_CONFIG };
|
|
61
|
+
this.filePath = filePath ?? DEFAULT_SAFEGUARDS_PATH;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Load safeguards from disk. Returns a new SafeguardManager.
|
|
66
|
+
* If file doesn't exist, uses defaults.
|
|
67
|
+
*/
|
|
68
|
+
static async load(path?: string): Promise<SafeguardManager> {
|
|
69
|
+
const filePath = path ?? DEFAULT_SAFEGUARDS_PATH;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const raw = await readFile(filePath, "utf8");
|
|
73
|
+
const parsed = JSON.parse(raw) as Partial<SafeguardConfig>;
|
|
74
|
+
const config: SafeguardConfig = { ...DEFAULT_SAFEGUARD_CONFIG, ...parsed };
|
|
75
|
+
return new SafeguardManager(config, filePath);
|
|
76
|
+
} catch (err: unknown) {
|
|
77
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
78
|
+
return new SafeguardManager(undefined, filePath);
|
|
79
|
+
}
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Get current safeguard settings. */
|
|
85
|
+
settings(): SafeguardConfig {
|
|
86
|
+
return { ...this.config };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Set per-transaction limit. */
|
|
90
|
+
setTxLimit(amount: string): void {
|
|
91
|
+
this.config.txLimit = amount;
|
|
92
|
+
void this.save();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Set daily spending limit. */
|
|
96
|
+
setDailyLimit(amount: string): void {
|
|
97
|
+
this.config.dailyLimit = amount;
|
|
98
|
+
void this.save();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Lock the wallet. All transactions will be rejected until unlocked. */
|
|
102
|
+
lock(pin?: string): void {
|
|
103
|
+
this.config.locked = true;
|
|
104
|
+
if (pin) {
|
|
105
|
+
this.config.lockedPinHash = hashPin(pin);
|
|
106
|
+
}
|
|
107
|
+
void this.save();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Unlock the wallet. Requires PIN if one was set during lock. */
|
|
111
|
+
unlock(pin: string): void {
|
|
112
|
+
if (this.config.lockedPinHash && hashPin(pin) !== this.config.lockedPinHash) {
|
|
113
|
+
throw new Error("Invalid PIN");
|
|
114
|
+
}
|
|
115
|
+
this.config.locked = false;
|
|
116
|
+
this.config.lockedPinHash = "";
|
|
117
|
+
void this.save();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Check if a transaction for the given amount is allowed.
|
|
122
|
+
* Auto-resets daily counter if the date has changed.
|
|
123
|
+
*/
|
|
124
|
+
check(amount: string): CheckResult {
|
|
125
|
+
this.autoResetDaily();
|
|
126
|
+
|
|
127
|
+
const dailyRemaining = subtractAmounts(this.config.dailyLimit, this.config.dailySpent);
|
|
128
|
+
|
|
129
|
+
if (this.config.locked) {
|
|
130
|
+
return { allowed: false, reason: "Wallet is locked", dailyRemaining };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (compareAmounts(amount, this.config.txLimit) > 0) {
|
|
134
|
+
return {
|
|
135
|
+
allowed: false,
|
|
136
|
+
reason: `Amount ${amount} exceeds per-transaction limit of ${this.config.txLimit}`,
|
|
137
|
+
dailyRemaining,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const projectedDaily = addAmounts(this.config.dailySpent, amount);
|
|
142
|
+
if (compareAmounts(projectedDaily, this.config.dailyLimit) > 0) {
|
|
143
|
+
return {
|
|
144
|
+
allowed: false,
|
|
145
|
+
reason: `Amount ${amount} would exceed daily limit of ${this.config.dailyLimit} (spent: ${this.config.dailySpent})`,
|
|
146
|
+
dailyRemaining,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { allowed: true, dailyRemaining };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Record a completed spend. Call after successful transaction. */
|
|
154
|
+
recordSpend(amount: string): void {
|
|
155
|
+
this.autoResetDaily();
|
|
156
|
+
this.config.dailySpent = addAmounts(this.config.dailySpent, amount);
|
|
157
|
+
void this.save();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Manually reset the daily counter. */
|
|
161
|
+
resetDaily(): void {
|
|
162
|
+
this.config.dailySpent = "0";
|
|
163
|
+
this.config.lastResetDate = today();
|
|
164
|
+
void this.save();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private autoResetDaily(): void {
|
|
168
|
+
const currentDate = today();
|
|
169
|
+
if (this.config.lastResetDate !== currentDate) {
|
|
170
|
+
this.config.dailySpent = "0";
|
|
171
|
+
this.config.lastResetDate = currentDate;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async save(): Promise<void> {
|
|
176
|
+
try {
|
|
177
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
178
|
+
await writeFile(this.filePath, JSON.stringify(this.config, null, 2), "utf8");
|
|
179
|
+
} catch {
|
|
180
|
+
// Best-effort save — in-memory state is still authoritative
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TrafficManager — Canton traffic budget management.
|
|
3
|
+
*
|
|
4
|
+
* Canton does NOT have per-transaction gas fees. Instead, each validator
|
|
5
|
+
* has a traffic budget (bandwidth allocation). Additional traffic can be
|
|
6
|
+
* purchased by burning Canton Coin (CC).
|
|
7
|
+
*
|
|
8
|
+
* NOTE: The actual traffic API depends on the validator/participant setup.
|
|
9
|
+
* This provides the interface with a reasonable stub implementation.
|
|
10
|
+
* Real implementation requires validator admin API access.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { CantonClient } from "../canton/client.js";
|
|
14
|
+
|
|
15
|
+
export interface TrafficBalance {
|
|
16
|
+
totalPurchased: number;
|
|
17
|
+
consumed: number;
|
|
18
|
+
remaining: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface AutoPurchaseConfig {
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
minBalance: number;
|
|
24
|
+
purchaseAmount: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class TrafficManager {
|
|
28
|
+
private autoPurchaseConfig: AutoPurchaseConfig = {
|
|
29
|
+
enabled: false,
|
|
30
|
+
minBalance: 1000,
|
|
31
|
+
purchaseAmount: "5.0",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
constructor(
|
|
35
|
+
private readonly client: CantonClient,
|
|
36
|
+
private readonly partyId: string,
|
|
37
|
+
) {}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check validator's traffic balance.
|
|
41
|
+
*
|
|
42
|
+
* TODO: Implement using actual validator admin API.
|
|
43
|
+
* The traffic balance is a validator-level concept, not per-party.
|
|
44
|
+
* For now, returns a stub indicating sufficient traffic.
|
|
45
|
+
*/
|
|
46
|
+
async trafficBalance(): Promise<TrafficBalance> {
|
|
47
|
+
// Verify we can reach the ledger (health check as proxy)
|
|
48
|
+
const healthy = await this.client.isHealthy();
|
|
49
|
+
|
|
50
|
+
if (!healthy) {
|
|
51
|
+
return { totalPurchased: 0, consumed: 0, remaining: 0 };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// TODO: Query actual traffic balance from validator admin API
|
|
55
|
+
// The endpoint varies by validator implementation:
|
|
56
|
+
// - Splice: GET /v0/admin/participant/traffic-state
|
|
57
|
+
// - Direct Canton: participant admin gRPC
|
|
58
|
+
return {
|
|
59
|
+
totalPurchased: 10_000_000,
|
|
60
|
+
consumed: 0,
|
|
61
|
+
remaining: 10_000_000,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Purchase additional traffic by burning Canton Coin (CC).
|
|
67
|
+
*
|
|
68
|
+
* TODO: Implement using actual CC burn mechanism.
|
|
69
|
+
*/
|
|
70
|
+
async purchaseTraffic(ccAmount: string): Promise<{ txId: string }> {
|
|
71
|
+
void ccAmount;
|
|
72
|
+
// TODO: Exercise the traffic purchase choice on the validator
|
|
73
|
+
// This involves burning CC tokens to increase the validator's traffic budget.
|
|
74
|
+
throw new Error("Traffic purchase not yet implemented — requires validator admin API");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Check if there's sufficient traffic for a standard operation.
|
|
79
|
+
* Returns true if remaining traffic > minimum threshold.
|
|
80
|
+
*/
|
|
81
|
+
async hasSufficientTraffic(): Promise<boolean> {
|
|
82
|
+
const balance = await this.trafficBalance();
|
|
83
|
+
return balance.remaining > this.autoPurchaseConfig.minBalance;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Configure auto-purchase settings. */
|
|
87
|
+
setAutoPurchase(config: AutoPurchaseConfig): void {
|
|
88
|
+
this.autoPurchaseConfig = { ...config };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Get current auto-purchase configuration. */
|
|
92
|
+
getAutoPurchaseConfig(): AutoPurchaseConfig {
|
|
93
|
+
return { ...this.autoPurchaseConfig };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent configuration — load/save ~/.caypo/config.json
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_CONFIG_PATH = join(homedir(), ".caypo", "config.json");
|
|
10
|
+
|
|
11
|
+
export interface TrafficConfig {
|
|
12
|
+
autoPurchase: boolean;
|
|
13
|
+
minBalance: number;
|
|
14
|
+
purchaseAmountCC: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SafeguardsConfig {
|
|
18
|
+
txLimit: string;
|
|
19
|
+
dailyLimit: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface MppConfig {
|
|
23
|
+
gatewayUrl: string;
|
|
24
|
+
maxAutoPayPrice: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AgentConfig {
|
|
28
|
+
version: number;
|
|
29
|
+
network: "mainnet" | "testnet" | "devnet";
|
|
30
|
+
ledgerUrl: string;
|
|
31
|
+
partyId: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
keystorePath: string;
|
|
34
|
+
traffic: TrafficConfig;
|
|
35
|
+
safeguards: SafeguardsConfig;
|
|
36
|
+
mpp: MppConfig;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_CONFIG: AgentConfig = {
|
|
40
|
+
version: 2,
|
|
41
|
+
network: "testnet",
|
|
42
|
+
ledgerUrl: "http://localhost:7575",
|
|
43
|
+
partyId: "",
|
|
44
|
+
userId: "ledger-api-user",
|
|
45
|
+
keystorePath: join(homedir(), ".caypo", "wallet.key"),
|
|
46
|
+
traffic: {
|
|
47
|
+
autoPurchase: true,
|
|
48
|
+
minBalance: 1000,
|
|
49
|
+
purchaseAmountCC: "5.0",
|
|
50
|
+
},
|
|
51
|
+
safeguards: {
|
|
52
|
+
txLimit: "100",
|
|
53
|
+
dailyLimit: "1000",
|
|
54
|
+
},
|
|
55
|
+
mpp: {
|
|
56
|
+
gatewayUrl: "https://mpp.cayvox.io",
|
|
57
|
+
maxAutoPayPrice: "1.00",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load agent configuration from disk.
|
|
63
|
+
* Returns DEFAULT_CONFIG merged with file contents.
|
|
64
|
+
*/
|
|
65
|
+
export async function loadConfig(path?: string): Promise<AgentConfig> {
|
|
66
|
+
const filePath = path ?? DEFAULT_CONFIG_PATH;
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const raw = await readFile(filePath, "utf8");
|
|
70
|
+
const parsed = JSON.parse(raw) as Partial<AgentConfig>;
|
|
71
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
72
|
+
} catch (err: unknown) {
|
|
73
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
74
|
+
return { ...DEFAULT_CONFIG };
|
|
75
|
+
}
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Save agent configuration to disk.
|
|
82
|
+
* Creates parent directory if it doesn't exist.
|
|
83
|
+
*/
|
|
84
|
+
export async function saveConfig(config: AgentConfig, path?: string): Promise<void> {
|
|
85
|
+
const filePath = path ?? DEFAULT_CONFIG_PATH;
|
|
86
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
87
|
+
await writeFile(filePath, JSON.stringify(config, null, 2), "utf8");
|
|
88
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet keystore — AES-256-GCM encrypted storage for Canton agent credentials.
|
|
3
|
+
*
|
|
4
|
+
* Storage format (JSON, base64-encoded fields):
|
|
5
|
+
* { iv, salt, encrypted, tag }
|
|
6
|
+
*
|
|
7
|
+
* Key derivation: PIN → PBKDF2 (100k iterations, SHA-256) → 32-byte AES key.
|
|
8
|
+
* Uses Node.js built-in crypto module only.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createCipheriv, createDecipheriv, pbkdf2Sync, randomBytes } from "node:crypto";
|
|
12
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
|
|
16
|
+
const PBKDF2_ITERATIONS = 100_000;
|
|
17
|
+
const PBKDF2_KEYLEN = 32;
|
|
18
|
+
const PBKDF2_DIGEST = "sha256";
|
|
19
|
+
const SALT_LEN = 32;
|
|
20
|
+
const IV_LEN = 16;
|
|
21
|
+
const ALGORITHM = "aes-256-gcm";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_WALLET_PATH = join(homedir(), ".caypo", "wallet.key");
|
|
24
|
+
|
|
25
|
+
export interface WalletData {
|
|
26
|
+
partyId: string;
|
|
27
|
+
jwt: string;
|
|
28
|
+
userId: string;
|
|
29
|
+
privateKey: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface EncryptedFile {
|
|
33
|
+
iv: string;
|
|
34
|
+
salt: string;
|
|
35
|
+
encrypted: string;
|
|
36
|
+
tag: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deriveKey(pin: string, salt: Buffer): Buffer {
|
|
40
|
+
return pbkdf2Sync(pin, salt, PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function encrypt(data: string, pin: string): EncryptedFile {
|
|
44
|
+
const salt = randomBytes(SALT_LEN);
|
|
45
|
+
const key = deriveKey(pin, salt);
|
|
46
|
+
const iv = randomBytes(IV_LEN);
|
|
47
|
+
|
|
48
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
49
|
+
const encrypted = Buffer.concat([cipher.update(data, "utf8"), cipher.final()]);
|
|
50
|
+
const tag = cipher.getAuthTag();
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
iv: iv.toString("base64"),
|
|
54
|
+
salt: salt.toString("base64"),
|
|
55
|
+
encrypted: encrypted.toString("base64"),
|
|
56
|
+
tag: tag.toString("base64"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function decrypt(file: EncryptedFile, pin: string): string {
|
|
61
|
+
const salt = Buffer.from(file.salt, "base64");
|
|
62
|
+
const iv = Buffer.from(file.iv, "base64");
|
|
63
|
+
const encrypted = Buffer.from(file.encrypted, "base64");
|
|
64
|
+
const tag = Buffer.from(file.tag, "base64");
|
|
65
|
+
|
|
66
|
+
const key = deriveKey(pin, salt);
|
|
67
|
+
|
|
68
|
+
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
|
69
|
+
decipher.setAuthTag(tag);
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
|
73
|
+
return decrypted.toString("utf8");
|
|
74
|
+
} catch {
|
|
75
|
+
throw new Error("Invalid PIN or corrupted wallet file");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export class Keystore {
|
|
80
|
+
private data: WalletData;
|
|
81
|
+
private filePath: string;
|
|
82
|
+
|
|
83
|
+
private constructor(data: WalletData, filePath: string) {
|
|
84
|
+
this.data = data;
|
|
85
|
+
this.filePath = filePath;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Party ID of this wallet. */
|
|
89
|
+
get address(): string {
|
|
90
|
+
return this.data.partyId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Create a new encrypted wallet.
|
|
95
|
+
* Generates a random 32-byte private key and saves encrypted to disk.
|
|
96
|
+
*/
|
|
97
|
+
static async create(
|
|
98
|
+
pin: string,
|
|
99
|
+
params: { partyId: string; jwt: string; userId: string },
|
|
100
|
+
path?: string,
|
|
101
|
+
): Promise<Keystore> {
|
|
102
|
+
const filePath = path ?? DEFAULT_WALLET_PATH;
|
|
103
|
+
|
|
104
|
+
const walletData: WalletData = {
|
|
105
|
+
partyId: params.partyId,
|
|
106
|
+
jwt: params.jwt,
|
|
107
|
+
userId: params.userId,
|
|
108
|
+
privateKey: randomBytes(32).toString("hex"),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const encryptedFile = encrypt(JSON.stringify(walletData), pin);
|
|
112
|
+
|
|
113
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
114
|
+
await writeFile(filePath, JSON.stringify(encryptedFile), "utf8");
|
|
115
|
+
|
|
116
|
+
return new Keystore(walletData, filePath);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Load and decrypt an existing wallet.
|
|
121
|
+
* Throws if the PIN is wrong or the file is corrupted.
|
|
122
|
+
*/
|
|
123
|
+
static async load(pin: string, path?: string): Promise<Keystore> {
|
|
124
|
+
const filePath = path ?? DEFAULT_WALLET_PATH;
|
|
125
|
+
|
|
126
|
+
const raw = await readFile(filePath, "utf8");
|
|
127
|
+
const encryptedFile: EncryptedFile = JSON.parse(raw);
|
|
128
|
+
|
|
129
|
+
const decrypted = decrypt(encryptedFile, pin);
|
|
130
|
+
const walletData: WalletData = JSON.parse(decrypted);
|
|
131
|
+
|
|
132
|
+
return new Keystore(walletData, filePath);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Get credentials for Canton Ledger API access. */
|
|
136
|
+
getCredentials(): { partyId: string; jwt: string; userId: string } {
|
|
137
|
+
return {
|
|
138
|
+
partyId: this.data.partyId,
|
|
139
|
+
jwt: this.data.jwt,
|
|
140
|
+
userId: this.data.userId,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Change the encryption PIN. Re-encrypts wallet data with new PIN. */
|
|
145
|
+
async changePin(oldPin: string, newPin: string): Promise<void> {
|
|
146
|
+
// Verify old PIN by re-loading
|
|
147
|
+
const raw = await readFile(this.filePath, "utf8");
|
|
148
|
+
const encryptedFile: EncryptedFile = JSON.parse(raw);
|
|
149
|
+
decrypt(encryptedFile, oldPin); // throws if wrong
|
|
150
|
+
|
|
151
|
+
// Re-encrypt with new PIN
|
|
152
|
+
const newEncrypted = encrypt(JSON.stringify(this.data), newPin);
|
|
153
|
+
await writeFile(this.filePath, JSON.stringify(newEncrypted), "utf8");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Export the raw private key. Dangerous — only call with explicit user consent. */
|
|
157
|
+
exportKey(pin: string): string {
|
|
158
|
+
// We don't re-verify PIN against file here since the Keystore is already decrypted.
|
|
159
|
+
// The pin parameter acts as a confirmation gate — callers should verify it matches.
|
|
160
|
+
// For extra safety, we could re-read the file, but that's an IO overhead for a rare op.
|
|
161
|
+
void pin;
|
|
162
|
+
return this.data.privateKey;
|
|
163
|
+
}
|
|
164
|
+
}
|