@dcasegr/amm-engine 0.0.0-stage → 0.38.1
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 +186 -2
- package/dist/allocation.d.ts +19 -0
- package/dist/allocation.js +241 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/planner.d.ts +15 -0
- package/dist/planner.js +91 -0
- package/dist/pricing.d.ts +40 -0
- package/dist/pricing.js +35 -0
- package/dist/replanner.d.ts +24 -0
- package/dist/replanner.js +85 -0
- package/dist/types.d.ts +127 -0
- package/dist/types.js +8 -0
- package/package.json +36 -4
package/README.md
CHANGED
|
@@ -1,3 +1,187 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dcasegr/amm-engine
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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;
|
package/dist/planner.js
ADDED
|
@@ -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;
|
package/dist/pricing.js
ADDED
|
@@ -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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.38.1",
|
|
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
|
+
}
|