@myzonerocks/pact 0.1.3 → 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 +5 -1
- package/dist/src/adapters/paypal.js +76 -25
- package/dist/src/adapters/stripe.d.ts +3 -2
- package/dist/src/adapters/stripe.js +14 -11
- 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 +33 -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 +102 -24
- 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/dist/src/client.js
CHANGED
|
@@ -7,8 +7,9 @@ import { intentHash, quoteHash, authorizationHash, corridorReceipt, isDirect, BR
|
|
|
7
7
|
import { authorize as signAuthorization, verifyAuthorization } from "./signing.js";
|
|
8
8
|
import { PassThroughBridge } from "./bridge.js";
|
|
9
9
|
import { railInList, isInteractivePayInLeg } from "./leg.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { RefundKind } from "./adapter.js";
|
|
11
|
+
import { route, PolicyKind, NoQuoteError, isExpired, maxFundingOptions } from "./router.js";
|
|
12
|
+
import { permissiveKyc, allowRisk, withinLimits, strictSignerAuthorizer, } from "./compliance.js";
|
|
12
13
|
import { MemoryLedger, eventReceipt } from "./ledger.js";
|
|
13
14
|
export function systemClock() {
|
|
14
15
|
return Date.now();
|
|
@@ -29,6 +30,7 @@ export class Client {
|
|
|
29
30
|
verifier;
|
|
30
31
|
kyc;
|
|
31
32
|
risk;
|
|
33
|
+
signerAuth;
|
|
32
34
|
clock;
|
|
33
35
|
idGen;
|
|
34
36
|
skew;
|
|
@@ -49,6 +51,7 @@ export class Client {
|
|
|
49
51
|
this.verifier = cfg.verifier;
|
|
50
52
|
this.kyc = cfg.kyc ?? permissiveKyc;
|
|
51
53
|
this.risk = cfg.risk ?? allowRisk;
|
|
54
|
+
this.signerAuth = cfg.signerAuth ?? strictSignerAuthorizer;
|
|
52
55
|
this.clock = cfg.clock ?? systemClock;
|
|
53
56
|
this.idGen = cfg.idGen ?? randomId;
|
|
54
57
|
this.skew = cfg.skew ?? DEFAULT_SKEW_MILLIS;
|
|
@@ -56,6 +59,11 @@ export class Client {
|
|
|
56
59
|
}
|
|
57
60
|
// createIntent mints an intent and records it as a draft.
|
|
58
61
|
createIntent(spec) {
|
|
62
|
+
// A zero deadline means "never expires", which would let an intent advance
|
|
63
|
+
// forever and bypass the liveness bound. Require a real deadline at creation.
|
|
64
|
+
if (!spec.expiresAt) {
|
|
65
|
+
throw new Error("pact: intent requires a non-zero expiry deadline");
|
|
66
|
+
}
|
|
59
67
|
const intent = {
|
|
60
68
|
id: this.idGen(),
|
|
61
69
|
senderRef: spec.senderRef,
|
|
@@ -74,6 +82,9 @@ export class Client {
|
|
|
74
82
|
// recipient's currency and a pay-out leg that can deliver it, prices the whole
|
|
75
83
|
// corridor, and returns the options for the payer to choose among.
|
|
76
84
|
async quoteOptions(intent, funding) {
|
|
85
|
+
if (funding.length > maxFundingOptions) {
|
|
86
|
+
throw new Error(`pact: ${funding.length} funding options exceed the maximum of ${maxFundingOptions}`);
|
|
87
|
+
}
|
|
77
88
|
const quotes = [];
|
|
78
89
|
let lastErr;
|
|
79
90
|
for (const f of funding) {
|
|
@@ -186,11 +197,17 @@ export class Client {
|
|
|
186
197
|
if (isExpired(quote.expiresAt, now, this.skew)) {
|
|
187
198
|
throw new Error("pact: quote has expired");
|
|
188
199
|
}
|
|
189
|
-
|
|
200
|
+
// The signer must be allowed to act for the sender, and compliance is measured
|
|
201
|
+
// against the sender — the party actually funding the payment — not whoever
|
|
202
|
+
// holds the signing key.
|
|
203
|
+
if (!this.signerAuth.authorized(signer.identity(), intent.senderRef)) {
|
|
204
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
205
|
+
}
|
|
206
|
+
const status = await this.kyc.status(intent.senderRef);
|
|
190
207
|
if (!withinLimits(status, quote)) {
|
|
191
208
|
throw new Error("pact: quote exceeds KYC limit");
|
|
192
209
|
}
|
|
193
|
-
const decision = this.risk.evaluate(
|
|
210
|
+
const decision = this.risk.evaluate(intent.senderRef, intent, quote);
|
|
194
211
|
if (!decision.allow) {
|
|
195
212
|
throw new Error(`pact: risk hook vetoed authorization: ${decision.reason}`);
|
|
196
213
|
}
|
|
@@ -208,10 +225,23 @@ export class Client {
|
|
|
208
225
|
// ledger.
|
|
209
226
|
async initiate(intent, quote, auth) {
|
|
210
227
|
verifyAuthorization(auth, intent, quote, this.verifier);
|
|
228
|
+
if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
|
|
229
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
230
|
+
}
|
|
231
|
+
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
232
|
+
throw new Error("pact: intent has expired");
|
|
233
|
+
}
|
|
211
234
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
212
235
|
if (!payInLeg) {
|
|
213
236
|
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
214
237
|
}
|
|
238
|
+
// A retry of an already-submitted corridor must not collect a second time. The
|
|
239
|
+
// pay-in leg carries its own provider idempotency for the narrow window where a
|
|
240
|
+
// collection completed but its record was lost.
|
|
241
|
+
const submitted = this.ledger.state(intent.id);
|
|
242
|
+
if (submitted === State.Submitted || submitted === State.Collecting) {
|
|
243
|
+
return submitted;
|
|
244
|
+
}
|
|
215
245
|
// A direct corridor collects straight to the recipient; a bridged one
|
|
216
246
|
// collects into escrow first.
|
|
217
247
|
let deliverTo = recipientDestination(intent);
|
|
@@ -233,9 +263,15 @@ export class Client {
|
|
|
233
263
|
// server-side into escrow, and the leg must offer interactive collection.
|
|
234
264
|
async interactiveInitiate(intent, quote, auth) {
|
|
235
265
|
verifyAuthorization(auth, intent, quote, this.verifier);
|
|
266
|
+
if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
|
|
267
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
268
|
+
}
|
|
236
269
|
if (!isDirect(quote)) {
|
|
237
270
|
throw new Error("pact: interactive pay-in is only available on a direct corridor");
|
|
238
271
|
}
|
|
272
|
+
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
273
|
+
throw new Error("pact: intent has expired");
|
|
274
|
+
}
|
|
239
275
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
240
276
|
if (!payInLeg) {
|
|
241
277
|
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
@@ -272,14 +308,14 @@ export class Client {
|
|
|
272
308
|
if (fromPayIn && current === State.Submitted) {
|
|
273
309
|
if (failed)
|
|
274
310
|
return this.recordFailure(intentId, authHash, quote, event.reason);
|
|
275
|
-
const
|
|
311
|
+
const collected = collectResult(events);
|
|
276
312
|
const settlement = {
|
|
277
313
|
intentId,
|
|
278
314
|
state: State.Settled,
|
|
279
315
|
adapterId: quote.payInAdapterId,
|
|
280
|
-
providerTxRef:
|
|
316
|
+
providerTxRef: collected.providerRef,
|
|
281
317
|
onchainTxHash: "",
|
|
282
|
-
receiptHash: corridorReceipt(authHash,
|
|
318
|
+
receiptHash: corridorReceipt(authHash, collected.providerRef, collected.providerRef, collected.received, "", State.Settled),
|
|
283
319
|
reason: "",
|
|
284
320
|
settledAt: this.clock(),
|
|
285
321
|
};
|
|
@@ -316,11 +352,32 @@ export class Client {
|
|
|
316
352
|
throw new Error(`pact: no bridge ${JSON.stringify(quote.bridgeId)}`);
|
|
317
353
|
}
|
|
318
354
|
const collected = collectResult(events);
|
|
319
|
-
|
|
355
|
+
// Claim the escrow hold before converting or disbursing. If a concurrent
|
|
356
|
+
// advance already claimed it, this call did not win, so abort rather than run
|
|
357
|
+
// the conversion and pay-out a second time.
|
|
358
|
+
const { created } = this.ledger.apply({
|
|
320
359
|
intentId: intent.id,
|
|
321
360
|
to: State.Held,
|
|
322
361
|
payloadHash: hashString(domain("held"), collected.providerRef),
|
|
323
362
|
});
|
|
363
|
+
if (!created) {
|
|
364
|
+
return { settlement: lastSettlement(events), state: this.ledger.state(intent.id) };
|
|
365
|
+
}
|
|
366
|
+
// An expired corridor must not convert at a stale rate; unwind the collection
|
|
367
|
+
// to a refund instead of driving it forward past its deadline.
|
|
368
|
+
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
369
|
+
return this.unwind(intent.id, quote, authHash, "intent expired before conversion");
|
|
370
|
+
}
|
|
371
|
+
// The pay-in must have collected at least the source net of fees; a short
|
|
372
|
+
// collection unwinds rather than disbursing the full quote against it.
|
|
373
|
+
try {
|
|
374
|
+
if (collected.received.cmp(quote.srcAmount.sub(quote.fees)) < 0) {
|
|
375
|
+
return this.unwind(intent.id, quote, authHash, "bridged corridor collected less than the quoted source amount");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
return this.unwind(intent.id, quote, authHash, "bridged corridor collected in an unexpected currency");
|
|
380
|
+
}
|
|
324
381
|
let converted;
|
|
325
382
|
try {
|
|
326
383
|
converted = await bridge.convert(intent.id, collected.received, intent.amount.currency, intent.amount.exponent);
|
|
@@ -328,6 +385,17 @@ export class Client {
|
|
|
328
385
|
catch (err) {
|
|
329
386
|
return this.unwind(intent.id, quote, authHash, reasonOf(err));
|
|
330
387
|
}
|
|
388
|
+
// Value must be conserved across the escrow: the conversion has to deliver at
|
|
389
|
+
// least the quoted destination amount before the pay-out is sent. A moved rate
|
|
390
|
+
// or a short conversion unwinds to a refund rather than draining escrow.
|
|
391
|
+
try {
|
|
392
|
+
if (converted.delivered.cmp(quote.dstAmount) < 0) {
|
|
393
|
+
return this.unwind(intent.id, quote, authHash, "bridged conversion delivered less than the quoted destination amount");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
return this.unwind(intent.id, quote, authHash, "bridged conversion delivered in an unexpected currency");
|
|
398
|
+
}
|
|
331
399
|
let disbursed;
|
|
332
400
|
try {
|
|
333
401
|
disbursed = await payOutLeg.disburse(intent.id, quote, recipientDestination(intent));
|
|
@@ -358,14 +426,14 @@ export class Client {
|
|
|
358
426
|
// binding both legs and the FX into the linked receipt.
|
|
359
427
|
settleBridged(intentId, quote, authHash, events) {
|
|
360
428
|
const inRef = payInRef(events);
|
|
361
|
-
const { payOut: outRef, bridge: bridgeRef } = disburseRefs(events);
|
|
429
|
+
const { payOut: outRef, bridge: bridgeRef, delivered } = disburseRefs(events);
|
|
362
430
|
const settlement = {
|
|
363
431
|
intentId,
|
|
364
432
|
state: State.Settled,
|
|
365
433
|
adapterId: quote.payOutAdapterId,
|
|
366
434
|
providerTxRef: outRef,
|
|
367
435
|
onchainTxHash: "",
|
|
368
|
-
receiptHash: corridorReceipt(authHash, inRef, outRef,
|
|
436
|
+
receiptHash: corridorReceipt(authHash, inRef, outRef, delivered, bridgeRef, State.Settled),
|
|
369
437
|
reason: "",
|
|
370
438
|
settledAt: this.clock(),
|
|
371
439
|
};
|
|
@@ -381,15 +449,78 @@ export class Client {
|
|
|
381
449
|
});
|
|
382
450
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
383
451
|
if (payInLeg) {
|
|
384
|
-
|
|
452
|
+
const kind = payInLeg.payInCapabilities().refunds;
|
|
453
|
+
// A pay-in rail that cannot refund in place leaves the corridor refunding
|
|
454
|
+
// for the operator to complete out of band, rather than recording a refund
|
|
455
|
+
// that never moved funds.
|
|
456
|
+
if (kind === RefundKind.None) {
|
|
457
|
+
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot refund automatically; manual return required: ${reason}`);
|
|
458
|
+
}
|
|
459
|
+
await payInLeg.refundIn(intentId, kind, quote.srcAmount, reason);
|
|
385
460
|
}
|
|
461
|
+
// The escrow unwind returns the whole collection, so the receipt attests the
|
|
462
|
+
// source amount the payer funded as the amount refunded.
|
|
386
463
|
const settlement = {
|
|
387
464
|
intentId,
|
|
388
465
|
state: State.Refunded,
|
|
389
466
|
adapterId: quote.payInAdapterId,
|
|
390
467
|
providerTxRef: "",
|
|
391
468
|
onchainTxHash: "",
|
|
392
|
-
receiptHash: corridorReceipt(authHash, "", "", quote.
|
|
469
|
+
receiptHash: corridorReceipt(authHash, "", "", quote.srcAmount, "", State.Refunded),
|
|
470
|
+
reason,
|
|
471
|
+
settledAt: this.clock(),
|
|
472
|
+
};
|
|
473
|
+
return this.finishAdvance(intentId, State.Refunded, settlement);
|
|
474
|
+
}
|
|
475
|
+
// refund returns funds to the payer for a settled intent. The amount is
|
|
476
|
+
// explicit, so a host can issue a partial refund, and it must be in the source
|
|
477
|
+
// currency and no greater than what the payer funded. The pay-in leg must
|
|
478
|
+
// advertise a refund it can honour — and a partial one for a partial amount.
|
|
479
|
+
// Like every advance it is keyed on the target state, so a second refund for the
|
|
480
|
+
// same intent is a no-op.
|
|
481
|
+
async refund(intentId, amount, reason) {
|
|
482
|
+
const events = this.ledger.events(intentId);
|
|
483
|
+
const replayed = replay(events);
|
|
484
|
+
if (!replayed) {
|
|
485
|
+
throw new Error("pact: intent has no recorded authorization to refund");
|
|
486
|
+
}
|
|
487
|
+
const { intent, quote, authorization: auth } = replayed;
|
|
488
|
+
const current = this.ledger.state(intentId);
|
|
489
|
+
// A refund is single-occurrence: once refunded, a repeat is an idempotent
|
|
490
|
+
// no-op that echoes the outcome rather than moving funds again.
|
|
491
|
+
if (current === State.Refunded) {
|
|
492
|
+
return { settlement: lastSettlement(events), state: current };
|
|
493
|
+
}
|
|
494
|
+
if (current !== State.Settled) {
|
|
495
|
+
throw new Error("pact: only a settled intent can be refunded");
|
|
496
|
+
}
|
|
497
|
+
if (amount.currency !== quote.srcAmount.currency || amount.exponent !== quote.srcAmount.exponent) {
|
|
498
|
+
throw new Error("pact: refund must be in the funded source currency");
|
|
499
|
+
}
|
|
500
|
+
if (amount.value() <= 0n) {
|
|
501
|
+
throw new Error("pact: refund amount must be positive");
|
|
502
|
+
}
|
|
503
|
+
const over = amount.cmp(quote.srcAmount);
|
|
504
|
+
if (over > 0) {
|
|
505
|
+
throw new Error("pact: refund exceeds the amount funded");
|
|
506
|
+
}
|
|
507
|
+
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
508
|
+
if (!payInLeg) {
|
|
509
|
+
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
510
|
+
}
|
|
511
|
+
const kind = refundKindFor(payInLeg.payInCapabilities().refunds, over === 0);
|
|
512
|
+
if (kind === undefined) {
|
|
513
|
+
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
|
|
514
|
+
}
|
|
515
|
+
const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
|
|
516
|
+
await payInLeg.refundIn(intentId, kind, amount, reason);
|
|
517
|
+
const settlement = {
|
|
518
|
+
intentId,
|
|
519
|
+
state: State.Refunded,
|
|
520
|
+
adapterId: quote.payInAdapterId,
|
|
521
|
+
providerTxRef: "",
|
|
522
|
+
onchainTxHash: "",
|
|
523
|
+
receiptHash: corridorReceipt(authHash, "", "", amount, "", State.Refunded),
|
|
393
524
|
reason,
|
|
394
525
|
settledAt: this.clock(),
|
|
395
526
|
};
|
|
@@ -403,7 +534,9 @@ export class Client {
|
|
|
403
534
|
adapterId: quote.payInAdapterId,
|
|
404
535
|
providerTxRef: "",
|
|
405
536
|
onchainTxHash: "",
|
|
406
|
-
|
|
537
|
+
// A pay-in that failed before escrow moved no value, so the receipt binds a
|
|
538
|
+
// zero amount alongside the failure.
|
|
539
|
+
receiptHash: corridorReceipt(authHash, "", "", Money.zero(), "", State.Failed),
|
|
407
540
|
reason,
|
|
408
541
|
settledAt: this.clock(),
|
|
409
542
|
};
|
|
@@ -414,12 +547,21 @@ export class Client {
|
|
|
414
547
|
return { settlement: finished, state };
|
|
415
548
|
}
|
|
416
549
|
finish(intentId, state, settlement) {
|
|
417
|
-
const event = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
|
|
550
|
+
const { event } = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
|
|
418
551
|
this.notify(event);
|
|
419
552
|
return settlement;
|
|
420
553
|
}
|
|
421
|
-
// expire records that an intent's deadline passed before it settled.
|
|
554
|
+
// expire records that an intent's deadline passed before it settled. It refuses
|
|
555
|
+
// to expire an intent whose deadline has not actually passed, so a caller cannot
|
|
556
|
+
// force a live payment to a terminal expired state.
|
|
422
557
|
expire(intentId) {
|
|
558
|
+
const recorded = this.ledger.events(intentId).find((e) => e.intent)?.intent;
|
|
559
|
+
if (!recorded) {
|
|
560
|
+
throw new Error("pact: intent has no recorded history to expire");
|
|
561
|
+
}
|
|
562
|
+
if (!isExpired(recorded.expiresAt, this.clock(), this.skew)) {
|
|
563
|
+
throw new Error("pact: intent has not reached its deadline");
|
|
564
|
+
}
|
|
423
565
|
this.ledger.apply({
|
|
424
566
|
intentId,
|
|
425
567
|
to: State.Expired,
|
|
@@ -475,25 +617,44 @@ function collectResult(events) {
|
|
|
475
617
|
if (e.collect)
|
|
476
618
|
return e.collect;
|
|
477
619
|
}
|
|
478
|
-
return { providerRef: "", received: Money.
|
|
620
|
+
return { providerRef: "", received: Money.zero() };
|
|
479
621
|
}
|
|
480
622
|
function payInRef(events) {
|
|
481
623
|
return collectResult(events).providerRef;
|
|
482
624
|
}
|
|
483
|
-
// disburseRefs returns the pay-out provider reference
|
|
484
|
-
// reference recorded on the
|
|
625
|
+
// disburseRefs returns the pay-out provider reference, the bridge receipt
|
|
626
|
+
// reference, and the amount the bridge actually delivered, all recorded on the
|
|
627
|
+
// disbursing step.
|
|
485
628
|
function disburseRefs(events) {
|
|
486
629
|
let payOut = "";
|
|
487
630
|
let bridge = "";
|
|
631
|
+
let delivered = Money.zero();
|
|
488
632
|
for (const e of events) {
|
|
489
633
|
if (e.state === State.Disbursing) {
|
|
490
634
|
if (e.settlement)
|
|
491
635
|
payOut = e.settlement.providerTxRef;
|
|
492
|
-
if (e.bridge)
|
|
636
|
+
if (e.bridge) {
|
|
493
637
|
bridge = e.bridge.receiptRef;
|
|
638
|
+
delivered = e.bridge.delivered;
|
|
639
|
+
}
|
|
494
640
|
}
|
|
495
641
|
}
|
|
496
|
-
return { payOut, bridge };
|
|
642
|
+
return { payOut, bridge, delivered };
|
|
643
|
+
}
|
|
644
|
+
// refundKindFor maps a pay-in leg's advertised refund capability and whether the
|
|
645
|
+
// refund is for the full funded amount to the kind the leg is handed, returning
|
|
646
|
+
// undefined for a refund the rail cannot express.
|
|
647
|
+
function refundKindFor(capability, full) {
|
|
648
|
+
switch (capability) {
|
|
649
|
+
case RefundKind.CounterTransfer:
|
|
650
|
+
return RefundKind.CounterTransfer;
|
|
651
|
+
case RefundKind.Partial:
|
|
652
|
+
return full ? RefundKind.Full : RefundKind.Partial;
|
|
653
|
+
case RefundKind.Full:
|
|
654
|
+
return full ? RefundKind.Full : undefined;
|
|
655
|
+
default:
|
|
656
|
+
return undefined;
|
|
657
|
+
}
|
|
497
658
|
}
|
|
498
659
|
// lastSettlement returns the settlement recorded on an intent's most recent
|
|
499
660
|
// event, if any, so an idempotent no-op can echo the outcome already reached.
|
package/dist/src/compliance.d.ts
CHANGED
|
@@ -13,6 +13,10 @@ export interface KycProvider {
|
|
|
13
13
|
}
|
|
14
14
|
export declare const permissiveKyc: KycProvider;
|
|
15
15
|
export declare function withinLimits(status: KycStatus, quote: Quote): boolean;
|
|
16
|
+
export interface SignerAuthorizer {
|
|
17
|
+
authorized(signerIdentity: string, senderRef: string): boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare const strictSignerAuthorizer: SignerAuthorizer;
|
|
16
20
|
export interface RiskDecision {
|
|
17
21
|
allow: boolean;
|
|
18
22
|
reason: string;
|
package/dist/src/compliance.js
CHANGED
|
@@ -6,8 +6,9 @@ export const permissiveKyc = {
|
|
|
6
6
|
},
|
|
7
7
|
};
|
|
8
8
|
// withinLimits reports whether a quote's source amount is inside an identity's
|
|
9
|
-
// per-payment cap. An absent cap
|
|
10
|
-
//
|
|
9
|
+
// per-payment cap. An absent cap means no limit. A cap that cannot be compared to
|
|
10
|
+
// the quote — because it is in a different currency — fails closed: the payment is
|
|
11
|
+
// refused rather than slipped past by funding in a currency the cap can't measure.
|
|
11
12
|
export function withinLimits(status, quote) {
|
|
12
13
|
const cap = status.limits.perPayment;
|
|
13
14
|
if (!cap)
|
|
@@ -16,9 +17,15 @@ export function withinLimits(status, quote) {
|
|
|
16
17
|
return quote.srcAmount.cmp(cap) <= 0;
|
|
17
18
|
}
|
|
18
19
|
catch {
|
|
19
|
-
return
|
|
20
|
+
return false;
|
|
20
21
|
}
|
|
21
22
|
}
|
|
23
|
+
// strictSignerAuthorizer is the default: the signer must be the sender.
|
|
24
|
+
export const strictSignerAuthorizer = {
|
|
25
|
+
authorized(signerIdentity, senderRef) {
|
|
26
|
+
return signerIdentity === senderRef;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
22
29
|
export const allowRisk = {
|
|
23
30
|
evaluate() {
|
|
24
31
|
return { allow: true, reason: "" };
|
package/dist/src/crypto.js
CHANGED
|
@@ -25,7 +25,10 @@ export function ed25519Sign(privateKey, message) {
|
|
|
25
25
|
return new Uint8Array(nodeSign(null, Buffer.from(message), privateKey));
|
|
26
26
|
}
|
|
27
27
|
export function ed25519Verify(publicKeyRaw, message, signature) {
|
|
28
|
-
|
|
28
|
+
// A key or signature of the wrong length can never verify; reject it up front.
|
|
29
|
+
// The underlying verifier already rejects a non-canonical S, so a malleated
|
|
30
|
+
// signature never verifies.
|
|
31
|
+
if (publicKeyRaw.length !== 32 || signature.length !== 64) {
|
|
29
32
|
return false;
|
|
30
33
|
}
|
|
31
34
|
const spki = Buffer.concat([spkiPublicPrefix, Buffer.from(publicKeyRaw)]);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -11,6 +11,5 @@ export { type KycProvider, type KycStatus, type KycLimits, type RiskHook, type R
|
|
|
11
11
|
export { PolicyKind, type Policy, route, isExpired, NoQuoteError } from "./router.js";
|
|
12
12
|
export { MemoryLedger, eventReceipt, chainLeaf, IdempotencyConflictError, type Ledger, type LedgerEvent, type Transition, } from "./ledger.js";
|
|
13
13
|
export { Client, type ClientConfig, type IntentSpec, type Funding, type Clock, type IdGen, systemClock, randomId, recipientDestination, } from "./client.js";
|
|
14
|
-
export { FakeLeg, FakeRates, FakeVault } from "./fake.js";
|
|
15
14
|
export { WireKind, type WireMessage, encodeIntent, encodeQuote, encodeAuthorization, encodeSettlement, encodeMessage, decodeMessage, decodeIntent, decodeQuote, decodeAuthorization, decodeSettlement, } from "./wire.js";
|
|
16
15
|
export { PAYLOAD_VERSION, encodePayload, decodePayload, isPayload, NotPayloadError, UnsupportedPayloadVersionError, } from "./payload.js";
|
package/dist/src/index.js
CHANGED
|
@@ -11,6 +11,5 @@ export { permissiveKyc, allowRisk, withinLimits, } from "./compliance.js";
|
|
|
11
11
|
export { PolicyKind, route, isExpired, NoQuoteError } from "./router.js";
|
|
12
12
|
export { MemoryLedger, eventReceipt, chainLeaf, IdempotencyConflictError, } from "./ledger.js";
|
|
13
13
|
export { Client, systemClock, randomId, recipientDestination, } from "./client.js";
|
|
14
|
-
export { FakeLeg, FakeRates, FakeVault } from "./fake.js";
|
|
15
14
|
export { WireKind, encodeIntent, encodeQuote, encodeAuthorization, encodeSettlement, encodeMessage, decodeMessage, decodeIntent, decodeQuote, decodeAuthorization, decodeSettlement, } from "./wire.js";
|
|
16
15
|
export { PAYLOAD_VERSION, encodePayload, decodePayload, isPayload, NotPayloadError, UnsupportedPayloadVersionError, } from "./payload.js";
|
package/dist/src/ledger.d.ts
CHANGED
|
@@ -30,15 +30,19 @@ export interface Transition {
|
|
|
30
30
|
export declare class IdempotencyConflictError extends Error {
|
|
31
31
|
constructor();
|
|
32
32
|
}
|
|
33
|
+
export interface ApplyResult {
|
|
34
|
+
event: LedgerEvent;
|
|
35
|
+
created: boolean;
|
|
36
|
+
}
|
|
33
37
|
export interface Ledger {
|
|
34
|
-
apply(t: Transition):
|
|
38
|
+
apply(t: Transition): ApplyResult;
|
|
35
39
|
state(intentId: string): State;
|
|
36
40
|
events(intentId: string): LedgerEvent[];
|
|
37
41
|
head(intentId: string): Uint8Array | undefined;
|
|
38
42
|
}
|
|
39
43
|
export declare class MemoryLedger implements Ledger {
|
|
40
44
|
private readonly byIntent;
|
|
41
|
-
apply(t: Transition):
|
|
45
|
+
apply(t: Transition): ApplyResult;
|
|
42
46
|
state(intentId: string): State;
|
|
43
47
|
events(intentId: string): LedgerEvent[];
|
|
44
48
|
head(intentId: string): Uint8Array | undefined;
|
package/dist/src/ledger.js
CHANGED
|
@@ -19,7 +19,7 @@ export class MemoryLedger {
|
|
|
19
19
|
for (const e of history) {
|
|
20
20
|
if (e.state === t.to) {
|
|
21
21
|
if (bytesEqual(e.payloadHash, t.payloadHash))
|
|
22
|
-
return e;
|
|
22
|
+
return { event: e, created: false };
|
|
23
23
|
throw new IdempotencyConflictError();
|
|
24
24
|
}
|
|
25
25
|
}
|
|
@@ -46,7 +46,7 @@ export class MemoryLedger {
|
|
|
46
46
|
...(t.bridge ? { bridge: t.bridge } : {}),
|
|
47
47
|
};
|
|
48
48
|
this.byIntent.set(t.intentId, [...history, event]);
|
|
49
|
-
return event;
|
|
49
|
+
return { event, created: true };
|
|
50
50
|
}
|
|
51
51
|
state(intentId) {
|
|
52
52
|
const history = this.byIntent.get(intentId);
|
package/dist/src/leg.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface PayInLeg {
|
|
|
15
15
|
id: string;
|
|
16
16
|
payInCapabilities(): PayInCapabilities;
|
|
17
17
|
collect(intentId: string, quote: Quote, auth: Authorization, deliverTo: string): Promise<CollectResult>;
|
|
18
|
-
refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement>;
|
|
18
|
+
refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement>;
|
|
19
19
|
}
|
|
20
20
|
export interface PayInPreparation {
|
|
21
21
|
providerRef: string;
|
package/dist/src/message.d.ts
CHANGED
|
@@ -52,4 +52,4 @@ export interface Settlement {
|
|
|
52
52
|
settledAt: number;
|
|
53
53
|
}
|
|
54
54
|
export declare function receiptHash(authHash: Uint8Array, providerTxRef: string, state: State): Uint8Array;
|
|
55
|
-
export declare function corridorReceipt(authHash: Uint8Array, payInProviderRef: string, payOutProviderRef: string,
|
|
55
|
+
export declare function corridorReceipt(authHash: Uint8Array, payInProviderRef: string, payOutProviderRef: string, amount: Money, bridgeReceiptRef: string, state: State): Uint8Array;
|
package/dist/src/message.js
CHANGED
|
@@ -72,16 +72,19 @@ export function receiptHash(authHash, providerTxRef, state) {
|
|
|
72
72
|
.str(stateName[state])
|
|
73
73
|
.preimage());
|
|
74
74
|
}
|
|
75
|
-
// corridorReceipt binds both legs of a bridged corridor and the
|
|
76
|
-
// commitment, so the payer can prove they funded X and
|
|
77
|
-
// they received Y as a single transaction, without
|
|
78
|
-
|
|
75
|
+
// corridorReceipt binds both legs of a bridged corridor and the amount that
|
|
76
|
+
// actually moved into one commitment, so the payer can prove they funded X and
|
|
77
|
+
// the recipient can prove they received Y as a single transaction, without
|
|
78
|
+
// revealing anything else. The amount is the value the corridor really delivered
|
|
79
|
+
// on settle or returned on refund — not the quoted rate, which the conversion may
|
|
80
|
+
// not have matched — so the receipt attests the outcome, not the plan.
|
|
81
|
+
export function corridorReceipt(authHash, payInProviderRef, payOutProviderRef, amount, bridgeReceiptRef, state) {
|
|
79
82
|
return hashPreimage(new CanonicalWriter()
|
|
80
83
|
.str(domain("corridor"))
|
|
81
84
|
.bytes(authHash)
|
|
82
85
|
.str(payInProviderRef)
|
|
83
86
|
.str(payOutProviderRef)
|
|
84
|
-
.
|
|
87
|
+
.money(amount)
|
|
85
88
|
.str(bridgeReceiptRef)
|
|
86
89
|
.str(stateName[state])
|
|
87
90
|
.preimage());
|
package/dist/src/money.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export declare class Money {
|
|
|
3
3
|
readonly exponent: number;
|
|
4
4
|
private readonly amount;
|
|
5
5
|
private constructor();
|
|
6
|
+
static zero(): Money;
|
|
6
7
|
static create(currency: string, exponent: number, amount: bigint): Money;
|
|
7
8
|
static parse(currency: string, exponent: number, minor: string): Money;
|
|
8
9
|
minor(): string;
|
package/dist/src/money.js
CHANGED
|
@@ -13,6 +13,13 @@ export class Money {
|
|
|
13
13
|
this.exponent = exponent;
|
|
14
14
|
this.amount = amount;
|
|
15
15
|
}
|
|
16
|
+
// zero is the empty-currency, zero-amount sentinel used where a corridor moved
|
|
17
|
+
// no value — a failure receipt, or an absent bridge result. It is not a valid
|
|
18
|
+
// wire amount (an empty currency never parses), only an internal placeholder,
|
|
19
|
+
// and it encodes canonically as minor "0", currency "", exponent 0.
|
|
20
|
+
static zero() {
|
|
21
|
+
return new Money("", 0, 0n);
|
|
22
|
+
}
|
|
16
23
|
static create(currency, exponent, amount) {
|
|
17
24
|
if (!currencyPattern.test(currency)) {
|
|
18
25
|
throw new Error("pact: currency must match [A-Z0-9]{1,16}");
|
|
@@ -26,10 +33,18 @@ export class Money {
|
|
|
26
33
|
return new Money(currency, exponent, amount);
|
|
27
34
|
}
|
|
28
35
|
// parse builds Money from a decimal-ASCII minor-unit string, the form used on
|
|
29
|
-
// the wire.
|
|
36
|
+
// the wire. The grammar is a bare non-negative integer — no sign, no radix
|
|
37
|
+
// prefix, no whitespace, no leading zeros — pinned identically across every SDK
|
|
38
|
+
// so two participants never disagree on whether a message is valid or on the
|
|
39
|
+
// value it decodes to.
|
|
30
40
|
static parse(currency, exponent, minor) {
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
// Bound the length so an untrusted string can't force a huge bigint parse
|
|
42
|
+
// before capability limits ever see it; eighty digits is far past any amount.
|
|
43
|
+
if (minor.length > 80) {
|
|
44
|
+
throw new Error("pact: amount has more than 80 digits");
|
|
45
|
+
}
|
|
46
|
+
if (!/^(0|[1-9][0-9]*)$/.test(minor)) {
|
|
47
|
+
throw new Error(`pact: ${minor} is not a canonical base-10 integer`);
|
|
33
48
|
}
|
|
34
49
|
return Money.create(currency, exponent, BigInt(minor));
|
|
35
50
|
}
|
package/dist/src/protocol.d.ts
CHANGED
package/dist/src/protocol.js
CHANGED
|
@@ -10,6 +10,14 @@ const domainPrefix = `${ID}/${VERSION}/`;
|
|
|
10
10
|
export function domain(kind) {
|
|
11
11
|
return domainPrefix + kind;
|
|
12
12
|
}
|
|
13
|
+
// idempotencyKey binds a provider mutation to one protocol step so a retry reuses
|
|
14
|
+
// the same key and the provider deduplicates it into a single side effect. ref is
|
|
15
|
+
// the stable reference the step acts on (the intent id, or a provider object id);
|
|
16
|
+
// step names the operation. Every adapter derives its provider idempotency key
|
|
17
|
+
// this way, so the same (ref, step) always maps to the same key.
|
|
18
|
+
export function idempotencyKey(ref, step) {
|
|
19
|
+
return `${ID}:${ref}:${step}`;
|
|
20
|
+
}
|
|
13
21
|
// DEFAULT_SKEW_MILLIS is the tolerated clock difference when deciding whether an
|
|
14
22
|
// intent or quote has expired.
|
|
15
23
|
export const DEFAULT_SKEW_MILLIS = 120_000;
|
package/dist/src/router.d.ts
CHANGED
|
@@ -13,4 +13,6 @@ export declare class NoQuoteError extends Error {
|
|
|
13
13
|
constructor(reason: string);
|
|
14
14
|
}
|
|
15
15
|
export declare function isExpired(deadline: number, now: number, skew: number): boolean;
|
|
16
|
+
export declare const maxQuotes = 256;
|
|
17
|
+
export declare const maxFundingOptions = 64;
|
|
16
18
|
export declare function route(quotes: Quote[], policy: Policy, kyc: KycStatus, now: number, skew: number): Quote;
|