@forgezero/runtime 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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +89 -0
  3. package/contracts/foundry.toml +9 -0
  4. package/contracts/src/ColdVault.sol +206 -0
  5. package/contracts/src/DepositFactory.sol +202 -0
  6. package/contracts/src/DepositProxy.sol +72 -0
  7. package/contracts/src/IERC20.sol +7 -0
  8. package/contracts/src/MockTokens.sol +32 -0
  9. package/contracts/src/SafeTransferLib.sol +31 -0
  10. package/contracts/test/Custody.t.sol +361 -0
  11. package/contracts/test/Vectors.t.sol +45 -0
  12. package/dist/audit.d.ts +265 -0
  13. package/dist/audit.js +291 -0
  14. package/dist/backup.d.ts +243 -0
  15. package/dist/backup.js +302 -0
  16. package/dist/calendar.d.ts +136 -0
  17. package/dist/calendar.js +129 -0
  18. package/dist/compliance.d.ts +172 -0
  19. package/dist/compliance.js +168 -0
  20. package/dist/finance/binance.d.ts +27 -0
  21. package/dist/finance/binance.js +452 -0
  22. package/dist/finance/chain-addresses.d.ts +130 -0
  23. package/dist/finance/chain-addresses.js +462 -0
  24. package/dist/finance/chain-deposits.d.ts +193 -0
  25. package/dist/finance/chain-deposits.js +596 -0
  26. package/dist/finance/chain-reconcile.d.ts +112 -0
  27. package/dist/finance/chain-reconcile.js +76 -0
  28. package/dist/finance/chain-withdrawals.d.ts +223 -0
  29. package/dist/finance/chain-withdrawals.js +631 -0
  30. package/dist/finance/chain.d.ts +116 -0
  31. package/dist/finance/chain.js +316 -0
  32. package/dist/finance/commission.d.ts +155 -0
  33. package/dist/finance/commission.js +419 -0
  34. package/dist/finance/custody.d.ts +68 -0
  35. package/dist/finance/custody.js +107 -0
  36. package/dist/finance/derive.d.ts +115 -0
  37. package/dist/finance/derive.js +116 -0
  38. package/dist/finance/discounts.d.ts +98 -0
  39. package/dist/finance/discounts.js +90 -0
  40. package/dist/finance/ledger.d.ts +221 -0
  41. package/dist/finance/ledger.js +308 -0
  42. package/dist/finance/market.d.ts +209 -0
  43. package/dist/finance/market.js +112 -0
  44. package/dist/finance/money.d.ts +118 -0
  45. package/dist/finance/money.js +176 -0
  46. package/dist/finance/rates.d.ts +178 -0
  47. package/dist/finance/rates.js +292 -0
  48. package/dist/finance/storage.d.ts +113 -0
  49. package/dist/finance/storage.js +226 -0
  50. package/dist/finance/tax.d.ts +132 -0
  51. package/dist/finance/tax.js +291 -0
  52. package/dist/finance/transfers.d.ts +153 -0
  53. package/dist/finance/transfers.js +292 -0
  54. package/dist/finance/venues.d.ts +190 -0
  55. package/dist/finance/venues.js +251 -0
  56. package/dist/identity.d.ts +115 -0
  57. package/dist/identity.js +111 -0
  58. package/dist/importers.d.ts +87 -0
  59. package/dist/importers.js +250 -0
  60. package/dist/jobs.d.ts +171 -0
  61. package/dist/jobs.js +250 -0
  62. package/dist/notify-templates.d.ts +11 -0
  63. package/dist/notify-templates.js +254 -0
  64. package/dist/notify.d.ts +172 -0
  65. package/dist/notify.js +122 -0
  66. package/dist/openssh.d.ts +36 -0
  67. package/dist/openssh.js +106 -0
  68. package/dist/otpauth.d.ts +57 -0
  69. package/dist/otpauth.js +223 -0
  70. package/dist/outbox.d.ts +234 -0
  71. package/dist/outbox.js +236 -0
  72. package/dist/passkey.d.ts +120 -0
  73. package/dist/passkey.js +105 -0
  74. package/dist/phrase.d.ts +87 -0
  75. package/dist/phrase.js +87 -0
  76. package/dist/pipeline.d.ts +137 -0
  77. package/dist/pipeline.js +121 -0
  78. package/dist/queue.d.ts +243 -0
  79. package/dist/queue.js +246 -0
  80. package/dist/schema-typebox.d.ts +24 -0
  81. package/dist/schema-typebox.js +201 -0
  82. package/dist/schema.d.ts +134 -0
  83. package/dist/schema.js +169 -0
  84. package/dist/serial.d.ts +54 -0
  85. package/dist/serial.js +40 -0
  86. package/dist/slip10.d.ts +37 -0
  87. package/dist/slip10.js +74 -0
  88. package/dist/snp.d.ts +115 -0
  89. package/dist/snp.js +109 -0
  90. package/dist/ssh-agent.d.ts +70 -0
  91. package/dist/ssh-agent.js +141 -0
  92. package/dist/ssh-cert.d.ts +73 -0
  93. package/dist/ssh-cert.js +111 -0
  94. package/dist/totp.d.ts +104 -0
  95. package/dist/totp.js +143 -0
  96. package/package.json +248 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Declaring a value ForgeZero generates rather than the tenant supplying.
3
+ *
4
+ * A tenant that holds a signing key holds the whole risk of holding a signing
5
+ * key: it appears in their backups, their logs, their heap dumps and their
6
+ * environment variables. The alternative is that they never have it. They
7
+ * declare which chain they have activated, ForgeZero derives the key from the
8
+ * realm master seed, and the only thing that ever crosses back is an ADDRESS.
9
+ *
10
+ * This module is the pure half — the specification and the path arithmetic. It
11
+ * cannot derive anything, because it has no seed and must not: this runs in a
12
+ * browser and in a tenant's process, and neither is a place a master seed
13
+ * belongs. The platform half does the derivation and the signing.
14
+ *
15
+ * ## Deterministic, so a lost row is not lost money
16
+ *
17
+ * The path is a function of the declared fields. Losing the database row loses
18
+ * the metadata and not the key — re-declaring the same chain and index derives
19
+ * the same address, and whatever was sent to it is reachable again. A random
20
+ * key stored next to the row would make that row the only copy.
21
+ */
22
+ export declare class DeriveError extends Error {
23
+ readonly code: 'BAD_SPEC' | 'BAD_INDEX' | 'UNSUPPORTED_SCHEME';
24
+ constructor(code: 'BAD_SPEC' | 'BAD_INDEX' | 'UNSUPPORTED_SCHEME', message: string);
25
+ }
26
+ /** Curves a chain actually verifies against. */
27
+ export declare const SCHEMES: readonly ["secp256k1", "ed25519"];
28
+ export type Scheme = (typeof SCHEMES)[number];
29
+ /**
30
+ * Who generates the key, which is a real trade-off and not a default.
31
+ *
32
+ * derived ForgeZero generates it from the realm master seed, on demand,
33
+ * and stores NOTHING. There is no key at rest to steal, and a lost
34
+ * row is recoverable because the key is a function of the seed and
35
+ * the path. The cost: the platform can always regenerate it, so
36
+ * the tenant's key is only as private as the ceremony guarding the
37
+ * seed.
38
+ *
39
+ * supplied the tenant generates it — on their own hardware, in their own
40
+ * ceremony, however they like — and the vault encrypts and stores
41
+ * it. The platform could never have produced this key, which is
42
+ * exactly the point for a tenant who needs that to be true. The
43
+ * cost: there is now a copy at rest, and losing it loses the key,
44
+ * because nothing can regenerate what the platform never derived.
45
+ *
46
+ * Both sign the same way and neither ever returns the private half. The choice
47
+ * is about who is CAPABLE of generating the key, not about how it is used.
48
+ */
49
+ export declare const CUSTODY: readonly ["derived", "supplied"];
50
+ export type Custody = (typeof CUSTODY)[number];
51
+ /**
52
+ * How one platform-managed field is produced.
53
+ *
54
+ * Carried in the JSON Schema under `x-fz-derive`, so the schema stays the one
55
+ * description of an entry — a separate list of "which fields are magic" is a
56
+ * second source of truth that drifts the first time somebody edits one.
57
+ */
58
+ export interface DeriveSpec {
59
+ /** Defaults to `derived`: nothing at rest is the safer default to forget. */
60
+ custody?: Custody;
61
+ scheme: Scheme;
62
+ /** BIP-44 coin type. 60 is every EVM chain, 0 is Bitcoin. Derived only. */
63
+ coinType?: number;
64
+ account?: number;
65
+ change?: 0 | 1;
66
+ /** Address index. Taken from a sibling field when `indexFrom` is set. */
67
+ index?: number;
68
+ /**
69
+ * Read the index from another field of the same entry.
70
+ *
71
+ * Lets one schema describe a whole series — a tenant adds an entry with
72
+ * `index: 7` rather than authoring a seventh schema.
73
+ */
74
+ indexFrom?: string;
75
+ /** What comes back to the tenant. Never the private key, under any value. */
76
+ expose?: 'address' | 'publicKey';
77
+ }
78
+ export declare const custodyOf: (spec: DeriveSpec) => Custody;
79
+ export declare function assertDeriveSpec(spec: DeriveSpec): void;
80
+ /**
81
+ * The BIP-44 path this spec resolves to, for one entry's data.
82
+ *
83
+ * `m / 44' / coin' / account' / change / index`. Purpose, coin and account are
84
+ * hardened; change and index are not, so a watch-only public key can enumerate
85
+ * addresses without exposing the account.
86
+ */
87
+ export declare function pathFor(spec: DeriveSpec, data?: Record<string, unknown>): string;
88
+ /** The `x-fz-derive` annotation on one property, if it carries one. */
89
+ export declare function deriveSpecOf(property: unknown): DeriveSpec | undefined;
90
+ export interface DerivedField {
91
+ /** Dotted path into the entry's data. */
92
+ path: string;
93
+ spec: DeriveSpec;
94
+ }
95
+ /**
96
+ * Every field of a schema that ForgeZero fills in.
97
+ *
98
+ * Read from the schema rather than configured alongside it, so a field cannot
99
+ * be derived in one place and expected from the tenant in another.
100
+ */
101
+ export declare function derivedFields(schema: unknown, prefix?: string): DerivedField[];
102
+ /**
103
+ * Fields a tenant must NOT send.
104
+ *
105
+ * Only the DERIVED ones. Accepting a value for a derived field would let a
106
+ * caller substitute their own key for the platform's — the entry would look
107
+ * identical and every signature would come from a key the tenant controls.
108
+ *
109
+ * A `supplied` field is the opposite: the tenant is REQUIRED to send it, once,
110
+ * and it is sealed on arrival.
111
+ */
112
+ export declare const derivedPaths: (schema: unknown) => readonly string[];
113
+ /** Fields the tenant generates and the vault only seals. */
114
+ export declare const suppliedPaths: (schema: unknown) => readonly string[];
115
+ export declare const DERIVE_KEYWORD: "x-fz-derive";
@@ -0,0 +1,116 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/finance/derive.ts
10
+ class DeriveError extends Error {
11
+ code;
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.code = code;
15
+ this.name = "DeriveError";
16
+ }
17
+ }
18
+ var SCHEMES = ["secp256k1", "ed25519"];
19
+ var CUSTODY = ["derived", "supplied"];
20
+ var HARDENED = 2147483648;
21
+ var assertIndex = (value, what) => {
22
+ if (!Number.isInteger(value) || value < 0 || value >= HARDENED) {
23
+ throw new DeriveError("BAD_INDEX", `${what} must be an integer in 0..${HARDENED - 1}.`);
24
+ }
25
+ return value;
26
+ };
27
+ var custodyOf = (spec) => spec.custody ?? "derived";
28
+ function assertDeriveSpec(spec) {
29
+ if (!SCHEMES.includes(spec.scheme)) {
30
+ throw new DeriveError("UNSUPPORTED_SCHEME", `${spec.scheme} is not a supported curve.`);
31
+ }
32
+ if (custodyOf(spec) === "supplied") {
33
+ if (spec.coinType !== undefined || spec.index !== undefined || spec.indexFrom) {
34
+ throw new DeriveError("BAD_SPEC", "A supplied key has no derivation path; drop coinType, index and indexFrom.");
35
+ }
36
+ return;
37
+ }
38
+ if (spec.coinType === undefined) {
39
+ throw new DeriveError("BAD_SPEC", "A derived key needs a coinType.");
40
+ }
41
+ assertIndex(spec.coinType, "coinType");
42
+ if (spec.account !== undefined)
43
+ assertIndex(spec.account, "account");
44
+ if (spec.index !== undefined)
45
+ assertIndex(spec.index, "index");
46
+ if (spec.change !== undefined && spec.change !== 0 && spec.change !== 1) {
47
+ throw new DeriveError("BAD_SPEC", "change is 0 (external) or 1 (internal).");
48
+ }
49
+ if (spec.index === undefined && !spec.indexFrom) {
50
+ throw new DeriveError("BAD_SPEC", "A spec needs an index, or a field to read one from.");
51
+ }
52
+ if (spec.index !== undefined && spec.indexFrom) {
53
+ throw new DeriveError("BAD_SPEC", "Give an index or an indexFrom, never both.");
54
+ }
55
+ }
56
+ function pathFor(spec, data = {}) {
57
+ assertDeriveSpec(spec);
58
+ if (custodyOf(spec) === "supplied") {
59
+ throw new DeriveError("BAD_SPEC", "A supplied key has no derivation path.");
60
+ }
61
+ let index = spec.index;
62
+ if (spec.indexFrom) {
63
+ const raw = data[spec.indexFrom];
64
+ const value = typeof raw === "string" ? Number(raw) : raw;
65
+ if (typeof value !== "number") {
66
+ throw new DeriveError("BAD_INDEX", `${spec.indexFrom} is not a number on this entry.`);
67
+ }
68
+ index = assertIndex(value, spec.indexFrom);
69
+ }
70
+ const account = spec.account ?? 0;
71
+ const change = spec.change ?? 0;
72
+ return `m/44'/${spec.coinType}'/${account}'/${change}/${index}`;
73
+ }
74
+ function deriveSpecOf(property) {
75
+ if (typeof property !== "object" || property === null)
76
+ return;
77
+ const spec = property["x-fz-derive"];
78
+ if (typeof spec !== "object" || spec === null)
79
+ return;
80
+ const parsed = spec;
81
+ assertDeriveSpec(parsed);
82
+ return parsed;
83
+ }
84
+ function derivedFields(schema, prefix = "") {
85
+ if (typeof schema !== "object" || schema === null)
86
+ return [];
87
+ const properties = schema.properties;
88
+ if (!properties)
89
+ return [];
90
+ const out = [];
91
+ for (const [name, property] of Object.entries(properties)) {
92
+ const path = prefix ? `${prefix}.${name}` : name;
93
+ const spec = deriveSpecOf(property);
94
+ if (spec)
95
+ out.push({ path, spec });
96
+ else
97
+ out.push(...derivedFields(property, path));
98
+ }
99
+ return out;
100
+ }
101
+ var derivedPaths = (schema) => derivedFields(schema).filter((field) => custodyOf(field.spec) === "derived").map((field) => field.path);
102
+ var suppliedPaths = (schema) => derivedFields(schema).filter((field) => custodyOf(field.spec) === "supplied").map((field) => field.path);
103
+ var DERIVE_KEYWORD = "x-fz-derive";
104
+ export {
105
+ suppliedPaths,
106
+ pathFor,
107
+ derivedPaths,
108
+ derivedFields,
109
+ deriveSpecOf,
110
+ custodyOf,
111
+ assertDeriveSpec,
112
+ SCHEMES,
113
+ DeriveError,
114
+ DERIVE_KEYWORD,
115
+ CUSTODY
116
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Promotions and coupons, as arithmetic over integer minor units.
3
+ *
4
+ * Two rules carry this module, and both exist because the alternative is a
5
+ * customer-visible number that nobody can reproduce:
6
+ *
7
+ * they NEVER stack — the single best offer applies
8
+ * the result is never negative, and never more than what was owed
9
+ *
10
+ * Stacking is where discount systems become unexplainable. Two 50% offers
11
+ * applied in sequence make 75%, applied together make 100%, and which one a
12
+ * customer got depends on the order rows came back from a database. Picking one
13
+ * winner means the invoice can always be explained in a sentence.
14
+ *
15
+ * ## Integers throughout
16
+ *
17
+ * `Math.round(price * 0.15)` on a float is how an invoice ends in a fraction of
18
+ * a cent that reconciliation then argues about. Percentages are applied as
19
+ * `amount * bp / 10_000` on bigints, and the rounding is stated rather than
20
+ * inherited.
21
+ */
22
+ export declare class DiscountError extends Error {
23
+ readonly code: 'BAD_RATE' | 'BAD_AMOUNT' | 'BAD_CODE' | 'BAD_WINDOW';
24
+ constructor(code: 'BAD_RATE' | 'BAD_AMOUNT' | 'BAD_CODE' | 'BAD_WINDOW', message: string);
25
+ }
26
+ export type DiscountKind = 'percentage' | 'fixed';
27
+ export interface Promotion {
28
+ /** What a customer types, or the internal name of a seasonal offer. */
29
+ code: string;
30
+ label: string;
31
+ kind: DiscountKind;
32
+ /**
33
+ * Basis points for a percentage (1500 = 15%), or minor units for a fixed
34
+ * amount. Basis points rather than a percent so 12.5% is expressible without
35
+ * a float anywhere near money.
36
+ */
37
+ value: number;
38
+ /** Epoch seconds. A promotion outside its window does not apply. */
39
+ startsAtSec?: number;
40
+ endsAtSec?: number;
41
+ /** Applies only to invoices at or above this subtotal, in minor units. */
42
+ minimumAmount?: number;
43
+ /** A cap on a percentage discount, in minor units. */
44
+ maximumDiscount?: number;
45
+ /** Total redemptions allowed. `undefined` is unlimited. */
46
+ maxRedemptions?: number;
47
+ redemptions?: number;
48
+ /** Set when an offer is withdrawn. Kept rather than deleted — invoices refer to it. */
49
+ withdrawnAtSec?: number;
50
+ /**
51
+ * Applies with nobody typing anything.
52
+ *
53
+ * A seasonal offer is automatic; a coupon is not. The distinction matters
54
+ * because an automatic promotion is not once-per-tenant — it is a price for
55
+ * a period, and treating it as a coupon would let the first invoice of a
56
+ * quarter consume it for everybody.
57
+ */
58
+ automatic?: boolean;
59
+ }
60
+ export interface AppliedDiscount {
61
+ code: string;
62
+ label: string;
63
+ /** Minor units taken off. Never negative, never more than the subtotal. */
64
+ amount: number;
65
+ /** What the customer pays after it. */
66
+ total: number;
67
+ }
68
+ /** Why a promotion did not apply. Returned, not thrown — a miss is normal. */
69
+ export type Ineligibility = 'not-started' | 'expired' | 'withdrawn' | 'below-minimum' | 'exhausted';
70
+ /**
71
+ * The discount one promotion would take off, before any comparison.
72
+ *
73
+ * A percentage rounds DOWN. Rounding a discount up gives away a unit of currency
74
+ * per invoice, forever, and nobody notices until it is a line in an audit.
75
+ */
76
+ export declare function discountFor(promotion: Promotion, subtotal: number): number;
77
+ /** Is this promotion usable right now, for this subtotal? */
78
+ export declare function ineligibility(promotion: Promotion, subtotal: number, nowSec: number): Ineligibility | null;
79
+ /**
80
+ * Pick the single best applicable promotion.
81
+ *
82
+ * Never a sum. Ties break on the code so the same inputs always produce the same
83
+ * winner — an invoice that names a different promotion on a rerun is one nobody
84
+ * can reconcile, even when the amount matches.
85
+ */
86
+ export declare function bestDiscount(promotions: readonly Promotion[], subtotal: number, nowSec?: number): AppliedDiscount | null;
87
+ /**
88
+ * Codes are compared case-insensitively and without spaces.
89
+ *
90
+ * People type what was printed on a card. Refusing `SPRING 25` because it was
91
+ * stored as `spring25` is a support ticket, not a security control — there is
92
+ * no secret here, only a name.
93
+ */
94
+ export declare const normaliseCode: (code: string) => string;
95
+ /** A code somebody may type. Refused early so it never reaches a lookup. */
96
+ export declare function assertCode(code: string): void;
97
+ /** A window that ends before it starts is a promotion nobody can ever use. */
98
+ export declare function assertWindow(promotion: Pick<Promotion, 'startsAtSec' | 'endsAtSec'>): void;
@@ -0,0 +1,90 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/finance/discounts.ts
10
+ class DiscountError extends Error {
11
+ code;
12
+ constructor(code, message) {
13
+ super(message);
14
+ this.code = code;
15
+ this.name = "DiscountError";
16
+ }
17
+ }
18
+ function discountFor(promotion, subtotal) {
19
+ if (!Number.isInteger(subtotal) || subtotal < 0) {
20
+ throw new DiscountError("BAD_AMOUNT", "A subtotal is a whole number of minor units.");
21
+ }
22
+ if (!Number.isInteger(promotion.value) || promotion.value < 0) {
23
+ throw new DiscountError("BAD_RATE", `"${promotion.value}" is not a usable discount value.`);
24
+ }
25
+ if (promotion.kind === "percentage") {
26
+ if (promotion.value > 1e4) {
27
+ throw new DiscountError("BAD_RATE", "A percentage discount cannot exceed 100% (10000bp).");
28
+ }
29
+ const raw = Number(BigInt(subtotal) * BigInt(promotion.value) / 10000n);
30
+ const capped = promotion.maximumDiscount ?? Number.POSITIVE_INFINITY;
31
+ return Math.min(raw, capped, subtotal);
32
+ }
33
+ return Math.min(promotion.value, subtotal);
34
+ }
35
+ function ineligibility(promotion, subtotal, nowSec) {
36
+ if (promotion.withdrawnAtSec !== undefined && nowSec >= promotion.withdrawnAtSec) {
37
+ return "withdrawn";
38
+ }
39
+ if (promotion.startsAtSec !== undefined && nowSec < promotion.startsAtSec)
40
+ return "not-started";
41
+ if (promotion.endsAtSec !== undefined && nowSec >= promotion.endsAtSec)
42
+ return "expired";
43
+ if (promotion.minimumAmount !== undefined && subtotal < promotion.minimumAmount) {
44
+ return "below-minimum";
45
+ }
46
+ if (promotion.maxRedemptions !== undefined && (promotion.redemptions ?? 0) >= promotion.maxRedemptions) {
47
+ return "exhausted";
48
+ }
49
+ return null;
50
+ }
51
+ function bestDiscount(promotions, subtotal, nowSec = Math.floor(Date.now() / 1000)) {
52
+ let best = null;
53
+ for (const promotion of promotions) {
54
+ if (ineligibility(promotion, subtotal, nowSec) !== null)
55
+ continue;
56
+ const amount = discountFor(promotion, subtotal);
57
+ if (amount <= 0)
58
+ continue;
59
+ if (best === null || amount > best.amount || amount === best.amount && promotion.code < best.code) {
60
+ best = {
61
+ code: promotion.code,
62
+ label: promotion.label,
63
+ amount,
64
+ total: subtotal - amount
65
+ };
66
+ }
67
+ }
68
+ return best;
69
+ }
70
+ var normaliseCode = (code) => code.replace(/\s+/g, "").toUpperCase();
71
+ function assertCode(code) {
72
+ const normalised = normaliseCode(code);
73
+ if (!/^[A-Z0-9][A-Z0-9-]{1,31}$/.test(normalised)) {
74
+ throw new DiscountError("BAD_CODE", "A code is 2–32 characters: letters, digits and hyphens.");
75
+ }
76
+ }
77
+ function assertWindow(promotion) {
78
+ if (promotion.startsAtSec !== undefined && promotion.endsAtSec !== undefined && promotion.endsAtSec <= promotion.startsAtSec) {
79
+ throw new DiscountError("BAD_WINDOW", "A promotion must be usable for a positive interval.");
80
+ }
81
+ }
82
+ export {
83
+ normaliseCode,
84
+ ineligibility,
85
+ discountFor,
86
+ bestDiscount,
87
+ assertWindow,
88
+ assertCode,
89
+ DiscountError
90
+ };
@@ -0,0 +1,221 @@
1
+ import { type Money } from './money';
2
+ /**
3
+ * Balances, as postings.
4
+ *
5
+ * **This module contains no concurrency control, and that is the design.**
6
+ *
7
+ * `@forgezero/runtime/queue` already claims a KEY rather than a message and
8
+ * carries a `dedupeKey`, which gives the two properties people build a ledger
9
+ * lock to get: two writes to one account run one after the other because they
10
+ * share a key, and a retried operation is recognised rather than repeated. A
11
+ * second mechanism next to that is not extra safety — it is a second thing to
12
+ * get right, and it is the one that ends up subtly wrong.
13
+ *
14
+ * So every write here is expected to be inside `drainKey(accountOwner)`, and
15
+ * this module is a data model: entries that sum to zero, balances derived from
16
+ * them.
17
+ *
18
+ * ## A hold is a posting, not a lock
19
+ *
20
+ * "Available" versus "total" is the reason ledgers grow locking code. Here an
21
+ * owner has sub-accounts — `available` and `held` — and placing a hold is a
22
+ * posting between them. `availableOf()` is then an ordinary balance query, and
23
+ * `requireBalance` in `@forgezero/access/conditions` (which already checks
24
+ * available rather than total) is the guard with no new code at all.
25
+ *
26
+ * ## Why double entry rather than a balance column
27
+ *
28
+ * A single column loses the ability to answer *why*. Every movement here has a
29
+ * counterparty and every transaction sums to zero, so a discrepancy names the
30
+ * posting that caused it instead of appearing as a number nobody can
31
+ * reconstruct. It is also what makes a commission split provable: platform
32
+ * revenue, referrer share and user debit are three legs of one transaction that
33
+ * must balance, so a split that loses a unit cannot be committed at all.
34
+ */
35
+ export declare class LedgerError extends Error {
36
+ readonly code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE';
37
+ constructor(code: 'UNBALANCED' | 'EMPTY_TRANSACTION' | 'MIXED_ASSETS' | 'ZERO_ENTRY' | 'UNKNOWN_ACCOUNT' | 'INSUFFICIENT_AVAILABLE', message: string);
38
+ }
39
+ /**
40
+ * The sub-accounts every owner has.
41
+ *
42
+ * available spendable now
43
+ * held committed to something open — an order, a pending withdrawal
44
+ *
45
+ * Splitting them is what removes the need for a lock: the question "is there
46
+ * enough?" becomes a balance read on one sub-account rather than a balance read
47
+ * plus a reservation somewhere else.
48
+ */
49
+ export declare const BUCKETS: readonly ["available", "held"];
50
+ export type Bucket = (typeof BUCKETS)[number];
51
+ /**
52
+ * Where money can sit.
53
+ *
54
+ * `external` is the counterparty for anything crossing the system boundary — a
55
+ * chain deposit, a card payment. Without it a deposit has one leg and cannot
56
+ * balance, which is how single-column ledgers start.
57
+ */
58
+ export declare const ACCOUNT_KINDS: readonly ["user", "platform", "external"];
59
+ export type AccountKind = (typeof ACCOUNT_KINDS)[number];
60
+ export interface AccountRef {
61
+ kind: AccountKind;
62
+ /** User key, or a platform account name like `revenue` or `promotions`. */
63
+ owner: string;
64
+ bucket?: Bucket;
65
+ }
66
+ /** Canonical string form, so an account is comparable and indexable. */
67
+ export declare const accountId: (account: AccountRef) => string;
68
+ export declare function parseAccount(id: string): AccountRef;
69
+ /**
70
+ * The queue key for any write touching this account.
71
+ *
72
+ * The OWNER, not the sub-account: moving between an owner's available and held
73
+ * buckets must serialise against every other write for that owner, or a hold
74
+ * and a withdrawal can interleave and both see the same available balance.
75
+ *
76
+ * Exported so callers cannot invent their own convention — two call sites
77
+ * disagreeing about the key is the same as having no key.
78
+ */
79
+ export declare const queueKeyFor: (account: AccountRef) => string;
80
+ export interface Entry {
81
+ account: AccountRef;
82
+ /** Positive credits the account, negative debits it. */
83
+ amount: Money;
84
+ }
85
+ export interface Transaction {
86
+ /**
87
+ * The idempotency key, and the queue's `dedupeKey`.
88
+ *
89
+ * A retry carrying the same reference is the same transaction. This is the
90
+ * field that makes a redelivered deposit a no-op rather than a double credit.
91
+ */
92
+ reference: string;
93
+ /** `deposit`, `order.fill`, `commission`, `bonus` — read during an incident. */
94
+ kind: string;
95
+ entries: readonly Entry[];
96
+ atMs: number;
97
+ memo?: string;
98
+ }
99
+ /**
100
+ * Every transaction sums to zero, per asset.
101
+ *
102
+ * Per asset, not overall: a transaction moving BTC and USDT in one breath would
103
+ * otherwise "balance" by cancelling unrelated currencies against each other,
104
+ * which is not balance, it is an accident that adds to zero.
105
+ */
106
+ export declare function assertBalanced(transaction: Pick<Transaction, 'entries'>): void;
107
+ /** Two legs: one account credited, another debited, same asset. */
108
+ export declare function transfer(args: {
109
+ reference: string;
110
+ kind: string;
111
+ from: AccountRef;
112
+ to: AccountRef;
113
+ amount: Money;
114
+ atMs: number;
115
+ memo?: string;
116
+ }): Transaction;
117
+ /**
118
+ * Place a hold: available → held, same owner.
119
+ *
120
+ * Not a lock. After this posting `availableOf()` returns less, so the next
121
+ * balance check sees the reduced figure with no reservation table, no expiry
122
+ * sweeper and no way for a crashed process to strand funds behind a lease.
123
+ */
124
+ export declare const placeHold: (args: {
125
+ reference: string;
126
+ owner: string;
127
+ kind?: AccountKind;
128
+ amount: Money;
129
+ atMs: number;
130
+ memo?: string;
131
+ }) => Transaction;
132
+ /** Release a hold: held → available. The order was cancelled, nothing moved. */
133
+ export declare const releaseHold: (args: {
134
+ reference: string;
135
+ owner: string;
136
+ kind?: AccountKind;
137
+ amount: Money;
138
+ atMs: number;
139
+ }) => Transaction;
140
+ /**
141
+ * Capture a hold: held → somewhere else. The thing it was held for happened.
142
+ *
143
+ * Deliberately separate from `releaseHold`. Collapsing them into one "resolve"
144
+ * with a flag is how a cancellation ends up paying somebody: the two have
145
+ * opposite effects and should not share a code path.
146
+ */
147
+ export declare const captureHold: (args: {
148
+ reference: string;
149
+ owner: string;
150
+ kind?: AccountKind;
151
+ to: AccountRef;
152
+ amount: Money;
153
+ atMs: number;
154
+ memo?: string;
155
+ }) => Transaction;
156
+ /**
157
+ * Sum entries into a balance per account and asset.
158
+ *
159
+ * Derived rather than stored, because a stored balance and its postings can
160
+ * disagree — and when they do, nobody can say which is right. Production reads
161
+ * a cached figure the store maintains; this is what proves the cache correct.
162
+ */
163
+ export declare function balancesFrom(transactions: readonly Transaction[]): Map<string, Map<string, Money>>;
164
+ export declare function balanceOf(balances: Map<string, Map<string, Money>>, account: AccountRef, asset: string): Money;
165
+ /** Spendable now. What `requireBalance` should be given as `available`. */
166
+ export declare const availableOf: (balances: Map<string, Map<string, Money>>, owner: string, asset: string, kind?: AccountKind) => Money;
167
+ export declare const heldOf: (balances: Map<string, Map<string, Money>>, owner: string, asset: string, kind?: AccountKind) => Money;
168
+ /** Available plus held. What the owner has, not what they can spend. */
169
+ export declare const totalOf: (balances: Map<string, Map<string, Money>>, owner: string, asset: string, kind?: AccountKind) => Money;
170
+ export interface TrialBalance {
171
+ ok: boolean;
172
+ perAsset: {
173
+ asset: string;
174
+ total: bigint;
175
+ accounts: number;
176
+ }[];
177
+ /** Assets that do not sum to zero, with the discrepancy. */
178
+ discrepancies: {
179
+ asset: string;
180
+ off: bigint;
181
+ }[];
182
+ }
183
+ /**
184
+ * Does the whole book balance?
185
+ *
186
+ * The check that finds a bug rather than preventing one. Every transaction is
187
+ * validated on the way in, so a non-zero total here means something wrote
188
+ * around this module or a row was edited — which is exactly the class of
189
+ * problem that is invisible until a customer notices.
190
+ *
191
+ * Run it on a schedule. A book that balanced yesterday and does not today has a
192
+ * bounded set of transactions to inspect; one nobody checks has a year of them.
193
+ */
194
+ export declare function trialBalance(transactions: readonly Transaction[]): TrialBalance;
195
+ /**
196
+ * A human-readable statement for one account.
197
+ *
198
+ * Ordered by time with a running balance, because the question during a dispute
199
+ * is never "what is the balance" — it is "when did it become that".
200
+ */
201
+ export declare function statement(transactions: readonly Transaction[], account: AccountRef, asset: string): {
202
+ atMs: number;
203
+ reference: string;
204
+ kind: string;
205
+ amount: string;
206
+ balance: string;
207
+ }[];
208
+ /**
209
+ * Would this leave the account short?
210
+ *
211
+ * A convenience for a caller that already holds the balances. The real guard is
212
+ * `requireBalance` at the route, which returns the right status and the right
213
+ * body; this exists so a job — which has no request to refuse — can make the
214
+ * same check without inventing a second answer.
215
+ */
216
+ export declare function assertAvailable(balances: Map<string, Map<string, Money>>, args: {
217
+ owner: string;
218
+ amount: Money;
219
+ kind?: AccountKind;
220
+ }): void;
221
+ export declare const VERSION = "0.1.0";