@myzonerocks/pact 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/src/adapter.d.ts +18 -0
  2. package/dist/src/adapter.js +11 -0
  3. package/dist/src/adapters/erc20.d.ts +43 -0
  4. package/dist/src/adapters/erc20.js +126 -0
  5. package/dist/src/adapters/mpesa.d.ts +61 -0
  6. package/dist/src/adapters/mpesa.js +272 -0
  7. package/dist/src/adapters/paypal.d.ts +68 -0
  8. package/dist/src/adapters/paypal.js +279 -0
  9. package/dist/src/adapters/stripe.d.ts +61 -0
  10. package/dist/src/adapters/stripe.js +336 -0
  11. package/dist/src/bridge.d.ts +40 -0
  12. package/dist/src/bridge.js +99 -0
  13. package/dist/src/canonical.d.ts +14 -0
  14. package/dist/src/canonical.js +82 -0
  15. package/dist/src/client.d.ts +84 -0
  16. package/dist/src/client.js +510 -0
  17. package/dist/src/compliance.d.ts +23 -0
  18. package/dist/src/compliance.js +26 -0
  19. package/dist/src/crypto.d.ts +12 -0
  20. package/dist/src/crypto.js +50 -0
  21. package/dist/src/fake.d.ts +29 -0
  22. package/dist/src/fake.js +79 -0
  23. package/dist/src/index.d.ts +16 -0
  24. package/dist/src/index.js +16 -0
  25. package/dist/src/ledger.d.ts +47 -0
  26. package/dist/src/ledger.js +84 -0
  27. package/dist/src/leg.d.ts +34 -0
  28. package/dist/src/leg.js +4 -0
  29. package/dist/src/message.d.ts +55 -0
  30. package/dist/src/message.js +88 -0
  31. package/dist/src/money.d.ts +16 -0
  32. package/dist/src/money.js +81 -0
  33. package/dist/src/payload.d.ts +11 -0
  34. package/dist/src/payload.js +52 -0
  35. package/dist/src/policy.d.ts +26 -0
  36. package/dist/src/policy.js +83 -0
  37. package/dist/src/protocol.d.ts +4 -0
  38. package/dist/src/protocol.js +15 -0
  39. package/dist/src/router.d.ts +16 -0
  40. package/dist/src/router.js +87 -0
  41. package/dist/src/signing.d.ts +27 -0
  42. package/dist/src/signing.js +72 -0
  43. package/dist/src/state.d.ts +23 -0
  44. package/dist/src/state.js +76 -0
  45. package/dist/src/wire.d.ts +30 -0
  46. package/dist/src/wire.js +221 -0
  47. package/dist/test/erc20.test.d.ts +1 -0
  48. package/dist/test/erc20.test.js +131 -0
  49. package/dist/test/lifecycle.test.d.ts +1 -0
  50. package/dist/test/lifecycle.test.js +212 -0
  51. package/dist/test/mpesa.test.d.ts +1 -0
  52. package/dist/test/mpesa.test.js +180 -0
  53. package/dist/test/payload.test.d.ts +1 -0
  54. package/dist/test/payload.test.js +32 -0
  55. package/dist/test/paypal.test.d.ts +1 -0
  56. package/dist/test/paypal.test.js +140 -0
  57. package/dist/test/policy.test.d.ts +1 -0
  58. package/dist/test/policy.test.js +131 -0
  59. package/dist/test/stripe.test.d.ts +1 -0
  60. package/dist/test/stripe.test.js +176 -0
  61. package/dist/test/vectors.test.d.ts +1 -0
  62. package/dist/test/vectors.test.js +91 -0
  63. package/dist/test/wire.test.d.ts +1 -0
  64. package/dist/test/wire.test.js +104 -0
  65. package/package.json +50 -0
  66. package/src/adapter.ts +32 -0
  67. package/src/adapters/erc20.ts +181 -0
  68. package/src/adapters/mpesa.ts +408 -0
  69. package/src/adapters/paypal.ts +409 -0
  70. package/src/adapters/stripe.ts +478 -0
  71. package/src/bridge.ts +148 -0
  72. package/src/canonical.ts +94 -0
  73. package/src/client.ts +605 -0
  74. package/src/compliance.ts +65 -0
  75. package/src/crypto.ts +68 -0
  76. package/src/fake.ts +96 -0
  77. package/src/index.ts +106 -0
  78. package/src/ledger.ts +145 -0
  79. package/src/leg.ts +65 -0
  80. package/src/message.ts +178 -0
  81. package/src/money.ts +87 -0
  82. package/src/payload.ts +58 -0
  83. package/src/policy.ts +110 -0
  84. package/src/protocol.ts +19 -0
  85. package/src/router.ts +97 -0
  86. package/src/signing.ts +92 -0
  87. package/src/state.ts +76 -0
  88. package/src/wire.ts +248 -0
@@ -0,0 +1,140 @@
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 { isDirect } from "../src/message.js";
6
+ import { RefundKind } from "../src/adapter.js";
7
+ import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
8
+ import { fromHex } from "../src/crypto.js";
9
+ import { PaypalLeg, ErrSignatureMismatch, majorAmount, } from "../src/adapters/paypal.js";
10
+ function counter(prefix) {
11
+ let n = 0;
12
+ return () => `${prefix}${String(++n).padStart(3, "0")}`;
13
+ }
14
+ function signerVerifier(identity) {
15
+ const seed = fromHex("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
16
+ const signer = Ed25519Signer.fromSeed(identity, seed);
17
+ return { signer, verifier: new Ed25519Verifier(new Map([[identity, signer.publicKey]])) };
18
+ }
19
+ // FakePaypal is an in-memory stand-in for PayPal so the leg runs end to end with
20
+ // no network and no credentials. verify controls whether an inbound webhook is
21
+ // treated as authentic.
22
+ class FakePaypal {
23
+ verify = true;
24
+ lastOrder;
25
+ lastPayout;
26
+ lastRefund;
27
+ ids;
28
+ constructor(ids) {
29
+ this.ids = ids;
30
+ }
31
+ async createAndCaptureOrder(params) {
32
+ this.lastOrder = params;
33
+ const id = this.ids();
34
+ return { orderId: `order_${id}`, captureId: `capture_${id}`, status: "COMPLETED" };
35
+ }
36
+ async sendPayout(params) {
37
+ this.lastPayout = params;
38
+ return { batchId: `batch_${this.ids()}`, status: "PENDING" };
39
+ }
40
+ async refundCapture(params) {
41
+ this.lastRefund = params;
42
+ return { id: `refund_${this.ids()}`, status: "COMPLETED" };
43
+ }
44
+ async verifyWebhook() {
45
+ return this.verify;
46
+ }
47
+ }
48
+ function buildLeg(api) {
49
+ return new PaypalLeg({ currencies: ["USD"], api, ids: counter("pp") });
50
+ }
51
+ function quote(fields) {
52
+ return {
53
+ id: "q",
54
+ intentId: "intent-1",
55
+ payInAdapterId: "paypal",
56
+ payInRail: "paypal",
57
+ payOutAdapterId: "paypal",
58
+ payOutRail: "paypal",
59
+ bridgeId: "passthrough",
60
+ srcAmount: Money.parse("USD", 2, "0"),
61
+ dstAmount: Money.parse("USD", 2, "0"),
62
+ fees: Money.parse("USD", 2, "0"),
63
+ fxRate: "1",
64
+ expiresAt: 0,
65
+ providerQuoteRef: "",
66
+ latencyEstimateMs: 0,
67
+ ...fields,
68
+ };
69
+ }
70
+ describe("paypal major amount", () => {
71
+ it("renders minor units as the decimal-major string PayPal expects", () => {
72
+ expect(majorAmount("1500", 2)).toBe("15.00");
73
+ expect(majorAmount("5", 2)).toBe("0.05");
74
+ expect(majorAmount("1290", 0)).toBe("1290");
75
+ expect(majorAmount("100", 2)).toBe("1.00");
76
+ });
77
+ });
78
+ describe("paypal webhook verification", () => {
79
+ it("maps an authentic capture to a settled event and rejects an unverifiable one", async () => {
80
+ const api = new FakePaypal(counter("tx"));
81
+ const leg = buildLeg(api);
82
+ const payload = new TextEncoder().encode(`{"event_type":"PAYMENT.CAPTURE.COMPLETED","resource":{"id":"capture_1","custom_id":"pact:intent-1"}}`);
83
+ const events = await leg.parseWebhook(payload, { "PayPal-Transmission-Id": ["t1"] });
84
+ expect(events).toHaveLength(1);
85
+ expect(events[0].state).toBe(State.Settled);
86
+ expect(events[0].intentId).toBe("intent-1");
87
+ expect(events[0].providerTxRef).toBe("capture_1");
88
+ // PayPal reports the signature does not verify, so the payload is rejected.
89
+ api.verify = false;
90
+ await expect(leg.parseWebhook(payload, {})).rejects.toThrow(ErrSignatureMismatch);
91
+ });
92
+ });
93
+ describe("paypal direct corridor", () => {
94
+ const now = 1_700_000_000_000;
95
+ it("charges the recipient payee in one phase and refunds fully", async () => {
96
+ const { signer, verifier } = signerVerifier("alice");
97
+ const api = new FakePaypal(counter("tx"));
98
+ const leg = buildLeg(api);
99
+ const client = new Client({
100
+ payIn: [leg],
101
+ payOut: [leg],
102
+ verifier,
103
+ clock: () => now,
104
+ idGen: counter("intent"),
105
+ });
106
+ const intent = client.createIntent({
107
+ senderRef: "alice",
108
+ recipientRef: "merchant@example.com",
109
+ amount: Money.parse("USD", 2, "1500"),
110
+ expiresAt: now + 600_000,
111
+ allowedRails: ["paypal"],
112
+ });
113
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: leg.id, currency: "USD", exponent: 2 }]);
114
+ const chosen = await client.select(quotes, signer.identity());
115
+ expect(isDirect(chosen)).toBe(true);
116
+ const auth = await client.authorize(intent, chosen, signer);
117
+ await client.initiate(intent, chosen, auth);
118
+ const { settlement } = await client.advance(intent.id, leg.id, { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
119
+ expect(settlement.state).toBe(State.Settled);
120
+ // The order was created to the recipient payee for the gross amount.
121
+ expect(api.lastOrder.payee).toBe("merchant@example.com");
122
+ expect(api.lastOrder.value).toBe("15.00");
123
+ // A full refund is accepted and binds to the capture id; a partial refund is a
124
+ // shape PayPal does not express here.
125
+ await leg.refundIn(intent.id, RefundKind.Full, "changed mind");
126
+ expect(api.lastRefund.captureId).toBe(settlement.providerTxRef);
127
+ await expect(leg.refundIn(intent.id, RefundKind.Partial, "half")).rejects.toThrow();
128
+ });
129
+ });
130
+ describe("paypal pay-out", () => {
131
+ it("sends the recipient's amount and refuses a reversal", async () => {
132
+ const api = new FakePaypal(counter("tx"));
133
+ const leg = buildLeg(api);
134
+ const result = await leg.disburse("intent-1", quote({ dstAmount: Money.parse("USD", 2, "1290") }), "receiver@example.com");
135
+ expect(result.providerRef).not.toBe("");
136
+ expect(api.lastPayout.receiver).toBe("receiver@example.com");
137
+ expect(api.lastPayout.value).toBe("12.90");
138
+ await expect(leg.reverseOut("intent-1", "oops")).rejects.toThrow();
139
+ });
140
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { Client } from "../src/client.js";
3
+ import { Money } from "../src/money.js";
4
+ import { FakeLeg } from "../src/fake.js";
5
+ import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
6
+ import { fromHex } from "../src/crypto.js";
7
+ import { Erc20Leg } from "../src/adapters/erc20.js";
8
+ import { TieredKyc, WindowRisk } from "../src/policy.js";
9
+ function counter() {
10
+ let n = 0;
11
+ return () => `id-${String(++n).padStart(3, "0")}`;
12
+ }
13
+ function signerVerifier(identity) {
14
+ const seed = fromHex("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
15
+ const signer = Ed25519Signer.fromSeed(identity, seed);
16
+ return { signer, verifier: new Ed25519Verifier(new Map([[identity, signer.publicKey]])) };
17
+ }
18
+ function tiers() {
19
+ const basic = {
20
+ name: "basic",
21
+ jurisdiction: "US",
22
+ perPayment: Money.parse("USD", 2, "5000"),
23
+ windowSpend: Money.parse("USD", 2, "8000"),
24
+ allowedRails: ["card"],
25
+ };
26
+ const unverified = { name: "unverified", jurisdiction: "", allowedRails: ["card"] };
27
+ return new TieredKyc([basic, unverified], { alice: "basic" }, unverified);
28
+ }
29
+ class OkChain {
30
+ async send(_call) {
31
+ return "0xtx";
32
+ }
33
+ async receipt() {
34
+ return { status: "success", blockTimestampMs: 0 };
35
+ }
36
+ }
37
+ const now = 1_700_000_000_000;
38
+ const cardFunding = [{ payInAdapterId: "card", currency: "USD", exponent: 2 }];
39
+ describe("tiered kyc and window risk policy", () => {
40
+ it("filters a quote over the tier's per-payment cap through the router", async () => {
41
+ const { signer, verifier } = signerVerifier("alice");
42
+ const card = new FakeLeg("card", "card", "USD", counter());
43
+ const client = new Client({
44
+ payIn: [card],
45
+ payOut: [card],
46
+ verifier,
47
+ kyc: tiers(),
48
+ clock: () => now,
49
+ idGen: counter(),
50
+ });
51
+ const over = client.createIntent({
52
+ senderRef: "alice",
53
+ recipientRef: "bob",
54
+ amount: Money.parse("USD", 2, "6000"),
55
+ expiresAt: now + 60_000,
56
+ allowedRails: ["card"],
57
+ });
58
+ await expect(client.select(await client.quoteOptions(over, cardFunding), signer.identity())).rejects.toThrow();
59
+ const within = client.createIntent({
60
+ senderRef: "alice",
61
+ recipientRef: "bob",
62
+ amount: Money.parse("USD", 2, "4000"),
63
+ expiresAt: now + 60_000,
64
+ allowedRails: ["card"],
65
+ });
66
+ await expect(client.select(await client.quoteOptions(within, cardFunding), signer.identity())).resolves.toBeDefined();
67
+ });
68
+ it("vetoes a rail the tier does not permit", async () => {
69
+ const { signer, verifier } = signerVerifier("alice");
70
+ const kyc = tiers();
71
+ const risk = new WindowRisk(kyc, 3_600_000, () => now);
72
+ const leg = new Erc20Leg({
73
+ token: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
74
+ currency: "USD",
75
+ chain: new OkChain(),
76
+ ids: counter(),
77
+ });
78
+ const client = new Client({
79
+ payIn: [leg],
80
+ payOut: [leg],
81
+ verifier,
82
+ kyc,
83
+ risk,
84
+ clock: () => now,
85
+ idGen: counter(),
86
+ });
87
+ const intent = client.createIntent({
88
+ senderRef: "alice",
89
+ recipientRef: "bob",
90
+ amount: Money.parse("USD", 2, "1000"),
91
+ expiresAt: now + 60_000,
92
+ allowedRails: ["erc20"],
93
+ metadata: { recipient_destination: "0x00000000000000000000000000000000000000aa" },
94
+ });
95
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: "erc20", currency: "USD", exponent: 2 }]);
96
+ const quote = await client.select(quotes, signer.identity());
97
+ await expect(client.authorize(intent, quote, signer)).rejects.toThrow();
98
+ });
99
+ it("blocks overspend across the rolling window and releases it after the window elapses", async () => {
100
+ const { signer, verifier } = signerVerifier("alice");
101
+ const kyc = tiers();
102
+ let clockMs = now;
103
+ const risk = new WindowRisk(kyc, 3_600_000, () => clockMs);
104
+ const card = new FakeLeg("card", "card", "USD", counter());
105
+ const client = new Client({
106
+ payIn: [card],
107
+ payOut: [card],
108
+ verifier,
109
+ kyc,
110
+ risk,
111
+ clock: () => clockMs,
112
+ idGen: counter(),
113
+ });
114
+ const pay = async (minor) => {
115
+ const intent = client.createIntent({
116
+ senderRef: "alice",
117
+ recipientRef: "bob",
118
+ amount: Money.parse("USD", 2, minor),
119
+ expiresAt: clockMs + 60_000,
120
+ allowedRails: ["card"],
121
+ });
122
+ const quote = await client.select(await client.quoteOptions(intent, cardFunding), signer.identity());
123
+ await client.authorize(intent, quote, signer);
124
+ };
125
+ await pay("4000");
126
+ await pay("4000");
127
+ await expect(pay("4000")).rejects.toThrow();
128
+ clockMs = now + 3_600_000 + 1;
129
+ await expect(pay("4000")).resolves.toBeUndefined();
130
+ });
131
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,176 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { createHmac } from "node:crypto";
3
+ import { Client } from "../src/client.js";
4
+ import { Money } from "../src/money.js";
5
+ import { State } from "../src/state.js";
6
+ import { isDirect } from "../src/message.js";
7
+ import { RefundKind } from "../src/adapter.js";
8
+ import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
9
+ import { fromHex } from "../src/crypto.js";
10
+ import { StripeLeg, ErrNoSignature, ErrSignatureMismatch, ErrTimestampOutOfTolerance, } from "../src/adapters/stripe.js";
11
+ function counter() {
12
+ let n = 0;
13
+ return () => `pi_test_${String(++n).padStart(3, "0")}`;
14
+ }
15
+ function signerVerifier(identity) {
16
+ const seed = fromHex("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff");
17
+ const signer = Ed25519Signer.fromSeed(identity, seed);
18
+ return { signer, verifier: new Ed25519Verifier(new Map([[identity, signer.publicKey]])) };
19
+ }
20
+ // FakeStripe is an in-memory stand-in for Stripe so the leg can be exercised end
21
+ // to end without a network or credentials.
22
+ class FakeStripe {
23
+ intents = new Map();
24
+ ids;
25
+ constructor(ids) {
26
+ this.ids = ids;
27
+ }
28
+ async createPaymentIntent(params, _idempotencyKey) {
29
+ const pi = {
30
+ id: this.ids(),
31
+ status: "requires_capture",
32
+ amount: params.amount,
33
+ currency: params.currency,
34
+ latestCharge: "",
35
+ metadata: params.metadata,
36
+ created: 1_700_000_000,
37
+ };
38
+ this.intents.set(pi.id, pi);
39
+ return pi;
40
+ }
41
+ async capturePaymentIntent(id, _idempotencyKey) {
42
+ const pi = this.intents.get(id);
43
+ pi.status = "succeeded";
44
+ this.intents.set(id, pi);
45
+ return pi;
46
+ }
47
+ async findPaymentIntent(pactIntentId) {
48
+ for (const pi of this.intents.values()) {
49
+ if (pi.metadata["pact_intent_id"] === pactIntentId) {
50
+ return pi;
51
+ }
52
+ }
53
+ throw new Error(`stripe: no payment intent for ${pactIntentId}`);
54
+ }
55
+ async createRefund(params, _idempotencyKey) {
56
+ return { id: this.ids(), status: "succeeded", amount: params.amount };
57
+ }
58
+ }
59
+ // cardPayout is a minimal pay-out leg for the direct corridor. Stripe collects to
60
+ // the recipient's account inside collect, so the pay-out side is a no-op that only
61
+ // lets the client compose a same-provider direct corridor.
62
+ class CardPayout {
63
+ id;
64
+ constructor(id) {
65
+ this.id = id;
66
+ }
67
+ payOutCapabilities() {
68
+ return { rails: ["card"], currencies: ["USD"], reversible: false };
69
+ }
70
+ async disburse() {
71
+ return { providerRef: "" };
72
+ }
73
+ async reverseOut(intentId, reason) {
74
+ return {
75
+ intentId,
76
+ state: State.Refunded,
77
+ adapterId: this.id,
78
+ providerTxRef: "",
79
+ onchainTxHash: "",
80
+ receiptHash: new Uint8Array(0),
81
+ reason,
82
+ settledAt: 0,
83
+ };
84
+ }
85
+ }
86
+ const webhookSecret = "whsec_test_secret";
87
+ const clockSeconds = 1_700_000_000;
88
+ function buildLeg(api) {
89
+ return new StripeLeg({
90
+ currencies: ["USD"],
91
+ api,
92
+ webhookKey: webhookSecret,
93
+ clock: () => clockSeconds,
94
+ ids: counter(),
95
+ });
96
+ }
97
+ function signHeader(payload, secret, timestamp) {
98
+ const mac = createHmac("sha256", secret);
99
+ mac.update(String(timestamp));
100
+ mac.update(".");
101
+ mac.update(payload);
102
+ return `t=${timestamp},v1=${mac.digest("hex")}`;
103
+ }
104
+ function succeededPayload(piId, intentId) {
105
+ const json = `{"type":"payment_intent.succeeded","data":{"object":{"id":${JSON.stringify(piId)},"status":"succeeded","metadata":{"pact_intent_id":${JSON.stringify(intentId)}},"created":${clockSeconds}}}}`;
106
+ return new TextEncoder().encode(json);
107
+ }
108
+ describe("stripe webhook verification", () => {
109
+ it("verifies a valid signature and rejects a tampered, stale, or missing one", () => {
110
+ const leg = buildLeg(new FakeStripe(counter()));
111
+ const payload = succeededPayload("pi_1", "intent-1");
112
+ const header = signHeader(payload, webhookSecret, clockSeconds);
113
+ const events = leg.parseWebhook(payload, { "Stripe-Signature": [header] });
114
+ expect(events).toHaveLength(1);
115
+ expect(events[0].state).toBe(State.Settled);
116
+ expect(events[0].intentId).toBe("intent-1");
117
+ expect(events[0].providerTxRef).toBe("pi_1");
118
+ // A tampered body no longer matches the signature.
119
+ const tampered = Uint8Array.from(payload);
120
+ const last = tampered.length - 3;
121
+ tampered[last] = tampered[last] ^ 0xff;
122
+ expect(() => leg.parseWebhook(tampered, { "Stripe-Signature": [header] })).toThrow(ErrSignatureMismatch);
123
+ // A stale timestamp is rejected as a possible replay.
124
+ const old = signHeader(payload, webhookSecret, clockSeconds - 4000);
125
+ expect(() => leg.parseWebhook(payload, { "Stripe-Signature": [old] })).toThrow(ErrTimestampOutOfTolerance);
126
+ // A missing signature header is rejected outright.
127
+ expect(() => leg.parseWebhook(payload, {})).toThrow(ErrNoSignature);
128
+ });
129
+ });
130
+ describe("stripe webhook failure mapping", () => {
131
+ it("maps payment_intent.payment_failed to a failed event", () => {
132
+ const leg = buildLeg(new FakeStripe(counter()));
133
+ const json = `{"type":"payment_intent.payment_failed","data":{"object":{"id":"pi_x","metadata":{"pact_intent_id":"intent-9"},"created":${clockSeconds}}}}`;
134
+ const payload = new TextEncoder().encode(json);
135
+ const header = signHeader(payload, webhookSecret, clockSeconds);
136
+ const events = leg.parseWebhook(payload, { "Stripe-Signature": [header] });
137
+ expect(events).toHaveLength(1);
138
+ expect(events[0].state).toBe(State.Failed);
139
+ });
140
+ });
141
+ describe("stripe direct card corridor", () => {
142
+ const now = 1_700_000_000_000;
143
+ it("collects to the recipient in one phase and refunds fully", async () => {
144
+ const { signer, verifier } = signerVerifier("alice");
145
+ const api = new FakeStripe(counter());
146
+ const leg = buildLeg(api);
147
+ const client = new Client({
148
+ payIn: [leg],
149
+ payOut: [new CardPayout(leg.id)],
150
+ verifier,
151
+ clock: () => now,
152
+ idGen: counter(),
153
+ });
154
+ const intent = client.createIntent({
155
+ senderRef: "alice",
156
+ recipientRef: "acct_merchant",
157
+ amount: Money.parse("USD", 2, "1500"),
158
+ expiresAt: now + 600_000,
159
+ allowedRails: ["card"],
160
+ });
161
+ const quotes = await client.quoteOptions(intent, [{ payInAdapterId: leg.id, currency: "USD", exponent: 2 }]);
162
+ const quote = await client.select(quotes, signer.identity());
163
+ expect(isDirect(quote)).toBe(true);
164
+ const auth = await client.authorize(intent, quote, signer);
165
+ await client.initiate(intent, quote, auth);
166
+ await client.advance(intent.id, leg.id, { intentId: intent.id, state: State.Settled, providerTxRef: "", onchainTxHash: "", reason: "", settledAt: 0 });
167
+ expect(client.state(intent.id)).toBe(State.Settled);
168
+ // The captured intent carries the pact intent id and reached a captured status.
169
+ const pi = await api.findPaymentIntent(intent.id);
170
+ expect(pi.status).toBe("succeeded");
171
+ // Stripe supports partial reversal, so a full refund is accepted and a
172
+ // counter-transfer is a shape the card rail does not express.
173
+ await leg.refundIn(intent.id, RefundKind.Full, "changed mind");
174
+ await expect(leg.refundIn(intent.id, RefundKind.CounterTransfer, "wrong kind")).rejects.toThrow();
175
+ });
176
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { Money } from "../src/money.js";
6
+ import { stateName } from "../src/state.js";
7
+ import { intentPreimage, intentHash, quotePreimage, quoteHash, signingPreimage, authorizationHash, receiptHash, } from "../src/message.js";
8
+ import { eventReceipt, chainLeaf } from "../src/ledger.js";
9
+ import { Ed25519Signer, Ed25519Verifier } from "../src/signing.js";
10
+ import { toHex, fromHex } from "../src/crypto.js";
11
+ const here = dirname(fileURLToPath(import.meta.url));
12
+ const vectors = JSON.parse(readFileSync(join(here, "..", "..", "..", "spec", "test-vectors.json"), "utf8"));
13
+ function money(m) {
14
+ return Money.parse(m.currency, m.exponent, m.amount);
15
+ }
16
+ function intentFrom(j) {
17
+ return {
18
+ id: j.id,
19
+ senderRef: j.sender_ref,
20
+ recipientRef: j.recipient_ref,
21
+ amount: money(j.amount),
22
+ memo: j.memo,
23
+ expiresAt: j.expires_at,
24
+ allowedRails: j.allowed_rails,
25
+ metadata: j.metadata,
26
+ };
27
+ }
28
+ function quoteFrom(j) {
29
+ return {
30
+ id: j.id,
31
+ intentId: j.intent_id,
32
+ payInAdapterId: j.pay_in_adapter_id,
33
+ payInRail: j.pay_in_rail,
34
+ payOutAdapterId: j.pay_out_adapter_id,
35
+ payOutRail: j.pay_out_rail,
36
+ bridgeId: j.bridge_id,
37
+ srcAmount: money(j.src_amount),
38
+ dstAmount: money(j.dst_amount),
39
+ fees: money(j.fees),
40
+ fxRate: j.fx_rate,
41
+ expiresAt: j.expires_at,
42
+ providerQuoteRef: j.provider_quote_ref,
43
+ latencyEstimateMs: j.latency_estimate_ms,
44
+ };
45
+ }
46
+ const nameToState = Object.fromEntries(Object.entries(stateName).map(([s, name]) => [name, Number(s)]));
47
+ describe("shared conformance vectors", () => {
48
+ it("reproduces every intent preimage and hash", () => {
49
+ for (const entry of vectors.intents) {
50
+ const intent = intentFrom(entry.intent);
51
+ expect(toHex(intentPreimage(intent))).toBe(entry.preimage_hex);
52
+ expect(toHex(intentHash(intent))).toBe(entry.hash_hex);
53
+ }
54
+ });
55
+ it("reproduces the quote preimage and hash", () => {
56
+ const quote = quoteFrom(vectors.quote.quote);
57
+ expect(toHex(quotePreimage(quote))).toBe(vectors.quote.preimage_hex);
58
+ expect(toHex(quoteHash(quote))).toBe(vectors.quote.hash_hex);
59
+ });
60
+ it("reproduces the authorization preimage, hash, and Ed25519 signature", () => {
61
+ const a = vectors.authorization;
62
+ const intentHashBytes = fromHex(a.intent_hash_hex);
63
+ const quoteHashBytes = fromHex(a.quote_hash_hex);
64
+ const preimage = signingPreimage(intentHashBytes, quoteHashBytes, a.signer_identity, a.signed_at);
65
+ expect(toHex(preimage)).toBe(a.signing_preimage_hex);
66
+ expect(toHex(authorizationHash(intentHashBytes, quoteHashBytes, a.signer_identity, a.signed_at))).toBe(a.auth_hash_hex);
67
+ const seed = fromHex(vectors.ed25519.seed_hex);
68
+ const signer = Ed25519Signer.fromSeed(a.signer_identity, seed);
69
+ expect(toHex(signer.publicKey)).toBe(vectors.ed25519.public_key_hex);
70
+ const signature = signer.sign(preimage);
71
+ expect(toHex(signature)).toBe(a.signature_hex);
72
+ const verifier = new Ed25519Verifier(new Map([[a.signer_identity, signer.publicKey]]));
73
+ expect(verifier.verify(a.signer_identity, preimage, signature)).toBe(true);
74
+ });
75
+ it("reproduces the settlement receipt hash", () => {
76
+ const r = vectors.receipt;
77
+ const hash = receiptHash(fromHex(r.auth_hash_hex), r.provider_tx_ref, nameToState[r.state]);
78
+ expect(toHex(hash)).toBe(r.receipt_hash_hex);
79
+ });
80
+ it("reproduces every ledger leaf and the Merkle head", () => {
81
+ const l = vectors.ledger;
82
+ let prevLeaf = new Uint8Array(32);
83
+ l.steps.forEach((step, i) => {
84
+ const receipt = eventReceipt(i + 1, l.intent_id, nameToState[step.state], fromHex(step.payload_hash_hex));
85
+ const leaf = chainLeaf(prevLeaf, receipt);
86
+ expect(toHex(leaf)).toBe(step.leaf_hex);
87
+ prevLeaf = leaf;
88
+ });
89
+ expect(toHex(prevLeaf)).toBe(l.head_hex);
90
+ });
91
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join } from "node:path";
5
+ import { Money } from "../src/money.js";
6
+ import { State, stateName } from "../src/state.js";
7
+ import { WireKind, encodeIntent, encodeQuote, encodeAuthorization, encodeSettlement, encodeMessage, decodeMessage, decodeIntent, decodeQuote, decodeAuthorization, decodeSettlement, } from "../src/wire.js";
8
+ import { toHex, fromHex } from "../src/crypto.js";
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ const vectors = JSON.parse(readFileSync(join(here, "..", "..", "..", "spec", "test-vectors.json"), "utf8"));
11
+ const nameToState = Object.fromEntries(Object.entries(stateName).map(([s, name]) => [name, Number(s)]));
12
+ function money(m) {
13
+ return Money.parse(m.currency, m.exponent, m.amount);
14
+ }
15
+ function intentFrom(j) {
16
+ return {
17
+ id: j.id,
18
+ senderRef: j.sender_ref,
19
+ recipientRef: j.recipient_ref,
20
+ amount: money(j.amount),
21
+ memo: j.memo,
22
+ expiresAt: j.expires_at,
23
+ allowedRails: j.allowed_rails,
24
+ metadata: j.metadata,
25
+ };
26
+ }
27
+ function quoteFrom(j) {
28
+ return {
29
+ id: j.id,
30
+ intentId: j.intent_id,
31
+ payInAdapterId: j.pay_in_adapter_id,
32
+ payInRail: j.pay_in_rail,
33
+ payOutAdapterId: j.pay_out_adapter_id,
34
+ payOutRail: j.pay_out_rail,
35
+ bridgeId: j.bridge_id,
36
+ srcAmount: money(j.src_amount),
37
+ dstAmount: money(j.dst_amount),
38
+ fees: money(j.fees),
39
+ fxRate: j.fx_rate,
40
+ expiresAt: j.expires_at,
41
+ providerQuoteRef: j.provider_quote_ref,
42
+ latencyEstimateMs: j.latency_estimate_ms,
43
+ };
44
+ }
45
+ describe("wire codec conformance and round-trips", () => {
46
+ it("encodes and decodes an intent to the vector bytes", () => {
47
+ const intent = intentFrom(vectors.intents[0].intent);
48
+ expect(toHex(encodeIntent(intent))).toBe(vectors.wire.intent_hex);
49
+ const decoded = decodeIntent(fromHex(vectors.wire.intent_hex));
50
+ expect(decoded.id).toBe(intent.id);
51
+ expect(decoded.amount.minor()).toBe(intent.amount.minor());
52
+ expect(decoded.allowedRails).toEqual(intent.allowedRails);
53
+ expect(decoded.metadata).toEqual(intent.metadata);
54
+ });
55
+ it("encodes and decodes a quote to the vector bytes", () => {
56
+ const quote = quoteFrom(vectors.quote.quote);
57
+ expect(toHex(encodeQuote(quote))).toBe(vectors.wire.quote_hex);
58
+ const decoded = decodeQuote(fromHex(vectors.wire.quote_hex));
59
+ expect(decoded.srcAmount.minor()).toBe(quote.srcAmount.minor());
60
+ expect(decoded.latencyEstimateMs).toBe(quote.latencyEstimateMs);
61
+ });
62
+ it("encodes and decodes an authorization to the vector bytes", () => {
63
+ const w = vectors.wire.authorization;
64
+ const auth = {
65
+ intentId: w.intent_id,
66
+ quoteId: w.quote_id,
67
+ signerIdentity: w.signer_identity,
68
+ signedAt: w.signed_at,
69
+ signature: fromHex(w.signature_hex),
70
+ };
71
+ expect(toHex(encodeAuthorization(auth))).toBe(w.hex);
72
+ const decoded = decodeAuthorization(fromHex(w.hex));
73
+ expect(decoded.signerIdentity).toBe(auth.signerIdentity);
74
+ expect(toHex(decoded.signature)).toBe(w.signature_hex);
75
+ });
76
+ it("encodes and decodes a settlement to the vector bytes", () => {
77
+ const w = vectors.wire.settlement;
78
+ const settlement = {
79
+ intentId: w.intent_id,
80
+ state: nameToState[w.state],
81
+ adapterId: w.adapter_id,
82
+ providerTxRef: w.provider_tx_ref,
83
+ onchainTxHash: w.onchain_tx_hash,
84
+ receiptHash: fromHex(w.receipt_hash_hex),
85
+ reason: w.reason,
86
+ settledAt: w.settled_at,
87
+ };
88
+ expect(toHex(encodeSettlement(settlement))).toBe(w.hex);
89
+ const decoded = decodeSettlement(fromHex(w.hex));
90
+ expect(decoded.state).toBe(State.Settled);
91
+ expect(toHex(decoded.receiptHash)).toBe(w.receipt_hash_hex);
92
+ });
93
+ it("dispatches a tagged frame back to its type", () => {
94
+ const intent = intentFrom(vectors.intents[0].intent);
95
+ const frame = encodeMessage({ kind: WireKind.Intent, message: intent });
96
+ const decoded = decodeMessage(frame);
97
+ expect(decoded.kind).toBe(WireKind.Intent);
98
+ expect(decoded.message.id).toBe(intent.id);
99
+ });
100
+ it("rejects a truncated frame", () => {
101
+ const full = encodeIntent(intentFrom(vectors.intents[0].intent));
102
+ expect(() => decodeIntent(full.subarray(0, full.length - 3))).toThrow();
103
+ });
104
+ });