@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,596 @@
|
|
|
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/ledger.ts
|
|
155
|
+
class LedgerError extends Error {
|
|
156
|
+
code;
|
|
157
|
+
constructor(code, message) {
|
|
158
|
+
super(message);
|
|
159
|
+
this.code = code;
|
|
160
|
+
this.name = "LedgerError";
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
var BUCKETS = ["available", "held"];
|
|
164
|
+
var ACCOUNT_KINDS = ["user", "platform", "external"];
|
|
165
|
+
var accountId = (account) => `${account.kind}:${account.owner}:${account.bucket ?? "available"}`;
|
|
166
|
+
function parseAccount(id) {
|
|
167
|
+
const [kind, owner, bucket] = id.split(":");
|
|
168
|
+
return { kind, owner, bucket };
|
|
169
|
+
}
|
|
170
|
+
var queueKeyFor = (account) => `${account.kind}:${account.owner}`;
|
|
171
|
+
function assertBalanced(transaction) {
|
|
172
|
+
if (transaction.entries.length === 0) {
|
|
173
|
+
throw new LedgerError("EMPTY_TRANSACTION", "A transaction needs at least two entries.");
|
|
174
|
+
}
|
|
175
|
+
const totals = new Map;
|
|
176
|
+
for (const entry of transaction.entries) {
|
|
177
|
+
if (entry.amount.units === 0n) {
|
|
178
|
+
throw new LedgerError("ZERO_ENTRY", `An entry on ${accountId(entry.account)} moves nothing. Remove it or give it an amount.`);
|
|
179
|
+
}
|
|
180
|
+
totals.set(entry.amount.asset, (totals.get(entry.amount.asset) ?? 0n) + entry.amount.units);
|
|
181
|
+
}
|
|
182
|
+
for (const [asset, total] of totals) {
|
|
183
|
+
if (total !== 0n) {
|
|
184
|
+
throw new LedgerError("UNBALANCED", `${asset} does not sum to zero — it is off by ${total} minor units. Every movement needs a counterparty.`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function transfer(args) {
|
|
189
|
+
const transaction = {
|
|
190
|
+
reference: args.reference,
|
|
191
|
+
kind: args.kind,
|
|
192
|
+
atMs: args.atMs,
|
|
193
|
+
memo: args.memo,
|
|
194
|
+
entries: [
|
|
195
|
+
{ account: args.from, amount: { units: -args.amount.units, asset: args.amount.asset } },
|
|
196
|
+
{ account: args.to, amount: args.amount }
|
|
197
|
+
]
|
|
198
|
+
};
|
|
199
|
+
assertBalanced(transaction);
|
|
200
|
+
return transaction;
|
|
201
|
+
}
|
|
202
|
+
var placeHold = (args) => transfer({
|
|
203
|
+
reference: args.reference,
|
|
204
|
+
kind: "hold.place",
|
|
205
|
+
from: { kind: args.kind ?? "user", owner: args.owner, bucket: "available" },
|
|
206
|
+
to: { kind: args.kind ?? "user", owner: args.owner, bucket: "held" },
|
|
207
|
+
amount: args.amount,
|
|
208
|
+
atMs: args.atMs,
|
|
209
|
+
memo: args.memo
|
|
210
|
+
});
|
|
211
|
+
var releaseHold = (args) => transfer({
|
|
212
|
+
reference: args.reference,
|
|
213
|
+
kind: "hold.release",
|
|
214
|
+
from: { kind: args.kind ?? "user", owner: args.owner, bucket: "held" },
|
|
215
|
+
to: { kind: args.kind ?? "user", owner: args.owner, bucket: "available" },
|
|
216
|
+
amount: args.amount,
|
|
217
|
+
atMs: args.atMs
|
|
218
|
+
});
|
|
219
|
+
var captureHold = (args) => transfer({
|
|
220
|
+
reference: args.reference,
|
|
221
|
+
kind: "hold.capture",
|
|
222
|
+
from: { kind: args.kind ?? "user", owner: args.owner, bucket: "held" },
|
|
223
|
+
to: args.to,
|
|
224
|
+
amount: args.amount,
|
|
225
|
+
atMs: args.atMs,
|
|
226
|
+
memo: args.memo
|
|
227
|
+
});
|
|
228
|
+
function balancesFrom(transactions) {
|
|
229
|
+
const balances = new Map;
|
|
230
|
+
for (const transaction of transactions) {
|
|
231
|
+
for (const entry of transaction.entries) {
|
|
232
|
+
const id = accountId(entry.account);
|
|
233
|
+
const perAsset = balances.get(id) ?? new Map;
|
|
234
|
+
const current = perAsset.get(entry.amount.asset) ?? zero(entry.amount.asset);
|
|
235
|
+
perAsset.set(entry.amount.asset, add(current, entry.amount));
|
|
236
|
+
balances.set(id, perAsset);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return balances;
|
|
240
|
+
}
|
|
241
|
+
function balanceOf(balances, account, asset) {
|
|
242
|
+
return balances.get(accountId(account))?.get(asset) ?? zero(asset);
|
|
243
|
+
}
|
|
244
|
+
var availableOf = (balances, owner, asset, kind = "user") => balanceOf(balances, { kind, owner, bucket: "available" }, asset);
|
|
245
|
+
var heldOf = (balances, owner, asset, kind = "user") => balanceOf(balances, { kind, owner, bucket: "held" }, asset);
|
|
246
|
+
var totalOf = (balances, owner, asset, kind = "user") => add(availableOf(balances, owner, asset, kind), heldOf(balances, owner, asset, kind));
|
|
247
|
+
function trialBalance(transactions) {
|
|
248
|
+
const totals = new Map;
|
|
249
|
+
const accounts = new Map;
|
|
250
|
+
for (const transaction of transactions) {
|
|
251
|
+
for (const entry of transaction.entries) {
|
|
252
|
+
totals.set(entry.amount.asset, (totals.get(entry.amount.asset) ?? 0n) + entry.amount.units);
|
|
253
|
+
const seen = accounts.get(entry.amount.asset) ?? new Set;
|
|
254
|
+
seen.add(accountId(entry.account));
|
|
255
|
+
accounts.set(entry.amount.asset, seen);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const perAsset = [...totals.entries()].map(([asset, total]) => ({
|
|
259
|
+
asset,
|
|
260
|
+
total,
|
|
261
|
+
accounts: accounts.get(asset)?.size ?? 0
|
|
262
|
+
}));
|
|
263
|
+
const discrepancies = perAsset.filter((row) => row.total !== 0n).map((row) => ({ asset: row.asset, off: row.total }));
|
|
264
|
+
return { ok: discrepancies.length === 0, perAsset, discrepancies };
|
|
265
|
+
}
|
|
266
|
+
function statement(transactions, account, asset) {
|
|
267
|
+
const id = accountId(account);
|
|
268
|
+
let running = zero(asset);
|
|
269
|
+
return [...transactions].sort((a, b) => a.atMs - b.atMs).flatMap((transaction) => transaction.entries.filter((entry) => accountId(entry.account) === id && entry.amount.asset === asset).map((entry) => {
|
|
270
|
+
running = add(running, entry.amount);
|
|
271
|
+
return {
|
|
272
|
+
atMs: transaction.atMs,
|
|
273
|
+
reference: transaction.reference,
|
|
274
|
+
kind: transaction.kind,
|
|
275
|
+
amount: formatAmount(entry.amount, { trim: true }),
|
|
276
|
+
balance: formatAmount(running, { trim: true })
|
|
277
|
+
};
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
function assertAvailable(balances, args) {
|
|
281
|
+
const available = availableOf(balances, args.owner, args.amount.asset, args.kind ?? "user");
|
|
282
|
+
if (subtract(available, args.amount).units < 0n) {
|
|
283
|
+
throw new LedgerError("INSUFFICIENT_AVAILABLE", `${args.owner} has ${formatAmount(available, { trim: true })} ${args.amount.asset} available; this needs ${formatAmount(args.amount, { trim: true })}.`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
var VERSION2 = "0.1.0";
|
|
287
|
+
|
|
288
|
+
// src/finance/chain.ts
|
|
289
|
+
class ChainError extends Error {
|
|
290
|
+
code;
|
|
291
|
+
constructor(code, message) {
|
|
292
|
+
super(message);
|
|
293
|
+
this.code = code;
|
|
294
|
+
this.name = "ChainError";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
var ADDRESS_SCHEMES = ["evm", "bitcoin", "tron", "solana"];
|
|
298
|
+
var SCHEME_PATTERNS = {
|
|
299
|
+
evm: /^0x[a-fA-F0-9]{40}$/,
|
|
300
|
+
bitcoin: /^(bc1[a-z0-9]{25,62}|[13][a-km-zA-HJ-NP-Z1-9]{25,34})$/,
|
|
301
|
+
tron: /^T[a-zA-Z0-9]{33}$/,
|
|
302
|
+
solana: /^[1-9A-HJ-NP-Za-km-z]{32,44}$/
|
|
303
|
+
};
|
|
304
|
+
function validateAddress(address, chain) {
|
|
305
|
+
if (!SCHEME_PATTERNS[chain.scheme].test(address.trim())) {
|
|
306
|
+
throw new ChainError("BAD_ADDRESS", `"${address.slice(0, 12)}…" is not a valid ${chain.label} address. Check you have not pasted an address for a different chain — that is the mistake that cannot be undone.`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function assertChain(chain) {
|
|
310
|
+
if (chain.confirmations.length === 0) {
|
|
311
|
+
throw new ChainError("BAD_CONFIRMATIONS", `${chain.key} declares no confirmation depth.`);
|
|
312
|
+
}
|
|
313
|
+
if (chain.confirmations[0].aboveUsd !== 0) {
|
|
314
|
+
throw new ChainError("BAD_CONFIRMATIONS", `${chain.key} must have a band starting at 0, or small transfers match no rule.`);
|
|
315
|
+
}
|
|
316
|
+
let previous = -1;
|
|
317
|
+
for (const band of chain.confirmations) {
|
|
318
|
+
if (band.aboveUsd <= previous) {
|
|
319
|
+
throw new ChainError("BAD_CONFIRMATIONS", `${chain.key} bands must ascend; ${band.aboveUsd} follows ${previous}.`);
|
|
320
|
+
}
|
|
321
|
+
if (!Number.isInteger(band.confirmations) || band.confirmations < 1) {
|
|
322
|
+
throw new ChainError("BAD_CONFIRMATIONS", `${chain.key} band above $${band.aboveUsd} needs at least one confirmation.`);
|
|
323
|
+
}
|
|
324
|
+
previous = band.aboveUsd;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
function confirmationsFor(chain, usdValue) {
|
|
328
|
+
let depth = chain.confirmations[0].confirmations;
|
|
329
|
+
for (const band of chain.confirmations) {
|
|
330
|
+
if (usdValue >= band.aboveUsd)
|
|
331
|
+
depth = band.confirmations;
|
|
332
|
+
}
|
|
333
|
+
return depth;
|
|
334
|
+
}
|
|
335
|
+
var estimatedSeconds = (chain, usdValue) => confirmationsFor(chain, usdValue) * chain.blockSeconds;
|
|
336
|
+
function createChainRegistry(args) {
|
|
337
|
+
for (const chain of args.chains)
|
|
338
|
+
assertChain(chain);
|
|
339
|
+
const chains = new Map(args.chains.map((chain) => [chain.key, chain]));
|
|
340
|
+
return {
|
|
341
|
+
all: () => [...chains.values()].filter((chain) => chain.enabled),
|
|
342
|
+
byKey(key) {
|
|
343
|
+
const chain = chains.get(key);
|
|
344
|
+
if (!chain)
|
|
345
|
+
throw new ChainError("UNKNOWN_CHAIN", `No chain "${key}" is configured.`);
|
|
346
|
+
return chain;
|
|
347
|
+
},
|
|
348
|
+
assetsOn: (chain) => args.assets.filter((asset) => asset.chain === chain && asset.enabled),
|
|
349
|
+
chainsFor: (asset) => args.assets.filter((entry) => entry.asset === asset && entry.enabled).map((entry) => chains.get(entry.chain)).filter((chain) => chain?.enabled === true),
|
|
350
|
+
asset(chain, asset) {
|
|
351
|
+
const entry = args.assets.find((row) => row.chain === chain && row.asset === asset);
|
|
352
|
+
if (!entry) {
|
|
353
|
+
throw new ChainError("UNKNOWN_ASSET", `${asset} is not configured on ${chain}.`);
|
|
354
|
+
}
|
|
355
|
+
assetSpec(asset);
|
|
356
|
+
return entry;
|
|
357
|
+
},
|
|
358
|
+
explorerTx: (chain, hash) => {
|
|
359
|
+
const spec = chains.get(chain);
|
|
360
|
+
return spec ? `${spec.explorerTx}${hash}` : hash;
|
|
361
|
+
},
|
|
362
|
+
explorerAddress: (chain, address) => {
|
|
363
|
+
const spec = chains.get(chain);
|
|
364
|
+
return spec ? `${spec.explorerAddress}${address}` : address;
|
|
365
|
+
},
|
|
366
|
+
validate(chain, address) {
|
|
367
|
+
validateAddress(address, this.byKey(chain));
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
var SEED_CHAINS = [
|
|
372
|
+
{
|
|
373
|
+
key: "bsc",
|
|
374
|
+
label: "BNB Smart Chain",
|
|
375
|
+
scheme: "evm",
|
|
376
|
+
chainId: 56,
|
|
377
|
+
nativeAsset: "BNB",
|
|
378
|
+
blockSeconds: 3,
|
|
379
|
+
confirmations: [
|
|
380
|
+
{ aboveUsd: 0, confirmations: 15 },
|
|
381
|
+
{ aboveUsd: 1e4, confirmations: 30 },
|
|
382
|
+
{ aboveUsd: 1e5, confirmations: 60 }
|
|
383
|
+
],
|
|
384
|
+
explorerTx: "https://bscscan.com/tx/",
|
|
385
|
+
explorerAddress: "https://bscscan.com/address/",
|
|
386
|
+
enabled: true
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
key: "ethereum",
|
|
390
|
+
label: "Ethereum",
|
|
391
|
+
scheme: "evm",
|
|
392
|
+
chainId: 1,
|
|
393
|
+
nativeAsset: "ETH",
|
|
394
|
+
blockSeconds: 12,
|
|
395
|
+
confirmations: [
|
|
396
|
+
{ aboveUsd: 0, confirmations: 12 },
|
|
397
|
+
{ aboveUsd: 50000, confirmations: 32 }
|
|
398
|
+
],
|
|
399
|
+
explorerTx: "https://etherscan.io/tx/",
|
|
400
|
+
explorerAddress: "https://etherscan.io/address/",
|
|
401
|
+
enabled: true
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
key: "tron",
|
|
405
|
+
label: "Tron",
|
|
406
|
+
scheme: "tron",
|
|
407
|
+
nativeAsset: "TRX",
|
|
408
|
+
blockSeconds: 3,
|
|
409
|
+
confirmations: [{ aboveUsd: 0, confirmations: 20 }],
|
|
410
|
+
explorerTx: "https://tronscan.org/#/transaction/",
|
|
411
|
+
explorerAddress: "https://tronscan.org/#/address/",
|
|
412
|
+
enabled: true
|
|
413
|
+
}
|
|
414
|
+
];
|
|
415
|
+
var SEED_CHAIN_ASSETS = [
|
|
416
|
+
{ chain: "bsc", asset: "BNB", minDeposit: "0.001", enabled: true },
|
|
417
|
+
{
|
|
418
|
+
chain: "bsc",
|
|
419
|
+
asset: "USDT",
|
|
420
|
+
contract: "0x55d398326f99059ff775485246999027b3197955",
|
|
421
|
+
minDeposit: "1",
|
|
422
|
+
enabled: true
|
|
423
|
+
},
|
|
424
|
+
{ chain: "ethereum", asset: "ETH", minDeposit: "0.005", enabled: true },
|
|
425
|
+
{
|
|
426
|
+
chain: "ethereum",
|
|
427
|
+
asset: "USDT",
|
|
428
|
+
contract: "0xdac17f958d2ee523a2206206994597c13d831ec7",
|
|
429
|
+
minDeposit: "20",
|
|
430
|
+
enabled: true
|
|
431
|
+
},
|
|
432
|
+
{
|
|
433
|
+
chain: "tron",
|
|
434
|
+
asset: "USDT",
|
|
435
|
+
contract: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t",
|
|
436
|
+
minDeposit: "1",
|
|
437
|
+
enabled: true
|
|
438
|
+
}
|
|
439
|
+
];
|
|
440
|
+
|
|
441
|
+
// src/finance/chain-deposits.ts
|
|
442
|
+
class DepositError extends Error {
|
|
443
|
+
code;
|
|
444
|
+
constructor(code, message) {
|
|
445
|
+
super(message);
|
|
446
|
+
this.code = code;
|
|
447
|
+
this.name = "DepositError";
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
var depositReference = (args) => `deposit:${args.chain}:${args.txHash.toLowerCase()}:${args.logIndex}`;
|
|
451
|
+
function isConfirmed(args) {
|
|
452
|
+
const depth = args.head - args.transfer.blockNumber + 1;
|
|
453
|
+
return depth >= confirmationsFor(args.chain, args.usdValue);
|
|
454
|
+
}
|
|
455
|
+
function creditDeposit(args) {
|
|
456
|
+
const external = { kind: "external", owner: args.chain, bucket: "available" };
|
|
457
|
+
const user = { kind: "user", owner: args.owner, bucket: "available" };
|
|
458
|
+
return transfer({
|
|
459
|
+
reference: depositReference({
|
|
460
|
+
chain: args.chain,
|
|
461
|
+
txHash: args.transfer.txHash,
|
|
462
|
+
logIndex: args.transfer.logIndex
|
|
463
|
+
}),
|
|
464
|
+
kind: "deposit",
|
|
465
|
+
from: external,
|
|
466
|
+
to: user,
|
|
467
|
+
amount: args.amount,
|
|
468
|
+
atMs: args.atMs,
|
|
469
|
+
memo: `${args.chain} ${args.transfer.txHash}`
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
var DEFAULT_BATCH = 2000;
|
|
473
|
+
async function scanOnce(cursor, context) {
|
|
474
|
+
if (cursor.chain !== context.chain.key) {
|
|
475
|
+
throw new DepositError("BAD_CURSOR", `That cursor is for ${cursor.chain}, not ${context.chain.key}.`);
|
|
476
|
+
}
|
|
477
|
+
const head = await context.reader.head();
|
|
478
|
+
const fromBlock = cursor.scannedTo + 1;
|
|
479
|
+
const batch = Math.max(1, context.batchSize ?? DEFAULT_BATCH);
|
|
480
|
+
const toBlock = Math.min(head, cursor.scannedTo + batch);
|
|
481
|
+
if (toBlock < fromBlock) {
|
|
482
|
+
return {
|
|
483
|
+
chain: context.chain.key,
|
|
484
|
+
fromBlock,
|
|
485
|
+
toBlock: cursor.scannedTo,
|
|
486
|
+
head,
|
|
487
|
+
matched: 0,
|
|
488
|
+
credited: 0,
|
|
489
|
+
pending: 0,
|
|
490
|
+
duplicates: 0,
|
|
491
|
+
dust: 0,
|
|
492
|
+
transactions: [],
|
|
493
|
+
cursor
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
const found = await context.reader.transfersIn({ fromBlock, toBlock });
|
|
497
|
+
const outcome = await classify(found, context, head);
|
|
498
|
+
const shallowest = outcome.pendingBlocks.length > 0 ? Math.min(...outcome.pendingBlocks) : null;
|
|
499
|
+
const scannedTo = shallowest === null ? toBlock : shallowest - 1;
|
|
500
|
+
return {
|
|
501
|
+
chain: context.chain.key,
|
|
502
|
+
fromBlock,
|
|
503
|
+
toBlock,
|
|
504
|
+
head,
|
|
505
|
+
matched: outcome.matched,
|
|
506
|
+
credited: outcome.transactions.length,
|
|
507
|
+
pending: outcome.pendingBlocks.length,
|
|
508
|
+
duplicates: outcome.duplicates,
|
|
509
|
+
dust: outcome.dust,
|
|
510
|
+
transactions: outcome.transactions,
|
|
511
|
+
cursor: { chain: cursor.chain, scannedTo: Math.max(cursor.scannedTo, scannedTo) }
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
async function rescanRange(range, context) {
|
|
515
|
+
if (range.toBlock < range.fromBlock) {
|
|
516
|
+
throw new DepositError("BAD_RANGE", "A rescan range ends before it starts.");
|
|
517
|
+
}
|
|
518
|
+
const head = await context.reader.head();
|
|
519
|
+
const found = await context.reader.transfersIn(range);
|
|
520
|
+
const outcome = await classify(found, context, head);
|
|
521
|
+
return {
|
|
522
|
+
chain: context.chain.key,
|
|
523
|
+
fromBlock: range.fromBlock,
|
|
524
|
+
toBlock: range.toBlock,
|
|
525
|
+
head,
|
|
526
|
+
matched: outcome.matched,
|
|
527
|
+
credited: outcome.transactions.length,
|
|
528
|
+
pending: outcome.pendingBlocks.length,
|
|
529
|
+
duplicates: outcome.duplicates,
|
|
530
|
+
dust: outcome.dust,
|
|
531
|
+
transactions: outcome.transactions,
|
|
532
|
+
cursor: { chain: context.chain.key, scannedTo: range.toBlock }
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
async function classify(found, context, head) {
|
|
536
|
+
const transactions = [];
|
|
537
|
+
const pendingBlocks = [];
|
|
538
|
+
let matched = 0;
|
|
539
|
+
let duplicates = 0;
|
|
540
|
+
let dust = 0;
|
|
541
|
+
for (const item of found) {
|
|
542
|
+
const owner = context.addresses.get(item.to.trim().toLowerCase());
|
|
543
|
+
if (!owner)
|
|
544
|
+
continue;
|
|
545
|
+
matched += 1;
|
|
546
|
+
let amount;
|
|
547
|
+
try {
|
|
548
|
+
amount = money(BigInt(item.units), item.asset);
|
|
549
|
+
} catch {
|
|
550
|
+
throw new DepositError("UNKNOWN_ASSET", `${item.asset} arrived at a watched address on ${context.chain.key} and is not a known asset. ` + "Crediting it would invent a balance; ignoring it silently would lose a real deposit.");
|
|
551
|
+
}
|
|
552
|
+
const usdValue = context.usdValueOf(amount);
|
|
553
|
+
if (context.minDepositUsd !== undefined && usdValue < context.minDepositUsd) {
|
|
554
|
+
dust += 1;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (!isConfirmed({ chain: context.chain, transfer: item, head, usdValue })) {
|
|
558
|
+
pendingBlocks.push(item.blockNumber);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
const reference = depositReference({
|
|
562
|
+
chain: context.chain.key,
|
|
563
|
+
txHash: item.txHash,
|
|
564
|
+
logIndex: item.logIndex
|
|
565
|
+
});
|
|
566
|
+
if (await context.seen(reference)) {
|
|
567
|
+
duplicates += 1;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
transactions.push(creditDeposit({
|
|
571
|
+
chain: context.chain.key,
|
|
572
|
+
transfer: item,
|
|
573
|
+
owner,
|
|
574
|
+
amount,
|
|
575
|
+
atMs: context.atMs
|
|
576
|
+
}));
|
|
577
|
+
}
|
|
578
|
+
return { transactions, pendingBlocks, matched, duplicates, dust };
|
|
579
|
+
}
|
|
580
|
+
var cursorFrom = (chain, block) => {
|
|
581
|
+
if (!Number.isInteger(block) || block < 0) {
|
|
582
|
+
throw new DepositError("BAD_CURSOR", "A cursor starts at a whole, non-negative block.");
|
|
583
|
+
}
|
|
584
|
+
return { chain, scannedTo: block };
|
|
585
|
+
};
|
|
586
|
+
var describeScan = (report) => `${report.chain} ${report.fromBlock}–${report.toBlock} of ${report.head}: ` + `${report.credited} credited, ${report.pending} pending, ${report.duplicates} already seen, ${report.dust} dust`;
|
|
587
|
+
export {
|
|
588
|
+
scanOnce,
|
|
589
|
+
rescanRange,
|
|
590
|
+
isConfirmed,
|
|
591
|
+
describeScan,
|
|
592
|
+
depositReference,
|
|
593
|
+
cursorFrom,
|
|
594
|
+
creditDeposit,
|
|
595
|
+
DepositError
|
|
596
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { type Money } from './money';
|
|
2
|
+
/**
|
|
3
|
+
* Comparing what the chain holds against what the ledger says it owes.
|
|
4
|
+
*
|
|
5
|
+
* Every other guard in this package is preventive: the confirmation depth, the
|
|
6
|
+
* idempotency key, the hold placed with acceptance. This is the one that tells
|
|
7
|
+
* you a preventive guard FAILED — and it is the difference between finding a
|
|
8
|
+
* shortfall in a scheduled report and finding it in a withdrawal queue, where
|
|
9
|
+
* the person who finds it is a customer.
|
|
10
|
+
*
|
|
11
|
+
* ## Why a surplus is a finding too
|
|
12
|
+
*
|
|
13
|
+
* The instinct is to alert on "less on chain than we owe" and treat the other
|
|
14
|
+
* direction as a happy accident. It is not. A surplus means money arrived that
|
|
15
|
+
* nothing credited — a deposit the scanner missed, an asset nobody configured,
|
|
16
|
+
* a transfer to an address the book no longer derives. Somebody is owed that
|
|
17
|
+
* money and does not know it, and the longer it sits the harder it is to
|
|
18
|
+
* attribute. Both directions are reported, with the reason each matters.
|
|
19
|
+
*
|
|
20
|
+
* ## Why this holds no I/O
|
|
21
|
+
*
|
|
22
|
+
* `@forgezero/runtime` has no credentials and no network. Both sides are passed
|
|
23
|
+
* in, so the same comparison runs against a live node, a snapshot taken during
|
|
24
|
+
* an incident, or a fixture — and the tests exercise real discrepancies rather
|
|
25
|
+
* than a mock of a reconciler.
|
|
26
|
+
*/
|
|
27
|
+
export declare class ReconcileError extends Error {
|
|
28
|
+
readonly code: 'ASSET_MISMATCH' | 'MALFORMED';
|
|
29
|
+
constructor(code: 'ASSET_MISMATCH' | 'MALFORMED', message: string);
|
|
30
|
+
}
|
|
31
|
+
/** What the chain actually holds, per asset, across every address in custody. */
|
|
32
|
+
export interface OnChainHolding {
|
|
33
|
+
chain: string;
|
|
34
|
+
asset: string;
|
|
35
|
+
/** Deposit addresses plus the hot wallet plus cold storage — everything. */
|
|
36
|
+
units: bigint;
|
|
37
|
+
}
|
|
38
|
+
/** What the ledger says is owed, per asset. */
|
|
39
|
+
export interface LedgerLiability {
|
|
40
|
+
chain: string;
|
|
41
|
+
asset: string;
|
|
42
|
+
/**
|
|
43
|
+
* Every user balance in this asset, available and held together.
|
|
44
|
+
*
|
|
45
|
+
* Held is INCLUDED. A withdrawal awaiting approval is money the platform
|
|
46
|
+
* still owes somebody; excluding it would make the platform look solvent
|
|
47
|
+
* exactly while a payout queue is building up against it.
|
|
48
|
+
*/
|
|
49
|
+
units: bigint;
|
|
50
|
+
/** Dispatched and not yet confirmed — in flight, and no longer on chain. */
|
|
51
|
+
inFlightUnits?: bigint;
|
|
52
|
+
}
|
|
53
|
+
export declare const DISCREPANCY_KINDS: readonly ["shortfall", "surplus"];
|
|
54
|
+
export type DiscrepancyKind = (typeof DISCREPANCY_KINDS)[number];
|
|
55
|
+
export interface Discrepancy {
|
|
56
|
+
chain: string;
|
|
57
|
+
asset: string;
|
|
58
|
+
kind: DiscrepancyKind;
|
|
59
|
+
/** Always positive — `kind` carries the direction. */
|
|
60
|
+
difference: Money;
|
|
61
|
+
onChain: Money;
|
|
62
|
+
owed: Money;
|
|
63
|
+
/** What this means, in the terms an operator has to act on. */
|
|
64
|
+
meaning: string;
|
|
65
|
+
}
|
|
66
|
+
export interface ReconcileReport {
|
|
67
|
+
atMs: number;
|
|
68
|
+
/** Every (chain, asset) pair compared, whether or not it differed. */
|
|
69
|
+
checked: number;
|
|
70
|
+
discrepancies: readonly Discrepancy[];
|
|
71
|
+
/** True when nothing differed. The only result that needs no action. */
|
|
72
|
+
balanced: boolean;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Compare one snapshot of both sides.
|
|
76
|
+
*
|
|
77
|
+
* A pair present on one side and absent from the other is treated as zero on
|
|
78
|
+
* the missing side rather than skipped. Skipping is how the most serious case
|
|
79
|
+
* hides: an asset that exists on chain and is entirely unknown to the ledger
|
|
80
|
+
* has no ledger row to iterate over, so a loop driven by ledger rows never
|
|
81
|
+
* looks at it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function reconcileOnce(args: {
|
|
84
|
+
onChain: readonly OnChainHolding[];
|
|
85
|
+
owed: readonly LedgerLiability[];
|
|
86
|
+
/**
|
|
87
|
+
* How much difference is not worth reporting, per asset in smallest units.
|
|
88
|
+
*
|
|
89
|
+
* Not a way to hide a shortfall — a tolerance covers rounding on chains that
|
|
90
|
+
* charge fees in the transferred asset, and nothing larger. It is per asset
|
|
91
|
+
* because "a thousand units" is dust in one and a fortune in another.
|
|
92
|
+
*/
|
|
93
|
+
tolerance?: Record<string, bigint>;
|
|
94
|
+
atMs: number;
|
|
95
|
+
}): ReconcileReport;
|
|
96
|
+
/**
|
|
97
|
+
* The report, as an operator reads it.
|
|
98
|
+
*
|
|
99
|
+
* Shortfalls first and named individually. A summary that leads with "3
|
|
100
|
+
* discrepancies" buries the one that matters underneath two that do not, and
|
|
101
|
+
* this text exists to be the first line of an alert.
|
|
102
|
+
*/
|
|
103
|
+
export declare function reconcileReport(report: ReconcileReport): string;
|
|
104
|
+
/**
|
|
105
|
+
* The one number an operator watches.
|
|
106
|
+
*
|
|
107
|
+
* Total shortfall across every pair, in USD, using a rate function the caller
|
|
108
|
+
* supplies. Per-asset figures are what you act on; this is what tells you
|
|
109
|
+
* whether to act now — and summing units across assets without converting
|
|
110
|
+
* would add satoshis to cents and produce a number that means nothing.
|
|
111
|
+
*/
|
|
112
|
+
export declare function shortfallUsd(report: ReconcileReport, usdValueOf: (amount: Money) => number): number;
|