@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,292 @@
|
|
|
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/money.ts
|
|
10
|
+
class MoneyError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "MoneyError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
var ASSETS = [
|
|
19
|
+
{ code: "USDT", decimals: 6 },
|
|
20
|
+
{ code: "USDC", decimals: 6 },
|
|
21
|
+
{ code: "BTC", decimals: 8 },
|
|
22
|
+
{ code: "ETH", decimals: 18 },
|
|
23
|
+
{ code: "BNB", decimals: 18 },
|
|
24
|
+
{ code: "EUR", decimals: 2 },
|
|
25
|
+
{ code: "USD", decimals: 2 }
|
|
26
|
+
];
|
|
27
|
+
var REGISTRY = new Map(ASSETS.map((asset) => [asset.code, asset]));
|
|
28
|
+
function defineAsset(spec) {
|
|
29
|
+
if (spec.decimals < 0 || spec.decimals > 30 || !Number.isInteger(spec.decimals)) {
|
|
30
|
+
throw new MoneyError("UNKNOWN_ASSET", `${spec.code}: decimals must be an integer 0–30.`);
|
|
31
|
+
}
|
|
32
|
+
REGISTRY.set(spec.code, spec);
|
|
33
|
+
}
|
|
34
|
+
function assetSpec(code) {
|
|
35
|
+
const spec = REGISTRY.get(code);
|
|
36
|
+
if (!spec)
|
|
37
|
+
throw new MoneyError("UNKNOWN_ASSET", `Unknown asset "${code}". Call defineAsset first.`);
|
|
38
|
+
return spec;
|
|
39
|
+
}
|
|
40
|
+
var money = (units, asset) => {
|
|
41
|
+
assetSpec(asset);
|
|
42
|
+
return { units, asset };
|
|
43
|
+
};
|
|
44
|
+
var zero = (asset) => money(0n, asset);
|
|
45
|
+
function parseAmount(value, asset) {
|
|
46
|
+
const spec = assetSpec(asset);
|
|
47
|
+
const text = value.trim();
|
|
48
|
+
if (!/^-?\d+(\.\d+)?$/.test(text)) {
|
|
49
|
+
throw new MoneyError("NOT_FINITE", `"${value}" is not a plain decimal amount.`);
|
|
50
|
+
}
|
|
51
|
+
const negative = text.startsWith("-");
|
|
52
|
+
const [whole, fraction = ""] = text.replace("-", "").split(".");
|
|
53
|
+
if (fraction.length > spec.decimals) {
|
|
54
|
+
throw new MoneyError("PRECISION_LOSS", `${asset} has ${spec.decimals} decimals; "${value}" has ${fraction.length}.`);
|
|
55
|
+
}
|
|
56
|
+
const padded = fraction.padEnd(spec.decimals, "0");
|
|
57
|
+
const units = BigInt(whole + padded);
|
|
58
|
+
return { units: negative ? -units : units, asset };
|
|
59
|
+
}
|
|
60
|
+
function formatAmount(amount, options = {}) {
|
|
61
|
+
const spec = assetSpec(amount.asset);
|
|
62
|
+
const negative = amount.units < 0n;
|
|
63
|
+
const digits = (negative ? -amount.units : amount.units).toString().padStart(spec.decimals + 1, "0");
|
|
64
|
+
const whole = digits.slice(0, digits.length - spec.decimals);
|
|
65
|
+
let fraction = spec.decimals === 0 ? "" : digits.slice(digits.length - spec.decimals);
|
|
66
|
+
if (options.trim && fraction)
|
|
67
|
+
fraction = fraction.replace(/0+$/, "");
|
|
68
|
+
return `${negative ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}`;
|
|
69
|
+
}
|
|
70
|
+
function sameAsset(a, b) {
|
|
71
|
+
if (a.asset !== b.asset) {
|
|
72
|
+
throw new MoneyError("ASSET_MISMATCH", `Cannot combine ${a.asset} and ${b.asset}.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function add(a, b) {
|
|
76
|
+
sameAsset(a, b);
|
|
77
|
+
return { units: a.units + b.units, asset: a.asset };
|
|
78
|
+
}
|
|
79
|
+
function subtract(a, b) {
|
|
80
|
+
sameAsset(a, b);
|
|
81
|
+
return { units: a.units - b.units, asset: a.asset };
|
|
82
|
+
}
|
|
83
|
+
var negate = (amount) => ({ units: -amount.units, asset: amount.asset });
|
|
84
|
+
var abs = (amount) => ({
|
|
85
|
+
units: amount.units < 0n ? -amount.units : amount.units,
|
|
86
|
+
asset: amount.asset
|
|
87
|
+
});
|
|
88
|
+
var isZero = (amount) => amount.units === 0n;
|
|
89
|
+
var isNegative = (amount) => amount.units < 0n;
|
|
90
|
+
function compare(a, b) {
|
|
91
|
+
sameAsset(a, b);
|
|
92
|
+
return a.units < b.units ? -1 : a.units > b.units ? 1 : 0;
|
|
93
|
+
}
|
|
94
|
+
var equals = (a, b) => a.asset === b.asset && a.units === b.units;
|
|
95
|
+
var ROUNDING = ["down", "up", "half-up"];
|
|
96
|
+
function divideRounded(numerator, denominator, mode) {
|
|
97
|
+
if (denominator === 0n)
|
|
98
|
+
throw new MoneyError("DIVIDE_BY_ZERO", "Division by zero.");
|
|
99
|
+
const negative = numerator < 0n !== denominator < 0n;
|
|
100
|
+
const a = numerator < 0n ? -numerator : numerator;
|
|
101
|
+
const b = denominator < 0n ? -denominator : denominator;
|
|
102
|
+
const quotient = a / b;
|
|
103
|
+
const remainder = a % b;
|
|
104
|
+
if (remainder === 0n)
|
|
105
|
+
return negative ? -quotient : quotient;
|
|
106
|
+
let result = quotient;
|
|
107
|
+
if (mode === "up")
|
|
108
|
+
result += 1n;
|
|
109
|
+
else if (mode === "half-up" && remainder * 2n >= b)
|
|
110
|
+
result += 1n;
|
|
111
|
+
return negative ? -result : result;
|
|
112
|
+
}
|
|
113
|
+
function mulRate(amount, rate, mode = "down") {
|
|
114
|
+
if (!/^-?\d+(\.\d+)?$/.test(rate.trim())) {
|
|
115
|
+
throw new MoneyError("NOT_FINITE", `"${rate}" is not a plain decimal rate.`);
|
|
116
|
+
}
|
|
117
|
+
const [whole, fraction = ""] = rate.trim().replace("-", "").split(".");
|
|
118
|
+
const scale = 10n ** BigInt(fraction.length);
|
|
119
|
+
const scaled = BigInt(whole + fraction) * (rate.trim().startsWith("-") ? -1n : 1n);
|
|
120
|
+
return { units: divideRounded(amount.units * scaled, scale, mode), asset: amount.asset };
|
|
121
|
+
}
|
|
122
|
+
function convert(amount, to, rate, mode = "down") {
|
|
123
|
+
const from = assetSpec(amount.asset);
|
|
124
|
+
const target = assetSpec(to);
|
|
125
|
+
const asTarget = mulRate({ units: amount.units, asset: to }, rate, mode);
|
|
126
|
+
const shift = target.decimals - from.decimals;
|
|
127
|
+
if (shift === 0)
|
|
128
|
+
return asTarget;
|
|
129
|
+
if (shift > 0)
|
|
130
|
+
return { units: asTarget.units * 10n ** BigInt(shift), asset: to };
|
|
131
|
+
return { units: divideRounded(asTarget.units, 10n ** BigInt(-shift), mode), asset: to };
|
|
132
|
+
}
|
|
133
|
+
function allocate(amount, parts) {
|
|
134
|
+
if (parts < 1)
|
|
135
|
+
throw new MoneyError("NOT_FINITE", "Cannot allocate into fewer than one part.");
|
|
136
|
+
const each = divideRounded(amount.units, BigInt(parts), "down");
|
|
137
|
+
const allocated = Array.from({ length: parts }, () => each);
|
|
138
|
+
let remainder = amount.units - each * BigInt(parts);
|
|
139
|
+
const step = remainder < 0n ? -1n : 1n;
|
|
140
|
+
for (let index = 0;remainder !== 0n; index = (index + 1) % parts) {
|
|
141
|
+
allocated[index] += step;
|
|
142
|
+
remainder -= step;
|
|
143
|
+
}
|
|
144
|
+
return allocated.map((units) => ({ units, asset: amount.asset }));
|
|
145
|
+
}
|
|
146
|
+
function toStep(amount, step, mode = "down") {
|
|
147
|
+
const stepUnits = parseAmount(step, amount.asset).units;
|
|
148
|
+
if (stepUnits <= 0n)
|
|
149
|
+
throw new MoneyError("NOT_FINITE", "A step must be positive.");
|
|
150
|
+
return { units: divideRounded(amount.units, stepUnits, mode) * stepUnits, asset: amount.asset };
|
|
151
|
+
}
|
|
152
|
+
var VERSION = "0.1.0";
|
|
153
|
+
|
|
154
|
+
// src/finance/rates.ts
|
|
155
|
+
class RateError extends Error {
|
|
156
|
+
code;
|
|
157
|
+
asset;
|
|
158
|
+
constructor(code, message, asset) {
|
|
159
|
+
super(message);
|
|
160
|
+
this.code = code;
|
|
161
|
+
this.asset = asset;
|
|
162
|
+
this.name = "RateError";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
var BASE_ASSET = "USD";
|
|
166
|
+
var RATE_SOURCES = ["peg", "venue", "manual"];
|
|
167
|
+
var DECIMAL = /^\d+(\.\d+)?$/;
|
|
168
|
+
function assertRate(rate) {
|
|
169
|
+
if (!DECIMAL.test(rate.usd.trim())) {
|
|
170
|
+
throw new RateError("BAD_RATE", `"${rate.usd}" is not a plain positive decimal.`, rate.asset);
|
|
171
|
+
}
|
|
172
|
+
if (Number(rate.usd) <= 0) {
|
|
173
|
+
throw new RateError("BAD_RATE", `A rate of ${rate.usd} for ${rate.asset} cannot be right.`, rate.asset);
|
|
174
|
+
}
|
|
175
|
+
if (rate.source === "peg" && rate.atMs !== undefined) {
|
|
176
|
+
throw new RateError("BAD_RATE", `${rate.asset} is pegged, so it has no timestamp — a peg that appears to age invites somebody to refresh it.`, rate.asset);
|
|
177
|
+
}
|
|
178
|
+
if (rate.source !== "peg" && rate.atMs === undefined) {
|
|
179
|
+
throw new RateError("BAD_RATE", `${rate.asset} is quoted from ${rate.source}, so it must carry when it was true. A rate with no age keeps quoting yesterday after the feed dies.`, rate.asset);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
var peg = (asset, usd = "1") => ({ asset, source: "peg", usd });
|
|
183
|
+
function createRateTable(options = {}) {
|
|
184
|
+
const maxAgeMs = options.maxAgeMs ?? 15 * 60000;
|
|
185
|
+
const now = options.now ?? Date.now;
|
|
186
|
+
const rates = new Map;
|
|
187
|
+
return {
|
|
188
|
+
set(rate) {
|
|
189
|
+
assertRate(rate);
|
|
190
|
+
rates.set(rate.asset, rate);
|
|
191
|
+
},
|
|
192
|
+
peek: (asset) => rates.get(asset),
|
|
193
|
+
ageOf(asset) {
|
|
194
|
+
const rate = rates.get(asset);
|
|
195
|
+
return rate?.atMs === undefined ? undefined : now() - rate.atMs;
|
|
196
|
+
},
|
|
197
|
+
isStale(asset) {
|
|
198
|
+
const rate = rates.get(asset);
|
|
199
|
+
if (!rate)
|
|
200
|
+
return true;
|
|
201
|
+
if (rate.source === "peg")
|
|
202
|
+
return false;
|
|
203
|
+
return now() - (rate.atMs ?? 0) > maxAgeMs;
|
|
204
|
+
},
|
|
205
|
+
require(asset) {
|
|
206
|
+
const rate = rates.get(asset);
|
|
207
|
+
if (!rate) {
|
|
208
|
+
throw new RateError("NO_RATE", `No USD rate is configured for ${asset}. Add a peg, a venue source, or a manual value.`, asset);
|
|
209
|
+
}
|
|
210
|
+
if (rate.source !== "peg" && now() - (rate.atMs ?? 0) > maxAgeMs) {
|
|
211
|
+
throw new RateError("STALE_RATE", `The ${asset} rate is ${Math.round((now() - (rate.atMs ?? 0)) / 60000)} minutes old; the limit is ${Math.round(maxAgeMs / 60000)}.`, asset);
|
|
212
|
+
}
|
|
213
|
+
return rate;
|
|
214
|
+
},
|
|
215
|
+
toUsd(amount) {
|
|
216
|
+
if (amount.asset === BASE_ASSET)
|
|
217
|
+
return amount;
|
|
218
|
+
return convert(amount, BASE_ASSET, this.require(amount.asset).usd, "down");
|
|
219
|
+
},
|
|
220
|
+
totalUsd(amounts) {
|
|
221
|
+
let total = parseAmount("0", BASE_ASSET);
|
|
222
|
+
for (const amount of amounts) {
|
|
223
|
+
const valued = this.toUsd(amount);
|
|
224
|
+
total = { units: total.units + valued.units, asset: BASE_ASSET };
|
|
225
|
+
}
|
|
226
|
+
return total;
|
|
227
|
+
},
|
|
228
|
+
all: () => [...rates.values()],
|
|
229
|
+
clear: () => rates.clear()
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function registerAsset(entry) {
|
|
233
|
+
defineAsset({ code: entry.code, decimals: entry.decimals, label: entry.label });
|
|
234
|
+
assertRate(entry.rate);
|
|
235
|
+
}
|
|
236
|
+
var isRegistered = (code) => {
|
|
237
|
+
try {
|
|
238
|
+
assetSpec(code);
|
|
239
|
+
return true;
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
async function refreshRates(table, fetcher, args) {
|
|
245
|
+
const now = args.now ?? Date.now;
|
|
246
|
+
const report = { refreshed: [], failed: [], skipped: [] };
|
|
247
|
+
for (const entry of args.assets) {
|
|
248
|
+
if (!entry.enabled || entry.rate.source !== "venue") {
|
|
249
|
+
report.skipped.push(entry.code);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const usd = await fetcher.quote(entry.code);
|
|
254
|
+
if (!usd) {
|
|
255
|
+
report.failed.push({ asset: entry.code, reason: "the source does not quote it" });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
table.set({ asset: entry.code, source: "venue", usd, atMs: now(), via: entry.rate.via });
|
|
259
|
+
report.refreshed.push(entry.code);
|
|
260
|
+
} catch (cause) {
|
|
261
|
+
report.failed.push({
|
|
262
|
+
asset: entry.code,
|
|
263
|
+
reason: cause instanceof Error ? cause.message : "unknown error"
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return report;
|
|
268
|
+
}
|
|
269
|
+
function rateRefreshJob(args) {
|
|
270
|
+
return {
|
|
271
|
+
key: args.key ?? "rates.refresh",
|
|
272
|
+
everyMs: args.everyMs ?? 5 * 60000,
|
|
273
|
+
run: async () => {
|
|
274
|
+
const report = await refreshRates(args.table, args.fetcher, { assets: args.assets() });
|
|
275
|
+
return { ok: report.failed.length === 0, detail: report };
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
var describeRate = (rate, ageMs) => rate.source === "peg" ? `1 ${rate.asset} = ${formatAmount(parseAmount(rate.usd, BASE_ASSET), { trim: true })} USD, pegged` : `1 ${rate.asset} = ${rate.usd} USD from ${rate.via ?? rate.source}, ${ageMs === undefined ? "age unknown" : `${Math.round(ageMs / 1000)}s old`}`;
|
|
280
|
+
export {
|
|
281
|
+
registerAsset,
|
|
282
|
+
refreshRates,
|
|
283
|
+
rateRefreshJob,
|
|
284
|
+
peg,
|
|
285
|
+
isRegistered,
|
|
286
|
+
describeRate,
|
|
287
|
+
createRateTable,
|
|
288
|
+
assertRate,
|
|
289
|
+
RateError,
|
|
290
|
+
RATE_SOURCES,
|
|
291
|
+
BASE_ASSET
|
|
292
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type Money } from './money';
|
|
2
|
+
/**
|
|
3
|
+
* How an amount is written to a database, and read back exactly.
|
|
4
|
+
*
|
|
5
|
+
* A ledger needs two incompatible things from the same value: perfect accuracy,
|
|
6
|
+
* and the ability to `SORT` and range-filter it. No single column gives both.
|
|
7
|
+
*
|
|
8
|
+
* ACCURACY `0.1` is not representable in IEEE-754, and 18-decimal minor
|
|
9
|
+
* units exceed what a double can hold exactly above 2^53. Stored
|
|
10
|
+
* as a number, a balance silently changes when it is read back.
|
|
11
|
+
*
|
|
12
|
+
* SORTING a string sorts lexically, so "9" comes after "10" and every
|
|
13
|
+
* ORDER BY on a balance is wrong. Range filters are worse: they
|
|
14
|
+
* silently return the wrong rows rather than failing.
|
|
15
|
+
*
|
|
16
|
+
* So both are stored, with one of them authoritative and the other explicitly
|
|
17
|
+
* not.
|
|
18
|
+
*
|
|
19
|
+
* ## Why the sort key cannot be an integer
|
|
20
|
+
*
|
|
21
|
+
* The obvious answer is "store minor units as a long". It does not work. One
|
|
22
|
+
* ETH is 10^18 minor units and a signed 64-bit integer stops at about
|
|
23
|
+
* 9.22 * 10^18, so the column overflows somewhere around nine ETH. For an
|
|
24
|
+
* 18-decimal asset an integer sort key is not a tradeoff, it is a defect that
|
|
25
|
+
* appears the first time somebody holds a real balance.
|
|
26
|
+
*
|
|
27
|
+
* The sort key is therefore a DOUBLE holding the whole-unit value. It is lossy
|
|
28
|
+
* by design and never read as money.
|
|
29
|
+
*
|
|
30
|
+
* ## The one property the sort key must have
|
|
31
|
+
*
|
|
32
|
+
* Monotonicity: if a is less than b, then sortKey(a) must not be greater than
|
|
33
|
+
* sortKey(b). Equal is fine — two enormous amounts a few units apart can share
|
|
34
|
+
* a double, and a tie broken by the exact string costs one comparison. What is
|
|
35
|
+
* never allowed is an INVERSION, because that is an ORDER BY that puts a larger
|
|
36
|
+
* balance below a smaller one, and no downstream code can detect it.
|
|
37
|
+
*
|
|
38
|
+
* Rounding to nearest gives exactly that: it can collapse two values together
|
|
39
|
+
* but cannot reorder them.
|
|
40
|
+
*/
|
|
41
|
+
/**
|
|
42
|
+
* One amount, as stored.
|
|
43
|
+
*
|
|
44
|
+
* `units` is the authority. `value` is the same number for a human and for any
|
|
45
|
+
* consumer that does not want to know the asset's decimals. `sort` is for the
|
|
46
|
+
* database and nothing else.
|
|
47
|
+
*/
|
|
48
|
+
export interface StoredMoney {
|
|
49
|
+
/** Minor units, as a base-10 string. THE authoritative field. */
|
|
50
|
+
units: string;
|
|
51
|
+
/** The same amount in whole units, exact, e.g. `"1234.5678"`. */
|
|
52
|
+
value: string;
|
|
53
|
+
asset: string;
|
|
54
|
+
/**
|
|
55
|
+
* Whole units as a double, for ORDER BY and range filters ONLY.
|
|
56
|
+
*
|
|
57
|
+
* Never read this back as money and never compute with it. It exists so the
|
|
58
|
+
* database can order rows it cannot otherwise order.
|
|
59
|
+
*/
|
|
60
|
+
sort: number;
|
|
61
|
+
}
|
|
62
|
+
/** The field a query sorts on, so callers do not hardcode the name. */
|
|
63
|
+
export declare const SORT_FIELD: "sort";
|
|
64
|
+
/**
|
|
65
|
+
* Write an amount.
|
|
66
|
+
*
|
|
67
|
+
* `value` is derived from `units` rather than carried alongside it, so the two
|
|
68
|
+
* cannot disagree — a row where they do is a row nobody can reconcile.
|
|
69
|
+
*/
|
|
70
|
+
export declare function toStored(amount: Money): StoredMoney;
|
|
71
|
+
/**
|
|
72
|
+
* Read an amount back, exactly.
|
|
73
|
+
*
|
|
74
|
+
* From `units`, never from `sort` and never from `value`. `units` is the only
|
|
75
|
+
* field that survives a round trip unchanged for every asset.
|
|
76
|
+
*/
|
|
77
|
+
export declare function fromStored(stored: StoredMoney): Money;
|
|
78
|
+
/**
|
|
79
|
+
* The sortable double for an amount.
|
|
80
|
+
*
|
|
81
|
+
* Round-to-nearest via `Number(string)`, which is monotonic: it can map two
|
|
82
|
+
* distinct amounts to one double but can never swap their order.
|
|
83
|
+
*
|
|
84
|
+
* Beyond roughly 1.8e308 a double is `Infinity`. That is still monotonic and
|
|
85
|
+
* still sorts correctly at the top of the range; it is only unusable as a
|
|
86
|
+
* value, which it already was.
|
|
87
|
+
*/
|
|
88
|
+
export declare function sortKey(amount: Money): number;
|
|
89
|
+
/**
|
|
90
|
+
* Bounds for a range filter, widened so the coarse index cannot exclude a row
|
|
91
|
+
* that genuinely matches.
|
|
92
|
+
*
|
|
93
|
+
* A filter written directly against `sort` can drop a boundary row, because the
|
|
94
|
+
* stored double may sit a fraction below the true value. Widening by one ULP in
|
|
95
|
+
* each direction makes the database step a SUPERSET, which the caller then
|
|
96
|
+
* narrows exactly. A superset is recoverable; a missing row is not, because
|
|
97
|
+
* nothing downstream knows it should have been there.
|
|
98
|
+
*/
|
|
99
|
+
export declare function rangeBounds(from: Money | null, to: Money | null): {
|
|
100
|
+
gte?: number;
|
|
101
|
+
lte?: number;
|
|
102
|
+
};
|
|
103
|
+
/** Exact comparison, for narrowing what the coarse filter returned. */
|
|
104
|
+
export declare function withinRange(amount: Money, from: Money | null, to: Money | null): boolean;
|
|
105
|
+
/**
|
|
106
|
+
* Sort rows exactly, breaking the ties the double could not.
|
|
107
|
+
*
|
|
108
|
+
* Used where the whole set is already in memory. For anything larger the
|
|
109
|
+
* database orders by `sort` and this settles the equal keys.
|
|
110
|
+
*/
|
|
111
|
+
export declare function sortExact<T>(rows: readonly T[], pick: (row: T) => Money, direction?: 'asc' | 'desc'): T[];
|
|
112
|
+
/** Parse and store in one step — the common path from a form or an API body. */
|
|
113
|
+
export declare const storeAmount: (value: string, asset: string) => StoredMoney;
|
|
@@ -0,0 +1,226 @@
|
|
|
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/money.ts
|
|
10
|
+
class MoneyError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code, message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = "MoneyError";
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
var ASSETS = [
|
|
19
|
+
{ code: "USDT", decimals: 6 },
|
|
20
|
+
{ code: "USDC", decimals: 6 },
|
|
21
|
+
{ code: "BTC", decimals: 8 },
|
|
22
|
+
{ code: "ETH", decimals: 18 },
|
|
23
|
+
{ code: "BNB", decimals: 18 },
|
|
24
|
+
{ code: "EUR", decimals: 2 },
|
|
25
|
+
{ code: "USD", decimals: 2 }
|
|
26
|
+
];
|
|
27
|
+
var REGISTRY = new Map(ASSETS.map((asset) => [asset.code, asset]));
|
|
28
|
+
function defineAsset(spec) {
|
|
29
|
+
if (spec.decimals < 0 || spec.decimals > 30 || !Number.isInteger(spec.decimals)) {
|
|
30
|
+
throw new MoneyError("UNKNOWN_ASSET", `${spec.code}: decimals must be an integer 0–30.`);
|
|
31
|
+
}
|
|
32
|
+
REGISTRY.set(spec.code, spec);
|
|
33
|
+
}
|
|
34
|
+
function assetSpec(code) {
|
|
35
|
+
const spec = REGISTRY.get(code);
|
|
36
|
+
if (!spec)
|
|
37
|
+
throw new MoneyError("UNKNOWN_ASSET", `Unknown asset "${code}". Call defineAsset first.`);
|
|
38
|
+
return spec;
|
|
39
|
+
}
|
|
40
|
+
var money = (units, asset) => {
|
|
41
|
+
assetSpec(asset);
|
|
42
|
+
return { units, asset };
|
|
43
|
+
};
|
|
44
|
+
var zero = (asset) => money(0n, asset);
|
|
45
|
+
function parseAmount(value, asset) {
|
|
46
|
+
const spec = assetSpec(asset);
|
|
47
|
+
const text = value.trim();
|
|
48
|
+
if (!/^-?\d+(\.\d+)?$/.test(text)) {
|
|
49
|
+
throw new MoneyError("NOT_FINITE", `"${value}" is not a plain decimal amount.`);
|
|
50
|
+
}
|
|
51
|
+
const negative = text.startsWith("-");
|
|
52
|
+
const [whole, fraction = ""] = text.replace("-", "").split(".");
|
|
53
|
+
if (fraction.length > spec.decimals) {
|
|
54
|
+
throw new MoneyError("PRECISION_LOSS", `${asset} has ${spec.decimals} decimals; "${value}" has ${fraction.length}.`);
|
|
55
|
+
}
|
|
56
|
+
const padded = fraction.padEnd(spec.decimals, "0");
|
|
57
|
+
const units = BigInt(whole + padded);
|
|
58
|
+
return { units: negative ? -units : units, asset };
|
|
59
|
+
}
|
|
60
|
+
function formatAmount(amount, options = {}) {
|
|
61
|
+
const spec = assetSpec(amount.asset);
|
|
62
|
+
const negative = amount.units < 0n;
|
|
63
|
+
const digits = (negative ? -amount.units : amount.units).toString().padStart(spec.decimals + 1, "0");
|
|
64
|
+
const whole = digits.slice(0, digits.length - spec.decimals);
|
|
65
|
+
let fraction = spec.decimals === 0 ? "" : digits.slice(digits.length - spec.decimals);
|
|
66
|
+
if (options.trim && fraction)
|
|
67
|
+
fraction = fraction.replace(/0+$/, "");
|
|
68
|
+
return `${negative ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}`;
|
|
69
|
+
}
|
|
70
|
+
function sameAsset(a, b) {
|
|
71
|
+
if (a.asset !== b.asset) {
|
|
72
|
+
throw new MoneyError("ASSET_MISMATCH", `Cannot combine ${a.asset} and ${b.asset}.`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function add(a, b) {
|
|
76
|
+
sameAsset(a, b);
|
|
77
|
+
return { units: a.units + b.units, asset: a.asset };
|
|
78
|
+
}
|
|
79
|
+
function subtract(a, b) {
|
|
80
|
+
sameAsset(a, b);
|
|
81
|
+
return { units: a.units - b.units, asset: a.asset };
|
|
82
|
+
}
|
|
83
|
+
var negate = (amount) => ({ units: -amount.units, asset: amount.asset });
|
|
84
|
+
var abs = (amount) => ({
|
|
85
|
+
units: amount.units < 0n ? -amount.units : amount.units,
|
|
86
|
+
asset: amount.asset
|
|
87
|
+
});
|
|
88
|
+
var isZero = (amount) => amount.units === 0n;
|
|
89
|
+
var isNegative = (amount) => amount.units < 0n;
|
|
90
|
+
function compare(a, b) {
|
|
91
|
+
sameAsset(a, b);
|
|
92
|
+
return a.units < b.units ? -1 : a.units > b.units ? 1 : 0;
|
|
93
|
+
}
|
|
94
|
+
var equals = (a, b) => a.asset === b.asset && a.units === b.units;
|
|
95
|
+
var ROUNDING = ["down", "up", "half-up"];
|
|
96
|
+
function divideRounded(numerator, denominator, mode) {
|
|
97
|
+
if (denominator === 0n)
|
|
98
|
+
throw new MoneyError("DIVIDE_BY_ZERO", "Division by zero.");
|
|
99
|
+
const negative = numerator < 0n !== denominator < 0n;
|
|
100
|
+
const a = numerator < 0n ? -numerator : numerator;
|
|
101
|
+
const b = denominator < 0n ? -denominator : denominator;
|
|
102
|
+
const quotient = a / b;
|
|
103
|
+
const remainder = a % b;
|
|
104
|
+
if (remainder === 0n)
|
|
105
|
+
return negative ? -quotient : quotient;
|
|
106
|
+
let result = quotient;
|
|
107
|
+
if (mode === "up")
|
|
108
|
+
result += 1n;
|
|
109
|
+
else if (mode === "half-up" && remainder * 2n >= b)
|
|
110
|
+
result += 1n;
|
|
111
|
+
return negative ? -result : result;
|
|
112
|
+
}
|
|
113
|
+
function mulRate(amount, rate, mode = "down") {
|
|
114
|
+
if (!/^-?\d+(\.\d+)?$/.test(rate.trim())) {
|
|
115
|
+
throw new MoneyError("NOT_FINITE", `"${rate}" is not a plain decimal rate.`);
|
|
116
|
+
}
|
|
117
|
+
const [whole, fraction = ""] = rate.trim().replace("-", "").split(".");
|
|
118
|
+
const scale = 10n ** BigInt(fraction.length);
|
|
119
|
+
const scaled = BigInt(whole + fraction) * (rate.trim().startsWith("-") ? -1n : 1n);
|
|
120
|
+
return { units: divideRounded(amount.units * scaled, scale, mode), asset: amount.asset };
|
|
121
|
+
}
|
|
122
|
+
function convert(amount, to, rate, mode = "down") {
|
|
123
|
+
const from = assetSpec(amount.asset);
|
|
124
|
+
const target = assetSpec(to);
|
|
125
|
+
const asTarget = mulRate({ units: amount.units, asset: to }, rate, mode);
|
|
126
|
+
const shift = target.decimals - from.decimals;
|
|
127
|
+
if (shift === 0)
|
|
128
|
+
return asTarget;
|
|
129
|
+
if (shift > 0)
|
|
130
|
+
return { units: asTarget.units * 10n ** BigInt(shift), asset: to };
|
|
131
|
+
return { units: divideRounded(asTarget.units, 10n ** BigInt(-shift), mode), asset: to };
|
|
132
|
+
}
|
|
133
|
+
function allocate(amount, parts) {
|
|
134
|
+
if (parts < 1)
|
|
135
|
+
throw new MoneyError("NOT_FINITE", "Cannot allocate into fewer than one part.");
|
|
136
|
+
const each = divideRounded(amount.units, BigInt(parts), "down");
|
|
137
|
+
const allocated = Array.from({ length: parts }, () => each);
|
|
138
|
+
let remainder = amount.units - each * BigInt(parts);
|
|
139
|
+
const step = remainder < 0n ? -1n : 1n;
|
|
140
|
+
for (let index = 0;remainder !== 0n; index = (index + 1) % parts) {
|
|
141
|
+
allocated[index] += step;
|
|
142
|
+
remainder -= step;
|
|
143
|
+
}
|
|
144
|
+
return allocated.map((units) => ({ units, asset: amount.asset }));
|
|
145
|
+
}
|
|
146
|
+
function toStep(amount, step, mode = "down") {
|
|
147
|
+
const stepUnits = parseAmount(step, amount.asset).units;
|
|
148
|
+
if (stepUnits <= 0n)
|
|
149
|
+
throw new MoneyError("NOT_FINITE", "A step must be positive.");
|
|
150
|
+
return { units: divideRounded(amount.units, stepUnits, mode) * stepUnits, asset: amount.asset };
|
|
151
|
+
}
|
|
152
|
+
var VERSION = "0.1.0";
|
|
153
|
+
|
|
154
|
+
// src/finance/storage.ts
|
|
155
|
+
var SORT_FIELD = "sort";
|
|
156
|
+
function toStored(amount) {
|
|
157
|
+
const value = formatAmount(amount);
|
|
158
|
+
return {
|
|
159
|
+
units: amount.units.toString(),
|
|
160
|
+
value,
|
|
161
|
+
asset: amount.asset,
|
|
162
|
+
sort: sortKey(amount)
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function fromStored(stored) {
|
|
166
|
+
if (!/^-?\d+$/.test(stored.units)) {
|
|
167
|
+
throw new MoneyError("NOT_FINITE", `Stored units are not an integer: ${stored.units}`);
|
|
168
|
+
}
|
|
169
|
+
assetSpec(stored.asset);
|
|
170
|
+
return { units: BigInt(stored.units), asset: stored.asset };
|
|
171
|
+
}
|
|
172
|
+
function sortKey(amount) {
|
|
173
|
+
return Number(formatAmount(amount));
|
|
174
|
+
}
|
|
175
|
+
function rangeBounds(from, to) {
|
|
176
|
+
const bounds = {};
|
|
177
|
+
if (from)
|
|
178
|
+
bounds.gte = previousDouble(sortKey(from));
|
|
179
|
+
if (to)
|
|
180
|
+
bounds.lte = nextDouble(sortKey(to));
|
|
181
|
+
return bounds;
|
|
182
|
+
}
|
|
183
|
+
function withinRange(amount, from, to) {
|
|
184
|
+
if (from && compare(amount, from) < 0)
|
|
185
|
+
return false;
|
|
186
|
+
if (to && compare(amount, to) > 0)
|
|
187
|
+
return false;
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
function sortExact(rows, pick, direction = "asc") {
|
|
191
|
+
const sign = direction === "asc" ? 1 : -1;
|
|
192
|
+
return [...rows].sort((a, b) => sign * compare(pick(a), pick(b)));
|
|
193
|
+
}
|
|
194
|
+
var storeAmount = (value, asset) => toStored(parseAmount(value, asset));
|
|
195
|
+
var nextDouble = (value) => {
|
|
196
|
+
if (!Number.isFinite(value))
|
|
197
|
+
return value;
|
|
198
|
+
if (value === 0)
|
|
199
|
+
return Number.MIN_VALUE;
|
|
200
|
+
const buffer = new DataView(new ArrayBuffer(8));
|
|
201
|
+
buffer.setFloat64(0, value);
|
|
202
|
+
const bits = buffer.getBigUint64(0);
|
|
203
|
+
buffer.setBigUint64(0, value > 0 ? bits + 1n : bits - 1n);
|
|
204
|
+
return buffer.getFloat64(0);
|
|
205
|
+
};
|
|
206
|
+
var previousDouble = (value) => {
|
|
207
|
+
if (!Number.isFinite(value))
|
|
208
|
+
return value;
|
|
209
|
+
if (value === 0)
|
|
210
|
+
return -Number.MIN_VALUE;
|
|
211
|
+
const buffer = new DataView(new ArrayBuffer(8));
|
|
212
|
+
buffer.setFloat64(0, value);
|
|
213
|
+
const bits = buffer.getBigUint64(0);
|
|
214
|
+
buffer.setBigUint64(0, value > 0 ? bits - 1n : bits + 1n);
|
|
215
|
+
return buffer.getFloat64(0);
|
|
216
|
+
};
|
|
217
|
+
export {
|
|
218
|
+
withinRange,
|
|
219
|
+
toStored,
|
|
220
|
+
storeAmount,
|
|
221
|
+
sortKey,
|
|
222
|
+
sortExact,
|
|
223
|
+
rangeBounds,
|
|
224
|
+
fromStored,
|
|
225
|
+
SORT_FIELD
|
|
226
|
+
};
|