@myzonerocks/pact 0.1.4 → 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.
- package/README.md +3 -3
- package/dist/src/adapters/erc20.d.ts +17 -1
- package/dist/src/adapters/erc20.js +19 -8
- package/dist/src/adapters/mpesa.d.ts +21 -2
- package/dist/src/adapters/mpesa.js +24 -10
- package/dist/src/adapters/paypal.d.ts +19 -2
- package/dist/src/adapters/paypal.js +26 -15
- package/dist/src/adapters/stripe.js +18 -5
- package/dist/src/canonical.js +2 -0
- package/dist/src/client.d.ts +2 -0
- package/dist/src/client.js +81 -20
- package/dist/src/message.d.ts +2 -0
- package/dist/src/message.js +10 -5
- package/dist/src/state.js +3 -1
- 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/package.json +1 -1
- package/src/adapters/erc20.ts +40 -8
- package/src/adapters/mpesa.ts +46 -15
- package/src/adapters/paypal.ts +47 -15
- package/src/adapters/stripe.ts +21 -6
- package/src/canonical.ts +2 -0
- package/src/client.ts +81 -21
- package/src/message.ts +11 -5
- package/src/state.ts +3 -1
- package/src/wire.ts +2 -16
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";
|
|
@@ -164,6 +164,9 @@ export class Client {
|
|
|
164
164
|
continue;
|
|
165
165
|
}
|
|
166
166
|
try {
|
|
167
|
+
// Corridors are priced in order so the router sees a stable ranking, matching
|
|
168
|
+
// the other SDKs; the loop is bounded by the configured funding options.
|
|
169
|
+
// eslint-disable-next-line no-await-in-loop
|
|
167
170
|
quotes.push(await this.composeQuote(intent, payInLeg, f));
|
|
168
171
|
} catch (err) {
|
|
169
172
|
lastErr = err;
|
|
@@ -289,11 +292,9 @@ export class Client {
|
|
|
289
292
|
// driven forward by advance as provider events arrive, so no promise or
|
|
290
293
|
// connection is held open per payment and durable state lives only in the
|
|
291
294
|
// ledger.
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
296
|
-
}
|
|
295
|
+
// resolvePayIn rejects an expired intent and returns the corridor's pay-in leg — the
|
|
296
|
+
// expiry-and-leg check the immediate and interactive starts share.
|
|
297
|
+
private resolvePayIn(intent: Intent, quote: Quote): PayInLeg {
|
|
297
298
|
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
298
299
|
throw new Error("pact: intent has expired");
|
|
299
300
|
}
|
|
@@ -301,13 +302,28 @@ export class Client {
|
|
|
301
302
|
if (!payInLeg) {
|
|
302
303
|
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
303
304
|
}
|
|
305
|
+
return payInLeg;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State> {
|
|
309
|
+
verifyAuthorization(auth, intent, quote, this.verifier);
|
|
310
|
+
if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
|
|
311
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
312
|
+
}
|
|
313
|
+
const payInLeg = this.resolvePayIn(intent, quote);
|
|
304
314
|
|
|
305
315
|
// A retry of an already-submitted corridor must not collect a second time. The
|
|
306
316
|
// pay-in leg carries its own provider idempotency for the narrow window where a
|
|
307
317
|
// collection completed but its record was lost.
|
|
308
|
-
const
|
|
309
|
-
if (
|
|
310
|
-
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`);
|
|
311
327
|
}
|
|
312
328
|
|
|
313
329
|
// A direct corridor collects straight to the recipient; a bridged one
|
|
@@ -338,16 +354,16 @@ export class Client {
|
|
|
338
354
|
if (!isDirect(quote)) {
|
|
339
355
|
throw new Error("pact: interactive pay-in is only available on a direct corridor");
|
|
340
356
|
}
|
|
341
|
-
|
|
342
|
-
throw new Error("pact: intent has expired");
|
|
343
|
-
}
|
|
344
|
-
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
345
|
-
if (!payInLeg) {
|
|
346
|
-
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
347
|
-
}
|
|
357
|
+
const payInLeg = this.resolvePayIn(intent, quote);
|
|
348
358
|
if (!isInteractivePayInLeg(payInLeg)) {
|
|
349
359
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
|
|
350
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
|
+
}
|
|
351
367
|
const preparation = await payInLeg.prepare(intent.id, quote, auth, recipientDestination(intent));
|
|
352
368
|
// Record the same shape a direct collection would, so the confirming webhook
|
|
353
369
|
// advances the corridor to settled through the unchanged pay-in path.
|
|
@@ -406,6 +422,31 @@ export class Client {
|
|
|
406
422
|
return this.settleBridged(intentId, quote, authHash, events);
|
|
407
423
|
}
|
|
408
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
|
+
|
|
409
450
|
// Any other event — a duplicate webhook, or one for a phase already past — is
|
|
410
451
|
// a no-op. This is what makes a re-delivered provider callback settle once.
|
|
411
452
|
return { settlement: lastSettlement(events), state: current };
|
|
@@ -521,6 +562,20 @@ export class Client {
|
|
|
521
562
|
return this.finishAdvance(intentId, State.Settled, settlement);
|
|
522
563
|
}
|
|
523
564
|
|
|
565
|
+
// claimRefund reserves the refunding step before any money moves. It returns the
|
|
566
|
+
// recorded outcome to echo when another refund already claimed the step, or null when
|
|
567
|
+
// this call won it and should proceed to move funds. Both refund paths go through it,
|
|
568
|
+
// so a refund is issued at most once however the calls race.
|
|
569
|
+
private claimRefund(intentId: string, reason: string): { settlement: Settlement; state: State } | null {
|
|
570
|
+
const { created } = this.ledger.apply({
|
|
571
|
+
intentId,
|
|
572
|
+
to: State.Refunding,
|
|
573
|
+
payloadHash: hashString(domain("refunding"), reason),
|
|
574
|
+
});
|
|
575
|
+
if (created) return null;
|
|
576
|
+
return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
|
|
577
|
+
}
|
|
578
|
+
|
|
524
579
|
// unwind refunds the payer from escrow when a bridged corridor cannot complete
|
|
525
580
|
// its pay-out, moving to refunding then refunded.
|
|
526
581
|
private async unwind(
|
|
@@ -529,11 +584,11 @@ export class Client {
|
|
|
529
584
|
authHash: Uint8Array,
|
|
530
585
|
reason: string,
|
|
531
586
|
): Promise<{ settlement: Settlement; state: State }> {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
587
|
+
// Claim the refund before moving money. A duplicate pay-out-failed webhook enters
|
|
588
|
+
// unwind twice; only the call that wins the refunding step may issue the counter-
|
|
589
|
+
// transfer, so the refund cannot fire a second time.
|
|
590
|
+
const echo = this.claimRefund(intentId, reason);
|
|
591
|
+
if (echo) return echo;
|
|
537
592
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
538
593
|
if (payInLeg) {
|
|
539
594
|
const kind = payInLeg.payInCapabilities().refunds;
|
|
@@ -601,6 +656,11 @@ export class Client {
|
|
|
601
656
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
|
|
602
657
|
}
|
|
603
658
|
const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
|
|
659
|
+
// Claim the refunding step before moving money. Two overlapping refund calls both
|
|
660
|
+
// pass the settled check above; only the one that wins this step issues the return,
|
|
661
|
+
// so the payer is never refunded twice.
|
|
662
|
+
const echo = this.claimRefund(intentId, reason);
|
|
663
|
+
if (echo) return echo;
|
|
604
664
|
await payInLeg.refundIn(intentId, kind, amount, reason);
|
|
605
665
|
const settlement: Settlement = {
|
|
606
666
|
intentId,
|
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/state.ts
CHANGED
|
@@ -52,7 +52,9 @@ const transitions: Record<State, ReadonlySet<State>> = {
|
|
|
52
52
|
[State.Held]: new Set([State.Disbursing, State.Refunding, State.Expired]),
|
|
53
53
|
[State.Disbursing]: new Set([State.Settled, State.Failed, State.Refunding]),
|
|
54
54
|
[State.Refunding]: new Set([State.Refunded, State.Failed]),
|
|
55
|
-
|
|
55
|
+
// A settled intent refunds through the same refunding step a bridged unwind uses,
|
|
56
|
+
// so the refund is claimed before any money moves and cannot fire twice.
|
|
57
|
+
[State.Settled]: new Set([State.Refunding, State.Refunded]),
|
|
56
58
|
[State.Failed]: new Set(),
|
|
57
59
|
[State.Expired]: new Set(),
|
|
58
60
|
[State.Refunded]: new Set(),
|
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 {
|