@myzonerocks/pact 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/adapter.d.ts +1 -1
- package/dist/src/adapters/erc20.d.ts +13 -3
- package/dist/src/adapters/erc20.js +93 -49
- package/dist/src/adapters/http.d.ts +2 -0
- package/dist/src/adapters/http.js +12 -0
- package/dist/src/adapters/mpesa.d.ts +9 -2
- package/dist/src/adapters/mpesa.js +82 -12
- package/dist/src/adapters/paypal.d.ts +15 -3
- package/dist/src/adapters/paypal.js +121 -21
- package/dist/src/adapters/stripe.d.ts +6 -2
- package/dist/src/adapters/stripe.js +25 -12
- package/dist/src/bridge.js +12 -5
- package/dist/src/canonical.js +5 -0
- package/dist/src/client.d.ts +8 -2
- package/dist/src/client.js +181 -20
- package/dist/src/compliance.d.ts +4 -0
- package/dist/src/compliance.js +10 -3
- package/dist/src/crypto.js +4 -1
- package/dist/src/index.d.ts +0 -1
- package/dist/src/index.js +0 -1
- package/dist/src/ledger.d.ts +6 -2
- package/dist/src/ledger.js +2 -2
- package/dist/src/leg.d.ts +1 -1
- package/dist/src/message.d.ts +1 -1
- package/dist/src/message.js +8 -5
- package/dist/src/money.d.ts +1 -0
- package/dist/src/money.js +18 -3
- package/dist/src/protocol.d.ts +1 -0
- package/dist/src/protocol.js +8 -0
- package/dist/src/router.d.ts +2 -0
- package/dist/src/router.js +45 -7
- package/dist/src/wire.js +19 -2
- package/dist/test/erc20.test.js +95 -31
- package/dist/test/fake.d.ts +29 -0
- package/dist/test/fake.js +79 -0
- package/dist/test/lifecycle.test.js +31 -3
- package/dist/test/money.test.d.ts +1 -0
- package/dist/test/money.test.js +27 -0
- package/dist/test/mpesa.test.js +41 -8
- package/dist/test/paypal.test.js +54 -8
- package/dist/test/policy.test.js +6 -2
- package/dist/test/router.test.d.ts +1 -0
- package/dist/test/router.test.js +52 -0
- package/dist/test/stripe.test.js +5 -4
- package/dist/test/vectors.test.js +48 -2
- package/dist/test/wire.test.js +15 -0
- package/package.json +1 -1
- package/src/adapter.ts +7 -2
- package/src/adapters/erc20.ts +118 -51
- package/src/adapters/http.ts +14 -0
- package/src/adapters/mpesa.ts +102 -13
- package/src/adapters/paypal.ts +168 -22
- package/src/adapters/stripe.ts +16 -13
- package/src/bridge.ts +12 -5
- package/src/canonical.ts +5 -0
- package/src/client.ts +194 -22
- package/src/compliance.ts +20 -3
- package/src/crypto.ts +4 -1
- package/src/index.ts +0 -1
- package/src/ledger.ts +12 -4
- package/src/leg.ts +4 -1
- package/src/message.ts +8 -5
- package/src/money.ts +19 -3
- package/src/protocol.ts +9 -0
- package/src/router.ts +44 -4
- package/src/wire.ts +20 -3
- package/src/fake.ts +0 -96
package/src/canonical.ts
CHANGED
|
@@ -26,6 +26,11 @@ export class CanonicalWriter {
|
|
|
26
26
|
return this;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// str writes a string as its raw UTF-8 bytes. No Unicode normalization is
|
|
30
|
+
// applied: the bytes are hashed as given, so two participants that compose the
|
|
31
|
+
// same text in different Unicode forms produce different hashes. Callers that
|
|
32
|
+
// need them to agree must normalize before building a message; string fields are
|
|
33
|
+
// otherwise treated as opaque bytes.
|
|
29
34
|
str(s: string): this {
|
|
30
35
|
return this.bytes(encoder.encode(s));
|
|
31
36
|
}
|
package/src/client.ts
CHANGED
|
@@ -18,9 +18,17 @@ import {
|
|
|
18
18
|
import { authorize as signAuthorization, verifyAuthorization, type Signer, type Verifier } from "./signing.js";
|
|
19
19
|
import { PassThroughBridge, type Bridge, type ConvertResult } from "./bridge.js";
|
|
20
20
|
import { railInList, isInteractivePayInLeg, type PayInLeg, type PayOutLeg, type CollectResult, type PayInPreparation } from "./leg.js";
|
|
21
|
-
import type
|
|
22
|
-
import { route, type Policy, PolicyKind, NoQuoteError, isExpired } from "./router.js";
|
|
23
|
-
import {
|
|
21
|
+
import { RefundKind, type AdapterEvent } from "./adapter.js";
|
|
22
|
+
import { route, type Policy, PolicyKind, NoQuoteError, isExpired, maxFundingOptions } from "./router.js";
|
|
23
|
+
import {
|
|
24
|
+
permissiveKyc,
|
|
25
|
+
allowRisk,
|
|
26
|
+
withinLimits,
|
|
27
|
+
strictSignerAuthorizer,
|
|
28
|
+
type KycProvider,
|
|
29
|
+
type RiskHook,
|
|
30
|
+
type SignerAuthorizer,
|
|
31
|
+
} from "./compliance.js";
|
|
24
32
|
import { MemoryLedger, eventReceipt, type Ledger, type LedgerEvent } from "./ledger.js";
|
|
25
33
|
|
|
26
34
|
// Clock returns the current time in Unix milliseconds; IdGen returns a fresh
|
|
@@ -50,6 +58,7 @@ export interface ClientConfig {
|
|
|
50
58
|
verifier: Verifier;
|
|
51
59
|
kyc?: KycProvider;
|
|
52
60
|
risk?: RiskHook;
|
|
61
|
+
signerAuth?: SignerAuthorizer;
|
|
53
62
|
clock?: Clock;
|
|
54
63
|
idGen?: IdGen;
|
|
55
64
|
skew?: number;
|
|
@@ -87,6 +96,7 @@ export class Client {
|
|
|
87
96
|
private readonly verifier: Verifier;
|
|
88
97
|
private readonly kyc: KycProvider;
|
|
89
98
|
private readonly risk: RiskHook;
|
|
99
|
+
private readonly signerAuth: SignerAuthorizer;
|
|
90
100
|
private readonly clock: Clock;
|
|
91
101
|
private readonly idGen: IdGen;
|
|
92
102
|
private readonly skew: number;
|
|
@@ -105,6 +115,7 @@ export class Client {
|
|
|
105
115
|
this.verifier = cfg.verifier;
|
|
106
116
|
this.kyc = cfg.kyc ?? permissiveKyc;
|
|
107
117
|
this.risk = cfg.risk ?? allowRisk;
|
|
118
|
+
this.signerAuth = cfg.signerAuth ?? strictSignerAuthorizer;
|
|
108
119
|
this.clock = cfg.clock ?? systemClock;
|
|
109
120
|
this.idGen = cfg.idGen ?? randomId;
|
|
110
121
|
this.skew = cfg.skew ?? DEFAULT_SKEW_MILLIS;
|
|
@@ -113,6 +124,11 @@ export class Client {
|
|
|
113
124
|
|
|
114
125
|
// createIntent mints an intent and records it as a draft.
|
|
115
126
|
createIntent(spec: IntentSpec): Intent {
|
|
127
|
+
// A zero deadline means "never expires", which would let an intent advance
|
|
128
|
+
// forever and bypass the liveness bound. Require a real deadline at creation.
|
|
129
|
+
if (!spec.expiresAt) {
|
|
130
|
+
throw new Error("pact: intent requires a non-zero expiry deadline");
|
|
131
|
+
}
|
|
116
132
|
const intent: Intent = {
|
|
117
133
|
id: this.idGen(),
|
|
118
134
|
senderRef: spec.senderRef,
|
|
@@ -132,6 +148,9 @@ export class Client {
|
|
|
132
148
|
// recipient's currency and a pay-out leg that can deliver it, prices the whole
|
|
133
149
|
// corridor, and returns the options for the payer to choose among.
|
|
134
150
|
async quoteOptions(intent: Intent, funding: Funding[]): Promise<Quote[]> {
|
|
151
|
+
if (funding.length > maxFundingOptions) {
|
|
152
|
+
throw new Error(`pact: ${funding.length} funding options exceed the maximum of ${maxFundingOptions}`);
|
|
153
|
+
}
|
|
135
154
|
const quotes: Quote[] = [];
|
|
136
155
|
let lastErr: unknown;
|
|
137
156
|
for (const f of funding) {
|
|
@@ -242,11 +261,17 @@ export class Client {
|
|
|
242
261
|
if (isExpired(quote.expiresAt, now, this.skew)) {
|
|
243
262
|
throw new Error("pact: quote has expired");
|
|
244
263
|
}
|
|
245
|
-
|
|
264
|
+
// The signer must be allowed to act for the sender, and compliance is measured
|
|
265
|
+
// against the sender — the party actually funding the payment — not whoever
|
|
266
|
+
// holds the signing key.
|
|
267
|
+
if (!this.signerAuth.authorized(signer.identity(), intent.senderRef)) {
|
|
268
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
269
|
+
}
|
|
270
|
+
const status = await this.kyc.status(intent.senderRef);
|
|
246
271
|
if (!withinLimits(status, quote)) {
|
|
247
272
|
throw new Error("pact: quote exceeds KYC limit");
|
|
248
273
|
}
|
|
249
|
-
const decision = this.risk.evaluate(
|
|
274
|
+
const decision = this.risk.evaluate(intent.senderRef, intent, quote);
|
|
250
275
|
if (!decision.allow) {
|
|
251
276
|
throw new Error(`pact: risk hook vetoed authorization: ${decision.reason}`);
|
|
252
277
|
}
|
|
@@ -266,11 +291,25 @@ export class Client {
|
|
|
266
291
|
// ledger.
|
|
267
292
|
async initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State> {
|
|
268
293
|
verifyAuthorization(auth, intent, quote, this.verifier);
|
|
294
|
+
if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
|
|
295
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
296
|
+
}
|
|
297
|
+
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
298
|
+
throw new Error("pact: intent has expired");
|
|
299
|
+
}
|
|
269
300
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
270
301
|
if (!payInLeg) {
|
|
271
302
|
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
272
303
|
}
|
|
273
304
|
|
|
305
|
+
// A retry of an already-submitted corridor must not collect a second time. The
|
|
306
|
+
// pay-in leg carries its own provider idempotency for the narrow window where a
|
|
307
|
+
// collection completed but its record was lost.
|
|
308
|
+
const submitted = this.ledger.state(intent.id);
|
|
309
|
+
if (submitted === State.Submitted || submitted === State.Collecting) {
|
|
310
|
+
return submitted;
|
|
311
|
+
}
|
|
312
|
+
|
|
274
313
|
// A direct corridor collects straight to the recipient; a bridged one
|
|
275
314
|
// collects into escrow first.
|
|
276
315
|
let deliverTo = recipientDestination(intent);
|
|
@@ -293,9 +332,15 @@ export class Client {
|
|
|
293
332
|
// server-side into escrow, and the leg must offer interactive collection.
|
|
294
333
|
async interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{ preparation: PayInPreparation; state: State }> {
|
|
295
334
|
verifyAuthorization(auth, intent, quote, this.verifier);
|
|
335
|
+
if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
|
|
336
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
337
|
+
}
|
|
296
338
|
if (!isDirect(quote)) {
|
|
297
339
|
throw new Error("pact: interactive pay-in is only available on a direct corridor");
|
|
298
340
|
}
|
|
341
|
+
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
342
|
+
throw new Error("pact: intent has expired");
|
|
343
|
+
}
|
|
299
344
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
300
345
|
if (!payInLeg) {
|
|
301
346
|
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
@@ -333,14 +378,14 @@ export class Client {
|
|
|
333
378
|
// A direct corridor's pay-in confirms and it settles.
|
|
334
379
|
if (fromPayIn && current === State.Submitted) {
|
|
335
380
|
if (failed) return this.recordFailure(intentId, authHash, quote, event.reason);
|
|
336
|
-
const
|
|
381
|
+
const collected = collectResult(events);
|
|
337
382
|
const settlement: Settlement = {
|
|
338
383
|
intentId,
|
|
339
384
|
state: State.Settled,
|
|
340
385
|
adapterId: quote.payInAdapterId,
|
|
341
|
-
providerTxRef:
|
|
386
|
+
providerTxRef: collected.providerRef,
|
|
342
387
|
onchainTxHash: "",
|
|
343
|
-
receiptHash: corridorReceipt(authHash,
|
|
388
|
+
receiptHash: corridorReceipt(authHash, collected.providerRef, collected.providerRef, collected.received, "", State.Settled),
|
|
344
389
|
reason: "",
|
|
345
390
|
settledAt: this.clock(),
|
|
346
391
|
};
|
|
@@ -384,11 +429,32 @@ export class Client {
|
|
|
384
429
|
throw new Error(`pact: no bridge ${JSON.stringify(quote.bridgeId)}`);
|
|
385
430
|
}
|
|
386
431
|
const collected = collectResult(events);
|
|
387
|
-
|
|
432
|
+
// Claim the escrow hold before converting or disbursing. If a concurrent
|
|
433
|
+
// advance already claimed it, this call did not win, so abort rather than run
|
|
434
|
+
// the conversion and pay-out a second time.
|
|
435
|
+
const { created } = this.ledger.apply({
|
|
388
436
|
intentId: intent.id,
|
|
389
437
|
to: State.Held,
|
|
390
438
|
payloadHash: hashString(domain("held"), collected.providerRef),
|
|
391
439
|
});
|
|
440
|
+
if (!created) {
|
|
441
|
+
return { settlement: lastSettlement(events), state: this.ledger.state(intent.id) };
|
|
442
|
+
}
|
|
443
|
+
// An expired corridor must not convert at a stale rate; unwind the collection
|
|
444
|
+
// to a refund instead of driving it forward past its deadline.
|
|
445
|
+
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
446
|
+
return this.unwind(intent.id, quote, authHash, "intent expired before conversion");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// The pay-in must have collected at least the source net of fees; a short
|
|
450
|
+
// collection unwinds rather than disbursing the full quote against it.
|
|
451
|
+
try {
|
|
452
|
+
if (collected.received.cmp(quote.srcAmount.sub(quote.fees)) < 0) {
|
|
453
|
+
return this.unwind(intent.id, quote, authHash, "bridged corridor collected less than the quoted source amount");
|
|
454
|
+
}
|
|
455
|
+
} catch {
|
|
456
|
+
return this.unwind(intent.id, quote, authHash, "bridged corridor collected in an unexpected currency");
|
|
457
|
+
}
|
|
392
458
|
|
|
393
459
|
let converted: ConvertResult;
|
|
394
460
|
try {
|
|
@@ -396,6 +462,16 @@ export class Client {
|
|
|
396
462
|
} catch (err) {
|
|
397
463
|
return this.unwind(intent.id, quote, authHash, reasonOf(err));
|
|
398
464
|
}
|
|
465
|
+
// Value must be conserved across the escrow: the conversion has to deliver at
|
|
466
|
+
// least the quoted destination amount before the pay-out is sent. A moved rate
|
|
467
|
+
// or a short conversion unwinds to a refund rather than draining escrow.
|
|
468
|
+
try {
|
|
469
|
+
if (converted.delivered.cmp(quote.dstAmount) < 0) {
|
|
470
|
+
return this.unwind(intent.id, quote, authHash, "bridged conversion delivered less than the quoted destination amount");
|
|
471
|
+
}
|
|
472
|
+
} catch {
|
|
473
|
+
return this.unwind(intent.id, quote, authHash, "bridged conversion delivered in an unexpected currency");
|
|
474
|
+
}
|
|
399
475
|
let disbursed;
|
|
400
476
|
try {
|
|
401
477
|
disbursed = await payOutLeg.disburse(intent.id, quote, recipientDestination(intent));
|
|
@@ -431,14 +507,14 @@ export class Client {
|
|
|
431
507
|
events: LedgerEvent[],
|
|
432
508
|
): { settlement: Settlement; state: State } {
|
|
433
509
|
const inRef = payInRef(events);
|
|
434
|
-
const { payOut: outRef, bridge: bridgeRef } = disburseRefs(events);
|
|
510
|
+
const { payOut: outRef, bridge: bridgeRef, delivered } = disburseRefs(events);
|
|
435
511
|
const settlement: Settlement = {
|
|
436
512
|
intentId,
|
|
437
513
|
state: State.Settled,
|
|
438
514
|
adapterId: quote.payOutAdapterId,
|
|
439
515
|
providerTxRef: outRef,
|
|
440
516
|
onchainTxHash: "",
|
|
441
|
-
receiptHash: corridorReceipt(authHash, inRef, outRef,
|
|
517
|
+
receiptHash: corridorReceipt(authHash, inRef, outRef, delivered, bridgeRef, State.Settled),
|
|
442
518
|
reason: "",
|
|
443
519
|
settledAt: this.clock(),
|
|
444
520
|
};
|
|
@@ -460,15 +536,79 @@ export class Client {
|
|
|
460
536
|
});
|
|
461
537
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
462
538
|
if (payInLeg) {
|
|
463
|
-
|
|
539
|
+
const kind = payInLeg.payInCapabilities().refunds;
|
|
540
|
+
// A pay-in rail that cannot refund in place leaves the corridor refunding
|
|
541
|
+
// for the operator to complete out of band, rather than recording a refund
|
|
542
|
+
// that never moved funds.
|
|
543
|
+
if (kind === RefundKind.None) {
|
|
544
|
+
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot refund automatically; manual return required: ${reason}`);
|
|
545
|
+
}
|
|
546
|
+
await payInLeg.refundIn(intentId, kind, quote.srcAmount, reason);
|
|
547
|
+
}
|
|
548
|
+
// The escrow unwind returns the whole collection, so the receipt attests the
|
|
549
|
+
// source amount the payer funded as the amount refunded.
|
|
550
|
+
const settlement: Settlement = {
|
|
551
|
+
intentId,
|
|
552
|
+
state: State.Refunded,
|
|
553
|
+
adapterId: quote.payInAdapterId,
|
|
554
|
+
providerTxRef: "",
|
|
555
|
+
onchainTxHash: "",
|
|
556
|
+
receiptHash: corridorReceipt(authHash, "", "", quote.srcAmount, "", State.Refunded),
|
|
557
|
+
reason,
|
|
558
|
+
settledAt: this.clock(),
|
|
559
|
+
};
|
|
560
|
+
return this.finishAdvance(intentId, State.Refunded, settlement);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// refund returns funds to the payer for a settled intent. The amount is
|
|
564
|
+
// explicit, so a host can issue a partial refund, and it must be in the source
|
|
565
|
+
// currency and no greater than what the payer funded. The pay-in leg must
|
|
566
|
+
// advertise a refund it can honour — and a partial one for a partial amount.
|
|
567
|
+
// Like every advance it is keyed on the target state, so a second refund for the
|
|
568
|
+
// same intent is a no-op.
|
|
569
|
+
async refund(intentId: string, amount: Money, reason: string): Promise<{ settlement: Settlement; state: State }> {
|
|
570
|
+
const events = this.ledger.events(intentId);
|
|
571
|
+
const replayed = replay(events);
|
|
572
|
+
if (!replayed) {
|
|
573
|
+
throw new Error("pact: intent has no recorded authorization to refund");
|
|
574
|
+
}
|
|
575
|
+
const { intent, quote, authorization: auth } = replayed;
|
|
576
|
+
const current = this.ledger.state(intentId);
|
|
577
|
+
// A refund is single-occurrence: once refunded, a repeat is an idempotent
|
|
578
|
+
// no-op that echoes the outcome rather than moving funds again.
|
|
579
|
+
if (current === State.Refunded) {
|
|
580
|
+
return { settlement: lastSettlement(events), state: current };
|
|
581
|
+
}
|
|
582
|
+
if (current !== State.Settled) {
|
|
583
|
+
throw new Error("pact: only a settled intent can be refunded");
|
|
584
|
+
}
|
|
585
|
+
if (amount.currency !== quote.srcAmount.currency || amount.exponent !== quote.srcAmount.exponent) {
|
|
586
|
+
throw new Error("pact: refund must be in the funded source currency");
|
|
464
587
|
}
|
|
588
|
+
if (amount.value() <= 0n) {
|
|
589
|
+
throw new Error("pact: refund amount must be positive");
|
|
590
|
+
}
|
|
591
|
+
const over = amount.cmp(quote.srcAmount);
|
|
592
|
+
if (over > 0) {
|
|
593
|
+
throw new Error("pact: refund exceeds the amount funded");
|
|
594
|
+
}
|
|
595
|
+
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
596
|
+
if (!payInLeg) {
|
|
597
|
+
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
598
|
+
}
|
|
599
|
+
const kind = refundKindFor(payInLeg.payInCapabilities().refunds, over === 0);
|
|
600
|
+
if (kind === undefined) {
|
|
601
|
+
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
|
|
602
|
+
}
|
|
603
|
+
const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
|
|
604
|
+
await payInLeg.refundIn(intentId, kind, amount, reason);
|
|
465
605
|
const settlement: Settlement = {
|
|
466
606
|
intentId,
|
|
467
607
|
state: State.Refunded,
|
|
468
608
|
adapterId: quote.payInAdapterId,
|
|
469
609
|
providerTxRef: "",
|
|
470
610
|
onchainTxHash: "",
|
|
471
|
-
receiptHash: corridorReceipt(authHash, "", "",
|
|
611
|
+
receiptHash: corridorReceipt(authHash, "", "", amount, "", State.Refunded),
|
|
472
612
|
reason,
|
|
473
613
|
settledAt: this.clock(),
|
|
474
614
|
};
|
|
@@ -488,7 +628,9 @@ export class Client {
|
|
|
488
628
|
adapterId: quote.payInAdapterId,
|
|
489
629
|
providerTxRef: "",
|
|
490
630
|
onchainTxHash: "",
|
|
491
|
-
|
|
631
|
+
// A pay-in that failed before escrow moved no value, so the receipt binds a
|
|
632
|
+
// zero amount alongside the failure.
|
|
633
|
+
receiptHash: corridorReceipt(authHash, "", "", Money.zero(), "", State.Failed),
|
|
492
634
|
reason,
|
|
493
635
|
settledAt: this.clock(),
|
|
494
636
|
};
|
|
@@ -501,13 +643,22 @@ export class Client {
|
|
|
501
643
|
}
|
|
502
644
|
|
|
503
645
|
private finish(intentId: string, state: State, settlement: Settlement): Settlement {
|
|
504
|
-
const event = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
|
|
646
|
+
const { event } = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
|
|
505
647
|
this.notify(event);
|
|
506
648
|
return settlement;
|
|
507
649
|
}
|
|
508
650
|
|
|
509
|
-
// expire records that an intent's deadline passed before it settled.
|
|
651
|
+
// expire records that an intent's deadline passed before it settled. It refuses
|
|
652
|
+
// to expire an intent whose deadline has not actually passed, so a caller cannot
|
|
653
|
+
// force a live payment to a terminal expired state.
|
|
510
654
|
expire(intentId: string): void {
|
|
655
|
+
const recorded = this.ledger.events(intentId).find((e) => e.intent)?.intent;
|
|
656
|
+
if (!recorded) {
|
|
657
|
+
throw new Error("pact: intent has no recorded history to expire");
|
|
658
|
+
}
|
|
659
|
+
if (!isExpired(recorded.expiresAt, this.clock(), this.skew)) {
|
|
660
|
+
throw new Error("pact: intent has not reached its deadline");
|
|
661
|
+
}
|
|
511
662
|
this.ledger.apply({
|
|
512
663
|
intentId,
|
|
513
664
|
to: State.Expired,
|
|
@@ -565,25 +716,46 @@ function collectResult(events: LedgerEvent[]): CollectResult {
|
|
|
565
716
|
for (const e of events) {
|
|
566
717
|
if (e.collect) return e.collect;
|
|
567
718
|
}
|
|
568
|
-
return { providerRef: "", received: Money.
|
|
719
|
+
return { providerRef: "", received: Money.zero() };
|
|
569
720
|
}
|
|
570
721
|
|
|
571
722
|
function payInRef(events: LedgerEvent[]): string {
|
|
572
723
|
return collectResult(events).providerRef;
|
|
573
724
|
}
|
|
574
725
|
|
|
575
|
-
// disburseRefs returns the pay-out provider reference
|
|
576
|
-
// reference recorded on the
|
|
577
|
-
|
|
726
|
+
// disburseRefs returns the pay-out provider reference, the bridge receipt
|
|
727
|
+
// reference, and the amount the bridge actually delivered, all recorded on the
|
|
728
|
+
// disbursing step.
|
|
729
|
+
function disburseRefs(events: LedgerEvent[]): { payOut: string; bridge: string; delivered: Money } {
|
|
578
730
|
let payOut = "";
|
|
579
731
|
let bridge = "";
|
|
732
|
+
let delivered = Money.zero();
|
|
580
733
|
for (const e of events) {
|
|
581
734
|
if (e.state === State.Disbursing) {
|
|
582
735
|
if (e.settlement) payOut = e.settlement.providerTxRef;
|
|
583
|
-
if (e.bridge)
|
|
736
|
+
if (e.bridge) {
|
|
737
|
+
bridge = e.bridge.receiptRef;
|
|
738
|
+
delivered = e.bridge.delivered;
|
|
739
|
+
}
|
|
584
740
|
}
|
|
585
741
|
}
|
|
586
|
-
return { payOut, bridge };
|
|
742
|
+
return { payOut, bridge, delivered };
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// refundKindFor maps a pay-in leg's advertised refund capability and whether the
|
|
746
|
+
// refund is for the full funded amount to the kind the leg is handed, returning
|
|
747
|
+
// undefined for a refund the rail cannot express.
|
|
748
|
+
function refundKindFor(capability: RefundKind, full: boolean): RefundKind | undefined {
|
|
749
|
+
switch (capability) {
|
|
750
|
+
case RefundKind.CounterTransfer:
|
|
751
|
+
return RefundKind.CounterTransfer;
|
|
752
|
+
case RefundKind.Partial:
|
|
753
|
+
return full ? RefundKind.Full : RefundKind.Partial;
|
|
754
|
+
case RefundKind.Full:
|
|
755
|
+
return full ? RefundKind.Full : undefined;
|
|
756
|
+
default:
|
|
757
|
+
return undefined;
|
|
758
|
+
}
|
|
587
759
|
}
|
|
588
760
|
|
|
589
761
|
// lastSettlement returns the settlement recorded on an intent's most recent
|
package/src/compliance.ts
CHANGED
|
@@ -32,18 +32,35 @@ export const permissiveKyc: KycProvider = {
|
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
// withinLimits reports whether a quote's source amount is inside an identity's
|
|
35
|
-
// per-payment cap. An absent cap
|
|
36
|
-
//
|
|
35
|
+
// per-payment cap. An absent cap means no limit. A cap that cannot be compared to
|
|
36
|
+
// the quote — because it is in a different currency — fails closed: the payment is
|
|
37
|
+
// refused rather than slipped past by funding in a currency the cap can't measure.
|
|
37
38
|
export function withinLimits(status: KycStatus, quote: Quote): boolean {
|
|
38
39
|
const cap = status.limits.perPayment;
|
|
39
40
|
if (!cap) return true;
|
|
40
41
|
try {
|
|
41
42
|
return quote.srcAmount.cmp(cap) <= 0;
|
|
42
43
|
} catch {
|
|
43
|
-
return
|
|
44
|
+
return false;
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
47
|
|
|
48
|
+
// SignerAuthorizer decides whether the identity that signed an authorization is
|
|
49
|
+
// allowed to act for the intent's sender. The default requires them to be the
|
|
50
|
+
// same party. A custodial host that signs for its users on one platform key
|
|
51
|
+
// supplies its own, so it can attest that its signer speaks for a given sender —
|
|
52
|
+
// and so compliance is measured against the party actually being charged.
|
|
53
|
+
export interface SignerAuthorizer {
|
|
54
|
+
authorized(signerIdentity: string, senderRef: string): boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// strictSignerAuthorizer is the default: the signer must be the sender.
|
|
58
|
+
export const strictSignerAuthorizer: SignerAuthorizer = {
|
|
59
|
+
authorized(signerIdentity: string, senderRef: string): boolean {
|
|
60
|
+
return signerIdentity === senderRef;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
47
64
|
// RiskDecision is the outcome of a RiskHook consultation.
|
|
48
65
|
export interface RiskDecision {
|
|
49
66
|
allow: boolean;
|
package/src/crypto.ts
CHANGED
|
@@ -42,7 +42,10 @@ export function ed25519Sign(privateKey: KeyObject, message: Uint8Array): Uint8Ar
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
export function ed25519Verify(publicKeyRaw: Uint8Array, message: Uint8Array, signature: Uint8Array): boolean {
|
|
45
|
-
|
|
45
|
+
// A key or signature of the wrong length can never verify; reject it up front.
|
|
46
|
+
// The underlying verifier already rejects a non-canonical S, so a malleated
|
|
47
|
+
// signature never verifies.
|
|
48
|
+
if (publicKeyRaw.length !== 32 || signature.length !== 64) {
|
|
46
49
|
return false;
|
|
47
50
|
}
|
|
48
51
|
const spki = Buffer.concat([spkiPublicPrefix, Buffer.from(publicKeyRaw)]);
|
package/src/index.ts
CHANGED
package/src/ledger.ts
CHANGED
|
@@ -49,11 +49,19 @@ export class IdempotencyConflictError extends Error {
|
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// ApplyResult carries the appended event and whether this call actually appended
|
|
53
|
+
// it (created) or found the step already recorded and returned the existing one,
|
|
54
|
+
// so a caller can claim a step before a side effect and abort if it did not win.
|
|
55
|
+
export interface ApplyResult {
|
|
56
|
+
event: LedgerEvent;
|
|
57
|
+
created: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
52
60
|
// Ledger is the append-only store of payment history. The kernel ships an
|
|
53
61
|
// in-memory reference; durable backends implement the same interface and must
|
|
54
62
|
// preserve the same event order, receipts, and Merkle head.
|
|
55
63
|
export interface Ledger {
|
|
56
|
-
apply(t: Transition):
|
|
64
|
+
apply(t: Transition): ApplyResult;
|
|
57
65
|
state(intentId: string): State;
|
|
58
66
|
events(intentId: string): LedgerEvent[];
|
|
59
67
|
head(intentId: string): Uint8Array | undefined;
|
|
@@ -65,14 +73,14 @@ const zeroLeaf = new Uint8Array(32);
|
|
|
65
73
|
export class MemoryLedger implements Ledger {
|
|
66
74
|
private readonly byIntent = new Map<string, LedgerEvent[]>();
|
|
67
75
|
|
|
68
|
-
apply(t: Transition):
|
|
76
|
+
apply(t: Transition): ApplyResult {
|
|
69
77
|
const history = this.byIntent.get(t.intentId) ?? [];
|
|
70
78
|
|
|
71
79
|
// A repeat of the same step is a no-op returning the first result; the same
|
|
72
80
|
// step with a different payload is a conflict.
|
|
73
81
|
for (const e of history) {
|
|
74
82
|
if (e.state === t.to) {
|
|
75
|
-
if (bytesEqual(e.payloadHash, t.payloadHash)) return e;
|
|
83
|
+
if (bytesEqual(e.payloadHash, t.payloadHash)) return { event: e, created: false };
|
|
76
84
|
throw new IdempotencyConflictError();
|
|
77
85
|
}
|
|
78
86
|
}
|
|
@@ -102,7 +110,7 @@ export class MemoryLedger implements Ledger {
|
|
|
102
110
|
...(t.bridge ? { bridge: t.bridge } : {}),
|
|
103
111
|
};
|
|
104
112
|
this.byIntent.set(t.intentId, [...history, event]);
|
|
105
|
-
return event;
|
|
113
|
+
return { event, created: true };
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
state(intentId: string): State {
|
package/src/leg.ts
CHANGED
|
@@ -33,7 +33,10 @@ export interface PayInLeg {
|
|
|
33
33
|
id: string;
|
|
34
34
|
payInCapabilities(): PayInCapabilities;
|
|
35
35
|
collect(intentId: string, quote: Quote, auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
36
|
-
refundIn
|
|
36
|
+
// refundIn returns amount to the payer. The amount is explicit so a leg can
|
|
37
|
+
// honour a partial refund, not only a full one; a leg that can refund at all
|
|
38
|
+
// must return exactly it.
|
|
39
|
+
refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
// PayInPreparation is what an interactive pay-in leg hands back so the payer can
|
package/src/message.ts
CHANGED
|
@@ -153,14 +153,17 @@ export function receiptHash(authHash: Uint8Array, providerTxRef: string, state:
|
|
|
153
153
|
);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
// corridorReceipt binds both legs of a bridged corridor and the
|
|
157
|
-
// commitment, so the payer can prove they funded X and
|
|
158
|
-
// they received Y as a single transaction, without
|
|
156
|
+
// corridorReceipt binds both legs of a bridged corridor and the amount that
|
|
157
|
+
// actually moved into one commitment, so the payer can prove they funded X and
|
|
158
|
+
// the recipient can prove they received Y as a single transaction, without
|
|
159
|
+
// revealing anything else. The amount is the value the corridor really delivered
|
|
160
|
+
// on settle or returned on refund — not the quoted rate, which the conversion may
|
|
161
|
+
// not have matched — so the receipt attests the outcome, not the plan.
|
|
159
162
|
export function corridorReceipt(
|
|
160
163
|
authHash: Uint8Array,
|
|
161
164
|
payInProviderRef: string,
|
|
162
165
|
payOutProviderRef: string,
|
|
163
|
-
|
|
166
|
+
amount: Money,
|
|
164
167
|
bridgeReceiptRef: string,
|
|
165
168
|
state: State,
|
|
166
169
|
): Uint8Array {
|
|
@@ -170,7 +173,7 @@ export function corridorReceipt(
|
|
|
170
173
|
.bytes(authHash)
|
|
171
174
|
.str(payInProviderRef)
|
|
172
175
|
.str(payOutProviderRef)
|
|
173
|
-
.
|
|
176
|
+
.money(amount)
|
|
174
177
|
.str(bridgeReceiptRef)
|
|
175
178
|
.str(stateName[state])
|
|
176
179
|
.preimage(),
|
package/src/money.ts
CHANGED
|
@@ -12,6 +12,14 @@ export class Money {
|
|
|
12
12
|
private readonly amount: bigint,
|
|
13
13
|
) {}
|
|
14
14
|
|
|
15
|
+
// zero is the empty-currency, zero-amount sentinel used where a corridor moved
|
|
16
|
+
// no value — a failure receipt, or an absent bridge result. It is not a valid
|
|
17
|
+
// wire amount (an empty currency never parses), only an internal placeholder,
|
|
18
|
+
// and it encodes canonically as minor "0", currency "", exponent 0.
|
|
19
|
+
static zero(): Money {
|
|
20
|
+
return new Money("", 0, 0n);
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
static create(currency: string, exponent: number, amount: bigint): Money {
|
|
16
24
|
if (!currencyPattern.test(currency)) {
|
|
17
25
|
throw new Error("pact: currency must match [A-Z0-9]{1,16}");
|
|
@@ -26,10 +34,18 @@ export class Money {
|
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
// parse builds Money from a decimal-ASCII minor-unit string, the form used on
|
|
29
|
-
// the wire.
|
|
37
|
+
// the wire. The grammar is a bare non-negative integer — no sign, no radix
|
|
38
|
+
// prefix, no whitespace, no leading zeros — pinned identically across every SDK
|
|
39
|
+
// so two participants never disagree on whether a message is valid or on the
|
|
40
|
+
// value it decodes to.
|
|
30
41
|
static parse(currency: string, exponent: number, minor: string): Money {
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
// Bound the length so an untrusted string can't force a huge bigint parse
|
|
43
|
+
// before capability limits ever see it; eighty digits is far past any amount.
|
|
44
|
+
if (minor.length > 80) {
|
|
45
|
+
throw new Error("pact: amount has more than 80 digits");
|
|
46
|
+
}
|
|
47
|
+
if (!/^(0|[1-9][0-9]*)$/.test(minor)) {
|
|
48
|
+
throw new Error(`pact: ${minor} is not a canonical base-10 integer`);
|
|
33
49
|
}
|
|
34
50
|
return Money.create(currency, exponent, BigInt(minor));
|
|
35
51
|
}
|
package/src/protocol.ts
CHANGED
|
@@ -14,6 +14,15 @@ export function domain(kind: string): string {
|
|
|
14
14
|
return domainPrefix + kind;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// idempotencyKey binds a provider mutation to one protocol step so a retry reuses
|
|
18
|
+
// the same key and the provider deduplicates it into a single side effect. ref is
|
|
19
|
+
// the stable reference the step acts on (the intent id, or a provider object id);
|
|
20
|
+
// step names the operation. Every adapter derives its provider idempotency key
|
|
21
|
+
// this way, so the same (ref, step) always maps to the same key.
|
|
22
|
+
export function idempotencyKey(ref: string, step: string): string {
|
|
23
|
+
return `${ID}:${ref}:${step}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
// DEFAULT_SKEW_MILLIS is the tolerated clock difference when deciding whether an
|
|
18
27
|
// intent or quote has expired.
|
|
19
28
|
export const DEFAULT_SKEW_MILLIS = 120_000;
|