@myzonerocks/pact 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/adapter.d.ts +18 -0
- package/dist/src/adapter.js +11 -0
- package/dist/src/adapters/erc20.d.ts +43 -0
- package/dist/src/adapters/erc20.js +126 -0
- package/dist/src/adapters/mpesa.d.ts +61 -0
- package/dist/src/adapters/mpesa.js +272 -0
- package/dist/src/adapters/paypal.d.ts +68 -0
- package/dist/src/adapters/paypal.js +279 -0
- package/dist/src/adapters/stripe.d.ts +61 -0
- package/dist/src/adapters/stripe.js +336 -0
- package/dist/src/bridge.d.ts +40 -0
- package/dist/src/bridge.js +99 -0
- package/dist/src/canonical.d.ts +14 -0
- package/dist/src/canonical.js +82 -0
- package/dist/src/client.d.ts +84 -0
- package/dist/src/client.js +510 -0
- package/dist/src/compliance.d.ts +23 -0
- package/dist/src/compliance.js +26 -0
- package/dist/src/crypto.d.ts +12 -0
- package/dist/src/crypto.js +50 -0
- package/dist/src/fake.d.ts +29 -0
- package/dist/src/fake.js +79 -0
- package/dist/src/index.d.ts +16 -0
- package/dist/src/index.js +16 -0
- package/dist/src/ledger.d.ts +47 -0
- package/dist/src/ledger.js +84 -0
- package/dist/src/leg.d.ts +34 -0
- package/dist/src/leg.js +4 -0
- package/dist/src/message.d.ts +55 -0
- package/dist/src/message.js +88 -0
- package/dist/src/money.d.ts +16 -0
- package/dist/src/money.js +81 -0
- package/dist/src/payload.d.ts +11 -0
- package/dist/src/payload.js +52 -0
- package/dist/src/policy.d.ts +26 -0
- package/dist/src/policy.js +83 -0
- package/dist/src/protocol.d.ts +4 -0
- package/dist/src/protocol.js +15 -0
- package/dist/src/router.d.ts +16 -0
- package/dist/src/router.js +87 -0
- package/dist/src/signing.d.ts +27 -0
- package/dist/src/signing.js +72 -0
- package/dist/src/state.d.ts +23 -0
- package/dist/src/state.js +76 -0
- package/dist/src/wire.d.ts +30 -0
- package/dist/src/wire.js +221 -0
- package/dist/test/erc20.test.d.ts +1 -0
- package/dist/test/erc20.test.js +131 -0
- package/dist/test/lifecycle.test.d.ts +1 -0
- package/dist/test/lifecycle.test.js +212 -0
- package/dist/test/mpesa.test.d.ts +1 -0
- package/dist/test/mpesa.test.js +180 -0
- package/dist/test/payload.test.d.ts +1 -0
- package/dist/test/payload.test.js +32 -0
- package/dist/test/paypal.test.d.ts +1 -0
- package/dist/test/paypal.test.js +140 -0
- package/dist/test/policy.test.d.ts +1 -0
- package/dist/test/policy.test.js +131 -0
- package/dist/test/stripe.test.d.ts +1 -0
- package/dist/test/stripe.test.js +176 -0
- package/dist/test/vectors.test.d.ts +1 -0
- package/dist/test/vectors.test.js +91 -0
- package/dist/test/wire.test.d.ts +1 -0
- package/dist/test/wire.test.js +104 -0
- package/package.json +50 -0
- package/src/adapter.ts +32 -0
- package/src/adapters/erc20.ts +181 -0
- package/src/adapters/mpesa.ts +408 -0
- package/src/adapters/paypal.ts +409 -0
- package/src/adapters/stripe.ts +478 -0
- package/src/bridge.ts +148 -0
- package/src/canonical.ts +94 -0
- package/src/client.ts +605 -0
- package/src/compliance.ts +65 -0
- package/src/crypto.ts +68 -0
- package/src/fake.ts +96 -0
- package/src/index.ts +106 -0
- package/src/ledger.ts +145 -0
- package/src/leg.ts +65 -0
- package/src/message.ts +178 -0
- package/src/money.ts +87 -0
- package/src/payload.ts +58 -0
- package/src/policy.ts +110 -0
- package/src/protocol.ts +19 -0
- package/src/router.ts +97 -0
- package/src/signing.ts +92 -0
- package/src/state.ts +76 -0
- package/src/wire.ts +248 -0
package/src/canonical.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Money } from "./money.js";
|
|
2
|
+
import { sha256 } from "./crypto.js";
|
|
3
|
+
|
|
4
|
+
const encoder = new TextEncoder();
|
|
5
|
+
|
|
6
|
+
// CanonicalWriter builds a deterministic, length-delimited byte string. It is the
|
|
7
|
+
// one encoder every signed and hashed preimage is built with, mirrored
|
|
8
|
+
// field-for-field across the SDKs. Protobuf is deliberately not used here: its
|
|
9
|
+
// serialization is not canonical across runtimes, so a signature over protobuf
|
|
10
|
+
// bytes would not verify between implementations.
|
|
11
|
+
export class CanonicalWriter {
|
|
12
|
+
private readonly parts: Uint8Array[] = [];
|
|
13
|
+
private length = 0;
|
|
14
|
+
|
|
15
|
+
private push(part: Uint8Array): void {
|
|
16
|
+
this.parts.push(part);
|
|
17
|
+
this.length += part.length;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// bytes writes a 4-byte big-endian length followed by the raw bytes.
|
|
21
|
+
bytes(b: Uint8Array): this {
|
|
22
|
+
const header = new Uint8Array(4);
|
|
23
|
+
new DataView(header.buffer).setUint32(0, b.length, false);
|
|
24
|
+
this.push(header);
|
|
25
|
+
this.push(b);
|
|
26
|
+
return this;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
str(s: string): this {
|
|
30
|
+
return this.bytes(encoder.encode(s));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
u64(n: number | bigint): this {
|
|
34
|
+
const b = new Uint8Array(8);
|
|
35
|
+
new DataView(b.buffer).setBigUint64(0, BigInt(n), false);
|
|
36
|
+
this.push(b);
|
|
37
|
+
return this;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
money(m: Money): this {
|
|
41
|
+
this.str(m.minor());
|
|
42
|
+
this.str(m.currency);
|
|
43
|
+
this.u64(m.exponent);
|
|
44
|
+
return this;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
list(xs: readonly string[]): this {
|
|
48
|
+
this.u64(xs.length);
|
|
49
|
+
for (const x of xs) {
|
|
50
|
+
this.str(x);
|
|
51
|
+
}
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// stringMap writes a length then each entry, keys sorted ascending by UTF-8
|
|
56
|
+
// byte value so the encoding never depends on insertion order. The sort is on
|
|
57
|
+
// encoded bytes, not on UTF-16 code units, to match the other SDKs exactly.
|
|
58
|
+
stringMap(kv: Readonly<Record<string, string>>): this {
|
|
59
|
+
const keys = Object.keys(kv).sort(compareUtf8);
|
|
60
|
+
this.u64(keys.length);
|
|
61
|
+
for (const k of keys) {
|
|
62
|
+
this.str(k);
|
|
63
|
+
this.str(kv[k] as string);
|
|
64
|
+
}
|
|
65
|
+
return this;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
preimage(): Uint8Array {
|
|
69
|
+
const out = new Uint8Array(this.length);
|
|
70
|
+
let offset = 0;
|
|
71
|
+
for (const part of this.parts) {
|
|
72
|
+
out.set(part, offset);
|
|
73
|
+
offset += part.length;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function compareUtf8(a: string, b: string): number {
|
|
80
|
+
const ab = encoder.encode(a);
|
|
81
|
+
const bb = encoder.encode(b);
|
|
82
|
+
const n = Math.min(ab.length, bb.length);
|
|
83
|
+
for (let i = 0; i < n; i++) {
|
|
84
|
+
const diff = (ab[i] as number) - (bb[i] as number);
|
|
85
|
+
if (diff !== 0) return diff;
|
|
86
|
+
}
|
|
87
|
+
return ab.length - bb.length;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// hashPreimage is the SHA-256 of a preimage. Every message hash and receipt is
|
|
91
|
+
// derived through it.
|
|
92
|
+
export function hashPreimage(preimage: Uint8Array): Uint8Array {
|
|
93
|
+
return sha256(preimage);
|
|
94
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { State } from "./state.js";
|
|
3
|
+
import { Money } from "./money.js";
|
|
4
|
+
import { DEFAULT_SKEW_MILLIS, domain } from "./protocol.js";
|
|
5
|
+
import { CanonicalWriter, hashPreimage } from "./canonical.js";
|
|
6
|
+
import {
|
|
7
|
+
intentHash,
|
|
8
|
+
quoteHash,
|
|
9
|
+
authorizationHash,
|
|
10
|
+
corridorReceipt,
|
|
11
|
+
isDirect,
|
|
12
|
+
BRIDGE_PASSTHROUGH,
|
|
13
|
+
type Intent,
|
|
14
|
+
type Quote,
|
|
15
|
+
type Authorization,
|
|
16
|
+
type Settlement,
|
|
17
|
+
} from "./message.js";
|
|
18
|
+
import { authorize as signAuthorization, verifyAuthorization, type Signer, type Verifier } from "./signing.js";
|
|
19
|
+
import { PassThroughBridge, type Bridge, type ConvertResult } from "./bridge.js";
|
|
20
|
+
import { railInList, type PayInLeg, type PayOutLeg, type CollectResult } from "./leg.js";
|
|
21
|
+
import type { AdapterEvent } from "./adapter.js";
|
|
22
|
+
import { route, type Policy, PolicyKind, NoQuoteError, isExpired } from "./router.js";
|
|
23
|
+
import { permissiveKyc, allowRisk, withinLimits, type KycProvider, type RiskHook } from "./compliance.js";
|
|
24
|
+
import { MemoryLedger, eventReceipt, type Ledger, type LedgerEvent } from "./ledger.js";
|
|
25
|
+
|
|
26
|
+
// Clock returns the current time in Unix milliseconds; IdGen returns a fresh
|
|
27
|
+
// collision-resistant identifier. Both are injectable so tests and replay can
|
|
28
|
+
// pin them.
|
|
29
|
+
export type Clock = () => number;
|
|
30
|
+
export type IdGen = () => string;
|
|
31
|
+
|
|
32
|
+
export function systemClock(): number {
|
|
33
|
+
return Date.now();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function randomId(): string {
|
|
37
|
+
return Buffer.from(randomBytes(16)).toString("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ClientConfig assembles a Client. Legs, a ledger, and a verifier are required;
|
|
41
|
+
// the remaining fields fall back to safe defaults. escrowRef is where a bridged
|
|
42
|
+
// corridor's pay-in leg deposits, before the bridge converts and the pay-out leg
|
|
43
|
+
// disburses.
|
|
44
|
+
export interface ClientConfig {
|
|
45
|
+
payIn?: PayInLeg[];
|
|
46
|
+
payOut?: PayOutLeg[];
|
|
47
|
+
bridges?: Bridge[];
|
|
48
|
+
ledger?: Ledger;
|
|
49
|
+
policy?: Policy;
|
|
50
|
+
verifier: Verifier;
|
|
51
|
+
kyc?: KycProvider;
|
|
52
|
+
risk?: RiskHook;
|
|
53
|
+
clock?: Clock;
|
|
54
|
+
idGen?: IdGen;
|
|
55
|
+
skew?: number;
|
|
56
|
+
escrowRef?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface IntentSpec {
|
|
60
|
+
senderRef: string;
|
|
61
|
+
recipientRef: string;
|
|
62
|
+
amount: Money;
|
|
63
|
+
memo?: string;
|
|
64
|
+
expiresAt: number;
|
|
65
|
+
allowedRails?: string[];
|
|
66
|
+
metadata?: Record<string, string>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Funding is a payer's chosen way to pay: which pay-in leg and in what currency.
|
|
70
|
+
// The chat UI collects it when the payer picks an option.
|
|
71
|
+
export interface Funding {
|
|
72
|
+
payInAdapterId: string;
|
|
73
|
+
currency: string;
|
|
74
|
+
exponent: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Client is the corridor orchestrator a host embeds. It mints intents, composes
|
|
78
|
+
// corridor quotes across legs and bridges, authorizes with the host's signer,
|
|
79
|
+
// and runs collect -> convert -> disburse, folding each step into the ledger. It
|
|
80
|
+
// never holds funds and never touches transport.
|
|
81
|
+
export class Client {
|
|
82
|
+
private readonly payIn = new Map<string, PayInLeg>();
|
|
83
|
+
private readonly payOut = new Map<string, PayOutLeg>();
|
|
84
|
+
private readonly bridges = new Map<string, Bridge>();
|
|
85
|
+
private readonly ledger: Ledger;
|
|
86
|
+
private readonly policy: Policy;
|
|
87
|
+
private readonly verifier: Verifier;
|
|
88
|
+
private readonly kyc: KycProvider;
|
|
89
|
+
private readonly risk: RiskHook;
|
|
90
|
+
private readonly clock: Clock;
|
|
91
|
+
private readonly idGen: IdGen;
|
|
92
|
+
private readonly skew: number;
|
|
93
|
+
private readonly escrowRef: string;
|
|
94
|
+
private readonly subscribers: Array<(event: LedgerEvent) => void> = [];
|
|
95
|
+
|
|
96
|
+
constructor(cfg: ClientConfig) {
|
|
97
|
+
for (const l of cfg.payIn ?? []) this.payIn.set(l.id, l);
|
|
98
|
+
for (const l of cfg.payOut ?? []) this.payOut.set(l.id, l);
|
|
99
|
+
for (const b of cfg.bridges ?? []) this.bridges.set(b.id, b);
|
|
100
|
+
if (this.bridges.size === 0) {
|
|
101
|
+
this.bridges.set(BRIDGE_PASSTHROUGH, new PassThroughBridge());
|
|
102
|
+
}
|
|
103
|
+
this.ledger = cfg.ledger ?? new MemoryLedger();
|
|
104
|
+
this.policy = cfg.policy ?? { kind: PolicyKind.Cheapest };
|
|
105
|
+
this.verifier = cfg.verifier;
|
|
106
|
+
this.kyc = cfg.kyc ?? permissiveKyc;
|
|
107
|
+
this.risk = cfg.risk ?? allowRisk;
|
|
108
|
+
this.clock = cfg.clock ?? systemClock;
|
|
109
|
+
this.idGen = cfg.idGen ?? randomId;
|
|
110
|
+
this.skew = cfg.skew ?? DEFAULT_SKEW_MILLIS;
|
|
111
|
+
this.escrowRef = cfg.escrowRef ?? "";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// createIntent mints an intent and records it as a draft.
|
|
115
|
+
createIntent(spec: IntentSpec): Intent {
|
|
116
|
+
const intent: Intent = {
|
|
117
|
+
id: this.idGen(),
|
|
118
|
+
senderRef: spec.senderRef,
|
|
119
|
+
recipientRef: spec.recipientRef,
|
|
120
|
+
amount: spec.amount,
|
|
121
|
+
memo: spec.memo ?? "",
|
|
122
|
+
expiresAt: spec.expiresAt,
|
|
123
|
+
allowedRails: spec.allowedRails ?? [],
|
|
124
|
+
metadata: spec.metadata ?? {},
|
|
125
|
+
};
|
|
126
|
+
this.ledger.apply({ intentId: intent.id, to: State.Draft, payloadHash: intentHash(intent), intent });
|
|
127
|
+
return intent;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// quoteOptions composes a corridor quote for each way the payer offered to fund
|
|
131
|
+
// the payment. For every funding option it finds a bridge that can reach the
|
|
132
|
+
// recipient's currency and a pay-out leg that can deliver it, prices the whole
|
|
133
|
+
// corridor, and returns the options for the payer to choose among.
|
|
134
|
+
async quoteOptions(intent: Intent, funding: Funding[]): Promise<Quote[]> {
|
|
135
|
+
const quotes: Quote[] = [];
|
|
136
|
+
let lastErr: unknown;
|
|
137
|
+
for (const f of funding) {
|
|
138
|
+
const payInLeg = this.payIn.get(f.payInAdapterId);
|
|
139
|
+
if (!payInLeg) {
|
|
140
|
+
lastErr = new Error(`pact: no pay-in leg ${JSON.stringify(f.payInAdapterId)}`);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (!containsOrEmpty(payInLeg.payInCapabilities().currencies, f.currency)) {
|
|
144
|
+
lastErr = new Error(`pact: pay-in leg ${JSON.stringify(f.payInAdapterId)} does not fund ${f.currency}`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
quotes.push(await this.composeQuote(intent, payInLeg, f));
|
|
149
|
+
} catch (err) {
|
|
150
|
+
lastErr = err;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (quotes.length === 0) {
|
|
154
|
+
if (lastErr instanceof Error) throw lastErr;
|
|
155
|
+
throw new NoQuoteError("no fundable corridor");
|
|
156
|
+
}
|
|
157
|
+
return quotes;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// composeQuote prices one corridor: a pay-out leg delivers the recipient's
|
|
161
|
+
// currency, a bridge plans the source needed from the payer's currency, and the
|
|
162
|
+
// leg and bridge fees are summed into what the payer pays.
|
|
163
|
+
private async composeQuote(intent: Intent, payInLeg: PayInLeg, f: Funding): Promise<Quote> {
|
|
164
|
+
const payOutLeg = this.pickPayOut(intent);
|
|
165
|
+
const bridge = this.pickBridge(f.currency, intent.amount.currency);
|
|
166
|
+
|
|
167
|
+
const bridged = await bridge.quote(intent.amount, f.currency, f.exponent);
|
|
168
|
+
const src = bridged.srcAmount.add(bridged.fee);
|
|
169
|
+
return {
|
|
170
|
+
id: this.idGen(),
|
|
171
|
+
intentId: intent.id,
|
|
172
|
+
payInAdapterId: payInLeg.id,
|
|
173
|
+
payInRail: firstRail(payInLeg.payInCapabilities().rails),
|
|
174
|
+
payOutAdapterId: payOutLeg.id,
|
|
175
|
+
payOutRail: this.payOutRail(payOutLeg, intent),
|
|
176
|
+
bridgeId: bridge.id,
|
|
177
|
+
srcAmount: src,
|
|
178
|
+
dstAmount: intent.amount,
|
|
179
|
+
fees: bridged.fee,
|
|
180
|
+
fxRate: bridged.fxRate,
|
|
181
|
+
expiresAt: intent.expiresAt,
|
|
182
|
+
providerQuoteRef: "",
|
|
183
|
+
latencyEstimateMs: 0,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// pickPayOut finds a pay-out leg that delivers the recipient's currency on an
|
|
188
|
+
// allowed rail.
|
|
189
|
+
private pickPayOut(intent: Intent): PayOutLeg {
|
|
190
|
+
for (const leg of this.payOut.values()) {
|
|
191
|
+
const caps = leg.payOutCapabilities();
|
|
192
|
+
if (!containsOrEmpty(caps.currencies, intent.amount.currency)) continue;
|
|
193
|
+
if (this.payOutRail(leg, intent) !== "") return leg;
|
|
194
|
+
}
|
|
195
|
+
throw new NoQuoteError("no pay-out leg for the recipient's currency and rails");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// payOutRail returns the rail this leg would use for the intent, or empty if
|
|
199
|
+
// none of the intent's allowed rails are served.
|
|
200
|
+
private payOutRail(leg: PayOutLeg, intent: Intent): string {
|
|
201
|
+
const caps = leg.payOutCapabilities();
|
|
202
|
+
if (intent.allowedRails.length === 0) return firstRail(caps.rails);
|
|
203
|
+
for (const rail of intent.allowedRails) {
|
|
204
|
+
if (railInList(caps.rails, rail)) return rail;
|
|
205
|
+
}
|
|
206
|
+
return "";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// pickBridge selects a bridge that converts the payer's currency to the
|
|
210
|
+
// recipient's. Same-currency prefers pass-through; otherwise a converting
|
|
211
|
+
// bridge.
|
|
212
|
+
private pickBridge(srcCurrency: string, dstCurrency: string): Bridge {
|
|
213
|
+
if (srcCurrency === dstCurrency) {
|
|
214
|
+
const passThrough = this.bridges.get(BRIDGE_PASSTHROUGH);
|
|
215
|
+
if (passThrough) return passThrough;
|
|
216
|
+
}
|
|
217
|
+
for (const b of this.bridges.values()) {
|
|
218
|
+
if (b.id !== BRIDGE_PASSTHROUGH) return b;
|
|
219
|
+
}
|
|
220
|
+
const passThrough = this.bridges.get(BRIDGE_PASSTHROUGH);
|
|
221
|
+
if (passThrough && srcCurrency === dstCurrency) return passThrough;
|
|
222
|
+
throw new NoQuoteError(`no bridge from ${srcCurrency} to ${dstCurrency}`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// select applies the configured policy to a set of corridor quotes.
|
|
226
|
+
async select(quotes: Quote[], identity: string): Promise<Quote> {
|
|
227
|
+
const status = await this.kyc.status(identity);
|
|
228
|
+
return route(quotes, this.policy, status, this.clock(), this.skew);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// authorize records the chosen corridor, runs the risk hook, signs the sender's
|
|
232
|
+
// commitment, and records the authorization, advancing draft -> quoted ->
|
|
233
|
+
// authorized.
|
|
234
|
+
async authorize(intent: Intent, quote: Quote, signer: Signer): Promise<Authorization> {
|
|
235
|
+
if (quote.intentId !== intent.id) {
|
|
236
|
+
throw new Error("pact: quote does not belong to intent");
|
|
237
|
+
}
|
|
238
|
+
const now = this.clock();
|
|
239
|
+
if (isExpired(intent.expiresAt, now, this.skew)) {
|
|
240
|
+
throw new Error("pact: intent has expired");
|
|
241
|
+
}
|
|
242
|
+
if (isExpired(quote.expiresAt, now, this.skew)) {
|
|
243
|
+
throw new Error("pact: quote has expired");
|
|
244
|
+
}
|
|
245
|
+
const status = await this.kyc.status(signer.identity());
|
|
246
|
+
if (!withinLimits(status, quote)) {
|
|
247
|
+
throw new Error("pact: quote exceeds KYC limit");
|
|
248
|
+
}
|
|
249
|
+
const decision = this.risk.evaluate(signer.identity(), intent, quote);
|
|
250
|
+
if (!decision.allow) {
|
|
251
|
+
throw new Error(`pact: risk hook vetoed authorization: ${decision.reason}`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.ledger.apply({ intentId: intent.id, to: State.Quoted, payloadHash: quoteHash(quote), quote });
|
|
255
|
+
const auth = signAuthorization(intent, quote, signer, now);
|
|
256
|
+
const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
|
|
257
|
+
this.ledger.apply({ intentId: intent.id, to: State.Authorized, payloadHash: authHash, authorization: auth });
|
|
258
|
+
return auth;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// initiate starts a corridor and returns its in-flight state. It verifies the
|
|
262
|
+
// authorization, kicks off the pay-in leg, records the state, and returns
|
|
263
|
+
// immediately — it does not wait for the provider to confirm. Settlement is
|
|
264
|
+
// driven forward by advance as provider events arrive, so no promise or
|
|
265
|
+
// connection is held open per payment and durable state lives only in the
|
|
266
|
+
// ledger.
|
|
267
|
+
async initiate(intent: Intent, quote: Quote, auth: Authorization): Promise<State> {
|
|
268
|
+
verifyAuthorization(auth, intent, quote, this.verifier);
|
|
269
|
+
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
270
|
+
if (!payInLeg) {
|
|
271
|
+
throw new Error(`pact: no pay-in leg ${JSON.stringify(quote.payInAdapterId)}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// A direct corridor collects straight to the recipient; a bridged one
|
|
275
|
+
// collects into escrow first.
|
|
276
|
+
let deliverTo = recipientDestination(intent);
|
|
277
|
+
let to = State.Submitted;
|
|
278
|
+
if (!isDirect(quote)) {
|
|
279
|
+
deliverTo = this.escrowRef;
|
|
280
|
+
to = State.Collecting;
|
|
281
|
+
}
|
|
282
|
+
const collected = await payInLeg.collect(intent.id, quote, auth, deliverTo);
|
|
283
|
+
this.ledger.apply({ intentId: intent.id, to, payloadHash: submitHash(collected.providerRef), collect: collected });
|
|
284
|
+
return to;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// advance resumes a corridor when a provider event arrives — a webhook, a
|
|
288
|
+
// callback, a chain receipt, already normalized into an AdapterEvent. It reads
|
|
289
|
+
// the corridor's state from the ledger, interprets the event against it, and
|
|
290
|
+
// moves it forward one hop. It returns the terminal settlement once reached and
|
|
291
|
+
// the resulting state. Advancing the same step twice is idempotent, so a
|
|
292
|
+
// duplicated webhook settles once.
|
|
293
|
+
async advance(intentId: string, adapterId: string, event: AdapterEvent): Promise<{ settlement: Settlement; state: State }> {
|
|
294
|
+
const events = this.ledger.events(intentId);
|
|
295
|
+
const replayed = replay(events);
|
|
296
|
+
if (!replayed) {
|
|
297
|
+
throw new Error("pact: intent has no recorded authorization to advance");
|
|
298
|
+
}
|
|
299
|
+
const { intent, quote, authorization: auth } = replayed;
|
|
300
|
+
const authHash = authorizationHash(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
|
|
301
|
+
const current = this.ledger.state(intentId);
|
|
302
|
+
const failed = event.state === State.Failed;
|
|
303
|
+
const fromPayIn = adapterId === quote.payInAdapterId;
|
|
304
|
+
const fromPayOut = adapterId === quote.payOutAdapterId;
|
|
305
|
+
|
|
306
|
+
// A direct corridor's pay-in confirms and it settles.
|
|
307
|
+
if (fromPayIn && current === State.Submitted) {
|
|
308
|
+
if (failed) return this.recordFailure(intentId, authHash, quote, event.reason);
|
|
309
|
+
const ref = payInRef(events);
|
|
310
|
+
const settlement: Settlement = {
|
|
311
|
+
intentId,
|
|
312
|
+
state: State.Settled,
|
|
313
|
+
adapterId: quote.payInAdapterId,
|
|
314
|
+
providerTxRef: ref,
|
|
315
|
+
onchainTxHash: "",
|
|
316
|
+
receiptHash: corridorReceipt(authHash, ref, ref, quote.fxRate, "", State.Settled),
|
|
317
|
+
reason: "",
|
|
318
|
+
settledAt: this.clock(),
|
|
319
|
+
};
|
|
320
|
+
return this.finishAdvance(intentId, State.Settled, settlement);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// A bridged corridor's pay-in confirms; hold, convert, and kick off the
|
|
324
|
+
// pay-out.
|
|
325
|
+
if (fromPayIn && current === State.Collecting) {
|
|
326
|
+
// Nothing has entered escrow, so a failure here is a plain failure.
|
|
327
|
+
if (failed) return this.recordFailure(intentId, authHash, quote, event.reason);
|
|
328
|
+
return this.holdAndDisburse(intent, quote, authHash, events);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// The pay-out confirms and the corridor settles, or fails and unwinds.
|
|
332
|
+
if (fromPayOut && current === State.Disbursing) {
|
|
333
|
+
if (failed) return this.unwind(intentId, quote, authHash, event.reason);
|
|
334
|
+
return this.settleBridged(intentId, quote, authHash, events);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Any other event — a duplicate webhook, or one for a phase already past — is
|
|
338
|
+
// a no-op. This is what makes a re-delivered provider callback settle once.
|
|
339
|
+
return { settlement: lastSettlement(events), state: current };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// holdAndDisburse records the escrow hold, converts through the bridge, and
|
|
343
|
+
// kicks off the pay-out leg, leaving the corridor disbursing until its pay-out
|
|
344
|
+
// confirms.
|
|
345
|
+
private async holdAndDisburse(
|
|
346
|
+
intent: Intent,
|
|
347
|
+
quote: Quote,
|
|
348
|
+
authHash: Uint8Array,
|
|
349
|
+
events: LedgerEvent[],
|
|
350
|
+
): Promise<{ settlement: Settlement; state: State }> {
|
|
351
|
+
const payOutLeg = this.payOut.get(quote.payOutAdapterId);
|
|
352
|
+
if (!payOutLeg) {
|
|
353
|
+
throw new Error(`pact: no pay-out leg ${JSON.stringify(quote.payOutAdapterId)}`);
|
|
354
|
+
}
|
|
355
|
+
const bridge = this.bridges.get(quote.bridgeId);
|
|
356
|
+
if (!bridge) {
|
|
357
|
+
throw new Error(`pact: no bridge ${JSON.stringify(quote.bridgeId)}`);
|
|
358
|
+
}
|
|
359
|
+
const collected = collectResult(events);
|
|
360
|
+
this.ledger.apply({
|
|
361
|
+
intentId: intent.id,
|
|
362
|
+
to: State.Held,
|
|
363
|
+
payloadHash: hashString(domain("held"), collected.providerRef),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
let converted: ConvertResult;
|
|
367
|
+
try {
|
|
368
|
+
converted = await bridge.convert(intent.id, collected.received, intent.amount.currency, intent.amount.exponent);
|
|
369
|
+
} catch (err) {
|
|
370
|
+
return this.unwind(intent.id, quote, authHash, reasonOf(err));
|
|
371
|
+
}
|
|
372
|
+
let disbursed;
|
|
373
|
+
try {
|
|
374
|
+
disbursed = await payOutLeg.disburse(intent.id, quote, recipientDestination(intent));
|
|
375
|
+
} catch (err) {
|
|
376
|
+
return this.unwind(intent.id, quote, authHash, reasonOf(err));
|
|
377
|
+
}
|
|
378
|
+
const marker: Settlement = {
|
|
379
|
+
intentId: intent.id,
|
|
380
|
+
state: State.Disbursing,
|
|
381
|
+
adapterId: payOutLeg.id,
|
|
382
|
+
providerTxRef: disbursed.providerRef,
|
|
383
|
+
onchainTxHash: "",
|
|
384
|
+
receiptHash: new Uint8Array(0),
|
|
385
|
+
reason: "",
|
|
386
|
+
settledAt: 0,
|
|
387
|
+
};
|
|
388
|
+
this.ledger.apply({
|
|
389
|
+
intentId: intent.id,
|
|
390
|
+
to: State.Disbursing,
|
|
391
|
+
payloadHash: hashString(domain("disburse"), disbursed.providerRef),
|
|
392
|
+
settlement: marker,
|
|
393
|
+
bridge: converted,
|
|
394
|
+
});
|
|
395
|
+
return { settlement: emptySettlement(), state: State.Disbursing };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// settleBridged closes out a bridged corridor once its pay-out has confirmed,
|
|
399
|
+
// binding both legs and the FX into the linked receipt.
|
|
400
|
+
private settleBridged(
|
|
401
|
+
intentId: string,
|
|
402
|
+
quote: Quote,
|
|
403
|
+
authHash: Uint8Array,
|
|
404
|
+
events: LedgerEvent[],
|
|
405
|
+
): { settlement: Settlement; state: State } {
|
|
406
|
+
const inRef = payInRef(events);
|
|
407
|
+
const { payOut: outRef, bridge: bridgeRef } = disburseRefs(events);
|
|
408
|
+
const settlement: Settlement = {
|
|
409
|
+
intentId,
|
|
410
|
+
state: State.Settled,
|
|
411
|
+
adapterId: quote.payOutAdapterId,
|
|
412
|
+
providerTxRef: outRef,
|
|
413
|
+
onchainTxHash: "",
|
|
414
|
+
receiptHash: corridorReceipt(authHash, inRef, outRef, quote.fxRate, bridgeRef, State.Settled),
|
|
415
|
+
reason: "",
|
|
416
|
+
settledAt: this.clock(),
|
|
417
|
+
};
|
|
418
|
+
return this.finishAdvance(intentId, State.Settled, settlement);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// unwind refunds the payer from escrow when a bridged corridor cannot complete
|
|
422
|
+
// its pay-out, moving to refunding then refunded.
|
|
423
|
+
private async unwind(
|
|
424
|
+
intentId: string,
|
|
425
|
+
quote: Quote,
|
|
426
|
+
authHash: Uint8Array,
|
|
427
|
+
reason: string,
|
|
428
|
+
): Promise<{ settlement: Settlement; state: State }> {
|
|
429
|
+
this.ledger.apply({
|
|
430
|
+
intentId,
|
|
431
|
+
to: State.Refunding,
|
|
432
|
+
payloadHash: hashString(domain("refunding"), reason),
|
|
433
|
+
});
|
|
434
|
+
const payInLeg = this.payIn.get(quote.payInAdapterId);
|
|
435
|
+
if (payInLeg) {
|
|
436
|
+
await payInLeg.refundIn(intentId, payInLeg.payInCapabilities().refunds, reason);
|
|
437
|
+
}
|
|
438
|
+
const settlement: Settlement = {
|
|
439
|
+
intentId,
|
|
440
|
+
state: State.Refunded,
|
|
441
|
+
adapterId: quote.payInAdapterId,
|
|
442
|
+
providerTxRef: "",
|
|
443
|
+
onchainTxHash: "",
|
|
444
|
+
receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Refunded),
|
|
445
|
+
reason,
|
|
446
|
+
settledAt: this.clock(),
|
|
447
|
+
};
|
|
448
|
+
return this.finishAdvance(intentId, State.Refunded, settlement);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// recordFailure marks a pay-in that failed before any escrow was taken.
|
|
452
|
+
private recordFailure(
|
|
453
|
+
intentId: string,
|
|
454
|
+
authHash: Uint8Array,
|
|
455
|
+
quote: Quote,
|
|
456
|
+
reason: string,
|
|
457
|
+
): { settlement: Settlement; state: State } {
|
|
458
|
+
const settlement: Settlement = {
|
|
459
|
+
intentId,
|
|
460
|
+
state: State.Failed,
|
|
461
|
+
adapterId: quote.payInAdapterId,
|
|
462
|
+
providerTxRef: "",
|
|
463
|
+
onchainTxHash: "",
|
|
464
|
+
receiptHash: corridorReceipt(authHash, "", "", quote.fxRate, "", State.Failed),
|
|
465
|
+
reason,
|
|
466
|
+
settledAt: this.clock(),
|
|
467
|
+
};
|
|
468
|
+
return this.finishAdvance(intentId, State.Failed, settlement);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private finishAdvance(intentId: string, state: State, settlement: Settlement): { settlement: Settlement; state: State } {
|
|
472
|
+
const finished = this.finish(intentId, state, settlement);
|
|
473
|
+
return { settlement: finished, state };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private finish(intentId: string, state: State, settlement: Settlement): Settlement {
|
|
477
|
+
const event = this.ledger.apply({ intentId, to: state, payloadHash: settlement.receiptHash, settlement });
|
|
478
|
+
this.notify(event);
|
|
479
|
+
return settlement;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// expire records that an intent's deadline passed before it settled.
|
|
483
|
+
expire(intentId: string): void {
|
|
484
|
+
this.ledger.apply({
|
|
485
|
+
intentId,
|
|
486
|
+
to: State.Expired,
|
|
487
|
+
payloadHash: eventReceipt(0, intentId, State.Expired, new Uint8Array(0)),
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
subscribe(handler: (event: LedgerEvent) => void): void {
|
|
492
|
+
this.subscribers.push(handler);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
history(intentId: string): LedgerEvent[] {
|
|
496
|
+
return this.ledger.events(intentId);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
state(intentId: string): State {
|
|
500
|
+
return this.ledger.state(intentId);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
head(intentId: string): Uint8Array | undefined {
|
|
504
|
+
return this.ledger.head(intentId);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
private notify(event: LedgerEvent): void {
|
|
508
|
+
for (const h of this.subscribers) h(event);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// recipientDestination is the rail-specific handle the pay-out lands on — a
|
|
513
|
+
// crypto address, a phone number — carried in metadata, falling back to the
|
|
514
|
+
// generic recipient reference.
|
|
515
|
+
export function recipientDestination(intent: Intent): string {
|
|
516
|
+
const d = intent.metadata["recipient_destination"];
|
|
517
|
+
return d ? d : intent.recipientRef;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// replay reconstructs the intent, quote, and authorization from the ledger so a
|
|
521
|
+
// corridor can resume without the caller holding them.
|
|
522
|
+
function replay(events: LedgerEvent[]): { intent: Intent; quote: Quote; authorization: Authorization } | undefined {
|
|
523
|
+
let intent: Intent | undefined;
|
|
524
|
+
let quote: Quote | undefined;
|
|
525
|
+
let authorization: Authorization | undefined;
|
|
526
|
+
for (const e of events) {
|
|
527
|
+
if (e.intent) intent = e.intent;
|
|
528
|
+
if (e.quote) quote = e.quote;
|
|
529
|
+
if (e.authorization) authorization = e.authorization;
|
|
530
|
+
}
|
|
531
|
+
if (!intent || !quote || !authorization) return undefined;
|
|
532
|
+
return { intent, quote, authorization };
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// collectResult returns what the pay-in leg recorded, or an empty result if none
|
|
536
|
+
// is present.
|
|
537
|
+
function collectResult(events: LedgerEvent[]): CollectResult {
|
|
538
|
+
for (const e of events) {
|
|
539
|
+
if (e.collect) return e.collect;
|
|
540
|
+
}
|
|
541
|
+
return { providerRef: "", received: Money.create("", 0, 0n) };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function payInRef(events: LedgerEvent[]): string {
|
|
545
|
+
return collectResult(events).providerRef;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// disburseRefs returns the pay-out provider reference and the bridge receipt
|
|
549
|
+
// reference recorded on the disbursing step.
|
|
550
|
+
function disburseRefs(events: LedgerEvent[]): { payOut: string; bridge: string } {
|
|
551
|
+
let payOut = "";
|
|
552
|
+
let bridge = "";
|
|
553
|
+
for (const e of events) {
|
|
554
|
+
if (e.state === State.Disbursing) {
|
|
555
|
+
if (e.settlement) payOut = e.settlement.providerTxRef;
|
|
556
|
+
if (e.bridge) bridge = e.bridge.receiptRef;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return { payOut, bridge };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// lastSettlement returns the settlement recorded on an intent's most recent
|
|
563
|
+
// event, if any, so an idempotent no-op can echo the outcome already reached.
|
|
564
|
+
function lastSettlement(events: LedgerEvent[]): Settlement {
|
|
565
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
566
|
+
const e = events[i] as LedgerEvent;
|
|
567
|
+
if (e.settlement) return e.settlement;
|
|
568
|
+
}
|
|
569
|
+
return emptySettlement();
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function emptySettlement(): Settlement {
|
|
573
|
+
return {
|
|
574
|
+
intentId: "",
|
|
575
|
+
state: State.Unspecified,
|
|
576
|
+
adapterId: "",
|
|
577
|
+
providerTxRef: "",
|
|
578
|
+
onchainTxHash: "",
|
|
579
|
+
receiptHash: new Uint8Array(0),
|
|
580
|
+
reason: "",
|
|
581
|
+
settledAt: 0,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function reasonOf(cause: unknown): string {
|
|
586
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function containsOrEmpty(xs: string[], target: string): boolean {
|
|
590
|
+
return xs.length === 0 || xs.includes(target);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function firstRail(rails: string[]): string {
|
|
594
|
+
return rails.length === 0 ? "" : (rails[0] as string);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// submitHash commits to a provider reference so a step carries a payload distinct
|
|
598
|
+
// from every other.
|
|
599
|
+
function submitHash(providerRef: string): Uint8Array {
|
|
600
|
+
return hashString(domain("submit"), providerRef);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function hashString(domainTag: string, value: string): Uint8Array {
|
|
604
|
+
return hashPreimage(new CanonicalWriter().str(domainTag).str(value).preimage());
|
|
605
|
+
}
|