@myzonerocks/pact 0.1.6 → 0.1.7

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.
@@ -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
- return oneEvent(decodePaymentIntent(event.data.object), State.Settled, "");
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 { id: obj.id ?? "", metadata: obj.metadata ?? {}, created: obj.created ?? 0 };
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
- ...(init.headers ?? {}),
357
+ ...init.headers,
345
358
  Authorization: `Bearer ${this.secretKey}`,
346
359
  "Stripe-Version": apiVersion,
347
360
  };
@@ -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) {
@@ -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 submitted = this.ledger.state(intent.id);
251
- if (submitted === State.Submitted || submitted === State.Collecting) {
252
- return submitted;
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 };
@@ -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;
@@ -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
- export function quotePreimage(q) {
24
- return new CanonicalWriter()
25
- .str(domain("quote"))
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
- .preimage();
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myzonerocks/pact",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "TypeScript SDK for the PACT payment abstraction protocol",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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
- return oneEvent(decodePaymentIntent(event.data.object), State.Settled, "");
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 { id: obj.id ?? "", metadata: obj.metadata ?? {}, created: obj.created ?? 0 };
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
- ...((init.headers as Record<string, string>) ?? {}),
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 submitted = this.ledger.state(intent.id);
319
- if (submitted === State.Submitted || submitted === State.Collecting) {
320
- return submitted;
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
- export function quotePreimage(q: Quote): Uint8Array {
64
- return new CanonicalWriter()
65
- .str(domain("quote"))
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
- .preimage();
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 {