mrmainspring 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/LICENSE +21 -0
- package/README.md +47 -0
- package/dist/audit/service.d.ts +7 -0
- package/dist/audit/service.js +98 -0
- package/dist/audit/store.d.ts +7 -0
- package/dist/audit/store.js +37 -0
- package/dist/audit/supabase-store.d.ts +9 -0
- package/dist/audit/supabase-store.js +22 -0
- package/dist/audit/types.d.ts +31 -0
- package/dist/audit/types.js +1 -0
- package/dist/casper/anchorClient.d.ts +99 -0
- package/dist/casper/anchorClient.js +412 -0
- package/dist/config.d.ts +51 -0
- package/dist/config.js +215 -0
- package/dist/env-file.d.ts +1 -0
- package/dist/env-file.js +51 -0
- package/dist/grimoire/service.d.ts +13 -0
- package/dist/grimoire/service.js +199 -0
- package/dist/grimoire/store.d.ts +10 -0
- package/dist/grimoire/store.js +64 -0
- package/dist/grimoire/supabase-store.d.ts +13 -0
- package/dist/grimoire/supabase-store.js +50 -0
- package/dist/grimoire/types.d.ts +60 -0
- package/dist/grimoire/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +17 -0
- package/dist/mcp/auditTools.d.ts +3 -0
- package/dist/mcp/auditTools.js +13 -0
- package/dist/mcp/grimoireTools.d.ts +3 -0
- package/dist/mcp/grimoireTools.js +91 -0
- package/dist/mcp/jsonResult.d.ts +2 -0
- package/dist/mcp/jsonResult.js +10 -0
- package/dist/mcp/memoryTools.d.ts +3 -0
- package/dist/mcp/memoryTools.js +73 -0
- package/dist/mcp/paymentTools.d.ts +3 -0
- package/dist/mcp/paymentTools.js +33 -0
- package/dist/memory/canonical.d.ts +4 -0
- package/dist/memory/canonical.js +49 -0
- package/dist/memory/hash.d.ts +1 -0
- package/dist/memory/hash.js +4 -0
- package/dist/memory/service.d.ts +37 -0
- package/dist/memory/service.js +175 -0
- package/dist/memory/store.d.ts +8 -0
- package/dist/memory/store.js +49 -0
- package/dist/memory/supabase-store.d.ts +10 -0
- package/dist/memory/supabase-store.js +30 -0
- package/dist/memory/types.d.ts +56 -0
- package/dist/memory/types.js +7 -0
- package/dist/payments/service.d.ts +26 -0
- package/dist/payments/service.js +613 -0
- package/dist/payments/store.d.ts +10 -0
- package/dist/payments/store.js +64 -0
- package/dist/payments/supabase-store.d.ts +13 -0
- package/dist/payments/supabase-store.js +51 -0
- package/dist/payments/types.d.ts +101 -0
- package/dist/payments/types.js +1 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +68 -0
- package/dist/storage/json-file-store.d.ts +17 -0
- package/dist/storage/json-file-store.js +87 -0
- package/dist/storage/store-factory.d.ts +12 -0
- package/dist/storage/store-factory.js +26 -0
- package/dist/storage/supabase-rest.d.ts +26 -0
- package/dist/storage/supabase-rest.js +85 -0
- package/dist/x402/client.d.ts +44 -0
- package/dist/x402/client.js +95 -0
- package/dist/x402/facilitator.d.ts +84 -0
- package/dist/x402/facilitator.js +800 -0
- package/dist/x402/readiness.d.ts +55 -0
- package/dist/x402/readiness.js +433 -0
- package/dist/x402/redaction.d.ts +1 -0
- package/dist/x402/redaction.js +30 -0
- package/dist/x402/resource.d.ts +69 -0
- package/dist/x402/resource.js +325 -0
- package/dist/x402/settlement.d.ts +176 -0
- package/dist/x402/settlement.js +1210 -0
- package/dist/x402/signer.d.ts +71 -0
- package/dist/x402/signer.js +616 -0
- package/package.json +61 -0
|
@@ -0,0 +1,800 @@
|
|
|
1
|
+
import { createPublicKey, verify as nodeVerify } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { canonicalizeJson, toJsonObject, toJsonValue } from "../memory/canonical.js";
|
|
4
|
+
import { sha256Hex } from "../memory/hash.js";
|
|
5
|
+
import { CasperCliX402SettlementProvider, createSignedPayloadHash } from "./settlement.js";
|
|
6
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
7
|
+
const HASH_HEX_PATTERN = /^[a-f0-9]{64}$/i;
|
|
8
|
+
const UNSIGNED_INTEGER_PATTERN = /^(0|[1-9]\d*)$/;
|
|
9
|
+
const HTTP_METHOD_PATTERN = /^[A-Za-z]+$/;
|
|
10
|
+
const SECP256K1_P = BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f");
|
|
11
|
+
export class InMemoryX402FacilitatorReplayStore {
|
|
12
|
+
records = new Map();
|
|
13
|
+
reserveVerify(payment) {
|
|
14
|
+
const existing = this.records.get(payment.replayKey);
|
|
15
|
+
if (existing) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
reason: existing.payloadHash === payment.payloadHash
|
|
19
|
+
? "payment_payload_replayed"
|
|
20
|
+
: "nonce_replayed"
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
this.records.set(payment.replayKey, replayRecord(payment, "verified", null));
|
|
24
|
+
return { ok: true };
|
|
25
|
+
}
|
|
26
|
+
reserveSettle(payment) {
|
|
27
|
+
const existing = this.records.get(payment.replayKey);
|
|
28
|
+
if (!existing) {
|
|
29
|
+
this.records.set(payment.replayKey, replayRecord(payment, "settling", null));
|
|
30
|
+
return { ok: true };
|
|
31
|
+
}
|
|
32
|
+
if (existing.state === "verified" && existing.payloadHash === payment.payloadHash) {
|
|
33
|
+
this.records.set(payment.replayKey, replayRecord(payment, "settling", null));
|
|
34
|
+
return { ok: true };
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
reason: existing.state === "settling"
|
|
39
|
+
? "payment_settlement_in_progress"
|
|
40
|
+
: existing.payloadHash === payment.payloadHash
|
|
41
|
+
? "payment_payload_replayed"
|
|
42
|
+
: "nonce_replayed"
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
completeSettle(payment, state, transactionHash) {
|
|
46
|
+
this.records.set(payment.replayKey, replayRecord(payment, state, transactionHash));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function createX402FacilitatorHttpServer(config) {
|
|
50
|
+
const replayStore = config.replayStore ?? new InMemoryX402FacilitatorReplayStore();
|
|
51
|
+
return createServer(async (request, response) => {
|
|
52
|
+
try {
|
|
53
|
+
await handleX402FacilitatorHttpRequest(request, response, config, replayStore);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
config.logger?.error?.(`x402 facilitator request failed: ${errorMessage(error)}`);
|
|
57
|
+
sendJson(response, 500, {
|
|
58
|
+
success: false,
|
|
59
|
+
settled: false,
|
|
60
|
+
error: "facilitator_failed"
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function validateX402FacilitatorPayment(input, options = {}) {
|
|
66
|
+
const body = asRecord(input);
|
|
67
|
+
if (!body) {
|
|
68
|
+
return rejected("request_not_object");
|
|
69
|
+
}
|
|
70
|
+
let paymentPayload;
|
|
71
|
+
try {
|
|
72
|
+
paymentPayload = toJsonObject(body.paymentPayload ?? body.payment_payload, "paymentPayload");
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return rejected("payment_payload_missing");
|
|
76
|
+
}
|
|
77
|
+
const selectedRequirementHash = firstString(paymentPayload, [
|
|
78
|
+
"selectedRequirementHash",
|
|
79
|
+
"selected_requirement_hash"
|
|
80
|
+
]);
|
|
81
|
+
if (!selectedRequirementHash) {
|
|
82
|
+
return rejected("selected_requirement_hash_missing");
|
|
83
|
+
}
|
|
84
|
+
if (!HASH_HEX_PATTERN.test(selectedRequirementHash)) {
|
|
85
|
+
return rejected("selected_requirement_hash_invalid");
|
|
86
|
+
}
|
|
87
|
+
const paymentRequirements = extractSelectedPaymentRequirements(body.paymentRequirements ?? body.payment_requirements ?? body.selectedRequirement, selectedRequirementHash.toLowerCase());
|
|
88
|
+
if (!paymentRequirements.ok) {
|
|
89
|
+
return rejected(paymentRequirements.reason);
|
|
90
|
+
}
|
|
91
|
+
const actualRequirementHash = sha256Hex(canonicalizeJson(paymentRequirements.value));
|
|
92
|
+
if (actualRequirementHash !== selectedRequirementHash.toLowerCase()) {
|
|
93
|
+
return rejected("selected_requirement_hash_mismatch");
|
|
94
|
+
}
|
|
95
|
+
const accepted = asRecord(paymentPayload.accepted);
|
|
96
|
+
if (accepted) {
|
|
97
|
+
const acceptedHash = sha256Hex(canonicalizeJson(accepted));
|
|
98
|
+
if (acceptedHash !== selectedRequirementHash.toLowerCase()) {
|
|
99
|
+
return rejected("accepted_requirement_hash_mismatch");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const requirement = normalizeRequirement(paymentRequirements.value);
|
|
103
|
+
if (!requirement.ok) {
|
|
104
|
+
return rejected(requirement.reason);
|
|
105
|
+
}
|
|
106
|
+
if (options.expectedNetwork &&
|
|
107
|
+
!networkMatches(requirement.value.network, options.expectedNetwork)) {
|
|
108
|
+
return rejected("network_mismatch");
|
|
109
|
+
}
|
|
110
|
+
const authorization = asRecord(paymentPayload.authorization);
|
|
111
|
+
if (!authorization) {
|
|
112
|
+
return rejected("authorization_missing");
|
|
113
|
+
}
|
|
114
|
+
const payer = firstString(paymentPayload, ["payer", "payerAccount", "payer_account"]);
|
|
115
|
+
if (!payer) {
|
|
116
|
+
return rejected("payer_missing");
|
|
117
|
+
}
|
|
118
|
+
const paymentId = firstString(authorization, ["paymentId", "payment_id"]) ??
|
|
119
|
+
firstString(paymentPayload, ["paymentId", "payment_id"]);
|
|
120
|
+
if (!paymentId) {
|
|
121
|
+
return rejected("payment_id_missing");
|
|
122
|
+
}
|
|
123
|
+
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(paymentId)) {
|
|
124
|
+
return rejected("payment_id_invalid");
|
|
125
|
+
}
|
|
126
|
+
const policyHash = firstString(authorization, ["policyHash", "policy_hash"]) ??
|
|
127
|
+
firstString(paymentPayload, ["policyHash", "policy_hash"]);
|
|
128
|
+
if (!policyHash) {
|
|
129
|
+
return rejected("policy_hash_missing");
|
|
130
|
+
}
|
|
131
|
+
if (!HASH_HEX_PATTERN.test(policyHash)) {
|
|
132
|
+
return rejected("policy_hash_invalid");
|
|
133
|
+
}
|
|
134
|
+
const publicKey = firstString(authorization, ["publicKey", "public_key"]);
|
|
135
|
+
if (!publicKey) {
|
|
136
|
+
return rejected("public_key_missing");
|
|
137
|
+
}
|
|
138
|
+
if (!validCasperPublicKey(publicKey)) {
|
|
139
|
+
return rejected("public_key_invalid");
|
|
140
|
+
}
|
|
141
|
+
const signature = firstString(authorization, ["signature"]);
|
|
142
|
+
if (!signature) {
|
|
143
|
+
return rejected("signature_missing");
|
|
144
|
+
}
|
|
145
|
+
const payloadNonce = firstString(paymentPayload, ["nonce"]);
|
|
146
|
+
const authorizationNonce = firstString(authorization, ["nonce"]);
|
|
147
|
+
if (!payloadNonce && !authorizationNonce) {
|
|
148
|
+
return rejected("nonce_missing");
|
|
149
|
+
}
|
|
150
|
+
if (payloadNonce && authorizationNonce && payloadNonce !== authorizationNonce) {
|
|
151
|
+
return rejected("nonce_mismatch");
|
|
152
|
+
}
|
|
153
|
+
const nonce = normalizeHex(payloadNonce ?? authorizationNonce, 64);
|
|
154
|
+
if (!nonce) {
|
|
155
|
+
return rejected("nonce_invalid");
|
|
156
|
+
}
|
|
157
|
+
const validAfter = firstString(paymentPayload, [
|
|
158
|
+
"validAfter",
|
|
159
|
+
"valid_after",
|
|
160
|
+
"validFrom",
|
|
161
|
+
"valid_from"
|
|
162
|
+
]);
|
|
163
|
+
if (!validAfter) {
|
|
164
|
+
return rejected("valid_after_missing");
|
|
165
|
+
}
|
|
166
|
+
const validUntil = firstString(paymentPayload, [
|
|
167
|
+
"validUntil",
|
|
168
|
+
"valid_until",
|
|
169
|
+
"validBefore",
|
|
170
|
+
"valid_before",
|
|
171
|
+
"expiresAt",
|
|
172
|
+
"expires_at"
|
|
173
|
+
]) ?? firstString(authorization, ["validBefore", "valid_before"]);
|
|
174
|
+
if (!validUntil) {
|
|
175
|
+
return rejected("valid_until_missing");
|
|
176
|
+
}
|
|
177
|
+
const validity = validateValidityWindow({
|
|
178
|
+
validAfter,
|
|
179
|
+
validUntil,
|
|
180
|
+
timeoutSeconds: requirement.value.timeoutSeconds,
|
|
181
|
+
now: options.now?.() ?? new Date()
|
|
182
|
+
});
|
|
183
|
+
if (!validity.ok) {
|
|
184
|
+
return rejected(validity.reason);
|
|
185
|
+
}
|
|
186
|
+
const fieldCheck = validatePayloadRequirementFields({
|
|
187
|
+
paymentPayload,
|
|
188
|
+
authorization,
|
|
189
|
+
requirement: requirement.value,
|
|
190
|
+
payer,
|
|
191
|
+
publicKey,
|
|
192
|
+
nonce
|
|
193
|
+
});
|
|
194
|
+
if (!fieldCheck.ok) {
|
|
195
|
+
return rejected(fieldCheck.reason);
|
|
196
|
+
}
|
|
197
|
+
const authorizationToSign = {
|
|
198
|
+
domain: "casper-x402-authorization",
|
|
199
|
+
version: 1,
|
|
200
|
+
paymentId,
|
|
201
|
+
policyHash: policyHash.toLowerCase(),
|
|
202
|
+
selectedRequirementHash: selectedRequirementHash.toLowerCase(),
|
|
203
|
+
method: requirement.value.method,
|
|
204
|
+
resource: requirement.value.resource,
|
|
205
|
+
scheme: requirement.value.scheme,
|
|
206
|
+
network: requirement.value.network,
|
|
207
|
+
asset: requirement.value.asset,
|
|
208
|
+
payTo: requirement.value.payTo,
|
|
209
|
+
amount: requirement.value.amount,
|
|
210
|
+
payer,
|
|
211
|
+
publicKey: publicKey.toLowerCase(),
|
|
212
|
+
validAfter,
|
|
213
|
+
validBefore: validUntil,
|
|
214
|
+
nonce
|
|
215
|
+
};
|
|
216
|
+
const canonicalAuthorization = canonicalizeJson(authorizationToSign);
|
|
217
|
+
const authorizationHash = firstString(authorization, [
|
|
218
|
+
"authorizationHash",
|
|
219
|
+
"authorization_hash"
|
|
220
|
+
]);
|
|
221
|
+
if (authorizationHash && authorizationHash !== sha256Hex(canonicalAuthorization)) {
|
|
222
|
+
return rejected("authorization_hash_mismatch");
|
|
223
|
+
}
|
|
224
|
+
if (!verifyCasperPayloadSignature({
|
|
225
|
+
publicKey,
|
|
226
|
+
signature,
|
|
227
|
+
canonicalAuthorization
|
|
228
|
+
})) {
|
|
229
|
+
return rejected("signature_invalid");
|
|
230
|
+
}
|
|
231
|
+
const payloadHash = createSignedPayloadHash(paymentPayload);
|
|
232
|
+
const replayKey = sha256Hex([
|
|
233
|
+
requirement.value.network.toLowerCase(),
|
|
234
|
+
payer.toLowerCase(),
|
|
235
|
+
nonce
|
|
236
|
+
].join(":"));
|
|
237
|
+
return {
|
|
238
|
+
ok: true,
|
|
239
|
+
payment: {
|
|
240
|
+
paymentPayload,
|
|
241
|
+
paymentRequirements: paymentRequirements.value,
|
|
242
|
+
selectedRequirementHash: selectedRequirementHash.toLowerCase(),
|
|
243
|
+
payloadHash,
|
|
244
|
+
replayKey,
|
|
245
|
+
nonce,
|
|
246
|
+
paymentId,
|
|
247
|
+
policyHash: policyHash.toLowerCase(),
|
|
248
|
+
payer,
|
|
249
|
+
publicKey: publicKey.toLowerCase(),
|
|
250
|
+
signature: signature.toLowerCase(),
|
|
251
|
+
requirement: requirement.value,
|
|
252
|
+
settlementInput: {
|
|
253
|
+
payment_id: paymentId,
|
|
254
|
+
facilitator_url: options.facilitatorUrl ?? null,
|
|
255
|
+
method: requirement.value.method,
|
|
256
|
+
url: requirement.value.resource,
|
|
257
|
+
selected_requirement: paymentRequirements.value,
|
|
258
|
+
selected_requirement_hash: selectedRequirementHash.toLowerCase(),
|
|
259
|
+
policy_hash: policyHash.toLowerCase()
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
async function handleX402FacilitatorHttpRequest(request, response, config, replayStore) {
|
|
265
|
+
const pathname = new URL(request.url ?? "/", "http://127.0.0.1").pathname;
|
|
266
|
+
if (request.method !== "POST" || (pathname !== "/verify" && pathname !== "/settle")) {
|
|
267
|
+
sendJson(response, 404, { error: "not_found" });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const body = await readJsonBody(request, config.maxBodyBytes ?? MAX_BODY_BYTES);
|
|
271
|
+
if (!body.ok) {
|
|
272
|
+
sendJson(response, 400, {
|
|
273
|
+
success: false,
|
|
274
|
+
settled: false,
|
|
275
|
+
valid: false,
|
|
276
|
+
error: "invalid_json",
|
|
277
|
+
reason: body.reason
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const validation = validateX402FacilitatorPayment(body.value, {
|
|
282
|
+
expectedNetwork: config.settlementConfig.caip2ChainId,
|
|
283
|
+
facilitatorUrl: config.facilitatorUrl,
|
|
284
|
+
now: config.now
|
|
285
|
+
});
|
|
286
|
+
if (!validation.ok) {
|
|
287
|
+
config.logger?.warn?.(`x402 facilitator rejected reason=${validation.reason}`);
|
|
288
|
+
sendJson(response, validation.statusCode, {
|
|
289
|
+
success: false,
|
|
290
|
+
settled: false,
|
|
291
|
+
valid: false,
|
|
292
|
+
error: "invalid_payment",
|
|
293
|
+
reason: validation.reason
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (pathname === "/verify") {
|
|
298
|
+
const reservation = replayStore.reserveVerify(validation.payment);
|
|
299
|
+
if (!reservation.ok) {
|
|
300
|
+
sendReplayRejection(response, reservation.reason);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
config.logger?.info?.(`x402 facilitator verified payment_id=${validation.payment.paymentId} selected_requirement_hash=${validation.payment.selectedRequirementHash} payload_hash=${validation.payment.payloadHash}`);
|
|
304
|
+
sendJson(response, 200, {
|
|
305
|
+
valid: true,
|
|
306
|
+
selectedRequirementHash: validation.payment.selectedRequirementHash,
|
|
307
|
+
network: validation.payment.requirement.network,
|
|
308
|
+
asset: validation.payment.requirement.asset,
|
|
309
|
+
amount: validation.payment.requirement.amount,
|
|
310
|
+
payer: validation.payment.payer,
|
|
311
|
+
payTo: validation.payment.requirement.payTo
|
|
312
|
+
});
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const reservation = replayStore.reserveSettle(validation.payment);
|
|
316
|
+
if (!reservation.ok) {
|
|
317
|
+
sendReplayRejection(response, reservation.reason);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const settlement = await settleValidatedPayment(validation.payment, config);
|
|
321
|
+
if (settlement.status === "settled") {
|
|
322
|
+
replayStore.completeSettle(validation.payment, "settled", settlement.casper_transaction_hash);
|
|
323
|
+
config.logger?.info?.(`x402 facilitator settled payment_id=${validation.payment.paymentId} transaction_hash=${settlement.casper_transaction_hash}`);
|
|
324
|
+
sendJson(response, 200, settlementSuccessResponse(validation.payment, settlement));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
replayStore.completeSettle(validation.payment, "failed", settlement.casper_transaction_hash);
|
|
328
|
+
config.logger?.warn?.(`x402 facilitator settlement failed payment_id=${validation.payment.paymentId} blocker=${settlement.blocker}`);
|
|
329
|
+
sendJson(response, settlement.status === "unavailable" ? 503 : 200, settlementFailedResponse(validation.payment, settlement));
|
|
330
|
+
}
|
|
331
|
+
async function settleValidatedPayment(payment, config) {
|
|
332
|
+
const signer = {
|
|
333
|
+
async sign() {
|
|
334
|
+
return {
|
|
335
|
+
signed: true,
|
|
336
|
+
signed_payload: payment.paymentPayload,
|
|
337
|
+
signed_payload_hash: payment.payloadHash
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const provider = new CasperCliX402SettlementProvider(signer, {
|
|
342
|
+
...config.settlementConfig,
|
|
343
|
+
now: config.settlementConfig.now ?? config.now
|
|
344
|
+
}, config.commandRunner);
|
|
345
|
+
return provider.settle(payment.settlementInput);
|
|
346
|
+
}
|
|
347
|
+
function settlementSuccessResponse(payment, settlement) {
|
|
348
|
+
return {
|
|
349
|
+
success: true,
|
|
350
|
+
settled: true,
|
|
351
|
+
transactionHash: settlement.casper_transaction_hash,
|
|
352
|
+
transaction: settlement.casper_transaction_hash,
|
|
353
|
+
network: payment.requirement.network,
|
|
354
|
+
asset: payment.requirement.asset,
|
|
355
|
+
amount: payment.requirement.amount,
|
|
356
|
+
payer: payment.payer,
|
|
357
|
+
payTo: payment.requirement.payTo,
|
|
358
|
+
receipt: receiptJsonValue(settlement.receipt_json)
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
function settlementFailedResponse(payment, settlement) {
|
|
362
|
+
return {
|
|
363
|
+
success: false,
|
|
364
|
+
settled: false,
|
|
365
|
+
error: settlement.status === "unavailable" ? "settlement_unavailable" : "settlement_failed",
|
|
366
|
+
reason: settlement.blocker,
|
|
367
|
+
transactionHash: settlement.casper_transaction_hash,
|
|
368
|
+
transaction: settlement.casper_transaction_hash,
|
|
369
|
+
network: payment.requirement.network,
|
|
370
|
+
asset: payment.requirement.asset,
|
|
371
|
+
amount: payment.requirement.amount,
|
|
372
|
+
payer: payment.payer,
|
|
373
|
+
payTo: payment.requirement.payTo,
|
|
374
|
+
receipt: receiptJsonValue(settlement.receipt_json)
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function sendReplayRejection(response, reason) {
|
|
378
|
+
sendJson(response, 409, {
|
|
379
|
+
success: false,
|
|
380
|
+
settled: false,
|
|
381
|
+
valid: false,
|
|
382
|
+
error: "payment_replayed",
|
|
383
|
+
reason
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
function validatePayloadRequirementFields(input) {
|
|
387
|
+
const checks = [
|
|
388
|
+
{
|
|
389
|
+
value: firstString(input.paymentPayload, ["scheme"]) ?? firstString(input.authorization, ["scheme"]),
|
|
390
|
+
expected: input.requirement.scheme,
|
|
391
|
+
reason: "scheme_unsupported"
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
value: firstString(input.paymentPayload, ["network"]) ?? firstString(input.authorization, ["network"]),
|
|
395
|
+
expected: input.requirement.network,
|
|
396
|
+
reason: "network_mismatch"
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
value: firstString(input.paymentPayload, ["method"]) ?? firstString(input.authorization, ["method"]),
|
|
400
|
+
expected: input.requirement.method,
|
|
401
|
+
reason: "method_mismatch"
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
value: firstString(input.paymentPayload, ["resource"]) ?? firstString(input.authorization, ["resource"]),
|
|
405
|
+
expected: input.requirement.resource,
|
|
406
|
+
reason: "resource_mismatch"
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
value: firstString(input.paymentPayload, ["asset"]) ?? firstString(input.authorization, ["asset"]),
|
|
410
|
+
expected: input.requirement.asset,
|
|
411
|
+
reason: "asset_mismatch"
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
value: firstString(input.paymentPayload, ["payTo", "pay_to", "payee"]) ??
|
|
415
|
+
firstString(input.authorization, ["payTo", "pay_to", "to"]),
|
|
416
|
+
expected: input.requirement.payTo,
|
|
417
|
+
reason: "payee_mismatch"
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
value: firstString(input.paymentPayload, ["amount", "maxAmountRequired", "max_amount_required"]) ??
|
|
421
|
+
firstString(input.authorization, ["amount", "value"]),
|
|
422
|
+
expected: input.requirement.amount,
|
|
423
|
+
reason: "amount_mismatch"
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
value: firstString(input.authorization, ["payer", "from"]),
|
|
427
|
+
expected: input.payer,
|
|
428
|
+
reason: "payer_missing"
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
value: firstString(input.authorization, ["publicKey", "public_key"]),
|
|
432
|
+
expected: input.publicKey,
|
|
433
|
+
reason: "public_key_invalid"
|
|
434
|
+
},
|
|
435
|
+
{
|
|
436
|
+
value: firstString(input.authorization, ["nonce"]),
|
|
437
|
+
expected: input.nonce,
|
|
438
|
+
reason: "nonce_mismatch"
|
|
439
|
+
}
|
|
440
|
+
];
|
|
441
|
+
for (const check of checks) {
|
|
442
|
+
if (check.value && !matchesNormalized(check.value, check.expected)) {
|
|
443
|
+
return { ok: false, reason: check.reason };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return { ok: true };
|
|
447
|
+
}
|
|
448
|
+
function normalizeRequirement(requirement) {
|
|
449
|
+
const scheme = firstString(requirement, ["scheme"]);
|
|
450
|
+
if (!scheme) {
|
|
451
|
+
return { ok: false, reason: "scheme_missing" };
|
|
452
|
+
}
|
|
453
|
+
if (scheme !== "exact") {
|
|
454
|
+
return { ok: false, reason: "scheme_unsupported" };
|
|
455
|
+
}
|
|
456
|
+
const network = firstString(requirement, [
|
|
457
|
+
"network",
|
|
458
|
+
"networkId",
|
|
459
|
+
"network_id",
|
|
460
|
+
"caip2_chain_id",
|
|
461
|
+
"caip2ChainId"
|
|
462
|
+
]);
|
|
463
|
+
if (!network) {
|
|
464
|
+
return { ok: false, reason: "network_missing" };
|
|
465
|
+
}
|
|
466
|
+
const amount = firstString(requirement, [
|
|
467
|
+
"maxAmountRequired",
|
|
468
|
+
"amount",
|
|
469
|
+
"max_amount_required"
|
|
470
|
+
]);
|
|
471
|
+
if (!amount) {
|
|
472
|
+
return { ok: false, reason: "amount_missing" };
|
|
473
|
+
}
|
|
474
|
+
if (!UNSIGNED_INTEGER_PATTERN.test(amount)) {
|
|
475
|
+
return { ok: false, reason: "amount_invalid" };
|
|
476
|
+
}
|
|
477
|
+
const resource = firstString(requirement, ["resource", "resourceUrl", "resource_url"]);
|
|
478
|
+
if (!resource) {
|
|
479
|
+
return { ok: false, reason: "resource_missing" };
|
|
480
|
+
}
|
|
481
|
+
const method = firstString(requirement, ["method", "httpMethod", "http_method"]);
|
|
482
|
+
if (!method) {
|
|
483
|
+
return { ok: false, reason: "method_missing" };
|
|
484
|
+
}
|
|
485
|
+
const normalizedMethod = method.toUpperCase();
|
|
486
|
+
if (!HTTP_METHOD_PATTERN.test(normalizedMethod)) {
|
|
487
|
+
return { ok: false, reason: "method_invalid" };
|
|
488
|
+
}
|
|
489
|
+
const asset = firstString(requirement, [
|
|
490
|
+
"asset",
|
|
491
|
+
"assetId",
|
|
492
|
+
"asset_id",
|
|
493
|
+
"assetPackage",
|
|
494
|
+
"asset_package",
|
|
495
|
+
"assetPackageHash",
|
|
496
|
+
"asset_package_hash"
|
|
497
|
+
]);
|
|
498
|
+
if (!asset) {
|
|
499
|
+
return { ok: false, reason: "asset_missing" };
|
|
500
|
+
}
|
|
501
|
+
const payTo = firstString(requirement, [
|
|
502
|
+
"payTo",
|
|
503
|
+
"pay_to",
|
|
504
|
+
"payee",
|
|
505
|
+
"recipient",
|
|
506
|
+
"recipientAddress",
|
|
507
|
+
"recipient_address"
|
|
508
|
+
]);
|
|
509
|
+
if (!payTo) {
|
|
510
|
+
return { ok: false, reason: "payee_missing" };
|
|
511
|
+
}
|
|
512
|
+
const timeout = firstValue(requirement, [
|
|
513
|
+
"timeout",
|
|
514
|
+
"timeoutSeconds",
|
|
515
|
+
"timeout_seconds",
|
|
516
|
+
"maxTimeoutSeconds",
|
|
517
|
+
"max_timeout_seconds"
|
|
518
|
+
]);
|
|
519
|
+
if (timeout === null) {
|
|
520
|
+
return { ok: false, reason: "timeout_missing" };
|
|
521
|
+
}
|
|
522
|
+
const timeoutSeconds = timeoutValue(timeout);
|
|
523
|
+
if (timeoutSeconds === null) {
|
|
524
|
+
return { ok: false, reason: "timeout_invalid" };
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
ok: true,
|
|
528
|
+
value: {
|
|
529
|
+
scheme: "exact",
|
|
530
|
+
network,
|
|
531
|
+
amount,
|
|
532
|
+
resource,
|
|
533
|
+
method: normalizedMethod,
|
|
534
|
+
asset,
|
|
535
|
+
payTo,
|
|
536
|
+
timeoutSeconds
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function extractSelectedPaymentRequirements(value, selectedRequirementHash) {
|
|
541
|
+
const record = asRecord(value);
|
|
542
|
+
if (!record) {
|
|
543
|
+
return { ok: false, reason: "payment_requirements_missing" };
|
|
544
|
+
}
|
|
545
|
+
if (!Array.isArray(record.accepts)) {
|
|
546
|
+
return { ok: true, value: toJsonObject(record, "paymentRequirements") };
|
|
547
|
+
}
|
|
548
|
+
for (const item of record.accepts) {
|
|
549
|
+
const candidate = asRecord(item);
|
|
550
|
+
if (!candidate) {
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
const candidateObject = toJsonObject(candidate, "paymentRequirements.accepts[]");
|
|
554
|
+
if (sha256Hex(canonicalizeJson(candidateObject)) === selectedRequirementHash) {
|
|
555
|
+
return { ok: true, value: candidateObject };
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return { ok: false, reason: "payment_requirements_no_matching_accept" };
|
|
559
|
+
}
|
|
560
|
+
function validateValidityWindow(input) {
|
|
561
|
+
const validAfterMs = Date.parse(input.validAfter);
|
|
562
|
+
const validUntilMs = Date.parse(input.validUntil);
|
|
563
|
+
if (!Number.isFinite(validAfterMs) ||
|
|
564
|
+
!Number.isFinite(validUntilMs) ||
|
|
565
|
+
validUntilMs <= validAfterMs) {
|
|
566
|
+
return { ok: false, reason: "validity_window_invalid" };
|
|
567
|
+
}
|
|
568
|
+
const nowMs = input.now.getTime();
|
|
569
|
+
if (validAfterMs > nowMs) {
|
|
570
|
+
return { ok: false, reason: "payment_not_yet_valid" };
|
|
571
|
+
}
|
|
572
|
+
if (validUntilMs <= nowMs) {
|
|
573
|
+
return { ok: false, reason: "payment_expired" };
|
|
574
|
+
}
|
|
575
|
+
if (validUntilMs - validAfterMs > input.timeoutSeconds * 1000) {
|
|
576
|
+
return { ok: false, reason: "timeout_exceeded" };
|
|
577
|
+
}
|
|
578
|
+
return { ok: true };
|
|
579
|
+
}
|
|
580
|
+
function verifyCasperPayloadSignature(input) {
|
|
581
|
+
const publicKey = normalizeHex(input.publicKey, null);
|
|
582
|
+
const signature = normalizeHex(input.signature, null);
|
|
583
|
+
if (!publicKey || !signature) {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
const authorizationBytes = Buffer.from(input.canonicalAuthorization, "utf8");
|
|
587
|
+
try {
|
|
588
|
+
if (publicKey.startsWith("01") && signature.startsWith("01")) {
|
|
589
|
+
const publicKeyObject = createEd25519PublicKey(publicKey.slice(2));
|
|
590
|
+
return nodeVerify(null, authorizationBytes, publicKeyObject, Buffer.from(signature.slice(2), "hex"));
|
|
591
|
+
}
|
|
592
|
+
if (publicKey.startsWith("02") && signature.startsWith("02")) {
|
|
593
|
+
const publicKeyObject = createSecp256k1PublicKey(publicKey.slice(2));
|
|
594
|
+
if (!publicKeyObject) {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
return nodeVerify("sha256", authorizationBytes, { key: publicKeyObject, dsaEncoding: "ieee-p1363" }, Buffer.from(signature.slice(2), "hex"));
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return false;
|
|
602
|
+
}
|
|
603
|
+
return false;
|
|
604
|
+
}
|
|
605
|
+
function createEd25519PublicKey(rawHex) {
|
|
606
|
+
return createPublicKey({
|
|
607
|
+
key: {
|
|
608
|
+
kty: "OKP",
|
|
609
|
+
crv: "Ed25519",
|
|
610
|
+
x: Buffer.from(rawHex, "hex").toString("base64url")
|
|
611
|
+
},
|
|
612
|
+
format: "jwk"
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
function createSecp256k1PublicKey(compressedHex) {
|
|
616
|
+
const point = decompressSecp256k1PublicKey(compressedHex);
|
|
617
|
+
if (!point) {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
return createPublicKey({
|
|
621
|
+
key: {
|
|
622
|
+
kty: "EC",
|
|
623
|
+
crv: "secp256k1",
|
|
624
|
+
x: bigIntToBuffer(point.x, 32).toString("base64url"),
|
|
625
|
+
y: bigIntToBuffer(point.y, 32).toString("base64url")
|
|
626
|
+
},
|
|
627
|
+
format: "jwk"
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
function decompressSecp256k1PublicKey(compressedHex) {
|
|
631
|
+
if (!/^(02|03)[a-f0-9]{64}$/i.test(compressedHex)) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
const prefix = compressedHex.slice(0, 2);
|
|
635
|
+
const x = BigInt(`0x${compressedHex.slice(2)}`);
|
|
636
|
+
const alpha = mod(x ** 3n + 7n, SECP256K1_P);
|
|
637
|
+
const beta = modPow(alpha, (SECP256K1_P + 1n) / 4n, SECP256K1_P);
|
|
638
|
+
const betaIsOdd = Boolean(beta & 1n);
|
|
639
|
+
const wantOdd = prefix === "03";
|
|
640
|
+
const y = betaIsOdd === wantOdd ? beta : SECP256K1_P - beta;
|
|
641
|
+
return { x, y };
|
|
642
|
+
}
|
|
643
|
+
function receiptJsonValue(receiptJson) {
|
|
644
|
+
try {
|
|
645
|
+
return toJsonValue(JSON.parse(receiptJson), "receipt");
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return { raw: receiptJson };
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function readJsonBody(request, maxBodyBytes) {
|
|
652
|
+
return new Promise((resolve) => {
|
|
653
|
+
const chunks = [];
|
|
654
|
+
let total = 0;
|
|
655
|
+
let resolved = false;
|
|
656
|
+
request.on("data", (chunk) => {
|
|
657
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
658
|
+
total += buffer.byteLength;
|
|
659
|
+
if (total > maxBodyBytes && !resolved) {
|
|
660
|
+
resolved = true;
|
|
661
|
+
request.destroy();
|
|
662
|
+
resolve({ ok: false, reason: "body_too_large" });
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
chunks.push(buffer);
|
|
666
|
+
});
|
|
667
|
+
request.on("end", () => {
|
|
668
|
+
if (resolved) {
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
672
|
+
try {
|
|
673
|
+
resolve({ ok: true, value: raw.trim() ? JSON.parse(raw) : {} });
|
|
674
|
+
}
|
|
675
|
+
catch {
|
|
676
|
+
resolve({ ok: false, reason: "body_invalid_json" });
|
|
677
|
+
}
|
|
678
|
+
});
|
|
679
|
+
request.on("error", () => {
|
|
680
|
+
if (!resolved) {
|
|
681
|
+
resolved = true;
|
|
682
|
+
resolve({ ok: false, reason: "body_invalid_json" });
|
|
683
|
+
}
|
|
684
|
+
});
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
function sendJson(response, statusCode, value) {
|
|
688
|
+
if (response.writableEnded) {
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
response.statusCode = statusCode;
|
|
692
|
+
response.setHeader("content-type", "application/json");
|
|
693
|
+
response.end(`${JSON.stringify(value)}\n`);
|
|
694
|
+
}
|
|
695
|
+
function replayRecord(payment, state, transactionHash) {
|
|
696
|
+
return {
|
|
697
|
+
replayKey: payment.replayKey,
|
|
698
|
+
payloadHash: payment.payloadHash,
|
|
699
|
+
state,
|
|
700
|
+
transactionHash,
|
|
701
|
+
updatedAt: new Date().toISOString()
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function rejected(reason) {
|
|
705
|
+
return {
|
|
706
|
+
ok: false,
|
|
707
|
+
reason,
|
|
708
|
+
statusCode: reason.includes("missing") ? 400 : 422
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
function asRecord(value) {
|
|
712
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
713
|
+
? value
|
|
714
|
+
: null;
|
|
715
|
+
}
|
|
716
|
+
function firstString(record, keys) {
|
|
717
|
+
if (!record) {
|
|
718
|
+
return null;
|
|
719
|
+
}
|
|
720
|
+
for (const key of keys) {
|
|
721
|
+
const value = record[key];
|
|
722
|
+
if (typeof value === "string" && value.trim()) {
|
|
723
|
+
return value.trim();
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
function firstValue(record, keys) {
|
|
729
|
+
for (const key of keys) {
|
|
730
|
+
if (record[key] !== undefined && record[key] !== null) {
|
|
731
|
+
return record[key];
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
function timeoutValue(value) {
|
|
737
|
+
if (typeof value === "number") {
|
|
738
|
+
return Number.isInteger(value) && value > 0 ? value : null;
|
|
739
|
+
}
|
|
740
|
+
if (typeof value === "string" && /^[1-9]\d*$/.test(value.trim())) {
|
|
741
|
+
const parsed = Number(value.trim());
|
|
742
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
743
|
+
}
|
|
744
|
+
return null;
|
|
745
|
+
}
|
|
746
|
+
function normalizeHex(value, length) {
|
|
747
|
+
const normalized = value?.toLowerCase().replace(/^0x/, "");
|
|
748
|
+
if (!normalized) {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
const pattern = length === null ? /^[a-f0-9]+$/ : new RegExp(`^[a-f0-9]{${length}}$`);
|
|
752
|
+
return pattern.test(normalized) ? normalized : null;
|
|
753
|
+
}
|
|
754
|
+
function validCasperPublicKey(value) {
|
|
755
|
+
const normalized = normalizeHex(value, null);
|
|
756
|
+
if (!normalized) {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
return /^01[a-f0-9]{64}$/.test(normalized) || /^02(02|03)[a-f0-9]{64}$/.test(normalized);
|
|
760
|
+
}
|
|
761
|
+
function matchesNormalized(left, right) {
|
|
762
|
+
return left.toLowerCase() === right.toLowerCase();
|
|
763
|
+
}
|
|
764
|
+
function networkMatches(left, right) {
|
|
765
|
+
const normalizedLeft = left.toLowerCase();
|
|
766
|
+
const normalizedRight = right.toLowerCase();
|
|
767
|
+
if (normalizedLeft === normalizedRight) {
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
const leftSuffix = normalizedLeft.split(":").at(-1);
|
|
771
|
+
const rightSuffix = normalizedRight.split(":").at(-1);
|
|
772
|
+
return Boolean(leftSuffix && rightSuffix && leftSuffix === rightSuffix);
|
|
773
|
+
}
|
|
774
|
+
function mod(value, by) {
|
|
775
|
+
const result = value % by;
|
|
776
|
+
return result >= 0n ? result : result + by;
|
|
777
|
+
}
|
|
778
|
+
function modPow(value, exponent, by) {
|
|
779
|
+
let result = 1n;
|
|
780
|
+
let base = mod(value, by);
|
|
781
|
+
let remaining = exponent;
|
|
782
|
+
while (remaining > 0n) {
|
|
783
|
+
if (remaining & 1n) {
|
|
784
|
+
result = mod(result * base, by);
|
|
785
|
+
}
|
|
786
|
+
base = mod(base * base, by);
|
|
787
|
+
remaining >>= 1n;
|
|
788
|
+
}
|
|
789
|
+
return result;
|
|
790
|
+
}
|
|
791
|
+
function bigIntToBuffer(value, length) {
|
|
792
|
+
const hex = value.toString(16);
|
|
793
|
+
if (hex.length > length * 2) {
|
|
794
|
+
throw new Error("Integer does not fit fixed buffer");
|
|
795
|
+
}
|
|
796
|
+
return Buffer.from(hex.padStart(length * 2, "0"), "hex");
|
|
797
|
+
}
|
|
798
|
+
function errorMessage(error) {
|
|
799
|
+
return error instanceof Error ? error.message : String(error);
|
|
800
|
+
}
|