mppx 0.17.3 → 0.17.4

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/dist/Errors.d.ts +94 -0
  3. package/dist/Errors.js +83 -0
  4. package/dist/Store.d.ts +199 -11
  5. package/dist/Store.js +527 -48
  6. package/dist/cli/cli.js +141 -137
  7. package/dist/cli/internal.js +28 -6
  8. package/dist/cli/plugins/index.js +16 -12
  9. package/dist/cli/validate/challenge.d.ts +4 -0
  10. package/dist/cli/validate/challenge.js +9 -6
  11. package/dist/cloudflare/ChannelStore.d.ts +97 -0
  12. package/dist/cloudflare/ChannelStore.js +304 -0
  13. package/dist/cloudflare/Channels.d.ts +92 -0
  14. package/dist/cloudflare/Channels.js +325 -0
  15. package/dist/cloudflare/index.d.ts +5 -0
  16. package/dist/cloudflare/index.js +5 -0
  17. package/dist/internal/sse.js +32 -15
  18. package/dist/internal/version.d.ts +2 -2
  19. package/dist/internal/version.js +1 -1
  20. package/dist/stripe/server/Methods.d.ts +2 -0
  21. package/dist/stripe/server/Methods.js +2 -1
  22. package/dist/stripe/server/internal/record-payment.d.ts +49 -0
  23. package/dist/stripe/server/internal/record-payment.js +75 -18
  24. package/dist/tempo/Methods.d.ts +8 -0
  25. package/dist/tempo/Methods.js +18 -2
  26. package/dist/tempo/client/Charge.d.ts +32 -1
  27. package/dist/tempo/client/Charge.js +201 -3
  28. package/dist/tempo/client/Methods.d.ts +16 -0
  29. package/dist/tempo/internal/fee-payer.d.ts +0 -25
  30. package/dist/tempo/internal/fee-payer.js +50 -3
  31. package/dist/tempo/internal/zone.d.ts +79 -0
  32. package/dist/tempo/internal/zone.js +133 -0
  33. package/dist/tempo/server/Charge.d.ts +7 -3
  34. package/dist/tempo/server/Charge.js +98 -4
  35. package/dist/tempo/server/Methods.d.ts +40 -0
  36. package/dist/tempo/server/internal/html.gen.js +1 -1
  37. package/dist/tempo/session/server/ChannelStore.d.ts +5 -0
  38. package/dist/tempo/session/server/ChannelStore.js +2 -2
  39. package/dist/tempo/session/server/CredentialVerification.js +1 -1
  40. package/dist/validation/core.js +2 -0
  41. package/package.json +5 -1
@@ -0,0 +1,325 @@
1
+ import { zeroAddress } from 'viem';
2
+ import { ChannelNotFoundError, VerificationFailedError } from '../Errors.js';
3
+ import * as Store from '../Store.js';
4
+ import { parsePkhSource } from '../tempo/internal/proof.js';
5
+ import { tip20ChannelEscrow } from '../tempo/session/precompile/Protocol.js';
6
+ import { isPrecompileState } from '../tempo/session/server/ChannelStore.js';
7
+ import { settle as settleChannel, SettlementCheckpointError, } from '../tempo/session/server/Settlement.js';
8
+ import { isChannelState, pendingClose, unsettledAmount } from './ChannelStore.js';
9
+ /**
10
+ * Tempo session channel storage on a {@link ChannelStore} Durable Object namespace, to spread
11
+ * into `tempo.session()`: `store` keeps each channel in its own object, which schedules its
12
+ * settlement, and `resolveChannelId` finds the payer's open channel to the same payee, token,
13
+ * escrow, operator and chain, so a client that lost its state resumes it instead of opening another.
14
+ * The lookup needs the payer's identity, which only a `bootstrap` HEAD proof supplies: enable
15
+ * `bootstrap` on the session and in the client's session manager, or it never resolves.
16
+ *
17
+ * The store keeps that lookup current: after a write opens, closes or finalizes a channel,
18
+ * it adds or removes the channel under its payer and authorized signer. Each lookup entry is
19
+ * its own key holding the payer's open channels there, most recent first, so closing one leaves
20
+ * the others findable. The lookup returns the first whose channel still resumes, so a delayed
21
+ * write for a channel that has since closed cannot hide an open one, and mppx validates any
22
+ * channel it resolves before reusing it.
23
+ *
24
+ * @example
25
+ * ```ts
26
+ * import { channels } from 'mppx/cloudflare'
27
+ * import { tempo } from 'mppx/server'
28
+ *
29
+ * tempo.session({ account, bootstrap: true, currency, getClient, ...channels(env.MPPX_CHANNELS) })
30
+ * ```
31
+ */
32
+ export function channels(namespace, options) {
33
+ const base = Store.durableObject(namespace, options);
34
+ const escrowContract = options?.escrowContract ?? tip20ChannelEscrow;
35
+ /** Removes `channelId` from the entry under `key`. */
36
+ async function remove(key, channelId) {
37
+ await base.update(key, (current) => {
38
+ const ids = lookupIds(current);
39
+ if (!ids.includes(channelId))
40
+ return noop;
41
+ const rest = ids.filter((id) => id !== channelId);
42
+ return rest.length
43
+ ? { op: 'set', value: rest, result: undefined }
44
+ : { op: 'delete', result: undefined };
45
+ });
46
+ }
47
+ /**
48
+ * Adds `channelId` to the front of the entry under `key` while its stored channel still
49
+ * resumes, so a delayed add after its close leaves it out. A full entry drops channels that no
50
+ * longer resume before the oldest open one, keeping the most recent {@link maxLookupChannels}.
51
+ */
52
+ async function add(key, channelId) {
53
+ for (let attempt = 1;; attempt++) {
54
+ const ids = lookupIds(await base.get(key));
55
+ if (ids.includes(channelId))
56
+ return;
57
+ if (!resumable(await base.get(channelId)))
58
+ return;
59
+ const full = ids.length >= maxLookupChannels;
60
+ const states = full ? await Promise.all(ids.map((id) => base.get(id))) : [];
61
+ const closed = new Set(ids.filter((_, i) => full && !resumable(states[i])));
62
+ const added = await base.update(key, (current) => {
63
+ const listed = lookupIds(current);
64
+ if (listed.includes(channelId))
65
+ return { op: 'noop', result: true };
66
+ // The entry changed since its channels were read: read them again, a few times at most.
67
+ if (attempt < maxLookupAttempts && !sameIds(listed, ids))
68
+ return { op: 'noop', result: false };
69
+ return {
70
+ op: 'set',
71
+ value: [channelId, ...listed.filter((id) => !closed.has(id))].slice(0, maxLookupChannels),
72
+ result: true,
73
+ };
74
+ });
75
+ if (added)
76
+ return;
77
+ }
78
+ }
79
+ /**
80
+ * Moves the lookup entries of a channel from its previous state to its next one. Entries
81
+ * for the next state are written on every write, not only when they change: the channel
82
+ * commits before its entries, so a failed entry write is repaired by the channel's next
83
+ * write instead of staying missing. Writing an entry that is already there changes nothing.
84
+ * Best effort: the channel write has already committed, so a failed entry write must not
85
+ * report it as failed (a charge would apply without its Receipt).
86
+ */
87
+ async function sync(previous, next) {
88
+ const before = lookupKeys(previous);
89
+ const after = lookupKeys(next);
90
+ await Promise.allSettled([
91
+ ...before
92
+ .filter((key) => !after.includes(key))
93
+ .map((key) => remove(key, previous.channelId)),
94
+ ...after.map((key) => add(key, next.channelId)),
95
+ ]);
96
+ }
97
+ const store = {
98
+ ...base,
99
+ async put(key, value) {
100
+ const previous = await base.get(key);
101
+ await base.put(key, value);
102
+ await sync(previous, value);
103
+ },
104
+ async delete(key) {
105
+ const previous = await base.get(key);
106
+ await base.delete(key);
107
+ await sync(previous, null);
108
+ },
109
+ async update(key, fn) {
110
+ // The last call of `fn` is the one the compare-and-swap committed.
111
+ const applied = {
112
+ previous: null,
113
+ };
114
+ const result = await base.update(key, (current) => {
115
+ applied.previous = current;
116
+ applied.change = fn(current);
117
+ return applied.change;
118
+ });
119
+ const { change, previous } = applied;
120
+ if (change && change.op !== 'noop')
121
+ await sync(previous, change.op === 'set' ? change.value : null);
122
+ return result;
123
+ },
124
+ };
125
+ const resolveChannelId = async ({ paymentRequest, source }) => {
126
+ const payer = source ? parsePkhSource(source) : null;
127
+ const { recipient } = paymentRequest;
128
+ if (!payer || !recipient)
129
+ return undefined;
130
+ if (paymentRequest.chainId !== undefined && paymentRequest.chainId !== payer.chainId)
131
+ return undefined;
132
+ const operator = paymentRequest.operator ?? zeroAddress;
133
+ const ids = lookupIds(await base.get(lookupKey({
134
+ chainId: payer.chainId,
135
+ escrowContract: paymentRequest.escrowContract ?? escrowContract,
136
+ payee: recipient,
137
+ operator,
138
+ payer: payer.address,
139
+ token: paymentRequest.currency,
140
+ })));
141
+ // An ID a delayed write left for a channel that has since closed resumes nothing.
142
+ for (const id of ids) {
143
+ const state = await base.get(id);
144
+ if (!resumable(state))
145
+ continue;
146
+ // mppx verifies a challenge without an operator against the zero operator, and only
147
+ // channels without one (legacy escrow channels) are not keyed by theirs.
148
+ if (isPrecompileState(state)
149
+ ? state.operator.toLowerCase() !== operator.toLowerCase()
150
+ : paymentRequest.operator !== undefined)
151
+ continue;
152
+ return id;
153
+ }
154
+ return undefined;
155
+ };
156
+ return { resolveChannelId, store };
157
+ }
158
+ /**
159
+ * Settles the channels of a settlement queue batch on-chain, from the Worker's `queue()`
160
+ * handler. Each message is acknowledged once its channel settled or has nothing left to
161
+ * settle, and when settlement cannot succeed (the channel is gone or has no voucher); any
162
+ * other failure, such as a fee over the fee payer's cap, retries it after 30 s, doubling up
163
+ * to 10 minutes. The {@link ChannelStore} watchdog sends a channel again if its job never settles it.
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * import { channels, settle } from 'mppx/cloudflare'
168
+ *
169
+ * export default {
170
+ * async queue(batch, env) {
171
+ * await settle(batch, { account, getClient, store: channels(env.MPPX_CHANNELS).store })
172
+ * },
173
+ * }
174
+ * ```
175
+ */
176
+ export async function settle(batch, parameters) {
177
+ const { getClient, settle: settle_ = settleChannel, store, ...options } = parameters;
178
+ for (const message of batch.messages) {
179
+ try {
180
+ const channelId = message.body
181
+ ?.channelId;
182
+ // A malformed job can never settle: drop it instead of failing the batch.
183
+ if (typeof channelId !== 'string') {
184
+ message.ack();
185
+ continue;
186
+ }
187
+ const state = await store.get(channelId);
188
+ // A duplicate job, such as one the watchdog sent while the first was queued, has nothing left.
189
+ if (!isChannelState(state) || state.finalized || unsettledAmount(state) <= 0n) {
190
+ message.ack();
191
+ continue;
192
+ }
193
+ // The server's own cooperative close captures it: check again once that close lands or expires.
194
+ if (pendingClose(state)) {
195
+ message.retry({ delaySeconds: 60 });
196
+ continue;
197
+ }
198
+ // Another job or a scheduled settlement is settling it: check again once that one is done.
199
+ const leaseOwner = await claim(store, channelId);
200
+ if (!leaseOwner) {
201
+ message.retry({ delaySeconds: 60 });
202
+ continue;
203
+ }
204
+ // Waiting for the receipt can outlast the lease: keep it while the settlement runs, so no
205
+ // other job claims the channel and broadcasts the same voucher.
206
+ const renewal = setInterval(() => {
207
+ void renew(store, channelId, leaseOwner).catch(() => undefined);
208
+ }, settlementLeaseMs / 2);
209
+ try {
210
+ await settle_(store, await getClient({ chainId: state.chainId }), channelId, options);
211
+ }
212
+ finally {
213
+ clearInterval(renewal);
214
+ await release(store, channelId, leaseOwner).catch(() => undefined);
215
+ }
216
+ message.ack();
217
+ }
218
+ catch (error) {
219
+ if (isPermanent(error))
220
+ message.ack();
221
+ else
222
+ message.retry({ delaySeconds: Math.min(30 * 2 ** Math.max(0, message.attempts - 1), 600) });
223
+ }
224
+ }
225
+ }
226
+ const noop = { op: 'noop', result: undefined };
227
+ /** How long a settlement claim holds before another job may take over, as scheduled settlement's. */
228
+ const settlementLeaseMs = 5 * 60_000;
229
+ /**
230
+ * Claims a channel's settlement with the lease scheduled settlement takes, so overlapping jobs,
231
+ * or a job and a scheduled settlement, never broadcast the same voucher twice. Returns the
232
+ * lease's owner, or `undefined` while another one holds a live lease.
233
+ */
234
+ async function claim(store, channelId) {
235
+ const owner = globalThis.crypto.randomUUID();
236
+ const now = Date.now();
237
+ return store.update(channelId, (current) => {
238
+ if (!isChannelState(current))
239
+ return { op: 'noop', result: undefined };
240
+ const lease = current.scheduledSettlementLease;
241
+ if (lease && lease.expiresAt > now)
242
+ return { op: 'noop', result: undefined };
243
+ return {
244
+ op: 'set',
245
+ value: {
246
+ ...current,
247
+ scheduledSettlementLease: { expiresAt: now + settlementLeaseMs, owner },
248
+ },
249
+ result: owner,
250
+ };
251
+ });
252
+ }
253
+ /** Extends a settlement lease {@link claim} took while its owner still holds it. */
254
+ async function renew(store, channelId, owner) {
255
+ await store.update(channelId, (current) => {
256
+ if (!isChannelState(current) || current.scheduledSettlementLease?.owner !== owner)
257
+ return noop;
258
+ return {
259
+ op: 'set',
260
+ value: {
261
+ ...current,
262
+ scheduledSettlementLease: { expiresAt: Date.now() + settlementLeaseMs, owner },
263
+ },
264
+ result: undefined,
265
+ };
266
+ });
267
+ }
268
+ /** Releases a settlement lease {@link claim} took, unless another owner has taken it since. */
269
+ async function release(store, channelId, owner) {
270
+ await store.update(channelId, (current) => {
271
+ if (!isChannelState(current) || current.scheduledSettlementLease?.owner !== owner)
272
+ return noop;
273
+ const { scheduledSettlementLease: _, ...channel } = current;
274
+ return { op: 'set', value: channel, result: undefined };
275
+ });
276
+ }
277
+ /**
278
+ * Whether a settlement failed for good: the channel or its voucher is gone, the account cannot
279
+ * settle it, or the transaction confirmed and only recording it failed.
280
+ */
281
+ function isPermanent(error) {
282
+ return (error instanceof ChannelNotFoundError ||
283
+ error instanceof VerificationFailedError ||
284
+ error instanceof SettlementCheckpointError);
285
+ }
286
+ /**
287
+ * The lookup key of a payer's channel to a payee, token, escrow, operator and chain. The operator
288
+ * is part of it so that channels opened with other operators cannot push a matching one out of
289
+ * the entry's {@link maxLookupChannels}.
290
+ */
291
+ function lookupKey(lookup) {
292
+ const { chainId, escrowContract, operator, payee, payer, token } = lookup;
293
+ return `mppx:session-lookup:v3:${[payer, payee, token, escrowContract, operator].map((address) => address.toLowerCase()).join(':')}:${chainId}`;
294
+ }
295
+ /** Most channels a lookup entry keeps per payer, payee, token, escrow, operator and chain. */
296
+ const maxLookupChannels = 8;
297
+ /** Reads of a full lookup entry's channels before adding to it regardless of concurrent changes. */
298
+ const maxLookupAttempts = 3;
299
+ /** Whether two lookup entries list the same channels in the same order. */
300
+ function sameIds(a, b) {
301
+ return a.length === b.length && a.every((id, i) => id === b[i]);
302
+ }
303
+ /** The channel IDs of a lookup entry, most recent first. */
304
+ function lookupIds(entry) {
305
+ return Array.isArray(entry) ? entry.filter((id) => typeof id === 'string') : [];
306
+ }
307
+ /** Whether a payer may resume a stored channel: open, with no close requested. */
308
+ function resumable(state) {
309
+ return isChannelState(state) && !state.finalized && !state.closeRequestedAt;
310
+ }
311
+ /** The lookup keys of a channel a payer may resume. */
312
+ function lookupKeys(state) {
313
+ if (!resumable(state))
314
+ return [];
315
+ const signers = new Set([state.payer.toLowerCase(), state.authorizedSigner.toLowerCase()]);
316
+ return [...signers].map((payer) => lookupKey({
317
+ chainId: state.chainId,
318
+ escrowContract: state.escrowContract,
319
+ operator: isPrecompileState(state) ? state.operator : zeroAddress,
320
+ payee: state.payee,
321
+ payer,
322
+ token: state.token,
323
+ }));
324
+ }
325
+ //# sourceMappingURL=Channels.js.map
@@ -0,0 +1,5 @@
1
+ /** Durable Object class that stores Tempo session channels and schedules their settlement. */
2
+ export { ChannelStore } from './ChannelStore.js';
3
+ /** Channel store and lookup for `tempo.session()`, and the settlement queue consumer. */
4
+ export { channels, settle } from './Channels.js';
5
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,5 @@
1
+ /** Durable Object class that stores Tempo session channels and schedules their settlement. */
2
+ export { ChannelStore } from './ChannelStore.js';
3
+ /** Channel store and lookup for `tempo.session()`, and the settlement queue consumer. */
4
+ export { channels, settle } from './Channels.js';
5
+ //# sourceMappingURL=index.js.map
@@ -21,17 +21,19 @@ export function createParser(onEvent) {
21
21
  event = '';
22
22
  return;
23
23
  }
24
- if (line.startsWith(':'))
25
- return;
26
24
  const colon = line.indexOf(':');
25
+ if (colon === 0)
26
+ return;
27
27
  const field = colon === -1 ? line : line.slice(0, colon);
28
- let value = colon === -1 ? '' : line.slice(colon + 1);
29
- if (value.startsWith(' '))
30
- value = value.slice(1);
31
- if (field === 'data')
32
- data = data === undefined ? value : `${data}\n${value}`;
33
- else if (field === 'event')
28
+ if (field !== 'data' && field !== 'event')
29
+ return;
30
+ let value = '';
31
+ if (colon !== -1)
32
+ value = line.slice(line.charCodeAt(colon + 1) === 32 ? colon + 2 : colon + 1);
33
+ if (field === 'event')
34
34
  event = value;
35
+ else
36
+ data = data === undefined ? value : data + '\n' + value;
35
37
  }
36
38
  return {
37
39
  /** Parses the next chunk of the stream. */
@@ -48,15 +50,30 @@ export function createParser(onEvent) {
48
50
  chunk = chunk.slice(1);
49
51
  }
50
52
  buffer += chunk;
51
- if (!/[\r\n]/.test(chunk))
53
+ if (chunk.indexOf('\n') === -1 && chunk.indexOf('\r') === -1)
52
54
  return;
55
+ // Scan with indexOf rather than a global RegExp: it is several times faster on
56
+ // line-dense bodies.
53
57
  let start = 0;
54
- const terminator = /\r\n?|\n/g;
55
- for (let match = terminator.exec(buffer); match; match = terminator.exec(buffer)) {
56
- parseLine(buffer.slice(start, match.index));
57
- start = terminator.lastIndex;
58
- if (match[0] === '\r' && start === buffer.length)
59
- skipLineFeed = true;
58
+ let lineFeed = buffer.indexOf('\n');
59
+ let carriageReturn = buffer.indexOf('\r');
60
+ while (lineFeed !== -1 || carriageReturn !== -1) {
61
+ const end = lineFeed === -1
62
+ ? carriageReturn
63
+ : carriageReturn === -1
64
+ ? lineFeed
65
+ : Math.min(lineFeed, carriageReturn);
66
+ parseLine(buffer.slice(start, end));
67
+ start = end + 1;
68
+ if (end === carriageReturn) {
69
+ if (start === buffer.length)
70
+ skipLineFeed = true;
71
+ else if (lineFeed === start)
72
+ start++;
73
+ carriageReturn = buffer.indexOf('\r', start);
74
+ }
75
+ if (lineFeed !== -1 && lineFeed < start)
76
+ lineFeed = buffer.indexOf('\n', start);
60
77
  }
61
78
  buffer = buffer.slice(start);
62
79
  },
@@ -1,5 +1,5 @@
1
1
  /** Current mppx package version. */
2
- export declare const version = "0.17.3";
2
+ export declare const version = "0.17.4";
3
3
  /** Canonical SDK identifier for this mppx release. */
4
- export declare const sdkIdentifier = "mppx/0.17.3";
4
+ export declare const sdkIdentifier = "mppx/0.17.4";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/sync-version.ts.
2
2
  /** Current mppx package version. */
3
- export const version = '0.17.3';
3
+ export const version = '0.17.4';
4
4
  /** Canonical SDK identifier for this mppx release. */
5
5
  export const sdkIdentifier = `mppx/${version}`;
6
6
  //# sourceMappingURL=version.js.map
@@ -7,6 +7,7 @@ import * as PaymentIntent from '../internal/payment-intent.js';
7
7
  import type { StripeClient } from '../internal/types.js';
8
8
  import { charge as charge_ } from './Charge.js';
9
9
  import { findOrCreateDepositAddress as _findOrCreateDepositAddress } from './internal/deposit-address.js';
10
+ import { recordPayment as _recordPayment } from './internal/record-payment.js';
10
11
  import { type ConnectConfig } from './internal/request.js';
11
12
  export declare namespace stripe {
12
13
  type Network = 'tempo' | 'base' | 'solana';
@@ -162,6 +163,7 @@ export declare namespace stripe {
162
163
  const charge: typeof charge_;
163
164
  const create: typeof stripe;
164
165
  const findOrCreateDepositAddress: typeof _findOrCreateDepositAddress;
166
+ const recordPayment: typeof _recordPayment;
165
167
  }
166
168
  export {};
167
169
  //# sourceMappingURL=Methods.d.ts.map
@@ -9,7 +9,7 @@ import { charge as charge_ } from './Charge.js';
9
9
  import { buildAnalytics } from './internal/analytics.js';
10
10
  import { findOrCreateDepositAddress as _findOrCreateDepositAddress } from './internal/deposit-address.js';
11
11
  import * as HostedFeePayer from './internal/hosted-fee-payer.js';
12
- import { recordCryptoPayment } from './internal/record-payment.js';
12
+ import { recordCryptoPayment, recordPayment as _recordPayment } from './internal/record-payment.js';
13
13
  const defaultMethodNames = ['tempo', 'spt'];
14
14
  const additionalBuiltinKeys = ['base', 'tempo'];
15
15
  class DefaultMethodsBuilder {
@@ -276,6 +276,7 @@ export function stripe(parameters) {
276
276
  stripe.charge = charge_;
277
277
  stripe.create = stripe;
278
278
  stripe.findOrCreateDepositAddress = _findOrCreateDepositAddress;
279
+ stripe.recordPayment = _recordPayment;
279
280
  })(stripe || (stripe = {}));
280
281
  function neededNetworks(excluded, additional) {
281
282
  const networks = [];
@@ -15,4 +15,53 @@ export declare function recordCryptoPayment(client: StripeClient, parameters: {
15
15
  analyticsMetadata: Record<string, string>;
16
16
  paymentIntentOptions?: PaymentIntent.Options | undefined;
17
17
  }): Promise<void>;
18
+ /**
19
+ * Records a settled crypto payment as a Stripe PaymentIntent in `transaction_verification` mode,
20
+ * the same call mppx makes after a Stripe-backed payment settles. Use it to record payments mppx
21
+ * could not, such as when the process stopped between the broadcast and the recording.
22
+ *
23
+ * The transaction hash (`reference`) is the Stripe idempotency key, as it is for mppx's own
24
+ * recording, so a repeat call for the same reference does not create a second PaymentIntent while
25
+ * Stripe keeps the key (at least 24 hours): it returns `replayed`, or `key_conflict` when an
26
+ * earlier call sent different parameters (such as mppx's own metadata). Stripe also caches failed
27
+ * responses under the key, so `key_conflict` does not mean a PaymentIntent exists: confirm it in
28
+ * Stripe before treating the payment as recorded. mppx's own recording uses
29
+ * `<reference>_fallback` instead only when Stripe rejected the server's `paymentIntentOptions`.
30
+ *
31
+ * Unlike mppx's own recording, it throws when Stripe fails, so the caller can retry.
32
+ */
33
+ export declare function recordPayment(client: StripeClient, parameters: recordPayment.Parameters): Promise<recordPayment.ReturnType>;
34
+ export declare namespace recordPayment {
35
+ type Parameters = {
36
+ /** Amount in the token's raw units (6 decimals), as in the Challenge request. */
37
+ amount: string;
38
+ /** Stripe Connect settings, when the payment belongs to a connected account. */
39
+ connect?: ConnectConfig | undefined;
40
+ /** PaymentIntent metadata. */
41
+ metadata?: Record<string, string> | undefined;
42
+ /** Network the payment settled on. */
43
+ network: stripe.Network;
44
+ /** Transaction hash of the settled payment. */
45
+ reference: string;
46
+ };
47
+ type ReturnType = {
48
+ /** A PaymentIntent was created, or Stripe replayed the earlier one for this reference. */
49
+ status: 'created' | 'replayed';
50
+ paymentIntentId: string;
51
+ /**
52
+ * The PaymentIntent's Stripe status. Only `succeeded` means Stripe verified the
53
+ * transaction; others, such as `processing` or `requires_action`, need a later lookup.
54
+ */
55
+ paymentIntentStatus: string;
56
+ } | {
57
+ /**
58
+ * An earlier call used this reference with different parameters. Stripe caches failed
59
+ * responses too, so whether that call created a PaymentIntent is unknown.
60
+ */
61
+ status: 'key_conflict';
62
+ } | {
63
+ /** The amount is below Stripe's 1 cent minimum, so nothing was recorded. */
64
+ status: 'below_minimum';
65
+ };
66
+ }
18
67
  //# sourceMappingURL=record-payment.d.ts.map
@@ -12,29 +12,17 @@ const NETWORK_CONFIG = {
12
12
  export function recordCryptoPayment(client, parameters) {
13
13
  const { network, reference, amount, connect, analyticsMetadata, paymentIntentOptions } = parameters;
14
14
  const { customer, hooks, metadata, receipt_email } = paymentIntentOptions ?? {};
15
- const { stripeNetworkName, tokenDecimals } = NETWORK_CONFIG[network];
16
- const amountCents = Number(BigInt(amount) / 10n ** BigInt(tokenDecimals - 2));
15
+ const amountCents = toAmountCents(network, amount);
17
16
  if (amountCents < 1) {
18
17
  console.warn(`[stripe] skipping PI recording: ${amount} raw units on ${network} floors to ${amountCents} cents (below Stripe minimum)`);
19
18
  return Promise.resolve();
20
19
  }
21
- const requiredParams = {
22
- amount: amountCents,
23
- currency: 'usd',
24
- confirm: true,
25
- payment_method_data: { type: 'crypto' },
26
- payment_method_types: ['crypto'],
27
- payment_method_options: {
28
- crypto: {
29
- mode: 'transaction_verification',
30
- transaction_verification_options: {
31
- network: stripeNetworkName,
32
- transaction_hash: reference,
33
- },
34
- },
35
- },
20
+ const requiredParams = paymentIntentParams({
21
+ amountCents,
36
22
  metadata: analyticsMetadata,
37
- };
23
+ network,
24
+ reference,
25
+ });
38
26
  const options = {
39
27
  idempotencyKey: reference,
40
28
  ...(connect && { connect }),
@@ -60,6 +48,75 @@ export function recordCryptoPayment(client, parameters) {
60
48
  console.error('[stripe] failed to record crypto payment:', err);
61
49
  });
62
50
  }
51
+ /**
52
+ * Records a settled crypto payment as a Stripe PaymentIntent in `transaction_verification` mode,
53
+ * the same call mppx makes after a Stripe-backed payment settles. Use it to record payments mppx
54
+ * could not, such as when the process stopped between the broadcast and the recording.
55
+ *
56
+ * The transaction hash (`reference`) is the Stripe idempotency key, as it is for mppx's own
57
+ * recording, so a repeat call for the same reference does not create a second PaymentIntent while
58
+ * Stripe keeps the key (at least 24 hours): it returns `replayed`, or `key_conflict` when an
59
+ * earlier call sent different parameters (such as mppx's own metadata). Stripe also caches failed
60
+ * responses under the key, so `key_conflict` does not mean a PaymentIntent exists: confirm it in
61
+ * Stripe before treating the payment as recorded. mppx's own recording uses
62
+ * `<reference>_fallback` instead only when Stripe rejected the server's `paymentIntentOptions`.
63
+ *
64
+ * Unlike mppx's own recording, it throws when Stripe fails, so the caller can retry.
65
+ */
66
+ export async function recordPayment(client, parameters) {
67
+ const { amount, connect, metadata = {}, network, reference } = parameters;
68
+ const amountCents = toAmountCents(network, amount);
69
+ if (amountCents < 1)
70
+ return { status: 'below_minimum' };
71
+ try {
72
+ const result = await createPaymentIntent(client, paymentIntentParams({ amountCents, metadata, network, reference }), { idempotencyKey: reference, ...(connect && { connect }) });
73
+ return {
74
+ paymentIntentId: result.id,
75
+ paymentIntentStatus: result.status,
76
+ status: result.replayed ? 'replayed' : 'created',
77
+ };
78
+ }
79
+ catch (error) {
80
+ if (isIdempotencyError(error))
81
+ return { status: 'key_conflict' };
82
+ throw error;
83
+ }
84
+ }
85
+ /** Converts raw token units to whole US cents, rounding down. */
86
+ function toAmountCents(network, amount) {
87
+ const { tokenDecimals } = NETWORK_CONFIG[network];
88
+ return Number(BigInt(amount) / 10n ** BigInt(tokenDecimals - 2));
89
+ }
90
+ /** The PaymentIntent parameters every recording sends. */
91
+ function paymentIntentParams(parameters) {
92
+ const { amountCents, metadata, network, reference } = parameters;
93
+ return {
94
+ amount: amountCents,
95
+ currency: 'usd',
96
+ confirm: true,
97
+ payment_method_data: { type: 'crypto' },
98
+ payment_method_types: ['crypto'],
99
+ payment_method_options: {
100
+ crypto: {
101
+ mode: 'transaction_verification',
102
+ transaction_verification_options: {
103
+ network: NETWORK_CONFIG[network].stripeNetworkName,
104
+ transaction_hash: reference,
105
+ },
106
+ },
107
+ },
108
+ metadata,
109
+ };
110
+ }
111
+ /** Returns whether Stripe rejected a reused idempotency key sent with different parameters. */
112
+ function isIdempotencyError(error) {
113
+ if (typeof error !== 'object' || error === null)
114
+ return false;
115
+ const candidate = error;
116
+ return (candidate.type === 'StripeIdempotencyError' ||
117
+ candidate.rawType === 'idempotency_error' ||
118
+ candidate.raw?.type === 'idempotency_error');
119
+ }
63
120
  /** Returns whether Stripe definitively rejected request parameters before creating a PI. */
64
121
  function isDefinitiveInvalidRequestError(error) {
65
122
  if (typeof error !== 'object' || error === null)
@@ -33,6 +33,11 @@ export declare const charge: {
33
33
  }, z.core.$strip>, z.ZodMiniObject<{
34
34
  signature: z.ZodMiniString<string>;
35
35
  type: z.ZodMiniLiteral<"proof">;
36
+ }, z.core.$strip>, z.ZodMiniObject<{
37
+ hash: z.ZodMiniString<string>;
38
+ senderTag: z.ZodMiniString<string>;
39
+ type: z.ZodMiniLiteral<"zone">;
40
+ zoneId: z.ZodMiniNumber<number>;
36
41
  }, z.core.$strip>], "type">;
37
42
  };
38
43
  request: z.ZodMiniPipe<z.ZodMiniObject<{
@@ -55,6 +60,7 @@ export declare const charge: {
55
60
  pull: "pull";
56
61
  push: "push";
57
62
  }>>>;
63
+ zones: z.ZodMiniOptional<z.ZodMiniUnion<readonly [z.ZodMiniLiteral<true>, z.ZodMiniArray<z.ZodMiniNumber<number>>]>>;
58
64
  }, z.core.$strip>, z.ZodMiniTransform<{
59
65
  currency: string;
60
66
  description?: string | undefined;
@@ -71,6 +77,7 @@ export declare const charge: {
71
77
  amount: string;
72
78
  }[] | undefined;
73
79
  supportedModes?: ("pull" | "push")[] | undefined;
80
+ zones?: true | number[] | undefined;
74
81
  } | undefined;
75
82
  }, {
76
83
  machineTokenEnabled?: boolean | undefined;
@@ -89,6 +96,7 @@ export declare const charge: {
89
96
  recipient: `0x${string}`;
90
97
  }[] | undefined;
91
98
  supportedModes?: ("pull" | "push")[] | undefined;
99
+ zones?: true | number[] | undefined;
92
100
  }>>;
93
101
  };
94
102
  };