Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 253 additions & 0 deletions docs/binned-amm-spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
# Binned AMM (`CtBinned`) — Sandbox Spec

**Status:** sandbox. Behind `featureAMMBinnedCurve` amendment, removable by toggle.

**Goal:** ship a Trader Joe LB / Meteora DLMM–style bin AMM alongside the existing v3-style `CtConcentratedLiquidity`. Both curves coexist; the choice is per-pool at `AMMCreate`. Designed to test bin-style LP UX for XRP/RLUSD against per-LP custom ranges.

This spec describes the load-bearing decisions and pseudo-code only. Edge cases are tagged with `[TODO-impl]` and resolved in code.

---

## 1. Mental model

A `CtBinned` pool has a one-dimensional grid of **bins**, each at a fixed price `p(id) = (1 + binStep/10000)^id`. Each bin holds reserves of both pool assets at a known constant-sum invariant **at that price**. The pool's `sfActiveBinID` tracks which bin contains the current price; bins above `activeBinID` are pure asset0, bins below are pure asset1, the active bin holds both.

Liquidity provision is per-bin: an LP deposits into one or more bins; deposits in a bin are pooled and fungible. The LP receives **MPT shares** of each bin's pooled liquidity. Shares are transferable via standard MPT mechanics — that is the composability story.

Swap semantics: a trade consumes the active bin's constant-sum reserves until one side is exhausted; the swap then moves to the next bin in the direction of the trade. Walk continues until amount is filled or `kMaxIterations` (30) is hit.

---

## 2. State

### 2.1 AMM SLE additions (only present when `sfCurveType == CtBinned`)

| Field | Type | Purpose |
|---|---|---|
| `sfBinStep` | uint16 | Bin step in basis points. Set at AMMCreate, immutable. Valid set: `{1, 5, 10, 25, 100}` `[TODO-impl]`. |
| `sfActiveBinID` | int32 | Bin ID containing current price. Initialized from `AMMCreate` initial reserves' implied price. |

`sfBinStep` reuses the position of `sfTickSpacing` on the AMM SLE — the schema accepts whichever is set per curve type. `[TODO-impl: check ledger_entries.macro for how to make AMM SLE fields conditional on curve type, or just allow both as optional.]`

### 2.2 New SLE: `ltAMM_BIN` (suggested code `0x0081`)

```
LEDGER_ENTRY(ltAMM_BIN, 0x0081, AMMBin, amm_bin, ({
{sfAMMID, SoeRequired},
{sfBinID, SoeRequired}, // int32 (stored UINT32, interpret signed)
{sfReserve0, SoeRequired}, // STAmount, asset0 inventory at this bin
{sfReserve1, SoeRequired}, // STAmount, asset1 inventory at this bin
{sfFeeGrowthBin0, SoeRequired}, // Number, per-MPT-unit fee accumulator
{sfFeeGrowthBin1, SoeRequired},
{sfMPTokenIssuanceID, SoeRequired}, // points to ltMPTOKEN_ISSUANCE for LP shares
{sfOwnerNode, SoeRequired}, // AMM pseudo-account directory
{sfPreviousTxnID, SoeRequired},
{sfPreviousTxnLgrSeq, SoeRequired},
}))
```

Keylet: `keylet::ammBin(ammID, binID)`, indexed by `(LedgerNameSpace::AmmBin, ammID, binID)`.

### 2.3 New SFields required

- `sfBinStep` (UINT16)
- `sfBinID` (INT32 — or UINT32 reinterpreted signed)
- `sfActiveBinID` (INT32 / UINT32 signed)
- `sfReserve0`, `sfReserve1` (STAmount) — `[TODO-impl: confirm not already defined under other names]`
- `sfFeeGrowthBin0`, `sfFeeGrowthBin1` (NUMBER)

### 2.4 MPT integration

- Each bin owns **one** `ltMPTOKEN_ISSUANCE` (issuer = AMM pseudo-account).
- Issuance is **created on first deposit** to the bin (lazy bin instantiation).
- LP deposit mints MPT to LP proportional to the bin's liquidity contribution.
- LP withdrawal burns MPT and redeems proportional reserves.
- MPT transfers between accounts work via standard MPT mechanics — no bespoke `AMMBinTransfer` transactor.

**Reserve exemption** (protocol-wide rule): `ltMPTOKEN` whose issuance issuer is an AMM pseudo-account does **not** count toward owner reserve. An LP spread across 30 bins pays zero owner reserve for those shares. Implementation: at `Transactor` owner-count tally sites, check if the MPToken's issuance is owned by an account flagged as AMM pseudo-account.

---

## 3. Math

### 3.1 Bin price

```
price(binID) = (1 + binStep / 10000)^binID
```

Computed in fixed-point or via XRPL `Number`. Reference center (`binID == 0`) is price 1.

### 3.2 Bin liquidity (constant-sum within bin)

At active bin `i`:
```
reserve0[i] * price(i) + reserve1[i] = liquidity[i]
```

This is the constant-sum invariant per bin. Swap fully within the bin keeps this invariant; once one side hits zero, the bin is "flipped" and the swap moves to the next bin.

### 3.3 Swap within active bin (input is asset0, output is asset1)

```
amount_out = min(amount_in * price(activeBinID), reserve1[activeBinID])
reserve1[activeBinID] -= amount_out
reserve0[activeBinID] += amount_in_consumed
```

If `reserve1[activeBinID]` reaches 0:
- `activeBinID -= 1` (price moves down: less of asset1 per asset0)
- Continue swap in the new active bin, up to `kMaxIterations`.

(Symmetric for the opposite direction.)

### 3.4 Fee distribution (per bin)

When a swap consumes `amount_in` in bin `i` at fee rate `f`:
```
fee_collected = amount_in * f
feeGrowthBin[i] += fee_collected / MPT_outstanding[i]
```

On withdraw or fee-collect, LP receives:
```
fees_owed = MPT_balance * (feeGrowthBin[i] - feeGrowthSnapshot[i])
```

`feeGrowthSnapshot` per LP per bin — `[TODO-impl: store on the LP's MPToken entry or compute lazily via a per-deposit MPTokenScratch SLE]`. Simplest: snapshot stored on the LP's `ltMPTOKEN` row when minted, advance on collect. Requires extending MPToken SLE or piggybacking on existing fields.

---

## 4. Transactor changes

### 4.1 `AMMCreate`

When `sfCurveType == CtBinned`:
- Require `sfBinStep` field; validate against curated set.
- Compute `activeBinID` from initial `amount/amount2` price.
- Create the AMM SLE with `sfBinStep`, `sfActiveBinID` set.
- Do NOT create any bin SLEs — they instantiate lazily on first deposit.

### 4.2 `AMMDeposit` (binned path)

Tx fields (binned mode):
- `sfBinID` (single-bin deposit) OR `sfBinIDs[]` + `sfDistribution[]` (spread deposit) — `[TODO-impl: pick one to start; single-bin is sufficient for sandbox]`
- `sfAmount` and/or `sfAmount2` (single-sided allowed)

Algorithm (single bin):
1. Validate bin is within bounds.
2. If `ltAMM_BIN` SLE missing, create it: instantiate the per-bin `ltMPTOKEN_ISSUANCE`, save issuance ID on the bin SLE.
3. Compute new MPT shares to mint based on contributed liquidity vs existing bin liquidity (or, on first deposit, mint shares == liquidity in bin's unit).
4. Move pool assets from LP into AMM pseudo-account.
5. Update bin reserves.
6. Mint MPT to LP via the bin's issuance.
7. Record LP's `feeGrowthSnapshot` for this bin.

### 4.3 `AMMWithdraw` (binned path)

Tx fields (binned mode):
- `sfBinID` (or array)
- `sfAmount` of MPT to redeem (or `sfFlags` for "all")

Algorithm:
1. Validate LP holds MPT for this bin.
2. Compute proportional `(reserve0_share, reserve1_share)` from bin reserves.
3. Compute fees owed via `feeGrowthBin - feeGrowthSnapshot`, send to LP.
4. Burn MPT.
5. Decrement bin reserves; pay LP from AMM pseudo-account.
6. If bin reserves AND MPT outstanding both go to zero, delete bin SLE and its MPT issuance (lazy cleanup).

### 4.4 `AMMCollectFees` (binned path)

Tx fields (binned mode):
- `sfBinID` to collect from (or "all bins LP holds" — `[TODO-impl]`).

Algorithm:
1. Fee owed = `MPT_balance * (feeGrowthBin - feeGrowthSnapshot)`.
2. Send to LP from AMM pseudo-account.
3. Advance LP's `feeGrowthSnapshot` for this bin.

### 4.5 Swap (payment engine)

The AMM offer construction in the payment engine dispatches on `sfCurveType`:
- `CtConstantProduct` → existing path
- `CtConcentratedLiquidity` → existing tick-walk
- `CtStableSwap` → existing
- `CtBinned` → new bin-walk

Bin-walk:
1. Read `activeBinID` from AMM SLE.
2. Read active bin's SLE.
3. Apply constant-sum swap within bin.
4. Carve fee; update `feeGrowthBin{0,1}` on bin SLE.
5. If bin reserve depleted, advance `activeBinID` and load next bin.
6. Honor `kMaxIterations`.
7. Update AMM SLE's `sfActiveBinID` if it changed.

### 4.6 `AMMPositionTransfer`

**Not applicable to binned pools.** Bins use MPTs; transfer is via standard MPT path. If submitted against a binned pool, return `tecAMM_FAILED` (the position keylet wouldn't resolve to a `ltAMM_POSITION` SLE anyway).

---

## 5. Invariants

For each `CtBinned` pool:
1. `sum(reserve0[i] * price(i) + reserve1[i] for i in bins) == AMM total liquidity`
2. For each bin `i`: `sum(MPT_balance[lp] for lp) == MPTokenIssuance.sfOutstandingAmount`
3. `sfActiveBinID` always points to a bin where both reserves are non-zero, OR no bin has any reserves (empty pool)
4. No bin SLE exists without a corresponding `ltMPTOKEN_ISSUANCE`

These should be added to the `AMMInvariant` checker.

---

## 6. Sandbox scope cuts

- **No dynamic fees per bin (DLMM volatility accumulator).** Fee is the static pool fee, same per bin. Dynamic fee is a follow-up axis.
- **No spread deposits in initial impl.** Single bin per `AMMDeposit` only. Spread can be added later.
- **No ALM / vault layer.** Third-party concern.
- **No bin transfer transactor.** MPTs cover it.
- **Bin range bounds**: `[-221818, 221818]` initial; bound prevents adversarial state bloat. Tunable per pool `[TODO-impl]`.

---

## 7. Test plan

`src/test/app/AMMBinned_test.cpp`:

1. `testCreate` — create pool with each valid binStep; reject invalid binStep.
2. `testSingleBinDepositWithdraw` — round-trip into one bin.
3. `testMultiBinDeposits` — two LPs into same bin, share MPT pro-rata.
4. `testSwapWithinBin` — partial bin consumption, reserves update.
5. `testSwapCrossesBin` — full bin consumption, `activeBinID` advances.
6. `testSwapMaxIterations` — multi-bin swap stops at cap.
7. `testSingleSidedDeposit` — deposit only asset0 in a bin above active price → acts as limit ask; swap into price → bin fills.
8. `testFeeAccrualAndCollect` — two LPs in one bin, swap, both collect, fees split pro-rata.
9. `testMPTTransfer` — LP1 transfers bin shares to LP2 via MPT path; LP2 can collect/withdraw.
10. `testReserveExemption` — LP deposits across 10 bins; owner reserve unchanged.
11. `testAmendmentDisabled` — `AMMCreate` with `CtBinned` returns `temDISABLED` when `featureAMMBinnedCurve` off.
12. `testCLRegression` — every existing CL test passes unchanged (no curve-dispatch regressions).

---

## 8. Open `[TODO-impl]` items (resolve during coding)

1. AMM SLE field shape for `sfBinStep` vs existing `sfTickSpacing`: separate fields or repurpose?
2. `feeGrowthSnapshot` storage location: extend MPToken SLE, or per-LP-per-bin scratch SLE?
3. Curated `binStep` set: `{1, 5, 10, 25, 100}` or free integer in `[1, 1000]`?
4. Single-bin vs spread deposit tx shape: pick array-of-bins encoding.
5. AMM pseudo-account flag for reserve-exemption check: use existing `lsfAMM` or new?
6. Cleanup behavior: delete empty bin SLE eagerly, or leave for lazy reaper?
7. Bin range bounds: hardcoded `[-221818, 221818]`, or configurable per pool?

---

## 9. Removal

Toggle off `featureAMMBinnedCurve`:
- Existing `CtBinned` pools become inaccessible (no new ops dispatch through the binned path).
- New `AMMCreate` with `CtBinned` returns `temDISABLED`.
- No SLE rewrite needed; pools sit idle until amendment is re-enabled or pool is `AMMDelete`'d.

Code removal (if we decide bins lose): drop `ltAMM_BIN`, drop bin-path branches in transactors, drop amendment, drop `CtBinned` enum value.
6 changes: 6 additions & 0 deletions include/xrpl/ledger/CachedView.h
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ class CachedViewImpl : public DigestAwareReadView
return base_.succ(key, last);
}

std::optional<key_type>
pred(key_type const& key, std::optional<key_type> const& first = std::nullopt) const override
{
return base_.pred(key, first);
}

std::unique_ptr<SlesType::iter_base>
slesBegin() const override
{
Expand Down
3 changes: 3 additions & 0 deletions include/xrpl/ledger/Ledger.h
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ class Ledger final : public std::enable_shared_from_this<Ledger>,
std::optional<uint256>
succ(uint256 const& key, std::optional<uint256> const& last = std::nullopt) const override;

std::optional<uint256>
pred(uint256 const& key, std::optional<uint256> const& first = std::nullopt) const override;

std::shared_ptr<SLE const>
read(Keylet const& k) const override;

Expand Down
3 changes: 3 additions & 0 deletions include/xrpl/ledger/OpenView.h
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,9 @@ class OpenView final : public ReadView, public TxsRawView
std::optional<key_type>
succ(key_type const& key, std::optional<key_type> const& last = std::nullopt) const override;

std::optional<key_type>
pred(key_type const& key, std::optional<key_type> const& first = std::nullopt) const override;

std::shared_ptr<SLE const>
read(Keylet const& k) const override;

Expand Down
13 changes: 13 additions & 0 deletions include/xrpl/ledger/ReadView.h
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,19 @@ class ReadView
[[nodiscard]] virtual std::optional<key_type>
succ(key_type const& key, std::optional<key_type> const& last = std::nullopt) const = 0;

/** Return the key of the previous state item.

This returns the key of the first state item
whose key is less than the specified key. If
no such key is present, std::nullopt is returned.

If `first` is engaged, returns std::nullopt when
the key returned would be outside the open
interval (first, key).
*/
[[nodiscard]] virtual std::optional<key_type>
pred(key_type const& key, std::optional<key_type> const& first = std::nullopt) const = 0;

/** Return the state item associated with a key.

Effects:
Expand Down
3 changes: 3 additions & 0 deletions include/xrpl/ledger/detail/ApplyStateTable.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ class ApplyStateTable
[[nodiscard]] std::optional<key_type>
succ(ReadView const& base, key_type const& key, std::optional<key_type> const& last) const;

[[nodiscard]] std::optional<key_type>
pred(ReadView const& base, key_type const& key, std::optional<key_type> const& first) const;

[[nodiscard]] std::shared_ptr<SLE const>
read(ReadView const& base, Keylet const& k) const;

Expand Down
3 changes: 3 additions & 0 deletions include/xrpl/ledger/detail/ApplyViewBase.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ class ApplyViewBase : public ApplyView, public RawView
[[nodiscard]] std::optional<key_type>
succ(key_type const& key, std::optional<key_type> const& last = std::nullopt) const override;

[[nodiscard]] std::optional<key_type>
pred(key_type const& key, std::optional<key_type> const& first = std::nullopt) const override;

[[nodiscard]] std::shared_ptr<SLE const>
read(Keylet const& k) const override;

Expand Down
3 changes: 3 additions & 0 deletions include/xrpl/ledger/detail/RawStateTable.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ class RawStateTable
[[nodiscard]] std::optional<key_type>
succ(ReadView const& base, key_type const& key, std::optional<key_type> const& last) const;

[[nodiscard]] std::optional<key_type>
pred(ReadView const& base, key_type const& key, std::optional<key_type> const& first) const;

void
erase(std::shared_ptr<SLE> const& sle);

Expand Down
Loading