@mayflower-sys/avm-sim
v202608.1222.849
Published
Standalone AVM simulator: an immutable, log-driven state machine over avm-calc for charting, notebooks, and projections.
Readme
@mayflower-sys/avm-sim
A standalone AVM simulator: an immutable record that wraps an
avm-calc calculator together with an ordered log of
actions. It is a low-level, general-purpose engine for charting apps, desktop
simulations, in-app projections, and Observable notebooks.
Everything is a pure synchronous function over plain number boundaries.
Operations never mutate and never throw: a partial operation returns
Either<Simulation, SimulationError>, and a quote that may not exist returns
Option<number>.
creation parameters -> blank Simulation (simulation.ts)
request + current curve -> recorded LogPayload (operations.ts)
Simulation + LogPayload -> next Simulation (apply.ts)
logs -> folded totals (derived.ts)
current curve -> geometry, quotes, capacity (derived/quotes/capacity)The record
interface Simulation {
readonly calculator: AvmCalculator<number> // compiled from the whole history
readonly logs: ReadonlyArray<Log> // the source of truth
}The log is an event-sourced history. Payloads are facts, not requests: both sides of every trade are stored at the amounts the curve quoted when the action was accepted, so a log can be folded and replayed without re-quoting.
| Payload | Fields | Curve transition |
| ----------------- | ------------------------ | ------------------------------- |
| Issuance | reserveIn, sharesOut | supply moves up the curve |
| Redemption | sharesIn, reserveOut | supply moves down; may contract |
| RedeemAtFloor | sharesIn, reserveOut | whole schedule translates left |
| ExerciseOptions | reserveIn, sharesOut | whole schedule translates right |
| Borrow | reserveOut | none — folded balances only |
| Repay | reserveIn | none — folded balances only |
| DonateReserve | reserveIn | none — creates reserve surplus |
There is no per-account position tracking: all supply is treated as pledged collateral, so borrow capacity is the floor value of the whole supply minus the outstanding draw.
Quick start
import { Either, Option, pipe } from "effect"
import * as Sim from "@mayflower-sys/avm-sim"
const outcome = pipe(
Sim.makeLinearSimulation({ slope: 2, initialFloor: 5 }), // blank linear AVM
Either.flatMap(Sim.issueShares({ reserveIn: 36 })), // buy shares for 36 reserves
Either.flatMap(Sim.redeemShares({ sharesIn: 1 })), // sell 1 share along the curve
Either.flatMap(Sim.borrow({ reserveOut: 0.5 })), // borrow 0.5 against floor collateral
)
const sim = Either.getOrThrow(outcome)
sim.logs // three facts, indexes 0..2
Sim.totalSupply(sim) // ≈ 3, folded from the log
Sim.netReserves(sim) // 36 − 12 − 0.5
Sim.spotPrice(sim) // 11
Sim.floorPrice(sim) // 5
// Floor raise is not a logged operation yet — probe how far a legal
// area-preserving raise could go (needs supply strictly above the ramp end).
const maxRise = Sim.maxAreaPreservingFloorRise(sim) // Option<number>
Option.getOrThrow(maxRise) // positive; capped below spot − floorEvery operation supports both call styles: Sim.issueShares(sim, request)
and pipe(sim, Sim.issueShares(request)). A refused operation is a Left
with a tagged reason (InsufficientSupply, ExceedsFloorRegion,
ExceedsBorrowCapacity, ExceedsBorrowedBalance, InvalidRequest,
CalculatorRejection) — match on it with SimulationError.$match.
Creating a simulation
Two factories build a blank record — empty log, zero supply, zero-width ramp at zero, spot price on the floor:
Sim.makeLinearSimulation({
slope: 0.001, // positive main slope
initialFloor: 0.1, // launch floor price f₀
tokenNormalizationFactor: 1, // optional affine x-scale, default 1
rampScalar: 2, // optional ramp steepness multiple, default 2
})
Sim.makeHingedExponentialSimulation({
m: 1, // positive slope of the linear component
h: 1, // nonnegative hinge: supply where the exponential premium begins
q: 2, // nonnegative curvature coefficient
k: 0.5, // signed exponential intensity
initialFloor: 1, // launch floor price f₀
tokenNormalizationFactor: 1, // optional affine x-scale, default 1
rampScalar: 2, // optional ramp steepness multiple, default 2
})Creation parameters are validated by Effect Schema; failures are returned as values such as
Left(InvalidRequest), never as a thrown exception. rampScalar only matters once the
floor rises (a future operation), but it is persisted state on the wrapped
calculator, so it is settable at creation; 2 mirrors the canonical
"ramp twice as steep as main" shape.
Derived data
Log folds (each is a sum of one Flow field over the log — see flowOf):
| Function | Meaning |
| ----------------------------------- | ---------------------------------------- |
| totalSupply | shares outstanding |
| totalBorrowed | outstanding borrowed reserves |
| netReserves | liquid reserve balance |
| grossReserves | liquid balance plus outstanding borrowed |
| totalReserveIn, totalReserveOut | cumulative reserve volumes |
Curve reads: requiredReserves (area up to supply), reserveSurplus
(gross − required; donations are the only source), borrowCapacity,
floorPrice, spotPrice, rampStart/rampEnd/rampWidth (world-space),
and the persisted affineParameters / segmentationParameters — the
x-translation moves with option exercise and floor redemption, the
y-translation with sell contraction.
Floor-rise capacities, each a legal witness within the accuracy contract:
maxAreaPreservingFloorRise— largest rise an area-preserving raise can achieve now;Noneunless supply is strictly above the ramp end.maxFloorRiseFromSurplus— largest rise the donated surplus can fund with the schedule above the new floor unchanged, capped at the spot price.requiredSupplyForFloorRise(rise)— the minimum supply at which a raise of that size becomes legal (the solved ramp end of the raise).requiredReservesAtFloor(target)— the solvency requirement if the floor stood attargetwith the curve above it unchanged.
Every capacity is a generic iterative search — bisection or a doubling
probe — over the calculator's own operations. This is a deliberate design
rule for the whole package: prefer simple, generic solvers over closed-form
special cases (closed forms appear only in tests, as independent oracles).
Every capacity takes an optional work budget as a trailing parameter
(SearchBudget { iterations? } for the bisections, DoublingBudget
{ maxDoublings? } for the supply probe) and threads it straight through to
its solver; the defaults resolve far past the 0.1% accuracy contract.
Budgets stay plain parameters at this layer so a wrapping consumer can add
whatever ergonomic layer it wants. The solvers themselves —
largestSatisfying, leastReaching, firstAccepted — are exported for
consumers building their own derived searches.
Trade quotes that never touch the log: quoteReservesInForSharesOut,
quoteSharesOutForReservesIn, quoteReservesOutForSharesIn,
quoteSharesInForReservesOut, each returning Option<number>. Running an
operation on a copy is an equally valid way to preview an outcome — the
record is immutable either way.
Logs and replay
Each log entry is { index, payload }. Walk the history with
LogPayload.$match for a narrative, and flowOf for signed balance
deltas (reserves, supply, borrowed) — the same fields the derived
totals fold:
let supply = 0
let reserves = 0
let borrowed = 0
for (const { index, payload } of sim.logs) {
const flow = Sim.flowOf(payload)
supply += flow.supply
reserves += flow.reserves
borrowed += flow.borrowed
const line = Sim.LogPayload.$match(payload, {
Issuance: ({ reserveIn, sharesOut }) =>
`buy ${sharesOut} shares for ${reserveIn} reserves`,
Redemption: ({ sharesIn, reserveOut }) =>
`sell ${sharesIn} shares for ${reserveOut} reserves`,
RedeemAtFloor: ({ sharesIn, reserveOut }) =>
`floor-redeem ${sharesIn} → ${reserveOut}`,
ExerciseOptions: ({ reserveIn, sharesOut }) =>
`exercise ${sharesOut} options for ${reserveIn}`,
Borrow: ({ reserveOut }) => `borrow ${reserveOut}`,
Repay: ({ reserveIn }) => `repay ${reserveIn}`,
DonateReserve: ({ reserveIn }) => `donate ${reserveIn}`,
})
console.log(`#${index}`, line, { supply, reserves, borrowed })
}Price is not stored on the payload — it comes from the curve after each
step. Fold with applyPayload (or replay) and read spotPrice /
floorPrice on the rebuilt simulation:
const blank = Either.getOrThrow(
Sim.makeLinearSimulation({ slope: 2, initialFloor: 5 }),
)
let cursor = blank
for (const { index, payload } of sim.logs) {
cursor = Either.getOrThrow(Sim.applyPayload(cursor, payload))
const line = Sim.LogPayload.$match(payload, {
Issuance: ({ reserveIn, sharesOut }) =>
`buy ${sharesOut} shares for ${reserveIn} reserves`,
Redemption: ({ sharesIn, reserveOut }) =>
`sell ${sharesIn} shares for ${reserveOut} reserves`,
RedeemAtFloor: ({ sharesIn, reserveOut }) =>
`floor-redeem ${sharesIn} → ${reserveOut}`,
ExerciseOptions: ({ reserveIn, sharesOut }) =>
`exercise ${sharesOut} options for ${reserveIn}`,
Borrow: ({ reserveOut }) => `borrow ${reserveOut}`,
Repay: ({ reserveIn }) => `repay ${reserveIn}`,
DonateReserve: ({ reserveIn }) => `donate ${reserveIn}`,
})
console.log(`#${index}`, line, {
spot: Sim.spotPrice(cursor),
floor: Sim.floorPrice(cursor),
supply: Sim.totalSupply(cursor),
reserves: Sim.netReserves(cursor),
borrowed: Sim.totalBorrowed(cursor),
})
}For the fill price of a single trade (not the post-trade spot), divide the recorded legs on the payload — borrow, repay, and donate have no curve effect, so spot is unchanged across those steps:
const fill = Sim.LogPayload.$match(payload, {
Issuance: ({ reserveIn, sharesOut }) => reserveIn / sharesOut,
Redemption: ({ sharesIn, reserveOut }) => reserveOut / sharesIn,
RedeemAtFloor: ({ sharesIn, reserveOut }) => reserveOut / sharesIn,
ExerciseOptions: ({ reserveIn, sharesOut }) => reserveIn / sharesOut,
Borrow: () => undefined,
Repay: () => undefined,
DonateReserve: () => undefined,
})applyPayload is the single enforcement path for every state change;
operations quote a request into a payload and come through it. replay
folds recorded payloads onto a blank record, so persisting creation
parameters plus logs.map((log) => log.payload) deterministically
reconstructs the simulation:
const blank = Either.getOrThrow(
Sim.makeLinearSimulation({ slope: 2, initialFloor: 5 }),
)
const rebuilt = Either.getOrThrow(
Sim.replay(
blank,
sim.logs.map((log) => log.payload),
),
)
// rebuilt mirrors sim: same totals, same curve geometryPayload amounts are trusted facts on replay; only structural validity (enough supply, floor-region width, borrow capacity) is re-checked.
Accuracy
The wrapped calculator targets 0.1% relative accuracy and on-chain settlement
is always authoritative. Exact-side amounts (reserveIn on issuance,
sharesIn on redemption, floor-priced legs, borrows, donations) are recorded
verbatim; solver-quoted legs inherit the calculator's contract, and the
capacity searches return accepted witnesses, so they are legal and at most a
hair conservative. Logs are plain float sums — the simulator is built for
about a hundred operations, not for settlement.
Future operations
The log and reducer are the extension seam. Planned next: an explicit floor
raise (the ramp already shifts right through raiseFloorPreserveArea on the
wrapped calculator) and leverage loops composed from the existing borrow and
issue primitives.
