@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.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/contracts/foundry.toml +9 -0
- package/contracts/src/ColdVault.sol +206 -0
- package/contracts/src/DepositFactory.sol +202 -0
- package/contracts/src/DepositProxy.sol +72 -0
- package/contracts/src/IERC20.sol +7 -0
- package/contracts/src/MockTokens.sol +32 -0
- package/contracts/src/SafeTransferLib.sol +31 -0
- package/contracts/test/Custody.t.sol +361 -0
- package/contracts/test/Vectors.t.sol +45 -0
- package/dist/audit.d.ts +265 -0
- package/dist/audit.js +291 -0
- package/dist/backup.d.ts +243 -0
- package/dist/backup.js +302 -0
- package/dist/calendar.d.ts +136 -0
- package/dist/calendar.js +129 -0
- package/dist/compliance.d.ts +172 -0
- package/dist/compliance.js +168 -0
- package/dist/finance/binance.d.ts +27 -0
- package/dist/finance/binance.js +452 -0
- package/dist/finance/chain-addresses.d.ts +130 -0
- package/dist/finance/chain-addresses.js +462 -0
- package/dist/finance/chain-deposits.d.ts +193 -0
- package/dist/finance/chain-deposits.js +596 -0
- package/dist/finance/chain-reconcile.d.ts +112 -0
- package/dist/finance/chain-reconcile.js +76 -0
- package/dist/finance/chain-withdrawals.d.ts +223 -0
- package/dist/finance/chain-withdrawals.js +631 -0
- package/dist/finance/chain.d.ts +116 -0
- package/dist/finance/chain.js +316 -0
- package/dist/finance/commission.d.ts +155 -0
- package/dist/finance/commission.js +419 -0
- package/dist/finance/custody.d.ts +68 -0
- package/dist/finance/custody.js +107 -0
- package/dist/finance/derive.d.ts +115 -0
- package/dist/finance/derive.js +116 -0
- package/dist/finance/discounts.d.ts +98 -0
- package/dist/finance/discounts.js +90 -0
- package/dist/finance/ledger.d.ts +221 -0
- package/dist/finance/ledger.js +308 -0
- package/dist/finance/market.d.ts +209 -0
- package/dist/finance/market.js +112 -0
- package/dist/finance/money.d.ts +118 -0
- package/dist/finance/money.js +176 -0
- package/dist/finance/rates.d.ts +178 -0
- package/dist/finance/rates.js +292 -0
- package/dist/finance/storage.d.ts +113 -0
- package/dist/finance/storage.js +226 -0
- package/dist/finance/tax.d.ts +132 -0
- package/dist/finance/tax.js +291 -0
- package/dist/finance/transfers.d.ts +153 -0
- package/dist/finance/transfers.js +292 -0
- package/dist/finance/venues.d.ts +190 -0
- package/dist/finance/venues.js +251 -0
- package/dist/identity.d.ts +115 -0
- package/dist/identity.js +111 -0
- package/dist/importers.d.ts +87 -0
- package/dist/importers.js +250 -0
- package/dist/jobs.d.ts +171 -0
- package/dist/jobs.js +250 -0
- package/dist/notify-templates.d.ts +11 -0
- package/dist/notify-templates.js +254 -0
- package/dist/notify.d.ts +172 -0
- package/dist/notify.js +122 -0
- package/dist/openssh.d.ts +36 -0
- package/dist/openssh.js +106 -0
- package/dist/otpauth.d.ts +57 -0
- package/dist/otpauth.js +223 -0
- package/dist/outbox.d.ts +234 -0
- package/dist/outbox.js +236 -0
- package/dist/passkey.d.ts +120 -0
- package/dist/passkey.js +105 -0
- package/dist/phrase.d.ts +87 -0
- package/dist/phrase.js +87 -0
- package/dist/pipeline.d.ts +137 -0
- package/dist/pipeline.js +121 -0
- package/dist/queue.d.ts +243 -0
- package/dist/queue.js +246 -0
- package/dist/schema-typebox.d.ts +24 -0
- package/dist/schema-typebox.js +201 -0
- package/dist/schema.d.ts +134 -0
- package/dist/schema.js +169 -0
- package/dist/serial.d.ts +54 -0
- package/dist/serial.js +40 -0
- package/dist/slip10.d.ts +37 -0
- package/dist/slip10.js +74 -0
- package/dist/snp.d.ts +115 -0
- package/dist/snp.js +109 -0
- package/dist/ssh-agent.d.ts +70 -0
- package/dist/ssh-agent.js +141 -0
- package/dist/ssh-cert.d.ts +73 -0
- package/dist/ssh-cert.js +111 -0
- package/dist/totp.d.ts +104 -0
- package/dist/totp.js +143 -0
- 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";
|