@myzonerocks/pact 0.1.3 → 0.1.6
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/adapter.d.ts +1 -1
- package/dist/src/adapters/erc20.d.ts +30 -4
- package/dist/src/adapters/erc20.js +105 -50
- package/dist/src/adapters/http.d.ts +2 -0
- package/dist/src/adapters/http.js +12 -0
- package/dist/src/adapters/mpesa.d.ts +30 -4
- package/dist/src/adapters/mpesa.js +106 -22
- package/dist/src/adapters/paypal.d.ts +23 -2
- package/dist/src/adapters/paypal.js +91 -29
- 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 +10 -2
- package/dist/src/client.js +215 -30
- 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/state.js +3 -1
- 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 +150 -51
- package/src/adapters/http.ts +14 -0
- package/src/adapters/mpesa.ts +148 -28
- package/src/adapters/paypal.ts +138 -28
- package/src/adapters/stripe.ts +16 -13
- package/src/bridge.ts +12 -5
- package/src/canonical.ts +5 -0
- package/src/client.ts +228 -33
- 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/state.ts +3 -1
- package/src/wire.ts +20 -3
- package/src/fake.ts +0 -96
package/src/adapters/stripe.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { Money } from "../money.js";
|
|
|
10
10
|
import { State } from "../state.js";
|
|
11
11
|
import type { Quote, Authorization, Settlement } from "../message.js";
|
|
12
12
|
import { RefundKind, type AdapterEvent } from "../adapter.js";
|
|
13
|
+
import { idempotencyKey } from "../protocol.js";
|
|
14
|
+
import { fetchWithTimeout } from "./http.js";
|
|
13
15
|
import type { InteractivePayInLeg, PayInCapabilities, CollectResult, PayInPreparation } from "../leg.js";
|
|
14
16
|
|
|
15
17
|
// The public base of the Stripe REST API. It is the same for every integration
|
|
@@ -29,7 +31,6 @@ const metadataIntentKey = "pact_intent_id";
|
|
|
29
31
|
const defaultToleranceSeconds = 300;
|
|
30
32
|
|
|
31
33
|
// The protocol identifier used to bind an idempotency key to one protocol step.
|
|
32
|
-
const idPrefix = "pact";
|
|
33
34
|
|
|
34
35
|
// PaymentIntent is the subset of Stripe's PaymentIntent this leg reads.
|
|
35
36
|
// clientSecret is present only on a freshly created intent and is the single
|
|
@@ -108,9 +109,8 @@ export interface StripeConfig {
|
|
|
108
109
|
currencies: string[];
|
|
109
110
|
api: StripeApi;
|
|
110
111
|
webhookKey: string;
|
|
111
|
-
// clock returns the current time in Unix seconds, for webhook timestamp
|
|
112
|
-
|
|
113
|
-
clock?: () => number;
|
|
112
|
+
// clock returns the current time in Unix seconds, for webhook timestamp checks.
|
|
113
|
+
clock: () => number;
|
|
114
114
|
// tolerance is the webhook timestamp tolerance in seconds; defaults to 300.
|
|
115
115
|
tolerance?: number;
|
|
116
116
|
ids: () => string;
|
|
@@ -136,11 +136,16 @@ export class StripeLeg implements InteractivePayInLeg {
|
|
|
136
136
|
if (!cfg.webhookKey) {
|
|
137
137
|
throw new Error("stripe: config requires a webhook signing key");
|
|
138
138
|
}
|
|
139
|
+
// Without a clock every webhook timestamp reads as far in the past and
|
|
140
|
+
// silently fails the tolerance check, so a real webhook never verifies.
|
|
141
|
+
if (!cfg.clock) {
|
|
142
|
+
throw new Error("stripe: config requires a clock for webhook timestamp checks");
|
|
143
|
+
}
|
|
139
144
|
this.id = cfg.id ?? "stripe";
|
|
140
145
|
this.currencies = cfg.currencies;
|
|
141
146
|
this.api = cfg.api;
|
|
142
147
|
this.webhookKey = cfg.webhookKey;
|
|
143
|
-
this.clock = cfg.clock
|
|
148
|
+
this.clock = cfg.clock;
|
|
144
149
|
this.tolerance = cfg.tolerance ?? defaultToleranceSeconds;
|
|
145
150
|
this.ids = cfg.ids;
|
|
146
151
|
this.methods = cfg.methods;
|
|
@@ -204,13 +209,16 @@ export class StripeLeg implements InteractivePayInLeg {
|
|
|
204
209
|
// refundIn reverses a captured payment, in full or in part. Stripe supports
|
|
205
210
|
// both, so the leg accepts the full and partial refund kinds and rejects a
|
|
206
211
|
// counter-transfer it cannot express.
|
|
207
|
-
async refundIn(intentId: string, kind: RefundKind, reason: string): Promise<Settlement> {
|
|
212
|
+
async refundIn(intentId: string, kind: RefundKind, amount: Money, reason: string): Promise<Settlement> {
|
|
208
213
|
if (kind !== RefundKind.Full && kind !== RefundKind.Partial) {
|
|
209
214
|
throw new Error(`stripe: cannot perform refund kind ${kind}`);
|
|
210
215
|
}
|
|
211
216
|
const pi = await this.api.findPaymentIntent(intentId);
|
|
217
|
+
// Stripe refunds the full capture when no amount is set, so a full refund
|
|
218
|
+
// leaves amount zero and a partial one carries the exact minor units to return.
|
|
219
|
+
const minor = kind === RefundKind.Partial ? minorToInteger(amount) : 0;
|
|
212
220
|
const refund = await this.api.createRefund(
|
|
213
|
-
{ paymentIntentId: pi.id, amount:
|
|
221
|
+
{ paymentIntentId: pi.id, amount: minor, reason },
|
|
214
222
|
idempotencyKey(intentId, "refund"),
|
|
215
223
|
);
|
|
216
224
|
return {
|
|
@@ -249,11 +257,6 @@ function signatureHeader(headers: Record<string, string[]>): string {
|
|
|
249
257
|
return "";
|
|
250
258
|
}
|
|
251
259
|
|
|
252
|
-
// idempotencyKey binds a Stripe mutation to one protocol step so a retry reuses
|
|
253
|
-
// the original result instead of acting twice.
|
|
254
|
-
function idempotencyKey(ref: string, step: string): string {
|
|
255
|
-
return `${idPrefix}:${ref}:${step}`;
|
|
256
|
-
}
|
|
257
260
|
|
|
258
261
|
// minorToInteger narrows a Money amount to the safe integer Stripe expects,
|
|
259
262
|
// refusing anything that would overflow. Fiat amounts fit comfortably; the guard
|
|
@@ -484,7 +487,7 @@ class HttpStripeApi implements StripeApi {
|
|
|
484
487
|
Authorization: `Bearer ${this.secretKey}`,
|
|
485
488
|
"Stripe-Version": apiVersion,
|
|
486
489
|
};
|
|
487
|
-
const resp = await
|
|
490
|
+
const resp = await fetchWithTimeout(this.baseURL + path, { ...init, headers });
|
|
488
491
|
const body = await resp.text();
|
|
489
492
|
if (resp.status >= 300) {
|
|
490
493
|
throw new Error(`stripe: ${path} returned ${resp.status}: ${stripeErrorMessage(body)}`);
|
package/src/bridge.ts
CHANGED
|
@@ -131,14 +131,21 @@ export function applyInverseRate(dst: Money, rate: string, srcCurrency: string,
|
|
|
131
131
|
// parseRate reads a decimal rate like "129.45" into a numerator and denominator,
|
|
132
132
|
// so the conversion stays exact integer arithmetic with no floating point.
|
|
133
133
|
export function parseRate(rate: string): [bigint, bigint] {
|
|
134
|
+
// Bound the length so an untrusted rate can't force a huge bigint parse and
|
|
135
|
+
// exponentiation in the FX math.
|
|
136
|
+
if (rate.length > 80) {
|
|
137
|
+
throw new Error("pact: rate has more than 80 characters");
|
|
138
|
+
}
|
|
139
|
+
// A non-negative integer part with no leading zeros and an optional fractional
|
|
140
|
+
// part; no sign, no radix prefix, no whitespace, matching the amount grammar so
|
|
141
|
+
// the SDKs never disagree on a rate's validity or value.
|
|
142
|
+
if (!/^(0|[1-9][0-9]*)(\.[0-9]+)?$/.test(rate)) {
|
|
143
|
+
throw new Error(`pact: ${JSON.stringify(rate)} is not a canonical decimal rate`);
|
|
144
|
+
}
|
|
134
145
|
const dot = rate.indexOf(".");
|
|
135
146
|
const whole = dot < 0 ? rate : rate.slice(0, dot);
|
|
136
147
|
const frac = dot < 0 ? "" : rate.slice(dot + 1);
|
|
137
|
-
const
|
|
138
|
-
if (!/^\d+$/.test(digits)) {
|
|
139
|
-
throw new Error(`pact: ${JSON.stringify(rate)} is not a decimal rate`);
|
|
140
|
-
}
|
|
141
|
-
const num = BigInt(digits);
|
|
148
|
+
const num = BigInt(whole + frac);
|
|
142
149
|
const den = frac.length > 0 ? pow10(frac.length) : 1n;
|
|
143
150
|
return [num, den];
|
|
144
151
|
}
|
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) {
|
|
@@ -145,6 +164,9 @@ export class Client {
|
|
|
145
164
|
continue;
|
|
146
165
|
}
|
|
147
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
|
|
148
170
|
quotes.push(await this.composeQuote(intent, payInLeg, f));
|
|
149
171
|
} catch (err) {
|
|
150
172
|
lastErr = err;
|
|
@@ -242,11 +264,17 @@ export class Client {
|
|
|
242
264
|
if (isExpired(quote.expiresAt, now, this.skew)) {
|
|
243
265
|
throw new Error("pact: quote has expired");
|
|
244
266
|
}
|
|
245
|
-
|
|
267
|
+
// The signer must be allowed to act for the sender, and compliance is measured
|
|
268
|
+
// against the sender — the party actually funding the payment — not whoever
|
|
269
|
+
// holds the signing key.
|
|
270
|
+
if (!this.signerAuth.authorized(signer.identity(), intent.senderRef)) {
|
|
271
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
272
|
+
}
|
|
273
|
+
const status = await this.kyc.status(intent.senderRef);
|
|
246
274
|
if (!withinLimits(status, quote)) {
|
|
247
275
|
throw new Error("pact: quote exceeds KYC limit");
|
|
248
276
|
}
|
|
249
|
-
const decision = this.risk.evaluate(
|
|
277
|
+
const decision = this.risk.evaluate(intent.senderRef, intent, quote);
|
|
250
278
|
if (!decision.allow) {
|
|
251
279
|
throw new Error(`pact: risk hook vetoed authorization: ${decision.reason}`);
|
|
252
280
|
}
|
|
@@ -264,12 +292,33 @@ export class Client {
|
|
|
264
292
|
// driven forward by advance as provider events arrive, so no promise or
|
|
265
293
|
// connection is held open per payment and durable state lives only in the
|
|
266
294
|
// ledger.
|
|
267
|
-
|
|
268
|
-
|
|
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 {
|
|
298
|
+
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
299
|
+
throw new Error("pact: intent has expired");
|
|
300
|
+
}
|
|
269
301
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
270
302
|
if (!payInLeg) {
|
|
271
303
|
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
272
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);
|
|
314
|
+
|
|
315
|
+
// A retry of an already-submitted corridor must not collect a second time. The
|
|
316
|
+
// pay-in leg carries its own provider idempotency for the narrow window where a
|
|
317
|
+
// collection completed but its record was lost.
|
|
318
|
+
const submitted = this.ledger.state(intent.id);
|
|
319
|
+
if (submitted === State.Submitted || submitted === State.Collecting) {
|
|
320
|
+
return submitted;
|
|
321
|
+
}
|
|
273
322
|
|
|
274
323
|
// A direct corridor collects straight to the recipient; a bridged one
|
|
275
324
|
// collects into escrow first.
|
|
@@ -293,13 +342,13 @@ export class Client {
|
|
|
293
342
|
// server-side into escrow, and the leg must offer interactive collection.
|
|
294
343
|
async interactiveInitiate(intent: Intent, quote: Quote, auth: Authorization): Promise<{ preparation: PayInPreparation; state: State }> {
|
|
295
344
|
verifyAuthorization(auth, intent, quote, this.verifier);
|
|
345
|
+
if (!this.signerAuth.authorized(auth.signerIdentity, intent.senderRef)) {
|
|
346
|
+
throw new Error("pact: signer is not authorized to act for the intent's sender");
|
|
347
|
+
}
|
|
296
348
|
if (!isDirect(quote)) {
|
|
297
349
|
throw new Error("pact: interactive pay-in is only available on a direct corridor");
|
|
298
350
|
}
|
|
299
|
-
const payInLeg = this.
|
|
300
|
-
if (!payInLeg) {
|
|
301
|
-
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
302
|
-
}
|
|
351
|
+
const payInLeg = this.resolvePayIn(intent, quote);
|
|
303
352
|
if (!isInteractivePayInLeg(payInLeg)) {
|
|
304
353
|
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} does not offer interactive collection`);
|
|
305
354
|
}
|
|
@@ -333,14 +382,14 @@ export class Client {
|
|
|
333
382
|
// A direct corridor's pay-in confirms and it settles.
|
|
334
383
|
if (fromPayIn && current === State.Submitted) {
|
|
335
384
|
if (failed) return this.recordFailure(intentId, authHash, quote, event.reason);
|
|
336
|
-
const
|
|
385
|
+
const collected = collectResult(events);
|
|
337
386
|
const settlement: Settlement = {
|
|
338
387
|
intentId,
|
|
339
388
|
state: State.Settled,
|
|
340
389
|
adapterId: quote.payInAdapterId,
|
|
341
|
-
providerTxRef:
|
|
390
|
+
providerTxRef: collected.providerRef,
|
|
342
391
|
onchainTxHash: "",
|
|
343
|
-
receiptHash: corridorReceipt(authHash,
|
|
392
|
+
receiptHash: corridorReceipt(authHash, collected.providerRef, collected.providerRef, collected.received, "", State.Settled),
|
|
344
393
|
reason: "",
|
|
345
394
|
settledAt: this.clock(),
|
|
346
395
|
};
|
|
@@ -384,11 +433,32 @@ export class Client {
|
|
|
384
433
|
throw new Error(`pact: no bridge ${JSON.stringify(quote.bridgeId)}`);
|
|
385
434
|
}
|
|
386
435
|
const collected = collectResult(events);
|
|
387
|
-
|
|
436
|
+
// Claim the escrow hold before converting or disbursing. If a concurrent
|
|
437
|
+
// advance already claimed it, this call did not win, so abort rather than run
|
|
438
|
+
// the conversion and pay-out a second time.
|
|
439
|
+
const { created } = this.ledger.apply({
|
|
388
440
|
intentId: intent.id,
|
|
389
441
|
to: State.Held,
|
|
390
442
|
payloadHash: hashString(domain("held"), collected.providerRef),
|
|
391
443
|
});
|
|
444
|
+
if (!created) {
|
|
445
|
+
return { settlement: lastSettlement(events), state: this.ledger.state(intent.id) };
|
|
446
|
+
}
|
|
447
|
+
// An expired corridor must not convert at a stale rate; unwind the collection
|
|
448
|
+
// to a refund instead of driving it forward past its deadline.
|
|
449
|
+
if (isExpired(intent.expiresAt, this.clock(), this.skew)) {
|
|
450
|
+
return this.unwind(intent.id, quote, authHash, "intent expired before conversion");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// The pay-in must have collected at least the source net of fees; a short
|
|
454
|
+
// collection unwinds rather than disbursing the full quote against it.
|
|
455
|
+
try {
|
|
456
|
+
if (collected.received.cmp(quote.srcAmount.sub(quote.fees)) < 0) {
|
|
457
|
+
return this.unwind(intent.id, quote, authHash, "bridged corridor collected less than the quoted source amount");
|
|
458
|
+
}
|
|
459
|
+
} catch {
|
|
460
|
+
return this.unwind(intent.id, quote, authHash, "bridged corridor collected in an unexpected currency");
|
|
461
|
+
}
|
|
392
462
|
|
|
393
463
|
let converted: ConvertResult;
|
|
394
464
|
try {
|
|
@@ -396,6 +466,16 @@ export class Client {
|
|
|
396
466
|
} catch (err) {
|
|
397
467
|
return this.unwind(intent.id, quote, authHash, reasonOf(err));
|
|
398
468
|
}
|
|
469
|
+
// Value must be conserved across the escrow: the conversion has to deliver at
|
|
470
|
+
// least the quoted destination amount before the pay-out is sent. A moved rate
|
|
471
|
+
// or a short conversion unwinds to a refund rather than draining escrow.
|
|
472
|
+
try {
|
|
473
|
+
if (converted.delivered.cmp(quote.dstAmount) < 0) {
|
|
474
|
+
return this.unwind(intent.id, quote, authHash, "bridged conversion delivered less than the quoted destination amount");
|
|
475
|
+
}
|
|
476
|
+
} catch {
|
|
477
|
+
return this.unwind(intent.id, quote, authHash, "bridged conversion delivered in an unexpected currency");
|
|
478
|
+
}
|
|
399
479
|
let disbursed;
|
|
400
480
|
try {
|
|
401
481
|
disbursed = await payOutLeg.disburse(intent.id, quote, recipientDestination(intent));
|
|
@@ -431,20 +511,34 @@ export class Client {
|
|
|
431
511
|
events: LedgerEvent[],
|
|
432
512
|
): { settlement: Settlement; state: State } {
|
|
433
513
|
const inRef = payInRef(events);
|
|
434
|
-
const { payOut: outRef, bridge: bridgeRef } = disburseRefs(events);
|
|
514
|
+
const { payOut: outRef, bridge: bridgeRef, delivered } = disburseRefs(events);
|
|
435
515
|
const settlement: Settlement = {
|
|
436
516
|
intentId,
|
|
437
517
|
state: State.Settled,
|
|
438
518
|
adapterId: quote.payOutAdapterId,
|
|
439
519
|
providerTxRef: outRef,
|
|
440
520
|
onchainTxHash: "",
|
|
441
|
-
receiptHash: corridorReceipt(authHash, inRef, outRef,
|
|
521
|
+
receiptHash: corridorReceipt(authHash, inRef, outRef, delivered, bridgeRef, State.Settled),
|
|
442
522
|
reason: "",
|
|
443
523
|
settledAt: this.clock(),
|
|
444
524
|
};
|
|
445
525
|
return this.finishAdvance(intentId, State.Settled, settlement);
|
|
446
526
|
}
|
|
447
527
|
|
|
528
|
+
// claimRefund reserves the refunding step before any money moves. It returns the
|
|
529
|
+
// recorded outcome to echo when another refund already claimed the step, or null when
|
|
530
|
+
// this call won it and should proceed to move funds. Both refund paths go through it,
|
|
531
|
+
// so a refund is issued at most once however the calls race.
|
|
532
|
+
private claimRefund(intentId: string, reason: string): { settlement: Settlement; state: State } | null {
|
|
533
|
+
const { created } = this.ledger.apply({
|
|
534
|
+
intentId,
|
|
535
|
+
to: State.Refunding,
|
|
536
|
+
payloadHash: hashString(domain("refunding"), reason),
|
|
537
|
+
});
|
|
538
|
+
if (created) return null;
|
|
539
|
+
return { settlement: lastSettlement(this.ledger.events(intentId)), state: this.ledger.state(intentId) };
|
|
540
|
+
}
|
|
541
|
+
|
|
448
542
|
// unwind refunds the payer from escrow when a bridged corridor cannot complete
|
|
449
543
|
// its pay-out, moving to refunding then refunded.
|
|
450
544
|
private async unwind(
|
|
@@ -453,22 +547,91 @@ export class Client {
|
|
|
453
547
|
authHash: Uint8Array,
|
|
454
548
|
reason: string,
|
|
455
549
|
): Promise<{ settlement: Settlement; state: State }> {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
550
|
+
// Claim the refund before moving money. A duplicate pay-out-failed webhook enters
|
|
551
|
+
// unwind twice; only the call that wins the refunding step may issue the counter-
|
|
552
|
+
// transfer, so the refund cannot fire a second time.
|
|
553
|
+
const echo = this.claimRefund(intentId, reason);
|
|
554
|
+
if (echo) return echo;
|
|
461
555
|
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
462
556
|
if (payInLeg) {
|
|
463
|
-
|
|
557
|
+
const kind = payInLeg.payInCapabilities().refunds;
|
|
558
|
+
// A pay-in rail that cannot refund in place leaves the corridor refunding
|
|
559
|
+
// for the operator to complete out of band, rather than recording a refund
|
|
560
|
+
// that never moved funds.
|
|
561
|
+
if (kind === RefundKind.None) {
|
|
562
|
+
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot refund automatically; manual return required: ${reason}`);
|
|
563
|
+
}
|
|
564
|
+
await payInLeg.refundIn(intentId, kind, quote.srcAmount, reason);
|
|
565
|
+
}
|
|
566
|
+
// The escrow unwind returns the whole collection, so the receipt attests the
|
|
567
|
+
// source amount the payer funded as the amount refunded.
|
|
568
|
+
const settlement: Settlement = {
|
|
569
|
+
intentId,
|
|
570
|
+
state: State.Refunded,
|
|
571
|
+
adapterId: quote.payInAdapterId,
|
|
572
|
+
providerTxRef: "",
|
|
573
|
+
onchainTxHash: "",
|
|
574
|
+
receiptHash: corridorReceipt(authHash, "", "", quote.srcAmount, "", State.Refunded),
|
|
575
|
+
reason,
|
|
576
|
+
settledAt: this.clock(),
|
|
577
|
+
};
|
|
578
|
+
return this.finishAdvance(intentId, State.Refunded, settlement);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// refund returns funds to the payer for a settled intent. The amount is
|
|
582
|
+
// explicit, so a host can issue a partial refund, and it must be in the source
|
|
583
|
+
// currency and no greater than what the payer funded. The pay-in leg must
|
|
584
|
+
// advertise a refund it can honour — and a partial one for a partial amount.
|
|
585
|
+
// Like every advance it is keyed on the target state, so a second refund for the
|
|
586
|
+
// same intent is a no-op.
|
|
587
|
+
async refund(intentId: string, amount: Money, reason: string): Promise<{ settlement: Settlement; state: State }> {
|
|
588
|
+
const events = this.ledger.events(intentId);
|
|
589
|
+
const replayed = replay(events);
|
|
590
|
+
if (!replayed) {
|
|
591
|
+
throw new Error("pact: intent has no recorded authorization to refund");
|
|
464
592
|
}
|
|
593
|
+
const { intent, quote, authorization: auth } = replayed;
|
|
594
|
+
const current = this.ledger.state(intentId);
|
|
595
|
+
// A refund is single-occurrence: once refunded, a repeat is an idempotent
|
|
596
|
+
// no-op that echoes the outcome rather than moving funds again.
|
|
597
|
+
if (current === State.Refunded) {
|
|
598
|
+
return { settlement: lastSettlement(events), state: current };
|
|
599
|
+
}
|
|
600
|
+
if (current !== State.Settled) {
|
|
601
|
+
throw new Error("pact: only a settled intent can be refunded");
|
|
602
|
+
}
|
|
603
|
+
if (amount.currency !== quote.srcAmount.currency || amount.exponent !== quote.srcAmount.exponent) {
|
|
604
|
+
throw new Error("pact: refund must be in the funded source currency");
|
|
605
|
+
}
|
|
606
|
+
if (amount.value() <= 0n) {
|
|
607
|
+
throw new Error("pact: refund amount must be positive");
|
|
608
|
+
}
|
|
609
|
+
const over = amount.cmp(quote.srcAmount);
|
|
610
|
+
if (over > 0) {
|
|
611
|
+
throw new Error("pact: refund exceeds the amount funded");
|
|
612
|
+
}
|
|
613
|
+
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
614
|
+
if (!payInLeg) {
|
|
615
|
+
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
616
|
+
}
|
|
617
|
+
const kind = refundKindFor(payInLeg.payInCapabilities().refunds, over === 0);
|
|
618
|
+
if (kind === undefined) {
|
|
619
|
+
throw new Error(`pact: pay-in leg ${JSON.stringify(quote.payInAdapterId)} cannot perform this refund`);
|
|
620
|
+
}
|
|
621
|
+
const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
|
|
622
|
+
// Claim the refunding step before moving money. Two overlapping refund calls both
|
|
623
|
+
// pass the settled check above; only the one that wins this step issues the return,
|
|
624
|
+
// so the payer is never refunded twice.
|
|
625
|
+
const echo = this.claimRefund(intentId, reason);
|
|
626
|
+
if (echo) return echo;
|
|
627
|
+
await payInLeg.refundIn(intentId, kind, amount, reason);
|
|
465
628
|
const settlement: Settlement = {
|
|
466
629
|
intentId,
|
|
467
630
|
state: State.Refunded,
|
|
468
631
|
adapterId: quote.payInAdapterId,
|
|
469
632
|
providerTxRef: "",
|
|
470
633
|
onchainTxHash: "",
|
|
471
|
-
receiptHash: corridorReceipt(authHash, "", "",
|
|
634
|
+
receiptHash: corridorReceipt(authHash, "", "", amount, "", State.Refunded),
|
|
472
635
|
reason,
|
|
473
636
|
settledAt: this.clock(),
|
|
474
637
|
};
|
|
@@ -488,7 +651,9 @@ export class Client {
|
|
|
488
651
|
adapterId: quote.payInAdapterId,
|
|
489
652
|
providerTxRef: "",
|
|
490
653
|
onchainTxHash: "",
|
|
491
|
-
|
|
654
|
+
// A pay-in that failed before escrow moved no value, so the receipt binds a
|
|
655
|
+
// zero amount alongside the failure.
|
|
656
|
+
receiptHash: corridorReceipt(authHash, "", "", Money.zero(), "", State.Failed),
|
|
492
657
|
reason,
|
|
493
658
|
settledAt: this.clock(),
|
|
494
659
|
};
|
|
@@ -501,13 +666,22 @@ export class Client {
|
|
|
501
666
|
}
|
|
502
667
|
|
|
503
668
|
private finish(intentId: string, state: State, settlement: Settlement): Settlement {
|
|
504
|
-
const event = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
|
|
669
|
+
const { event } = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
|
|
505
670
|
this.notify(event);
|
|
506
671
|
return settlement;
|
|
507
672
|
}
|
|
508
673
|
|
|
509
|
-
// expire records that an intent's deadline passed before it settled.
|
|
674
|
+
// expire records that an intent's deadline passed before it settled. It refuses
|
|
675
|
+
// to expire an intent whose deadline has not actually passed, so a caller cannot
|
|
676
|
+
// force a live payment to a terminal expired state.
|
|
510
677
|
expire(intentId: string): void {
|
|
678
|
+
const recorded = this.ledger.events(intentId).find((e) => e.intent)?.intent;
|
|
679
|
+
if (!recorded) {
|
|
680
|
+
throw new Error("pact: intent has no recorded history to expire");
|
|
681
|
+
}
|
|
682
|
+
if (!isExpired(recorded.expiresAt, this.clock(), this.skew)) {
|
|
683
|
+
throw new Error("pact: intent has not reached its deadline");
|
|
684
|
+
}
|
|
511
685
|
this.ledger.apply({
|
|
512
686
|
intentId,
|
|
513
687
|
to: State.Expired,
|
|
@@ -565,25 +739,46 @@ function collectResult(events: LedgerEvent[]): CollectResult {
|
|
|
565
739
|
for (const e of events) {
|
|
566
740
|
if (e.collect) return e.collect;
|
|
567
741
|
}
|
|
568
|
-
return { providerRef: "", received: Money.
|
|
742
|
+
return { providerRef: "", received: Money.zero() };
|
|
569
743
|
}
|
|
570
744
|
|
|
571
745
|
function payInRef(events: LedgerEvent[]): string {
|
|
572
746
|
return collectResult(events).providerRef;
|
|
573
747
|
}
|
|
574
748
|
|
|
575
|
-
// disburseRefs returns the pay-out provider reference
|
|
576
|
-
// reference recorded on the
|
|
577
|
-
|
|
749
|
+
// disburseRefs returns the pay-out provider reference, the bridge receipt
|
|
750
|
+
// reference, and the amount the bridge actually delivered, all recorded on the
|
|
751
|
+
// disbursing step.
|
|
752
|
+
function disburseRefs(events: LedgerEvent[]): { payOut: string; bridge: string; delivered: Money } {
|
|
578
753
|
let payOut = "";
|
|
579
754
|
let bridge = "";
|
|
755
|
+
let delivered = Money.zero();
|
|
580
756
|
for (const e of events) {
|
|
581
757
|
if (e.state === State.Disbursing) {
|
|
582
758
|
if (e.settlement) payOut = e.settlement.providerTxRef;
|
|
583
|
-
if (e.bridge)
|
|
759
|
+
if (e.bridge) {
|
|
760
|
+
bridge = e.bridge.receiptRef;
|
|
761
|
+
delivered = e.bridge.delivered;
|
|
762
|
+
}
|
|
584
763
|
}
|
|
585
764
|
}
|
|
586
|
-
return { payOut, bridge };
|
|
765
|
+
return { payOut, bridge, delivered };
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// refundKindFor maps a pay-in leg's advertised refund capability and whether the
|
|
769
|
+
// refund is for the full funded amount to the kind the leg is handed, returning
|
|
770
|
+
// undefined for a refund the rail cannot express.
|
|
771
|
+
function refundKindFor(capability: RefundKind, full: boolean): RefundKind | undefined {
|
|
772
|
+
switch (capability) {
|
|
773
|
+
case RefundKind.CounterTransfer:
|
|
774
|
+
return RefundKind.CounterTransfer;
|
|
775
|
+
case RefundKind.Partial:
|
|
776
|
+
return full ? RefundKind.Full : RefundKind.Partial;
|
|
777
|
+
case RefundKind.Full:
|
|
778
|
+
return full ? RefundKind.Full : undefined;
|
|
779
|
+
default:
|
|
780
|
+
return undefined;
|
|
781
|
+
}
|
|
587
782
|
}
|
|
588
783
|
|
|
589
784
|
// 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)]);
|