@tollstile/mpp 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/dist/index.js ADDED
@@ -0,0 +1,1455 @@
1
+ // src/stripe-rail.ts
2
+ import {
3
+ TollstileError as TollstileError4,
4
+ toAssetUnits
5
+ } from "tollstile";
6
+
7
+ // src/challenge.ts
8
+ import { TollstileError as TollstileError2 } from "tollstile";
9
+
10
+ // src/encoding.ts
11
+ var encoder = new TextEncoder();
12
+ var decoder = new TextDecoder("utf-8", { fatal: true });
13
+ function utf8(input) {
14
+ return encoder.encode(input);
15
+ }
16
+ function fromUtf8(bytes) {
17
+ try {
18
+ return decoder.decode(bytes);
19
+ } catch {
20
+ return void 0;
21
+ }
22
+ }
23
+ function base64url(bytes) {
24
+ let binary = "";
25
+ for (const byte of bytes) binary += String.fromCharCode(byte);
26
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
27
+ }
28
+ function fromBase64url(input) {
29
+ if (!/^[A-Za-z0-9_-]*$/.test(input) || input.length % 4 === 1) return void 0;
30
+ const padded = input.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(input.length / 4) * 4, "=");
31
+ return Uint8Array.from(atob(padded), (character) => character.charCodeAt(0));
32
+ }
33
+ function toHex(bytes) {
34
+ return `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
35
+ }
36
+ function fromHex(input) {
37
+ if (!/^0x([0-9a-fA-F]{2})*$/.test(input)) return void 0;
38
+ const bytes = new Uint8Array((input.length - 2) / 2);
39
+ for (let index = 0; index < bytes.length; index += 1) {
40
+ bytes[index] = Number.parseInt(input.slice(2 + index * 2, 4 + index * 2), 16);
41
+ }
42
+ return bytes;
43
+ }
44
+ function concat(...parts) {
45
+ const result = new Uint8Array(parts.reduce((length, part) => length + part.length, 0));
46
+ let offset = 0;
47
+ for (const part of parts) {
48
+ result.set(part, offset);
49
+ offset += part.length;
50
+ }
51
+ return result;
52
+ }
53
+ function toBigInt(bytes) {
54
+ let value = 0n;
55
+ for (const byte of bytes) value = value << 8n | BigInt(byte);
56
+ return value;
57
+ }
58
+ function fromBigInt(value, size) {
59
+ const bytes = new Uint8Array(size);
60
+ let rest = value;
61
+ for (let index = size - 1; index >= 0; index -= 1) {
62
+ bytes[index] = Number(rest & 0xffn);
63
+ rest >>= 8n;
64
+ }
65
+ return bytes;
66
+ }
67
+ function constantTimeEqual(a, b) {
68
+ if (a.length !== b.length) return false;
69
+ let difference = 0;
70
+ for (let index = 0; index < a.length; index += 1) difference |= (a[index] ?? 0) ^ (b[index] ?? 0);
71
+ return difference === 0;
72
+ }
73
+ function parseJson(text) {
74
+ try {
75
+ return JSON.parse(text);
76
+ } catch {
77
+ return void 0;
78
+ }
79
+ }
80
+ function isObject(value) {
81
+ return typeof value === "object" && value !== null && !Array.isArray(value);
82
+ }
83
+ function isIntegerString(value) {
84
+ return typeof value === "string" && /^(0|[1-9]\d*)$/.test(value);
85
+ }
86
+
87
+ // src/jcs.ts
88
+ import { TollstileError } from "tollstile";
89
+ function canonicalize(value) {
90
+ if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value);
91
+ if (typeof value === "number") {
92
+ if (!Number.isFinite(value)) throw new TollstileError("UNREACHABLE", "JCS cannot represent a non-finite number.");
93
+ return JSON.stringify(value);
94
+ }
95
+ if (isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
96
+ const keys = Object.keys(value).sort();
97
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalize(value[key] ?? null)}`).join(",")}}`;
98
+ }
99
+ function isArray(value) {
100
+ return Array.isArray(value);
101
+ }
102
+
103
+ // src/challenge.ts
104
+ var QUOTE_OPAQUE_KEY = "tollstile_quote";
105
+ var MIN_SECRET_LENGTH = 32;
106
+ function challengeSecrets(secret, rail) {
107
+ const secrets = typeof secret === "string" ? [secret] : secret;
108
+ const [first, ...rest] = secrets;
109
+ if (first === void 0 || secrets.some((value) => value.length < MIN_SECRET_LENGTH)) {
110
+ throw new TollstileError2(
111
+ "CONFIG_INVALID",
112
+ `Rail "${rail}" needs \`secret\` of at least ${String(MIN_SECRET_LENGTH)} characters to bind MPP challenge ids. Pass a list to rotate: the first signs, all verify.`
113
+ );
114
+ }
115
+ return [first, ...rest];
116
+ }
117
+ async function issueChallenge(secrets, input) {
118
+ const id = await computeChallengeId(secrets[0], bindingSlots(input));
119
+ return { id, ...input };
120
+ }
121
+ function bindingSlots(input) {
122
+ return {
123
+ realm: input.realm,
124
+ method: input.method,
125
+ intent: input.intent,
126
+ request: encodeJson(input.request),
127
+ expires: input.expires,
128
+ digest: "",
129
+ opaque: Object.keys(input.opaque).length === 0 ? "" : encodeJson(input.opaque)
130
+ };
131
+ }
132
+ async function computeChallengeId(secret, slots) {
133
+ const input = [slots.realm, slots.method, slots.intent, slots.request, slots.expires, slots.digest, slots.opaque].join("|");
134
+ const key = await crypto.subtle.importKey("raw", utf8(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
135
+ return base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, utf8(input))));
136
+ }
137
+ function encodeJson(value) {
138
+ return base64url(utf8(canonicalize(value)));
139
+ }
140
+ function challengeHeader(challenge) {
141
+ const slots = bindingSlots(challenge);
142
+ const parameters = [
143
+ ["id", challenge.id],
144
+ ["realm", challenge.realm],
145
+ ["method", challenge.method],
146
+ ["intent", challenge.intent],
147
+ ["request", slots.request],
148
+ ["expires", challenge.expires]
149
+ ];
150
+ if (slots.opaque !== "") parameters.push(["opaque", slots.opaque]);
151
+ return ["www-authenticate", `Payment ${parameters.map(([name, value]) => `${name}="${quoted(value)}"`).join(", ")}`];
152
+ }
153
+ function mcpChallenge(challenge) {
154
+ const slots = bindingSlots(challenge);
155
+ return {
156
+ id: challenge.id,
157
+ realm: challenge.realm,
158
+ method: challenge.method,
159
+ intent: challenge.intent,
160
+ request: challenge.request,
161
+ expires: challenge.expires,
162
+ ...slots.opaque === "" ? {} : { opaque: slots.opaque }
163
+ };
164
+ }
165
+ async function quoteChallenge(secrets, input, quote, quoteToken) {
166
+ const challenge = await issueChallenge(secrets, {
167
+ ...input,
168
+ expires: rfc3339(quote.expiresAt),
169
+ opaque: { [QUOTE_OPAQUE_KEY]: quoteToken }
170
+ });
171
+ const native = mcpChallenge(challenge);
172
+ return { headers: [challengeHeader(challenge)], accepts: native, mcp: { style: "mpp", challenge: native } };
173
+ }
174
+ function asciiRealm(realm) {
175
+ if (!/^[\x21-\x7e]([\x20-\x7e]*[\x21-\x7e])?$/.test(realm) || realm.includes('"') || realm.includes("\\")) {
176
+ throw new TollstileError2("CONFIG_INVALID", `MPP realm "${realm}" must be printable ASCII without quotes, e.g. "api.example.com".`);
177
+ }
178
+ return realm;
179
+ }
180
+ function rfc3339(date) {
181
+ return `${date.toISOString().slice(0, 19)}Z`;
182
+ }
183
+ function quoted(value) {
184
+ if (/[\r\n]/.test(value)) throw new TollstileError2("CONFIG_INVALID", "Challenge parameters cannot contain line breaks.");
185
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
186
+ }
187
+
188
+ // src/credential.ts
189
+ var CREDENTIAL_META = "org.paymentauth/credential";
190
+ var HTTP_CREDENTIAL = /^Payment\s+([A-Za-z0-9_-]+)\s*$/i;
191
+ async function readCredential(context, expected) {
192
+ const raw = rawCredential(context);
193
+ if (raw === void 0) return { status: "absent" };
194
+ if (!isObject(raw) || !isObject(raw.challenge)) return invalid("malformed_credential");
195
+ const { challenge, payload, source } = raw;
196
+ if (challenge.method !== expected.method || challenge.intent !== expected.intent) return { status: "absent" };
197
+ if (typeof challenge.id !== "string" || challenge.id === "" || challenge.realm !== expected.realm) {
198
+ return invalid("challenge_invalid");
199
+ }
200
+ if (challenge.digest !== void 0 || challenge.header !== void 0) return invalid("challenge_invalid");
201
+ if (typeof challenge.expires !== "string") return invalid("challenge_expires_missing");
202
+ const expires = Date.parse(challenge.expires);
203
+ if (Number.isNaN(expires)) return invalid("malformed_credential");
204
+ const request = decodeObject(challenge.request);
205
+ const opaque = challenge.opaque === void 0 ? {} : decodeObject(challenge.opaque);
206
+ if (request === void 0 || opaque === void 0 || !isStringMap(opaque)) return invalid("malformed_credential");
207
+ const issued = {
208
+ id: challenge.id,
209
+ realm: expected.realm,
210
+ method: expected.method,
211
+ intent: expected.intent,
212
+ request,
213
+ expires: challenge.expires,
214
+ opaque
215
+ };
216
+ if (!await isBound(issued, expected.secrets)) return invalid("challenge_invalid");
217
+ if (expires <= expected.now.getTime()) return { status: "invalid", reason: "challenge_expired", challengeId: issued.id };
218
+ if (!isObject(payload)) return invalid("invalid_payload");
219
+ return {
220
+ status: "present",
221
+ credential: { challenge: issued, payload, source: typeof source === "string" ? source : null }
222
+ };
223
+ }
224
+ async function chargeTerms(credential, terms, rail) {
225
+ const token = credential.challenge.opaque[QUOTE_OPAQUE_KEY];
226
+ if (token === void 0) {
227
+ if (terms.price === null) return { status: "invalid", reason: "quote_required" };
228
+ return { status: "ok", quote: null, price: terms.price, offer: null };
229
+ }
230
+ const quote = await terms.openQuote(token);
231
+ if (quote === void 0) return { status: "invalid", reason: "quote_invalid", challengeId: credential.challenge.id };
232
+ const offer = quote.offers.find((candidate) => candidate.rail === rail);
233
+ if (offer === void 0) return { status: "invalid", reason: "quote_offer_missing" };
234
+ return { status: "ok", quote, price: quote.price, offer };
235
+ }
236
+ function sameRequest(credential, expected) {
237
+ return canonicalize(credential.challenge.request) === canonicalize(expected);
238
+ }
239
+ function rawCredential(context) {
240
+ if (context.transport === "mcp") return context.mcp?.meta[CREDENTIAL_META];
241
+ const header2 = context.request?.headers.get("authorization");
242
+ if (header2 === null || header2 === void 0) return void 0;
243
+ if (!/^Payment\s/i.test(header2)) return void 0;
244
+ const match = HTTP_CREDENTIAL.exec(header2);
245
+ const bytes = match?.[1] === void 0 ? void 0 : fromBase64url(match[1]);
246
+ const text = bytes === void 0 ? void 0 : fromUtf8(bytes);
247
+ return text === void 0 ? null : parseJson(text) ?? null;
248
+ }
249
+ function decodeObject(value) {
250
+ if (isObject(value)) return value;
251
+ if (typeof value !== "string") return void 0;
252
+ const bytes = fromBase64url(value);
253
+ const text = bytes === void 0 ? void 0 : fromUtf8(bytes);
254
+ const parsed = text === void 0 ? void 0 : parseJson(text);
255
+ return isObject(parsed) ? parsed : void 0;
256
+ }
257
+ function isStringMap(value) {
258
+ return Object.values(value).every((entry) => typeof entry === "string");
259
+ }
260
+ async function isBound(challenge, secrets) {
261
+ const slots = bindingSlots(challenge);
262
+ const presented = utf8(challenge.id);
263
+ let bound = false;
264
+ for (const secret of secrets) {
265
+ if (constantTimeEqual(presented, utf8(await computeChallengeId(secret, slots)))) bound = true;
266
+ }
267
+ return bound;
268
+ }
269
+ function rejected(result) {
270
+ return result.challengeId === void 0 ? { status: "invalid", reason: result.reason } : { status: "invalid", reason: result.reason, proofId: result.challengeId };
271
+ }
272
+ function invalid(reason) {
273
+ return { status: "invalid", reason };
274
+ }
275
+
276
+ // src/receipt.ts
277
+ var RECEIPT_META = "org.paymentauth/receipt";
278
+ function paymentReceipt(context, fields) {
279
+ const receipt = {
280
+ ...fields.extra,
281
+ status: "success",
282
+ method: fields.method,
283
+ timestamp: rfc3339(fields.settledAt),
284
+ reference: fields.reference,
285
+ challengeId: fields.challengeId
286
+ };
287
+ if (context.transport === "mcp") return { headers: [], meta: { [RECEIPT_META]: receipt } };
288
+ return {
289
+ headers: [
290
+ ["payment-receipt", base64url(utf8(canonicalize(receipt)))],
291
+ ["cache-control", "private"]
292
+ ],
293
+ meta: {}
294
+ };
295
+ }
296
+
297
+ // src/stripe-api.ts
298
+ import { TollstileError as TollstileError3 } from "tollstile";
299
+ async function stripeCall(config, call) {
300
+ const mutation = call.method === "POST";
301
+ const headers = new Headers({
302
+ authorization: `Bearer ${config.secretKey}`,
303
+ "stripe-version": config.apiVersion
304
+ });
305
+ if (call.body !== void 0) headers.set("content-type", "application/x-www-form-urlencoded");
306
+ if (call.idempotencyKey !== void 0) headers.set("idempotency-key", call.idempotencyKey);
307
+ let response;
308
+ try {
309
+ response = await config.fetch(`${config.apiBase}${call.path}`, {
310
+ method: call.method,
311
+ headers,
312
+ ...call.body === void 0 ? {} : { body: call.body },
313
+ signal: call.signal
314
+ });
315
+ } catch (error) {
316
+ throw failure(mutation, `Stripe ${call.method} ${routeOf(call.path)} did not complete.`, error);
317
+ }
318
+ const body = await readJson(response);
319
+ const code = errorCode(body);
320
+ if (response.ok) {
321
+ if (body === void 0) throw failure(mutation, `Stripe ${call.method} ${routeOf(call.path)} returned an unreadable body.`);
322
+ return { ok: true, body, replayed: response.headers.get("idempotent-replayed") === "true" };
323
+ }
324
+ if (response.status >= 500) throw failure(mutation, `Stripe answered ${String(response.status)} for ${routeOf(call.path)}.`);
325
+ if (response.status === 429 || response.status === 401 || response.status === 403) {
326
+ throw new TollstileError3("PROVIDER_UNAVAILABLE", `Stripe refused ${routeOf(call.path)} with ${String(response.status)}. Check the API key and rate limits.`);
327
+ }
328
+ if (response.status === 409 || code === "idempotency_key_in_use" || errorType(body) === "idempotency_error") {
329
+ throw new TollstileError3("PROVIDER_TIMEOUT", `Stripe reported an idempotency conflict for ${routeOf(call.path)}.`);
330
+ }
331
+ return { ok: false, status: response.status, code: code ?? errorType(body) ?? `http_${String(response.status)}` };
332
+ }
333
+ function parsePaymentIntent(body) {
334
+ const { id, status, amount, currency } = body;
335
+ if (typeof id !== "string" || typeof status !== "string" || typeof amount !== "number" || typeof currency !== "string") {
336
+ return void 0;
337
+ }
338
+ return { id, status, amount, currency };
339
+ }
340
+ function parseRefund(body) {
341
+ const { id, status, charge } = body;
342
+ if (typeof id !== "string" || typeof status !== "string") return void 0;
343
+ return { id, status, charge: typeof charge === "string" ? charge : null };
344
+ }
345
+ function listData(body) {
346
+ const { data } = body;
347
+ if (!Array.isArray(data)) return void 0;
348
+ const items = data.filter(isObject);
349
+ return items.length === data.length ? items : void 0;
350
+ }
351
+ function metadataOf(body) {
352
+ return isObject(body.metadata) ? body.metadata : {};
353
+ }
354
+ async function readJson(response) {
355
+ try {
356
+ const parsed = await response.json();
357
+ return isObject(parsed) ? parsed : void 0;
358
+ } catch {
359
+ return void 0;
360
+ }
361
+ }
362
+ function errorCode(body) {
363
+ const error = body?.error;
364
+ if (!isObject(error)) return void 0;
365
+ if (typeof error.decline_code === "string") return error.decline_code;
366
+ return typeof error.code === "string" ? error.code : void 0;
367
+ }
368
+ function errorType(body) {
369
+ const error = body?.error;
370
+ return isObject(error) && typeof error.type === "string" ? error.type : void 0;
371
+ }
372
+ function routeOf(path) {
373
+ return path.replace(/\?.*$/, "").replace(/\/(pi|re|ch)_[A-Za-z0-9]+/g, "/:id");
374
+ }
375
+ function failure(mutation, message, cause) {
376
+ return new TollstileError3(mutation ? "PROVIDER_TIMEOUT" : "PROVIDER_UNAVAILABLE", message, cause === void 0 ? void 0 : { cause });
377
+ }
378
+
379
+ // src/stripe-rail.ts
380
+ var NAME = "mpp-stripe";
381
+ var METHOD = "stripe";
382
+ var INTENT = "charge";
383
+ var IDEMPOTENCY_PREFIX = "tollstile_mpp_";
384
+ var CURRENCIES = {
385
+ USD: { scale: 2, minimum: 50n },
386
+ EUR: { scale: 2, minimum: 50n },
387
+ GBP: { scale: 2, minimum: 30n },
388
+ CAD: { scale: 2, minimum: 50n },
389
+ AUD: { scale: 2, minimum: 50n },
390
+ NZD: { scale: 2, minimum: 50n },
391
+ CHF: { scale: 2, minimum: 50n },
392
+ SGD: { scale: 2, minimum: 50n },
393
+ HKD: { scale: 2, minimum: 400n },
394
+ SEK: { scale: 2, minimum: 300n },
395
+ NOK: { scale: 2, minimum: 300n },
396
+ DKK: { scale: 2, minimum: 250n },
397
+ MXN: { scale: 2, minimum: 1000n },
398
+ JPY: { scale: 0, minimum: 50n }
399
+ };
400
+ var IN_FLIGHT = /* @__PURE__ */ new Set(["processing", "requires_capture"]);
401
+ function mppStripe(options) {
402
+ const secrets = challengeSecrets(options.secret, NAME);
403
+ const realm = asciiRealm(options.realm);
404
+ const clock = options.clock ?? { now: () => /* @__PURE__ */ new Date() };
405
+ const searchLagMs = options.searchLagMs ?? 10 * 6e4;
406
+ const sptParameter = options.sptParameter ?? "shared_payment_granted_token";
407
+ const paymentMethodTypes = options.paymentMethodTypes ?? ["card"];
408
+ const stripe = {
409
+ secretKey: options.secretKey,
410
+ apiBase: options.apiBase ?? "https://api.stripe.com",
411
+ apiVersion: options.apiVersion ?? "2026-07-29.preview",
412
+ fetch: options.fetch ?? ((input, init) => fetch(input, init))
413
+ };
414
+ if (options.networkId.length === 0) {
415
+ throw new TollstileError4("CONFIG_INVALID", "mppStripe() needs `networkId`, your Stripe Business Network Profile id.");
416
+ }
417
+ const tokens = /* @__PURE__ */ new Map();
418
+ const request = (amount, currency) => ({
419
+ amount: amount.toString(),
420
+ currency: currency.toLowerCase(),
421
+ methodDetails: { networkId: options.networkId, paymentMethodTypes }
422
+ });
423
+ return {
424
+ name: NAME,
425
+ livemode: true,
426
+ capabilities: {
427
+ flows: ["upfront"],
428
+ authorization: "single",
429
+ variableAmount: false,
430
+ quotes: true,
431
+ refund: true,
432
+ partialRefund: true,
433
+ lookup: true
434
+ },
435
+ offer({ price }) {
436
+ const minor = minorUnits(price);
437
+ if (minor === void 0) return Promise.resolve(null);
438
+ return Promise.resolve({
439
+ rail: NAME,
440
+ asset: { code: price.currency, network: "stripe", scale: minor.scale },
441
+ amount: minor.amount.toString(),
442
+ basis: "par",
443
+ details: { method: METHOD, intent: INTENT }
444
+ });
445
+ },
446
+ challenge(quote, token, offer) {
447
+ return quoteChallenge(
448
+ secrets,
449
+ { realm, method: METHOD, intent: INTENT, request: request(BigInt(offer.amount), offer.asset.code) },
450
+ quote,
451
+ token
452
+ );
453
+ },
454
+ async verify(context, terms) {
455
+ const now = clock.now();
456
+ for (const [id, entry] of tokens) if (entry.expiresAt <= now.getTime()) tokens.delete(id);
457
+ const read = await readCredential(context, { realm, method: METHOD, intent: INTENT, secrets, now });
458
+ if (read.status === "absent") return read;
459
+ if (read.status === "invalid") return rejected(read);
460
+ const { credential } = read;
461
+ const resolved = await chargeTerms(credential, terms, NAME);
462
+ if (resolved.status === "invalid") return rejected(resolved);
463
+ const minor = resolved.offer === null ? minorUnits(resolved.price) : { amount: BigInt(resolved.offer.amount) };
464
+ if (minor === void 0) return { status: "invalid", reason: "price_not_payable" };
465
+ if (!sameRequest(credential, request(minor.amount, resolved.price.currency))) {
466
+ return { status: "invalid", reason: "challenge_terms_mismatch" };
467
+ }
468
+ const { spt } = credential.payload;
469
+ if (typeof spt !== "string" || !/^spt_[A-Za-z0-9_]+$/.test(spt)) return { status: "invalid", reason: "invalid_payload" };
470
+ const challengeId = credential.challenge.id;
471
+ tokens.set(context.requestId, { spt, expiresAt: Date.parse(credential.challenge.expires) });
472
+ return {
473
+ status: "valid",
474
+ proofId: challengeId,
475
+ // An SPT does not identify the payer before it is charged; the challenge is the only stable handle.
476
+ payer: `stripe:${challengeId}`,
477
+ quote: resolved.quote,
478
+ limit: resolved.price,
479
+ expiresAt: new Date(Date.parse(credential.challenge.expires)),
480
+ data: { challengeId, amount: minor.amount.toString(), currency: resolved.price.currency.toLowerCase() },
481
+ // A challenge is issued for one 402 and paid once: every retry of that credential is the same
482
+ // logical request, the same key Stripe deduplicates the PaymentIntent on.
483
+ idempotencyKey: challengeId
484
+ };
485
+ },
486
+ async settle(authorization, charge, operation) {
487
+ const data = stripeData(authorization);
488
+ const entry = tokens.get(charge.requestId);
489
+ if (entry === void 0) {
490
+ throw new TollstileError4(
491
+ "PROVIDER_UNAVAILABLE",
492
+ `The shared payment token for ${authorization.id} is not held by this process, so ${charge.id} cannot be settled here. Reconciliation will look it up.`
493
+ );
494
+ }
495
+ const body = new URLSearchParams({
496
+ amount: data.amount,
497
+ currency: data.currency,
498
+ confirm: "true",
499
+ "automatic_payment_methods[enabled]": "true",
500
+ "automatic_payment_methods[allow_redirects]": "never",
501
+ "metadata[challenge_id]": data.challengeId,
502
+ "metadata[tollstile_authorization]": authorization.id
503
+ });
504
+ body.set(sptParameter, entry.spt);
505
+ const response = await stripeCall(stripe, {
506
+ method: "POST",
507
+ path: "/v1/payment_intents",
508
+ body,
509
+ // One key per challenge, not per charge: a retry of the same credential after a released
510
+ // charge must replay the PaymentIntent, never create a second one.
511
+ idempotencyKey: `${IDEMPOTENCY_PREFIX}${data.challengeId}`,
512
+ signal: operation.signal
513
+ });
514
+ if (!response.ok) return { status: "rejected", reason: response.code };
515
+ const intent = parsePaymentIntent(response.body);
516
+ if (intent === void 0) throw new TollstileError4("PROVIDER_TIMEOUT", "Stripe returned a PaymentIntent without id or status.");
517
+ if (IN_FLIGHT.has(intent.status)) {
518
+ throw new TollstileError4("PROVIDER_TIMEOUT", `PaymentIntent for ${charge.id} is ${intent.status}; its outcome is not final yet.`);
519
+ }
520
+ if (intent.status !== "succeeded") return { status: "rejected", reason: `payment_intent_${intent.status}` };
521
+ if (String(intent.amount) !== data.amount || intent.currency !== data.currency) {
522
+ throw new TollstileError4("LEDGER_INCONSISTENT", `PaymentIntent ${intent.id} does not match the amount recorded for ${charge.id}.`);
523
+ }
524
+ return settled(intent, response.replayed);
525
+ },
526
+ async refund(authorization, charge, operation) {
527
+ const data = stripeData(authorization);
528
+ const paymentIntent = charge.settlement?.reference;
529
+ if (paymentIntent === void 0) {
530
+ throw new TollstileError4("LEDGER_INCONSISTENT", `Charge ${charge.id} has no PaymentIntent to refund.`);
531
+ }
532
+ const response = await stripeCall(stripe, {
533
+ method: "POST",
534
+ path: "/v1/refunds",
535
+ body: new URLSearchParams({
536
+ payment_intent: paymentIntent,
537
+ amount: data.amount,
538
+ "metadata[tollstile_charge]": charge.id,
539
+ "metadata[challenge_id]": data.challengeId
540
+ }),
541
+ idempotencyKey: operation.key,
542
+ signal: operation.signal
543
+ });
544
+ if (!response.ok) return { status: "rejected", reason: response.code };
545
+ const refund = parseRefund(response.body);
546
+ if (refund === void 0) throw new TollstileError4("PROVIDER_TIMEOUT", "Stripe returned a refund without id or status.");
547
+ return refund.status === "succeeded" || refund.status === "pending" ? { status: "refunded", reference: refund.id } : { status: "rejected", reason: `refund_${refund.status}` };
548
+ },
549
+ release(_authorization, charge) {
550
+ tokens.delete(charge.requestId);
551
+ return Promise.resolve();
552
+ },
553
+ async lookup(authorization, charge, operation) {
554
+ const data = stripeData(authorization);
555
+ const intent = await findPaymentIntent(stripe, data, charge, operation.signal);
556
+ if (intent === void 0) {
557
+ if (clock.now().getTime() - charge.updatedAt.getTime() < searchLagMs) {
558
+ throw new TollstileError4(
559
+ "PROVIDER_TIMEOUT",
560
+ `No PaymentIntent is searchable yet for ${charge.id}; Stripe Search can lag, so the answer waits until ${String(searchLagMs)}ms have passed.`
561
+ );
562
+ }
563
+ return { status: "none" };
564
+ }
565
+ if (IN_FLIGHT.has(intent.status)) {
566
+ throw new TollstileError4("PROVIDER_TIMEOUT", `PaymentIntent for ${charge.id} is still ${intent.status}.`);
567
+ }
568
+ if (intent.status !== "succeeded") return { status: "none" };
569
+ return await findRefund(stripe, intent, charge, operation.signal) ?? settled(intent, false);
570
+ },
571
+ receipt(authorization, charge, context) {
572
+ return paymentReceipt(context, {
573
+ method: METHOD,
574
+ reference: charge.settlement?.reference ?? charge.id,
575
+ settledAt: charge.updatedAt,
576
+ challengeId: stripeData(authorization).challengeId
577
+ });
578
+ }
579
+ };
580
+ }
581
+ async function findPaymentIntent(stripe, data, charge, signal) {
582
+ const known = charge.settlement?.reference;
583
+ if (known !== void 0) {
584
+ const response2 = await stripeCall(stripe, { method: "GET", path: `/v1/payment_intents/${encodeURIComponent(known)}`, signal });
585
+ if (!response2.ok) throw new TollstileError4("PROVIDER_UNAVAILABLE", `Stripe could not return the PaymentIntent of ${charge.id} (${response2.code}).`);
586
+ return readIntent(response2.body);
587
+ }
588
+ const query = new URLSearchParams({ query: `metadata['challenge_id']:'${data.challengeId}'`, limit: "10" });
589
+ const response = await stripeCall(stripe, { method: "GET", path: `/v1/payment_intents/search?${query.toString()}`, signal });
590
+ if (!response.ok) throw new TollstileError4("PROVIDER_UNAVAILABLE", `Stripe Search failed for ${charge.id} (${response.code}).`);
591
+ const items = listData(response.body);
592
+ if (items === void 0) throw new TollstileError4("PROVIDER_UNAVAILABLE", "Stripe Search returned an unexpected shape.");
593
+ const intents = items.map(readIntent).filter((intent) => metadataMatches(items, intent, data.challengeId));
594
+ return intents.find((intent) => intent.status === "succeeded") ?? intents[0];
595
+ }
596
+ async function findRefund(stripe, intent, charge, signal) {
597
+ const query = new URLSearchParams({ payment_intent: intent.id, limit: "100" });
598
+ const response = await stripeCall(stripe, { method: "GET", path: `/v1/refunds?${query.toString()}`, signal });
599
+ if (!response.ok) throw new TollstileError4("PROVIDER_UNAVAILABLE", `Stripe could not list refunds for ${charge.id} (${response.code}).`);
600
+ const items = listData(response.body);
601
+ if (items === void 0) throw new TollstileError4("PROVIDER_UNAVAILABLE", "Stripe returned an unexpected refund list.");
602
+ for (const item of items) {
603
+ const refund = parseRefund(item);
604
+ if (refund === void 0 || metadataOf(item).tollstile_charge !== charge.id) continue;
605
+ if (refund.status === "succeeded" || refund.status === "pending") return { status: "refunded", reference: refund.id };
606
+ }
607
+ return void 0;
608
+ }
609
+ function readIntent(body) {
610
+ const intent = parsePaymentIntent(body);
611
+ if (intent === void 0) throw new TollstileError4("PROVIDER_UNAVAILABLE", "Stripe returned a PaymentIntent without id or status.");
612
+ return intent;
613
+ }
614
+ function metadataMatches(items, intent, challengeId) {
615
+ const item = items.find((candidate) => candidate.id === intent.id);
616
+ return item !== void 0 && metadataOf(item).challenge_id === challengeId;
617
+ }
618
+ function settled(intent, replayed) {
619
+ return {
620
+ status: "settled",
621
+ reference: intent.id,
622
+ details: { paymentIntent: intent.id, amount: intent.amount, currency: intent.currency, replayed }
623
+ };
624
+ }
625
+ function minorUnits(price) {
626
+ const currency = CURRENCIES[price.currency];
627
+ if (currency === void 0) return void 0;
628
+ const divisor = 10n ** BigInt(6 - currency.scale);
629
+ if (price.micros % divisor !== 0n) return void 0;
630
+ const amount = toAssetUnits(price, currency.scale);
631
+ return amount < currency.minimum ? void 0 : { amount, scale: currency.scale };
632
+ }
633
+ function stripeData(authorization) {
634
+ const { data } = authorization;
635
+ if (!isObject(data) || typeof data.challengeId !== "string" || !isIntegerString(data.amount) || typeof data.currency !== "string") {
636
+ throw new TollstileError4("LEDGER_INCONSISTENT", `Authorization ${authorization.id} does not hold mpp-stripe data.`);
637
+ }
638
+ return { challengeId: data.challengeId, amount: data.amount, currency: data.currency };
639
+ }
640
+
641
+ // src/tempo-rail.ts
642
+ import {
643
+ TollstileError as TollstileError6,
644
+ toAssetUnits as toAssetUnits2
645
+ } from "tollstile";
646
+
647
+ // src/evm.ts
648
+ import { secp256k1 } from "@noble/curves/secp256k1.js";
649
+ import { keccak_256 } from "@noble/hashes/sha3.js";
650
+ var HALF_ORDER = 0x7fffffffffffffffffffffffffffffff5d576e7357a4501ddfe92f46681b20a0n;
651
+ var ZERO_ADDRESS = "0x0000000000000000000000000000000000000000";
652
+ function keccak256(bytes) {
653
+ return keccak_256(bytes);
654
+ }
655
+ function selector(signature) {
656
+ return toHex(keccak256(utf8(signature)).subarray(0, 4));
657
+ }
658
+ function parseAddress(value) {
659
+ return typeof value === "string" && /^0x[0-9a-fA-F]{40}$/.test(value) ? value.toLowerCase() : void 0;
660
+ }
661
+ function parseBytes32(value) {
662
+ return typeof value === "string" && /^0x[0-9a-fA-F]{64}$/.test(value) ? value.toLowerCase() : void 0;
663
+ }
664
+ function recoverAddress(digest, signature) {
665
+ let r;
666
+ let s;
667
+ let recovery;
668
+ if (signature.length === 65) {
669
+ r = toBigInt(signature.subarray(0, 32));
670
+ s = toBigInt(signature.subarray(32, 64));
671
+ const v = signature[64] ?? 0;
672
+ recovery = v >= 27 ? v - 27 : v;
673
+ } else if (signature.length === 64) {
674
+ r = toBigInt(signature.subarray(0, 32));
675
+ const yParityAndS = toBigInt(signature.subarray(32, 64));
676
+ recovery = Number(yParityAndS >> 255n);
677
+ s = yParityAndS & (1n << 255n) - 1n;
678
+ } else {
679
+ return void 0;
680
+ }
681
+ if (recovery !== 0 && recovery !== 1) return void 0;
682
+ if (r === 0n || s === 0n || s > HALF_ORDER || r >= secp256k1.Point.CURVE().n) return void 0;
683
+ try {
684
+ const point = new secp256k1.Signature(r, s, recovery).recoverPublicKey(digest);
685
+ return publicKeyAddress(point.toBytes(false));
686
+ } catch {
687
+ return void 0;
688
+ }
689
+ }
690
+ function publicKeyAddress(uncompressed) {
691
+ return toHex(keccak256(uncompressed.subarray(1)).subarray(12));
692
+ }
693
+ function word(value) {
694
+ return fromBigInt(value, 32);
695
+ }
696
+ function addressWord(address) {
697
+ return concat(new Uint8Array(12), fromHex(address) ?? new Uint8Array(20));
698
+ }
699
+ function wordAt(data, index) {
700
+ const slice = data.subarray(index * 32, index * 32 + 32);
701
+ return slice.length === 32 ? slice : void 0;
702
+ }
703
+ function addressAt(data, index) {
704
+ const slice = wordAt(data, index);
705
+ if (slice === void 0 || slice.subarray(0, 12).some((byte) => byte !== 0)) return void 0;
706
+ return toHex(slice.subarray(12));
707
+ }
708
+
709
+ // src/rlp.ts
710
+ function decodeRlp(bytes) {
711
+ const result = decodeAt(bytes, 0);
712
+ return result === void 0 || result.end !== bytes.length ? void 0 : result.item;
713
+ }
714
+ function encodeRlp(item) {
715
+ if (item instanceof Uint8Array) {
716
+ if (item.length === 1 && (item[0] ?? 0) < 128) return item;
717
+ return concat(header(128, item.length), item);
718
+ }
719
+ const payload = concat(...item.map(encodeRlp));
720
+ return concat(header(192, payload.length), payload);
721
+ }
722
+ function rlpInteger(item) {
723
+ if (!(item instanceof Uint8Array) || item.length > 0 && item[0] === 0) return void 0;
724
+ return toBigInt(item);
725
+ }
726
+ function integerBytes(value) {
727
+ if (value === 0n) return new Uint8Array();
728
+ let size = 0;
729
+ for (let rest = value; rest > 0n; rest >>= 8n) size += 1;
730
+ return fromBigInt(value, size);
731
+ }
732
+ function header(offset, length) {
733
+ if (length <= 55) return Uint8Array.of(offset + length);
734
+ const size = integerBytes(BigInt(length));
735
+ return concat(Uint8Array.of(offset + 55 + size.length), size);
736
+ }
737
+ function decodeAt(bytes, start) {
738
+ const prefix = bytes[start];
739
+ if (prefix === void 0) return void 0;
740
+ if (prefix < 128) return { item: bytes.subarray(start, start + 1), end: start + 1 };
741
+ const isList = prefix >= 192;
742
+ const base = isList ? 192 : 128;
743
+ const short = prefix - base <= 55;
744
+ let length;
745
+ let offset;
746
+ if (short) {
747
+ length = prefix - base;
748
+ offset = start + 1;
749
+ } else {
750
+ const sizeOfLength = prefix - base - 55;
751
+ const encoded = bytes.subarray(start + 1, start + 1 + sizeOfLength);
752
+ if (encoded.length !== sizeOfLength || encoded[0] === 0) return void 0;
753
+ length = Number(toBigInt(encoded));
754
+ if (length <= 55) return void 0;
755
+ offset = start + 1 + sizeOfLength;
756
+ }
757
+ const end = offset + length;
758
+ if (end > bytes.length) return void 0;
759
+ if (!isList) {
760
+ const item = bytes.subarray(offset, end);
761
+ if (short && length === 1 && (item[0] ?? 0) < 128) return void 0;
762
+ return { item, end };
763
+ }
764
+ const items = [];
765
+ let cursor = offset;
766
+ while (cursor < end) {
767
+ const next = decodeAt(bytes.subarray(0, end), cursor);
768
+ if (next === void 0) return void 0;
769
+ items.push(next.item);
770
+ cursor = next.end;
771
+ }
772
+ return { item: items, end };
773
+ }
774
+
775
+ // src/tempo-transaction.ts
776
+ var TEMPO_TX_TYPE = 118;
777
+ function decodeTempoTransaction(serialized) {
778
+ const bytes = fromHex(serialized);
779
+ if (bytes === void 0 || bytes[0] !== TEMPO_TX_TYPE) return fail("transaction_malformed");
780
+ const fields = decodeRlp(bytes.subarray(1));
781
+ if (fields instanceof Uint8Array || fields === void 0) return fail("transaction_malformed");
782
+ if (fields.length !== 14) return fail("transaction_unsupported");
783
+ const [chainId, , , , calls, , nonceKey, nonce, validBefore, validAfter, , feePayer, authorizationList, signature] = fields;
784
+ const integers = [chainId, nonceKey, nonce, validBefore, validAfter].map(rlpInteger);
785
+ const [chain, lane, sequence, before, after] = integers;
786
+ if (chain === void 0 || lane === void 0 || sequence === void 0 || before === void 0 || after === void 0) {
787
+ return fail("transaction_malformed");
788
+ }
789
+ if (!(feePayer instanceof Uint8Array) || feePayer.length !== 0) return fail("fee_payer_unsupported");
790
+ if (!Array.isArray(authorizationList) || authorizationList.length !== 0) return fail("transaction_unsupported");
791
+ if (!(signature instanceof Uint8Array)) return fail("transaction_malformed");
792
+ const decodedCalls = decodeCalls(calls);
793
+ if (decodedCalls === void 0) return fail("transaction_malformed");
794
+ const unsigned = fields.slice(0, 13);
795
+ const digest = keccak256(concat(Uint8Array.of(TEMPO_TX_TYPE), encodeRlp(unsigned)));
796
+ const sender = recoverAddress(digest, signature);
797
+ if (sender === void 0 || signature.length !== 65) return fail("signature_invalid");
798
+ return {
799
+ ok: true,
800
+ transaction: {
801
+ chainId: chain,
802
+ calls: decodedCalls,
803
+ nonceKey: lane,
804
+ nonce: sequence,
805
+ validBefore: before === 0n ? null : before,
806
+ validAfter: after === 0n ? null : after,
807
+ sender,
808
+ hash: toHex(keccak256(bytes))
809
+ }
810
+ };
811
+ }
812
+ function decodeCalls(item) {
813
+ if (item === void 0 || item instanceof Uint8Array) return void 0;
814
+ const calls = [];
815
+ for (const call of item) {
816
+ if (call instanceof Uint8Array || call.length !== 3) return void 0;
817
+ const [to, value, data] = call;
818
+ const amount = rlpInteger(value);
819
+ if (!(to instanceof Uint8Array) || to.length !== 20 || amount === void 0 || !(data instanceof Uint8Array)) return void 0;
820
+ calls.push({ to: toHex(to), value: amount, data });
821
+ }
822
+ return calls;
823
+ }
824
+ function fail(reason) {
825
+ return { ok: false, reason };
826
+ }
827
+
828
+ // src/tempo-rpc.ts
829
+ import { TollstileError as TollstileError5 } from "tollstile";
830
+ async function rpcCall(config, method, params, options) {
831
+ const code = options.write ? "PROVIDER_TIMEOUT" : "PROVIDER_UNAVAILABLE";
832
+ let response;
833
+ try {
834
+ response = await config.fetch(config.url, {
835
+ method: "POST",
836
+ headers: { "content-type": "application/json" },
837
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
838
+ signal: options.signal
839
+ });
840
+ } catch (error) {
841
+ throw new TollstileError5(code, `Tempo RPC ${method} did not complete.`, { cause: error });
842
+ }
843
+ if (!response.ok) throw new TollstileError5(code, `Tempo RPC answered HTTP ${String(response.status)} to ${method}.`);
844
+ let body;
845
+ try {
846
+ body = await response.json();
847
+ } catch (error) {
848
+ throw new TollstileError5(code, `Tempo RPC returned an unreadable body for ${method}.`, { cause: error });
849
+ }
850
+ if (!isObject(body)) throw new TollstileError5(code, `Tempo RPC returned an unexpected body for ${method}.`);
851
+ if (isObject(body.error)) {
852
+ return {
853
+ ok: false,
854
+ code: typeof body.error.code === "number" ? body.error.code : 0,
855
+ message: typeof body.error.message === "string" ? body.error.message : ""
856
+ };
857
+ }
858
+ if (!("result" in body)) throw new TollstileError5(code, `Tempo RPC returned neither result nor error for ${method}.`);
859
+ return { ok: true, result: body.result };
860
+ }
861
+ async function getReceipt(config, hash, signal) {
862
+ const answer = await rpcCall(config, "eth_getTransactionReceipt", [hash], { signal, write: false });
863
+ if (!answer.ok) throw new TollstileError5("PROVIDER_UNAVAILABLE", `Tempo RPC refused eth_getTransactionReceipt (${String(answer.code)}).`);
864
+ if (answer.result === null) return null;
865
+ const receipt = parseReceipt(answer.result);
866
+ if (receipt === void 0) throw new TollstileError5("PROVIDER_UNAVAILABLE", "Tempo RPC returned a malformed transaction receipt.");
867
+ return receipt;
868
+ }
869
+ function parseReceipt(value) {
870
+ if (!isObject(value)) return void 0;
871
+ const hash = parseBytes32(value.transactionHash);
872
+ const from = parseAddress(value.from);
873
+ const { status, blockNumber, logs } = value;
874
+ if (hash === void 0 || from === void 0 || typeof status !== "string" || typeof blockNumber !== "string" || !Array.isArray(logs)) {
875
+ return void 0;
876
+ }
877
+ const parsed = [];
878
+ for (const log of logs) {
879
+ if (!isObject(log)) return void 0;
880
+ const address = parseAddress(log.address);
881
+ const topics = Array.isArray(log.topics) ? log.topics.map(parseBytes32) : [];
882
+ if (address === void 0 || typeof log.data !== "string" || !/^0x([0-9a-fA-F]{2})*$/.test(log.data)) return void 0;
883
+ if (topics.some((topic) => topic === void 0)) return void 0;
884
+ parsed.push({ address, topics: topics.filter((topic) => topic !== void 0), data: log.data.toLowerCase() });
885
+ }
886
+ return { transactionHash: hash, success: status === "0x1", from, blockNumber, logs: parsed };
887
+ }
888
+
889
+ // src/tip20.ts
890
+ var TRANSFER = selector("transfer(address,uint256)");
891
+ var TRANSFER_WITH_MEMO = selector("transferWithMemo(address,uint256,bytes32)");
892
+ var TRANSFER_EVENT = toHex(keccak256(utf8("Transfer(address,address,uint256)")));
893
+ var TRANSFER_WITH_MEMO_EVENT = toHex(keccak256(utf8("TransferWithMemo(address,address,uint256,bytes32)")));
894
+ function challengeMemo(realm, quote) {
895
+ return toHex(keccak256(utf8(`tollstile/mpp:${realm}:${quote.id}:${quote.nonce}`)));
896
+ }
897
+ function requiredTransfers(amount, recipient, memo, splits) {
898
+ const splitTotal = splits.reduce((total, split) => total + split.amount, 0n);
899
+ return [{ recipient, amount: amount - splitTotal, memo }, ...splits];
900
+ }
901
+ function requestTransfersJson(splits) {
902
+ return splits.map((split) => ({
903
+ amount: split.amount.toString(),
904
+ recipient: split.recipient,
905
+ ...split.memo === null ? {} : { memo: split.memo }
906
+ }));
907
+ }
908
+ function callsPay(calls, token, transfers) {
909
+ if (calls.length !== transfers.length) return false;
910
+ const remaining = [...transfers];
911
+ for (const call of calls) {
912
+ const decoded = decodeCall(call, token);
913
+ const index = decoded === void 0 ? -1 : remaining.findIndex((transfer) => sameTransfer(transfer, decoded));
914
+ if (index === -1) return false;
915
+ remaining.splice(index, 1);
916
+ }
917
+ return remaining.length === 0;
918
+ }
919
+ function logsPay(logs, token, transfers) {
920
+ const events = transferEvents(logs, token);
921
+ let sender;
922
+ for (const transfer of transfers) {
923
+ const index = events.findIndex((event2) => (sender === void 0 || event2.from === sender) && sameTransfer(transfer, event2));
924
+ const event = events[index];
925
+ if (event === void 0) return void 0;
926
+ sender = event.from;
927
+ events.splice(index, 1);
928
+ }
929
+ return sender;
930
+ }
931
+ function sameTransfer(expected, actual) {
932
+ if (expected.recipient !== actual.recipient || expected.amount !== actual.amount) return false;
933
+ return expected.memo === null || expected.memo === actual.memo;
934
+ }
935
+ function decodeCall(call, token) {
936
+ if (call.to !== token || call.value !== 0n) return void 0;
937
+ const method = toHex(call.data.subarray(0, 4));
938
+ const args = call.data.subarray(4);
939
+ if (method === TRANSFER && args.length === 64) return decodeArgs(args, null);
940
+ if (method === TRANSFER_WITH_MEMO && args.length === 96) {
941
+ const memo = wordAt(args, 2);
942
+ return memo === void 0 ? void 0 : decodeArgs(args, toHex(memo));
943
+ }
944
+ return void 0;
945
+ }
946
+ function decodeArgs(args, memo) {
947
+ const recipient = addressAt(args, 0);
948
+ const amount = wordAt(args, 1);
949
+ return recipient === void 0 || amount === void 0 ? void 0 : { recipient, amount: toBigInt(amount), memo };
950
+ }
951
+ function transferEvents(logs, token) {
952
+ const events = [];
953
+ for (const log of logs) {
954
+ if (log.address !== token) continue;
955
+ const [topic, from, to, memo] = log.topics;
956
+ const data = fromHex(log.data);
957
+ const amount = data === void 0 ? void 0 : wordAt(data, 0);
958
+ const sender = from === void 0 ? void 0 : addressAt(fromHex(from) ?? new Uint8Array(), 0);
959
+ const recipient = to === void 0 ? void 0 : addressAt(fromHex(to) ?? new Uint8Array(), 0);
960
+ if (amount === void 0 || sender === void 0 || recipient === void 0) continue;
961
+ if (topic === TRANSFER_EVENT && log.topics.length === 3) {
962
+ events.push({ kind: "plain", from: sender, recipient, amount: toBigInt(amount), memo: null });
963
+ } else if (topic === TRANSFER_WITH_MEMO_EVENT && memo !== void 0) {
964
+ events.push({ kind: "memo", from: sender, recipient, amount: toBigInt(amount), memo });
965
+ }
966
+ }
967
+ const memoEvents = events.filter((event) => event.kind === "memo");
968
+ const plain = events.filter((event) => event.kind === "plain");
969
+ for (const memoEvent of memoEvents) {
970
+ const twin = plain.findIndex((event) => event.from === memoEvent.from && event.recipient === memoEvent.recipient && event.amount === memoEvent.amount);
971
+ if (twin !== -1) plain.splice(twin, 1);
972
+ }
973
+ return [...memoEvents, ...plain];
974
+ }
975
+
976
+ // src/tempo-rail.ts
977
+ var NAME2 = "mpp-tempo";
978
+ var METHOD2 = "tempo";
979
+ var INTENT2 = "charge";
980
+ var TOKEN_SCALE = 6;
981
+ function mppTempo(options) {
982
+ const secrets = challengeSecrets(options.secret, NAME2);
983
+ const realm = asciiRealm(options.realm);
984
+ const recipient = requireAddress(options.recipient, "recipient");
985
+ const token = requireAddress(options.token.address, "token.address");
986
+ const modes = options.modes ?? ["pull"];
987
+ if (modes.length === 0) {
988
+ throw new TollstileError6("CONFIG_INVALID", 'mppTempo() `modes` must list "pull", "push", or both.');
989
+ }
990
+ if (!Number.isSafeInteger(options.chainId) || options.chainId <= 0) {
991
+ throw new TollstileError6("CONFIG_INVALID", `mppTempo() \`chainId\` must be a positive integer, got ${String(options.chainId)}.`);
992
+ }
993
+ const clock = options.clock ?? { now: () => /* @__PURE__ */ new Date() };
994
+ const marginMs = options.validityMarginMs ?? 6e4;
995
+ const rpc = { url: options.rpcUrl, fetch: options.fetch ?? ((input, init) => fetch(input, init)) };
996
+ const splitsFor = (amount) => {
997
+ const splits = (options.splits?.(amount) ?? []).map((split) => ({
998
+ recipient: requireAddress(split.recipient, "splits[].recipient"),
999
+ amount: split.amount,
1000
+ memo: split.memo === void 0 ? null : requireBytes32(split.memo)
1001
+ }));
1002
+ const total = splits.reduce((sum, split) => sum + split.amount, 0n);
1003
+ return splits.some((split) => split.amount <= 0n) || total >= amount ? void 0 : splits;
1004
+ };
1005
+ const request = (amount, quote, splits) => ({
1006
+ amount: amount.toString(),
1007
+ currency: token,
1008
+ recipient,
1009
+ methodDetails: {
1010
+ chainId: options.chainId,
1011
+ memo: challengeMemo(realm, quote),
1012
+ // The spec requires listing the modes only when not both are supported.
1013
+ ...modes.includes("pull") && modes.includes("push") ? {} : { supportedModes: [...modes] },
1014
+ ...splits.length === 0 ? {} : { splits: requestTransfersJson(splits) }
1015
+ }
1016
+ });
1017
+ const expired = (data) => data.validBefore !== null && clock.now().getTime() > Number(data.validBefore) * 1e3 + marginMs;
1018
+ const fromReceipt = (receipt, data) => receipt.success ? { status: "settled", reference: receipt.transactionHash, details: { mode: data.mode, blockNumber: receipt.blockNumber } } : { status: "rejected", reason: "transaction_reverted" };
1019
+ return {
1020
+ name: NAME2,
1021
+ livemode: true,
1022
+ capabilities: {
1023
+ flows: ["authorization"],
1024
+ authorization: "single",
1025
+ variableAmount: false,
1026
+ quotes: true,
1027
+ refund: false,
1028
+ partialRefund: false,
1029
+ lookup: true
1030
+ },
1031
+ offer({ price }) {
1032
+ if (price.currency !== options.denomination) return Promise.resolve(null);
1033
+ const amount = toAssetUnits2(price, TOKEN_SCALE);
1034
+ if (splitsFor(amount) === void 0) return Promise.resolve(null);
1035
+ return Promise.resolve({
1036
+ rail: NAME2,
1037
+ asset: { code: options.token.code, network: `eip155:${String(options.chainId)}`, scale: TOKEN_SCALE },
1038
+ amount: amount.toString(),
1039
+ basis: "par",
1040
+ details: { method: METHOD2, intent: INTENT2, token, recipient }
1041
+ });
1042
+ },
1043
+ challenge(quote, quoteToken, offer) {
1044
+ const amount = BigInt(offer.amount);
1045
+ const splits = splitsFor(amount) ?? [];
1046
+ return quoteChallenge(secrets, { realm, method: METHOD2, intent: INTENT2, request: request(amount, quote, splits) }, quote, quoteToken);
1047
+ },
1048
+ async verify(context, terms, operation) {
1049
+ const now = clock.now();
1050
+ const read = await readCredential(context, { realm, method: METHOD2, intent: INTENT2, secrets, now });
1051
+ if (read.status === "absent") return read;
1052
+ if (read.status === "invalid") return rejected(read);
1053
+ const { credential } = read;
1054
+ const resolved = await chargeTerms(credential, terms, NAME2);
1055
+ if (resolved.status === "invalid") return rejected(resolved);
1056
+ if (resolved.quote === null || resolved.offer === null) return { status: "invalid", reason: "quote_required" };
1057
+ const { quote } = resolved;
1058
+ const amount = BigInt(resolved.offer.amount);
1059
+ const splits = splitsFor(amount);
1060
+ if (splits === void 0 || !sameRequest(credential, request(amount, quote, splits))) {
1061
+ return { status: "invalid", reason: "challenge_terms_mismatch" };
1062
+ }
1063
+ const transfers = requiredTransfers(amount, recipient, challengeMemo(realm, quote), splits);
1064
+ const challengeId = credential.challenge.id;
1065
+ const challengeExpires = new Date(Date.parse(credential.challenge.expires));
1066
+ const { payload } = credential;
1067
+ if (payload.type === "transaction" && modes.includes("pull")) {
1068
+ if (typeof payload.signature !== "string") return { status: "invalid", reason: "invalid_payload" };
1069
+ const decoded = decodeTempoTransaction(payload.signature);
1070
+ if (!decoded.ok) return { status: "invalid", reason: decoded.reason };
1071
+ const { transaction } = decoded;
1072
+ if (transaction.chainId !== BigInt(options.chainId)) return { status: "invalid", reason: "chain_mismatch" };
1073
+ if (transaction.validBefore === null) return { status: "invalid", reason: "valid_before_required" };
1074
+ const validBeforeMs = Number(transaction.validBefore) * 1e3;
1075
+ if (validBeforeMs > challengeExpires.getTime()) return { status: "invalid", reason: "valid_before_after_expiry" };
1076
+ if (validBeforeMs <= now.getTime()) return { status: "invalid", reason: "transaction_expired", proofId: challengeId };
1077
+ if (transaction.validAfter !== null && Number(transaction.validAfter) * 1e3 > now.getTime()) {
1078
+ return { status: "invalid", reason: "transaction_not_yet_valid" };
1079
+ }
1080
+ if (!callsPay(transaction.calls, token, transfers)) return { status: "invalid", reason: "transfer_mismatch" };
1081
+ return {
1082
+ status: "valid",
1083
+ proofId: challengeId,
1084
+ payer: `did:pkh:eip155:${String(options.chainId)}:${transaction.sender}`,
1085
+ quote,
1086
+ limit: resolved.price,
1087
+ expiresAt: new Date(validBeforeMs),
1088
+ data: {
1089
+ challengeId,
1090
+ mode: "pull",
1091
+ hash: transaction.hash,
1092
+ transaction: payload.signature,
1093
+ validBefore: transaction.validBefore.toString()
1094
+ },
1095
+ idempotencyKey: challengeId
1096
+ };
1097
+ }
1098
+ if (payload.type === "hash" && modes.includes("push")) {
1099
+ const hash = parseBytes32(payload.hash);
1100
+ if (hash === void 0) return { status: "invalid", reason: "invalid_payload" };
1101
+ const receipt = await getReceipt(rpc, hash, operation.signal);
1102
+ if (receipt === null) return { status: "invalid", reason: "transaction_not_found" };
1103
+ if (!receipt.success) return { status: "invalid", reason: "transaction_reverted" };
1104
+ const sender = logsPay(receipt.logs, token, transfers);
1105
+ if (sender === void 0) return { status: "invalid", reason: "transfer_mismatch" };
1106
+ return {
1107
+ status: "valid",
1108
+ proofId: challengeId,
1109
+ payer: `did:pkh:eip155:${String(options.chainId)}:${sender}`,
1110
+ quote,
1111
+ limit: resolved.price,
1112
+ expiresAt: challengeExpires,
1113
+ data: { challengeId, mode: "push", hash, transaction: null, validBefore: null },
1114
+ settled: { reference: receipt.transactionHash, details: { mode: "push", blockNumber: receipt.blockNumber } },
1115
+ idempotencyKey: challengeId
1116
+ };
1117
+ }
1118
+ return { status: "invalid", reason: "mode_unsupported" };
1119
+ },
1120
+ async settle(authorization, _charge, operation) {
1121
+ const data = tempoData(authorization);
1122
+ if (data.mode === "push") {
1123
+ return { status: "settled", reference: data.hash, details: { mode: "push" } };
1124
+ }
1125
+ const { transaction } = data;
1126
+ if (transaction === null || expired(data)) {
1127
+ const receipt2 = await getReceipt(rpc, data.hash, operation.signal);
1128
+ if (receipt2 !== null) return fromReceipt(receipt2, data);
1129
+ return { status: "rejected", reason: transaction === null ? "transaction_not_included" : "transaction_expired" };
1130
+ }
1131
+ const answer = await rpcCall(rpc, "eth_sendRawTransactionSync", [transaction], { signal: operation.signal, write: true });
1132
+ const receipt = answer.ok ? parseReceipt(answer.result) : await getReceipt(rpc, data.hash, operation.signal);
1133
+ if (receipt === void 0) throw new TollstileError6("PROVIDER_TIMEOUT", "Tempo RPC returned a malformed receipt from eth_sendRawTransactionSync.");
1134
+ if (receipt !== null) {
1135
+ if (receipt.transactionHash !== data.hash) throw new TollstileError6("PROVIDER_TIMEOUT", "Tempo RPC returned a receipt for another transaction.");
1136
+ return fromReceipt(receipt, data);
1137
+ }
1138
+ if (expired(data)) return { status: "rejected", reason: `rpc_error_${String(answer.ok ? 0 : answer.code)}` };
1139
+ throw new TollstileError6("PROVIDER_TIMEOUT", `Tempo RPC refused the transaction (${answer.ok ? "no receipt" : String(answer.code)}); it may still be included before it expires.`);
1140
+ },
1141
+ refund() {
1142
+ return Promise.resolve({ status: "rejected", reason: "refund_unsupported" });
1143
+ },
1144
+ release() {
1145
+ return Promise.resolve();
1146
+ },
1147
+ redact(data) {
1148
+ return { ...data, transaction: null };
1149
+ },
1150
+ async lookup(authorization, _charge, operation) {
1151
+ const data = tempoData(authorization);
1152
+ const receipt = await getReceipt(rpc, data.hash, operation.signal);
1153
+ if (receipt !== null) {
1154
+ return receipt.success ? { status: "settled", reference: receipt.transactionHash, details: { mode: data.mode, blockNumber: receipt.blockNumber } } : { status: "none" };
1155
+ }
1156
+ if (data.mode === "pull") return { status: "none" };
1157
+ throw new TollstileError6("PROVIDER_TIMEOUT", `Push transaction for ${authorization.id} was verified on-chain but has no receipt now.`);
1158
+ },
1159
+ receipt(authorization, charge, context) {
1160
+ const data = tempoData(authorization);
1161
+ return paymentReceipt(context, { method: METHOD2, reference: charge.settlement?.reference ?? data.hash, settledAt: charge.updatedAt, challengeId: data.challengeId });
1162
+ }
1163
+ };
1164
+ }
1165
+ function tempoData(authorization) {
1166
+ const { data } = authorization;
1167
+ if (!isObject(data) || typeof data.challengeId !== "string" || data.mode !== "pull" && data.mode !== "push" || typeof data.hash !== "string" || !(typeof data.transaction === "string" || data.transaction === null) || !(isIntegerString(data.validBefore) || data.validBefore === null)) {
1168
+ throw inconsistent(authorization);
1169
+ }
1170
+ return { challengeId: data.challengeId, mode: data.mode, hash: data.hash, transaction: data.transaction, validBefore: data.validBefore };
1171
+ }
1172
+ function inconsistent(authorization) {
1173
+ return new TollstileError6("LEDGER_INCONSISTENT", `Authorization ${authorization.id} does not hold mpp-tempo data.`);
1174
+ }
1175
+ function requireAddress(value, name) {
1176
+ const address = parseAddress(value);
1177
+ if (address === void 0) throw new TollstileError6("CONFIG_INVALID", `\`${name}\` must be a 0x-prefixed 20-byte address, got "${value}".`);
1178
+ return address;
1179
+ }
1180
+ function requireBytes32(value) {
1181
+ const bytes = parseBytes32(value);
1182
+ if (bytes === void 0) throw new TollstileError6("CONFIG_INVALID", `Split memo must be a 0x-prefixed bytes32, got "${value}".`);
1183
+ return bytes;
1184
+ }
1185
+
1186
+ // src/tempo-session-rail.ts
1187
+ import {
1188
+ TollstileError as TollstileError7,
1189
+ money,
1190
+ toAssetUnits as toAssetUnits3
1191
+ } from "tollstile";
1192
+
1193
+ // src/tempo-channel.ts
1194
+ var TEMPO_CHANNEL_ESCROW = "0x4d50500000000000000000000000000000000000";
1195
+ var MAX_UINT96 = (1n << 96n) - 1n;
1196
+ var DOMAIN_TYPE = keccak256(utf8("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"));
1197
+ var DOMAIN_NAME = keccak256(utf8("TIP20 Channel Reserve"));
1198
+ var DOMAIN_VERSION = keccak256(utf8("1"));
1199
+ var VOUCHER_TYPE = keccak256(utf8("Voucher(bytes32 channelId,uint96 cumulativeAmount)"));
1200
+ var GET_CHANNEL_STATE = selector("getChannelState(bytes32)");
1201
+ var CLOSE = selector("close((address,address,address,address,bytes32,address,bytes32),uint96,uint96,bytes)");
1202
+ function parseDescriptor(value) {
1203
+ if (!isObject(value)) return void 0;
1204
+ const payer = parseAddress(value.payer);
1205
+ const payee = parseAddress(value.payee);
1206
+ const operator = parseAddress(value.operator);
1207
+ const token = parseAddress(value.token);
1208
+ const salt = parseBytes32(value.salt);
1209
+ const authorizedSigner = parseAddress(value.authorizedSigner);
1210
+ const expiringNonceHash = parseBytes32(value.expiringNonceHash);
1211
+ if (payer === void 0 || payee === void 0 || operator === void 0 || token === void 0 || salt === void 0 || authorizedSigner === void 0 || expiringNonceHash === void 0) {
1212
+ return void 0;
1213
+ }
1214
+ return { payer, payee, operator, token, salt, authorizedSigner, expiringNonceHash };
1215
+ }
1216
+ function channelId(descriptor, escrow, chainId) {
1217
+ return toHex(keccak256(concat(descriptorWords(descriptor), addressWord(escrow), word(BigInt(chainId)))));
1218
+ }
1219
+ function voucherSigner(descriptor) {
1220
+ return descriptor.authorizedSigner === ZERO_ADDRESS ? descriptor.payer : descriptor.authorizedSigner;
1221
+ }
1222
+ function voucherDigest(escrow, chainId, channel, cumulativeAmount) {
1223
+ const domain = keccak256(concat(DOMAIN_TYPE, DOMAIN_NAME, DOMAIN_VERSION, word(BigInt(chainId)), addressWord(escrow)));
1224
+ const voucher = keccak256(concat(VOUCHER_TYPE, fromHex(channel) ?? new Uint8Array(32), word(cumulativeAmount)));
1225
+ return keccak256(concat(Uint8Array.of(25, 1), domain, voucher));
1226
+ }
1227
+ function getChannelStateCall(channel) {
1228
+ return toHex(concat(fromHex(GET_CHANNEL_STATE) ?? new Uint8Array(), fromHex(channel) ?? new Uint8Array(32)));
1229
+ }
1230
+ function decodeChannelState(result) {
1231
+ const bytes = typeof result === "string" ? fromHex(result) : void 0;
1232
+ if (bytes?.length !== 96) return void 0;
1233
+ return {
1234
+ settled: toBigInt(bytes.subarray(0, 32)),
1235
+ deposit: toBigInt(bytes.subarray(32, 64)),
1236
+ closeRequestedAt: toBigInt(bytes.subarray(64, 96))
1237
+ };
1238
+ }
1239
+ function closeCall(descriptor, cumulativeAmount, captureAmount, signature) {
1240
+ const padded = new Uint8Array(Math.ceil(signature.length / 32) * 32);
1241
+ padded.set(signature);
1242
+ const head = concat(descriptorWords(descriptor), word(cumulativeAmount), word(captureAmount), word(10n * 32n));
1243
+ return toHex(concat(fromHex(CLOSE) ?? new Uint8Array(), head, word(BigInt(signature.length)), padded));
1244
+ }
1245
+ function descriptorWords(descriptor) {
1246
+ return concat(
1247
+ addressWord(descriptor.payer),
1248
+ addressWord(descriptor.payee),
1249
+ addressWord(descriptor.operator),
1250
+ addressWord(descriptor.token),
1251
+ fromHex(descriptor.salt) ?? new Uint8Array(32),
1252
+ addressWord(descriptor.authorizedSigner),
1253
+ fromHex(descriptor.expiringNonceHash) ?? new Uint8Array(32)
1254
+ );
1255
+ }
1256
+
1257
+ // src/tempo-session-rail.ts
1258
+ var NAME3 = "mpp-tempo-session";
1259
+ var METHOD3 = "tempo";
1260
+ var INTENT3 = "session";
1261
+ var TOKEN_SCALE2 = 6;
1262
+ function mppTempoSession(options) {
1263
+ const secrets = challengeSecrets(options.secret, NAME3);
1264
+ const realm = asciiRealm(options.realm);
1265
+ const recipient = requireAddress(options.recipient, "recipient");
1266
+ const token = requireAddress(options.token.address, "token.address");
1267
+ const escrow = requireAddress(options.escrow ?? TEMPO_CHANNEL_ESCROW, "escrow");
1268
+ const operator = requireAddress(options.operator ?? ZERO_ADDRESS, "operator");
1269
+ const clock = options.clock ?? { now: () => /* @__PURE__ */ new Date() };
1270
+ const rpc = { url: options.rpcUrl, fetch: options.fetch ?? ((input, init) => fetch(input, init)) };
1271
+ const vouchers = /* @__PURE__ */ new Map();
1272
+ const request = (amount) => ({
1273
+ amount: amount.toString(),
1274
+ unitType: "request",
1275
+ currency: token,
1276
+ recipient,
1277
+ methodDetails: {
1278
+ chainId: options.chainId,
1279
+ escrowContract: escrow,
1280
+ sessionProtocol: "v2",
1281
+ ...operator === ZERO_ADDRESS ? {} : { operator }
1282
+ }
1283
+ });
1284
+ return {
1285
+ name: NAME3,
1286
+ livemode: true,
1287
+ capabilities: {
1288
+ flows: ["upfront"],
1289
+ authorization: "reusable",
1290
+ variableAmount: false,
1291
+ quotes: true,
1292
+ // Nothing is captured on-chain per charge, so a refund is releasing consumption the close
1293
+ // helper would otherwise capture. See README: closing must use the ledger's consumption.
1294
+ refund: true,
1295
+ partialRefund: false,
1296
+ lookup: true
1297
+ },
1298
+ offer({ price }) {
1299
+ if (price.currency !== options.denomination) return Promise.resolve(null);
1300
+ return Promise.resolve({
1301
+ rail: NAME3,
1302
+ asset: { code: options.token.code, network: `eip155:${String(options.chainId)}`, scale: TOKEN_SCALE2 },
1303
+ amount: toAssetUnits3(price, TOKEN_SCALE2).toString(),
1304
+ basis: "par",
1305
+ details: { method: METHOD3, intent: INTENT3, escrow, recipient }
1306
+ });
1307
+ },
1308
+ challenge(quote, quoteToken, offer) {
1309
+ return quoteChallenge(secrets, { realm, method: METHOD3, intent: INTENT3, request: request(BigInt(offer.amount)) }, quote, quoteToken);
1310
+ },
1311
+ async verify(context, terms, operation) {
1312
+ const now = clock.now();
1313
+ for (const [key, entry] of vouchers) if (entry.expiresAt <= now.getTime()) vouchers.delete(key);
1314
+ const read = await readCredential(context, { realm, method: METHOD3, intent: INTENT3, secrets, now });
1315
+ if (read.status === "absent") return read;
1316
+ if (read.status === "invalid") return { status: "invalid", reason: read.reason };
1317
+ const { credential } = read;
1318
+ const resolved = await chargeTerms(credential, terms, NAME3);
1319
+ if (resolved.status === "invalid") return { status: "invalid", reason: resolved.reason };
1320
+ const amount = resolved.offer === null ? toAssetUnits3(resolved.price, TOKEN_SCALE2) : BigInt(resolved.offer.amount);
1321
+ if (resolved.price.currency !== options.denomination || !sameRequest(credential, request(amount))) {
1322
+ return { status: "invalid", reason: "challenge_terms_mismatch" };
1323
+ }
1324
+ const { payload } = credential;
1325
+ if (payload.action !== "voucher") return { status: "invalid", reason: "session_action_unsupported" };
1326
+ const descriptor = parseDescriptor(payload.descriptor);
1327
+ const channel = parseBytes32(payload.channelId);
1328
+ const signature = typeof payload.signature === "string" ? fromHex(payload.signature) : void 0;
1329
+ if (descriptor === void 0 || channel === void 0 || signature === void 0 || !isIntegerString(payload.cumulativeAmount)) {
1330
+ return { status: "invalid", reason: "invalid_payload" };
1331
+ }
1332
+ const cumulativeAmount = BigInt(payload.cumulativeAmount);
1333
+ if (cumulativeAmount > MAX_UINT96) return { status: "invalid", reason: "invalid_payload" };
1334
+ if (descriptor.payee !== recipient || descriptor.token !== token || descriptor.operator !== operator) {
1335
+ return { status: "invalid", reason: "channel_terms_mismatch" };
1336
+ }
1337
+ if (channelId(descriptor, escrow, options.chainId) !== channel) return { status: "invalid", reason: "channel_id_mismatch" };
1338
+ if (recoverAddress(voucherDigest(escrow, options.chainId, channel, cumulativeAmount), signature) !== voucherSigner(descriptor)) {
1339
+ return { status: "invalid", reason: "signature_invalid" };
1340
+ }
1341
+ const answer = await rpcCall(rpc, "eth_call", [{ to: escrow, data: getChannelStateCall(channel) }, "latest"], { signal: operation.signal, write: false });
1342
+ const state = answer.ok ? decodeChannelState(answer.result) : void 0;
1343
+ if (state === void 0) throw new TollstileError7("PROVIDER_UNAVAILABLE", "Tempo RPC could not read the channel state.");
1344
+ if (state.deposit === 0n) return { status: "invalid", reason: "channel_not_found" };
1345
+ if (state.closeRequestedAt !== 0n) return { status: "invalid", reason: "channel_closing" };
1346
+ if (cumulativeAmount > state.deposit) return { status: "invalid", reason: "amount_exceeds_deposit" };
1347
+ if (cumulativeAmount < state.settled) return { status: "invalid", reason: "voucher_below_settled" };
1348
+ const expiresAt = Date.parse(credential.challenge.expires);
1349
+ vouchers.set(context.requestId, { channelId: channel, cumulativeAmount, signature: payload.signature, challengeId: credential.challenge.id, expiresAt });
1350
+ return {
1351
+ status: "valid",
1352
+ proofId: channel,
1353
+ payer: `did:pkh:eip155:${String(options.chainId)}:${descriptor.payer}`,
1354
+ quote: resolved.quote,
1355
+ // The ledger stores the limit of the first verification; later deposits do not raise it.
1356
+ limit: money(options.denomination, state.deposit - state.settled),
1357
+ expiresAt: null,
1358
+ data: { channelId: channel, descriptor, baseline: state.settled.toString() }
1359
+ };
1360
+ },
1361
+ settle(authorization, charge) {
1362
+ const data = sessionData(authorization);
1363
+ const voucher = vouchers.get(charge.requestId);
1364
+ if (voucher?.channelId !== data.channelId) {
1365
+ return Promise.reject(
1366
+ new TollstileError7("PROVIDER_UNAVAILABLE", `No voucher for ${charge.id} is held by this process. Reconciliation will release it.`)
1367
+ );
1368
+ }
1369
+ vouchers.delete(charge.requestId);
1370
+ const required = BigInt(data.baseline) + toAssetUnits3(authorization.consumed, TOKEN_SCALE2) + toAssetUnits3(authorization.reserved, TOKEN_SCALE2);
1371
+ if (voucher.cumulativeAmount < required) return Promise.resolve({ status: "rejected", reason: "voucher_insufficient" });
1372
+ return Promise.resolve({
1373
+ status: "settled",
1374
+ reference: `${data.channelId}:${required.toString()}`,
1375
+ details: {
1376
+ channelId: data.channelId,
1377
+ challengeId: voucher.challengeId,
1378
+ cumulativeAmount: voucher.cumulativeAmount.toString(),
1379
+ signature: voucher.signature,
1380
+ required: required.toString()
1381
+ }
1382
+ });
1383
+ },
1384
+ refund(_authorization, charge) {
1385
+ return Promise.resolve({ status: "refunded", reference: `${charge.settlement?.reference ?? charge.id}:uncaptured` });
1386
+ },
1387
+ release(_authorization, charge) {
1388
+ vouchers.delete(charge.requestId);
1389
+ return Promise.resolve();
1390
+ },
1391
+ lookup(_authorization, charge) {
1392
+ return Promise.resolve(
1393
+ charge.pending === "refund" ? { status: "refunded", reference: `${charge.settlement?.reference ?? charge.id}:uncaptured` } : { status: "none" }
1394
+ );
1395
+ },
1396
+ receipt(authorization, charge, context) {
1397
+ const data = sessionData(authorization);
1398
+ const details = isObject(charge.settlement?.details) ? charge.settlement.details : {};
1399
+ return paymentReceipt(context, {
1400
+ method: METHOD3,
1401
+ reference: data.channelId,
1402
+ settledAt: charge.updatedAt,
1403
+ challengeId: typeof details.challengeId === "string" ? details.challengeId : "",
1404
+ extra: {
1405
+ intent: INTENT3,
1406
+ channelId: data.channelId,
1407
+ acceptedCumulative: stringOr(details.cumulativeAmount),
1408
+ spent: stringOr(details.required)
1409
+ }
1410
+ });
1411
+ }
1412
+ };
1413
+ }
1414
+ function tempoSessionClose(input) {
1415
+ const data = sessionData(input.authorization);
1416
+ const escrow = requireAddress(input.escrow ?? TEMPO_CHANNEL_ESCROW, "escrow");
1417
+ const descriptor = parseDescriptor(data.descriptor);
1418
+ if (descriptor === void 0) throw new TollstileError7("LEDGER_INCONSISTENT", `Authorization ${input.authorization.id} has no channel descriptor.`);
1419
+ const consumed = BigInt(data.baseline) + toAssetUnits3(input.authorization.consumed, TOKEN_SCALE2);
1420
+ const captureAmount = input.settledOnChain === void 0 || input.settledOnChain < consumed ? consumed : input.settledOnChain;
1421
+ const voucher = input.charges.filter((charge) => charge.authorizationId === input.authorization.id).map((charge) => isObject(charge.settlement?.details) ? charge.settlement.details : {}).flatMap(
1422
+ (details) => isIntegerString(details.cumulativeAmount) && typeof details.signature === "string" ? [{ cumulativeAmount: BigInt(details.cumulativeAmount), signature: details.signature }] : []
1423
+ ).reduce((best, next) => best === void 0 || next.cumulativeAmount > best.cumulativeAmount ? next : best, void 0);
1424
+ const signature = voucher === void 0 ? void 0 : fromHex(voucher.signature);
1425
+ if (voucher === void 0 || signature === void 0 || voucher.cumulativeAmount < captureAmount) {
1426
+ throw new TollstileError7(
1427
+ "LEDGER_INCONSISTENT",
1428
+ `No voucher among the given charges covers the ${captureAmount.toString()} base units consumed on ${data.channelId}. Pass every settled charge of the authorization.`
1429
+ );
1430
+ }
1431
+ return {
1432
+ channelId: data.channelId,
1433
+ to: escrow,
1434
+ data: closeCall(descriptor, voucher.cumulativeAmount, captureAmount, signature),
1435
+ captureAmount,
1436
+ cumulativeAmount: voucher.cumulativeAmount
1437
+ };
1438
+ }
1439
+ function sessionData(authorization) {
1440
+ const { data } = authorization;
1441
+ const descriptor = isObject(data) ? parseDescriptor(data.descriptor) : void 0;
1442
+ if (!isObject(data) || typeof data.channelId !== "string" || descriptor === void 0 || !isIntegerString(data.baseline)) {
1443
+ throw new TollstileError7("LEDGER_INCONSISTENT", `Authorization ${authorization.id} does not hold mpp-tempo-session data.`);
1444
+ }
1445
+ return { channelId: data.channelId, descriptor, baseline: data.baseline };
1446
+ }
1447
+ function stringOr(value) {
1448
+ return typeof value === "string" ? value : "";
1449
+ }
1450
+ export {
1451
+ mppStripe,
1452
+ mppTempo,
1453
+ mppTempoSession,
1454
+ tempoSessionClose
1455
+ };