@genesis-tech/genesispay-seller 0.13.1 → 1.3.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/CHANGELOG.md +206 -0
- package/README.md +670 -91
- package/dist/checkout-documents.d.ts +99 -0
- package/dist/checkout-documents.d.ts.map +1 -0
- package/dist/checkout-documents.js +155 -0
- package/dist/checkout-documents.js.map +1 -0
- package/dist/checkout-return.d.ts +41 -0
- package/dist/checkout-return.d.ts.map +1 -0
- package/dist/checkout-return.js +68 -0
- package/dist/checkout-return.js.map +1 -0
- package/dist/client.d.ts +318 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +718 -0
- package/dist/client.js.map +1 -0
- package/dist/contract-mandates.d.ts +39 -0
- package/dist/contract-mandates.d.ts.map +1 -0
- package/dist/contract-mandates.js +98 -0
- package/dist/contract-mandates.js.map +1 -0
- package/dist/contract-usage.d.ts +29 -0
- package/dist/contract-usage.d.ts.map +1 -0
- package/dist/contract-usage.js +92 -0
- package/dist/contract-usage.js.map +1 -0
- package/dist/customers.d.ts +41 -0
- package/dist/customers.d.ts.map +1 -0
- package/dist/customers.js +76 -0
- package/dist/customers.js.map +1 -0
- package/dist/entitlements.d.ts +19 -0
- package/dist/entitlements.d.ts.map +1 -0
- package/dist/entitlements.js +30 -0
- package/dist/entitlements.js.map +1 -0
- package/dist/errors.d.ts +129 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +273 -0
- package/dist/errors.js.map +1 -0
- package/dist/fulfillment-attempts.d.ts +29 -0
- package/dist/fulfillment-attempts.d.ts.map +1 -0
- package/dist/fulfillment-attempts.js +71 -0
- package/dist/fulfillment-attempts.js.map +1 -0
- package/dist/fulfillment.d.ts +152 -0
- package/dist/fulfillment.d.ts.map +1 -0
- package/dist/fulfillment.js +810 -0
- package/dist/fulfillment.js.map +1 -0
- package/dist/genesispay-settlement.d.ts +32 -0
- package/dist/genesispay-settlement.d.ts.map +1 -0
- package/dist/genesispay-settlement.js +500 -0
- package/dist/genesispay-settlement.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/invoices.d.ts +111 -0
- package/dist/invoices.d.ts.map +1 -0
- package/dist/invoices.js +192 -0
- package/dist/invoices.js.map +1 -0
- package/dist/item-tax.d.ts +15 -0
- package/dist/item-tax.d.ts.map +1 -0
- package/dist/item-tax.js +19 -0
- package/dist/item-tax.js.map +1 -0
- package/dist/mandate-gate.d.ts +54 -0
- package/dist/mandate-gate.d.ts.map +1 -0
- package/dist/mandate-gate.js +183 -0
- package/dist/mandate-gate.js.map +1 -0
- package/dist/mandates.d.ts +189 -0
- package/dist/mandates.d.ts.map +1 -0
- package/dist/mandates.js +234 -0
- package/dist/mandates.js.map +1 -0
- package/dist/networks.d.ts +10 -0
- package/dist/networks.d.ts.map +1 -0
- package/dist/networks.js +23 -0
- package/dist/networks.js.map +1 -0
- package/dist/payment-gate.d.ts +98 -0
- package/dist/payment-gate.d.ts.map +1 -0
- package/dist/payment-gate.js +370 -0
- package/dist/payment-gate.js.map +1 -0
- package/dist/plans.d.ts +68 -0
- package/dist/plans.d.ts.map +1 -0
- package/dist/plans.js +127 -0
- package/dist/plans.js.map +1 -0
- package/dist/products.d.ts +170 -0
- package/dist/products.d.ts.map +1 -0
- package/dist/products.js +1317 -0
- package/dist/products.js.map +1 -0
- package/dist/resource.d.ts +47 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +71 -0
- package/dist/resource.js.map +1 -0
- package/dist/strict-response.d.ts +21 -0
- package/dist/strict-response.d.ts.map +1 -0
- package/dist/strict-response.js +68 -0
- package/dist/strict-response.js.map +1 -0
- package/dist/usdc-amount.d.ts +7 -0
- package/dist/usdc-amount.d.ts.map +1 -0
- package/dist/usdc-amount.js +21 -0
- package/dist/usdc-amount.js.map +1 -0
- package/dist/webhooks.d.ts +352 -0
- package/dist/webhooks.d.ts.map +1 -0
- package/dist/webhooks.js +244 -0
- package/dist/webhooks.js.map +1 -0
- package/package.json +4 -3
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
// This intentionally stays one authority-boundary module even though it is
|
|
2
|
+
// large: the public contract, request serializer, strict wire parser, registry
|
|
3
|
+
// cross-check, and expected-contract comparator must evolve atomically. Splitting
|
|
4
|
+
// those pieces across tolerant resource helpers would make it possible to parse
|
|
5
|
+
// evidence under one rule set and authorize it under another; the repetition
|
|
6
|
+
// here is the audit trail for every field that can release fulfilment.
|
|
7
|
+
import { GenesisPayAmbiguousLocatorError, GenesisPayConfigError, GenesisPayContractMismatchError, GenesisPayEvidenceError, GenesisPayRateLimitError, GenesisPayValidationError, GenesisPayVersionError, } from "./errors.js";
|
|
8
|
+
import { GENESISPAY_ASSET_DEPLOYMENTS, } from "@genesis-tech/genesispay-protocol";
|
|
9
|
+
import { createFulfillmentAttemptsLister } from "./fulfillment-attempts.js";
|
|
10
|
+
export const GENESISPAY_API_VERSION = "2026-08-26";
|
|
11
|
+
export const GENESISPAY_VERSION_HEADER = "GENESISPAY-Version";
|
|
12
|
+
export const GENESISPAY_REQUEST_ID_HEADER = "GENESISPAY-Request-Id";
|
|
13
|
+
export const GENESISPAY_PAYMENT_ATTEMPT_ID_HEADER = "GENESISPAY-Payment-Attempt-Id";
|
|
14
|
+
const POSTGRES_BIGINT_MAX = 9223372036854775807n;
|
|
15
|
+
const PRODUCT_PRICE_MINOR_MAX = 1000000000000n;
|
|
16
|
+
const CANONICAL_MONEY_PATTERN = /^(0|[1-9][0-9]*)$/;
|
|
17
|
+
const EVM_ADDRESS_PATTERN = /^0x[0-9a-fA-F]{40}$/;
|
|
18
|
+
const TRANSACTION_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/;
|
|
19
|
+
const FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
20
|
+
const ASSET_REGISTRY = {
|
|
21
|
+
live: {
|
|
22
|
+
USDC: toNetworkContract(GENESISPAY_ASSET_DEPLOYMENTS.live.USDC),
|
|
23
|
+
EURC: toNetworkContract(GENESISPAY_ASSET_DEPLOYMENTS.live.EURC),
|
|
24
|
+
},
|
|
25
|
+
test: {
|
|
26
|
+
USDC: toNetworkContract(GENESISPAY_ASSET_DEPLOYMENTS.test.USDC),
|
|
27
|
+
EURC: toNetworkContract(GENESISPAY_ASSET_DEPLOYMENTS.test.EURC),
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
function toNetworkContract(deployment) {
|
|
31
|
+
return {
|
|
32
|
+
mode: deployment.mode,
|
|
33
|
+
network: deployment.network,
|
|
34
|
+
chainId: deployment.chainId,
|
|
35
|
+
asset: deployment.asset,
|
|
36
|
+
tokenAddress: deployment.tokenAddress,
|
|
37
|
+
minorUnitScale: deployment.minorUnitScale,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function createFulfillmentResource(options) {
|
|
41
|
+
return {
|
|
42
|
+
listAttempts: createFulfillmentAttemptsLister(options),
|
|
43
|
+
async verify(input) {
|
|
44
|
+
const locator = validateLocator(input?.locator);
|
|
45
|
+
const expected = validateExpectedContract(input?.expected);
|
|
46
|
+
// The immutable expected contract and client pin must agree. Historical
|
|
47
|
+
// payments remain readable with a client pinned to their original wallet;
|
|
48
|
+
// an expected contract cannot silently override local policy (MR-102/202).
|
|
49
|
+
options.assertPinnedDestination(expected.settlementDestination, "fulfillment contract");
|
|
50
|
+
let response;
|
|
51
|
+
try {
|
|
52
|
+
response = await options.fetchFn(`${options.baseUrl}/api/v1/fulfillment/verify`, {
|
|
53
|
+
method: "POST",
|
|
54
|
+
headers: {
|
|
55
|
+
authorization: `Bearer ${options.apiKey}`,
|
|
56
|
+
"content-type": "application/json",
|
|
57
|
+
accept: "application/json",
|
|
58
|
+
[GENESISPAY_VERSION_HEADER]: GENESISPAY_API_VERSION,
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({ locator, expected: toWireExpectedContract(expected) }),
|
|
61
|
+
cache: "no-store",
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
throw new GenesisPayConfigError(`Failed to reach GenesisPay to verify fulfilment: ${describeError(error)}`);
|
|
66
|
+
}
|
|
67
|
+
const rawBody = await readJsonBody(response);
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throwFulfillmentHttpError(response, rawBody);
|
|
70
|
+
}
|
|
71
|
+
const root = requireRecord(rawBody, "response", metadataFromResponse(response, rawBody));
|
|
72
|
+
const metadata = requireResponseMetadata(response, root);
|
|
73
|
+
requireExact(root.object, "fulfillment_verification", "object", metadata);
|
|
74
|
+
if (root.verified === false) {
|
|
75
|
+
return {
|
|
76
|
+
verified: false,
|
|
77
|
+
reason: requireOneOf(root.reason, ["not_found", "not_confirmed", "simulated", "entitlement_invalid"], "reason", metadata),
|
|
78
|
+
requestId: metadata.requestId,
|
|
79
|
+
apiVersion: GENESISPAY_API_VERSION,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (root.verified !== true) {
|
|
83
|
+
malformed("verified must be the boolean true or false", metadata);
|
|
84
|
+
}
|
|
85
|
+
const evidence = parseEvidence(root.evidence, metadata);
|
|
86
|
+
const mismatches = compareEvidence(evidence, expected, locator);
|
|
87
|
+
if (mismatches.length > 0) {
|
|
88
|
+
throw new GenesisPayContractMismatchError(`GenesisPay payment evidence does not match the expected contract (${mismatches.join(", ")}).`, mismatches, metadata);
|
|
89
|
+
}
|
|
90
|
+
// Entitlement locators authorize delivery, whereas attempt/link locators
|
|
91
|
+
// prove immutable payment history even after delivery expires or is revoked.
|
|
92
|
+
if (locator.entitlementId !== undefined && evidence.entitlement?.valid !== true) {
|
|
93
|
+
inconsistent("a verified entitlement lookup must carry valid delivery authority", metadata);
|
|
94
|
+
}
|
|
95
|
+
const payment = {
|
|
96
|
+
attemptId: evidence.attemptId,
|
|
97
|
+
linkId: evidence.linkId,
|
|
98
|
+
entitlementId: evidence.entitlementId,
|
|
99
|
+
entitlement: evidence.entitlement,
|
|
100
|
+
paymentChannel: evidence.paymentChannel,
|
|
101
|
+
confirmedAt: evidence.confirmedAt,
|
|
102
|
+
transactionHash: evidence.transactionHash,
|
|
103
|
+
grossAmountMinor: evidence.grossAmountMinor,
|
|
104
|
+
sellerAmountMinor: evidence.sellerAmountMinor,
|
|
105
|
+
feeTerms: evidence.feeTerms,
|
|
106
|
+
feeCollection: evidence.feeCollection,
|
|
107
|
+
settlementDestination: evidence.settlementDestination,
|
|
108
|
+
network: evidence.network,
|
|
109
|
+
product: evidence.product,
|
|
110
|
+
};
|
|
111
|
+
return {
|
|
112
|
+
verified: true,
|
|
113
|
+
requestId: metadata.requestId,
|
|
114
|
+
apiVersion: GENESISPAY_API_VERSION,
|
|
115
|
+
payment,
|
|
116
|
+
};
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function validateLocator(value) {
|
|
121
|
+
const record = plainRecord(value);
|
|
122
|
+
const fields = ["attemptId", "linkId", "entitlementId"];
|
|
123
|
+
const supplied = fields.filter((field) => record !== null && Object.prototype.hasOwnProperty.call(record, field));
|
|
124
|
+
if (supplied.length !== 1) {
|
|
125
|
+
throw new GenesisPayAmbiguousLocatorError("fulfillment.verify requires exactly one non-empty attemptId, linkId, or entitlementId.");
|
|
126
|
+
}
|
|
127
|
+
const field = supplied[0];
|
|
128
|
+
const locator = record?.[field];
|
|
129
|
+
if (typeof locator !== "string" || locator.trim().length === 0) {
|
|
130
|
+
throw new GenesisPayAmbiguousLocatorError("fulfillment.verify requires exactly one non-empty attemptId, linkId, or entitlementId.");
|
|
131
|
+
}
|
|
132
|
+
return { [field]: locator.trim() };
|
|
133
|
+
}
|
|
134
|
+
function validateExpectedContract(value) {
|
|
135
|
+
const record = plainRecord(value);
|
|
136
|
+
const issues = [];
|
|
137
|
+
if (!record || (record.kind !== "product" && record.kind !== "payment_link")) {
|
|
138
|
+
throw new GenesisPayValidationError('fulfillment.verify expected.kind must be "product" or "payment_link".', [{ path: "expected.kind", message: "unsupported contract kind" }]);
|
|
139
|
+
}
|
|
140
|
+
const grossAmountMinor = validateInputMoney(record.grossAmountMinor, "expected.grossAmountMinor", record.kind === "product" ? PRODUCT_PRICE_MINOR_MAX : POSTGRES_BIGINT_MAX, issues);
|
|
141
|
+
const network = validateInputNetwork(record.network, issues);
|
|
142
|
+
const settlementDestination = validateInputAddress(record.settlementDestination, "expected.settlementDestination", issues);
|
|
143
|
+
if (record.kind === "product") {
|
|
144
|
+
const productId = validateInputNonEmptyString(record.productId, "expected.productId", issues);
|
|
145
|
+
const sku = validateInputNullableString(record.sku, "expected.sku", issues);
|
|
146
|
+
const delivery = validateInputDelivery(record.delivery, issues);
|
|
147
|
+
throwInputIssues(issues);
|
|
148
|
+
return {
|
|
149
|
+
kind: "product",
|
|
150
|
+
productId,
|
|
151
|
+
sku,
|
|
152
|
+
grossAmountMinor,
|
|
153
|
+
network,
|
|
154
|
+
settlementDestination,
|
|
155
|
+
delivery,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const linkId = validateInputNonEmptyString(record.linkId, "expected.linkId", issues);
|
|
159
|
+
throwInputIssues(issues);
|
|
160
|
+
return {
|
|
161
|
+
kind: "payment_link",
|
|
162
|
+
linkId,
|
|
163
|
+
grossAmountMinor,
|
|
164
|
+
network,
|
|
165
|
+
settlementDestination,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
/** Internal shared validation for product gates before any payment negotiation begins. */
|
|
169
|
+
export function validateExpectedProductContract(value) {
|
|
170
|
+
const expected = validateExpectedContract(value);
|
|
171
|
+
if (expected.kind !== "product") {
|
|
172
|
+
throw new GenesisPayValidationError("A product gate requires an ExpectedProductContract.", [{ path: "expected.kind", message: "must be product" }]);
|
|
173
|
+
}
|
|
174
|
+
return expected;
|
|
175
|
+
}
|
|
176
|
+
function validateInputMoney(value, path, maximum, issues) {
|
|
177
|
+
if (typeof value !== "bigint") {
|
|
178
|
+
issues.push({ path, message: "must be bigint minor units" });
|
|
179
|
+
return 0n;
|
|
180
|
+
}
|
|
181
|
+
if (value <= 0n || value > maximum) {
|
|
182
|
+
issues.push({ path, message: `must be between 1 and ${maximum.toString()} minor units` });
|
|
183
|
+
}
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
function validateInputNetwork(value, issues) {
|
|
187
|
+
const raw = plainRecord(value);
|
|
188
|
+
if (!raw) {
|
|
189
|
+
issues.push({ path: "expected.network", message: "must be a network contract" });
|
|
190
|
+
return { ...ASSET_REGISTRY.test.USDC };
|
|
191
|
+
}
|
|
192
|
+
const mode = raw.mode === "test" || raw.mode === "live" ? raw.mode : null;
|
|
193
|
+
const asset = raw.asset === "USDC" || raw.asset === "EURC" ? raw.asset : null;
|
|
194
|
+
if (!mode)
|
|
195
|
+
issues.push({ path: "expected.network.mode", message: "must be test or live" });
|
|
196
|
+
if (raw.network !== "base") {
|
|
197
|
+
issues.push({ path: "expected.network.network", message: "must be base" });
|
|
198
|
+
}
|
|
199
|
+
if (!asset)
|
|
200
|
+
issues.push({ path: "expected.network.asset", message: "must be USDC or EURC" });
|
|
201
|
+
const known = mode && asset ? ASSET_REGISTRY[mode][asset] : ASSET_REGISTRY.test.USDC;
|
|
202
|
+
if (raw.chainId !== known.chainId) {
|
|
203
|
+
issues.push({ path: "expected.network.chainId", message: "does not match mode" });
|
|
204
|
+
}
|
|
205
|
+
if (raw.minorUnitScale !== 6) {
|
|
206
|
+
issues.push({ path: "expected.network.minorUnitScale", message: "must be 6" });
|
|
207
|
+
}
|
|
208
|
+
if (typeof raw.tokenAddress !== "string" ||
|
|
209
|
+
!EVM_ADDRESS_PATTERN.test(raw.tokenAddress) ||
|
|
210
|
+
raw.tokenAddress.toLowerCase() !== known.tokenAddress.toLowerCase()) {
|
|
211
|
+
issues.push({
|
|
212
|
+
path: "expected.network.tokenAddress",
|
|
213
|
+
message: "does not match the canonical asset deployment",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return { ...known };
|
|
217
|
+
}
|
|
218
|
+
function validateInputDelivery(value, issues) {
|
|
219
|
+
const raw = plainRecord(value);
|
|
220
|
+
if (!raw || (raw.type !== "none" && raw.type !== "url" && raw.type !== "gate")) {
|
|
221
|
+
issues.push({ path: "expected.delivery.type", message: "must be none, url, or gate" });
|
|
222
|
+
return { type: "none", url: null, gate: null };
|
|
223
|
+
}
|
|
224
|
+
if (raw.type === "none") {
|
|
225
|
+
if (raw.url !== null)
|
|
226
|
+
issues.push({ path: "expected.delivery.url", message: "must be null" });
|
|
227
|
+
if (raw.gate !== null) {
|
|
228
|
+
issues.push({ path: "expected.delivery.gate", message: "must be null" });
|
|
229
|
+
}
|
|
230
|
+
return { type: "none", url: null, gate: null };
|
|
231
|
+
}
|
|
232
|
+
if (raw.type === "url") {
|
|
233
|
+
const url = validateInputUrl(raw.url, "expected.delivery.url", issues);
|
|
234
|
+
if (raw.gate !== null) {
|
|
235
|
+
issues.push({ path: "expected.delivery.gate", message: "must be null" });
|
|
236
|
+
}
|
|
237
|
+
return { type: "url", url, gate: null };
|
|
238
|
+
}
|
|
239
|
+
if (raw.url !== null)
|
|
240
|
+
issues.push({ path: "expected.delivery.url", message: "must be null" });
|
|
241
|
+
const gate = plainRecord(raw.gate);
|
|
242
|
+
const method = validateInputMethod(gate?.method, "expected.delivery.gate.method", issues);
|
|
243
|
+
const resourceUrl = validateInputUrl(gate?.resourceUrl, "expected.delivery.gate.resourceUrl", issues);
|
|
244
|
+
const fingerprint = typeof gate?.fingerprint === "string" && FINGERPRINT_PATTERN.test(gate.fingerprint)
|
|
245
|
+
? gate.fingerprint
|
|
246
|
+
: "";
|
|
247
|
+
if (!fingerprint) {
|
|
248
|
+
issues.push({
|
|
249
|
+
path: "expected.delivery.gate.fingerprint",
|
|
250
|
+
message: "must be a lowercase sha256 fingerprint",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
return { type: "gate", url: null, gate: { method, resourceUrl, fingerprint } };
|
|
254
|
+
}
|
|
255
|
+
function validateInputMethod(value, path, issues) {
|
|
256
|
+
if (typeof value !== "string" || !/^[A-Z]+$/.test(value)) {
|
|
257
|
+
issues.push({ path, message: "must be an uppercase HTTP method" });
|
|
258
|
+
return "";
|
|
259
|
+
}
|
|
260
|
+
return value;
|
|
261
|
+
}
|
|
262
|
+
function validateInputUrl(value, path, issues) {
|
|
263
|
+
try {
|
|
264
|
+
if (typeof value !== "string")
|
|
265
|
+
throw new Error("not a string");
|
|
266
|
+
return canonicalAuthorityUrl(value);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
issues.push({ path, message: "must be an absolute https URL without credentials or fragment" });
|
|
270
|
+
return "";
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
function validateInputAddress(value, path, issues) {
|
|
274
|
+
if (typeof value !== "string" || !EVM_ADDRESS_PATTERN.test(value)) {
|
|
275
|
+
issues.push({ path, message: "must be a 20-byte 0x address" });
|
|
276
|
+
return "0x0000000000000000000000000000000000000000";
|
|
277
|
+
}
|
|
278
|
+
return value;
|
|
279
|
+
}
|
|
280
|
+
function validateInputNonEmptyString(value, path, issues) {
|
|
281
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
282
|
+
issues.push({ path, message: "must be a non-empty string" });
|
|
283
|
+
return "";
|
|
284
|
+
}
|
|
285
|
+
return value.trim();
|
|
286
|
+
}
|
|
287
|
+
function validateInputNullableString(value, path, issues) {
|
|
288
|
+
if (value === null)
|
|
289
|
+
return null;
|
|
290
|
+
return validateInputNonEmptyString(value, path, issues);
|
|
291
|
+
}
|
|
292
|
+
function throwInputIssues(issues) {
|
|
293
|
+
if (issues.length > 0) {
|
|
294
|
+
throw new GenesisPayValidationError("fulfillment.verify rejected the expected payment contract.", issues);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function toWireExpectedContract(expected) {
|
|
298
|
+
return {
|
|
299
|
+
...expected,
|
|
300
|
+
grossAmountMinor: expected.grossAmountMinor.toString(),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
/** Internal shape check after webhook HMAC verification, not authorization. */
|
|
304
|
+
export function assertFulfillmentEventEvidence(value) {
|
|
305
|
+
parseEvidence(value, { requestId: null, apiVersion: GENESISPAY_API_VERSION });
|
|
306
|
+
}
|
|
307
|
+
function parseEvidence(value, metadata) {
|
|
308
|
+
const raw = requireRecord(value, "evidence", metadata);
|
|
309
|
+
requireExact(raw.schema, "genesispay.fulfillment-evidence", "evidence.schema", metadata);
|
|
310
|
+
const version = requireString(raw.version, "evidence.version", metadata);
|
|
311
|
+
if (version !== GENESISPAY_API_VERSION) {
|
|
312
|
+
throw new GenesisPayVersionError("unsupported_evidence_version", `Unsupported fulfillment evidence version "${version}".`, metadata);
|
|
313
|
+
}
|
|
314
|
+
if (raw.authorityVersion === null || raw.authorityVersion === undefined) {
|
|
315
|
+
throw new GenesisPayEvidenceError("historical_evidence_unavailable", "This payment predates strict authority evidence and cannot authorize fulfillment.", metadata);
|
|
316
|
+
}
|
|
317
|
+
const authorityVersion = requireString(raw.authorityVersion, "evidence.authorityVersion", metadata);
|
|
318
|
+
if (authorityVersion !== GENESISPAY_API_VERSION) {
|
|
319
|
+
throw new GenesisPayVersionError("unsupported_evidence_version", `Unsupported payment authority version "${authorityVersion}".`, metadata);
|
|
320
|
+
}
|
|
321
|
+
requireExact(raw.status, "confirmed", "evidence.status", metadata, "inconsistent_evidence");
|
|
322
|
+
const simulation = requireRecord(raw.simulation, "evidence.simulation", metadata);
|
|
323
|
+
if (simulation.simulated !== false) {
|
|
324
|
+
inconsistent("evidence.simulation.simulated must be the literal false", metadata);
|
|
325
|
+
}
|
|
326
|
+
const paymentChannel = requirePaymentChannel(raw.paymentChannel, metadata);
|
|
327
|
+
const settlement = requireRecord(raw.settlement, "evidence.settlement", metadata);
|
|
328
|
+
const feeTerms = parseFeeTerms(settlement.feeTerms, metadata);
|
|
329
|
+
const feeCollection = parseFeeCollection(settlement.feeCollection, metadata);
|
|
330
|
+
const grossAmountMinor = parseMoney(settlement.grossAmountMinor, "evidence.settlement.grossAmountMinor", metadata);
|
|
331
|
+
const sellerAmountMinor = parseMoney(settlement.sellerAmountMinor, "evidence.settlement.sellerAmountMinor", metadata);
|
|
332
|
+
if (grossAmountMinor <= 0n) {
|
|
333
|
+
inconsistent("evidence gross amount must be positive", metadata);
|
|
334
|
+
}
|
|
335
|
+
if (feeTerms.amountMinor > grossAmountMinor) {
|
|
336
|
+
inconsistent("the quoted fee must not exceed the gross payment", metadata);
|
|
337
|
+
}
|
|
338
|
+
// The split the SELLER leg moved is decided by what the payer authorized. A
|
|
339
|
+
// record-only fee is owed to the platform but was never deducted, so it must
|
|
340
|
+
// not be subtracted from what the seller received — and a fee the ledger has
|
|
341
|
+
// not collected must never silently balance this equation either.
|
|
342
|
+
const authorizedFeeMinor = feeTerms.type === "payer_authorized" ? feeTerms.amountMinor : 0n;
|
|
343
|
+
if (authorizedFeeMinor >= grossAmountMinor) {
|
|
344
|
+
inconsistent("the payer-authorized fee must leave a positive seller payment", metadata);
|
|
345
|
+
}
|
|
346
|
+
if (sellerAmountMinor + authorizedFeeMinor !== grossAmountMinor) {
|
|
347
|
+
inconsistent("sellerAmountMinor plus the payer-authorized fee must equal grossAmountMinor", metadata);
|
|
348
|
+
}
|
|
349
|
+
if (feeCollection.amountMinor !== null && feeCollection.amountMinor !== feeTerms.amountMinor) {
|
|
350
|
+
inconsistent("evidence.settlement.feeCollection.amountMinor contradicts the frozen fee terms", metadata);
|
|
351
|
+
}
|
|
352
|
+
const entitlementId = requireNullableString(raw.entitlementId, "evidence.entitlementId", metadata);
|
|
353
|
+
const network = parseNetwork(raw.network, metadata);
|
|
354
|
+
const product = parseProduct(raw.product, metadata);
|
|
355
|
+
if (product && grossAmountMinor > PRODUCT_PRICE_MINOR_MAX) {
|
|
356
|
+
inconsistent("product gross amount exceeds the product-domain maximum", metadata);
|
|
357
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
sellerId: requireString(raw.sellerId, "evidence.sellerId", metadata),
|
|
360
|
+
linkId: requireString(raw.linkId, "evidence.linkId", metadata),
|
|
361
|
+
attemptId: requireString(raw.attemptId, "evidence.attemptId", metadata),
|
|
362
|
+
entitlementId,
|
|
363
|
+
entitlement: parseEntitlementState(raw.entitlement, entitlementId, metadata),
|
|
364
|
+
paymentChannel,
|
|
365
|
+
confirmedAt: requireTimestamp(raw.confirmedAt, "evidence.confirmedAt", metadata),
|
|
366
|
+
transactionHash: requireTransactionHash(raw.transactionHash, "evidence.transactionHash", metadata),
|
|
367
|
+
grossAmountMinor,
|
|
368
|
+
sellerAmountMinor,
|
|
369
|
+
feeTerms,
|
|
370
|
+
feeCollection,
|
|
371
|
+
settlementDestination: requireAddress(settlement.destination, "evidence.settlement.destination", metadata),
|
|
372
|
+
settlementStrategy: requireOneOf(settlement.strategy, ["dual", "atomic"], "evidence.settlement.strategy", metadata),
|
|
373
|
+
network,
|
|
374
|
+
product,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function parseFeeTerms(value, metadata) {
|
|
378
|
+
const raw = requireRecord(value, "evidence.settlement.feeTerms", metadata);
|
|
379
|
+
const type = requireOneOf(raw.type, ["record_only", "payer_authorized"], "evidence.settlement.feeTerms.type", metadata);
|
|
380
|
+
const amountMinor = parseMoney(raw.amountMinor, "evidence.settlement.feeTerms.amountMinor", metadata);
|
|
381
|
+
// An authorized fee leg names the treasury the payer signed to; a record-only
|
|
382
|
+
// fee has no signed destination and must not invent one.
|
|
383
|
+
if (type === "record_only") {
|
|
384
|
+
requireNull(raw.treasuryAddress, "evidence.settlement.feeTerms.treasuryAddress", metadata);
|
|
385
|
+
return { type, amountMinor, treasuryAddress: null };
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
type,
|
|
389
|
+
amountMinor,
|
|
390
|
+
treasuryAddress: requireAddress(raw.treasuryAddress, "evidence.settlement.feeTerms.treasuryAddress", metadata),
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
function parseFeeCollection(value, metadata) {
|
|
394
|
+
const raw = requireRecord(value, "evidence.settlement.feeCollection", metadata);
|
|
395
|
+
const status = requireOneOf(raw.status, [
|
|
396
|
+
"not_recorded",
|
|
397
|
+
"recorded",
|
|
398
|
+
"pending",
|
|
399
|
+
"collecting",
|
|
400
|
+
"collected",
|
|
401
|
+
"failed",
|
|
402
|
+
"written_off",
|
|
403
|
+
], "evidence.settlement.feeCollection.status", metadata);
|
|
404
|
+
const amountMinor = raw.amountMinor === null
|
|
405
|
+
? null
|
|
406
|
+
: parseMoney(raw.amountMinor, "evidence.settlement.feeCollection.amountMinor", metadata);
|
|
407
|
+
const treasuryAddress = raw.treasuryAddress === null
|
|
408
|
+
? null
|
|
409
|
+
: requireAddress(raw.treasuryAddress, "evidence.settlement.feeCollection.treasuryAddress", metadata);
|
|
410
|
+
const transactionHash = raw.transactionHash === null
|
|
411
|
+
? null
|
|
412
|
+
: requireTransactionHash(raw.transactionHash, "evidence.settlement.feeCollection.transactionHash", metadata);
|
|
413
|
+
const collectedAt = raw.collectedAt === null
|
|
414
|
+
? null
|
|
415
|
+
: requireTimestamp(raw.collectedAt, "evidence.settlement.feeCollection.collectedAt", metadata);
|
|
416
|
+
// `collected` is the only status that claims tokens moved, and it must carry
|
|
417
|
+
// the transfer that moved them. `failed` asserts the opposite, so a hash there
|
|
418
|
+
// is a contradiction; `not_recorded` means no ledger row exists at all.
|
|
419
|
+
if (status === "collected" && (!transactionHash || !collectedAt || amountMinor === null)) {
|
|
420
|
+
inconsistent("a collected fee must carry its amount, transaction hash and time", metadata);
|
|
421
|
+
}
|
|
422
|
+
if (status === "failed" && transactionHash) {
|
|
423
|
+
inconsistent("a failed fee collection must carry no transaction hash", metadata);
|
|
424
|
+
}
|
|
425
|
+
if (status === "not_recorded" &&
|
|
426
|
+
(amountMinor !== null ||
|
|
427
|
+
treasuryAddress !== null ||
|
|
428
|
+
transactionHash !== null ||
|
|
429
|
+
collectedAt !== null)) {
|
|
430
|
+
inconsistent("an unrecorded fee collection must carry no ledger detail", metadata);
|
|
431
|
+
}
|
|
432
|
+
if (status !== "not_recorded" && (amountMinor === null || treasuryAddress === null)) {
|
|
433
|
+
inconsistent("a recorded fee collection must name its amount and treasury", metadata);
|
|
434
|
+
}
|
|
435
|
+
return { status, amountMinor, treasuryAddress, transactionHash, collectedAt };
|
|
436
|
+
}
|
|
437
|
+
function parseEntitlementState(value, entitlementId, metadata) {
|
|
438
|
+
if (value === undefined) {
|
|
439
|
+
malformed("evidence.entitlement is required (null when there is none)", metadata);
|
|
440
|
+
}
|
|
441
|
+
// Both or neither: an id with no state would hide a revocation, and state
|
|
442
|
+
// with no id names nothing.
|
|
443
|
+
if (value === null) {
|
|
444
|
+
if (entitlementId !== null) {
|
|
445
|
+
inconsistent("evidence names an entitlement but omits its state", metadata);
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
if (entitlementId === null) {
|
|
450
|
+
inconsistent("evidence carries entitlement state without an entitlementId", metadata);
|
|
451
|
+
}
|
|
452
|
+
const raw = requireRecord(value, "evidence.entitlement", metadata);
|
|
453
|
+
const expiresAt = requireTimestamp(raw.expiresAt, "evidence.entitlement.expiresAt", metadata);
|
|
454
|
+
const revokedAt = raw.revokedAt === null
|
|
455
|
+
? null
|
|
456
|
+
: requireTimestamp(raw.revokedAt, "evidence.entitlement.revokedAt", metadata);
|
|
457
|
+
if (typeof raw.valid !== "boolean") {
|
|
458
|
+
malformed("evidence.entitlement.valid must be a boolean", metadata);
|
|
459
|
+
}
|
|
460
|
+
if (raw.valid && revokedAt !== null) {
|
|
461
|
+
inconsistent("a revoked entitlement cannot be valid", metadata);
|
|
462
|
+
}
|
|
463
|
+
return { expiresAt, revokedAt, valid: raw.valid };
|
|
464
|
+
}
|
|
465
|
+
function parseNetwork(value, metadata) {
|
|
466
|
+
const raw = requireRecord(value, "evidence.network", metadata);
|
|
467
|
+
const mode = requireOneOf(raw.mode, ["test", "live"], "evidence.network.mode", metadata);
|
|
468
|
+
const asset = requireOneOf(raw.asset, ["USDC", "EURC"], "evidence.network.asset", metadata);
|
|
469
|
+
const known = ASSET_REGISTRY[mode][asset];
|
|
470
|
+
requireExact(raw.network, "base", "evidence.network.network", metadata, "inconsistent_evidence");
|
|
471
|
+
if (raw.chainId !== known.chainId) {
|
|
472
|
+
inconsistent("evidence.network.chainId contradicts deployment mode", metadata);
|
|
473
|
+
}
|
|
474
|
+
if (raw.minorUnitScale !== 6) {
|
|
475
|
+
inconsistent("evidence.network.minorUnitScale must be 6", metadata);
|
|
476
|
+
}
|
|
477
|
+
const tokenAddress = requireAddress(raw.tokenAddress, "evidence.network.tokenAddress", metadata);
|
|
478
|
+
if (tokenAddress.toLowerCase() !== known.tokenAddress.toLowerCase()) {
|
|
479
|
+
inconsistent("evidence token address contradicts the canonical asset registry", metadata);
|
|
480
|
+
}
|
|
481
|
+
// Public result objects are mutable; never expose the shared trusted registry.
|
|
482
|
+
return { ...known };
|
|
483
|
+
}
|
|
484
|
+
function parseProduct(value, metadata) {
|
|
485
|
+
if (value === null)
|
|
486
|
+
return null;
|
|
487
|
+
const raw = requireRecord(value, "evidence.product", metadata);
|
|
488
|
+
const contractVersion = requireString(raw.contractVersion, "evidence.product.contractVersion", metadata);
|
|
489
|
+
if (contractVersion !== GENESISPAY_API_VERSION) {
|
|
490
|
+
throw new GenesisPayVersionError("unsupported_evidence_version", `Unsupported product contract version "${contractVersion}".`, metadata);
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
productId: requireString(raw.productId, "evidence.product.productId", metadata),
|
|
494
|
+
sku: requireNullableString(raw.sku, "evidence.product.sku", metadata),
|
|
495
|
+
delivery: parseDelivery(raw.delivery, metadata),
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function parseDelivery(value, metadata) {
|
|
499
|
+
const raw = requireRecord(value, "evidence.product.delivery", metadata);
|
|
500
|
+
const type = requireOneOf(raw.type, ["none", "url", "gate"], "evidence.product.delivery.type", metadata);
|
|
501
|
+
if (type === "none") {
|
|
502
|
+
requireNull(raw.url, "evidence.product.delivery.url", metadata);
|
|
503
|
+
requireNull(raw.gate, "evidence.product.delivery.gate", metadata);
|
|
504
|
+
return { type, url: null, gate: null };
|
|
505
|
+
}
|
|
506
|
+
if (type === "url") {
|
|
507
|
+
requireNull(raw.gate, "evidence.product.delivery.gate", metadata);
|
|
508
|
+
return {
|
|
509
|
+
type,
|
|
510
|
+
url: requireCanonicalUrl(raw.url, "evidence.product.delivery.url", metadata),
|
|
511
|
+
gate: null,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
requireNull(raw.url, "evidence.product.delivery.url", metadata);
|
|
515
|
+
const gate = requireRecord(raw.gate, "evidence.product.delivery.gate", metadata);
|
|
516
|
+
const method = requireString(gate.method, "evidence.product.delivery.gate.method", metadata);
|
|
517
|
+
if (!/^[A-Z]+$/.test(method)) {
|
|
518
|
+
malformed("evidence.product.delivery.gate.method must be uppercase", metadata);
|
|
519
|
+
}
|
|
520
|
+
const fingerprint = requireString(gate.fingerprint, "evidence.product.delivery.gate.fingerprint", metadata);
|
|
521
|
+
if (!FINGERPRINT_PATTERN.test(fingerprint)) {
|
|
522
|
+
malformed("evidence gate fingerprint is not canonical sha256", metadata);
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
type,
|
|
526
|
+
url: null,
|
|
527
|
+
gate: {
|
|
528
|
+
method,
|
|
529
|
+
resourceUrl: requireCanonicalUrl(gate.resourceUrl, "evidence.product.delivery.gate.resourceUrl", metadata),
|
|
530
|
+
fingerprint,
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function compareEvidence(evidence, expected, locator) {
|
|
535
|
+
const mismatches = [];
|
|
536
|
+
const compare = (matches, path) => {
|
|
537
|
+
if (!matches)
|
|
538
|
+
mismatches.push(path);
|
|
539
|
+
};
|
|
540
|
+
if (locator.attemptId !== undefined) {
|
|
541
|
+
compare(evidence.attemptId === locator.attemptId, "locator.attemptId");
|
|
542
|
+
}
|
|
543
|
+
if (locator.linkId !== undefined) {
|
|
544
|
+
compare(evidence.linkId === locator.linkId, "locator.linkId");
|
|
545
|
+
}
|
|
546
|
+
if (locator.entitlementId !== undefined) {
|
|
547
|
+
compare(evidence.entitlementId === locator.entitlementId, "locator.entitlementId");
|
|
548
|
+
}
|
|
549
|
+
compare(evidence.grossAmountMinor === expected.grossAmountMinor, "grossAmountMinor");
|
|
550
|
+
compare(evidence.settlementDestination.toLowerCase() ===
|
|
551
|
+
expected.settlementDestination.toLowerCase(), "settlementDestination");
|
|
552
|
+
compare(evidence.network.mode === expected.network.mode, "network.mode");
|
|
553
|
+
compare(evidence.network.network === expected.network.network, "network.network");
|
|
554
|
+
compare(evidence.network.chainId === expected.network.chainId, "network.chainId");
|
|
555
|
+
compare(evidence.network.asset === expected.network.asset, "network.asset");
|
|
556
|
+
compare(evidence.network.tokenAddress.toLowerCase() === expected.network.tokenAddress.toLowerCase(), "network.tokenAddress");
|
|
557
|
+
compare(evidence.network.minorUnitScale === expected.network.minorUnitScale, "network.minorUnitScale");
|
|
558
|
+
if (expected.kind === "payment_link") {
|
|
559
|
+
compare(evidence.linkId === expected.linkId, "linkId");
|
|
560
|
+
// A payment-link contract claims nothing about delivery, so a product
|
|
561
|
+
// payment must not satisfy it: the product and delivery comparisons below
|
|
562
|
+
// would be skipped entirely, and a `verified: true` would read as though
|
|
563
|
+
// delivery had been checked.
|
|
564
|
+
compare(evidence.product === null, "product");
|
|
565
|
+
return mismatches;
|
|
566
|
+
}
|
|
567
|
+
if (!evidence.product) {
|
|
568
|
+
mismatches.push("product");
|
|
569
|
+
return mismatches;
|
|
570
|
+
}
|
|
571
|
+
compare(evidence.product.productId === expected.productId, "product.productId");
|
|
572
|
+
compare(evidence.product.sku === expected.sku, "product.sku");
|
|
573
|
+
compare(evidence.product.delivery.type === expected.delivery.type, "product.delivery.type");
|
|
574
|
+
compare(evidence.product.delivery.url === expected.delivery.url, "product.delivery.url");
|
|
575
|
+
compare(evidence.product.delivery.gate?.method === expected.delivery.gate?.method, "product.delivery.gate.method");
|
|
576
|
+
compare(evidence.product.delivery.gate?.resourceUrl === expected.delivery.gate?.resourceUrl, "product.delivery.gate.resourceUrl");
|
|
577
|
+
compare(evidence.product.delivery.gate?.fingerprint === expected.delivery.gate?.fingerprint, "product.delivery.gate.fingerprint");
|
|
578
|
+
return mismatches;
|
|
579
|
+
}
|
|
580
|
+
function requireResponseMetadata(response, root) {
|
|
581
|
+
const headerRequestId = response.headers.get(GENESISPAY_REQUEST_ID_HEADER);
|
|
582
|
+
const headerVersion = response.headers.get(GENESISPAY_VERSION_HEADER);
|
|
583
|
+
const bodyRequestId = typeof root.requestId === "string" ? root.requestId : null;
|
|
584
|
+
const bodyVersion = typeof root.apiVersion === "string" ? root.apiVersion : null;
|
|
585
|
+
const metadata = {
|
|
586
|
+
requestId: headerRequestId ?? bodyRequestId,
|
|
587
|
+
apiVersion: headerVersion ?? bodyVersion,
|
|
588
|
+
};
|
|
589
|
+
if (!headerVersion || !bodyVersion) {
|
|
590
|
+
malformed("versioned fulfillment response omitted apiVersion metadata", metadata);
|
|
591
|
+
}
|
|
592
|
+
if (headerVersion !== GENESISPAY_API_VERSION || bodyVersion !== GENESISPAY_API_VERSION) {
|
|
593
|
+
throw new GenesisPayVersionError("unsupported_api_version", `Unsupported GenesisPay API version "${headerVersion ?? bodyVersion ?? "missing"}".`, metadata);
|
|
594
|
+
}
|
|
595
|
+
if (!headerRequestId || !bodyRequestId) {
|
|
596
|
+
malformed("versioned fulfillment response omitted requestId metadata", metadata);
|
|
597
|
+
}
|
|
598
|
+
if (headerRequestId !== bodyRequestId || headerVersion !== bodyVersion) {
|
|
599
|
+
inconsistent("response headers and body carry different request/version metadata", metadata);
|
|
600
|
+
}
|
|
601
|
+
return { requestId: headerRequestId, apiVersion: GENESISPAY_API_VERSION };
|
|
602
|
+
}
|
|
603
|
+
function metadataFromResponse(response, body) {
|
|
604
|
+
const root = plainRecord(body);
|
|
605
|
+
return {
|
|
606
|
+
requestId: response.headers.get(GENESISPAY_REQUEST_ID_HEADER) ??
|
|
607
|
+
(typeof root?.requestId === "string" ? root.requestId : null),
|
|
608
|
+
apiVersion: response.headers.get(GENESISPAY_VERSION_HEADER) ??
|
|
609
|
+
(typeof root?.apiVersion === "string" ? root.apiVersion : null),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Maps a non-OK verification response onto a typed error.
|
|
614
|
+
*
|
|
615
|
+
* Everything here is read LENIENTLY on purpose. An error response is not
|
|
616
|
+
* evidence: every branch throws, so nothing can be believed by being
|
|
617
|
+
* well-formed. Demanding a parseable object or complete strict metadata first
|
|
618
|
+
* turned an upstream proxy's 502 HTML page into `malformed_evidence` — which
|
|
619
|
+
* `isPermanentAuthorityError` classifies as deterministic, so the product gate
|
|
620
|
+
* told the seller a confirmed payment "requires operator intervention" when a
|
|
621
|
+
* single retry would have succeeded. A deterministic verdict may only come from
|
|
622
|
+
* a backend that actually stated one.
|
|
623
|
+
*/
|
|
624
|
+
function throwFulfillmentHttpError(response, body) {
|
|
625
|
+
const root = plainRecord(body);
|
|
626
|
+
const nestedError = plainRecord(root?.error);
|
|
627
|
+
const code = typeof nestedError?.code === "string"
|
|
628
|
+
? nestedError.code
|
|
629
|
+
: typeof root?.code === "string"
|
|
630
|
+
? root.code
|
|
631
|
+
: typeof root?.error === "string"
|
|
632
|
+
? root.error
|
|
633
|
+
: null;
|
|
634
|
+
const message = (typeof nestedError?.message === "string" && nestedError.message) ||
|
|
635
|
+
(typeof root?.message === "string" && root.message) ||
|
|
636
|
+
(typeof root?.error === "string" && root.error) ||
|
|
637
|
+
`GenesisPay fulfillment verification failed with HTTP ${response.status}.`;
|
|
638
|
+
const metadata = metadataFromResponse(response, body);
|
|
639
|
+
if (code === "unsupported_api_version") {
|
|
640
|
+
throw new GenesisPayVersionError(code, message, metadata);
|
|
641
|
+
}
|
|
642
|
+
if (code === "unsupported_evidence_version") {
|
|
643
|
+
throw new GenesisPayVersionError(code, message, metadata);
|
|
644
|
+
}
|
|
645
|
+
if (code === "ambiguous_locator") {
|
|
646
|
+
throw new GenesisPayAmbiguousLocatorError(message, metadata);
|
|
647
|
+
}
|
|
648
|
+
if (code === "contract_mismatch") {
|
|
649
|
+
const mismatches = Array.isArray(root?.mismatches)
|
|
650
|
+
? root.mismatches.filter((value) => typeof value === "string")
|
|
651
|
+
: [];
|
|
652
|
+
throw new GenesisPayContractMismatchError(message, mismatches, metadata);
|
|
653
|
+
}
|
|
654
|
+
if (code === "malformed_evidence" ||
|
|
655
|
+
code === "inconsistent_evidence" ||
|
|
656
|
+
code === "historical_evidence_unavailable" ||
|
|
657
|
+
code === "unsupported_payment_channel") {
|
|
658
|
+
throw new GenesisPayEvidenceError(code, message, metadata);
|
|
659
|
+
}
|
|
660
|
+
if (response.status === 422) {
|
|
661
|
+
const issues = Array.isArray(root?.issues)
|
|
662
|
+
? root.issues.flatMap((entry) => {
|
|
663
|
+
const issue = plainRecord(entry);
|
|
664
|
+
return typeof issue?.message === "string"
|
|
665
|
+
? [{ path: typeof issue.path === "string" ? issue.path : "", message: issue.message }]
|
|
666
|
+
: [];
|
|
667
|
+
})
|
|
668
|
+
: [];
|
|
669
|
+
throw new GenesisPayValidationError(message, issues, metadata);
|
|
670
|
+
}
|
|
671
|
+
if (response.status === 429) {
|
|
672
|
+
throw new GenesisPayRateLimitError(message, parseRetryAfterSeconds(response.headers.get("retry-after")), metadata);
|
|
673
|
+
}
|
|
674
|
+
throw new GenesisPayConfigError(message, metadata);
|
|
675
|
+
}
|
|
676
|
+
function parseMoney(value, path, metadata) {
|
|
677
|
+
if (typeof value !== "string" ||
|
|
678
|
+
value.length < 1 ||
|
|
679
|
+
value.length > 19 ||
|
|
680
|
+
!CANONICAL_MONEY_PATTERN.test(value)) {
|
|
681
|
+
malformed(`${path} must be a canonical decimal string of at most 19 digits`, metadata);
|
|
682
|
+
}
|
|
683
|
+
const parsed = BigInt(value);
|
|
684
|
+
if (parsed > POSTGRES_BIGINT_MAX) {
|
|
685
|
+
malformed(`${path} exceeds the signed PostgreSQL bigint maximum`, metadata);
|
|
686
|
+
}
|
|
687
|
+
return parsed;
|
|
688
|
+
}
|
|
689
|
+
function requirePaymentChannel(value, metadata) {
|
|
690
|
+
if (value === "unknown") {
|
|
691
|
+
throw new GenesisPayEvidenceError("unsupported_payment_channel", 'paymentChannel "unknown" cannot authorize fulfillment.', metadata);
|
|
692
|
+
}
|
|
693
|
+
return requireOneOf(value, ["checkout", "x402", "bank_transfer"], "evidence.paymentChannel", metadata);
|
|
694
|
+
}
|
|
695
|
+
function requireCanonicalUrl(value, path, metadata) {
|
|
696
|
+
if (typeof value !== "string")
|
|
697
|
+
malformed(`${path} must be a string`, metadata);
|
|
698
|
+
let canonical;
|
|
699
|
+
try {
|
|
700
|
+
canonical = canonicalAuthorityUrl(value);
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
malformed(`${path} is not a valid authority URL`, metadata);
|
|
704
|
+
}
|
|
705
|
+
if (canonical !== value)
|
|
706
|
+
inconsistent(`${path} is not canonically serialized`, metadata);
|
|
707
|
+
return canonical;
|
|
708
|
+
}
|
|
709
|
+
function canonicalAuthorityUrl(value) {
|
|
710
|
+
const url = new URL(value);
|
|
711
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash) {
|
|
712
|
+
throw new Error("unsafe authority URL");
|
|
713
|
+
}
|
|
714
|
+
return url.href;
|
|
715
|
+
}
|
|
716
|
+
function requireString(value, path, metadata) {
|
|
717
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
718
|
+
malformed(`${path} must be a non-empty string`, metadata);
|
|
719
|
+
}
|
|
720
|
+
return value;
|
|
721
|
+
}
|
|
722
|
+
function requireNullableString(value, path, metadata) {
|
|
723
|
+
if (value === null)
|
|
724
|
+
return null;
|
|
725
|
+
return requireString(value, path, metadata);
|
|
726
|
+
}
|
|
727
|
+
function requireAddress(value, path, metadata) {
|
|
728
|
+
if (typeof value !== "string" || !EVM_ADDRESS_PATTERN.test(value)) {
|
|
729
|
+
malformed(`${path} must be a 20-byte 0x address`, metadata);
|
|
730
|
+
}
|
|
731
|
+
return value;
|
|
732
|
+
}
|
|
733
|
+
function requireTransactionHash(value, path, metadata) {
|
|
734
|
+
if (typeof value !== "string" || !TRANSACTION_HASH_PATTERN.test(value)) {
|
|
735
|
+
malformed(`${path} must be a 32-byte transaction hash`, metadata);
|
|
736
|
+
}
|
|
737
|
+
return value;
|
|
738
|
+
}
|
|
739
|
+
function requireTimestamp(value, path, metadata) {
|
|
740
|
+
const timestamp = requireString(value, path, metadata);
|
|
741
|
+
const parsed = new Date(timestamp);
|
|
742
|
+
if (Number.isNaN(parsed.getTime()) || parsed.toISOString() !== timestamp) {
|
|
743
|
+
malformed(`${path} must be a canonical UTC ISO timestamp`, metadata);
|
|
744
|
+
}
|
|
745
|
+
return timestamp;
|
|
746
|
+
}
|
|
747
|
+
function requireNull(value, path, metadata) {
|
|
748
|
+
if (value !== null)
|
|
749
|
+
malformed(`${path} must be null`, metadata);
|
|
750
|
+
}
|
|
751
|
+
function requireRecord(value, path, metadata) {
|
|
752
|
+
const record = plainRecord(value);
|
|
753
|
+
if (!record)
|
|
754
|
+
malformed(`${path} must be an object`, metadata);
|
|
755
|
+
return record;
|
|
756
|
+
}
|
|
757
|
+
function plainRecord(value) {
|
|
758
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
759
|
+
? value
|
|
760
|
+
: null;
|
|
761
|
+
}
|
|
762
|
+
function requireOneOf(value, allowed, path, metadata) {
|
|
763
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
764
|
+
malformed(`${path} must be one of ${allowed.join(", ")}`, metadata);
|
|
765
|
+
}
|
|
766
|
+
return value;
|
|
767
|
+
}
|
|
768
|
+
function requireExact(value, expected, path, metadata, errorCode = "malformed_evidence") {
|
|
769
|
+
if (value !== expected) {
|
|
770
|
+
if (errorCode === "inconsistent_evidence") {
|
|
771
|
+
inconsistent(`${path} must be ${expected}`, metadata);
|
|
772
|
+
}
|
|
773
|
+
malformed(`${path} must be ${expected}`, metadata);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
function malformed(message, metadata) {
|
|
777
|
+
throw new GenesisPayEvidenceError("malformed_evidence", message, metadata);
|
|
778
|
+
}
|
|
779
|
+
function inconsistent(message, metadata) {
|
|
780
|
+
throw new GenesisPayEvidenceError("inconsistent_evidence", message, metadata);
|
|
781
|
+
}
|
|
782
|
+
function describeError(error) {
|
|
783
|
+
return error instanceof Error ? error.message : "unknown error";
|
|
784
|
+
}
|
|
785
|
+
async function readJsonBody(response) {
|
|
786
|
+
let text;
|
|
787
|
+
try {
|
|
788
|
+
text = await response.text();
|
|
789
|
+
}
|
|
790
|
+
catch (error) {
|
|
791
|
+
// Headers can arrive before the connection fails. An unreadable stream is
|
|
792
|
+
// a transport failure, not a completed malformed authority statement.
|
|
793
|
+
throw new GenesisPayConfigError(`Failed to read GenesisPay fulfillment verification: ${describeError(error)}`, metadataFromResponse(response, null));
|
|
794
|
+
}
|
|
795
|
+
if (!text)
|
|
796
|
+
return null;
|
|
797
|
+
try {
|
|
798
|
+
return JSON.parse(text);
|
|
799
|
+
}
|
|
800
|
+
catch {
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
function parseRetryAfterSeconds(value) {
|
|
805
|
+
if (!value || !/^\d+$/.test(value.trim()))
|
|
806
|
+
return null;
|
|
807
|
+
const parsed = Number(value.trim());
|
|
808
|
+
return Number.isSafeInteger(parsed) ? parsed : null;
|
|
809
|
+
}
|
|
810
|
+
//# sourceMappingURL=fulfillment.js.map
|