@caypo/mpp-canton 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 +40 -0
- package/.turbo/turbo-test.log +16 -0
- package/README.md +104 -0
- package/SPEC.md +269 -0
- package/dist/chunk-5CWLHTUR.js +111 -0
- package/dist/chunk-5CWLHTUR.js.map +1 -0
- package/dist/chunk-757U7PM3.js +140 -0
- package/dist/chunk-757U7PM3.js.map +1 -0
- package/dist/chunk-NTWNP6H5.js +43 -0
- package/dist/chunk-NTWNP6H5.js.map +1 -0
- package/dist/client.cjs +200 -0
- package/dist/client.cjs.map +1 -0
- package/dist/client.d.cts +24 -0
- package/dist/client.d.ts +24 -0
- package/dist/client.js +8 -0
- package/dist/client.js.map +1 -0
- package/dist/index.cjs +319 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +84 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/server.cjs +172 -0
- package/dist/server.cjs.map +1 -0
- package/dist/server.d.cts +28 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.js +10 -0
- package/dist/server.js.map +1 -0
- package/package.json +64 -0
- package/src/__tests__/client.test.ts +216 -0
- package/src/__tests__/method.test.ts +140 -0
- package/src/__tests__/server.test.ts +361 -0
- package/src/client.ts +198 -0
- package/src/index.ts +29 -0
- package/src/method.ts +21 -0
- package/src/schemas.ts +33 -0
- package/src/server.ts +178 -0
- package/tsconfig.json +8 -0
- package/tsup.config.ts +14 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
|
|
2
|
+
> @caypo/mpp-canton@0.1.0 build /Users/anil/Desktop/caypo/packages/mpp
|
|
3
|
+
> tsup
|
|
4
|
+
|
|
5
|
+
CLI Building entry: src/client.ts, src/index.ts, src/server.ts
|
|
6
|
+
CLI Using tsconfig: tsconfig.json
|
|
7
|
+
CLI tsup v8.5.1
|
|
8
|
+
CLI Using tsup config: /Users/anil/Desktop/caypo/packages/mpp/tsup.config.ts
|
|
9
|
+
CLI Target: es2022
|
|
10
|
+
CLI Cleaning output folder
|
|
11
|
+
ESM Build start
|
|
12
|
+
CJS Build start
|
|
13
|
+
ESM dist/index.js 497.00 B
|
|
14
|
+
ESM dist/chunk-NTWNP6H5.js 1.14 KB
|
|
15
|
+
ESM dist/server.js 193.00 B
|
|
16
|
+
ESM dist/chunk-757U7PM3.js 4.17 KB
|
|
17
|
+
ESM dist/client.js 145.00 B
|
|
18
|
+
ESM dist/chunk-5CWLHTUR.js 3.31 KB
|
|
19
|
+
ESM dist/index.js.map 893.00 B
|
|
20
|
+
ESM dist/chunk-NTWNP6H5.js.map 2.76 KB
|
|
21
|
+
ESM dist/client.js.map 71.00 B
|
|
22
|
+
ESM dist/chunk-757U7PM3.js.map 8.67 KB
|
|
23
|
+
ESM dist/server.js.map 71.00 B
|
|
24
|
+
ESM dist/chunk-5CWLHTUR.js.map 7.01 KB
|
|
25
|
+
ESM ⚡️ Build success in 10ms
|
|
26
|
+
CJS dist/server.cjs 5.57 KB
|
|
27
|
+
CJS dist/index.cjs 10.04 KB
|
|
28
|
+
CJS dist/client.cjs 6.38 KB
|
|
29
|
+
CJS dist/server.cjs.map 9.75 KB
|
|
30
|
+
CJS dist/index.cjs.map 19.18 KB
|
|
31
|
+
CJS dist/client.cjs.map 11.40 KB
|
|
32
|
+
CJS ⚡️ Build success in 10ms
|
|
33
|
+
DTS Build start
|
|
34
|
+
DTS ⚡️ Build success in 997ms
|
|
35
|
+
DTS dist/index.d.ts 2.60 KB
|
|
36
|
+
DTS dist/client.d.ts 839.00 B
|
|
37
|
+
DTS dist/server.d.ts 971.00 B
|
|
38
|
+
DTS dist/index.d.cts 2.60 KB
|
|
39
|
+
DTS dist/client.d.cts 839.00 B
|
|
40
|
+
DTS dist/server.d.cts 971.00 B
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
|
|
2
|
+
> @caypo/mpp-canton@0.1.0 test /Users/anil/Desktop/caypo/packages/mpp
|
|
3
|
+
> vitest run
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
RUN v3.2.4 /Users/anil/Desktop/caypo/packages/mpp
|
|
7
|
+
|
|
8
|
+
✓ src/__tests__/method.test.ts (19 tests) 5ms
|
|
9
|
+
✓ src/__tests__/client.test.ts (6 tests) 18ms
|
|
10
|
+
✓ src/__tests__/server.test.ts (10 tests) 18ms
|
|
11
|
+
|
|
12
|
+
Test Files 3 passed (3)
|
|
13
|
+
Tests 35 passed (35)
|
|
14
|
+
Start at 19:11:45
|
|
15
|
+
Duration 296ms (transform 89ms, setup 0ms, collect 158ms, tests 41ms, environment 0ms, prepare 141ms)
|
|
16
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# @caypo/mpp-canton
|
|
2
|
+
|
|
3
|
+
**Canton Network payment method for the [Machine Payments Protocol (MPP)](https://mpp.dev)**
|
|
4
|
+
|
|
5
|
+
Accept and make USDCx payments in any HTTP API using Canton's CIP-56 token standard with 1-step TransferPreapproval transfers.
|
|
6
|
+
|
|
7
|
+
[](../../LICENSE-APACHE)
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install @caypo/mpp-canton
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Accept Payments (Server)
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
import { cantonServer } from "@caypo/mpp-canton/server";
|
|
19
|
+
|
|
20
|
+
const server = cantonServer({
|
|
21
|
+
ledgerUrl: "http://localhost:7575",
|
|
22
|
+
token: process.env.CANTON_JWT,
|
|
23
|
+
userId: "ledger-api-user",
|
|
24
|
+
recipientPartyId: "Gateway::1220...",
|
|
25
|
+
network: "mainnet",
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// When agent submits credential after paying:
|
|
29
|
+
const receipt = await server.verify({ credential });
|
|
30
|
+
// { method: "canton", reference: "upd-abc123", status: "success" }
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Make Payments (Client)
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { cantonClient } from "@caypo/mpp-canton/client";
|
|
37
|
+
|
|
38
|
+
const client = cantonClient({
|
|
39
|
+
ledgerUrl: "http://localhost:7575",
|
|
40
|
+
token: process.env.CANTON_JWT,
|
|
41
|
+
userId: "ledger-api-user",
|
|
42
|
+
partyId: "Agent::1220...",
|
|
43
|
+
network: "mainnet",
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// When receiving a 402 challenge:
|
|
47
|
+
const credential = await client.createCredential({ challenge });
|
|
48
|
+
// Base64-encoded credential with updateId, completionOffset, sender, commandId
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Method Definition
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { cantonMethod } from "@caypo/mpp-canton";
|
|
55
|
+
|
|
56
|
+
cantonMethod.name; // "canton"
|
|
57
|
+
cantonMethod.intent; // "charge"
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Schemas
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { requestSchema, credentialPayloadSchema } from "@caypo/mpp-canton";
|
|
64
|
+
|
|
65
|
+
// Validate payment requests
|
|
66
|
+
requestSchema.parse({ amount: "0.01", currency: "USDCx", recipient: "...", network: "mainnet" });
|
|
67
|
+
|
|
68
|
+
// Validate credentials
|
|
69
|
+
credentialPayloadSchema.parse({ updateId: "...", completionOffset: 42, sender: "...", commandId: "..." });
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Error Handling
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
import { MppVerificationError } from "@caypo/mpp-canton";
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
await server.verify({ credential });
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err instanceof MppVerificationError) {
|
|
81
|
+
console.log(err.problemCode); // "verification-failed" | "payment-insufficient"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## How It Works
|
|
87
|
+
|
|
88
|
+
The Canton MPP method uses **CIP-56 TransferPreapproval** for 1-step transfers:
|
|
89
|
+
|
|
90
|
+
1. Service returns `402` with `WWW-Authenticate: Payment method="canton", amount="0.01", recipient="...", network="..."`
|
|
91
|
+
2. Agent exercises `TransferFactory_Transfer` on Canton (requires recipient's TransferPreapproval)
|
|
92
|
+
3. Agent builds credential with `{ updateId, completionOffset, sender, commandId }`
|
|
93
|
+
4. Service verifies by fetching the transaction from Canton ledger
|
|
94
|
+
5. Service confirms: Holding created for recipient, amount >= required, correct sender
|
|
95
|
+
|
|
96
|
+
## Links
|
|
97
|
+
|
|
98
|
+
- [MPP Protocol](https://mpp.dev) — Machine Payments Protocol by Stripe and Tempo
|
|
99
|
+
- [Canton Network](https://canton.network) — Privacy-enabled institutional blockchain
|
|
100
|
+
- [CIP-56](https://canton.network) — Canton token standard
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
Apache-2.0 OR MIT
|
package/SPEC.md
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# @cayvox/mpp-canton — MPP Payment Method Specification (Production)
|
|
2
|
+
|
|
3
|
+
## Transfer Flow for MPP
|
|
4
|
+
|
|
5
|
+
**The critical insight:** The MPP gateway server MUST have a `TransferPreapproval` contract active. This enables 1-step transfers from agents — no receiver acceptance needed.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Agent Gateway (Canton) Canton Ledger
|
|
9
|
+
| | |
|
|
10
|
+
|-- GET /api/data ------------->| |
|
|
11
|
+
|<-- 402 + Challenge -----------| |
|
|
12
|
+
| | |
|
|
13
|
+
| (Agent exercises TransferFactory_Transfer |
|
|
14
|
+
| using gateway's TransferPreapproval) |
|
|
15
|
+
| | |
|
|
16
|
+
|-- POST /v2/commands/submit-and-wait -------------------------------->|
|
|
17
|
+
|<-- { updateId, completionOffset } ----------------------------------|
|
|
18
|
+
| | |
|
|
19
|
+
|-- GET /api/data + credential->| |
|
|
20
|
+
| |-- verify via /v2/updates/... ----->|
|
|
21
|
+
| |<-- transaction tree ---------------|
|
|
22
|
+
|<-- 200 + Receipt + data ------| |
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Method Definition
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { Method, z } from "mppx";
|
|
29
|
+
|
|
30
|
+
export const cantonMethod = Method.from({
|
|
31
|
+
intent: "charge",
|
|
32
|
+
name: "canton",
|
|
33
|
+
schema: {
|
|
34
|
+
credential: {
|
|
35
|
+
payload: z.object({
|
|
36
|
+
updateId: z.string().min(1),
|
|
37
|
+
completionOffset: z.number().int(),
|
|
38
|
+
sender: z.string().min(1), // e.g., "Agent::1220abcd..."
|
|
39
|
+
commandId: z.string().min(1),
|
|
40
|
+
}),
|
|
41
|
+
},
|
|
42
|
+
request: z.object({
|
|
43
|
+
amount: z.string().regex(/^\d+\.?\d{0,10}$/),
|
|
44
|
+
currency: z.enum(["USDCx", "CC"]),
|
|
45
|
+
recipient: z.string().min(1), // e.g., "Gateway::1220efgh..."
|
|
46
|
+
network: z.enum(["mainnet", "testnet", "devnet"]),
|
|
47
|
+
description: z.string().optional(),
|
|
48
|
+
expiry: z.number().int().min(1).max(3600).default(300),
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Note: Canton uses `Numeric 10` (10 decimal places) internally. USDCx has 6 meaningful decimals but the on-chain representation may use 10.
|
|
55
|
+
|
|
56
|
+
## Client Implementation
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
import { Method, Credential } from "mppx";
|
|
60
|
+
import { cantonMethod } from "./method";
|
|
61
|
+
|
|
62
|
+
export interface CantonClientConfig {
|
|
63
|
+
ledgerUrl: string; // e.g., "http://localhost:7575"
|
|
64
|
+
token: string; // JWT bearer token
|
|
65
|
+
userId: string; // Ledger API user ID
|
|
66
|
+
partyId: string; // Agent's party ID (e.g., "Agent::1220...")
|
|
67
|
+
network: "mainnet" | "testnet" | "devnet";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function cantonClient(config: CantonClientConfig) {
|
|
71
|
+
return Method.toClient(cantonMethod, {
|
|
72
|
+
async createCredential({ challenge }) {
|
|
73
|
+
// 1. Validate network
|
|
74
|
+
if (challenge.request.network !== config.network) {
|
|
75
|
+
throw new Error(`Network mismatch: expected ${config.network}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Query agent's USDCx holdings
|
|
79
|
+
const holdingsResponse = await fetch(
|
|
80
|
+
`${config.ledgerUrl}/v2/state/active-contracts`,
|
|
81
|
+
{
|
|
82
|
+
method: "POST",
|
|
83
|
+
headers: {
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
"Authorization": `Bearer ${config.token}`,
|
|
86
|
+
},
|
|
87
|
+
body: JSON.stringify({
|
|
88
|
+
eventFormat: {
|
|
89
|
+
filtersByParty: {
|
|
90
|
+
[config.partyId]: {
|
|
91
|
+
cumulative: [{
|
|
92
|
+
identifierFilter: {
|
|
93
|
+
TemplateFilter: {
|
|
94
|
+
value: { templateId: USDCX_HOLDING_TEMPLATE_ID }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}]
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
verbose: true
|
|
101
|
+
},
|
|
102
|
+
activeAtOffset: await getLedgerEnd(config),
|
|
103
|
+
}),
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// 3. Select holdings covering the amount
|
|
108
|
+
const holdings = parseHoldings(await holdingsResponse.json());
|
|
109
|
+
const selected = selectHoldings(holdings, challenge.request.amount);
|
|
110
|
+
|
|
111
|
+
// 4. Generate unique commandId
|
|
112
|
+
const commandId = crypto.randomUUID();
|
|
113
|
+
|
|
114
|
+
// 5. Exercise TransferFactory_Transfer via submit-and-wait
|
|
115
|
+
const submitResponse = await fetch(
|
|
116
|
+
`${config.ledgerUrl}/v2/commands/submit-and-wait`,
|
|
117
|
+
{
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: {
|
|
120
|
+
"Content-Type": "application/json",
|
|
121
|
+
"Authorization": `Bearer ${config.token}`,
|
|
122
|
+
},
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
commands: [{
|
|
125
|
+
ExerciseCommand: {
|
|
126
|
+
templateId: TRANSFER_FACTORY_TEMPLATE_ID,
|
|
127
|
+
contractId: selected.transferFactoryContractId,
|
|
128
|
+
choice: "TransferFactory_Transfer",
|
|
129
|
+
choiceArgument: {
|
|
130
|
+
sender: config.partyId,
|
|
131
|
+
receiver: challenge.request.recipient,
|
|
132
|
+
amount: challenge.request.amount,
|
|
133
|
+
instrumentId: USDCX_INSTRUMENT_ID,
|
|
134
|
+
inputHoldingCids: selected.holdingContractIds,
|
|
135
|
+
meta: {},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
}],
|
|
139
|
+
userId: config.userId,
|
|
140
|
+
commandId,
|
|
141
|
+
actAs: [config.partyId],
|
|
142
|
+
readAs: [config.partyId],
|
|
143
|
+
}),
|
|
144
|
+
}
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const result = await submitResponse.json();
|
|
148
|
+
|
|
149
|
+
// 6. Return credential
|
|
150
|
+
return Credential.serialize({
|
|
151
|
+
challenge,
|
|
152
|
+
payload: {
|
|
153
|
+
updateId: result.updateId,
|
|
154
|
+
completionOffset: result.completionOffset,
|
|
155
|
+
sender: config.partyId,
|
|
156
|
+
commandId,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Server Implementation
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
import { Method, Receipt } from "mppx";
|
|
168
|
+
import { cantonMethod } from "./method";
|
|
169
|
+
|
|
170
|
+
export interface CantonServerConfig {
|
|
171
|
+
ledgerUrl: string;
|
|
172
|
+
token: string;
|
|
173
|
+
userId: string;
|
|
174
|
+
recipientPartyId: string;
|
|
175
|
+
network: "mainnet" | "testnet" | "devnet";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function cantonServer(config: CantonServerConfig) {
|
|
179
|
+
return Method.toServer(cantonMethod, {
|
|
180
|
+
async verify({ credential }) {
|
|
181
|
+
const { updateId, completionOffset, sender, commandId } = credential.payload;
|
|
182
|
+
const { amount, recipient, network } = credential.challenge.request;
|
|
183
|
+
|
|
184
|
+
// 1. Network check
|
|
185
|
+
if (network !== config.network) {
|
|
186
|
+
throw new Error("Network mismatch");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 2. Recipient check
|
|
190
|
+
if (recipient !== config.recipientPartyId) {
|
|
191
|
+
throw new Error("Recipient mismatch");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 3. Fetch transaction by updateId
|
|
195
|
+
const txResponse = await fetch(
|
|
196
|
+
`${config.ledgerUrl}/v2/updates/transaction-by-id/${updateId}`,
|
|
197
|
+
{
|
|
198
|
+
headers: {
|
|
199
|
+
"Authorization": `Bearer ${config.token}`,
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
if (!txResponse.ok) {
|
|
205
|
+
throw new Error("Transaction not found on Canton ledger");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const tx = await txResponse.json();
|
|
209
|
+
|
|
210
|
+
// 4. Verify: find the created Holding event for recipient
|
|
211
|
+
const recipientHolding = findCreatedHolding(tx, config.recipientPartyId);
|
|
212
|
+
if (!recipientHolding) {
|
|
213
|
+
throw new Error("No holding created for recipient");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// 5. Verify amount >= required
|
|
217
|
+
if (parseFloat(recipientHolding.amount) < parseFloat(amount)) {
|
|
218
|
+
throw new Error(`Insufficient: ${recipientHolding.amount} < ${amount}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// 6. Verify sender
|
|
222
|
+
const senderEvent = findExercisedEvent(tx, sender);
|
|
223
|
+
if (!senderEvent) {
|
|
224
|
+
throw new Error("Sender mismatch");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 7. Return receipt
|
|
228
|
+
return Receipt.from({
|
|
229
|
+
method: "canton",
|
|
230
|
+
reference: updateId,
|
|
231
|
+
status: "success",
|
|
232
|
+
timestamp: new Date().toISOString(),
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Gateway TransferPreapproval Setup
|
|
240
|
+
|
|
241
|
+
The gateway MUST run this setup once before accepting payments:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
// One-time setup: Create TransferPreapproval for gateway party
|
|
245
|
+
// This allows agents to do 1-step transfers to the gateway
|
|
246
|
+
async function setupGatewayPreapproval(config: CantonServerConfig) {
|
|
247
|
+
// Option A: Via validator API (if using Splice wallet)
|
|
248
|
+
// POST /v0/admin/external-party/setup-proposal
|
|
249
|
+
|
|
250
|
+
// Option B: Via Ledger API (direct)
|
|
251
|
+
// Exercise the appropriate TransferPreapproval creation choice
|
|
252
|
+
// on the token registry
|
|
253
|
+
|
|
254
|
+
// The preapproval must be renewed before expiry (default: $1/year fee)
|
|
255
|
+
}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
## Error Mapping
|
|
259
|
+
|
|
260
|
+
| Canton Error | MPP Problem Code | HTTP |
|
|
261
|
+
|-------------|-----------------|------|
|
|
262
|
+
| Transaction not found | verification-failed | 402 |
|
|
263
|
+
| Amount insufficient | payment-insufficient | 402 |
|
|
264
|
+
| Wrong recipient | verification-failed | 402 |
|
|
265
|
+
| Wrong network | verification-failed | 402 |
|
|
266
|
+
| Duplicate commandId | verification-failed | 402 |
|
|
267
|
+
| INVALID_ARGUMENT | malformed-credential | 402 |
|
|
268
|
+
| PERMISSION_DENIED | verification-failed | 402 |
|
|
269
|
+
| UNAVAILABLE | N/A | 503 |
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cantonMethod
|
|
3
|
+
} from "./chunk-NTWNP6H5.js";
|
|
4
|
+
|
|
5
|
+
// src/server.ts
|
|
6
|
+
import { Method, Receipt } from "mppx";
|
|
7
|
+
var MppVerificationError = class extends Error {
|
|
8
|
+
problemCode;
|
|
9
|
+
constructor(message, problemCode) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "MppVerificationError";
|
|
12
|
+
this.problemCode = problemCode;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
function findCreatedHolding(tx, recipientParty) {
|
|
16
|
+
const events = tx.eventsById ?? {};
|
|
17
|
+
for (const evt of Object.values(events)) {
|
|
18
|
+
if (evt.createdEvent) {
|
|
19
|
+
const signatories = evt.createdEvent.signatories ?? [];
|
|
20
|
+
const witnesses = evt.createdEvent.witnessParties ?? [];
|
|
21
|
+
const allParties = [...signatories, ...witnesses];
|
|
22
|
+
if (allParties.includes(recipientParty)) {
|
|
23
|
+
const amount = evt.createdEvent.createArgument?.amount;
|
|
24
|
+
if (typeof amount === "string") {
|
|
25
|
+
return { amount };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
function findExercisedEvent(tx, senderParty) {
|
|
33
|
+
const events = tx.eventsById ?? {};
|
|
34
|
+
for (const evt of Object.values(events)) {
|
|
35
|
+
if (evt.exercisedEvent) {
|
|
36
|
+
const acting = evt.exercisedEvent.actingParties ?? [];
|
|
37
|
+
if (acting.includes(senderParty)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
function cantonServer(config) {
|
|
45
|
+
return Method.toServer(cantonMethod, {
|
|
46
|
+
async verify({
|
|
47
|
+
credential
|
|
48
|
+
}) {
|
|
49
|
+
const { updateId, sender } = credential.payload;
|
|
50
|
+
const { amount, recipient, network } = credential.challenge.request;
|
|
51
|
+
if (network !== config.network) {
|
|
52
|
+
throw new MppVerificationError(
|
|
53
|
+
`Network mismatch: credential is for ${network}, server is on ${config.network}`,
|
|
54
|
+
"verification-failed"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (recipient !== config.recipientPartyId) {
|
|
58
|
+
throw new MppVerificationError(
|
|
59
|
+
`Recipient mismatch: credential targets ${recipient}, server is ${config.recipientPartyId}`,
|
|
60
|
+
"verification-failed"
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const txResponse = await fetch(
|
|
64
|
+
`${config.ledgerUrl}/v2/updates/transaction-by-id/${encodeURIComponent(updateId)}`,
|
|
65
|
+
{
|
|
66
|
+
headers: {
|
|
67
|
+
Authorization: `Bearer ${config.token}`
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
);
|
|
71
|
+
if (!txResponse.ok) {
|
|
72
|
+
throw new MppVerificationError(
|
|
73
|
+
"Transaction not found on Canton ledger",
|
|
74
|
+
"verification-failed"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
const tx = await txResponse.json();
|
|
78
|
+
const recipientHolding = findCreatedHolding(tx, config.recipientPartyId);
|
|
79
|
+
if (!recipientHolding) {
|
|
80
|
+
throw new MppVerificationError(
|
|
81
|
+
"No holding created for recipient in transaction",
|
|
82
|
+
"verification-failed"
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
if (parseFloat(recipientHolding.amount) < parseFloat(amount)) {
|
|
86
|
+
throw new MppVerificationError(
|
|
87
|
+
`Payment insufficient: received ${recipientHolding.amount}, required ${amount}`,
|
|
88
|
+
"payment-insufficient"
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (!findExercisedEvent(tx, sender)) {
|
|
92
|
+
throw new MppVerificationError(
|
|
93
|
+
`Sender mismatch: ${sender} did not execute the transfer`,
|
|
94
|
+
"verification-failed"
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return Receipt.from({
|
|
98
|
+
method: "canton",
|
|
99
|
+
reference: updateId,
|
|
100
|
+
status: "success",
|
|
101
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export {
|
|
108
|
+
MppVerificationError,
|
|
109
|
+
cantonServer
|
|
110
|
+
};
|
|
111
|
+
//# sourceMappingURL=chunk-5CWLHTUR.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"sourcesContent":["/**\n * Canton MPP server — used by gateways to verify payments.\n *\n * Verification flow:\n * 1. Check network matches server config\n * 2. Check recipient matches server's party\n * 3. Fetch transaction from ledger by updateId\n * 4. Find Created Holding event for recipient, verify amount >= required\n * 5. Find Exercised event proving the sender executed the transfer\n * 6. Return receipt\n */\n\nimport { Method, Receipt, type CredentialData } from \"mppx\";\nimport { cantonMethod } from \"./method.js\";\nimport type { CantonCredentialPayload, CantonRequest } from \"./schemas.js\";\n\nexport type CantonNetwork = \"mainnet\" | \"testnet\" | \"devnet\";\n\nexport interface CantonMppServerConfig {\n ledgerUrl: string;\n token: string;\n userId: string;\n recipientPartyId: string;\n network: CantonNetwork;\n}\n\nexport class MppVerificationError extends Error {\n readonly problemCode: string;\n\n constructor(message: string, problemCode: string) {\n super(message);\n this.name = \"MppVerificationError\";\n this.problemCode = problemCode;\n }\n}\n\ninterface TransactionEvent {\n createdEvent?: {\n contractId: string;\n templateId: string;\n createArgument: Record<string, unknown>;\n witnessParties: string[];\n signatories: string[];\n };\n exercisedEvent?: {\n contractId: string;\n choice: string;\n actingParties: string[];\n };\n}\n\ninterface TransactionData {\n updateId: string;\n eventsById?: Record<string, TransactionEvent>;\n rootEventIds?: string[];\n}\n\nfunction findCreatedHolding(\n tx: TransactionData,\n recipientParty: string,\n): { amount: string } | null {\n const events = tx.eventsById ?? {};\n\n for (const evt of Object.values(events)) {\n if (evt.createdEvent) {\n const signatories = evt.createdEvent.signatories ?? [];\n const witnesses = evt.createdEvent.witnessParties ?? [];\n const allParties = [...signatories, ...witnesses];\n\n if (allParties.includes(recipientParty)) {\n const amount = evt.createdEvent.createArgument?.amount;\n if (typeof amount === \"string\") {\n return { amount };\n }\n }\n }\n }\n\n return null;\n}\n\nfunction findExercisedEvent(\n tx: TransactionData,\n senderParty: string,\n): boolean {\n const events = tx.eventsById ?? {};\n\n for (const evt of Object.values(events)) {\n if (evt.exercisedEvent) {\n const acting = evt.exercisedEvent.actingParties ?? [];\n if (acting.includes(senderParty)) {\n return true;\n }\n }\n }\n\n return false;\n}\n\nexport function cantonServer(config: CantonMppServerConfig) {\n return Method.toServer(cantonMethod, {\n async verify({\n credential,\n }: {\n credential: CredentialData<CantonCredentialPayload, CantonRequest>;\n }) {\n const { updateId, sender } = credential.payload;\n const { amount, recipient, network } = credential.challenge.request;\n\n // 1. Network check\n if (network !== config.network) {\n throw new MppVerificationError(\n `Network mismatch: credential is for ${network}, server is on ${config.network}`,\n \"verification-failed\",\n );\n }\n\n // 2. Recipient check\n if (recipient !== config.recipientPartyId) {\n throw new MppVerificationError(\n `Recipient mismatch: credential targets ${recipient}, server is ${config.recipientPartyId}`,\n \"verification-failed\",\n );\n }\n\n // 3. Fetch transaction by updateId\n const txResponse = await fetch(\n `${config.ledgerUrl}/v2/updates/transaction-by-id/${encodeURIComponent(updateId)}`,\n {\n headers: {\n Authorization: `Bearer ${config.token}`,\n },\n },\n );\n\n if (!txResponse.ok) {\n throw new MppVerificationError(\n \"Transaction not found on Canton ledger\",\n \"verification-failed\",\n );\n }\n\n const tx = (await txResponse.json()) as TransactionData;\n\n // 4. Find created Holding for recipient and verify amount\n const recipientHolding = findCreatedHolding(tx, config.recipientPartyId);\n if (!recipientHolding) {\n throw new MppVerificationError(\n \"No holding created for recipient in transaction\",\n \"verification-failed\",\n );\n }\n\n if (parseFloat(recipientHolding.amount) < parseFloat(amount)) {\n throw new MppVerificationError(\n `Payment insufficient: received ${recipientHolding.amount}, required ${amount}`,\n \"payment-insufficient\",\n );\n }\n\n // 5. Verify sender\n if (!findExercisedEvent(tx, sender)) {\n throw new MppVerificationError(\n `Sender mismatch: ${sender} did not execute the transfer`,\n \"verification-failed\",\n );\n }\n\n // 6. Return receipt\n return Receipt.from({\n method: \"canton\",\n reference: updateId,\n status: \"success\",\n timestamp: new Date().toISOString(),\n });\n },\n });\n}\n"],"mappings":";;;;;AAYA,SAAS,QAAQ,eAAoC;AAc9C,IAAM,uBAAN,cAAmC,MAAM;AAAA,EACrC;AAAA,EAET,YAAY,SAAiB,aAAqB;AAChD,UAAM,OAAO;AACb,SAAK,OAAO;AACZ,SAAK,cAAc;AAAA,EACrB;AACF;AAuBA,SAAS,mBACP,IACA,gBAC2B;AAC3B,QAAM,SAAS,GAAG,cAAc,CAAC;AAEjC,aAAW,OAAO,OAAO,OAAO,MAAM,GAAG;AACvC,QAAI,IAAI,cAAc;AACpB,YAAM,cAAc,IAAI,aAAa,eAAe,CAAC;AACrD,YAAM,YAAY,IAAI,aAAa,kBAAkB,CAAC;AACtD,YAAM,aAAa,CAAC,GAAG,aAAa,GAAG,SAAS;AAEhD,UAAI,WAAW,SAAS,cAAc,GAAG;AACvC,cAAM,SAAS,IAAI,aAAa,gBAAgB;AAChD,YAAI,OAAO,WAAW,UAAU;AAC9B,iBAAO,EAAE,OAAO;AAAA,QAClB;AAAA,MACF;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAEA,SAAS,mBACP,IACA,aACS;AACT,QAAM,SAAS,GAAG,cAAc,CAAC;AAEjC,aAAW,OAAO,OAAO,OAAO,MAAM,GAAG;AACvC,QAAI,IAAI,gBAAgB;AACtB,YAAM,SAAS,IAAI,eAAe,iBAAiB,CAAC;AACpD,UAAI,OAAO,SAAS,WAAW,GAAG;AAChC,eAAO;AAAA,MACT;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAEO,SAAS,aAAa,QAA+B;AAC1D,SAAO,OAAO,SAAS,cAAc;AAAA,IACnC,MAAM,OAAO;AAAA,MACX;AAAA,IACF,GAEG;AACD,YAAM,EAAE,UAAU,OAAO,IAAI,WAAW;AACxC,YAAM,EAAE,QAAQ,WAAW,QAAQ,IAAI,WAAW,UAAU;AAG5D,UAAI,YAAY,OAAO,SAAS;AAC9B,cAAM,IAAI;AAAA,UACR,uCAAuC,OAAO,kBAAkB,OAAO,OAAO;AAAA,UAC9E;AAAA,QACF;AAAA,MACF;AAGA,UAAI,cAAc,OAAO,kBAAkB;AACzC,cAAM,IAAI;AAAA,UACR,0CAA0C,SAAS,eAAe,OAAO,gBAAgB;AAAA,UACzF;AAAA,QACF;AAAA,MACF;AAGA,YAAM,aAAa,MAAM;AAAA,QACvB,GAAG,OAAO,SAAS,iCAAiC,mBAAmB,QAAQ,CAAC;AAAA,QAChF;AAAA,UACE,SAAS;AAAA,YACP,eAAe,UAAU,OAAO,KAAK;AAAA,UACvC;AAAA,QACF;AAAA,MACF;AAEA,UAAI,CAAC,WAAW,IAAI;AAClB,cAAM,IAAI;AAAA,UACR;AAAA,UACA;AAAA,QACF;AAAA,MACF;AAEA,YAAM,KAAM,MAAM,WAAW,KAAK;AAGlC,YAAM,mBAAmB,mBAAmB,IAAI,OAAO,gBAAgB;AACvE,UAAI,CAAC,kBAAkB;AACrB,cAAM,IAAI;AAAA,UACR;AAAA,UACA;AAAA,QACF;AAAA,MACF;AAEA,UAAI,WAAW,iBAAiB,MAAM,IAAI,WAAW,MAAM,GAAG;AAC5D,cAAM,IAAI;AAAA,UACR,kCAAkC,iBAAiB,MAAM,cAAc,MAAM;AAAA,UAC7E;AAAA,QACF;AAAA,MACF;AAGA,UAAI,CAAC,mBAAmB,IAAI,MAAM,GAAG;AACnC,cAAM,IAAI;AAAA,UACR,oBAAoB,MAAM;AAAA,UAC1B;AAAA,QACF;AAAA,MACF;AAGA,aAAO,QAAQ,KAAK;AAAA,QAClB,QAAQ;AAAA,QACR,WAAW;AAAA,QACX,QAAQ;AAAA,QACR,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,MACpC,CAAC;AAAA,IACH;AAAA,EACF,CAAC;AACH;","names":[]}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cantonMethod
|
|
3
|
+
} from "./chunk-NTWNP6H5.js";
|
|
4
|
+
|
|
5
|
+
// src/client.ts
|
|
6
|
+
import { Credential, Method } from "mppx";
|
|
7
|
+
var USDCX_HOLDING_TEMPLATE_ID = "Splice.Api.Token.HoldingV1:Holding";
|
|
8
|
+
var TRANSFER_FACTORY_TEMPLATE_ID = "Splice.Api.Token.TransferFactoryV1:TransferFactory";
|
|
9
|
+
var USDCX_INSTRUMENT_ID = "USDCx";
|
|
10
|
+
async function fetchJson(url, token, init) {
|
|
11
|
+
const response = await fetch(url, {
|
|
12
|
+
...init,
|
|
13
|
+
headers: {
|
|
14
|
+
"Content-Type": "application/json",
|
|
15
|
+
Authorization: `Bearer ${token}`,
|
|
16
|
+
...init?.headers
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
const text = await response.text().catch(() => "");
|
|
21
|
+
throw new Error(`Canton API error: HTTP ${response.status} \u2014 ${text}`);
|
|
22
|
+
}
|
|
23
|
+
return response.json();
|
|
24
|
+
}
|
|
25
|
+
async function getLedgerEnd(config) {
|
|
26
|
+
const data = await fetchJson(`${config.ledgerUrl}/v2/state/ledger-end`, config.token);
|
|
27
|
+
return data.offset;
|
|
28
|
+
}
|
|
29
|
+
async function queryHoldings(config) {
|
|
30
|
+
const offset = await getLedgerEnd(config);
|
|
31
|
+
const data = await fetchJson(`${config.ledgerUrl}/v2/state/active-contracts`, config.token, {
|
|
32
|
+
method: "POST",
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
eventFormat: {
|
|
35
|
+
filtersByParty: {
|
|
36
|
+
[config.partyId]: {
|
|
37
|
+
cumulative: [
|
|
38
|
+
{
|
|
39
|
+
identifierFilter: {
|
|
40
|
+
TemplateFilter: {
|
|
41
|
+
value: { templateId: USDCX_HOLDING_TEMPLATE_ID }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
verbose: true
|
|
49
|
+
},
|
|
50
|
+
activeAtOffset: offset
|
|
51
|
+
})
|
|
52
|
+
});
|
|
53
|
+
return (data.contractEntry ?? []).filter((e) => e.createdEvent != null).map((e) => ({
|
|
54
|
+
contractId: e.createdEvent.contractId,
|
|
55
|
+
amount: e.createdEvent.createArgument.amount
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
function selectHoldings(holdings, requiredAmount) {
|
|
59
|
+
if (holdings.length === 0) {
|
|
60
|
+
throw new Error(`Insufficient balance: no holdings available`);
|
|
61
|
+
}
|
|
62
|
+
const sorted = [...holdings].sort(
|
|
63
|
+
(a, b) => parseFloat(b.amount) - parseFloat(a.amount)
|
|
64
|
+
);
|
|
65
|
+
for (let i = sorted.length - 1; i >= 0; i--) {
|
|
66
|
+
if (parseFloat(sorted[i].amount) >= parseFloat(requiredAmount)) {
|
|
67
|
+
return [sorted[i].contractId];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
let total = 0;
|
|
71
|
+
const selected = [];
|
|
72
|
+
for (const h of sorted) {
|
|
73
|
+
selected.push(h.contractId);
|
|
74
|
+
total += parseFloat(h.amount);
|
|
75
|
+
if (total >= parseFloat(requiredAmount)) {
|
|
76
|
+
return selected;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Insufficient balance: have ${total}, need ${requiredAmount}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
function cantonClient(config) {
|
|
84
|
+
return Method.toClient(cantonMethod, {
|
|
85
|
+
async createCredential({ challenge }) {
|
|
86
|
+
if (challenge.request.network !== config.network) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`Network mismatch: challenge requires ${challenge.request.network}, agent configured for ${config.network}`
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
const holdings = await queryHoldings(config);
|
|
92
|
+
const selectedCids = selectHoldings(holdings, challenge.request.amount);
|
|
93
|
+
const commandId = crypto.randomUUID();
|
|
94
|
+
const result = await fetchJson(
|
|
95
|
+
`${config.ledgerUrl}/v2/commands/submit-and-wait`,
|
|
96
|
+
config.token,
|
|
97
|
+
{
|
|
98
|
+
method: "POST",
|
|
99
|
+
body: JSON.stringify({
|
|
100
|
+
commands: [
|
|
101
|
+
{
|
|
102
|
+
ExerciseCommand: {
|
|
103
|
+
templateId: TRANSFER_FACTORY_TEMPLATE_ID,
|
|
104
|
+
contractId: selectedCids[0],
|
|
105
|
+
choice: "TransferFactory_Transfer",
|
|
106
|
+
choiceArgument: {
|
|
107
|
+
sender: config.partyId,
|
|
108
|
+
receiver: challenge.request.recipient,
|
|
109
|
+
amount: challenge.request.amount,
|
|
110
|
+
instrumentId: USDCX_INSTRUMENT_ID,
|
|
111
|
+
inputHoldingCids: selectedCids,
|
|
112
|
+
meta: {}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
],
|
|
117
|
+
userId: config.userId,
|
|
118
|
+
commandId,
|
|
119
|
+
actAs: [config.partyId],
|
|
120
|
+
readAs: [config.partyId]
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
);
|
|
124
|
+
return Credential.serialize({
|
|
125
|
+
challenge,
|
|
126
|
+
payload: {
|
|
127
|
+
updateId: result.updateId,
|
|
128
|
+
completionOffset: result.completionOffset,
|
|
129
|
+
sender: config.partyId,
|
|
130
|
+
commandId
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export {
|
|
138
|
+
cantonClient
|
|
139
|
+
};
|
|
140
|
+
//# sourceMappingURL=chunk-757U7PM3.js.map
|