@myzonerocks/pact 0.1.6 → 0.1.8
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/src/adapters/mpesa.js +6 -19
- package/dist/src/adapters/stripe.js +18 -5
- package/dist/src/canonical.js +2 -0
- package/dist/src/client.js +41 -4
- package/dist/src/message.d.ts +2 -0
- package/dist/src/message.js +10 -5
- package/dist/src/wire.js +2 -16
- package/dist/test/initiate-guard.test.d.ts +1 -0
- package/dist/test/initiate-guard.test.js +38 -0
- package/dist/test/mpesa.test.js +7 -3
- package/package.json +1 -1
- package/src/adapters/mpesa.ts +6 -21
- package/src/adapters/stripe.ts +21 -6
- package/src/canonical.ts +2 -0
- package/src/client.ts +41 -4
- package/src/message.ts +11 -5
- package/src/wire.ts +2 -16
|
@@ -153,12 +153,12 @@ export class MpesaLeg {
|
|
|
153
153
|
},
|
|
154
154
|
];
|
|
155
155
|
}
|
|
156
|
-
// The
|
|
157
|
-
// or
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
156
|
+
// The STK push fixed the amount we authorized; a payer approves that exact amount
|
|
157
|
+
// or cancels, so an approved collection is always the full authorized amount. The
|
|
158
|
+
// authenticated query above confirms success, so the settled amount is the one we
|
|
159
|
+
// recorded — it is never read from the unsigned callback, since a forged callback
|
|
160
|
+
// (the checkout id is not a secret) could otherwise carry a wrong amount and block
|
|
161
|
+
// a collection the payer completed. The receipt is an audit reference only.
|
|
162
162
|
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
163
163
|
return [
|
|
164
164
|
{
|
|
@@ -185,19 +185,6 @@ function wholeShillings(m) {
|
|
|
185
185
|
}
|
|
186
186
|
return Number(amount);
|
|
187
187
|
}
|
|
188
|
-
// metadataInt pulls a named numeric value out of the callback metadata items,
|
|
189
|
-
// used to read the paid Amount. M-Pesa amounts are whole shillings but may arrive
|
|
190
|
-
// as a number with a fractional part, so it is floored to the shilling.
|
|
191
|
-
function metadataInt(items, name) {
|
|
192
|
-
for (const item of items) {
|
|
193
|
-
if (item.Name !== name) {
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
const n = Number(item.Value);
|
|
197
|
-
return Number.isFinite(n) ? Math.floor(n) : 0;
|
|
198
|
-
}
|
|
199
|
-
return 0;
|
|
200
|
-
}
|
|
201
188
|
// metadataString pulls a named string value out of the callback metadata items.
|
|
202
189
|
function metadataString(items, name) {
|
|
203
190
|
for (const item of items) {
|
|
@@ -232,8 +232,15 @@ function parseEvent(payload, header, secret, now, tolerance) {
|
|
|
232
232
|
verifySignature(payload, header, secret, now, tolerance);
|
|
233
233
|
const event = JSON.parse(new TextDecoder().decode(payload));
|
|
234
234
|
switch (event.type) {
|
|
235
|
-
case "payment_intent.succeeded":
|
|
236
|
-
|
|
235
|
+
case "payment_intent.succeeded": {
|
|
236
|
+
// The webhook is signed by Stripe, so its amounts are authentic. A capture for
|
|
237
|
+
// less than the authorized amount must not settle the corridor as fully paid.
|
|
238
|
+
const pi = decodePaymentIntent(event.data.object);
|
|
239
|
+
if (pi.amountReceived < pi.amount) {
|
|
240
|
+
return oneEvent(pi, State.Failed, "captured amount is less than the authorized amount");
|
|
241
|
+
}
|
|
242
|
+
return oneEvent(pi, State.Settled, "");
|
|
243
|
+
}
|
|
237
244
|
case "payment_intent.payment_failed":
|
|
238
245
|
return oneEvent(decodePaymentIntent(event.data.object), State.Failed, "payment failed");
|
|
239
246
|
case "charge.refunded":
|
|
@@ -244,13 +251,19 @@ function parseEvent(payload, header, secret, now, tolerance) {
|
|
|
244
251
|
}
|
|
245
252
|
function decodePaymentIntent(raw) {
|
|
246
253
|
const obj = raw;
|
|
247
|
-
return {
|
|
254
|
+
return {
|
|
255
|
+
id: obj.id ?? "",
|
|
256
|
+
metadata: obj.metadata ?? {},
|
|
257
|
+
created: obj.created ?? 0,
|
|
258
|
+
amount: obj.amount ?? 0,
|
|
259
|
+
amountReceived: obj.amount_received ?? 0,
|
|
260
|
+
};
|
|
248
261
|
}
|
|
249
262
|
// decodeRefundedIntent reads the pact intent id off a refunded charge. A charge
|
|
250
263
|
// carries the originating PaymentIntent id and copies its metadata.
|
|
251
264
|
function decodeRefundedIntent(raw) {
|
|
252
265
|
const charge = raw;
|
|
253
|
-
return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0 };
|
|
266
|
+
return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0, amount: 0, amountReceived: 0 };
|
|
254
267
|
}
|
|
255
268
|
function oneEvent(pi, state, reason) {
|
|
256
269
|
return [
|
|
@@ -341,7 +354,7 @@ class HttpStripeApi {
|
|
|
341
354
|
}
|
|
342
355
|
async send(path, init) {
|
|
343
356
|
const headers = {
|
|
344
|
-
...
|
|
357
|
+
...init.headers,
|
|
345
358
|
Authorization: `Bearer ${this.secretKey}`,
|
|
346
359
|
"Stripe-Version": apiVersion,
|
|
347
360
|
};
|
package/dist/src/canonical.js
CHANGED
|
@@ -51,6 +51,8 @@ export class CanonicalWriter {
|
|
|
51
51
|
// byte value so the encoding never depends on insertion order. The sort is on
|
|
52
52
|
// encoded bytes, not on UTF-16 code units, to match the other SDKs exactly.
|
|
53
53
|
stringMap(kv) {
|
|
54
|
+
// Object.keys returns a fresh array, so sorting it in place mutates nothing shared.
|
|
55
|
+
// oxlint-disable-next-line unicorn/no-array-sort
|
|
54
56
|
const keys = Object.keys(kv).sort(compareUtf8);
|
|
55
57
|
this.u64(keys.length);
|
|
56
58
|
for (const k of keys) {
|
package/dist/src/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { State } from "./state.js";
|
|
2
|
+
import { State, stateName } from "./state.js";
|
|
3
3
|
import { Money } from "./money.js";
|
|
4
4
|
import { DEFAULT_SKEW_MILLIS, domain } from "./protocol.js";
|
|
5
5
|
import { CanonicalWriter, hashPreimage } from "./canonical.js";
|
|
@@ -247,9 +247,15 @@ export class Client {
|
|
|
247
247
|
// A retry of an already-submitted corridor must not collect a second time. The
|
|
248
248
|
// pay-in leg carries its own provider idempotency for the narrow window where a
|
|
249
249
|
// collection completed but its record was lost.
|
|
250
|
-
const
|
|
251
|
-
if (
|
|
252
|
-
return
|
|
250
|
+
const current = this.ledger.state(intent.id);
|
|
251
|
+
if (current === State.Submitted || current === State.Collecting) {
|
|
252
|
+
return current;
|
|
253
|
+
}
|
|
254
|
+
// Collection may only start from an authorized intent. Without this a replayed
|
|
255
|
+
// or out-of-order call would charge the payer through collect before the ledger
|
|
256
|
+
// rejected the transition, so the guard is enforced before any money moves.
|
|
257
|
+
if (current !== State.Authorized) {
|
|
258
|
+
throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
|
|
253
259
|
}
|
|
254
260
|
// A direct corridor collects straight to the recipient; a bridged one
|
|
255
261
|
// collects into escrow first.
|
|
@@ -282,6 +288,12 @@ export class Client {
|
|
|
282
288
|
if (!isInteractivePayInLeg(payInLeg)) {
|
|
283
289
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
|
|
284
290
|
}
|
|
291
|
+
// The provider intent may only be prepared from an authorized intent, so a
|
|
292
|
+
// replayed or out-of-order call cannot open a second collection.
|
|
293
|
+
const current = this.ledger.state(intent.id);
|
|
294
|
+
if (current !== State.Authorized) {
|
|
295
|
+
throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
|
|
296
|
+
}
|
|
285
297
|
const preparation = await payInLeg.prepare(intent.id, quote, auth, recipientDestination(intent));
|
|
286
298
|
// Record the same shape a direct collection would, so the confirming webhook
|
|
287
299
|
// advances the corridor to settled through the unchanged pay-in path.
|
|
@@ -338,6 +350,31 @@ export class Client {
|
|
|
338
350
|
return this.unwind(intentId, quote, authHash, event.reason);
|
|
339
351
|
return this.settleBridged(intentId, quote, authHash, events);
|
|
340
352
|
}
|
|
353
|
+
// A refund reported out of band — an operator refunds in the provider's
|
|
354
|
+
// dashboard, say — is recorded against the settled intent so the ledger matches
|
|
355
|
+
// where the money actually is. The provider has already returned the funds, so
|
|
356
|
+
// this only claims the refunding step (once, so a duplicate webhook is a no-op)
|
|
357
|
+
// and records the refunded outcome; it moves no money itself.
|
|
358
|
+
if (event.state === State.Refunded && current === State.Settled) {
|
|
359
|
+
const { created } = this.ledger.apply({
|
|
360
|
+
intentId,
|
|
361
|
+
to: State.Refunding,
|
|
362
|
+
payloadHash: hashString(domain("refunding"), event.reason),
|
|
363
|
+
});
|
|
364
|
+
if (!created)
|
|
365
|
+
return { settlement: lastSettlement(events), state: this.ledger.state(intentId) };
|
|
366
|
+
const settlement = {
|
|
367
|
+
intentId,
|
|
368
|
+
state: State.Refunded,
|
|
369
|
+
adapterId,
|
|
370
|
+
providerTxRef: event.providerTxRef,
|
|
371
|
+
onchainTxHash: "",
|
|
372
|
+
receiptHash: corridorReceipt(authHash, "", event.providerTxRef, quote.srcAmount, "", State.Refunded),
|
|
373
|
+
reason: event.reason,
|
|
374
|
+
settledAt: this.clock(),
|
|
375
|
+
};
|
|
376
|
+
return this.finishAdvance(intentId, State.Refunded, settlement);
|
|
377
|
+
}
|
|
341
378
|
// Any other event — a duplicate webhook, or one for a phase already past — is
|
|
342
379
|
// a no-op. This is what makes a re-delivered provider callback settle once.
|
|
343
380
|
return { settlement: lastSettlement(events), state: current };
|
package/dist/src/message.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Money } from "./money.js";
|
|
2
2
|
import { State } from "./state.js";
|
|
3
|
+
import { CanonicalWriter } from "./canonical.js";
|
|
3
4
|
export interface Intent {
|
|
4
5
|
id: string;
|
|
5
6
|
senderRef: string;
|
|
@@ -29,6 +30,7 @@ export interface Quote {
|
|
|
29
30
|
providerQuoteRef: string;
|
|
30
31
|
latencyEstimateMs: number;
|
|
31
32
|
}
|
|
33
|
+
export declare function writeQuoteFields(w: CanonicalWriter, q: Quote): CanonicalWriter;
|
|
32
34
|
export declare function quotePreimage(q: Quote): Uint8Array;
|
|
33
35
|
export declare function quoteHash(q: Quote): Uint8Array;
|
|
34
36
|
export declare function isDirect(q: Quote): boolean;
|
package/dist/src/message.js
CHANGED
|
@@ -20,9 +20,11 @@ export function intentHash(i) {
|
|
|
20
20
|
// The bridge that moves value unchanged when the payer and recipient already
|
|
21
21
|
// share a currency. Its identifier is a stable wire value carried in a quote.
|
|
22
22
|
export const BRIDGE_PASSTHROUGH = "passthrough";
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// writeQuoteFields appends a quote's fields to a writer in the one canonical order
|
|
24
|
+
// shared by the signing preimage and the wire codec. The two callers differ only in
|
|
25
|
+
// what frames the fields: the preimage prepends a domain tag, the wire form does not.
|
|
26
|
+
export function writeQuoteFields(w, q) {
|
|
27
|
+
return w
|
|
26
28
|
.str(q.id)
|
|
27
29
|
.str(q.intentId)
|
|
28
30
|
.str(q.payInAdapterId)
|
|
@@ -36,8 +38,11 @@ export function quotePreimage(q) {
|
|
|
36
38
|
.str(q.fxRate)
|
|
37
39
|
.u64(q.expiresAt)
|
|
38
40
|
.str(q.providerQuoteRef)
|
|
39
|
-
.u64(q.latencyEstimateMs)
|
|
40
|
-
|
|
41
|
+
.u64(q.latencyEstimateMs);
|
|
42
|
+
}
|
|
43
|
+
export function quotePreimage(q) {
|
|
44
|
+
const w = new CanonicalWriter().str(domain("quote"));
|
|
45
|
+
return writeQuoteFields(w, q).preimage();
|
|
41
46
|
}
|
|
42
47
|
export function quoteHash(q) {
|
|
43
48
|
return hashPreimage(quotePreimage(q));
|
package/dist/src/wire.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Money } from "./money.js";
|
|
2
2
|
import { stateName } from "./state.js";
|
|
3
3
|
import { CanonicalWriter } from "./canonical.js";
|
|
4
|
+
import { writeQuoteFields } from "./message.js";
|
|
4
5
|
// The wire codec serializes messages for transport. It is distinct from the
|
|
5
6
|
// canonical signing preimage: signing binds a fixed subset of fields under a
|
|
6
7
|
// domain tag, while the wire form carries every field so a peer can reconstruct
|
|
@@ -27,22 +28,7 @@ export function encodeIntent(i) {
|
|
|
27
28
|
.preimage();
|
|
28
29
|
}
|
|
29
30
|
export function encodeQuote(q) {
|
|
30
|
-
return new CanonicalWriter()
|
|
31
|
-
.str(q.id)
|
|
32
|
-
.str(q.intentId)
|
|
33
|
-
.str(q.payInAdapterId)
|
|
34
|
-
.str(q.payInRail)
|
|
35
|
-
.str(q.payOutAdapterId)
|
|
36
|
-
.str(q.payOutRail)
|
|
37
|
-
.str(q.bridgeId)
|
|
38
|
-
.money(q.srcAmount)
|
|
39
|
-
.money(q.dstAmount)
|
|
40
|
-
.money(q.fees)
|
|
41
|
-
.str(q.fxRate)
|
|
42
|
-
.u64(q.expiresAt)
|
|
43
|
-
.str(q.providerQuoteRef)
|
|
44
|
-
.u64(q.latencyEstimateMs)
|
|
45
|
-
.preimage();
|
|
31
|
+
return writeQuoteFields(new CanonicalWriter(), q).preimage();
|
|
46
32
|
}
|
|
47
33
|
export function encodeAuthorization(a) {
|
|
48
34
|
return new CanonicalWriter()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { Client } from "../src/client.js";
|
|
3
|
+
import { Money } from "../src/money.js";
|
|
4
|
+
import { State } from "../src/state.js";
|
|
5
|
+
import { FakeLeg } from "./fake.js";
|
|
6
|
+
import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
|
|
7
|
+
import { fromHex } from "../src/crypto.js";
|
|
8
|
+
// Initiate may only collect from an authorized intent. This guards against a
|
|
9
|
+
// replayed or out-of-order call charging the payer a second time, matching the
|
|
10
|
+
// same guard in the Go, Dart, Swift, and Kotlin SDKs.
|
|
11
|
+
describe("initiate state guard", () => {
|
|
12
|
+
it("refuses to initiate from any state but authorized", async () => {
|
|
13
|
+
const now = 1_700_000_000_000;
|
|
14
|
+
const seed = fromHex("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
|
|
15
|
+
const signer = Ed25519Signer.fromSeed("alice", seed);
|
|
16
|
+
const verifier = new Ed25519Verifier(new Map([["alice", signer.publicKey]]));
|
|
17
|
+
let n = 0;
|
|
18
|
+
const ids = () => `id-${String(++n).padStart(3, "0")}`;
|
|
19
|
+
const wallet = new FakeLeg("wallet", "wallet", "USD", ids);
|
|
20
|
+
const client = new Client({ payIn: [wallet], payOut: [wallet], verifier, clock: () => now, idGen: ids });
|
|
21
|
+
const intent = client.createIntent({
|
|
22
|
+
senderRef: "alice",
|
|
23
|
+
recipientRef: "bob",
|
|
24
|
+
amount: Money.parse("USD", 2, "1500"),
|
|
25
|
+
expiresAt: now + 600_000,
|
|
26
|
+
allowedRails: ["wallet"],
|
|
27
|
+
});
|
|
28
|
+
const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "wallet", currency: "USD", exponent: 2 }]);
|
|
29
|
+
const quote = await client.select(quotes, signer.identity());
|
|
30
|
+
// Before authorizing, the intent is only quoted, so initiate is refused.
|
|
31
|
+
await expect(client.initiate(intent, quote, { intentId: intent.id })).rejects.toThrow();
|
|
32
|
+
const auth = await client.authorize(intent, quote, signer);
|
|
33
|
+
await client.initiate(intent, quote, auth);
|
|
34
|
+
await client.advance(intent.id, "wallet", { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
|
|
35
|
+
// Once settled, a replayed initiate must not collect again.
|
|
36
|
+
await expect(client.initiate(intent, quote, auth)).rejects.toThrow();
|
|
37
|
+
});
|
|
38
|
+
});
|
package/dist/test/mpesa.test.js
CHANGED
|
@@ -201,13 +201,17 @@ describe("mpesa pay-in", () => {
|
|
|
201
201
|
const events = await leg.parseWebhook(successCallback(collected.providerRef, "FORGEDRCPT"), {});
|
|
202
202
|
expect(events).toHaveLength(0);
|
|
203
203
|
});
|
|
204
|
-
it("
|
|
204
|
+
it("a forged callback amount cannot block a confirmed collection", async () => {
|
|
205
205
|
const api = new FakeDaraja(counter("tx"));
|
|
206
206
|
const leg = buildLeg(api);
|
|
207
207
|
const q = quote({ srcAmount: kes("5000"), fees: kes("0") });
|
|
208
208
|
const collected = await leg.collect("intent-1", q, emptyAuth, "0711000111");
|
|
209
|
-
// Daraja confirms success
|
|
209
|
+
// Daraja's authenticated query confirms success; the callback body claims a
|
|
210
|
+
// different (forged) amount, which must be ignored — the collection still settles.
|
|
210
211
|
api.queryResults.set(collected.providerRef, { resultCode: 0, resultDesc: "", pending: false });
|
|
211
|
-
await
|
|
212
|
+
const events = await leg.parseWebhook(successCallback(collected.providerRef, "QGR7XYZ123"), {});
|
|
213
|
+
expect(events).toHaveLength(1);
|
|
214
|
+
expect(events[0].state).toBe(State.Settled);
|
|
215
|
+
expect(events[0].providerTxRef).toBe("QGR7XYZ123");
|
|
212
216
|
});
|
|
213
217
|
});
|
package/package.json
CHANGED
package/src/adapters/mpesa.ts
CHANGED
|
@@ -270,13 +270,12 @@ export class MpesaLeg implements PayInLeg, PayOutLeg {
|
|
|
270
270
|
},
|
|
271
271
|
];
|
|
272
272
|
}
|
|
273
|
-
// The
|
|
274
|
-
// or
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
273
|
+
// The STK push fixed the amount we authorized; a payer approves that exact amount
|
|
274
|
+
// or cancels, so an approved collection is always the full authorized amount. The
|
|
275
|
+
// authenticated query above confirms success, so the settled amount is the one we
|
|
276
|
+
// recorded — it is never read from the unsigned callback, since a forged callback
|
|
277
|
+
// (the checkout id is not a secret) could otherwise carry a wrong amount and block
|
|
278
|
+
// a collection the payer completed. The receipt is an audit reference only.
|
|
280
279
|
const receipt = metadataString(cb.CallbackMetadata?.Item ?? [], "MpesaReceiptNumber");
|
|
281
280
|
return [
|
|
282
281
|
{
|
|
@@ -321,20 +320,6 @@ function wholeShillings(m: Money): number {
|
|
|
321
320
|
return Number(amount);
|
|
322
321
|
}
|
|
323
322
|
|
|
324
|
-
// metadataInt pulls a named numeric value out of the callback metadata items,
|
|
325
|
-
// used to read the paid Amount. M-Pesa amounts are whole shillings but may arrive
|
|
326
|
-
// as a number with a fractional part, so it is floored to the shilling.
|
|
327
|
-
function metadataInt(items: StkCallbackItem[], name: string): number {
|
|
328
|
-
for (const item of items) {
|
|
329
|
-
if (item.Name !== name) {
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
const n = Number(item.Value);
|
|
333
|
-
return Number.isFinite(n) ? Math.floor(n) : 0;
|
|
334
|
-
}
|
|
335
|
-
return 0;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
323
|
// metadataString pulls a named string value out of the callback metadata items.
|
|
339
324
|
function metadataString(items: StkCallbackItem[], name: string): string {
|
|
340
325
|
for (const item of items) {
|
package/src/adapters/stripe.ts
CHANGED
|
@@ -353,8 +353,15 @@ function parseEvent(
|
|
|
353
353
|
const event = JSON.parse(new TextDecoder().decode(payload)) as StripeEvent;
|
|
354
354
|
|
|
355
355
|
switch (event.type) {
|
|
356
|
-
case "payment_intent.succeeded":
|
|
357
|
-
|
|
356
|
+
case "payment_intent.succeeded": {
|
|
357
|
+
// The webhook is signed by Stripe, so its amounts are authentic. A capture for
|
|
358
|
+
// less than the authorized amount must not settle the corridor as fully paid.
|
|
359
|
+
const pi = decodePaymentIntent(event.data.object);
|
|
360
|
+
if (pi.amountReceived < pi.amount) {
|
|
361
|
+
return oneEvent(pi, State.Failed, "captured amount is less than the authorized amount");
|
|
362
|
+
}
|
|
363
|
+
return oneEvent(pi, State.Settled, "");
|
|
364
|
+
}
|
|
358
365
|
case "payment_intent.payment_failed":
|
|
359
366
|
return oneEvent(decodePaymentIntent(event.data.object), State.Failed, "payment failed");
|
|
360
367
|
case "charge.refunded":
|
|
@@ -368,18 +375,26 @@ interface WebhookIntent {
|
|
|
368
375
|
id: string;
|
|
369
376
|
metadata: Record<string, string>;
|
|
370
377
|
created: number;
|
|
378
|
+
amount: number;
|
|
379
|
+
amountReceived: number;
|
|
371
380
|
}
|
|
372
381
|
|
|
373
382
|
function decodePaymentIntent(raw: unknown): WebhookIntent {
|
|
374
|
-
const obj = raw as { id?: string; metadata?: Record<string, string>; created?: number };
|
|
375
|
-
return {
|
|
383
|
+
const obj = raw as { id?: string; metadata?: Record<string, string>; created?: number; amount?: number; amount_received?: number };
|
|
384
|
+
return {
|
|
385
|
+
id: obj.id ?? "",
|
|
386
|
+
metadata: obj.metadata ?? {},
|
|
387
|
+
created: obj.created ?? 0,
|
|
388
|
+
amount: obj.amount ?? 0,
|
|
389
|
+
amountReceived: obj.amount_received ?? 0,
|
|
390
|
+
};
|
|
376
391
|
}
|
|
377
392
|
|
|
378
393
|
// decodeRefundedIntent reads the pact intent id off a refunded charge. A charge
|
|
379
394
|
// carries the originating PaymentIntent id and copies its metadata.
|
|
380
395
|
function decodeRefundedIntent(raw: unknown): WebhookIntent {
|
|
381
396
|
const charge = raw as { payment_intent?: string; metadata?: Record<string, string>; created?: number };
|
|
382
|
-
return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0 };
|
|
397
|
+
return { id: charge.payment_intent ?? "", metadata: charge.metadata ?? {}, created: charge.created ?? 0, amount: 0, amountReceived: 0 };
|
|
383
398
|
}
|
|
384
399
|
|
|
385
400
|
function oneEvent(pi: WebhookIntent, state: State, reason: string): AdapterEvent[] {
|
|
@@ -483,7 +498,7 @@ class HttpStripeApi implements StripeApi {
|
|
|
483
498
|
|
|
484
499
|
private async send<T>(path: string, init: RequestInit): Promise<T> {
|
|
485
500
|
const headers: Record<string, string> = {
|
|
486
|
-
...(
|
|
501
|
+
...(init.headers as Record<string, string> | undefined),
|
|
487
502
|
Authorization: `Bearer ${this.secretKey}`,
|
|
488
503
|
"Stripe-Version": apiVersion,
|
|
489
504
|
};
|
package/src/canonical.ts
CHANGED
|
@@ -61,6 +61,8 @@ export class CanonicalWriter {
|
|
|
61
61
|
// byte value so the encoding never depends on insertion order. The sort is on
|
|
62
62
|
// encoded bytes, not on UTF-16 code units, to match the other SDKs exactly.
|
|
63
63
|
stringMap(kv: Readonly<Record<string, string>>): this {
|
|
64
|
+
// Object.keys returns a fresh array, so sorting it in place mutates nothing shared.
|
|
65
|
+
// oxlint-disable-next-line unicorn/no-array-sort
|
|
64
66
|
const keys = Object.keys(kv).sort(compareUtf8);
|
|
65
67
|
this.u64(keys.length);
|
|
66
68
|
for (const k of keys) {
|
package/src/client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { State } from "./state.js";
|
|
2
|
+
import { State, stateName } from "./state.js";
|
|
3
3
|
import { Money } from "./money.js";
|
|
4
4
|
import { DEFAULT_SKEW_MILLIS, domain } from "./protocol.js";
|
|
5
5
|
import { CanonicalWriter, hashPreimage } from "./canonical.js";
|
|
@@ -315,9 +315,15 @@ export class Client {
|
|
|
315
315
|
// A retry of an already-submitted corridor must not collect a second time. The
|
|
316
316
|
// pay-in leg carries its own provider idempotency for the narrow window where a
|
|
317
317
|
// collection completed but its record was lost.
|
|
318
|
-
const
|
|
319
|
-
if (
|
|
320
|
-
return
|
|
318
|
+
const current = this.ledger.state(intent.id);
|
|
319
|
+
if (current === State.Submitted || current === State.Collecting) {
|
|
320
|
+
return current;
|
|
321
|
+
}
|
|
322
|
+
// Collection may only start from an authorized intent. Without this a replayed
|
|
323
|
+
// or out-of-order call would charge the payer through collect before the ledger
|
|
324
|
+
// rejected the transition, so the guard is enforced before any money moves.
|
|
325
|
+
if (current !== State.Authorized) {
|
|
326
|
+
throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
|
|
321
327
|
}
|
|
322
328
|
|
|
323
329
|
// A direct corridor collects straight to the recipient; a bridged one
|
|
@@ -352,6 +358,12 @@ export class Client {
|
|
|
352
358
|
if (!isInteractivePayInLeg(payInLeg)) {
|
|
353
359
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
|
|
354
360
|
}
|
|
361
|
+
// The provider intent may only be prepared from an authorized intent, so a
|
|
362
|
+
// replayed or out-of-order call cannot open a second collection.
|
|
363
|
+
const current = this.ledger.state(intent.id);
|
|
364
|
+
if (current !== State.Authorized) {
|
|
365
|
+
throw new Error(`pact: cannot initiate an intent in state ${stateName[current]}; it must be authorized first`);
|
|
366
|
+
}
|
|
355
367
|
const preparation = await payInLeg.prepare(intent.id, quote, auth, recipientDestination(intent));
|
|
356
368
|
// Record the same shape a direct collection would, so the confirming webhook
|
|
357
369
|
// advances the corridor to settled through the unchanged pay-in path.
|
|
@@ -410,6 +422,31 @@ export class Client {
|
|
|
410
422
|
return this.settleBridged(intentId, quote, authHash, events);
|
|
411
423
|
}
|
|
412
424
|
|
|
425
|
+
// A refund reported out of band — an operator refunds in the provider's
|
|
426
|
+
// dashboard, say — is recorded against the settled intent so the ledger matches
|
|
427
|
+
// where the money actually is. The provider has already returned the funds, so
|
|
428
|
+
// this only claims the refunding step (once, so a duplicate webhook is a no-op)
|
|
429
|
+
// and records the refunded outcome; it moves no money itself.
|
|
430
|
+
if (event.state === State.Refunded && current === State.Settled) {
|
|
431
|
+
const { created } = this.ledger.apply({
|
|
432
|
+
intentId,
|
|
433
|
+
to: State.Refunding,
|
|
434
|
+
payloadHash: hashString(domain("refunding"), event.reason),
|
|
435
|
+
});
|
|
436
|
+
if (!created) return { settlement: lastSettlement(events), state: this.ledger.state(intentId) };
|
|
437
|
+
const settlement: Settlement = {
|
|
438
|
+
intentId,
|
|
439
|
+
state: State.Refunded,
|
|
440
|
+
adapterId,
|
|
441
|
+
providerTxRef: event.providerTxRef,
|
|
442
|
+
onchainTxHash: "",
|
|
443
|
+
receiptHash: corridorReceipt(authHash, "", event.providerTxRef, quote.srcAmount, "", State.Refunded),
|
|
444
|
+
reason: event.reason,
|
|
445
|
+
settledAt: this.clock(),
|
|
446
|
+
};
|
|
447
|
+
return this.finishAdvance(intentId, State.Refunded, settlement);
|
|
448
|
+
}
|
|
449
|
+
|
|
413
450
|
// Any other event — a duplicate webhook, or one for a phase already past — is
|
|
414
451
|
// a no-op. This is what makes a re-delivered provider callback settle once.
|
|
415
452
|
return { settlement: lastSettlement(events), state: current };
|
package/src/message.ts
CHANGED
|
@@ -60,9 +60,11 @@ export interface Quote {
|
|
|
60
60
|
latencyEstimateMs: number;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
// writeQuoteFields appends a quote's fields to a writer in the one canonical order
|
|
64
|
+
// shared by the signing preimage and the wire codec. The two callers differ only in
|
|
65
|
+
// what frames the fields: the preimage prepends a domain tag, the wire form does not.
|
|
66
|
+
export function writeQuoteFields(w: CanonicalWriter, q: Quote): CanonicalWriter {
|
|
67
|
+
return w
|
|
66
68
|
.str(q.id)
|
|
67
69
|
.str(q.intentId)
|
|
68
70
|
.str(q.payInAdapterId)
|
|
@@ -76,8 +78,12 @@ export function quotePreimage(q: Quote): Uint8Array {
|
|
|
76
78
|
.str(q.fxRate)
|
|
77
79
|
.u64(q.expiresAt)
|
|
78
80
|
.str(q.providerQuoteRef)
|
|
79
|
-
.u64(q.latencyEstimateMs)
|
|
80
|
-
|
|
81
|
+
.u64(q.latencyEstimateMs);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function quotePreimage(q: Quote): Uint8Array {
|
|
85
|
+
const w = new CanonicalWriter().str(domain("quote"));
|
|
86
|
+
return writeQuoteFields(w, q).preimage();
|
|
81
87
|
}
|
|
82
88
|
|
|
83
89
|
export function quoteHash(q: Quote): Uint8Array {
|
package/src/wire.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Money } from "./money.js";
|
|
2
2
|
import { State, stateName } from "./state.js";
|
|
3
3
|
import { CanonicalWriter } from "./canonical.js";
|
|
4
|
+
import { writeQuoteFields } from "./message.js";
|
|
4
5
|
import type { Intent, Quote, Authorization, Settlement } from "./message.js";
|
|
5
6
|
|
|
6
7
|
// The wire codec serializes messages for transport. It is distinct from the
|
|
@@ -32,22 +33,7 @@ export function encodeIntent(i: Intent): Uint8Array {
|
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
export function encodeQuote(q: Quote): Uint8Array {
|
|
35
|
-
return new CanonicalWriter()
|
|
36
|
-
.str(q.id)
|
|
37
|
-
.str(q.intentId)
|
|
38
|
-
.str(q.payInAdapterId)
|
|
39
|
-
.str(q.payInRail)
|
|
40
|
-
.str(q.payOutAdapterId)
|
|
41
|
-
.str(q.payOutRail)
|
|
42
|
-
.str(q.bridgeId)
|
|
43
|
-
.money(q.srcAmount)
|
|
44
|
-
.money(q.dstAmount)
|
|
45
|
-
.money(q.fees)
|
|
46
|
-
.str(q.fxRate)
|
|
47
|
-
.u64(q.expiresAt)
|
|
48
|
-
.str(q.providerQuoteRef)
|
|
49
|
-
.u64(q.latencyEstimateMs)
|
|
50
|
-
.preimage();
|
|
36
|
+
return writeQuoteFields(new CanonicalWriter(), q).preimage();
|
|
51
37
|
}
|
|
52
38
|
|
|
53
39
|
export function encodeAuthorization(a: Authorization): Uint8Array {
|