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.
- package/CHANGELOG.md +37 -0
- package/dist/Errors.d.ts +94 -0
- package/dist/Errors.js +83 -0
- package/dist/Store.d.ts +199 -11
- package/dist/Store.js +527 -48
- package/dist/cli/cli.js +141 -137
- package/dist/cli/internal.js +28 -6
- package/dist/cli/plugins/index.js +16 -12
- package/dist/cli/validate/challenge.d.ts +4 -0
- package/dist/cli/validate/challenge.js +9 -6
- package/dist/cloudflare/ChannelStore.d.ts +97 -0
- package/dist/cloudflare/ChannelStore.js +304 -0
- package/dist/cloudflare/Channels.d.ts +92 -0
- package/dist/cloudflare/Channels.js +325 -0
- package/dist/cloudflare/index.d.ts +5 -0
- package/dist/cloudflare/index.js +5 -0
- package/dist/internal/sse.js +32 -15
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/stripe/server/Methods.d.ts +2 -0
- package/dist/stripe/server/Methods.js +2 -1
- package/dist/stripe/server/internal/record-payment.d.ts +49 -0
- package/dist/stripe/server/internal/record-payment.js +75 -18
- package/dist/tempo/Methods.d.ts +8 -0
- package/dist/tempo/Methods.js +18 -2
- package/dist/tempo/client/Charge.d.ts +32 -1
- package/dist/tempo/client/Charge.js +201 -3
- package/dist/tempo/client/Methods.d.ts +16 -0
- package/dist/tempo/internal/fee-payer.d.ts +0 -25
- package/dist/tempo/internal/fee-payer.js +50 -3
- package/dist/tempo/internal/zone.d.ts +79 -0
- package/dist/tempo/internal/zone.js +133 -0
- package/dist/tempo/server/Charge.d.ts +7 -3
- package/dist/tempo/server/Charge.js +98 -4
- package/dist/tempo/server/Methods.d.ts +40 -0
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/session/server/ChannelStore.d.ts +5 -0
- package/dist/tempo/session/server/ChannelStore.js +2 -2
- package/dist/tempo/session/server/CredentialVerification.js +1 -1
- package/dist/validation/core.js +2 -0
- 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
|
package/dist/internal/sse.js
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
-
|
|
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 (
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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.
|
|
4
|
+
export declare const sdkIdentifier = "mppx/0.17.4";
|
|
5
5
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/internal/version.js
CHANGED
|
@@ -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
|
+
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
|
|
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
|
-
|
|
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)
|
package/dist/tempo/Methods.d.ts
CHANGED
|
@@ -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
|
};
|