@dcasegr/amm-engine 0.0.0-stage → 0.38.2

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 CHANGED
@@ -1,3 +1,187 @@
1
- # Temporary Holding Version
1
+ # @dcasegr/amm-engine
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Pure TypeScript planning library for the AMM market UI's matching engine.
4
+ No UI framework, no network: it plans swaps over an inventory of pools the
5
+ caller supplies (queried from the overlay elsewhere) and returns a set of
6
+ legs to build, sign and broadcast. See `docs/notes.md` in the main repo,
7
+ "Pool contract draft (2026-09-23)" and "Overlay and communication layer on
8
+ skein (2026-09-29)" (the "Market UI and matching engine" and "Commission
9
+ fee" paragraphs), and `pool/Pool.runar.go` for the contract this
10
+ mirrors.
11
+
12
+ ## Install
13
+
14
+ ```
15
+ npm install @dcasegr/amm-engine
16
+ ```
17
+
18
+ ESM with type declarations (`dist/`, built by `tsc -p tsconfig.build.json`; `npm pack` builds
19
+ it). No dependencies. Its version is the skein-amm release it is cut from (named
20
+ `@amm-poc/matching-engine` and private before 0.38.0). The client package
21
+ `@dcasegr/amm-client` (`../ui`, README "The packages") depends on it; the pages read this
22
+ directory's `src/` directly (vite alias and tsconfig `paths` in `../ui`).
23
+
24
+ ## Test
25
+
26
+ ```
27
+ npm ci
28
+ npm test # vitest run
29
+ npm run typecheck
30
+ npm run build # dist/
31
+ ```
32
+
33
+ ## API
34
+
35
+ ```ts
36
+ import { plan, replan, computeSwap, feeAmount } from "@dcasegr/amm-engine";
37
+ ```
38
+
39
+ ### `computeSwap(pool, direction, amountIn): SwapResult | null`
40
+
41
+ Contract-exact swap arithmetic (BigInt), mirroring `pool/Pool.runar.go`'s
42
+ `Swap` method bit for bit:
43
+
44
+ ```
45
+ liquidityFee = ceil(amountIn * liquidityFeeBps / 10000)
46
+ validationFee = ceil(amountIn * validationFeeBps / 10000)
47
+ commission = ceil(amountIn * commissionBps / 10000)
48
+ net = amountIn - liquidityFee - validationFee - commission // must be > 0
49
+ amountOut = floor(net * outputReserve / (inputReserve + net))
50
+ // must be > 0
51
+ ```
52
+
53
+ Returns `null` if the contract's own assertions (`net > 0`, `amountOut >
54
+ 0`) would fail for this amountIn against this pool — i.e. this is not a
55
+ valid swap.
56
+
57
+ ### `plan(request: PlanRequest): Plan`
58
+
59
+ Plans a fresh order against an inventory of pools. Deterministic and pure:
60
+ no clock, no randomness; ties are always broken by outpoint (ascending).
61
+
62
+ - `allowPartial: false` — only pools where the **entire** `amountIn` swaps
63
+ within the slippage bound are eligible; the engine picks the single best
64
+ one (max `amountOut`, tie-break by outpoint). If none qualify, returns an
65
+ empty plan with `meetsSlippageBound: false`.
66
+ - `allowPartial: true` (default expectation) — splits the order across
67
+ pools by water-filling: pools are added in descending (fee-adjusted)
68
+ spot-price order while the next pool's marginal improvement in total
69
+ `amountOut` exceeds its fixed per-leg cost (`fixedCost.minerFeeSats`,
70
+ converted into the output asset's units — see
71
+ "Assumptions" below). The chosen pools are then filled so their marginal
72
+ price is equalized (continuous water-filling, closed form), and the
73
+ continuous split is rounded to integers with largest-remainder rounding
74
+ (ties broken by outpoint), then **re-derived from the exact BigInt
75
+ contract formula**. Any rounded leg the contract would reject (net <= 0
76
+ or out <= 0 — only possible for a sub-1-unit rounding remainder) is
77
+ folded into the largest other leg and re-derived, so every leg the
78
+ planner returns is a leg the contract would actually accept.
79
+ - The plan's `meetsSlippageBound` flag reports whether the (would-be)
80
+ result satisfies the resolved slippage bound. If it doesn't, `plan()`
81
+ still returns the best achievable allocation (not an empty plan) with
82
+ the flag set to `false` — callers that want "no fill unless it clears
83
+ the bound" should check the flag rather than assume an empty plan.
84
+ - An optional liveness filter (`livenessThreshold` + `now`, both supplied
85
+ by the caller — the engine never reads the clock) excludes pools whose
86
+ `lastSeen` is older than the threshold, or that have no `lastSeen` at all
87
+ while the filter is active.
88
+
89
+ ### `replan(plan: Plan, results: LegResult[]): Plan`
90
+
91
+ Re-plans the remainder of an order after a round of legs has been
92
+ attempted. Pure and deterministic, chainable: `replan()`'s return value is
93
+ itself a valid `Plan` that can be fed back into `replan()` again for the
94
+ next round, carrying cumulative bookkeeping (`cumulativeFilledAmountIn/Out`,
95
+ `originalAmountIn`, `resolvedMinAmountOut`) forward automatically.
96
+
97
+ Per-leg result handling:
98
+
99
+ - `filled` — locked in. Its `amountIn`/`amountOut` count toward the
100
+ order's cumulative fill; its pool is gone (spent).
101
+ - `rejected` with `newPoolState` — the pool is re-priced from the
102
+ validator's returned state (new outpoint + reserves) and stays a
103
+ candidate for the remainder.
104
+ - `rejected` with no `newPoolState`, `timeout`, or a leg with no matching
105
+ result at all — the pool's true state is unknown (it may have raced with
106
+ another swap), so it is **dropped** from the candidate set rather than
107
+ retried against stale reserves. (See "Assumptions.")
108
+
109
+ The slippage budget is cumulative across rounds: `resolvedMinAmountOut` is
110
+ fixed once, at the first `plan()` call, against the whole order; each
111
+ `replan()` round only needs to make up what hasn't already been filled.
112
+ `replan()` stops (returns a plan with empty `legs`) once the order is
113
+ fully filled, or once nothing eligible remains for the remainder.
114
+
115
+ ### Types
116
+
117
+ See `src/types.ts` for the full shapes: `PoolState`, `FixedCost`,
118
+ `SlippageBound`, `PlanRequest`, `Leg`, `Plan`, `LegResult`.
119
+
120
+ ## Assumptions
121
+
122
+ A few judgment calls the task left open, made explicit here:
123
+
124
+ 1. **Slippage bound resolution.** `SlippageBound` supports both
125
+ `minAmountOut` and `toleranceBps`; when both are given, the stricter
126
+ (larger) resolved bound wins. `toleranceBps` is measured against a
127
+ "quoted mid": the unconstrained optimal split across *every* eligible
128
+ pool for the requested `amountIn`, ignoring fixed per-leg costs
129
+ entirely. This is recomputed once per `plan()` call (not once per
130
+ `replan()` round — the bound is fixed at the start of the order).
131
+ 2. **Fixed cost units.** `fixedCost` is always denominated in sats (the
132
+ miner fee, paid in BSV regardless of swap direction). The commission is
133
+ not a fixed cost: it is the pool's `commissionBps`, a third fee on
134
+ amountIn next to the LP and validator fees (in the fee multiplier `m`
135
+ and in `computeSwap`); each `Leg` carries its `commission`. When the output asset is BSV
136
+ (`tokenToBsv`), the water-filling gate compares directly in sats. When
137
+ the output asset is the token (`bsvToToken`), the sats cost is
138
+ converted to token-equivalent units using the newly-added pool's spot
139
+ ratio (`tokenReserve / bsvReserve`) for the comparison. This is a
140
+ heuristic gate for *how many* legs to use, not a settlement value.
141
+ 3. **Water-filling is continuous, then rounded and re-verified.** The
142
+ optimizer works in IEEE doubles for speed and simplicity (reserves at
143
+ realistic BSV/token magnitudes fit a double's 53-bit mantissa with no
144
+ meaningful loss for planning purposes). The result is converted to
145
+ integer BigInt amounts (largest-remainder rounding, ties by outpoint)
146
+ and every leg is re-derived through `computeSwap` (exact BigInt), which
147
+ is what the planner actually returns and what determines
148
+ `meetsSlippageBound`. A rounded leg the contract would reject is folded
149
+ into the largest other leg rather than dropped silently.
150
+ 4. **`meetsSlippageBound` on an empty/no-inventory plan.** If no bound was
151
+ requested (`slippage: {}`) and there's no inventory to plan against, the
152
+ resolved bound is `0`, so an empty plan trivially "meets" it. Callers
153
+ should check `filledCompletely` (or `legs.length`) separately to detect
154
+ "nothing was planned," rather than relying on `meetsSlippageBound` alone.
155
+ 5. **`replan()` drops pools with an unknown post-attempt state** (a
156
+ `rejected` leg with no `newPoolState`, a `timeout`, or a leg absent from
157
+ `results` entirely) rather than retrying them against the stale
158
+ pre-attempt reserves, since a race could have moved the pool to a state
159
+ the engine can't see. They simply fall out of the candidate set; a
160
+ caller that later learns their true state (e.g. via the overlay's spend
161
+ index, per the "Market UI and matching engine" note about following
162
+ pool continuations to the newest outpoint) can add them back into a
163
+ fresh `inventory` on the next top-level `plan()` call.
164
+ 6. **Liveness filter excludes pools with no `lastSeen`** whenever
165
+ `livenessThreshold`/`now` are supplied, on the conservative assumption
166
+ that "never seen" is at least as stale as "seen too long ago."
167
+ 7. **`allowPartial: false` picks the best single pool** among those that
168
+ can fill the *entire* `amountIn` within the slippage bound in one leg
169
+ (max `amountOut`, ties broken by outpoint), per the task's spec. It
170
+ does not attempt any splitting.
171
+ 8. **Every pool in `inventory` is assumed pre-scoped to `tokenId`** — the
172
+ `PoolState` shape given in the task has no per-pool token id field, so
173
+ the engine trusts the caller queried the overlay for the right token.
174
+ 9. **Determinism.** All internal sorts break ties by `outpoint` (lexical,
175
+ ascending): pool selection order, single-leg pick, and rounding-
176
+ remainder distribution. No leg is ever chosen by insertion order.
177
+
178
+ ## Files
179
+
180
+ - `src/types.ts` — shared types.
181
+ - `src/pricing.ts` — contract-exact BigInt swap arithmetic (`computeSwap`, `feeAmount`).
182
+ - `src/allocation.ts` — continuous water-filling split + integer rounding (`planSplit`, `quoteMid`).
183
+ - `src/planner.ts` — `plan()`, single-leg selection, liveness filter, slippage bound resolution.
184
+ - `src/replanner.ts` — `replan()`.
185
+ - `test/pricing.test.ts` — `computeSwap` checked against an independently transcribed oracle of the Go contract's formula, for a table of cases (typical, zero fees, high fees, tiny amounts with ceil-rounding edge cases, a reject case, asymmetric reserves, heavy price impact, realistic large BSV magnitudes).
186
+ - `test/planner.test.ts` — single pool, split-beats-single, fixed-cost-prevents-split, `allowPartial: false`, slippage exhaustion, liveness filter, `bsvToToken` direction, determinism.
187
+ - `test/replanner.test.ts` — re-pricing after rejection (better and worse new state), partial-fill + cumulative slippage budget, stopping when nothing eligible remains, stopping when fully filled, multi-round chaining.
@@ -0,0 +1,19 @@
1
+ import type { Direction, FixedCost, Leg, PoolState } from "./types.js";
2
+ /**
3
+ * Plans a split of `amountIn` across `pools` (already filtered to the
4
+ * requested token and liveness threshold, deduplicated by outpoint). Adds
5
+ * pools in descending spot-price order while the next pool's marginal
6
+ * improvement in total amountOut exceeds its fixed per-leg cost, then
7
+ * equalises marginal price across the chosen pools and rounds to exact
8
+ * integer, contract-valid legs.
9
+ *
10
+ * Returns legs sorted by outpoint for a deterministic, stable plan shape.
11
+ */
12
+ export declare function planSplit(pools: PoolState[], direction: Direction, amountIn: bigint, fixedCost: FixedCost): Leg[];
13
+ /**
14
+ * The unconstrained "quoted mid": the total amountOut an optimal split
15
+ * across every given pool would produce for amountIn, ignoring fixed
16
+ * per-leg costs entirely (used only to resolve a toleranceBps slippage
17
+ * bound into an absolute minAmountOut).
18
+ */
19
+ export declare function quoteMid(pools: PoolState[], direction: Direction, amountIn: bigint): bigint;
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Water-filling allocation across constant-product pools.
3
+ *
4
+ * Splitting an order across N constant-product pools to maximise total
5
+ * amountOut is solved by equalising the *marginal* price across the pools
6
+ * used (the standard water-filling / optimal-routing result). For a pool
7
+ * with input-side reserve R_in, output-side reserve R_out and post-fee
8
+ * multiplier m = (10000 - liquidityFeeBps - validationFeeBps - commissionBps) / 10000, the
9
+ * marginal price at post-fee input `net` is:
10
+ *
11
+ * price(net) = R_in * R_out / (R_in + net)^2
12
+ *
13
+ * Setting price(net) = P (a shared shadow price) gives a closed form:
14
+ *
15
+ * net(P) = sqrt(R_in * R_out / P) - R_in (clamped to >= 0)
16
+ *
17
+ * This module works in `number` for the continuous optimisation (it is a
18
+ * planning heuristic, not a settlement — every leg that is actually
19
+ * returned is re-derived with exact BigInt contract arithmetic in
20
+ * pricing.ts and rejected if the contract would reject it). Reserve sizes
21
+ * for BSV (<= ~2.1e15 sats) and realistic token amounts fit in a double's
22
+ * 53-bit mantissa with no meaningful loss for this purpose.
23
+ */
24
+ import { computeSwap } from "./pricing.js";
25
+ function toPoolMath(pool, direction) {
26
+ const bsv = Number(pool.bsvReserve);
27
+ const tok = Number(pool.tokenReserve);
28
+ const inputReserve = direction === "tokenToBsv" ? tok : bsv;
29
+ const outputReserve = direction === "tokenToBsv" ? bsv : tok;
30
+ const feeBps = Number(pool.liquidityFeeBps) + Number(pool.validationFeeBps) + Number(pool.commissionBps);
31
+ const m = Math.max(0, (10000 - feeBps) / 10000);
32
+ const k = inputReserve * outputReserve;
33
+ const spotPrice = inputReserve > 0 ? (m * outputReserve) / inputReserve : 0;
34
+ return { pool, inputReserve, outputReserve, k, m, spotPrice };
35
+ }
36
+ /**
37
+ * Shadow price P is the marginal amountOut per unit of amountIn (pre-fee),
38
+ * matching `spotPrice`'s basis: d(out)/d(amountIn) = m * d(out)/d(net) =
39
+ * m * R_in*R_out / (R_in+net)^2. Solving for net at a given P:
40
+ *
41
+ * net(P) = sqrt(m * k / P) - R_in
42
+ */
43
+ function netAt(pm, shadowPrice) {
44
+ if (shadowPrice <= 0 || pm.k <= 0 || pm.m <= 0 || !isFinite(shadowPrice))
45
+ return Infinity;
46
+ const net = Math.sqrt((pm.m * pm.k) / shadowPrice) - pm.inputReserve;
47
+ return net > 0 ? net : 0;
48
+ }
49
+ function amountInAt(pm, shadowPrice) {
50
+ if (pm.m <= 0)
51
+ return 0;
52
+ return netAt(pm, shadowPrice) / pm.m;
53
+ }
54
+ function totalAmountInAt(pms, shadowPrice) {
55
+ let sum = 0;
56
+ for (const pm of pms)
57
+ sum += amountInAt(pm, shadowPrice);
58
+ return sum;
59
+ }
60
+ function outputAt(pm, net) {
61
+ if (net <= 0)
62
+ return 0;
63
+ return (net * pm.outputReserve) / (pm.inputReserve + net);
64
+ }
65
+ /** Finds the shadow price P such that sum(amountInAt(pm, P)) == target. */
66
+ function solveShadowPrice(pms, target) {
67
+ if (pms.length === 0 || target <= 0)
68
+ return Infinity;
69
+ const maxSpot = Math.max(...pms.map((p) => p.spotPrice));
70
+ if (!isFinite(maxSpot) || maxSpot <= 0)
71
+ return Infinity;
72
+ let hi = maxSpot; // at P = maxSpot, at least one pool's net is ~0 -> total ~0 (upper bound on total)
73
+ let lo = maxSpot / 2;
74
+ let guard = 0;
75
+ while (totalAmountInAt(pms, lo) < target && guard < 400) {
76
+ lo = lo / 2;
77
+ guard++;
78
+ if (lo < Number.EPSILON)
79
+ break;
80
+ }
81
+ for (let i = 0; i < 100; i++) {
82
+ const mid = (lo + hi) / 2;
83
+ if (totalAmountInAt(pms, mid) > target)
84
+ lo = mid;
85
+ else
86
+ hi = mid;
87
+ }
88
+ return (lo + hi) / 2;
89
+ }
90
+ function totalOutputForTarget(pms, target) {
91
+ if (target <= 0 || pms.length === 0)
92
+ return 0;
93
+ const price = solveShadowPrice(pms, target);
94
+ let out = 0;
95
+ for (const pm of pms)
96
+ out += outputAt(pm, netAt(pm, price));
97
+ return out;
98
+ }
99
+ /** Fixed per-leg cost (sats), converted into the output asset's units for comparison. */
100
+ function fixedCostInOutputUnits(pm, direction, fixedCost) {
101
+ const sats = Number(fixedCost.minerFeeSats);
102
+ if (direction === "tokenToBsv") {
103
+ // output asset is BSV: no conversion needed.
104
+ return sats;
105
+ }
106
+ // output asset is the token: convert sats -> tokens at this pool's spot ratio.
107
+ const bsv = Number(pm.pool.bsvReserve);
108
+ const tok = Number(pm.pool.tokenReserve);
109
+ if (bsv <= 0)
110
+ return Infinity;
111
+ return sats * (tok / bsv);
112
+ }
113
+ /** Largest-remainder rounding of float shares to integer BigInt summing exactly to target. */
114
+ function roundAllocation(pms, floatShares, target) {
115
+ const totalFloat = floatShares.reduce((a, b) => a + b, 0);
116
+ if (totalFloat <= 0 || target <= 0n)
117
+ return pms.map(() => 0n);
118
+ const targetNum = Number(target);
119
+ const scaled = floatShares.map((s) => (s / totalFloat) * targetNum);
120
+ const floors = scaled.map((s) => BigInt(Math.max(0, Math.floor(s))));
121
+ let used = floors.reduce((a, b) => a + b, 0n);
122
+ let remainder = target - used;
123
+ const order = scaled
124
+ .map((s, i) => ({ i, frac: s - Math.floor(s), outpoint: pms[i].pool.outpoint }))
125
+ .sort((a, b) => b.frac - a.frac || (a.outpoint < b.outpoint ? -1 : a.outpoint > b.outpoint ? 1 : 0));
126
+ const result = [...floors];
127
+ let idx = 0;
128
+ while (remainder > 0n && idx < order.length) {
129
+ const target_ = order[idx].i;
130
+ result[target_] = (result[target_] ?? 0n) + 1n;
131
+ remainder -= 1n;
132
+ idx++;
133
+ }
134
+ if (remainder > 0n && result.length > 0) {
135
+ result[0] = (result[0] ?? 0n) + remainder;
136
+ }
137
+ return result;
138
+ }
139
+ /**
140
+ * Plans a split of `amountIn` across `pools` (already filtered to the
141
+ * requested token and liveness threshold, deduplicated by outpoint). Adds
142
+ * pools in descending spot-price order while the next pool's marginal
143
+ * improvement in total amountOut exceeds its fixed per-leg cost, then
144
+ * equalises marginal price across the chosen pools and rounds to exact
145
+ * integer, contract-valid legs.
146
+ *
147
+ * Returns legs sorted by outpoint for a deterministic, stable plan shape.
148
+ */
149
+ export function planSplit(pools, direction, amountIn, fixedCost) {
150
+ if (pools.length === 0 || amountIn <= 0n)
151
+ return [];
152
+ const sorted = pools
153
+ .map((p) => toPoolMath(p, direction))
154
+ .sort((a, b) => b.spotPrice - a.spotPrice || (a.pool.outpoint < b.pool.outpoint ? -1 : 1));
155
+ const targetNum = Number(amountIn);
156
+ let bestN = 1;
157
+ let prevOut = totalOutputForTarget(sorted.slice(0, 1), targetNum);
158
+ for (let n = 2; n <= sorted.length; n++) {
159
+ const out = totalOutputForTarget(sorted.slice(0, n), targetNum);
160
+ const gain = out - prevOut;
161
+ const justAdded = sorted[n - 1];
162
+ const cost = fixedCostInOutputUnits(justAdded, direction, fixedCost);
163
+ if (gain > cost) {
164
+ bestN = n;
165
+ prevOut = out;
166
+ }
167
+ else {
168
+ break;
169
+ }
170
+ }
171
+ const active = sorted.slice(0, bestN);
172
+ const price = solveShadowPrice(active, targetNum);
173
+ const shares = active.map((pm) => amountInAt(pm, price));
174
+ let allocation = roundAllocation(active, shares, amountIn);
175
+ // Fold any allocation the contract would reject (net <= 0 or out <= 0,
176
+ // possible only for tiny rounded shares) into the largest remaining leg,
177
+ // deterministically, until every nonzero leg is contract-valid.
178
+ for (let pass = 0; pass < active.length; pass++) {
179
+ let changed = false;
180
+ for (let i = 0; i < active.length; i++) {
181
+ const amt = allocation[i];
182
+ if (amt <= 0n)
183
+ continue;
184
+ const result = computeSwap(active[i].pool, direction, amt);
185
+ if (result === null) {
186
+ // fold into the largest other allocation (tie-break by outpoint)
187
+ let bestJ = -1;
188
+ for (let j = 0; j < active.length; j++) {
189
+ if (j === i)
190
+ continue;
191
+ if (bestJ === -1 ||
192
+ allocation[j] > allocation[bestJ] ||
193
+ (allocation[j] === allocation[bestJ] && active[j].pool.outpoint < active[bestJ].pool.outpoint)) {
194
+ bestJ = j;
195
+ }
196
+ }
197
+ if (bestJ === -1)
198
+ break;
199
+ allocation[bestJ] = (allocation[bestJ] ?? 0n) + amt;
200
+ allocation[i] = 0n;
201
+ changed = true;
202
+ }
203
+ }
204
+ if (!changed)
205
+ break;
206
+ }
207
+ const legs = [];
208
+ for (let i = 0; i < active.length; i++) {
209
+ const amt = allocation[i];
210
+ if (amt <= 0n)
211
+ continue;
212
+ const pm = active[i];
213
+ const result = computeSwap(pm.pool, direction, amt);
214
+ if (result === null)
215
+ continue; // still invalid even after folding; drop it
216
+ legs.push({
217
+ outpoint: pm.pool.outpoint,
218
+ amountIn: amt,
219
+ liquidityFee: result.liquidityFee,
220
+ validationFee: result.validationFee,
221
+ commission: result.commission,
222
+ amountOut: result.amountOut,
223
+ newBsvReserve: result.newBsvReserve,
224
+ newTokenReserve: result.newTokenReserve,
225
+ });
226
+ }
227
+ legs.sort((a, b) => (a.outpoint < b.outpoint ? -1 : a.outpoint > b.outpoint ? 1 : 0));
228
+ return legs;
229
+ }
230
+ /**
231
+ * The unconstrained "quoted mid": the total amountOut an optimal split
232
+ * across every given pool would produce for amountIn, ignoring fixed
233
+ * per-leg costs entirely (used only to resolve a toleranceBps slippage
234
+ * bound into an absolute minAmountOut).
235
+ */
236
+ export function quoteMid(pools, direction, amountIn) {
237
+ if (pools.length === 0 || amountIn <= 0n)
238
+ return 0n;
239
+ const legs = planSplit(pools, direction, amountIn, { minerFeeSats: 0n });
240
+ return legs.reduce((a, l) => a + l.amountOut, 0n);
241
+ }
@@ -0,0 +1,6 @@
1
+ export * from "./types.js";
2
+ export { feeAmount, computeSwap } from "./pricing.js";
3
+ export type { SwapResult } from "./pricing.js";
4
+ export { planSplit, quoteMid } from "./allocation.js";
5
+ export { plan, planOrder, filterLivePools, resolveMinAmountOut } from "./planner.js";
6
+ export { replan } from "./replanner.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export * from "./types.js";
2
+ export { feeAmount, computeSwap } from "./pricing.js";
3
+ export { planSplit, quoteMid } from "./allocation.js";
4
+ export { plan, planOrder, filterLivePools, resolveMinAmountOut } from "./planner.js";
5
+ export { replan } from "./replanner.js";
@@ -0,0 +1,15 @@
1
+ import type { Direction, FixedCost, Leg, Plan, PlanRequest, PoolState, SlippageBound } from "./types.js";
2
+ /** Applies the liveness filter (pure: caller supplies `now`, engine never reads the clock). */
3
+ export declare function filterLivePools(inventory: PoolState[], livenessThreshold: number | undefined, now: number | undefined): PoolState[];
4
+ /**
5
+ * Resolves a SlippageBound to a single absolute minAmountOut. When both
6
+ * minAmountOut and toleranceBps are given, the stricter (larger) bound wins.
7
+ * toleranceBps is measured against the "quoted mid": the unconstrained
8
+ * optimal-split amountOut across every eligible pool right now, ignoring
9
+ * fixed per-leg costs.
10
+ */
11
+ export declare function resolveMinAmountOut(pools: PoolState[], direction: Direction, amountIn: bigint, slippage: SlippageBound): bigint;
12
+ /** Core planning step shared by plan() and replan(): plan `amountIn` against `pools` given an absolute minAmountOut. */
13
+ export declare function planOrder(pools: PoolState[], direction: Direction, amountIn: bigint, allowPartial: boolean, fixedCost: FixedCost, minAmountOut: bigint): Leg[];
14
+ /** Plans a fresh order against the given inventory. Pure, deterministic. */
15
+ export declare function plan(request: PlanRequest): Plan;
@@ -0,0 +1,91 @@
1
+ import { computeSwap } from "./pricing.js";
2
+ import { planSplit, quoteMid } from "./allocation.js";
3
+ /** Applies the liveness filter (pure: caller supplies `now`, engine never reads the clock). */
4
+ export function filterLivePools(inventory, livenessThreshold, now) {
5
+ if (livenessThreshold === undefined || now === undefined)
6
+ return inventory;
7
+ return inventory.filter((p) => p.lastSeen !== undefined && now - p.lastSeen <= livenessThreshold);
8
+ }
9
+ /**
10
+ * Resolves a SlippageBound to a single absolute minAmountOut. When both
11
+ * minAmountOut and toleranceBps are given, the stricter (larger) bound wins.
12
+ * toleranceBps is measured against the "quoted mid": the unconstrained
13
+ * optimal-split amountOut across every eligible pool right now, ignoring
14
+ * fixed per-leg costs.
15
+ */
16
+ export function resolveMinAmountOut(pools, direction, amountIn, slippage) {
17
+ let bound = 0n;
18
+ if (slippage.minAmountOut !== undefined && slippage.minAmountOut > bound) {
19
+ bound = slippage.minAmountOut;
20
+ }
21
+ if (slippage.toleranceBps !== undefined) {
22
+ const quote = quoteMid(pools, direction, amountIn);
23
+ const tolerant = (quote * (10000n - slippage.toleranceBps)) / 10000n;
24
+ if (tolerant > bound)
25
+ bound = tolerant;
26
+ }
27
+ return bound;
28
+ }
29
+ function pickBestSingleLeg(pools, direction, amountIn, minAmountOut) {
30
+ let best = null;
31
+ for (const pool of pools) {
32
+ const result = computeSwap(pool, direction, amountIn);
33
+ if (result === null)
34
+ continue;
35
+ if (minAmountOut > 0n && result.amountOut < minAmountOut)
36
+ continue;
37
+ const leg = {
38
+ outpoint: pool.outpoint,
39
+ amountIn,
40
+ liquidityFee: result.liquidityFee,
41
+ validationFee: result.validationFee,
42
+ commission: result.commission,
43
+ amountOut: result.amountOut,
44
+ newBsvReserve: result.newBsvReserve,
45
+ newTokenReserve: result.newTokenReserve,
46
+ };
47
+ if (best === null ||
48
+ leg.amountOut > best.amountOut ||
49
+ (leg.amountOut === best.amountOut && leg.outpoint < best.outpoint)) {
50
+ best = leg;
51
+ }
52
+ }
53
+ return best;
54
+ }
55
+ /** Core planning step shared by plan() and replan(): plan `amountIn` against `pools` given an absolute minAmountOut. */
56
+ export function planOrder(pools, direction, amountIn, allowPartial, fixedCost, minAmountOut) {
57
+ if (amountIn <= 0n || pools.length === 0)
58
+ return [];
59
+ if (!allowPartial) {
60
+ const best = pickBestSingleLeg(pools, direction, amountIn, minAmountOut);
61
+ return best ? [best] : [];
62
+ }
63
+ return planSplit(pools, direction, amountIn, fixedCost);
64
+ }
65
+ /** Plans a fresh order against the given inventory. Pure, deterministic. */
66
+ export function plan(request) {
67
+ const eligible = filterLivePools(request.inventory, request.livenessThreshold, request.now);
68
+ const resolvedMinAmountOut = resolveMinAmountOut(eligible, request.direction, request.amountIn, request.slippage);
69
+ const legs = planOrder(eligible, request.direction, request.amountIn, request.allowPartial, request.fixedCost, resolvedMinAmountOut);
70
+ const totalAmountIn = legs.reduce((a, l) => a + l.amountIn, 0n);
71
+ const totalAmountOut = legs.reduce((a, l) => a + l.amountOut, 0n);
72
+ const usedOutpoints = new Set(legs.map((l) => l.outpoint));
73
+ const remainingInventory = eligible.filter((p) => !usedOutpoints.has(p.outpoint));
74
+ return {
75
+ legs,
76
+ totalAmountIn,
77
+ totalAmountOut,
78
+ meetsSlippageBound: totalAmountOut >= resolvedMinAmountOut,
79
+ filledCompletely: totalAmountIn === request.amountIn,
80
+ direction: request.direction,
81
+ allowPartial: request.allowPartial,
82
+ fixedCost: request.fixedCost,
83
+ livenessThreshold: request.livenessThreshold,
84
+ now: request.now,
85
+ originalAmountIn: request.amountIn,
86
+ resolvedMinAmountOut,
87
+ cumulativeFilledAmountIn: 0n,
88
+ cumulativeFilledAmountOut: 0n,
89
+ remainingInventory,
90
+ };
91
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Contract-exact swap arithmetic, mirroring pool/Pool.runar.go's Swap method
3
+ * bit for bit (BigInt, same truncation/ceiling behaviour as the Go integer
4
+ * division the contract uses).
5
+ *
6
+ * lpFee = ceil(amountIn * LpFeeBps / 10000)
7
+ * validatorFee = ceil(amountIn * ValidatorFeeBps / 10000)
8
+ * commission = ceil(amountIn * CommissionBps / 10000)
9
+ * net = amountIn - lpFee - validatorFee - commission (must be > 0)
10
+ *
11
+ * tokenToBsv (token in, bsv out):
12
+ * out = floor(net * bsvReserve / (tokenReserve + net))
13
+ * newBsv = bsvReserve - out
14
+ * newTokens = tokenReserve + net
15
+ *
16
+ * bsvToToken (bsv in, token out):
17
+ * out = floor(net * tokenReserve / (bsvReserve + net))
18
+ * newBsv = bsvReserve + net
19
+ * newTokens = tokenReserve - out
20
+ *
21
+ * out must be > 0.
22
+ */
23
+ import type { Direction, PoolState } from "./types.js";
24
+ export interface SwapResult {
25
+ liquidityFee: bigint;
26
+ validationFee: bigint;
27
+ commission: bigint;
28
+ net: bigint;
29
+ amountOut: bigint;
30
+ newBsvReserve: bigint;
31
+ newTokenReserve: bigint;
32
+ }
33
+ /** ceil(amountIn * bps / 10000), exactly as the contract computes each fee. */
34
+ export declare function feeAmount(amountIn: bigint, bps: bigint): bigint;
35
+ /**
36
+ * Computes one pool's swap exactly as the contract would, or returns null if
37
+ * the contract's assertions (`net > 0`, `out > 0`) would fail — i.e. this
38
+ * amountIn is not a valid swap against this pool.
39
+ */
40
+ export declare function computeSwap(pool: Pick<PoolState, "bsvReserve" | "tokenReserve" | "liquidityFeeBps" | "validationFeeBps" | "commissionBps">, direction: Direction, amountIn: bigint): SwapResult | null;
@@ -0,0 +1,35 @@
1
+ /** ceil(amountIn * bps / 10000), exactly as the contract computes each fee. */
2
+ export function feeAmount(amountIn, bps) {
3
+ return (amountIn * bps + 9999n) / 10000n;
4
+ }
5
+ /**
6
+ * Computes one pool's swap exactly as the contract would, or returns null if
7
+ * the contract's assertions (`net > 0`, `out > 0`) would fail — i.e. this
8
+ * amountIn is not a valid swap against this pool.
9
+ */
10
+ export function computeSwap(pool, direction, amountIn) {
11
+ if (amountIn <= 0n)
12
+ return null;
13
+ const liquidityFee = feeAmount(amountIn, pool.liquidityFeeBps);
14
+ const validationFee = feeAmount(amountIn, pool.validationFeeBps);
15
+ const commission = feeAmount(amountIn, pool.commissionBps);
16
+ const net = amountIn - liquidityFee - validationFee - commission;
17
+ if (net <= 0n)
18
+ return null;
19
+ let amountOut;
20
+ let newBsvReserve;
21
+ let newTokenReserve;
22
+ if (direction === "tokenToBsv") {
23
+ amountOut = (net * pool.bsvReserve) / (pool.tokenReserve + net);
24
+ newBsvReserve = pool.bsvReserve - amountOut;
25
+ newTokenReserve = pool.tokenReserve + net;
26
+ }
27
+ else {
28
+ amountOut = (net * pool.tokenReserve) / (pool.bsvReserve + net);
29
+ newBsvReserve = pool.bsvReserve + net;
30
+ newTokenReserve = pool.tokenReserve - amountOut;
31
+ }
32
+ if (amountOut <= 0n)
33
+ return null;
34
+ return { liquidityFee, validationFee, commission, net, amountOut, newBsvReserve, newTokenReserve };
35
+ }
@@ -0,0 +1,24 @@
1
+ import type { LegResult, Plan } from "./types.js";
2
+ /**
3
+ * Re-plans the remainder of an order after a round of legs has been
4
+ * attempted. Pure and deterministic: no clock, no randomness, ties broken
5
+ * by outpoint.
6
+ *
7
+ * Semantics for each leg result:
8
+ * - filled: locked in. Its amountIn/amountOut count toward the order's
9
+ * cumulative fill and its pool is gone (spent).
10
+ * - rejected with newPoolState: the pool is still available, re-priced
11
+ * from the validator's returned state, and stays a candidate.
12
+ * - rejected with no newPoolState, or timeout, or a leg with no matching
13
+ * result at all: the pool's true state is unknown (it may have raced
14
+ * with another swap), so it is conservatively dropped from the
15
+ * candidate set rather than retried against stale reserves.
16
+ *
17
+ * The slippage budget is cumulative across rounds: resolvedMinAmountOut
18
+ * (fixed at the first plan() call) applies to the whole order, and each
19
+ * round only needs to cover what filled legs haven't already delivered.
20
+ *
21
+ * Stops (returns a plan with empty legs) once the order is fully filled,
22
+ * or once nothing eligible remains for the remainder.
23
+ */
24
+ export declare function replan(plan: Plan, results: LegResult[]): Plan;
@@ -0,0 +1,85 @@
1
+ import { planOrder } from "./planner.js";
2
+ /**
3
+ * Re-plans the remainder of an order after a round of legs has been
4
+ * attempted. Pure and deterministic: no clock, no randomness, ties broken
5
+ * by outpoint.
6
+ *
7
+ * Semantics for each leg result:
8
+ * - filled: locked in. Its amountIn/amountOut count toward the order's
9
+ * cumulative fill and its pool is gone (spent).
10
+ * - rejected with newPoolState: the pool is still available, re-priced
11
+ * from the validator's returned state, and stays a candidate.
12
+ * - rejected with no newPoolState, or timeout, or a leg with no matching
13
+ * result at all: the pool's true state is unknown (it may have raced
14
+ * with another swap), so it is conservatively dropped from the
15
+ * candidate set rather than retried against stale reserves.
16
+ *
17
+ * The slippage budget is cumulative across rounds: resolvedMinAmountOut
18
+ * (fixed at the first plan() call) applies to the whole order, and each
19
+ * round only needs to cover what filled legs haven't already delivered.
20
+ *
21
+ * Stops (returns a plan with empty legs) once the order is fully filled,
22
+ * or once nothing eligible remains for the remainder.
23
+ */
24
+ export function replan(plan, results) {
25
+ const resultByOutpoint = new Map(results.map((r) => [r.outpoint, r]));
26
+ let filledIn = 0n;
27
+ let filledOut = 0n;
28
+ const repriced = [];
29
+ for (const leg of plan.legs) {
30
+ const result = resultByOutpoint.get(leg.outpoint);
31
+ if (result === undefined)
32
+ continue; // unknown outcome: drop, conservative
33
+ if (result.status === "filled") {
34
+ filledIn += leg.amountIn;
35
+ filledOut += leg.amountOut;
36
+ }
37
+ else if (result.status === "rejected" && result.newPoolState) {
38
+ repriced.push(result.newPoolState);
39
+ }
40
+ // rejected without newPoolState, or timeout: pool dropped (unknown state)
41
+ }
42
+ const nextInventory = [...plan.remainingInventory, ...repriced];
43
+ const cumulativeFilledAmountIn = plan.cumulativeFilledAmountIn + filledIn;
44
+ const cumulativeFilledAmountOut = plan.cumulativeFilledAmountOut + filledOut;
45
+ const remainder = plan.originalAmountIn - cumulativeFilledAmountIn;
46
+ const remainingMinAmountOut = plan.resolvedMinAmountOut > cumulativeFilledAmountOut
47
+ ? plan.resolvedMinAmountOut - cumulativeFilledAmountOut
48
+ : 0n;
49
+ const base = {
50
+ direction: plan.direction,
51
+ allowPartial: plan.allowPartial,
52
+ fixedCost: plan.fixedCost,
53
+ livenessThreshold: plan.livenessThreshold,
54
+ now: plan.now,
55
+ originalAmountIn: plan.originalAmountIn,
56
+ resolvedMinAmountOut: plan.resolvedMinAmountOut,
57
+ cumulativeFilledAmountIn,
58
+ cumulativeFilledAmountOut,
59
+ };
60
+ if (remainder <= 0n) {
61
+ return {
62
+ ...base,
63
+ legs: [],
64
+ totalAmountIn: 0n,
65
+ totalAmountOut: 0n,
66
+ meetsSlippageBound: cumulativeFilledAmountOut >= plan.resolvedMinAmountOut,
67
+ filledCompletely: true,
68
+ remainingInventory: nextInventory,
69
+ };
70
+ }
71
+ const newLegs = planOrder(nextInventory, plan.direction, remainder, plan.allowPartial, plan.fixedCost, remainingMinAmountOut);
72
+ const totalAmountIn = newLegs.reduce((a, l) => a + l.amountIn, 0n);
73
+ const totalAmountOut = newLegs.reduce((a, l) => a + l.amountOut, 0n);
74
+ const usedOutpoints = new Set(newLegs.map((l) => l.outpoint));
75
+ const remainingInventory = nextInventory.filter((p) => !usedOutpoints.has(p.outpoint));
76
+ return {
77
+ ...base,
78
+ legs: newLegs,
79
+ totalAmountIn,
80
+ totalAmountOut,
81
+ meetsSlippageBound: cumulativeFilledAmountOut + totalAmountOut >= plan.resolvedMinAmountOut,
82
+ filledCompletely: cumulativeFilledAmountIn + totalAmountIn === plan.originalAmountIn,
83
+ remainingInventory,
84
+ };
85
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Types for the matching engine. All monetary / reserve quantities are
3
+ * BigInt to mirror the pool contract's integer arithmetic exactly
4
+ * (see pool/Pool.runar.go). Floating point is only ever used internally,
5
+ * as a heuristic for *how* to split an order across pools; every number
6
+ * that ends up in a Leg is produced by exact BigInt contract arithmetic.
7
+ */
8
+ /** Which asset is being sold. The taker always receives the other one. */
9
+ export type Direction = "bsvToToken" | "tokenToBsv";
10
+ /** One pool UTXO, as the caller's inventory query would report it. */
11
+ export interface PoolState {
12
+ /** txid:vout of the pool UTXO. */
13
+ outpoint: string;
14
+ bsvReserve: bigint;
15
+ tokenReserve: bigint;
16
+ /** LP fee rate, in basis points, taken from amountIn (ceil). */
17
+ liquidityFeeBps: bigint;
18
+ /** Validator fee rate, in basis points, taken from amountIn (ceil). */
19
+ validationFeeBps: bigint;
20
+ /**
21
+ * Commission rate (the contract's CommissionBps, fixed at deploy), in basis
22
+ * points, taken from amountIn (ceil) and paid to the relay's address.
23
+ */
24
+ commissionBps: bigint;
25
+ /** The pool's validator identity key (33-byte hex or similar). Opaque to the engine. */
26
+ validatorIdentityKey: string;
27
+ /** Last time (ms epoch) this pool's validator was seen live, if known. */
28
+ lastSeen?: number;
29
+ }
30
+ /** Fixed cost the taker pays to add one more leg to a plan, in satoshis. */
31
+ export interface FixedCost {
32
+ /** Estimated miner fee for this leg's transaction, in sats. */
33
+ minerFeeSats: bigint;
34
+ }
35
+ /**
36
+ * Slippage bound on the *total* amountOut across every leg of the order.
37
+ * Both forms may be given; the engine resolves them to a single absolute
38
+ * `minAmountOut` and uses whichever is stricter (larger).
39
+ */
40
+ export interface SlippageBound {
41
+ /** Minimum acceptable total amountOut, in the output asset's smallest unit. */
42
+ minAmountOut?: bigint;
43
+ /**
44
+ * Tolerance in basis points against the "quoted mid": the total amountOut
45
+ * an unconstrained optimal split across all eligible pools would produce
46
+ * right now, ignoring fixed per-leg costs. minAmountOut is then
47
+ * quote * (10000 - toleranceBps) / 10000.
48
+ */
49
+ toleranceBps?: bigint;
50
+ }
51
+ /** A single pool's leg of a plan, with the pool's exact post-swap state. */
52
+ export interface Leg {
53
+ outpoint: string;
54
+ amountIn: bigint;
55
+ liquidityFee: bigint;
56
+ validationFee: bigint;
57
+ /** The commission, in the input asset. */
58
+ commission: bigint;
59
+ amountOut: bigint;
60
+ /** Pool reserves after this leg, per the contract's swap arithmetic. */
61
+ newBsvReserve: bigint;
62
+ newTokenReserve: bigint;
63
+ }
64
+ export interface PlanRequest {
65
+ tokenId: string;
66
+ direction: Direction;
67
+ amountIn: bigint;
68
+ slippage: SlippageBound;
69
+ allowPartial: boolean;
70
+ fixedCost: FixedCost;
71
+ /** Pools available for this token. Assumed already scoped to tokenId. */
72
+ inventory: PoolState[];
73
+ /** Exclude pools whose validator lastSeen is older than this many ms before `now`. */
74
+ livenessThreshold?: number;
75
+ /**
76
+ * Caller-supplied "current time" (ms epoch) for the liveness filter. The
77
+ * engine never reads the clock itself, to stay pure. Required if
78
+ * livenessThreshold is set; pools with no lastSeen are excluded whenever
79
+ * a liveness filter is active.
80
+ */
81
+ now?: number;
82
+ }
83
+ export interface Plan {
84
+ /** Legs to build, sign and broadcast in parallel for this round. */
85
+ legs: Leg[];
86
+ /** Sum of this round's legs' amountIn. */
87
+ totalAmountIn: bigint;
88
+ /** Sum of this round's legs' amountOut (expected, pre-execution). */
89
+ totalAmountOut: bigint;
90
+ /**
91
+ * True if, assuming this round's legs all fill as planned, the
92
+ * cumulative amountOut (including prior rounds) meets resolvedMinAmountOut.
93
+ */
94
+ meetsSlippageBound: boolean;
95
+ /** True if this round's legs cover the full remaining order (originalAmountIn - cumulativeFilledAmountIn). */
96
+ filledCompletely: boolean;
97
+ direction: Direction;
98
+ allowPartial: boolean;
99
+ fixedCost: FixedCost;
100
+ livenessThreshold?: number;
101
+ now?: number;
102
+ /** The full order size, constant across every round. */
103
+ originalAmountIn: bigint;
104
+ /** Absolute slippage bound for the whole order, resolved once at the first plan() call. */
105
+ resolvedMinAmountOut: bigint;
106
+ /** amountIn confirmed filled in prior rounds (not including this round's legs). */
107
+ cumulativeFilledAmountIn: bigint;
108
+ /** amountOut confirmed received in prior rounds. */
109
+ cumulativeFilledAmountOut: bigint;
110
+ /**
111
+ * Pools not used by this round's legs but still known-live candidates for
112
+ * a future replan() round (i.e. the rest of the inventory passed to plan(),
113
+ * after the liveness/eligibility filter).
114
+ */
115
+ remainingInventory: PoolState[];
116
+ }
117
+ export type LegResult = {
118
+ outpoint: string;
119
+ status: "filled";
120
+ } | {
121
+ outpoint: string;
122
+ status: "rejected";
123
+ newPoolState?: PoolState;
124
+ } | {
125
+ outpoint: string;
126
+ status: "timeout";
127
+ };
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Types for the matching engine. All monetary / reserve quantities are
3
+ * BigInt to mirror the pool contract's integer arithmetic exactly
4
+ * (see pool/Pool.runar.go). Floating point is only ever used internally,
5
+ * as a heuristic for *how* to split an order across pools; every number
6
+ * that ends up in a Leg is produced by exact BigInt contract arithmetic.
7
+ */
8
+ export {};
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "@dcasegr/amm-engine",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.38.2",
4
+ "type": "module",
5
+ "description": "Pure TypeScript order-matching / planning library for the AMM (skein-amm): plans swaps over an inventory of pools the caller supplies. No UI framework, no network.",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/shruggr/skein-amm.git",
9
+ "directory": "web/engine"
10
+ },
11
+ "main": "dist/index.js",
12
+ "types": "dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md"
22
+ ],
23
+ "sideEffects": false,
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.build.json",
29
+ "prepack": "npm run build",
30
+ "test": "vitest run",
31
+ "test:watch": "vitest",
32
+ "typecheck": "tsc --noEmit"
33
+ },
34
+ "devDependencies": {
35
+ "typescript": "^5.6.3",
36
+ "vitest": "^2.1.4"
37
+ }
38
+ }