@forgezero/runtime 0.1.14 → 0.1.15
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/README.md +27 -388
- package/dist/notify-templates.js +1 -1
- package/dist/schema-typebox.js +116 -7
- package/dist/schema.d.ts +14 -1
- package/dist/schema.js +116 -7
- package/package.json +2 -58
- package/dist/compliance.d.ts +0 -172
- package/dist/compliance.js +0 -168
- package/dist/finance/chain-addresses.d.ts +0 -130
- package/dist/finance/chain-addresses.js +0 -462
- package/dist/finance/chain-deposits.d.ts +0 -193
- package/dist/finance/chain-deposits.js +0 -600
- package/dist/finance/chain-reconcile.d.ts +0 -112
- package/dist/finance/chain-reconcile.js +0 -76
- package/dist/finance/chain-withdrawals.d.ts +0 -223
- package/dist/finance/chain-withdrawals.js +0 -635
- package/dist/finance/chain.d.ts +0 -116
- package/dist/finance/chain.js +0 -316
- package/dist/finance/commission.d.ts +0 -155
- package/dist/finance/commission.js +0 -423
- package/dist/finance/custody.d.ts +0 -68
- package/dist/finance/custody.js +0 -107
- package/dist/finance/derive.d.ts +0 -115
- package/dist/finance/derive.js +0 -116
- package/dist/finance/ledger.d.ts +0 -227
- package/dist/finance/ledger.js +0 -313
- package/dist/finance/market.d.ts +0 -209
- package/dist/finance/market.js +0 -112
- package/dist/finance/rates.d.ts +0 -178
- package/dist/finance/rates.js +0 -292
- package/dist/finance/transfers.d.ts +0 -153
- package/dist/finance/transfers.js +0 -292
- package/dist/finance/venues.d.ts +0 -190
- package/dist/finance/venues.js +0 -251
package/dist/finance/rates.d.ts
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
import { type Money } from './money';
|
|
2
|
-
/**
|
|
3
|
-
* What an asset is worth, and how old that answer is.
|
|
4
|
-
*
|
|
5
|
-
* The platform keeps its books in USD, so every balance in BTC, ETH or a token
|
|
6
|
-
* has to be expressed in one unit before it can be added to anything. That
|
|
7
|
-
* needs a rate, and a rate needs two things people routinely leave out:
|
|
8
|
-
*
|
|
9
|
-
* A SOURCE a peg is not a quote. USDT is 1 by definition and asking an
|
|
10
|
-
* exchange for it introduces an error where none existed — a
|
|
11
|
-
* stablecoin trading at 0.9994 would silently revalue every
|
|
12
|
-
* balance on the platform by six basis points.
|
|
13
|
-
* AN AGE a rate with no timestamp is a rate that keeps quoting yesterday
|
|
14
|
-
* after the feed dies. Nothing here returns a value without also
|
|
15
|
-
* saying when it was true, and a caller pricing a withdrawal can
|
|
16
|
-
* refuse a stale one.
|
|
17
|
-
*
|
|
18
|
-
* ## Why the conversion itself is not implemented here
|
|
19
|
-
*
|
|
20
|
-
* `./money` already has `convert`, and the hard part is not
|
|
21
|
-
* the multiplication — it is the decimal adjustment between assets of different
|
|
22
|
-
* precision, which is where a factor-of-a-thousand error comes from. That is
|
|
23
|
-
* written and tested once. This module answers *what rate*, never *how to
|
|
24
|
-
* apply it*.
|
|
25
|
-
*/
|
|
26
|
-
export declare class RateError extends Error {
|
|
27
|
-
readonly code: 'NO_RATE' | 'STALE_RATE' | 'BAD_RATE' | 'UNKNOWN_ASSET';
|
|
28
|
-
readonly asset?: string | undefined;
|
|
29
|
-
constructor(code: 'NO_RATE' | 'STALE_RATE' | 'BAD_RATE' | 'UNKNOWN_ASSET', message: string, asset?: string | undefined);
|
|
30
|
-
}
|
|
31
|
-
/** The unit the platform keeps its books in. */
|
|
32
|
-
export declare const BASE_ASSET = "USD";
|
|
33
|
-
export declare const RATE_SOURCES: readonly ["peg", "venue", "manual"];
|
|
34
|
-
export type RateSource = (typeof RATE_SOURCES)[number];
|
|
35
|
-
/**
|
|
36
|
-
* How an asset is priced.
|
|
37
|
-
*
|
|
38
|
-
* peg fixed by definition and never fetched. USDT is 1.
|
|
39
|
-
* venue quoted live from a chosen source, and therefore has an age.
|
|
40
|
-
* manual an operator typed it. Also has an age, and deliberately so: a
|
|
41
|
-
* manual rate somebody set in March is more dangerous than a missing
|
|
42
|
-
* one, because it looks maintained.
|
|
43
|
-
*/
|
|
44
|
-
export interface AssetRate {
|
|
45
|
-
asset: string;
|
|
46
|
-
source: RateSource;
|
|
47
|
-
/** USD per ONE whole unit, as a decimal string. Never a float. */
|
|
48
|
-
usd: string;
|
|
49
|
-
/** When this was true. Absent only for a peg, which has no such moment. */
|
|
50
|
-
atMs?: number;
|
|
51
|
-
/** For `venue`: which market it came from, so a wrong price is traceable. */
|
|
52
|
-
via?: string;
|
|
53
|
-
}
|
|
54
|
-
export interface AssetEntry {
|
|
55
|
-
code: string;
|
|
56
|
-
label: string;
|
|
57
|
-
decimals: number;
|
|
58
|
-
/**
|
|
59
|
-
* Chain this asset lives on, when it is a token.
|
|
60
|
-
*
|
|
61
|
-
* Absent for fiat and for a chain's own coin, which is why the field is
|
|
62
|
-
* optional rather than a string that sometimes says "native" — a sentinel
|
|
63
|
-
* value in a foreign key is a join nobody can write.
|
|
64
|
-
*/
|
|
65
|
-
chain?: string;
|
|
66
|
-
/** Contract address for a token. Absent for a native coin. */
|
|
67
|
-
contract?: string;
|
|
68
|
-
enabled: boolean;
|
|
69
|
-
rate: AssetRate;
|
|
70
|
-
}
|
|
71
|
-
export declare function assertRate(rate: AssetRate): void;
|
|
72
|
-
/** USDT and USDC at exactly 1, by definition rather than by quote. */
|
|
73
|
-
export declare const peg: (asset: string, usd?: string) => AssetRate;
|
|
74
|
-
export interface RateTableOptions {
|
|
75
|
-
/** How old a quoted rate may be before it is refused. Default 15 minutes. */
|
|
76
|
-
maxAgeMs?: number;
|
|
77
|
-
now?: () => number;
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* The rates in force, and the one place that decides a quote is too old.
|
|
81
|
-
*
|
|
82
|
-
* Staleness is checked on READ rather than swept on a timer. A sweeper that
|
|
83
|
-
* deletes old rates turns a stale-price incident into a missing-price incident
|
|
84
|
-
* — the same outage with less information — and it can itself fail, leaving
|
|
85
|
-
* rates that look fresh because nothing removed them.
|
|
86
|
-
*/
|
|
87
|
-
export declare function createRateTable(options?: RateTableOptions): {
|
|
88
|
-
set(rate: AssetRate): void;
|
|
89
|
-
/** The raw entry, however old. For an admin screen that must show staleness. */
|
|
90
|
-
peek: (asset: string) => AssetRate | undefined;
|
|
91
|
-
ageOf(asset: string): number | undefined;
|
|
92
|
-
isStale(asset: string): boolean;
|
|
93
|
-
/**
|
|
94
|
-
* The rate, or a refusal that says which problem it is.
|
|
95
|
-
*
|
|
96
|
-
* Missing and stale are different failures with different fixes — one is
|
|
97
|
-
* an unconfigured asset, the other a dead feed — and collapsing them sends
|
|
98
|
-
* an operator to the wrong screen.
|
|
99
|
-
*
|
|
100
|
-
* Every valuation below goes through THIS, so the staleness rule exists in
|
|
101
|
-
* exactly one place. Two copies of it is two chances for a valuation path
|
|
102
|
-
* to accept a rate another path would refuse.
|
|
103
|
-
*/
|
|
104
|
-
require(asset: string): AssetRate;
|
|
105
|
-
/**
|
|
106
|
-
* Value an amount in USD.
|
|
107
|
-
*
|
|
108
|
-
* Rounded DOWN. A rounding decision on a platform balance should leave the
|
|
109
|
-
* platform conservative about what it holds — a thousand rounded-up
|
|
110
|
-
* valuations become a shortfall nobody can locate.
|
|
111
|
-
*/
|
|
112
|
-
toUsd(amount: Money): Money;
|
|
113
|
-
/** Sum a mixed bag of assets into one USD figure. */
|
|
114
|
-
totalUsd(amounts: readonly Money[]): Money;
|
|
115
|
-
all: () => AssetRate[];
|
|
116
|
-
clear: () => void;
|
|
117
|
-
};
|
|
118
|
-
export type RateTable = ReturnType<typeof createRateTable>;
|
|
119
|
-
/**
|
|
120
|
-
* Register an asset so `money` knows its precision.
|
|
121
|
-
*
|
|
122
|
-
* Called when an admin adds one. Getting `decimals` wrong is a factor-of-ten
|
|
123
|
-
* error in every balance of that asset, which is why it is declared per asset
|
|
124
|
-
* rather than defaulted — there is no safe default.
|
|
125
|
-
*/
|
|
126
|
-
export declare function registerAsset(entry: AssetEntry): void;
|
|
127
|
-
export declare const isRegistered: (code: string) => boolean;
|
|
128
|
-
/**
|
|
129
|
-
* A rate fetched from a venue.
|
|
130
|
-
*
|
|
131
|
-
* The fetcher is injected rather than imported, so this module stays free of a
|
|
132
|
-
* transport and a test needs no network. The venue adapter already carries the
|
|
133
|
-
* weight budget, which is the reason not to open a second HTTP path here.
|
|
134
|
-
*/
|
|
135
|
-
export interface RateFetcher {
|
|
136
|
-
/** USD price of one whole unit, as a decimal string, or undefined if unquoted. */
|
|
137
|
-
quote(asset: string): Promise<string | undefined>;
|
|
138
|
-
}
|
|
139
|
-
export interface RefreshReport {
|
|
140
|
-
refreshed: string[];
|
|
141
|
-
/** Assets whose quote failed. The PREVIOUS rate is left in place — see below. */
|
|
142
|
-
failed: {
|
|
143
|
-
asset: string;
|
|
144
|
-
reason: string;
|
|
145
|
-
}[];
|
|
146
|
-
skipped: string[];
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Refresh every quoted asset.
|
|
150
|
-
*
|
|
151
|
-
* A failed quote leaves the old rate alone rather than clearing it. Clearing
|
|
152
|
-
* would turn a brief feed outage into "no rate configured", which reads as a
|
|
153
|
-
* misconfiguration and sends somebody to the wrong screen — and the old rate is
|
|
154
|
-
* still visibly ageing, so `require()` refuses it on its own once it passes the
|
|
155
|
-
* limit. Doing nothing is the correct action; doing nothing SILENTLY is not,
|
|
156
|
-
* which is why failures are reported.
|
|
157
|
-
*/
|
|
158
|
-
export declare function refreshRates(table: RateTable, fetcher: RateFetcher, args: {
|
|
159
|
-
assets: readonly AssetEntry[];
|
|
160
|
-
now?: () => number;
|
|
161
|
-
}): Promise<RefreshReport>;
|
|
162
|
-
/** The refresh as a job spec, for `@forgezero/runtime/jobs`. */
|
|
163
|
-
export declare function rateRefreshJob(args: {
|
|
164
|
-
table: RateTable;
|
|
165
|
-
fetcher: RateFetcher;
|
|
166
|
-
assets: () => readonly AssetEntry[];
|
|
167
|
-
everyMs?: number;
|
|
168
|
-
key?: string;
|
|
169
|
-
}): {
|
|
170
|
-
key: string;
|
|
171
|
-
everyMs: number;
|
|
172
|
-
run: () => Promise<{
|
|
173
|
-
ok: boolean;
|
|
174
|
-
detail: RefreshReport;
|
|
175
|
-
}>;
|
|
176
|
-
};
|
|
177
|
-
/** For an admin screen: what each asset is worth and how much to trust it. */
|
|
178
|
-
export declare const describeRate: (rate: AssetRate, ageMs?: number) => string;
|
package/dist/finance/rates.js
DELETED
|
@@ -1,292 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { Refusal } from '@forgezero/access';
|
|
2
|
-
import { type Money } from './money';
|
|
3
|
-
/**
|
|
4
|
-
* Deposits and withdrawals as ORDERED PIPELINES, not as functions.
|
|
5
|
-
*
|
|
6
|
-
* The seam exists before the thing that plugs into it, and that ordering is the
|
|
7
|
-
* whole point. AML screening is deferred, but the position it will occupy —
|
|
8
|
-
* first, before anything else looks at the transfer — is decided now. Adding a
|
|
9
|
-
* first-position check to a function later means editing every path money
|
|
10
|
-
* takes, and the one path somebody misses is the one that matters.
|
|
11
|
-
*
|
|
12
|
-
* So a transfer runs a list of stages. Adding screening later is registering a
|
|
13
|
-
* stage. Nothing in deposit or withdrawal code changes.
|
|
14
|
-
*
|
|
15
|
-
* ## Stages refuse the way routes refuse
|
|
16
|
-
*
|
|
17
|
-
* A stage throws `Refusal` from `@forgezero/access`, which already carries a
|
|
18
|
-
* status, a code, details and a retryable flag — and the HTTP pipeline already
|
|
19
|
-
* knows how to turn one into a response. So a screening hit and a route denial
|
|
20
|
-
* are the same shape, are logged the same way, and reach a client the same way.
|
|
21
|
-
* Inventing a second failure type here would mean translating between them at
|
|
22
|
-
* every boundary, and a translation layer is where a `retryable` flag gets lost.
|
|
23
|
-
*
|
|
24
|
-
* ## What this does NOT do
|
|
25
|
-
*
|
|
26
|
-
* No balance movement, no persistence, no ordering. Postings belong to
|
|
27
|
-
* `./ledger` and ordering belongs to the queue keyed by account owner. This
|
|
28
|
-
* decides only whether a transfer may proceed, and records why not.
|
|
29
|
-
*/
|
|
30
|
-
export declare const DIRECTIONS: readonly ["deposit", "withdrawal"];
|
|
31
|
-
export type Direction = (typeof DIRECTIONS)[number];
|
|
32
|
-
export interface Transfer {
|
|
33
|
-
direction: Direction;
|
|
34
|
-
/** The account owner. Also the queue key the caller should be holding. */
|
|
35
|
-
owner: string;
|
|
36
|
-
amount: Money;
|
|
37
|
-
/** Chain, venue, or wherever this is coming from or going to. */
|
|
38
|
-
network?: string;
|
|
39
|
-
/** Destination for a withdrawal, source for a deposit. */
|
|
40
|
-
address?: string;
|
|
41
|
-
/** On-chain transaction hash, once one exists. */
|
|
42
|
-
txHash?: string;
|
|
43
|
-
/** Idempotency key. The queue's `dedupeKey` for this transfer. */
|
|
44
|
-
reference: string;
|
|
45
|
-
atMs: number;
|
|
46
|
-
/** Anything a stage wants to pass to a later one. */
|
|
47
|
-
context?: Record<string, unknown>;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* One check.
|
|
51
|
-
*
|
|
52
|
-
* Returning normally means "proceed". Throwing `Refusal` stops the transfer
|
|
53
|
-
* with a reason a client can read. A stage may also return a patch, which is
|
|
54
|
-
* merged into `context` for later stages — that is how screening passes a risk
|
|
55
|
-
* score to an approval stage without either knowing about the other.
|
|
56
|
-
*/
|
|
57
|
-
export interface Stage {
|
|
58
|
-
name: string;
|
|
59
|
-
/**
|
|
60
|
-
* Where this runs. Lower numbers first, ties broken by registration order.
|
|
61
|
-
*
|
|
62
|
-
* Explicit rather than array position, because the ordering IS the security
|
|
63
|
-
* property here and a list somebody reorders while tidying is a screening
|
|
64
|
-
* check that silently moved behind the balance check.
|
|
65
|
-
*/
|
|
66
|
-
order: number;
|
|
67
|
-
directions?: readonly Direction[];
|
|
68
|
-
run(transfer: Transfer): Promise<void | Record<string, unknown>> | void | Record<string, unknown>;
|
|
69
|
-
}
|
|
70
|
-
/** Reserved for the check that must always be first. */
|
|
71
|
-
export declare const SCREENING_ORDER = 0;
|
|
72
|
-
export interface StageOutcome {
|
|
73
|
-
stage: string;
|
|
74
|
-
ok: boolean;
|
|
75
|
-
ms: number;
|
|
76
|
-
code?: string;
|
|
77
|
-
}
|
|
78
|
-
export interface PipelineResult {
|
|
79
|
-
ok: boolean;
|
|
80
|
-
transfer: Transfer;
|
|
81
|
-
stages: StageOutcome[];
|
|
82
|
-
/** The refusal that stopped it. Absent when it passed. */
|
|
83
|
-
refusal?: Refusal;
|
|
84
|
-
}
|
|
85
|
-
export declare class PipelineError extends Error {
|
|
86
|
-
readonly code: 'DUPLICATE_STAGE' | 'NO_SCREENING';
|
|
87
|
-
constructor(code: 'DUPLICATE_STAGE' | 'NO_SCREENING', message: string);
|
|
88
|
-
}
|
|
89
|
-
/**
|
|
90
|
-
* Build a pipeline from stages.
|
|
91
|
-
*
|
|
92
|
-
* `requireScreening` is off by default and ON for production wiring: a platform
|
|
93
|
-
* that has decided it needs AML should fail to start rather than run without
|
|
94
|
-
* it. A missing screening stage is exactly the kind of omission that is
|
|
95
|
-
* invisible until somebody audits, so it is expressible as a boot-time
|
|
96
|
-
* assertion rather than a runbook item.
|
|
97
|
-
*/
|
|
98
|
-
export declare function createTransferPipeline(options: {
|
|
99
|
-
stages: readonly Stage[];
|
|
100
|
-
requireScreening?: boolean;
|
|
101
|
-
now?: () => number;
|
|
102
|
-
/** Called for every stage, passed or refused. The audit hook. */
|
|
103
|
-
onStage?: (outcome: StageOutcome, transfer: Transfer) => void;
|
|
104
|
-
}): {
|
|
105
|
-
stages: {
|
|
106
|
-
name: string;
|
|
107
|
-
order: number;
|
|
108
|
-
}[];
|
|
109
|
-
/**
|
|
110
|
-
* Run every stage that applies, in order, stopping at the first refusal.
|
|
111
|
-
*
|
|
112
|
-
* First refusal, not all of them: the stages are a sequence where each may
|
|
113
|
-
* depend on the last having passed, so continuing past one would run checks
|
|
114
|
-
* against a transfer that has already been rejected — and report failures
|
|
115
|
-
* that are consequences rather than causes.
|
|
116
|
-
*/
|
|
117
|
-
run(transfer: Transfer): Promise<PipelineResult>;
|
|
118
|
-
};
|
|
119
|
-
export type TransferPipeline = ReturnType<typeof createTransferPipeline>;
|
|
120
|
-
/**
|
|
121
|
-
* The AML placeholder.
|
|
122
|
-
*
|
|
123
|
-
* Registered at order 0 and currently passes everything, which is what the
|
|
124
|
-
* owner asked for. Its value is entirely in existing: the position is agreed,
|
|
125
|
-
* the audit trail already records it running, and replacing the body later
|
|
126
|
-
* changes no caller.
|
|
127
|
-
*
|
|
128
|
-
* `allow` is a parameter rather than a hard-coded `true` so a test can prove
|
|
129
|
-
* the pipeline actually stops on a screening refusal — a stage that has never
|
|
130
|
-
* refused anything is a stage nobody knows works.
|
|
131
|
-
*/
|
|
132
|
-
export declare const screeningStage: (check?: (transfer: Transfer) => Promise<boolean> | boolean) => Stage;
|
|
133
|
-
/** Amount bounds, per direction. */
|
|
134
|
-
export declare const limitsStage: (limits: {
|
|
135
|
-
min?: Money;
|
|
136
|
-
max?: Money;
|
|
137
|
-
directions?: readonly Direction[];
|
|
138
|
-
}) => Stage;
|
|
139
|
-
/**
|
|
140
|
-
* Enough available balance, for a withdrawal.
|
|
141
|
-
*
|
|
142
|
-
* Available, never total — a check against total lets a withdrawal spend what
|
|
143
|
-
* an open order has already committed. Runs AFTER screening, deliberately: a
|
|
144
|
-
* screened-out transfer should not reveal whether the balance was sufficient.
|
|
145
|
-
*/
|
|
146
|
-
export declare const balanceStage: (available: (transfer: Transfer) => Promise<Money> | Money) => Stage;
|
|
147
|
-
/** Hold a withdrawal for a human above a threshold. */
|
|
148
|
-
export declare const approvalStage: (args: {
|
|
149
|
-
threshold: Money;
|
|
150
|
-
isApproved: (transfer: Transfer) => Promise<boolean> | boolean;
|
|
151
|
-
}) => Stage;
|
|
152
|
-
/** Refuse while an account is frozen for non-payment. */
|
|
153
|
-
export declare const notFrozenStage: (isFrozen: (owner: string) => Promise<boolean> | boolean) => Stage;
|