@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.
@@ -1,112 +0,0 @@
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;
@@ -1,76 +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/chain-reconcile.ts
10
- class ReconcileError extends Error {
11
- code;
12
- constructor(code, message) {
13
- super(message);
14
- this.code = code;
15
- this.name = "ReconcileError";
16
- }
17
- }
18
- var DISCREPANCY_KINDS = ["shortfall", "surplus"];
19
- var key = (item) => `${item.chain}:${item.asset}`;
20
- function reconcileOnce(args) {
21
- const chainSide = new Map(args.onChain.map((item) => [key(item), item]));
22
- const ledgerSide = new Map(args.owed.map((item) => [key(item), item]));
23
- const pairs = [...new Set([...chainSide.keys(), ...ledgerSide.keys()])].sort();
24
- const discrepancies = [];
25
- for (const pair of pairs) {
26
- const held = chainSide.get(pair);
27
- const liability = ledgerSide.get(pair);
28
- const [chain, asset] = pair.split(":");
29
- const onChainUnits = held?.units ?? 0n;
30
- const owedUnits = (liability?.units ?? 0n) + (liability?.inFlightUnits ?? 0n);
31
- const difference = onChainUnits - owedUnits;
32
- const allowed = args.tolerance?.[asset] ?? 0n;
33
- if (difference >= -allowed && difference <= allowed)
34
- continue;
35
- const kind = difference < 0n ? "shortfall" : "surplus";
36
- discrepancies.push({
37
- chain,
38
- asset,
39
- kind,
40
- difference: { units: difference < 0n ? -difference : difference, asset },
41
- onChain: { units: onChainUnits, asset },
42
- owed: { units: owedUnits, asset },
43
- meaning: kind === "shortfall" ? "The chain holds less than is owed. Withdrawals will fail once the balance is drawn down — " + "this is the state that becomes a customer-facing incident." : "Money arrived that nothing credited. Somebody is owed it and does not know, and attribution " + "gets harder the longer it sits."
44
- });
45
- }
46
- return {
47
- atMs: args.atMs,
48
- checked: pairs.length,
49
- discrepancies,
50
- balanced: discrepancies.length === 0
51
- };
52
- }
53
- function reconcileReport(report) {
54
- if (report.balanced) {
55
- return `Reconciled: ${report.checked} chain/asset pair(s) agree.`;
56
- }
57
- const shortfalls = report.discrepancies.filter((item) => item.kind === "shortfall");
58
- const surpluses = report.discrepancies.filter((item) => item.kind === "surplus");
59
- const line = (item) => ` ${item.kind === "shortfall" ? "SHORT" : "over "} ${item.chain}/${item.asset}: ` + `${item.difference.units} units (chain ${item.onChain.units}, owed ${item.owed.units})`;
60
- return [
61
- shortfalls.length > 0 ? `SHORTFALL on ${shortfalls.length} pair(s) — withdrawals will fail once drawn down.` : `No shortfall. ${surpluses.length} pair(s) hold more than is owed.`,
62
- ...shortfalls.map(line),
63
- ...surpluses.map(line)
64
- ].join(`
65
- `);
66
- }
67
- function shortfallUsd(report, usdValueOf) {
68
- return report.discrepancies.filter((item) => item.kind === "shortfall").reduce((total, item) => total + usdValueOf(item.difference), 0);
69
- }
70
- export {
71
- shortfallUsd,
72
- reconcileReport,
73
- reconcileOnce,
74
- ReconcileError,
75
- DISCREPANCY_KINDS
76
- };
@@ -1,223 +0,0 @@
1
- import { type Transaction } from './ledger';
2
- import { type Money } from './money';
3
- import { type ChainSpec } from './chain';
4
- /**
5
- * Getting money out, which is the most dangerous path in the product.
6
- *
7
- * Everything else here can be wrong and cost a correction. This one can be
8
- * wrong and cost the money. Four things go wrong in practice and all four are
9
- * arranged against explicitly:
10
- *
11
- * THE HOLD placed as part of accepting the request, never after. A gap
12
- * between "accepted" and "held" is a window in which the same
13
- * balance funds two withdrawals.
14
- * THE BROADCAST keyed, so a retry after a timeout cannot send twice. A
15
- * dispatcher that cannot tell "did not send" from "sent, reply
16
- * lost" will eventually send twice.
17
- * THE REFUND a transaction that fails AFTER the balance was debited leaves
18
- * the user short. The prior art reconciled failed and rejected
19
- * refunds in two separate passes, because both were found
20
- * missing in production — separately.
21
- * THE SWEEP on demand to fund a payout, never on a timer. A timer burns
22
- * gas continuously moving dust nobody asked to move.
23
- *
24
- * ## A hold is not a lock
25
- *
26
- * It is a posting between the owner's `available` and `held` sub-accounts. So
27
- * `availableOf()` is an ordinary balance query, there is no reservation table,
28
- * no expiry sweeper, and a process that dies mid-withdrawal strands nothing —
29
- * the posting either happened or it did not.
30
- */
31
- export declare class WithdrawalError extends Error {
32
- readonly code: 'BAD_STATE' | 'BAD_ADDRESS' | 'BAD_AMOUNT' | 'INSUFFICIENT' | 'CHAIN_DISABLED' | 'MALFORMED';
33
- constructor(code: 'BAD_STATE' | 'BAD_ADDRESS' | 'BAD_AMOUNT' | 'INSUFFICIENT' | 'CHAIN_DISABLED' | 'MALFORMED', message: string);
34
- }
35
- /**
36
- * Every state a withdrawal can be in, and there are no others.
37
- *
38
- * Written down as a list because the transitions are checked against it. An
39
- * undeclared state reachable by assignment is how a withdrawal ends up
40
- * `approved` twice, or dispatched from a state that never held the funds.
41
- */
42
- export declare const WITHDRAWAL_STATES: readonly ["requested", "approved", "dispatched", "confirmed", "rejected", "failed", "refunded"];
43
- export type WithdrawalState = (typeof WITHDRAWAL_STATES)[number];
44
- export interface Withdrawal {
45
- key: string;
46
- owner: string;
47
- chain: string;
48
- /** Where the money is going. Validated against the chain's scheme. */
49
- destination: string;
50
- amount: Money;
51
- state: WithdrawalState;
52
- requestedAtMs: number;
53
- /** The broadcast idempotency key. Present from `approved` onward. */
54
- broadcastKey?: string;
55
- txHash?: string;
56
- decidedByUserKey?: string;
57
- reason?: string;
58
- }
59
- /** May this withdrawal move there? */
60
- export declare const canTransition: (from: WithdrawalState, to: WithdrawalState) => boolean;
61
- /** The reference for the hold, and therefore the queue's dedupe key. */
62
- export declare const holdReference: (key: string) => string;
63
- /**
64
- * The key a broadcast is made under.
65
- *
66
- * Derived from the withdrawal, so the same withdrawal produces the same key on
67
- * every attempt. A dispatcher that timed out and retries presents this again;
68
- * anything downstream that has seen it already knows this is the same send
69
- * rather than a second one. Deriving it rather than generating it is the whole
70
- * point — a generated key is a new key on every retry, which is exactly the
71
- * failure it was meant to prevent.
72
- */
73
- export declare const broadcastKeyFor: (withdrawal: Pick<Withdrawal, "key" | "chain">) => string;
74
- /**
75
- * Accept a request, and hold the funds in the same breath.
76
- *
77
- * Returns the withdrawal AND the posting together, so a caller cannot persist
78
- * one without the other. Two functions — "create the request" then "place the
79
- * hold" — would leave a window in which the balance is still available and a
80
- * second request can spend it. They are one operation because they must not be
81
- * separable.
82
- */
83
- export declare function requestWithdrawal(args: {
84
- key: string;
85
- owner: string;
86
- chain: ChainSpec;
87
- destination: string;
88
- amount: Money;
89
- /** From `availableOf()` — what the owner can actually spend right now. */
90
- available: Money;
91
- atMs: number;
92
- }): {
93
- withdrawal: Withdrawal;
94
- hold: Transaction;
95
- };
96
- /**
97
- * A human says yes.
98
- *
99
- * No posting: the funds were held at request time and stay held. Approval mints
100
- * the broadcast key, which is the moment the send acquires an identity — before
101
- * this there is nothing to be idempotent about.
102
- */
103
- export declare function approveWithdrawal(withdrawal: Withdrawal, args: {
104
- actorUserKey: string;
105
- atMs: number;
106
- }): Withdrawal;
107
- /**
108
- * A human says no, and the hold comes back.
109
- *
110
- * Reachable from `approved` as well as `requested`: a withdrawal approved and
111
- * not yet broadcast can still be stopped, and that is exactly when somebody
112
- * notices something is wrong.
113
- */
114
- export declare function rejectWithdrawal(withdrawal: Withdrawal, args: {
115
- actorUserKey: string;
116
- reason: string;
117
- atMs: number;
118
- }): {
119
- withdrawal: Withdrawal;
120
- release: Transaction;
121
- };
122
- /** What a broadcaster must provide. Injected — runtime holds no credentials. */
123
- export interface Broadcaster {
124
- /**
125
- * Send, or report that this key was already sent.
126
- *
127
- * The KEY is the argument that matters. An implementation that ignores it
128
- * and sends anyway defeats the only protection against a double payout, so
129
- * it is passed first and named for what it does.
130
- */
131
- send(args: {
132
- broadcastKey: string;
133
- chain: string;
134
- destination: string;
135
- amount: Money;
136
- }): Promise<{
137
- txHash: string;
138
- alreadySent: boolean;
139
- }>;
140
- }
141
- /**
142
- * Broadcast one approved withdrawal.
143
- *
144
- * `alreadySent` is treated as success, not as an error. A dispatcher that timed
145
- * out and retried is the ordinary case, and treating the second attempt as a
146
- * failure would move a withdrawal that IS on-chain into `failed` and then
147
- * refund it — paying the same money twice, which is the exact outcome the key
148
- * exists to prevent.
149
- */
150
- export declare function processOnce(withdrawal: Withdrawal, args: {
151
- broadcaster: Broadcaster;
152
- atMs: number;
153
- }): Promise<Withdrawal>;
154
- /**
155
- * Seen on-chain, deep enough. The hold becomes a real debit.
156
- *
157
- * `captureHold` is what actually removes the money: until now it was held, not
158
- * spent, and a withdrawal abandoned before this point costs the owner nothing.
159
- */
160
- export declare function confirmWithdrawal(withdrawal: Withdrawal, args: {
161
- txHash: string;
162
- atMs: number;
163
- }): {
164
- withdrawal: Withdrawal;
165
- capture: Transaction;
166
- };
167
- /** Broadcast and failed on-chain. Marked, not yet made good. */
168
- export declare function markFailed(withdrawal: Withdrawal, args: {
169
- reason: string;
170
- atMs: number;
171
- }): Withdrawal;
172
- /**
173
- * Make a failed withdrawal good.
174
- *
175
- * A separate pass from rejection on purpose. They look like one problem — money
176
- * to return — and they are two: a rejection never left the platform, whereas a
177
- * failure was broadcast and did not land. The prior art ran them as one and
178
- * found BOTH refund paths missing in production, separately, which is the
179
- * strongest argument available for keeping them apart.
180
- */
181
- export declare function refundFailed(withdrawal: Withdrawal, args: {
182
- atMs: number;
183
- }): {
184
- withdrawal: Withdrawal;
185
- release: Transaction;
186
- };
187
- /** A deposit address holding funds that have not been moved to the hot wallet. */
188
- export interface SweepCandidate {
189
- address: string;
190
- owner: string;
191
- amount: Money;
192
- /** What it costs to move it, in the same asset terms as `amount`. */
193
- estimatedGas: Money;
194
- }
195
- export interface SweepPlan {
196
- /** Addresses worth sweeping, largest first. */
197
- sweep: readonly SweepCandidate[];
198
- /** Skipped because moving them costs more than they hold. */
199
- uneconomic: readonly SweepCandidate[];
200
- /** What the plan raises, net of gas. */
201
- raised: Money;
202
- /** True when the plan cannot fund the payout even after sweeping everything. */
203
- short: boolean;
204
- }
205
- /**
206
- * Which deposit addresses to sweep to fund one payout.
207
- *
208
- * ON DEMAND, never on a timer. A timer sweeps continuously and burns gas moving
209
- * dust nobody asked to move; funding a specific payout sweeps the least it can
210
- * and stops. That is why this takes a target rather than a schedule.
211
- *
212
- * Largest first, so the fewest transactions raise the amount — each sweep is
213
- * its own gas cost, and twenty small ones cost more than two large ones raising
214
- * the same total.
215
- */
216
- export declare function sweepForPayout(args: {
217
- need: Money;
218
- /** Already in the hot wallet and spendable without sweeping anything. */
219
- hot: Money;
220
- candidates: readonly SweepCandidate[];
221
- }): SweepPlan;
222
- /** Human-readable, for an operator screen. */
223
- export declare const describeSweep: (plan: SweepPlan) => string;