@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.
Files changed (88) hide show
  1. package/dist/src/adapter.d.ts +18 -0
  2. package/dist/src/adapter.js +11 -0
  3. package/dist/src/adapters/erc20.d.ts +43 -0
  4. package/dist/src/adapters/erc20.js +126 -0
  5. package/dist/src/adapters/mpesa.d.ts +61 -0
  6. package/dist/src/adapters/mpesa.js +272 -0
  7. package/dist/src/adapters/paypal.d.ts +68 -0
  8. package/dist/src/adapters/paypal.js +279 -0
  9. package/dist/src/adapters/stripe.d.ts +61 -0
  10. package/dist/src/adapters/stripe.js +336 -0
  11. package/dist/src/bridge.d.ts +40 -0
  12. package/dist/src/bridge.js +99 -0
  13. package/dist/src/canonical.d.ts +14 -0
  14. package/dist/src/canonical.js +82 -0
  15. package/dist/src/client.d.ts +84 -0
  16. package/dist/src/client.js +510 -0
  17. package/dist/src/compliance.d.ts +23 -0
  18. package/dist/src/compliance.js +26 -0
  19. package/dist/src/crypto.d.ts +12 -0
  20. package/dist/src/crypto.js +50 -0
  21. package/dist/src/fake.d.ts +29 -0
  22. package/dist/src/fake.js +79 -0
  23. package/dist/src/index.d.ts +16 -0
  24. package/dist/src/index.js +16 -0
  25. package/dist/src/ledger.d.ts +47 -0
  26. package/dist/src/ledger.js +84 -0
  27. package/dist/src/leg.d.ts +34 -0
  28. package/dist/src/leg.js +4 -0
  29. package/dist/src/message.d.ts +55 -0
  30. package/dist/src/message.js +88 -0
  31. package/dist/src/money.d.ts +16 -0
  32. package/dist/src/money.js +81 -0
  33. package/dist/src/payload.d.ts +11 -0
  34. package/dist/src/payload.js +52 -0
  35. package/dist/src/policy.d.ts +26 -0
  36. package/dist/src/policy.js +83 -0
  37. package/dist/src/protocol.d.ts +4 -0
  38. package/dist/src/protocol.js +15 -0
  39. package/dist/src/router.d.ts +16 -0
  40. package/dist/src/router.js +87 -0
  41. package/dist/src/signing.d.ts +27 -0
  42. package/dist/src/signing.js +72 -0
  43. package/dist/src/state.d.ts +23 -0
  44. package/dist/src/state.js +76 -0
  45. package/dist/src/wire.d.ts +30 -0
  46. package/dist/src/wire.js +221 -0
  47. package/dist/test/erc20.test.d.ts +1 -0
  48. package/dist/test/erc20.test.js +131 -0
  49. package/dist/test/lifecycle.test.d.ts +1 -0
  50. package/dist/test/lifecycle.test.js +212 -0
  51. package/dist/test/mpesa.test.d.ts +1 -0
  52. package/dist/test/mpesa.test.js +180 -0
  53. package/dist/test/payload.test.d.ts +1 -0
  54. package/dist/test/payload.test.js +32 -0
  55. package/dist/test/paypal.test.d.ts +1 -0
  56. package/dist/test/paypal.test.js +140 -0
  57. package/dist/test/policy.test.d.ts +1 -0
  58. package/dist/test/policy.test.js +131 -0
  59. package/dist/test/stripe.test.d.ts +1 -0
  60. package/dist/test/stripe.test.js +176 -0
  61. package/dist/test/vectors.test.d.ts +1 -0
  62. package/dist/test/vectors.test.js +91 -0
  63. package/dist/test/wire.test.d.ts +1 -0
  64. package/dist/test/wire.test.js +104 -0
  65. package/package.json +50 -0
  66. package/src/adapter.ts +32 -0
  67. package/src/adapters/erc20.ts +181 -0
  68. package/src/adapters/mpesa.ts +408 -0
  69. package/src/adapters/paypal.ts +409 -0
  70. package/src/adapters/stripe.ts +478 -0
  71. package/src/bridge.ts +148 -0
  72. package/src/canonical.ts +94 -0
  73. package/src/client.ts +605 -0
  74. package/src/compliance.ts +65 -0
  75. package/src/crypto.ts +68 -0
  76. package/src/fake.ts +96 -0
  77. package/src/index.ts +106 -0
  78. package/src/ledger.ts +145 -0
  79. package/src/leg.ts +65 -0
  80. package/src/message.ts +178 -0
  81. package/src/money.ts +87 -0
  82. package/src/payload.ts +58 -0
  83. package/src/policy.ts +110 -0
  84. package/src/protocol.ts +19 -0
  85. package/src/router.ts +97 -0
  86. package/src/signing.ts +92 -0
  87. package/src/state.ts +76 -0
  88. package/src/wire.ts +248 -0
package/src/message.ts ADDED
@@ -0,0 +1,178 @@
1
+ import { Money } from "./money.js";
2
+ import { State, stateName } from "./state.js";
3
+ import { CanonicalWriter, hashPreimage } from "./canonical.js";
4
+ import { domain } from "./protocol.js";
5
+
6
+ // Intent is a payment request minted by the sender. senderRef and recipientRef
7
+ // are opaque host-scoped handles, never personally identifying data on the wire.
8
+ export interface Intent {
9
+ id: string;
10
+ senderRef: string;
11
+ recipientRef: string;
12
+ amount: Money;
13
+ memo: string;
14
+ expiresAt: number;
15
+ allowedRails: string[];
16
+ metadata: Record<string, string>;
17
+ }
18
+
19
+ export function intentPreimage(i: Intent): Uint8Array {
20
+ return new CanonicalWriter()
21
+ .str(domain("intent"))
22
+ .str(i.id)
23
+ .str(i.senderRef)
24
+ .str(i.recipientRef)
25
+ .money(i.amount)
26
+ .str(i.memo)
27
+ .u64(i.expiresAt)
28
+ .list(i.allowedRails)
29
+ .stringMap(i.metadata)
30
+ .preimage();
31
+ }
32
+
33
+ export function intentHash(i: Intent): Uint8Array {
34
+ return hashPreimage(intentPreimage(i));
35
+ }
36
+
37
+ // The bridge that moves value unchanged when the payer and recipient already
38
+ // share a currency. Its identifier is a stable wire value carried in a quote.
39
+ export const BRIDGE_PASSTHROUGH = "passthrough";
40
+
41
+ // Quote is a corridor: one offer to move value from the payer to the recipient
42
+ // through a pay-in leg, a bridge, and a pay-out leg. A direct payment is the case
43
+ // where the pay-in and pay-out are the same provider, the bridge is pass-through,
44
+ // and src and dst share a currency. srcAmount is what the payer pays in X;
45
+ // dstAmount is what the recipient receives in Y.
46
+ export interface Quote {
47
+ id: string;
48
+ intentId: string;
49
+ payInAdapterId: string;
50
+ payInRail: string;
51
+ payOutAdapterId: string;
52
+ payOutRail: string;
53
+ bridgeId: string;
54
+ srcAmount: Money;
55
+ dstAmount: Money;
56
+ fees: Money; // total across the legs and bridge, in X
57
+ fxRate: string; // decimal ASCII, Y per X; "1" for a direct corridor
58
+ expiresAt: number;
59
+ providerQuoteRef: string;
60
+ latencyEstimateMs: number;
61
+ }
62
+
63
+ export function quotePreimage(q: Quote): Uint8Array {
64
+ return new CanonicalWriter()
65
+ .str(domain("quote"))
66
+ .str(q.id)
67
+ .str(q.intentId)
68
+ .str(q.payInAdapterId)
69
+ .str(q.payInRail)
70
+ .str(q.payOutAdapterId)
71
+ .str(q.payOutRail)
72
+ .str(q.bridgeId)
73
+ .money(q.srcAmount)
74
+ .money(q.dstAmount)
75
+ .money(q.fees)
76
+ .str(q.fxRate)
77
+ .u64(q.expiresAt)
78
+ .str(q.providerQuoteRef)
79
+ .u64(q.latencyEstimateMs)
80
+ .preimage();
81
+ }
82
+
83
+ export function quoteHash(q: Quote): Uint8Array {
84
+ return hashPreimage(quotePreimage(q));
85
+ }
86
+
87
+ // isDirect reports whether a corridor settles in one phase: the same provider
88
+ // serves both legs and the bridge is pass-through.
89
+ export function isDirect(q: Quote): boolean {
90
+ return q.payInAdapterId === q.payOutAdapterId && q.bridgeId === BRIDGE_PASSTHROUGH;
91
+ }
92
+
93
+ // Authorization is the sender's signed commitment to exactly one quote. The
94
+ // signature covers a preimage binding both the intent hash and the quote hash,
95
+ // so it cannot be replayed against a different intent or quote.
96
+ export interface Authorization {
97
+ intentId: string;
98
+ quoteId: string;
99
+ signerIdentity: string;
100
+ signedAt: number;
101
+ signature: Uint8Array;
102
+ }
103
+
104
+ // signingPreimage returns the bytes an authorization signs over.
105
+ export function signingPreimage(
106
+ intentHashBytes: Uint8Array,
107
+ quoteHashBytes: Uint8Array,
108
+ signerIdentity: string,
109
+ signedAt: number,
110
+ ): Uint8Array {
111
+ return new CanonicalWriter()
112
+ .str(domain("authorize"))
113
+ .bytes(intentHashBytes)
114
+ .bytes(quoteHashBytes)
115
+ .str(signerIdentity)
116
+ .u64(signedAt)
117
+ .preimage();
118
+ }
119
+
120
+ export function authorizationHash(
121
+ intentHashBytes: Uint8Array,
122
+ quoteHashBytes: Uint8Array,
123
+ signerIdentity: string,
124
+ signedAt: number,
125
+ ): Uint8Array {
126
+ return hashPreimage(signingPreimage(intentHashBytes, quoteHashBytes, signerIdentity, signedAt));
127
+ }
128
+
129
+ // Settlement is the terminal or refund outcome for an intent.
130
+ export interface Settlement {
131
+ intentId: string;
132
+ state: State;
133
+ adapterId: string;
134
+ providerTxRef: string;
135
+ onchainTxHash: string;
136
+ receiptHash: Uint8Array;
137
+ reason: string;
138
+ settledAt: number;
139
+ }
140
+
141
+ // receiptHash derives a settlement's receipt commitment from the authorization
142
+ // hash it settles, the provider's transaction reference, and the resulting
143
+ // state. Revealing it plus a ledger inclusion proof lets a recipient prove a
144
+ // settlement without exposing any surrounding conversation.
145
+ export function receiptHash(authHash: Uint8Array, providerTxRef: string, state: State): Uint8Array {
146
+ return hashPreimage(
147
+ new CanonicalWriter()
148
+ .str(domain("receipt"))
149
+ .bytes(authHash)
150
+ .str(providerTxRef)
151
+ .str(stateName[state])
152
+ .preimage(),
153
+ );
154
+ }
155
+
156
+ // corridorReceipt binds both legs of a bridged corridor and the FX rate into one
157
+ // commitment, so the payer can prove they funded X and the recipient can prove
158
+ // they received Y as a single transaction, without revealing anything else.
159
+ export function corridorReceipt(
160
+ authHash: Uint8Array,
161
+ payInProviderRef: string,
162
+ payOutProviderRef: string,
163
+ fxRate: string,
164
+ bridgeReceiptRef: string,
165
+ state: State,
166
+ ): Uint8Array {
167
+ return hashPreimage(
168
+ new CanonicalWriter()
169
+ .str(domain("corridor"))
170
+ .bytes(authHash)
171
+ .str(payInProviderRef)
172
+ .str(payOutProviderRef)
173
+ .str(fxRate)
174
+ .str(bridgeReceiptRef)
175
+ .str(stateName[state])
176
+ .preimage(),
177
+ );
178
+ }
package/src/money.ts ADDED
@@ -0,0 +1,87 @@
1
+ // Money is an exact count of minor units in a single currency. The amount is a
2
+ // bigint, never a number: an 18-decimal token counted in its smallest unit
3
+ // overflows a 64-bit integer at roughly nine whole tokens, and floating point
4
+ // has no place near money.
5
+ const currencyPattern = /^[A-Z0-9]{1,16}$/;
6
+ const maxExponent = 30;
7
+
8
+ export class Money {
9
+ private constructor(
10
+ readonly currency: string,
11
+ readonly exponent: number,
12
+ private readonly amount: bigint,
13
+ ) {}
14
+
15
+ static create(currency: string, exponent: number, amount: bigint): Money {
16
+ if (!currencyPattern.test(currency)) {
17
+ throw new Error("pact: currency must match [A-Z0-9]{1,16}");
18
+ }
19
+ if (!Number.isInteger(exponent) || exponent < 0 || exponent > maxExponent) {
20
+ throw new Error(`pact: exponent must be 0..${maxExponent}`);
21
+ }
22
+ if (amount < 0n) {
23
+ throw new Error("pact: amount must be non-negative");
24
+ }
25
+ return new Money(currency, exponent, amount);
26
+ }
27
+
28
+ // parse builds Money from a decimal-ASCII minor-unit string, the form used on
29
+ // the wire.
30
+ static parse(currency: string, exponent: number, minor: string): Money {
31
+ if (!/^\d+$/.test(minor)) {
32
+ throw new Error(`pact: ${minor} is not a base-10 integer`);
33
+ }
34
+ return Money.create(currency, exponent, BigInt(minor));
35
+ }
36
+
37
+ // minor renders the amount as canonical decimal ASCII: no leading zeros, "0"
38
+ // for zero. This is the exact string the canonical writer and the wire use.
39
+ minor(): string {
40
+ return this.amount.toString();
41
+ }
42
+
43
+ value(): bigint {
44
+ return this.amount;
45
+ }
46
+
47
+ private sameKind(other: Money): boolean {
48
+ return this.currency === other.currency && this.exponent === other.exponent;
49
+ }
50
+
51
+ add(other: Money): Money {
52
+ if (!this.sameKind(other)) {
53
+ throw new Error("pact: money operands differ in currency or exponent");
54
+ }
55
+ return new Money(this.currency, this.exponent, this.amount + other.amount);
56
+ }
57
+
58
+ // sub errors when the result would be negative: Money has no negative range,
59
+ // and a refund is a settlement, not a negative sum.
60
+ sub(other: Money): Money {
61
+ if (!this.sameKind(other)) {
62
+ throw new Error("pact: money operands differ in currency or exponent");
63
+ }
64
+ const diff = this.amount - other.amount;
65
+ if (diff < 0n) {
66
+ throw new Error("pact: amount must be non-negative");
67
+ }
68
+ return new Money(this.currency, this.exponent, diff);
69
+ }
70
+
71
+ cmp(other: Money): number {
72
+ if (!this.sameKind(other)) {
73
+ throw new Error("pact: money operands differ in currency or exponent");
74
+ }
75
+ if (this.amount < other.amount) return -1;
76
+ if (this.amount > other.amount) return 1;
77
+ return 0;
78
+ }
79
+
80
+ isZero(): boolean {
81
+ return this.amount === 0n;
82
+ }
83
+
84
+ toString(): string {
85
+ return `${this.minor()} ${this.currency}/${this.exponent}`;
86
+ }
87
+ }
package/src/payload.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { ID } from "./protocol.js";
2
+ import { encodeMessage, decodeMessage, type WireMessage } from "./wire.js";
3
+
4
+ // A payload is how a payment message rides inside a host's own envelope — a chat
5
+ // message, a queue entry, any transport. It wraps a wire frame with a small
6
+ // self-identifying header so a receiver can tell a PACT payload apart from other
7
+ // envelope content and reject a version it does not understand. The host carries
8
+ // opaque bytes and stays oblivious to the message inside.
9
+
10
+ const payloadMagic = new TextEncoder().encode(ID);
11
+
12
+ // PAYLOAD_VERSION is the framing version, independent of the wire version, so the
13
+ // envelope format can evolve without breaking a decoder.
14
+ export const PAYLOAD_VERSION = 1;
15
+
16
+ export class NotPayloadError extends Error {
17
+ constructor() {
18
+ super("pact: not a pact payload");
19
+ this.name = "NotPayloadError";
20
+ }
21
+ }
22
+
23
+ export class UnsupportedPayloadVersionError extends Error {
24
+ constructor(version: number) {
25
+ super(`pact: unsupported payload version ${version}`);
26
+ this.name = "UnsupportedPayloadVersionError";
27
+ }
28
+ }
29
+
30
+ export function encodePayload(msg: WireMessage): Uint8Array {
31
+ const frame = encodeMessage(msg);
32
+ const out = new Uint8Array(payloadMagic.length + 1 + frame.length);
33
+ out.set(payloadMagic, 0);
34
+ out[payloadMagic.length] = PAYLOAD_VERSION;
35
+ out.set(frame, payloadMagic.length + 1);
36
+ return out;
37
+ }
38
+
39
+ // isPayload reports whether bytes carry the PACT payload header, so an envelope
40
+ // decoder can route them to decodePayload without attempting a full parse.
41
+ export function isPayload(bytes: Uint8Array): boolean {
42
+ if (bytes.length <= payloadMagic.length) return false;
43
+ for (let i = 0; i < payloadMagic.length; i++) {
44
+ if (bytes[i] !== payloadMagic[i]) return false;
45
+ }
46
+ return true;
47
+ }
48
+
49
+ export function decodePayload(bytes: Uint8Array): WireMessage {
50
+ if (!isPayload(bytes)) {
51
+ throw new NotPayloadError();
52
+ }
53
+ const version = bytes[payloadMagic.length] as number;
54
+ if (version !== PAYLOAD_VERSION) {
55
+ throw new UnsupportedPayloadVersionError(version);
56
+ }
57
+ return decodeMessage(bytes.subarray(payloadMagic.length + 1));
58
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,110 @@
1
+ import { Money } from "./money.js";
2
+ import type { Intent, Quote } from "./message.js";
3
+ import type { KycProvider, KycStatus, RiskHook, RiskDecision } from "./compliance.js";
4
+
5
+ // This is an optional reference implementation of the compliance seams. The
6
+ // kernel ships no policy of its own; an adopter either wires this in or supplies
7
+ // their own KycProvider and RiskHook. Nothing here is required by the protocol,
8
+ // and everything is configurable so it can be replaced without touching the
9
+ // kernel.
10
+
11
+ // Tier is a verification level with the limits and reach it grants. An adopter
12
+ // defines the tiers their jurisdiction and licensing require.
13
+ export interface Tier {
14
+ name: string;
15
+ jurisdiction: string;
16
+ perPayment?: Money; // absent means no per-payment cap
17
+ windowSpend?: Money; // absent means no rolling-window cap
18
+ allowedRails?: string[]; // absent or empty means every rail is allowed
19
+ }
20
+
21
+ function railAllowed(tier: Tier, rail: string): boolean {
22
+ if (!tier.allowedRails || tier.allowedRails.length === 0) return true;
23
+ return tier.allowedRails.includes(rail);
24
+ }
25
+
26
+ // TieredKyc resolves an identity to its tier and reports the per-payment limit the
27
+ // kernel's router already enforces. Identities with no assigned tier fall to the
28
+ // configured fallback tier.
29
+ export class TieredKyc implements KycProvider {
30
+ private readonly tiers: Map<string, Tier>;
31
+
32
+ constructor(
33
+ tiers: Tier[],
34
+ private readonly assigned: Record<string, string>,
35
+ private readonly fallback: Tier,
36
+ ) {
37
+ this.tiers = new Map(tiers.map((t) => [t.name, t]));
38
+ }
39
+
40
+ // tierFor returns the tier governing an identity.
41
+ tierFor(identity: string): Tier {
42
+ const name = this.assigned[identity];
43
+ if (name !== undefined) {
44
+ const tier = this.tiers.get(name);
45
+ if (tier) return tier;
46
+ }
47
+ return this.fallback;
48
+ }
49
+
50
+ async status(identity: string): Promise<KycStatus> {
51
+ const tier = this.tierFor(identity);
52
+ return {
53
+ tier: tier.name,
54
+ jurisdiction: tier.jurisdiction,
55
+ limits: tier.perPayment ? { perPayment: tier.perPayment } : {},
56
+ };
57
+ }
58
+ }
59
+
60
+ interface SpendEntry {
61
+ atMillis: number;
62
+ amount: Money;
63
+ }
64
+
65
+ // WindowRisk enforces the per-tier rail allow-list and a rolling-window spend
66
+ // cap. It owns its own spend ledger, so the state a compliance decision needs
67
+ // never leaks into the kernel.
68
+ export class WindowRisk implements RiskHook {
69
+ private readonly spend = new Map<string, SpendEntry[]>();
70
+
71
+ constructor(
72
+ private readonly kyc: TieredKyc,
73
+ private readonly windowMillis: number,
74
+ private readonly now: () => number,
75
+ ) {}
76
+
77
+ // evaluate vetoes a payment whose rail the tier forbids, or whose source amount
78
+ // would push the identity's spend over the window cap. An allowed payment is
79
+ // recorded so it counts against the window for the next one.
80
+ evaluate(identity: string, _intent: Intent, quote: Quote): RiskDecision {
81
+ const tier = this.kyc.tierFor(identity);
82
+ if (!railAllowed(tier, quote.payInRail) || !railAllowed(tier, quote.payOutRail)) {
83
+ return { allow: false, reason: "rail not permitted for this tier" };
84
+ }
85
+ if (!tier.windowSpend) {
86
+ return { allow: true, reason: "" };
87
+ }
88
+
89
+ const now = this.now();
90
+ const horizon = now - this.windowMillis;
91
+ const kept = (this.spend.get(identity) ?? []).filter((e) => e.atMillis > horizon);
92
+
93
+ let total = Money.create(tier.windowSpend.currency, tier.windowSpend.exponent, 0n);
94
+ try {
95
+ for (const e of kept) total = total.add(e.amount);
96
+ const projected = total.add(quote.srcAmount);
97
+ if (projected.cmp(tier.windowSpend) > 0) {
98
+ this.spend.set(identity, kept);
99
+ return { allow: false, reason: "payment exceeds the rolling spend limit" };
100
+ }
101
+ } catch {
102
+ this.spend.set(identity, kept);
103
+ return { allow: false, reason: "payment currency differs from the window cap" };
104
+ }
105
+
106
+ kept.push({ atMillis: now, amount: quote.srcAmount });
107
+ this.spend.set(identity, kept);
108
+ return { allow: true, reason: "" };
109
+ }
110
+ }
@@ -0,0 +1,19 @@
1
+ // ID is the stable protocol identifier. It is a technical constant, not a brand:
2
+ // it appears only inside domain-separation tags and is never localized or
3
+ // rebranded. Changing it is a breaking wire migration.
4
+ export const ID = "pact";
5
+
6
+ // VERSION is the wire version. It composes with ID into the domain prefix every
7
+ // signed preimage begins with.
8
+ export const VERSION = "1";
9
+
10
+ const domainPrefix = `${ID}/${VERSION}/`;
11
+
12
+ // domain builds the full domain tag for a message kind, e.g. "pact/1/intent".
13
+ export function domain(kind: string): string {
14
+ return domainPrefix + kind;
15
+ }
16
+
17
+ // DEFAULT_SKEW_MILLIS is the tolerated clock difference when deciding whether an
18
+ // intent or quote has expired.
19
+ export const DEFAULT_SKEW_MILLIS = 120_000;
package/src/router.ts ADDED
@@ -0,0 +1,97 @@
1
+ import type { Quote } from "./message.js";
2
+ import type { KycStatus } from "./compliance.js";
3
+ import { withinLimits } from "./compliance.js";
4
+
5
+ // PolicyKind names how the router chooses among competing quotes.
6
+ export enum PolicyKind {
7
+ Cheapest = 0,
8
+ Fastest,
9
+ PreferredRail,
10
+ }
11
+
12
+ // Policy configures router selection. It is plain data so the client and the
13
+ // server can be handed the identical policy and reach the identical choice.
14
+ export interface Policy {
15
+ kind: PolicyKind;
16
+ preferredRails?: string[];
17
+ }
18
+
19
+ export class NoQuoteError extends Error {
20
+ constructor(reason: string) {
21
+ super(`pact: no eligible quote: ${reason}`);
22
+ this.name = "NoQuoteError";
23
+ }
24
+ }
25
+
26
+ export function isExpired(deadline: number, now: number, skew: number): boolean {
27
+ if (deadline === 0) return false;
28
+ return deadline + skew <= now;
29
+ }
30
+
31
+ // route selects one quote deterministically. It first discards quotes that are
32
+ // expired against now or that exceed the identity's KYC limit, then ranks the
33
+ // survivors by the policy. Ties break by pay-in adapter, then pay-out adapter,
34
+ // then quote id, so the choice is total and stable and never depends on input
35
+ // order or randomness.
36
+ export function route(quotes: Quote[], policy: Policy, kyc: KycStatus, now: number, skew: number): Quote {
37
+ const eligible = quotes.filter((q) => !isExpired(q.expiresAt, now, skew) && withinLimits(kyc, q));
38
+ if (eligible.length === 0) {
39
+ throw new NoQuoteError("all quotes expired or over limit");
40
+ }
41
+ const rank = rankFn(policy);
42
+ eligible.sort((a, b) => {
43
+ const primary = rank(a, b);
44
+ if (primary !== 0) return primary;
45
+ if (a.payInAdapterId !== b.payInAdapterId) return a.payInAdapterId < b.payInAdapterId ? -1 : 1;
46
+ if (a.payOutAdapterId !== b.payOutAdapterId) return a.payOutAdapterId < b.payOutAdapterId ? -1 : 1;
47
+ if (a.id !== b.id) return a.id < b.id ? -1 : 1;
48
+ return 0;
49
+ });
50
+ return eligible[0] as Quote;
51
+ }
52
+
53
+ function rankFn(policy: Policy): (a: Quote, b: Quote) => number {
54
+ switch (policy.kind) {
55
+ case PolicyKind.Fastest:
56
+ return (a, b) => cmpNumber(a.latencyEstimateMs, b.latencyEstimateMs);
57
+ case PolicyKind.PreferredRail: {
58
+ const rank = railRank(policy.preferredRails ?? []);
59
+ return (a, b) => {
60
+ const byRail = cmpNumber(rank(a.payOutRail), rank(b.payOutRail));
61
+ return byRail !== 0 ? byRail : cmpMoney(a, b);
62
+ };
63
+ }
64
+ default:
65
+ return (a, b) => {
66
+ const bySrc = cmpMoney(a, b);
67
+ if (bySrc !== 0) return bySrc;
68
+ return safeCmp(a.fees, b.fees);
69
+ };
70
+ }
71
+ }
72
+
73
+ function railRank(preferred: string[]): (rail: string) => number {
74
+ const index = new Map<string, number>();
75
+ preferred.forEach((rail, i) => index.set(rail, i));
76
+ const unranked = preferred.length;
77
+ return (rail) => index.get(rail) ?? unranked;
78
+ }
79
+
80
+ function cmpNumber(a: number, b: number): number {
81
+ return a < b ? -1 : a > b ? 1 : 0;
82
+ }
83
+
84
+ // cmpMoney orders two quotes by source amount. Amounts in different currencies
85
+ // are treated as incomparable and reported equal, deferring to the tie-break so
86
+ // the router never throws on a mixed-currency quote set.
87
+ function cmpMoney(a: Quote, b: Quote): number {
88
+ return safeCmp(a.srcAmount, b.srcAmount);
89
+ }
90
+
91
+ function safeCmp(a: { cmp(o: typeof a): number }, b: typeof a): number {
92
+ try {
93
+ return a.cmp(b);
94
+ } catch {
95
+ return 0;
96
+ }
97
+ }
package/src/signing.ts ADDED
@@ -0,0 +1,92 @@
1
+ import type { Intent, Quote, Authorization } from "./message.js";
2
+ import { intentHash, quoteHash, signingPreimage } from "./message.js";
3
+ import { ed25519KeyFromSeed, ed25519Sign, ed25519Verify } from "./crypto.js";
4
+ import type { KeyObject } from "node:crypto";
5
+
6
+ // Signer produces a signature over a preimage for an identity. The host supplies
7
+ // it, reusing whatever key already backs the identity. The kernel never holds or
8
+ // sees private key material.
9
+ export interface Signer {
10
+ identity(): string;
11
+ sign(preimage: Uint8Array): Uint8Array;
12
+ }
13
+
14
+ // Verifier checks a signature over a preimage against a claimed identity. It is
15
+ // the host-supplied counterpart to Signer, keeping the kernel scheme-agnostic.
16
+ export interface Verifier {
17
+ verify(identity: string, preimage: Uint8Array, signature: Uint8Array): boolean;
18
+ }
19
+
20
+ export class BadSignatureError extends Error {
21
+ constructor() {
22
+ super("pact: signature does not verify");
23
+ this.name = "BadSignatureError";
24
+ }
25
+ }
26
+
27
+ // authorize builds and signs an authorization committing a sender to one quote
28
+ // for one intent.
29
+ export function authorize(intent: Intent, quote: Quote, signer: Signer, signedAt: number): Authorization {
30
+ if (quote.intentId !== intent.id) {
31
+ throw new Error("pact: quote does not belong to intent");
32
+ }
33
+ const preimage = signingPreimage(intentHash(intent), quoteHash(quote), signer.identity(), signedAt);
34
+ return {
35
+ intentId: intent.id,
36
+ quoteId: quote.id,
37
+ signerIdentity: signer.identity(),
38
+ signedAt,
39
+ signature: signer.sign(preimage),
40
+ };
41
+ }
42
+
43
+ // verifyAuthorization checks that an authorization was signed for exactly this
44
+ // intent and quote. Binding both hashes into the preimage makes a captured
45
+ // signature useless against any other intent or quote.
46
+ export function verifyAuthorization(auth: Authorization, intent: Intent, quote: Quote, verifier: Verifier): void {
47
+ if (auth.intentId !== intent.id || auth.quoteId !== quote.id || quote.intentId !== intent.id) {
48
+ throw new Error("pact: authorization does not match intent and quote");
49
+ }
50
+ const preimage = signingPreimage(intentHash(intent), quoteHash(quote), auth.signerIdentity, auth.signedAt);
51
+ if (!verifier.verify(auth.signerIdentity, preimage, auth.signature)) {
52
+ throw new BadSignatureError();
53
+ }
54
+ }
55
+
56
+ // Ed25519Signer is the reference signer used by the shared test vectors and by
57
+ // hosts whose identity key is an Ed25519 key. Hosts that sign with a
58
+ // smart-account validator supply their own Signer instead.
59
+ export class Ed25519Signer implements Signer {
60
+ private readonly privateKey: KeyObject;
61
+ readonly publicKey: Uint8Array;
62
+
63
+ private constructor(private readonly id: string, privateKey: KeyObject, publicKey: Uint8Array) {
64
+ this.privateKey = privateKey;
65
+ this.publicKey = publicKey;
66
+ }
67
+
68
+ static fromSeed(identity: string, seed: Uint8Array): Ed25519Signer {
69
+ const { privateKey, publicKey } = ed25519KeyFromSeed(seed);
70
+ return new Ed25519Signer(identity, privateKey, publicKey);
71
+ }
72
+
73
+ identity(): string {
74
+ return this.id;
75
+ }
76
+
77
+ sign(preimage: Uint8Array): Uint8Array {
78
+ return ed25519Sign(this.privateKey, preimage);
79
+ }
80
+ }
81
+
82
+ // Ed25519Verifier resolves an identity string to its Ed25519 public key. A real
83
+ // host resolves keys from its own key directory instead of a map.
84
+ export class Ed25519Verifier implements Verifier {
85
+ constructor(private readonly keys: Map<string, Uint8Array>) {}
86
+
87
+ verify(identity: string, preimage: Uint8Array, signature: Uint8Array): boolean {
88
+ const pub = this.keys.get(identity);
89
+ if (!pub) return false;
90
+ return ed25519Verify(pub, preimage, signature);
91
+ }
92
+ }