commit 9dce4e2855ea31a3783b2742ae2e48be6daa3e38 Author: Jeff Emmett Date: Wed Apr 1 00:12:00 2026 -0700 feat: complete MYCO bonding surface system — math specs, simulations, and tests N-dimensional ellipsoidal bonding surface for multi-asset token issuance, combining Balancer/Gyroscope DeFi primitives with commitment-based minting channels (labor, subscriptions, staking). 128 tests, 13 math specs, 5 Jupyter notebooks, composed system with scenario simulator. Co-Authored-By: Claude Opus 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d037ede --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +.venv/ +*.ipynb_checkpoints/ +.pytest_cache/ diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 0000000..7e366c2 --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,2 @@ +reference/MycoConditionalOrder.sol:generic-api-key:116 +reference/MycoConditionalOrder.sol:generic-api-key:137 diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e79a91 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# MYCO Bonding Surface + +Multi-asset bonding surface for **$MYCO** token issuance, combining N-dimensional ellipsoidal pricing with commitment-based minting channels and Gyroscope-inspired reserve management. + +## Architecture + +``` + ┌─────────────────────┐ + │ $MYCO Token Mint │ + └─────────┬───────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌─────────────┐ ┌──────────────┐ ┌────────────┐ + │ Financial │ │ Commitment │ │ Staking │ + │ Reserves │ │ Channels │ │ Lockups │ + │ (N assets) │ │ (labor/sub) │ │ (duration) │ + └──────┬──────┘ └──────┬───────┘ └─────┬──────┘ + │ │ │ + ┌──────▼──────┐ ┌──────▼───────┐ ┌─────▼──────┐ + │ Ellipsoid │ │ Contribution │ │ Time-weight │ + │ Bonding │ │ Oracle / │ │ Multiplier │ + │ Surface │ │ Attestation │ │ Curve │ + │ (N-CLP) │ │ │ │ │ + └──────┬──────┘ └──────┬───────┘ └─────┬──────┘ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────────┐ + │ Reserve Tranching Layer │ + │ (target weights, safety checks, rebalance) │ + └──────────────────┬──────────────────────────┘ + │ + ┌────────▼────────┐ + │ P-AMM Redemption│ + │ Curve + Flow │ + │ Dampening │ + └─────────────────┘ +``` + +## What This Is + +A research/simulation repo exploring advanced token issuance mechanics drawn from: + +- **[Balancer](https://github.com/balancer)** — Weighted constant product, StableSwap, concentrated liquidity, GradualValueChange, StableSurge fees +- **[Gyroscope](https://github.com/gyrostable)** — E-CLP elliptical curves, P-AMM redemption, reserve tranching, flow dampening + +The system extends these DeFi primitives into an **N-dimensional bonding surface** where $MYCO can be minted via: + +1. **Financial deposits** (ETH, USDC, DAI, ...) priced on an ellipsoidal invariant surface +2. **Labor contributions** — proof-of-contribution attestations converted to tokens at governed rates +3. **Subscriptions** — recurring pledges with loyalty multipliers +4. **Staking lockups** — time-weighted bonus minting with concave (sqrt) reward curves + +## Primitives + +| # | Primitive | Source | Purpose | +|---|-----------|--------|---------| +| 1 | Weighted Product | Balancer | N-asset constant product invariant | +| 2 | StableSwap | Curve/Balancer | Stable-pegged invariant (Newton solver) | +| 3 | Concentrated 2-CLP | Gyroscope | Virtual-reserve concentrated liquidity | +| 4 | Elliptical CLP | Gyroscope | A-matrix elliptical pricing | +| 5 | **N-D Ellipsoid Surface** | Novel | Generalized N-asset bonding surface | +| 6 | Reserve Tranching | Gyroscope GYD | Multi-vault weight targets + safety | +| 7 | P-AMM Redemption | Gyroscope | Backing-ratio-dependent redemption pricing | +| 8 | Dynamic Weights | Balancer/QuantAMM | Time-varying parameters + oracle multipliers | +| 9 | Flow Dampening | Gyroscope | Anti-bank-run exponential outflow memory | +| 10 | Imbalance Fees | Balancer StableSurge | Median-based surge fees | +| 11 | Commitment Issuance | Novel | Labor, subscription, staking channels | + +Each primitive has a math spec in `docs/` and a Python simulation in `src/primitives/` or `src/commitments/`. + +## Quick Start + +```bash +# Install +pip install -e ".[dev]" + +# Run tests (128 tests) +pytest tests/ + +# Run a simulation +python -c " +from src.composed.simulator import scenario_token_launch +result = scenario_token_launch(n_assets=3, n_depositors=50, duration=90) +print(f'Final supply: {result.supply[-1]:.2f}') +print(f'Final reserve: {result.reserve_value[-1]:.2f}') +print(f'Backing ratio: {result.backing_ratio[-1]:.4f}') +" +``` + +## Project Structure + +``` +myco-bonding-curve/ +├── docs/ # Math specifications (00–12) +├── src/ +│ ├── primitives/ # Core invariants and mechanisms +│ ├── commitments/ # Labor, subscription, staking channels +│ ├── composed/ # Full MycoSystem + simulator +│ └── utils/ # Fixed-point, Newton solver, linear algebra +├── notebooks/ # Interactive exploration +├── reference/ # Existing Solidity contracts (read-only) +└── tests/ # 128 property-based tests +``` + +## Key Invariants + +1. `supply >= 0` — never negative supply +2. `reserve_value >= 0` — never negative reserves +3. `redemption_amount <= reserve_value` — never return more than exists +4. `sum(vault_weights) == 1.0` — weights always normalized +5. `flow_tracker.current_flow >= 0` — flow tracking non-negative +6. `commitment_minted <= cap * supply` — commitment channels capped + +## Relationship to payment-infra + +This repo sits alongside `payment-infra` (which has the existing polynomial bonding curve + USDC reserve). This is the research/advanced design track exploring multi-asset surfaces and commitment-based issuance. Both repos coexist. + +## Dependencies + +- Python >= 3.11 +- numpy, scipy, matplotlib, sympy +- pytest, jupyter (dev) diff --git a/docs/00-architecture.md b/docs/00-architecture.md new file mode 100644 index 0000000..84fa56e --- /dev/null +++ b/docs/00-architecture.md @@ -0,0 +1,89 @@ +# MYCO Bonding Surface Architecture + +## Vision + +$MYCO is minted through a multi-channel bonding surface that accepts both financial reserves and non-financial commitments. The system draws on production-tested AMM math from Balancer and Gyroscope, composed into a novel architecture for token issuance. + +## System Diagram + +``` + ┌─────────────────────┐ + │ $MYCO Token Mint │ + └─────────┬───────────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + ┌─────────────┐ ┌──────────────┐ ┌────────────┐ + │ Financial │ │ Commitment │ │ Staking │ + │ Reserves │ │ Channels │ │ Lockups │ + │ (N assets) │ │ (labor/sub) │ │ (duration) │ + └──────┬──────┘ └──────┬───────┘ └─────┬──────┘ + │ │ │ + ┌──────▼──────┐ ┌──────▼───────┐ ┌─────▼──────┐ + │ Ellipsoid │ │ Contribution │ │ Time-weight │ + │ Bonding │ │ Oracle / │ │ Multiplier │ + │ Surface │ │ Attestation │ │ Curve │ + │ (N-CLP) │ │ │ │ │ + └──────┬──────┘ └──────┬───────┘ └─────┬──────┘ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────────────────┐ + │ Reserve Tranching Layer │ + │ (target weights, safety checks, rebalance) │ + └──────────────────┬──────────────────────────┘ + │ + ┌────────▼────────┐ + │ P-AMM Redemption│ + │ Curve + Flow │ + │ Dampening │ + └─────────────────┘ +``` + +## Primitives (bottom-up) + +| # | Primitive | Source | Role in MYCO | +|---|-----------|--------|-------------| +| 1 | Weighted constant product | Balancer | Baseline N-asset pricing; fallback invariant | +| 2 | StableSwap | Curve/Balancer | Pegged asset pricing within reserve tranches | +| 3 | Concentrated LP (2-CLP) | Gyroscope | Virtual reserves, price bounding (2-asset case) | +| 4 | Elliptical CLP (E-CLP) | Gyroscope | Elliptical concentration with rotation (2-asset) | +| 5 | N-dimensional surface | Novel (extends E-CLP) | Full N-asset ellipsoidal bonding surface | +| 6 | Reserve tranching | Gyroscope GYD | Multi-vault risk separation | +| 7 | Redemption curve (P-AMM) | Gyroscope | Piecewise redemption pricing by backing ratio | +| 8 | Dynamic weights | Balancer | Time-varying parameters, oracle-driven shifts | +| 9 | Flow dampening | Gyroscope | Anti-bank-run exponential outflow memory | +| 10 | Imbalance fees | Balancer | Surge fees when reserves become unbalanced | +| 11 | Commitment issuance | Novel | Labor, subscription, and staking mint channels | + +## How They Compose + +**Minting (inflow):** +1. User deposits one or more reserve assets +2. The ellipsoid bonding surface prices the deposit in aggregate token-space +3. Reserve tranching layer routes assets to appropriate vaults and checks safety +4. Imbalance fees apply if the deposit skews reserve weights away from targets +5. Dynamic weights may shift pricing based on time or oracle signals +6. $MYCO is minted proportional to the invariant increase + +**Commitment minting (parallel channel):** +1. Labor oracle attests to contribution units → mint at governed rate +2. Subscription stream → continuous drip mint +3. Staking lockup → time-weighted bonus mint +4. All subject to flow dampening (rate limits) + +**Redemption (outflow):** +1. User burns $MYCO +2. P-AMM determines redemption rate based on backing ratio +3. Flow dampening tracks recent redemptions, applies decay +4. If backing ratio < 1, piecewise discount applies (parabolic → linear floor) +5. Reserve tranching layer selects which vault(s) to redeem from +6. Assets returned to user + +## Relationship to Existing $MYCO + +The `payment-infra` repo contains the production V1: a simple polynomial bonding curve +(`price = basePrice + coefficient * supply^exponent`) with USDC-only reserve on Base. + +This repo is the V2 research track. The two coexist — V1 serves current needs while V2 +explores the multi-asset, commitment-augmented design. Migration path TBD after V2 +simulation validates the approach. diff --git a/docs/01-weighted-product.md b/docs/01-weighted-product.md new file mode 100644 index 0000000..6389a9f --- /dev/null +++ b/docs/01-weighted-product.md @@ -0,0 +1,64 @@ +# 01: Weighted Constant Product + +## Source +- **Protocol**: Balancer V2/V3 +- **Files**: `WeightedMath.sol`, `LogExpMath.sol` +- **Repo**: `balancer/balancer-v2-monorepo`, `balancer/balancer-v3-monorepo` + +## Rationale for MYCO + +The weighted constant product is the **simplest N-asset invariant** and serves as: +1. The baseline against which more sophisticated curves are compared +2. A fallback pricing mechanism when parameters for elliptical curves aren't calibrated +3. The foundation for understanding how invariant-based bonding works before adding concentration, rotation, and tranching +4. Production-proven at scale ($B+ TVL on Balancer) + +For MYCO specifically: if the reserve starts with 2-3 assets and grows to N, the weighted product provides immediate N-asset support with intuitive parameter tuning (just set weights). + +## Invariant + +$$I = \prod_{i=0}^{N} b_i^{w_i}$$ + +where: +- $b_i$ = balance of token $i$ (all positive) +- $w_i$ = normalized weight of token $i$ ($\sum w_i = 1$, each $w_i \in [0.01, 0.99]$) + +**Key property — homogeneous of degree 1:** +$$I(k \cdot b_1, \ldots, k \cdot b_n) = k \cdot I(b_1, \ldots, b_n)$$ + +This means scaling all balances by $k$ scales the invariant by $k$, which is required for Balancer V3's `BasePoolMath` to compute unbalanced liquidity operations generically. + +## Swap Math + +**Exact input (compute output):** +$$a_{out} = b_{out} \cdot \left(1 - \left(\frac{b_{in}}{b_{in} + a_{in}}\right)^{w_{in}/w_{out}}\right)$$ + +**Exact output (compute input):** +$$a_{in} = b_{in} \cdot \left(\left(\frac{b_{out}}{b_{out} - a_{out}}\right)^{w_{out}/w_{in}} - 1\right)$$ + +**Spot price** of token $i$ in terms of token $j$: +$$p_{ij} = \frac{b_j / w_j}{b_i / w_i}$$ + +## Parameters + +| Parameter | Range | Effect | +|-----------|-------|--------| +| $w_i$ | [0.01, 0.99] | Relative value share. Higher weight = more price-inert to that token's balance changes | +| N tokens | [2, 100] | Number of reserve assets (limited by min weight 1%) | + +**MYCO application**: Initial weights could be 50/50 for 2-asset launch, or 80/10/10 for a primary reserve + two satellite assets. + +## Properties + +- **Convexity**: The level sets (iso-invariant curves) are convex — swaps always move along a convex surface +- **No impermanent loss bounds**: Unlike concentrated LPs, the curve extends to infinity in all directions +- **Self-rebalancing**: As external prices change, arbitrageurs rebalance the pool to match, maintaining value shares equal to weights +- **Slippage**: Proportional to swap size relative to balance — large swaps relative to pool size incur significant price impact + +## MYCO Application + +In the composed system, weighted product serves as the **outer envelope** of the bonding surface. When reserves are far from target ratios (outside the concentrated region of the ellipsoid), pricing falls back to weighted-product-like behavior. This provides continuous pricing even when the elliptical concentration region is exhausted. + +## Implementation + +See `src/primitives/weighted_product.py` diff --git a/docs/02-stableswap.md b/docs/02-stableswap.md new file mode 100644 index 0000000..d326a03 --- /dev/null +++ b/docs/02-stableswap.md @@ -0,0 +1,77 @@ +# 02: StableSwap Invariant + +## Source +- **Protocol**: Curve Finance / Balancer V2/V3 +- **Files**: `StableMath.sol` (Balancer), Curve StableSwap whitepaper +- **Repo**: `balancer/balancer-v2-monorepo`, `balancer/balancer-v3-monorepo` + +## Rationale for MYCO + +StableSwap is essential for **reserve tranches that hold pegged assets** (e.g., multiple stablecoins, or wrapped versions of the same underlying). Within a single tranche, assets should trade near 1:1 with minimal slippage — exactly what StableSwap provides. + +For MYCO: +1. If multiple stablecoins (USDC, USDT, DAI) serve as reserve, they should be priced via StableSwap within their tranche rather than weighted product +2. The amplification parameter A lets us tune how "stable" the peg is — higher A = tighter peg, lower A = more AMM-like +3. The Newton-Raphson solver pattern established here is reused by the E-CLP and P-AMM primitives +4. Production-proven across $B+ in Curve and Balancer stable pools + +## Invariant + +$$A \cdot n^n \cdot S + D = A \cdot D \cdot n^n + \frac{D^{n+1}}{n^n \cdot P}$$ + +where: +- $A$ = amplification coefficient ($1 \leq A \leq 50{,}000$) +- $n$ = number of tokens +- $S = \sum b_i$ (sum of balances) +- $P = \prod b_i$ (product of balances) +- $D$ = the invariant (total virtual liquidity, analogous to constant-sum amount) + +**Limiting behavior:** +- $A \to \infty$: $D \to S$ (constant sum — perfect 1:1 peg) +- $A \to 0$: $D^{n+1} \to n^n \cdot P \cdot D$ → reduces to constant product + +## Swap Math + +To compute `amountOut` for a given `amountIn`: + +1. Compute $D$ from current balances +2. Set `new_balance[token_in] = old_balance[token_in] + amountIn` +3. Solve for `new_balance[token_out]` using the quadratic: + +$$y^2 + \left(S' + \frac{D}{A \cdot n^n} - D\right) y = \frac{D^{n+1}}{A \cdot n^{2n} \cdot P'}$$ + +where $S'$, $P'$ exclude the target token. Solved via Newton-Raphson: + +$$y_{k+1} = \frac{y_k^2 + c}{2y_k + b - D}$$ + +4. `amountOut = old_balance[token_out] - new_balance[token_out]` + +## Parameters + +| Parameter | Range | Effect | +|-----------|-------|--------| +| $A$ | [1, 50000] | Amplification. Higher = tighter peg, lower slippage near 1:1 | +| $n$ | [2, 5] | Number of tokens in the stable pool | + +**Balancer V3 additions:** +- `MAX_IMBALANCE_RATIO = 10,000` — max ratio between highest and lowest balance +- `StableSurgeHook` — imbalance-contingent fees (see primitive #10) + +## Properties + +- **Convexity**: Level sets are convex +- **Homogeneous degree 1**: $D(k \cdot \vec{b}) = k \cdot D(\vec{b})$ +- **Low slippage near peg**: For balanced pools with high A, swaps near 1:1 have ~zero price impact +- **High slippage far from peg**: When balances diverge significantly, the curve steepens rapidly (protective) + +## MYCO Application + +Within the reserve tranching layer, each tranche that holds pegged assets (e.g., a stablecoin basket) uses StableSwap internally. This means: + +- Depositing any accepted stablecoin into the stable tranche incurs minimal friction +- The tranche's internal invariant (D) grows smoothly with deposits +- The tranche's total value feeds into the outer bonding surface as one aggregate reserve dimension + +## Implementation + +See `src/primitives/stableswap.py` diff --git a/docs/03-concentrated-lp.md b/docs/03-concentrated-lp.md new file mode 100644 index 0000000..8599c88 --- /dev/null +++ b/docs/03-concentrated-lp.md @@ -0,0 +1,62 @@ +# 03: Concentrated Liquidity Pool (2-CLP) + +## Source +- **Protocol**: Gyroscope +- **Files**: `Gyro2CLPMath.sol`, `math_implementation.py` +- **Repos**: `gyrostable/concentrated-lps`, `gyrostable/gyro-pools`, `balancer/balancer-v3-monorepo` + +## Rationale for MYCO + +The 2-CLP introduces **virtual reserves** — the key concept that enables price bounding. Instead of liquidity spread across all prices (0, ∞), it concentrates within [α, β]. This is the stepping stone to the E-CLP ellipse. + +For MYCO: +1. Concentrating liquidity near target price ratios means less capital required for deep liquidity +2. Price bounds prevent the bonding curve from pricing reserves at absurd ratios +3. The quadratic invariant (analytically solvable) is simpler than Uniswap V3's tick-based approach +4. Forms the conceptual bridge between standard CPMM (weighted product) and the elliptical surface + +## Invariant + +$$L^2 = (x + L/\sqrt{\beta}) \cdot (y + L \cdot \sqrt{\alpha})$$ + +Rearranged as a quadratic in L: + +$$\left(1 - \frac{\sqrt{\alpha}}{\sqrt{\beta}}\right) L^2 - \left(\frac{y}{\sqrt{\beta}} + x \cdot \sqrt{\alpha}\right) L - x \cdot y = 0$$ + +Solved via Bhaskara's formula (standard quadratic, always positive discriminant). + +**Virtual offsets:** +- $a = L / \sqrt{\beta}$ — added to x +- $b = L \cdot \sqrt{\alpha}$ — added to y + +On virtual reserves, it's standard CPMM: $(x+a)(y+b) = L^2$ + +## Swap Math + +Standard constant product on virtual reserves: + +$$\Delta y = \frac{(y + b) \cdot \Delta x}{(x + a) + \Delta x}$$ + +## Parameters + +| Parameter | Range | Effect | +|-----------|-------|--------| +| $\alpha$ | (0, 1) | Lower price bound. Pool holds only x below this price | +| $\beta$ | (1, ∞) | Upper price bound. Pool holds only y above this price | + +**Concentration** is controlled by the width of the range β/α. Narrower = more concentrated = deeper liquidity near the peg, but less range. + +## Properties + +- **Analytically solvable**: No iterative solver needed (unlike StableSwap) +- **Price bounded**: Spot price always in [α, β] +- **Capital efficient**: All liquidity used within the active range +- **Degenerates to CPMM**: When α → 0 and β → ∞, virtual offsets vanish + +## MYCO Application + +If MYCO launches with a primary reserve pair (e.g., USDC + ETH), the 2-CLP provides concentrated pricing near the expected ratio. This is simpler than the full elliptical or N-D surface and may be sufficient for early-stage 2-asset reserves. + +## Implementation + +See `src/primitives/concentrated_2clp.py` diff --git a/docs/04-elliptical-clp.md b/docs/04-elliptical-clp.md new file mode 100644 index 0000000..8b97dce --- /dev/null +++ b/docs/04-elliptical-clp.md @@ -0,0 +1,80 @@ +# 04: Elliptic Concentrated Liquidity Pool (E-CLP) + +## Source +- **Protocol**: Gyroscope +- **Files**: `GyroECLPMath.sol` (53KB), `eclp_float.py`, `eclp_prec_implementation.py` +- **Repos**: `gyrostable/gyro-pools`, `balancer/balancer-v3-monorepo` +- **Paper**: `gyrostable/technical-papers/E-CLP/E-CLP Mathematics.pdf` + +## Rationale for MYCO + +The E-CLP is the most mathematically sophisticated AMM curve in production. It adds two degrees of freedom beyond the 2-CLP: + +1. **Rotation (φ)**: Tilts the price curve, useful when the target price ratio isn't 1:1 +2. **Stretching (λ)**: Controls how elongated the ellipse is — higher λ = more concentrated near the peg, like a flattened oval + +For MYCO: +1. The A-matrix transformation is the **key abstraction** that generalizes to N dimensions +2. Rotation handles non-unit price targets (e.g., ETH/USDC at $3000) +3. Stretching controls how much slippage increases as reserves deviate from target +4. Production-tested on Balancer V3 with formal security review and technical paper +5. The 5 parameters (α, β, c, s, λ) provide fine-grained curve shaping + +## Invariant + +$$|A(v - \text{offset})|^2 = r^2$$ + +where: +- $v = (x, y)$ are reserve balances +- $\text{offset} = (a, b)$ are virtual offsets (functions of $r$) +- $A$ is the 2×2 transformation matrix mapping ellipse → unit circle +- $r$ is the scalar invariant (liquidity parameter) + +**A-matrix:** + +$$A = \begin{pmatrix} c/\lambda & -s/\lambda \\ s & c \end{pmatrix}$$ + +**5 Parameters:** + +| Param | Meaning | Range | +|-------|---------|-------| +| $\alpha$ | Lower price bound | (0, 1) typically | +| $\beta$ | Upper price bound | (1, ∞) typically | +| $c$ | $\cos(-\phi)$ — rotation | [0, 1] | +| $s$ | $\sin(-\phi)$ — rotation | [0, 1] | +| $\lambda$ | Stretching factor | [1, 10^8] | + +Constraint: $c^2 + s^2 = 1$ + +**Derived parameters** (computed once, stored as immutables): +- $\tau(\alpha), \tau(\beta)$ — unit circle endpoints at 38-decimal precision +- $u, v, w, z$ — decomposition of $A \cdot \chi$ (center direction) +- $d_{Sq}$ — error correction for $c^2 + s^2$ + +## Swap Math + +Given new x, solve for y on the ellipse via quadratic: + +$$(A_{01}^2 + A_{11}^2) \cdot v^2 + 2(A_{00} A_{01} + A_{10} A_{11}) \cdot u \cdot v + (A_{00}^2 + A_{10}^2) \cdot u^2 = r^2$$ + +where $u = x - a$, $v = y - b$, solved with standard quadratic formula. + +## Properties + +- **Elliptical iso-invariant curves**: Level sets are ellipses (not circles or hyperbolas) +- **Concentrated + rotated**: Liquidity concentrated along the ellipse's long axis +- **Homogeneous degree 1**: $r(k \cdot v) = k \cdot r(v)$ — compatible with BPT math +- **Degenerates to 2-CLP**: When $\lambda = 1$ and $\phi = 0$, the ellipse becomes a circle + +## MYCO Application + +The E-CLP is the **2-asset specialization** of the full MYCO bonding surface. For any pair of reserve assets within the N-D surface, the local geometry is essentially an ellipse — the N-D surface is an N-D generalization of this. + +The A-matrix pattern is the key insight: by encoding geometry as a linear transform, we can: +1. Compute invariants efficiently (quadratic formula, not iterative) +2. Parameterize curves with intuitive knobs (rotation, stretch) +3. Generalize to N dimensions naturally (N×N matrix) + +## Implementation + +See `src/primitives/elliptical_clp.py` diff --git a/docs/05-n-dimensional-surface.md b/docs/05-n-dimensional-surface.md new file mode 100644 index 0000000..b920be4 --- /dev/null +++ b/docs/05-n-dimensional-surface.md @@ -0,0 +1,108 @@ +# 05: N-Dimensional Ellipsoidal Bonding Surface + +## Source +- **Novel construction** extending Gyroscope E-CLP (2D) and 3-CLP (3D) +- **Foundation**: E-CLP A-matrix pattern, 3-CLP cubic invariant approach +- **Math**: Standard N-dimensional ellipsoid geometry + +## Rationale for MYCO + +This is the **core innovation** of the MYCO bonding surface. While Gyroscope demonstrated: +- 2-CLP: Concentrated LP for 2 tokens (quadratic) +- 3-CLP: Concentrated LP for 3 tokens (cubic, symmetric price bounds) +- E-CLP: Elliptical concentration for 2 tokens + +Nobody has deployed an **N-asset ellipsoidal bonding surface** for token issuance. This primitive generalizes all three into a single framework where: + +1. Each reserve asset occupies one dimension +2. The ellipsoid geometry controls pricing and concentration +3. Per-axis stretch factors ($\lambda_i$) give independent concentration control per asset +4. The rotation matrix $Q$ controls correlation structure between assets +5. $MYCO supply maps to the invariant $r$ (radius of the ellipsoid) + +## Invariant + +$$|A(\vec{v} - \vec{\text{offset}})|^2 = r^2$$ + +where: +- $\vec{v} = (b_1, \ldots, b_N)$ — reserve balances +- $\vec{\text{offset}} = (a_1, \ldots, a_N)$ — virtual offsets (functions of $r$) +- $A = \text{diag}(1/\lambda_i) \cdot Q^T$ — N×N transformation matrix +- $Q$ — N×N orthogonal rotation matrix +- $\lambda_i$ — per-axis stretch factors +- $r$ — scalar invariant (bonding surface "radius") + +**Key property — homogeneous degree 1:** +$$r(k \cdot \vec{v}) = k \cdot r(\vec{v})$$ + +This ensures BPT/LP-token math works: minting is proportional to invariant increase. + +## Parameters + +| Parameter | Dimension | Range | Effect | +|-----------|-----------|-------|--------| +| $\lambda_i$ | N | [1, ∞) | Per-axis concentration. Higher = tighter pricing for that asset | +| $Q$ | N×N | Orthogonal | Rotation encoding correlations. Identity = axis-aligned | +| $\alpha_i$ | N | (0, 1) | Lower price bounds per asset | +| $\beta_i$ | N | (1, ∞) | Upper price bounds per asset | + +**Degrees of freedom:** +- N stretch factors +- N(N-1)/2 rotation angles (via Givens rotations) +- 2N price bounds +- Total: N² + N/2 parameters — rich enough to model complex reserve structures + +## Swap Math + +Given a swap of token $k$ in for token $j$ out: + +1. Set $b_k \leftarrow b_k + \Delta_k$ +2. Solve $|A(\vec{v}' - \vec{\text{offset}})|^2 = r^2$ for $b_j'$ +3. This is a **quadratic in $b_j$** (isolate terms involving $b_j$ from the norm) +4. $\Delta_j = b_j - b_j'$ + +The quadratic structure means swaps are computed in O(N) time (matrix-vector multiply + scalar quadratic solve). + +## Minting / Bonding + +Depositing reserves into the surface: + +1. User deposits amounts $\Delta_1, \ldots, \Delta_N$ +2. New invariant $r' = r(\vec{v} + \vec{\Delta})$ +3. $\text{MYCO\_minted} = \text{supply} \cdot (r'/r - 1)$ + +The invariant increase depends on the **geometry** of the deposit relative to the surface. Deposits aligned with the ellipsoid's minor axes (high $\lambda$ directions) increase the invariant more per unit deposited — this is by design, as those are the "more needed" reserves. + +## Geometric Interpretation + +The iso-invariant surface in N-D space is an **N-dimensional ellipsoid** (offset from the origin by the virtual reserves). Trading moves along this ellipsoid. Minting expands it (larger $r$). Redeeming shrinks it. + +The stretch factors $\lambda_i$ control the **eccentricity** along each axis: +- $\lambda_i = 1$ for all $i$: hypersphere (isotropic, like weighted product) +- $\lambda_i \gg 1$ for asset $i$: very concentrated in that dimension (tight pricing) +- Mixed $\lambda$: some assets tightly priced, others loosely (risk tranching effect) + +The rotation matrix $Q$ controls **correlations**: +- $Q = I$: axis-aligned, no cross-asset correlation in concentration +- Non-trivial $Q$: Correlated assets share concentration (e.g., ETH and stETH) + +## Properties + +- **Convexity**: Ellipsoids are convex — swap paths are well-defined +- **Homogeneous degree 1**: Required for LP math +- **O(N) swaps**: Quadratic solve per swap (after O(N) matrix multiply) +- **Degenerates to E-CLP**: When N=2, recovers the Gyroscope E-CLP exactly +- **Degenerates to hypersphere**: When all $\lambda_i = 1$ and $Q = I$ + +## MYCO Application + +This is the **primary pricing surface** for financial reserve deposits. When a user deposits ETH, USDC, DAI, or any accepted reserve token, the ellipsoid geometry determines how much $MYCO they receive. + +Governance controls the surface shape: +- Increasing $\lambda_i$ for a scarce reserve makes deposits of that asset more valuable +- Rotating the surface via $Q$ can encode expected correlations +- Price bounds prevent minting at absurd ratios + +## Implementation + +See `src/primitives/n_dimensional_surface.py` diff --git a/docs/06-reserve-tranching.md b/docs/06-reserve-tranching.md new file mode 100644 index 0000000..8abab4e --- /dev/null +++ b/docs/06-reserve-tranching.md @@ -0,0 +1,62 @@ +# 06: Reserve Tranching + +## Source +- **Protocol**: Gyroscope GYD +- **Files**: `ReserveManager.sol`, `VaultRegistry.sol`, `ReserveSafetyManager.sol`, `DataTypes.sol` +- **Repo**: `gyrostable/gyd-core` + +## Rationale for MYCO + +A multi-asset bonding curve needs risk separation. Not all reserve assets carry the same risk — stablecoins differ from ETH, which differs from governance tokens. Tranching provides: + +1. **Risk isolation**: If one vault's assets crash, other tranches are unaffected +2. **Governance control**: Target weights can be adjusted to shift risk profile +3. **Dynamic rebalancing**: Weights drift with asset performance (price-weighted) +4. **Safety invariants**: Operations that worsen imbalance are blocked +5. **Flow limits**: Short-term capital movement between tranches is bounded + +For MYCO specifically: commitment-based minting (labor, subscriptions, staking) creates "non-financial tranches" — the tranching system provides a unified framework for managing both financial and commitment-backed reserves. + +## Architecture + +Each tranche is a vault holding one type of collateral (or a pool of correlated collateral). The reserve system tracks: + +- **Target weights**: Governance-set, time-interpolated between old and new values +- **Current weights**: Actual USD value / total USD value +- **Price drift**: Target weights adjust for asset performance since calibration +- **Safety checks**: Every mint/redeem must either keep weights within epsilon of targets, or move weights closer to targets + +## Key Formulas + +**Target weight with time interpolation:** +``` +scheduled_weight = prev_weight + (current_weight - prev_weight) * min(elapsed / duration, 1) +``` + +**Price-adjusted target:** +``` +weighted_return[i] = (current_price[i] / calibration_price[i]) * scheduled_weight[i] +target_weight[i] = weighted_return[i] / sum(weighted_returns) +``` + +**Safety check:** +``` +For each vault outside epsilon band: + new_deviation = |new_weight - target| + old_deviation = |old_weight - target| + REQUIRE: new_deviation <= old_deviation (must be rebalancing) +``` + +## Parameters + +| Parameter | Effect | +|-----------|--------| +| `target_weight` | Fraction of total reserve value | +| `max_deviation` | How far current weight can deviate before blocking | +| `transition_duration` | How long to interpolate between old/new target | +| `short_flow_memory` | Decay rate for flow tracking per vault | +| `short_flow_threshold` | Max flow as fraction of vault value | + +## Implementation + +See `src/primitives/reserve_tranching.py` diff --git a/docs/07-redemption-curve.md b/docs/07-redemption-curve.md new file mode 100644 index 0000000..aaf8edf --- /dev/null +++ b/docs/07-redemption-curve.md @@ -0,0 +1,58 @@ +# 07: P-AMM Redemption Curve + +## Source +- **Protocol**: Gyroscope GYD +- **Files**: `PrimaryAMMV1.sol`, `IPAMM.sol`, `Flow.sol` +- **Paper**: `gyrostable/technical-papers/P-AMM/P-AMM technical paper.pdf` + +## Rationale for MYCO + +Every bonding curve needs a redemption mechanism. The P-AMM provides a sophisticated, bank-run-resistant approach: + +1. **Piecewise pricing**: Full redemption at par when fully backed, smooth degradation when underbacked +2. **No cliff**: Unlike simple bonding curves where selling drains the reserve linearly, the parabolic discount protects remaining holders +3. **Floor guarantee**: Even deeply underbacked, there's a minimum redemption rate (θ̄) +4. **Outflow memory**: Tracks recent redemptions, dampening cascade effects +5. **Separating redemption from swap pricing**: The bonding surface determines minting price; the P-AMM independently determines redemption price + +For MYCO: this is the "exit" side of the bonding curve. It determines how much a $MYCO holder gets back when selling, ensuring the system degrades gracefully rather than catastrophically. + +## Invariant / Pricing Function + +Three regions by backing ratio ba = reserve / supply: + +**Region I: Fully backed (ba ≥ 1)** +``` +rate = 1.0 (redeem at par) +``` + +**Region II: Parabolic discount (xu < x ≤ xl)** +``` +b(x) = ba - x + α(x - xu)² / 2 +``` +where x = cumulative recent redemptions as fraction of supply. + +**Region III: Linear floor (x > xl)** +``` +rate = θ̄ (minimum guaranteed) +``` + +## Parameters + +| Parameter | Symbol | Range | Effect | +|-----------|--------|-------|--------| +| Curvature | ᾱ | > 0 | Steepness of discount. Higher = sharper drop | +| No-discount threshold | x̄_U | [0, 1] | Fraction of supply that can redeem at par | +| Floor rate | θ̄ | [0, 1] | Minimum redemption rate | +| Outflow memory | | (0, 1) | Decay rate for flow tracking. Higher = longer memory | + +## Properties + +- **Monotonically decreasing**: More redemption → worse rate (protects remaining holders) +- **Continuous**: No jumps between regions (smooth transitions) +- **Bounded**: Rate always in [θ̄, 1.0] +- **Path-dependent**: Rate depends on recent history (outflow memory), not just current state + +## Implementation + +See `src/primitives/redemption_curve.py` diff --git a/docs/08-dynamic-weights.md b/docs/08-dynamic-weights.md new file mode 100644 index 0000000..6e8f94a --- /dev/null +++ b/docs/08-dynamic-weights.md @@ -0,0 +1,47 @@ +# 08: Dynamic Weights + +## Source +- **Protocol**: Balancer +- **Files**: `GradualValueChange.sol`, `LBPool.sol` (V3), `quantamm_math.py` +- **Repos**: `balancer/balancer-v2-monorepo`, `balancer/balancer-v3-monorepo`, `balancer/balancer-maths` + +## Rationale for MYCO + +Static bonding curves have fixed geometry — the price surface never adapts. Dynamic weights allow the MYCO bonding surface to evolve over time: + +1. **Token launch**: LBP-style weight schedule for initial distribution (start 95/5 MYCO/USDC, gradually shift to 50/50) +2. **Adaptive pricing**: Oracle-driven multipliers let the surface respond to market conditions +3. **Governance updates**: Smooth transitions between parameter sets (no discontinuities) +4. **Reserve rebalancing**: As target allocations change, weights interpolate smoothly + +Two mechanisms: +- **GradualValueChange**: Linear interpolation on a schedule (deterministic) +- **QuantAMM multiplier**: Oracle sets a velocity, weight drifts linearly until next update (reactive) + +## Formulas + +**GradualValueChange:** +``` +progress = (t - t_start) / (t_end - t_start) +value(t) = start_value + progress * (end_value - start_value) +``` +Clamped: before start → start_value, after end → end_value. + +**QuantAMM multiplier:** +``` +weight(t) = base_weight + multiplier * (t - last_update_time) +``` +Oracle updates `(base_weight, multiplier)` at each block/epoch. + +## Parameters + +| Parameter | Effect | +|-----------|--------| +| start/end values | What the weight changes between | +| start/end times | Duration of the transition | +| multiplier | Rate of change per time unit (oracle-driven) | +| min/max weight | Bounds to prevent extreme allocations | + +## Implementation + +See `src/primitives/dynamic_weights.py` diff --git a/docs/09-flow-dampening.md b/docs/09-flow-dampening.md new file mode 100644 index 0000000..ac9c525 --- /dev/null +++ b/docs/09-flow-dampening.md @@ -0,0 +1,42 @@ +# 09: Flow Dampening + +## Source +- **Protocol**: Gyroscope GYD +- **Files**: `Flow.sol` +- **Repo**: `gyrostable/gyd-core` + +## Rationale for MYCO + +Bank runs are the existential threat to any bonding curve. If all holders try to redeem simultaneously, early redeemers drain the reserve and late redeemers get nothing. Flow dampening prevents this by tracking recent outflows and applying progressive penalties. + +For MYCO: +1. Protects against coordinated sell-offs +2. Makes the redemption path predictable (not first-come-first-served) +3. Integrates with both the P-AMM (reducing redemption rate) and imbalance fees (increasing costs) +4. The exponential memory parameter is a single governance knob: closer to 1 = more conservative + +## Formula + +``` +flow(t) = flow(t-1) * memory^(dt) + new_flow +``` + +The memory parameter (∈ (0,1)) controls decay speed: +- memory = 0.999: ~1000 time units to decay by 63% +- memory = 0.99: ~100 time units to decay by 63% +- memory = 0.9: ~10 time units to decay by 63% + +Half-life: `t_half = -ln(2) / ln(memory)` + +## Penalty Function + +When flow exceeds the threshold: +``` +penalty_multiplier = max(0.1, 1 - ((ratio - threshold/2) / (threshold * 1.5))²) +``` + +This multiplier is applied to redemption amounts, effectively slowing outflows when they're too rapid. + +## Implementation + +See `src/primitives/flow_dampening.py` diff --git a/docs/10-imbalance-fees.md b/docs/10-imbalance-fees.md new file mode 100644 index 0000000..1dd84dd --- /dev/null +++ b/docs/10-imbalance-fees.md @@ -0,0 +1,44 @@ +# 10: Imbalance Fees (StableSurge) + +## Source +- **Protocol**: Balancer V3 +- **Files**: `StableSurgeHook.sol`, `StableSurgeMedianMath.sol` +- **Repo**: `balancer/balancer-v3-monorepo` + +## Rationale for MYCO + +The bonding surface should incentivize balanced reserve composition. Imbalance fees make it expensive to deposit assets that worsen the reserve balance, and cheap (or free) to deposit scarce assets. + +For MYCO: +1. Discourages depositing only the cheapest/most abundant reserve asset +2. Creates economic incentive to maintain target reserve ratios +3. Generates fee revenue for the treasury when users unbalance the reserve +4. Complements reserve tranching (soft incentive vs. hard safety check) + +## Formula + +**Imbalance metric:** +``` +imbalance = sum(|balance_i - median|) / sum(balance_i) +``` + +**Surge fee:** +``` +if imbalance_after > threshold AND imbalance_after > imbalance_before: + excess = (imbalance_after - threshold) / (1 - threshold) + fee = static_fee + (surge_rate - static_fee) * min(excess, 1) +else: + fee = static_fee +``` + +## Parameters + +| Parameter | Default | Effect | +|-----------|---------|--------| +| static_fee | 0.3% | Base fee for balanced operations | +| surge_fee_rate | 5% | Maximum fee for heavily unbalancing operations | +| threshold | 20% | Imbalance level at which surge begins | + +## Implementation + +See `src/primitives/imbalance_fees.py` diff --git a/docs/11-commitment-issuance.md b/docs/11-commitment-issuance.md new file mode 100644 index 0000000..ae590a0 --- /dev/null +++ b/docs/11-commitment-issuance.md @@ -0,0 +1,77 @@ +# 11: Commitment-Based Issuance + +## Source +- **Novel construction** inspired by: + - MycoFi paper: "subscription-based bonding curves", "myco-mortgages" + - Commons Stack augmented bonding curves + - Proof-of-contribution systems (Coordinape, SourceCred) + +## Rationale for MYCO + +Financial bonding curves only recognize capital. But the MycoFi ecosystem values labor, sustained commitment, and community building equally. Commitment-based issuance creates parallel minting channels that don't draw from financial reserves — they recognize non-financial value contributions. + +Three channels: + +### 1. Labor / Proof-of-Contribution +- Oracle or peer review attests to contribution units +- Code, governance participation, community building +- Rate-limited per period with cooldowns +- Unclaimed contributions decay (use-it-or-lose-it) +- Governed conversion rates (tokens per contribution unit) + +### 2. Subscription / Recurring Pledges +- Recurring payments (e.g., $10/month) create continuous mint streams +- Better rate than spot bonding curve (subscriber premium) +- Loyalty multiplier grows with duration (up to 2x for long-term patrons) +- Predictable inflow for the reserve +- Three tiers: Supporter ($10/mo), Sustainer ($50/mo), Patron ($200/mo) + +### 3. Staking / Time-Weighted Lockup +- Lock $MYCO or approved assets for T time units +- Bonus minting: `amount * base_rate * lockup_multiplier(T)` +- Concave multiplier: `sqrt(T)` curve (diminishing returns) +- Max 3x multiplier for 1-year lockup +- Early withdrawal forfeits 50% of unvested bonus +- Creates demand-side pressure and long-term alignment + +## Integration with Reserve System + +Commitment channels mint $MYCO **without adding to financial reserves**. This means: +- They dilute the backing ratio (more supply, same reserves) +- The P-AMM redemption curve accounts for this (backing ratio < 1 triggers discounts) +- Flow dampening applies to commitment minting too (rate limits) +- The system has governance-controlled caps on total commitment minting + +The key insight: commitment-minted tokens are backed by **ecosystem value** (labor, loyalty, commitment) rather than financial reserves. The reserve tranching layer treats "commitment value" as a separate vault type alongside financial vaults. + +## Parameters + +### Labor +| Parameter | Default | Effect | +|-----------|---------|--------| +| tokens_per_unit | 3-10 | Conversion rate per contribution type | +| max_units_per_period | 20-100 | Rate limit | +| period_length | 30 days | Period for rate limits | +| cooldown | 1-7 days | Min time between claims | +| decay_rate | 0-0.1 | Annual decay of unclaimed units | + +### Subscription +| Parameter | Default | Effect | +|-----------|---------|--------| +| payment_per_period | $10-200 | Recurring payment amount | +| base_mint_rate | 1.5-2.0x | Premium vs spot rate | +| loyalty_multiplier | up to 2x | Grows with subscription duration | +| loyalty_halflife | 90-180 days | How fast loyalty builds | + +### Staking +| Parameter | Default | Effect | +|-----------|---------|--------| +| base_rate | 5% APR | Base staking reward | +| max_multiplier | 3x | Max bonus for longest lockup | +| max_lockup | 365 days | Maximum lockup duration | +| multiplier_curve | sqrt | Shape of lockup bonus curve | +| early_withdrawal_penalty | 50% | Forfeited bonus on early exit | + +## Implementation + +See `src/commitments/labor.py`, `src/commitments/subscription.py`, `src/commitments/staking.py` diff --git a/docs/12-composed-system.md b/docs/12-composed-system.md new file mode 100644 index 0000000..96449e9 --- /dev/null +++ b/docs/12-composed-system.md @@ -0,0 +1,112 @@ +# 12: Composed MYCO System + +## How Everything Fits Together + +The MYCO bonding surface is a **multi-channel token issuance system** that combines financial reserve deposits with non-financial commitments, all managed through a unified reserve architecture. + +## Data Flow + +### Minting (Inflow) + +``` +User deposits ETH, USDC, DAI, ... + │ + ▼ +[Imbalance Fee Check] + │ Fee applied if deposit skews reserve weights + ▼ +[Safety Check — Reserve Tranching] + │ Blocked if deposit worsens weight deviation beyond threshold + ▼ +[N-D Ellipsoid Bonding Surface] + │ Invariant r increases proportional to deposit value + geometry + │ MYCO minted = supply * (r_new/r_old - 1) + ▼ +[Update Reserve State] + │ Vault balances, total value, backing ratio + ▼ +$MYCO credited to depositor +``` + +### Commitment Minting (Parallel Channels) + +``` +Labor attestation / Subscription payment / Staking lockup + │ + ▼ +[Rate Limit + Flow Dampening] + │ Per-channel caps, cooldowns, exponential memory + ▼ +[Mint $MYCO] + │ No invariant change (commitment-backed, not reserve-backed) + │ Subscription payments DO add to reserve; labor/staking do NOT + ▼ +[Update Supply] + │ Backing ratio decreases (more supply, same/slightly more reserve) + ▼ +$MYCO credited to contributor/subscriber/staker +``` + +### Redemption (Outflow) + +``` +User burns $MYCO + │ + ▼ +[Flow Dampening] + │ Exponential outflow memory → penalty multiplier + │ Effective redemption = amount * penalty + ▼ +[P-AMM Redemption Curve] + │ Rate depends on backing ratio: + │ ba ≥ 1 → rate = 1.0 (par) + │ ba < 1 → parabolic discount + │ ba ≪ 1 → floor rate θ̄ + ▼ +[Optimal Withdrawal Split] + │ Withdraw more from overweight vaults → rebalance + ▼ +[Safety Check] + │ Ensure withdrawal doesn't worsen vault deviations + ▼ +[Execute] + │ Return USD-equivalent assets to user + │ Burn $MYCO, shrink surface invariant + ▼ +Assets returned to redeemer +``` + +## Parameter Governance + +All parameters are governance-controlled. Key governance decisions: + +| Decision | Parameters | Effect | +|----------|-----------|--------| +| Reserve composition | Target weights, max deviation | Which assets and in what proportion | +| Surface geometry | λ_i, Q rotation | How concentrated pricing is per asset | +| Redemption safety | P-AMM α, θ̄, outflow memory | How aggressively to protect remaining holders | +| Fee structure | Static fee, surge rate, threshold | Economic incentives for balanced deposits | +| Commitment rates | Tokens per unit, rate limits, caps | How much non-financial contributions mint | +| Dynamic evolution | Weight schedules, oracle multipliers | How parameters change over time | + +## Invariants (Things That Must Always Hold) + +1. `supply >= 0` — never negative supply +2. `reserve_value >= 0` — never negative reserves +3. `redemption_amount <= reserve_value` — never return more than exists +4. `sum(vault_weights) == 1.0` — weights always normalized +5. `flow_tracker.current_flow >= 0` — flow tracking always non-negative +6. `commitment_minted <= cap * supply` — commitment channels capped + +## Simulation Scenarios + +Three built-in scenarios test the system: + +1. **Token Launch**: 50 depositors over 30 days, some redemptions at day 60-90 +2. **Bank Run**: All holders try to redeem continuously — flow dampening protects +3. **Mixed Issuance**: Monthly financial deposits + weekly labor contributions over 1 year + +## Implementation + +- `src/composed/myco_surface.py` — Full system class (`MycoSystem`) +- `src/composed/simulator.py` — Scenario runner and built-in scenarios diff --git a/notebooks/01_primitives_gallery.ipynb b/notebooks/01_primitives_gallery.ipynb new file mode 100644 index 0000000..7191aa3 --- /dev/null +++ b/notebooks/01_primitives_gallery.ipynb @@ -0,0 +1,347 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Primitives Gallery\n", + "\n", + "Visual tour of all bonding curve primitives in the MYCO stack." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from matplotlib import cm\n", + "\n", + "%matplotlib inline\n", + "plt.rcParams['figure.figsize'] = (12, 5)\n", + "plt.rcParams['figure.dpi'] = 100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Weighted Constant Product\n", + "\n", + "Balancer invariant: $I = \\prod_i b_i^{w_i}$" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.primitives.weighted_product import compute_invariant, calc_out_given_in, spot_price\n", + "\n", + "# Compare 50/50 vs 80/20 weight pools\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "for ax, weights, title in [\n", + " (axes[0], np.array([0.5, 0.5]), '50/50 Pool'),\n", + " (axes[1], np.array([0.8, 0.2]), '80/20 Pool'),\n", + "]:\n", + " balances = np.array([1000.0, 1000.0])\n", + " amounts_in = np.linspace(1, 500, 100)\n", + " amounts_out = [calc_out_given_in(balances, weights, 0, 1, a) for a in amounts_in]\n", + " prices = [spot_price(np.array([balances[0] + a, balances[1] - o]), weights, 0, 1)\n", + " for a, o in zip(amounts_in, amounts_out)]\n", + "\n", + " ax.plot(amounts_in, amounts_out, 'b-', label='Amount out')\n", + " ax2 = ax.twinx()\n", + " ax2.plot(amounts_in, prices, 'r--', label='Spot price')\n", + " ax.set_xlabel('Amount in (token A)')\n", + " ax.set_ylabel('Amount out (token B)', color='b')\n", + " ax2.set_ylabel('Spot price (A/B)', color='r')\n", + " ax.set_title(f'Weighted Product — {title}')\n", + " ax.legend(loc='upper left')\n", + " ax2.legend(loc='upper right')\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. StableSwap\n", + "\n", + "Curve invariant blending constant-sum and constant-product." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.primitives.stableswap import compute_invariant as ss_invariant, calc_out_given_in as ss_swap\n", + "\n", + "fig, ax = plt.subplots(figsize=(10, 6))\n", + "\n", + "for A, color in [(1, 'red'), (10, 'orange'), (100, 'green'), (1000, 'blue')]:\n", + " balances = np.array([1000.0, 1000.0])\n", + " amounts_in = np.linspace(1, 800, 200)\n", + " amounts_out = [ss_swap(balances, A, 0, 1, a) for a in amounts_in]\n", + " ax.plot(amounts_in, amounts_out, color=color, label=f'A = {A}')\n", + "\n", + "ax.plot([0, 800], [0, 800], 'k:', alpha=0.3, label='1:1 line')\n", + "ax.set_xlabel('Amount in')\n", + "ax.set_ylabel('Amount out')\n", + "ax.set_title('StableSwap — Effect of Amplification Parameter A')\n", + "ax.legend()\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Concentrated 2-CLP\n", + "\n", + "Gyroscope virtual-reserve concentrated liquidity." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.primitives.concentrated_2clp import (\n", + " TwoCLPParams, compute_invariant as clp_invariant,\n", + " calc_out_given_in as clp_swap, virtual_offset_x, virtual_offset_y,\n", + ")\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "# Invariant curves for different price ranges\n", + "ax = axes[0]\n", + "for sqrt_alpha, sqrt_beta, label in [\n", + " (0.5, 2.0, 'Wide [0.25, 4.0]'),\n", + " (0.9, 1.11, 'Narrow [0.81, 1.23]'),\n", + " (0.99, 1.01, 'Very narrow [0.98, 1.02]'),\n", + "]:\n", + " params = TwoCLPParams(sqrt_alpha=sqrt_alpha, sqrt_beta=sqrt_beta)\n", + " balances = np.array([1000.0, 1000.0])\n", + " L = clp_invariant(balances, params)\n", + " a = virtual_offset_x(L, params)\n", + " b = virtual_offset_y(L, params)\n", + "\n", + " x_range = np.linspace(10, 2000, 500)\n", + " y_range = L**2 / (x_range + a) - b\n", + " valid = y_range > 0\n", + " ax.plot(x_range[valid], y_range[valid], label=label)\n", + "\n", + "ax.set_xlabel('Balance X')\n", + "ax.set_ylabel('Balance Y')\n", + "ax.set_title('2-CLP Invariant Curves')\n", + "ax.legend()\n", + "\n", + "# Swap comparison\n", + "ax = axes[1]\n", + "for sqrt_alpha, sqrt_beta, label in [\n", + " (0.5, 2.0, 'Wide'),\n", + " (0.9, 1.11, 'Narrow'),\n", + "]:\n", + " params = TwoCLPParams(sqrt_alpha=sqrt_alpha, sqrt_beta=sqrt_beta)\n", + " balances = np.array([1000.0, 1000.0])\n", + " amounts_in = np.linspace(1, 500, 100)\n", + " amounts_out = [clp_swap(balances, params, 0, 1, a) for a in amounts_in]\n", + " ax.plot(amounts_in, amounts_out, label=label)\n", + "\n", + "ax.plot([0, 500], [0, 500], 'k:', alpha=0.3)\n", + "ax.set_xlabel('Amount in')\n", + "ax.set_ylabel('Amount out')\n", + "ax.set_title('2-CLP Swap Output by Concentration')\n", + "ax.legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Elliptical CLP (E-CLP)\n", + "\n", + "Gyroscope's A-matrix elliptical invariant." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.primitives.elliptical_clp import (\n", + " ECLPParams, compute_derived_params, compute_invariant as eclp_invariant,\n", + " calc_out_given_in as eclp_swap,\n", + ")\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "# Effect of lambda\n", + "ax = axes[0]\n", + "for lam, label in [(1.0, 'λ=1 (circle)'), (2.0, 'λ=2'), (5.0, 'λ=5')]:\n", + " params = ECLPParams(alpha=0.9, beta=1.1, c=1.0, s=0.0, lam=lam)\n", + " derived = compute_derived_params(params)\n", + " balances = np.array([1000.0, 1000.0])\n", + " amounts_in = np.linspace(1, 400, 100)\n", + " amounts_out = [eclp_swap(balances, params, derived, 0, 1, a) for a in amounts_in]\n", + " ax.plot(amounts_in, amounts_out, label=label)\n", + "\n", + "ax.set_xlabel('Amount in')\n", + "ax.set_ylabel('Amount out')\n", + "ax.set_title('E-CLP — Effect of λ (stretch)')\n", + "ax.legend()\n", + "\n", + "# Effect of rotation\n", + "ax = axes[1]\n", + "for angle, label in [(0, 'θ=0°'), (15, 'θ=15°'), (30, 'θ=30°'), (45, 'θ=45°')]:\n", + " rad = np.radians(angle)\n", + " params = ECLPParams(alpha=0.8, beta=1.2, c=np.cos(rad), s=np.sin(rad), lam=3.0)\n", + " derived = compute_derived_params(params)\n", + " balances = np.array([1000.0, 1000.0])\n", + " amounts_in = np.linspace(1, 400, 100)\n", + " amounts_out = [eclp_swap(balances, params, derived, 0, 1, a) for a in amounts_in]\n", + " ax.plot(amounts_in, amounts_out, label=label)\n", + "\n", + "ax.set_xlabel('Amount in')\n", + "ax.set_ylabel('Amount out')\n", + "ax.set_title('E-CLP — Effect of Rotation Angle')\n", + "ax.legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Reserve Management Primitives" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.primitives.redemption_curve import PAMMParams, PAMMState, compute_redemption_rate\n", + "\n", + "# P-AMM redemption rate vs backing ratio\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "ax = axes[0]\n", + "params = PAMMParams()\n", + "ba_range = np.linspace(0.01, 1.5, 200)\n", + "rates = []\n", + "for ba in ba_range:\n", + " state = PAMMState(reserve_value=ba * 1000, myco_supply=1000)\n", + " rates.append(compute_redemption_rate(state, params, 10.0))\n", + "ax.plot(ba_range, rates, 'b-', linewidth=2)\n", + "ax.axhline(y=1.0, color='g', linestyle='--', alpha=0.5, label='Par (rate=1)')\n", + "ax.axhline(y=params.theta_bar, color='r', linestyle='--', alpha=0.5, label=f'Floor (θ̄={params.theta_bar})')\n", + "ax.axvline(x=1.0, color='gray', linestyle=':', alpha=0.5)\n", + "ax.set_xlabel('Backing Ratio')\n", + "ax.set_ylabel('Redemption Rate')\n", + "ax.set_title('P-AMM Redemption Curve')\n", + "ax.legend()\n", + "\n", + "# Flow dampening\n", + "from src.primitives.flow_dampening import simulate_bank_run\n", + "\n", + "ax = axes[1]\n", + "result = simulate_bank_run(initial_reserve=10000, outflow_per_step=500, memory=0.99, steps=100)\n", + "ax.plot(result['reserve'], 'b-', label='Reserve value')\n", + "ax.plot(result['penalties'], 'r--', label='Flow penalty')\n", + "ax.set_xlabel('Time step')\n", + "ax.set_ylabel('Value')\n", + "ax.set_title('Flow Dampening During Bank Run')\n", + "ax.legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Imbalance Fees & Dynamic Weights" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.primitives.imbalance_fees import compute_imbalance, surge_fee\n", + "from src.primitives.dynamic_weights import simulate_lbp, create_lbp_schedule\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "# Imbalance fee surface\n", + "ax = axes[0]\n", + "ratios = np.linspace(0.1, 5.0, 50)\n", + "fees = []\n", + "for r in ratios:\n", + " old = np.array([1000.0, 1000.0, 1000.0])\n", + " deposit = np.array([r * 100, 100.0, 100.0])\n", + " f = surge_fee(old, old + deposit, 0.003, 0.05, 0.2)\n", + " fees.append(f)\n", + "ax.plot(ratios, fees, 'b-', linewidth=2)\n", + "ax.set_xlabel('Deposit ratio (asset 0 vs others)')\n", + "ax.set_ylabel('Surge fee rate')\n", + "ax.set_title('Imbalance-Contingent Surge Fee')\n", + "\n", + "# LBP weight decay\n", + "ax = axes[1]\n", + "schedule = create_lbp_schedule(\n", + " start_weights=np.array([0.9, 0.1]),\n", + " end_weights=np.array([0.5, 0.5]),\n", + " start_time=0,\n", + " end_time=72,\n", + ")\n", + "times = np.linspace(0, 72, 200)\n", + "w0 = [schedule.get_weight(t, 0) for t in times]\n", + "w1 = [schedule.get_weight(t, 1) for t in times]\n", + "ax.plot(times, w0, 'b-', label='Token weight')\n", + "ax.plot(times, w1, 'r-', label='Collateral weight')\n", + "ax.set_xlabel('Time (hours)')\n", + "ax.set_ylabel('Weight')\n", + "ax.set_title('LBP Weight Decay (72h launch)')\n", + "ax.legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/02_ellipsoid_surface.ipynb b/notebooks/02_ellipsoid_surface.ipynb new file mode 100644 index 0000000..19468b3 --- /dev/null +++ b/notebooks/02_ellipsoid_surface.ipynb @@ -0,0 +1,281 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# N-Dimensional Ellipsoid Bonding Surface\n", + "\n", + "Deep dive into the generalized N-asset bonding surface — the core pricing primitive for MYCO." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "from mpl_toolkits.mplot3d import Axes3D\n", + "from matplotlib import cm\n", + "\n", + "%matplotlib inline\n", + "plt.rcParams['figure.figsize'] = (12, 8)\n", + "plt.rcParams['figure.dpi'] = 100" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.primitives.n_dimensional_surface import (\n", + " NDSurfaceParams, NDSurfaceState,\n", + " create_params, compute_invariant, compute_virtual_offsets,\n", + " mint, redeem, spot_prices,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2D Surface Visualization\n", + "\n", + "Start with 2 assets to visualize the invariant curve." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Create 2D surface with different lambda values\n", + "fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n", + "\n", + "for ax, lambdas, title in [\n", + " (axes[0], np.array([1.0, 1.0]), 'λ = [1, 1] (circular)'),\n", + " (axes[1], np.array([3.0, 1.0]), 'λ = [3, 1] (elliptical)'),\n", + " (axes[2], np.array([5.0, 5.0]), 'λ = [5, 5] (concentrated)'),\n", + "]:\n", + " params = create_params(2, lambdas=lambdas)\n", + " \n", + " # Compute invariant at a reference point\n", + " ref_balances = np.array([1000.0, 1000.0])\n", + " r = compute_invariant(ref_balances, params)\n", + " offsets = compute_virtual_offsets(r, params)\n", + " \n", + " # Trace the invariant curve\n", + " b0_range = np.linspace(100, 2000, 500)\n", + " b1_values = []\n", + " for b0 in b0_range:\n", + " # Find b1 such that invariant = r\n", + " v0 = b0 + offsets[0]\n", + " A = params.A\n", + " # Quadratic in v1: sum of (A @ v)^2 terms involving v1\n", + " # Use numerical search\n", + " from scipy.optimize import brentq\n", + " def f(b1):\n", + " v = np.array([b0 + offsets[0], b1 + offsets[1]])\n", + " return np.sum((A @ v)**2) - r**2\n", + " try:\n", + " b1 = brentq(f, 1, 5000)\n", + " b1_values.append(b1)\n", + " except:\n", + " b1_values.append(np.nan)\n", + " \n", + " ax.plot(b0_range, b1_values, 'b-', linewidth=2)\n", + " ax.plot(1000, 1000, 'ro', markersize=8, label='Reference point')\n", + " ax.set_xlabel('Balance 0')\n", + " ax.set_ylabel('Balance 1')\n", + " ax.set_title(title)\n", + " ax.set_aspect('equal')\n", + " ax.legend()\n", + " ax.grid(True, alpha=0.3)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Minting Dynamics\n", + "\n", + "How token supply grows with deposits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Simulate sequential deposits\n", + "params = create_params(3, lambdas=np.array([2.0, 2.0, 2.0]))\n", + "state = NDSurfaceState(\n", + " balances=np.array([1000.0, 1000.0, 1000.0]),\n", + " invariant=0.0,\n", + " supply=0.0,\n", + ")\n", + "state.invariant = compute_invariant(state.balances, params)\n", + "state.supply = state.invariant\n", + "\n", + "deposits = []\n", + "supplies = [state.supply]\n", + "invariants = [state.invariant]\n", + "prices_history = [spot_prices(state, params)]\n", + "\n", + "for i in range(20):\n", + " # Deposit into different assets to create imbalance\n", + " deposit = np.zeros(3)\n", + " deposit[i % 3] = 200.0 # Rotate deposits\n", + " state, minted = mint(state, params, deposit)\n", + " deposits.append(deposit)\n", + " supplies.append(state.supply)\n", + " invariants.append(state.invariant)\n", + " prices_history.append(spot_prices(state, params))\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n", + "\n", + "axes[0].plot(supplies, 'b-o', markersize=4)\n", + "axes[0].set_xlabel('Deposit #')\n", + "axes[0].set_ylabel('Total supply')\n", + "axes[0].set_title('Supply Growth')\n", + "\n", + "axes[1].plot(invariants, 'r-o', markersize=4)\n", + "axes[1].set_xlabel('Deposit #')\n", + "axes[1].set_ylabel('Invariant r')\n", + "axes[1].set_title('Invariant Growth')\n", + "\n", + "prices_arr = np.array(prices_history)\n", + "for j in range(3):\n", + " axes[2].plot(prices_arr[:, j], '-o', markersize=4, label=f'Asset {j}')\n", + "axes[2].set_xlabel('Deposit #')\n", + "axes[2].set_ylabel('Spot price')\n", + "axes[2].set_title('Spot Prices (rotating deposits)')\n", + "axes[2].legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3D Invariant Surface\n", + "\n", + "For 3 assets, the invariant defines an ellipsoidal surface in balance space." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "params = create_params(3, lambdas=np.array([2.0, 1.5, 1.0]))\n", + "ref = np.array([1000.0, 1000.0, 1000.0])\n", + "r = compute_invariant(ref, params)\n", + "offsets = compute_virtual_offsets(r, params)\n", + "\n", + "# Sample points on the ellipsoid using parametric form\n", + "fig = plt.figure(figsize=(10, 8))\n", + "ax = fig.add_subplot(111, projection='3d')\n", + "\n", + "# Use spherical coordinates to trace the surface\n", + "theta = np.linspace(0.1, np.pi/2 - 0.1, 30)\n", + "phi = np.linspace(0.1, np.pi/2 - 0.1, 30)\n", + "THETA, PHI = np.meshgrid(theta, phi)\n", + "\n", + "# Direction vectors on the positive octant\n", + "d0 = np.sin(THETA) * np.cos(PHI)\n", + "d1 = np.sin(THETA) * np.sin(PHI)\n", + "d2 = np.cos(THETA)\n", + "\n", + "A = params.A\n", + "B0 = np.zeros_like(THETA)\n", + "B1 = np.zeros_like(THETA)\n", + "B2 = np.zeros_like(THETA)\n", + "\n", + "for i in range(THETA.shape[0]):\n", + " for j in range(THETA.shape[1]):\n", + " d = np.array([d0[i,j], d1[i,j], d2[i,j]])\n", + " # Scale d so that |A @ (scale*d)|^2 = r^2\n", + " Ad = A @ d\n", + " scale = r / np.linalg.norm(Ad)\n", + " v = scale * d\n", + " b = v - offsets\n", + " B0[i,j] = max(b[0], 0)\n", + " B1[i,j] = max(b[1], 0)\n", + " B2[i,j] = max(b[2], 0)\n", + "\n", + "ax.plot_surface(B0, B1, B2, cmap=cm.viridis, alpha=0.6)\n", + "ax.scatter([1000], [1000], [1000], color='red', s=100, label='Reference')\n", + "ax.set_xlabel('Balance 0')\n", + "ax.set_ylabel('Balance 1')\n", + "ax.set_zlabel('Balance 2')\n", + "ax.set_title(f'3-Asset Ellipsoid Surface (λ = {list(params.lambdas)})')\n", + "ax.legend()\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Mint/Redeem Symmetry\n", + "\n", + "Verify that minting then redeeming the same amount returns approximately the original state." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "params = create_params(3)\n", + "initial_balances = np.array([1000.0, 1000.0, 1000.0])\n", + "state = NDSurfaceState(\n", + " balances=initial_balances.copy(),\n", + " invariant=compute_invariant(initial_balances, params),\n", + " supply=0.0,\n", + ")\n", + "state.supply = state.invariant\n", + "\n", + "# Mint\n", + "deposit = np.array([100.0, 50.0, 75.0])\n", + "state_after_mint, minted = mint(state, params, deposit)\n", + "print(f'Minted: {minted:.4f} MYCO')\n", + "print(f'Balances after mint: {state_after_mint.balances}')\n", + "\n", + "# Redeem exactly what was minted\n", + "state_after_redeem, amounts_out = redeem(state_after_mint, params, minted)\n", + "print(f'\\nAmounts out: {amounts_out}')\n", + "print(f'Balances after redeem: {state_after_redeem.balances}')\n", + "print(f'\\nBalance difference from original: {state_after_redeem.balances - initial_balances}')\n", + "print(f'Supply difference: {state_after_redeem.supply - state.supply:.6f}')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/03_commitment_channels.ipynb b/notebooks/03_commitment_channels.ipynb new file mode 100644 index 0000000..af31bc4 --- /dev/null +++ b/notebooks/03_commitment_channels.ipynb @@ -0,0 +1,241 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Commitment-Based Issuance Channels\n", + "\n", + "Exploring labor, subscription, and staking issuance — parallel minting paths that don't require financial reserves." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "%matplotlib inline\n", + "plt.rcParams['figure.figsize'] = (12, 5)\n", + "plt.rcParams['figure.dpi'] = 100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Labor Issuance\n", + "\n", + "Proof-of-contribution attestations converted to tokens at governed rates." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.commitments.labor import (\n", + " create_default_system, ContributorState,\n", + " attest_contribution, claim_tokens,\n", + ")\n", + "\n", + "system = create_default_system()\n", + "contributors = {\n", + " 'alice': ContributorState(address='alice'),\n", + " 'bob': ContributorState(address='bob'),\n", + " 'carol': ContributorState(address='carol'),\n", + "}\n", + "\n", + "# Simulate contributions over 30 days\n", + "daily_minted = {name: [] for name in contributors}\n", + "total_minted = {name: 0 for name in contributors}\n", + "days = list(range(30))\n", + "\n", + "for day in days:\n", + " t = float(day)\n", + " # Alice: consistent code contributor\n", + " contributors['alice'] = attest_contribution(system, contributors['alice'], 'code', 5.0, t)\n", + " system, contributors['alice'], tokens = claim_tokens(system, contributors['alice'], 'code', t)\n", + " daily_minted['alice'].append(tokens)\n", + " total_minted['alice'] += tokens\n", + "\n", + " # Bob: governance contributor (less frequent)\n", + " if day % 3 == 0:\n", + " contributors['bob'] = attest_contribution(system, contributors['bob'], 'governance', 3.0, t)\n", + " system, contributors['bob'], tokens = claim_tokens(system, contributors['bob'], 'governance', t)\n", + " else:\n", + " tokens = 0\n", + " daily_minted['bob'].append(tokens)\n", + " total_minted['bob'] += tokens\n", + "\n", + " # Carol: community (high frequency)\n", + " contributors['carol'] = attest_contribution(system, contributors['carol'], 'community', 10.0, t)\n", + " system, contributors['carol'], tokens = claim_tokens(system, contributors['carol'], 'community', t)\n", + " daily_minted['carol'].append(tokens)\n", + " total_minted['carol'] += tokens\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "# Daily minting\n", + "ax = axes[0]\n", + "for name, mints in daily_minted.items():\n", + " cumulative = np.cumsum(mints)\n", + " ax.plot(days, cumulative, '-o', markersize=3, label=name)\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('Cumulative MYCO minted')\n", + "ax.set_title('Labor Issuance Over Time')\n", + "ax.legend()\n", + "\n", + "# Total breakdown\n", + "ax = axes[1]\n", + "names = list(total_minted.keys())\n", + "values = list(total_minted.values())\n", + "colors = ['#2196F3', '#4CAF50', '#FF9800']\n", + "ax.bar(names, values, color=colors)\n", + "ax.set_ylabel('Total MYCO minted')\n", + "ax.set_title('Total Labor Issuance by Contributor')\n", + "for i, v in enumerate(values):\n", + " ax.text(i, v + 5, f'{v:.0f}', ha='center')\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Subscription Issuance\n", + "\n", + "Recurring pledges with growing loyalty multipliers." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.commitments.subscription import (\n", + " SubscriptionSystem, create_subscription, process_payment,\n", + " create_default_tiers, loyalty_multiplier,\n", + ")\n", + "\n", + "tiers = create_default_tiers()\n", + "system = SubscriptionSystem(tiers=tiers)\n", + "\n", + "# Create subscribers at different tiers\n", + "system, _ = create_subscription(system, 'alice', 'supporter', 0.0)\n", + "system, _ = create_subscription(system, 'bob', 'sustainer', 0.0)\n", + "system, _ = create_subscription(system, 'carol', 'patron', 0.0)\n", + "\n", + "# Simulate 12 months of payments\n", + "months = list(range(1, 13))\n", + "monthly_tokens = {name: [] for name in ['alice', 'bob', 'carol']}\n", + "\n", + "for month in months:\n", + " t = month * 30.0\n", + " for name in ['alice', 'bob', 'carol']:\n", + " system, tokens = process_payment(system, name, t)\n", + " monthly_tokens[name].append(tokens)\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "# Monthly tokens by tier\n", + "ax = axes[0]\n", + "for name, label in [('alice', 'Supporter ($10/mo)'), ('bob', 'Sustainer ($25/mo)'), ('carol', 'Patron ($100/mo)')]:\n", + " ax.plot(months, monthly_tokens[name], '-o', markersize=4, label=label)\n", + "ax.set_xlabel('Month')\n", + "ax.set_ylabel('MYCO minted')\n", + "ax.set_title('Monthly Subscription Issuance (grows with loyalty)')\n", + "ax.legend()\n", + "\n", + "# Loyalty multiplier curve\n", + "ax = axes[1]\n", + "days = np.linspace(0, 730, 200)\n", + "multipliers = [loyalty_multiplier(d, max_multiplier=2.0, halflife_days=180) for d in days]\n", + "ax.plot(days / 30, multipliers, 'b-', linewidth=2)\n", + "ax.axhline(y=2.0, color='r', linestyle='--', alpha=0.5, label='Max (2x)')\n", + "ax.axvline(x=6, color='gray', linestyle=':', alpha=0.5, label='Halflife (6 months)')\n", + "ax.set_xlabel('Months subscribed')\n", + "ax.set_ylabel('Loyalty multiplier')\n", + "ax.set_title('Loyalty Multiplier Growth')\n", + "ax.legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Staking Issuance\n", + "\n", + "Lock MYCO for duration T, earn bonus via concave multiplier curve." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.commitments.staking import (\n", + " StakingParams, lockup_multiplier, compute_pending_bonus,\n", + " StakingSystem, create_stake,\n", + ")\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "# Multiplier curves comparison\n", + "ax = axes[0]\n", + "durations = np.linspace(0, 365, 200)\n", + "for curve, color in [('sqrt', 'blue'), ('log', 'green'), ('linear', 'red')]:\n", + " params = StakingParams(multiplier_curve=curve)\n", + " mults = [lockup_multiplier(d, params) for d in durations]\n", + " ax.plot(durations / 30, mults, color=color, linewidth=2, label=f'{curve} curve')\n", + "\n", + "ax.set_xlabel('Lockup duration (months)')\n", + "ax.set_ylabel('Bonus multiplier')\n", + "ax.set_title('Staking Multiplier Curves')\n", + "ax.legend()\n", + "\n", + "# Bonus accumulation\n", + "ax = axes[1]\n", + "params = StakingParams(multiplier_curve='sqrt')\n", + "for lockup, color in [(30, 'blue'), (90, 'green'), (180, 'orange'), (365, 'red')]:\n", + " system = StakingSystem(params=params)\n", + " system, pos = create_stake(system, 'staker', 1000.0, 'MYCO', float(lockup), 0.0)\n", + " times = np.linspace(0, lockup, 100)\n", + " bonuses = [compute_pending_bonus(pos, params, t) for t in times]\n", + " ax.plot(times / 30, bonuses, color=color, linewidth=2, label=f'{lockup}d lockup')\n", + "\n", + "ax.set_xlabel('Time (months)')\n", + "ax.set_ylabel('Pending bonus (MYCO)')\n", + "ax.set_title('Staking Bonus Accumulation (1000 MYCO staked)')\n", + "ax.legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/04_composed_system.ipynb b/notebooks/04_composed_system.ipynb new file mode 100644 index 0000000..1c33968 --- /dev/null +++ b/notebooks/04_composed_system.ipynb @@ -0,0 +1,274 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Composed MYCO System\n", + "\n", + "Full system simulation combining financial deposits, commitment channels, and reserve management." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "%matplotlib inline\n", + "plt.rcParams['figure.figsize'] = (14, 5)\n", + "plt.rcParams['figure.dpi'] = 100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Token Launch Scenario\n", + "\n", + "50 depositors over 30 days, then some redemptions at days 60-90." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.composed.simulator import scenario_token_launch\n", + "\n", + "np.random.seed(42)\n", + "result = scenario_token_launch(n_assets=3, total_raise=100_000, n_depositors=50, duration=90)\n", + "\n", + "fig, axes = plt.subplots(2, 2, figsize=(14, 10))\n", + "\n", + "# Supply\n", + "ax = axes[0, 0]\n", + "ax.plot(result.times, result.supply, 'b-', linewidth=2)\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('MYCO Supply')\n", + "ax.set_title('Token Supply Over Time')\n", + "ax.axvspan(0, 30, alpha=0.1, color='green', label='Deposit phase')\n", + "ax.axvspan(60, 90, alpha=0.1, color='red', label='Redemption phase')\n", + "ax.legend()\n", + "\n", + "# Reserve Value\n", + "ax = axes[0, 1]\n", + "ax.plot(result.times, result.reserve_value, 'g-', linewidth=2)\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('Reserve Value (USD)')\n", + "ax.set_title('Total Reserve Value')\n", + "\n", + "# Backing Ratio\n", + "ax = axes[1, 0]\n", + "ax.plot(result.times, result.backing_ratio, 'r-', linewidth=2)\n", + "ax.axhline(y=1.0, color='k', linestyle='--', alpha=0.5, label='Par (1:1)')\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('Backing Ratio')\n", + "ax.set_title('Backing Ratio (Reserve / Supply)')\n", + "ax.legend()\n", + "\n", + "# Cumulative minted vs redeemed\n", + "ax = axes[1, 1]\n", + "ax.plot(result.times, result.financial_minted, 'b-', label='Financial minted')\n", + "ax.plot(result.times, result.total_redeemed, 'r-', label='Total redeemed')\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('MYCO')\n", + "ax.set_title('Cumulative Minted vs Redeemed')\n", + "ax.legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "print(f'Final supply: {result.supply[-1]:,.2f} MYCO')\n", + "print(f'Final reserve: ${result.reserve_value[-1]:,.2f}')\n", + "print(f'Final backing ratio: {result.backing_ratio[-1]:.4f}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Bank Run Scenario\n", + "\n", + "All holders try to redeem simultaneously. Flow dampening protects remaining holders." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.composed.simulator import scenario_bank_run\n", + "\n", + "result = scenario_bank_run(initial_reserve=100_000, n_assets=3, redemption_fraction=0.05, duration=100)\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n", + "\n", + "ax = axes[0]\n", + "ax.plot(result.times, result.supply, 'b-', linewidth=2)\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('MYCO Supply')\n", + "ax.set_title('Supply During Bank Run')\n", + "ax.axvline(x=10, color='r', linestyle='--', alpha=0.5, label='Run starts')\n", + "ax.legend()\n", + "\n", + "ax = axes[1]\n", + "ax.plot(result.times, result.reserve_value, 'g-', linewidth=2)\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('Reserve Value')\n", + "ax.set_title('Reserve Preservation (flow dampening)')\n", + "ax.axvline(x=10, color='r', linestyle='--', alpha=0.5)\n", + "\n", + "ax = axes[2]\n", + "ax.plot(result.times, result.backing_ratio, 'r-', linewidth=2)\n", + "ax.axhline(y=1.0, color='k', linestyle='--', alpha=0.5)\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('Backing Ratio')\n", + "ax.set_title('Backing Ratio During Bank Run')\n", + "\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "print(f'Reserve preserved: ${result.reserve_value[-1]:,.2f} ({result.reserve_value[-1]/100000*100:.1f}% of initial)')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Mixed Issuance Scenario\n", + "\n", + "Monthly financial deposits + weekly labor contributions over 1 year." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.composed.simulator import scenario_mixed_issuance\n", + "\n", + "result = scenario_mixed_issuance(n_assets=3, duration=365)\n", + "\n", + "fig, axes = plt.subplots(2, 2, figsize=(14, 10))\n", + "\n", + "ax = axes[0, 0]\n", + "ax.plot(result.times, result.supply, 'b-', linewidth=2)\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('MYCO Supply')\n", + "ax.set_title('Total Supply (Financial + Commitment)')\n", + "\n", + "ax = axes[0, 1]\n", + "ax.plot(result.times, result.financial_minted, 'b-', label='Financial')\n", + "ax.plot(result.times, result.commitment_minted, 'g-', label='Commitment')\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('Cumulative MYCO')\n", + "ax.set_title('Financial vs Commitment Issuance')\n", + "ax.legend()\n", + "\n", + "ax = axes[1, 0]\n", + "ax.plot(result.times, result.backing_ratio, 'r-', linewidth=2)\n", + "ax.axhline(y=1.0, color='k', linestyle='--', alpha=0.5)\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('Backing Ratio')\n", + "ax.set_title('Backing Ratio (diluted by commitment minting)')\n", + "\n", + "ax = axes[1, 1]\n", + "ax.plot(result.times, result.imbalance, 'purple', linewidth=2)\n", + "ax.set_xlabel('Day')\n", + "ax.set_ylabel('Imbalance')\n", + "ax.set_title('Reserve Imbalance Over Time')\n", + "\n", + "plt.tight_layout()\n", + "plt.show()\n", + "\n", + "fin = result.financial_minted[-1]\n", + "com = result.commitment_minted[-1]\n", + "total = fin + com\n", + "print(f'Financial minted: {fin:,.2f} ({fin/total*100:.1f}%)')\n", + "print(f'Commitment minted: {com:,.2f} ({com/total*100:.1f}%)')\n", + "print(f'Final backing ratio: {result.backing_ratio[-1]:.4f}')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Interactive: Custom Deposit/Redeem" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.composed.myco_surface import MycoSystem, MycoSystemConfig\n", + "\n", + "system = MycoSystem(MycoSystemConfig(n_reserve_assets=3))\n", + "\n", + "# Series of operations\n", + "operations = [\n", + " ('deposit', np.array([5000.0, 3000.0, 2000.0]), 0),\n", + " ('deposit', np.array([1000.0, 1000.0, 1000.0]), 1),\n", + " ('deposit', np.array([2000.0, 500.0, 500.0]), 2), # Imbalanced\n", + " ('redeem', 500.0, 5),\n", + " ('deposit', np.array([1000.0, 1000.0, 1000.0]), 10),\n", + " ('redeem', 1000.0, 15),\n", + " ('redeem', 2000.0, 16), # Rapid redemptions → flow penalty\n", + "]\n", + "\n", + "log = []\n", + "for op, amount, t in operations:\n", + " if op == 'deposit':\n", + " minted, meta = system.deposit(amount, float(t))\n", + " metrics = system.get_metrics()\n", + " log.append({\n", + " 'time': t, 'op': 'deposit', 'value': float(np.sum(amount)),\n", + " 'minted': minted, 'supply': metrics['supply'],\n", + " 'reserve': metrics['reserve_value'], 'br': metrics['backing_ratio'],\n", + " 'fee': meta.get('fee_rate', 0),\n", + " })\n", + " else:\n", + " amounts_out, meta = system.redeem(amount, float(t))\n", + " metrics = system.get_metrics()\n", + " log.append({\n", + " 'time': t, 'op': 'redeem', 'value': float(np.sum(amounts_out)),\n", + " 'burned': amount, 'supply': metrics['supply'],\n", + " 'reserve': metrics['reserve_value'], 'br': metrics['backing_ratio'],\n", + " 'flow_penalty': meta.get('flow_penalty', 1.0),\n", + " })\n", + "\n", + "# Display log\n", + "print(f'{\"Time\":>5} {\"Op\":>8} {\"Value\":>10} {\"Supply\":>12} {\"Reserve\":>12} {\"BR\":>8} {\"Extra\":>15}')\n", + "print('-' * 75)\n", + "for entry in log:\n", + " extra = ''\n", + " if entry['op'] == 'deposit':\n", + " extra = f'fee={entry[\"fee\"]:.4f}'\n", + " else:\n", + " extra = f'penalty={entry.get(\"flow_penalty\", 1.0):.4f}'\n", + " print(f'{entry[\"time\"]:>5} {entry[\"op\"]:>8} {entry[\"value\"]:>10.2f} {entry[\"supply\"]:>12.2f} {entry[\"reserve\"]:>12.2f} {entry[\"br\"]:>8.4f} {extra:>15}')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/05_stress_scenarios.ipynb b/notebooks/05_stress_scenarios.ipynb new file mode 100644 index 0000000..2619bd7 --- /dev/null +++ b/notebooks/05_stress_scenarios.ipynb @@ -0,0 +1,324 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Stress Scenarios\n", + "\n", + "Bank runs, imbalance attacks, parameter sweeps — testing the system's resilience." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "\n", + "%matplotlib inline\n", + "plt.rcParams['figure.figsize'] = (14, 5)\n", + "plt.rcParams['figure.dpi'] = 100" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Bank Run: Varying Redemption Pressure\n", + "\n", + "How much reserve is preserved under different redemption intensities?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.composed.simulator import scenario_bank_run\n", + "\n", + "fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n", + "\n", + "fractions = [0.02, 0.05, 0.10, 0.20]\n", + "colors = ['blue', 'green', 'orange', 'red']\n", + "\n", + "for frac, color in zip(fractions, colors):\n", + " result = scenario_bank_run(\n", + " initial_reserve=100_000, n_assets=3,\n", + " redemption_fraction=frac, duration=100,\n", + " )\n", + " label = f'{frac*100:.0f}%/step'\n", + " axes[0].plot(result.times, result.reserve_value / 100_000 * 100, color=color, label=label)\n", + " axes[1].plot(result.times, result.supply, color=color, label=label)\n", + " axes[2].plot(result.times, result.backing_ratio, color=color, label=label)\n", + "\n", + "axes[0].set_ylabel('Reserve (% of initial)')\n", + "axes[0].set_title('Reserve Preservation')\n", + "axes[0].legend()\n", + "\n", + "axes[1].set_ylabel('MYCO Supply')\n", + "axes[1].set_title('Supply Decay')\n", + "\n", + "axes[2].set_ylabel('Backing Ratio')\n", + "axes[2].set_title('Backing Ratio')\n", + "axes[2].axhline(y=1.0, color='k', linestyle='--', alpha=0.3)\n", + "\n", + "for ax in axes:\n", + " ax.set_xlabel('Day')\n", + " ax.axvline(x=10, color='gray', linestyle=':', alpha=0.3)\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Flow Dampening Parameter Sweep\n", + "\n", + "How does the flow memory parameter affect protection?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.primitives.flow_dampening import simulate_bank_run as sim_flow\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "for memory, color in [(0.9, 'red'), (0.95, 'orange'), (0.99, 'green'), (0.999, 'blue')]:\n", + " result = sim_flow(initial_reserve=10000, outflow_per_step=500, memory=memory, steps=100)\n", + " axes[0].plot(result['reserve'], color=color, label=f'memory={memory}')\n", + " axes[1].plot(result['penalties'], color=color, label=f'memory={memory}')\n", + "\n", + "axes[0].set_xlabel('Step')\n", + "axes[0].set_ylabel('Reserve')\n", + "axes[0].set_title('Reserve Under Different Memory Parameters')\n", + "axes[0].legend()\n", + "\n", + "axes[1].set_xlabel('Step')\n", + "axes[1].set_ylabel('Penalty multiplier')\n", + "axes[1].set_title('Flow Penalty Over Time')\n", + "axes[1].legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Imbalance Attack\n", + "\n", + "What happens if an attacker repeatedly deposits only one asset?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.composed.myco_surface import MycoSystem, MycoSystemConfig\n", + "from src.primitives.reserve_tranching import current_weights\n", + "\n", + "system = MycoSystem(MycoSystemConfig(n_reserve_assets=3))\n", + "\n", + "# Bootstrap with balanced deposit\n", + "system.deposit(np.array([10000.0, 10000.0, 10000.0]), 0.0)\n", + "\n", + "# Attacker deposits only asset 0\n", + "imbalances = []\n", + "weights_history = []\n", + "fees = []\n", + "minted_per_dollar = []\n", + "\n", + "for i in range(20):\n", + " amount = np.array([1000.0, 0.0, 0.0])\n", + " minted, meta = system.deposit(amount, float(i + 1))\n", + " metrics = system.get_metrics()\n", + " \n", + " imbalances.append(metrics['imbalance'])\n", + " weights_history.append(metrics['reserve_weights'].copy())\n", + " fees.append(meta.get('fee_rate', 0))\n", + " minted_per_dollar.append(minted / 1000.0 if minted > 0 else 0)\n", + "\n", + "fig, axes = plt.subplots(2, 2, figsize=(14, 10))\n", + "\n", + "steps = list(range(1, 21))\n", + "\n", + "ax = axes[0, 0]\n", + "w_arr = np.array(weights_history)\n", + "for j in range(3):\n", + " ax.plot(steps, w_arr[:, j], '-o', markersize=4, label=f'Asset {j}')\n", + "ax.axhline(y=1/3, color='k', linestyle='--', alpha=0.3, label='Target (1/3)')\n", + "ax.set_xlabel('Attacker deposit #')\n", + "ax.set_ylabel('Weight')\n", + "ax.set_title('Reserve Weight Drift')\n", + "ax.legend()\n", + "\n", + "ax = axes[0, 1]\n", + "ax.plot(steps, imbalances, 'r-o', markersize=4)\n", + "ax.set_xlabel('Attacker deposit #')\n", + "ax.set_ylabel('Imbalance')\n", + "ax.set_title('Reserve Imbalance')\n", + "\n", + "ax = axes[1, 0]\n", + "ax.plot(steps, fees, 'purple', marker='o', markersize=4)\n", + "ax.set_xlabel('Attacker deposit #')\n", + "ax.set_ylabel('Fee rate')\n", + "ax.set_title('Surge Fee (penalizes imbalanced deposits)')\n", + "\n", + "ax = axes[1, 1]\n", + "ax.plot(steps, minted_per_dollar, 'g-o', markersize=4)\n", + "ax.set_xlabel('Attacker deposit #')\n", + "ax.set_ylabel('MYCO per $1 deposited')\n", + "ax.set_title('Minting Efficiency (decreasing returns)')\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. P-AMM Parameter Sweep\n", + "\n", + "How do alpha and theta_bar affect redemption behavior?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.primitives.redemption_curve import PAMMParams, PAMMState, compute_redemption_rate\n", + "\n", + "fig, axes = plt.subplots(1, 2, figsize=(14, 5))\n", + "\n", + "ba_range = np.linspace(0.01, 1.5, 200)\n", + "\n", + "# Sweep alpha\n", + "ax = axes[0]\n", + "for alpha, color in [(0.5, 'red'), (1.0, 'orange'), (2.0, 'green'), (5.0, 'blue')]:\n", + " params = PAMMParams(alpha_bar=alpha)\n", + " rates = [compute_redemption_rate(PAMMState(reserve_value=ba*1000, myco_supply=1000), params, 10.0)\n", + " for ba in ba_range]\n", + " ax.plot(ba_range, rates, color=color, linewidth=2, label=f'α={alpha}')\n", + "ax.axhline(y=1.0, color='k', linestyle='--', alpha=0.3)\n", + "ax.set_xlabel('Backing Ratio')\n", + "ax.set_ylabel('Redemption Rate')\n", + "ax.set_title('P-AMM: Effect of α (steepness)')\n", + "ax.legend()\n", + "\n", + "# Sweep theta_bar (floor)\n", + "ax = axes[1]\n", + "for theta, color in [(0.0, 'red'), (0.1, 'orange'), (0.3, 'green'), (0.5, 'blue')]:\n", + " params = PAMMParams(theta_bar=theta)\n", + " rates = [compute_redemption_rate(PAMMState(reserve_value=ba*1000, myco_supply=1000), params, 10.0)\n", + " for ba in ba_range]\n", + " ax.plot(ba_range, rates, color=color, linewidth=2, label=f'θ̄={theta}')\n", + "ax.axhline(y=1.0, color='k', linestyle='--', alpha=0.3)\n", + "ax.set_xlabel('Backing Ratio')\n", + "ax.set_ylabel('Redemption Rate')\n", + "ax.set_title('P-AMM: Effect of θ̄ (floor rate)')\n", + "ax.legend()\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Commitment Dilution Stress Test\n", + "\n", + "What happens if commitment channels mint aggressively?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from src.composed.myco_surface import MycoSystem, MycoSystemConfig\n", + "from src.commitments.labor import ContributorState, attest_contribution\n", + "\n", + "# Compare: commitment cap at 10%, 25%, 50%\n", + "fig, axes = plt.subplots(1, 3, figsize=(18, 5))\n", + "\n", + "for cap, color, label in [(0.1, 'blue', '10% cap'), (0.25, 'orange', '25% cap'), (0.5, 'red', '50% cap')]:\n", + " config = MycoSystemConfig(\n", + " n_reserve_assets=2,\n", + " max_labor_mint_fraction=cap,\n", + " )\n", + " system = MycoSystem(config)\n", + " system.deposit(np.array([10000.0, 10000.0]), 0.0)\n", + "\n", + " supplies = []\n", + " backing_ratios = []\n", + " commitment_pcts = []\n", + "\n", + " contrib = ContributorState(address='heavy_contributor')\n", + " for day in range(1, 91):\n", + " t = float(day)\n", + " contrib = attest_contribution(system.state.labor_system, contrib, 'code', 50.0, t)\n", + " contrib, tokens = system.mint_from_labor(contrib, 'code', t)\n", + " metrics = system.get_metrics()\n", + " supplies.append(metrics['supply'])\n", + " backing_ratios.append(metrics['backing_ratio'])\n", + " total = metrics['financial_minted'] + metrics['commitment_minted']\n", + " commitment_pcts.append(metrics['commitment_minted'] / total * 100 if total > 0 else 0)\n", + "\n", + " days = list(range(1, 91))\n", + " axes[0].plot(days, supplies, color=color, label=label)\n", + " axes[1].plot(days, backing_ratios, color=color, label=label)\n", + " axes[2].plot(days, commitment_pcts, color=color, label=label)\n", + "\n", + "axes[0].set_ylabel('Supply')\n", + "axes[0].set_title('Supply Growth Under Heavy Labor Minting')\n", + "axes[0].legend()\n", + "\n", + "axes[1].set_ylabel('Backing Ratio')\n", + "axes[1].set_title('Backing Ratio Dilution')\n", + "axes[1].axhline(y=1.0, color='k', linestyle='--', alpha=0.3)\n", + "axes[1].legend()\n", + "\n", + "axes[2].set_ylabel('Commitment %')\n", + "axes[2].set_title('Commitment Share of Total Supply')\n", + "axes[2].legend()\n", + "\n", + "for ax in axes:\n", + " ax.set_xlabel('Day')\n", + "\n", + "plt.tight_layout()\n", + "plt.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..85ce2e7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "myco-bonding-curve" +version = "0.1.0" +description = "Multi-asset bonding surfaces with commitment-based issuance for $MYCO" +requires-python = ">=3.11" +dependencies = [ + "numpy>=1.26", + "scipy>=1.12", + "matplotlib>=3.8", + "sympy>=1.13", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0", "jupyter>=1.0", "ipywidgets>=8.0"] + +[build-system] +requires = ["setuptools>=69.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["src*"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] diff --git a/reference/BondingCurveAdapter.sol b/reference/BondingCurveAdapter.sol new file mode 100644 index 0000000..ea98e3d --- /dev/null +++ b/reference/BondingCurveAdapter.sol @@ -0,0 +1,207 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "../MycoBondingCurve.sol"; +import "./interfaces/ISettlement.sol"; + +/** + * @title BondingCurveAdapter + * @notice Bridge between CoW Protocol settlement and MycoBondingCurve. + * + * Called as a pre-interaction hook by solvers during batch settlement. + * The adapter receives tokens from settlement, executes the curve operation, + * and holds output tokens for settlement accounting. + * + * Token flow (buy): + * User USDC → VaultRelayer → Adapter.executeBuyForCoW() → BondingCurve.buy() + * → MYCO minted to Adapter → Settlement → User + * + * Token flow (sell): + * User MYCO → VaultRelayer → Adapter.executeSellForCoW() → BondingCurve.sell() + * → USDC to Adapter → Settlement → User + */ +contract BondingCurveAdapter is ReentrancyGuard, Ownable { + using SafeERC20 for IERC20; + + // =================== + // State + // =================== + + MycoBondingCurve public immutable bondingCurve; + IERC20 public immutable usdc; + IERC20 public immutable mycoToken; + address public immutable settlement; + address public immutable vaultRelayer; + + // =================== + // Events + // =================== + + event CowBuyExecuted(uint256 usdcIn, uint256 mycoOut); + event CowSellExecuted(uint256 mycoIn, uint256 usdcOut); + event TokensRecovered(address token, uint256 amount, address to); + + // =================== + // Errors + // =================== + + error OnlySettlement(); + error ZeroAmount(); + error SlippageExceeded(uint256 got, uint256 minExpected); + error InsufficientBalance(uint256 available, uint256 required); + + // =================== + // Modifiers + // =================== + + modifier onlySettlement() { + if (msg.sender != settlement) revert OnlySettlement(); + _; + } + + // =================== + // Constructor + // =================== + + /** + * @param _bondingCurve Address of MycoBondingCurve + * @param _settlement Address of GPv2Settlement on Base + * @param _usdc Address of USDC + * @param _mycoToken Address of MYCO token + */ + constructor( + address _bondingCurve, + address _settlement, + address _usdc, + address _mycoToken + ) Ownable(msg.sender) { + bondingCurve = MycoBondingCurve(_bondingCurve); + settlement = _settlement; + usdc = IERC20(_usdc); + mycoToken = IERC20(_mycoToken); + vaultRelayer = ISettlement(_settlement).vaultRelayer(); + + // Pre-approve bonding curve to spend our USDC (for buy operations) + IERC20(_usdc).approve(_bondingCurve, type(uint256).max); + // Pre-approve bonding curve to burn our MYCO (for sell operations) + IERC20(_mycoToken).approve(_bondingCurve, type(uint256).max); + // Pre-approve vault relayer to pull output tokens for settlement + IERC20(_usdc).approve(vaultRelayer, type(uint256).max); + IERC20(_mycoToken).approve(vaultRelayer, type(uint256).max); + } + + // =================== + // Execute Functions (called by solvers as pre-interaction) + // =================== + + /** + * @notice Execute a buy on the bonding curve for CoW settlement. + * @dev Called by GPv2Settlement as a pre-interaction hook. + * USDC must already be in this contract (transferred by settlement). + * @param usdcAmount Amount of USDC to spend + * @param minMycoOut Minimum MYCO tokens to receive (slippage protection) + * @return mycoOut Amount of MYCO minted + */ + function executeBuyForCoW( + uint256 usdcAmount, + uint256 minMycoOut + ) external onlySettlement nonReentrant returns (uint256 mycoOut) { + if (usdcAmount == 0) revert ZeroAmount(); + + uint256 usdcBalance = usdc.balanceOf(address(this)); + if (usdcBalance < usdcAmount) { + revert InsufficientBalance(usdcBalance, usdcAmount); + } + + // Execute buy on bonding curve — mints MYCO to this adapter + mycoOut = bondingCurve.buy(usdcAmount, minMycoOut); + + if (mycoOut < minMycoOut) { + revert SlippageExceeded(mycoOut, minMycoOut); + } + + emit CowBuyExecuted(usdcAmount, mycoOut); + } + + /** + * @notice Execute a sell on the bonding curve for CoW settlement. + * @dev Called by GPv2Settlement as a pre-interaction hook. + * MYCO must already be in this contract (transferred by settlement). + * @param mycoAmount Amount of MYCO to sell + * @param minUsdcOut Minimum USDC to receive (slippage protection) + * @return usdcOut Amount of USDC received + */ + function executeSellForCoW( + uint256 mycoAmount, + uint256 minUsdcOut + ) external onlySettlement nonReentrant returns (uint256 usdcOut) { + if (mycoAmount == 0) revert ZeroAmount(); + + uint256 mycoBalance = mycoToken.balanceOf(address(this)); + if (mycoBalance < mycoAmount) { + revert InsufficientBalance(mycoBalance, mycoAmount); + } + + // Execute sell on bonding curve — returns USDC to this adapter + usdcOut = bondingCurve.sell(mycoAmount, minUsdcOut); + + if (usdcOut < minUsdcOut) { + revert SlippageExceeded(usdcOut, minUsdcOut); + } + + emit CowSellExecuted(mycoAmount, usdcOut); + } + + // =================== + // View Functions (used by Watch Tower for quotes) + // =================== + + /** + * @notice Quote a buy: how much MYCO for a given USDC amount + * @param usdcAmount Amount of USDC to spend + * @return mycoOut Expected MYCO output + */ + function quoteBuy(uint256 usdcAmount) external view returns (uint256 mycoOut) { + return bondingCurve.calculateBuyReturn(usdcAmount); + } + + /** + * @notice Quote a sell: how much USDC for a given MYCO amount + * @param mycoAmount Amount of MYCO to sell + * @return usdcOut Expected USDC output (after fees) + */ + function quoteSell(uint256 mycoAmount) external view returns (uint256 usdcOut) { + return bondingCurve.calculateSellReturn(mycoAmount); + } + + /** + * @notice Get the current token price from the bonding curve + * @return price Current price in USDC (6 decimals) + */ + function currentPrice() external view returns (uint256 price) { + return bondingCurve.getCurrentPrice(); + } + + // =================== + // Admin (emergency recovery) + // =================== + + /** + * @notice Recover tokens stuck in the adapter (emergency only) + * @param token Token to recover + * @param amount Amount to recover + * @param to Recipient + */ + function recoverTokens( + address token, + uint256 amount, + address to + ) external onlyOwner { + IERC20(token).safeTransfer(to, amount); + emit TokensRecovered(token, amount, to); + } +} diff --git a/reference/MycoBondingCurve.sol b/reference/MycoBondingCurve.sol new file mode 100644 index 0000000..b49e8fb --- /dev/null +++ b/reference/MycoBondingCurve.sol @@ -0,0 +1,334 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "./MycoToken.sol"; + +/** + * @title MycoBondingCurve + * @notice Polynomial bonding curve for $MYCO token + * @dev Price = basePrice + coefficient * supply^exponent + * + * Users buy/sell $MYCO against USDC through this curve. + * - Buy: Send USDC, receive $MYCO (minted) + * - Sell: Send $MYCO (burned), receive USDC + */ +contract MycoBondingCurve is ReentrancyGuard, Ownable { + using SafeERC20 for IERC20; + + // =================== + // State Variables + // =================== + + /// @notice The $MYCO token + MycoToken public immutable mycoToken; + + /// @notice USDC token (6 decimals on Base) + IERC20 public immutable usdc; + + /// @notice Base price in USDC (6 decimals) - starting price when supply is 0 + uint256 public basePrice; + + /// @notice Coefficient for price growth (6 decimals) + uint256 public coefficient; + + /// @notice Exponent for curve steepness (1 = linear, 2 = quadratic) + uint256 public exponent; + + /// @notice Protocol fee in basis points (100 = 1%) + uint256 public feePercentage; + + /// @notice Treasury address for protocol fees + address public treasury; + + /// @notice Total USDC held in reserve + uint256 public reserveBalance; + + /// @notice Accumulated protocol fees + uint256 public accumulatedFees; + + // =================== + // Events + // =================== + + event TokensPurchased( + address indexed buyer, + uint256 usdcAmount, + uint256 tokensMinted, + uint256 newSupply, + uint256 newPrice + ); + + event TokensSold( + address indexed seller, + uint256 tokensBurned, + uint256 usdcReturned, + uint256 newSupply, + uint256 newPrice + ); + + event FeesWithdrawn(address indexed to, uint256 amount); + event CurveParametersUpdated(uint256 basePrice, uint256 coefficient, uint256 exponent); + event FeePercentageUpdated(uint256 oldFee, uint256 newFee); + event TreasuryUpdated(address indexed oldTreasury, address indexed newTreasury); + + // =================== + // Errors + // =================== + + error ZeroAmount(); + error ZeroAddress(); + error SlippageExceeded(); + error InsufficientReserve(); + error InvalidParameters(); + error FeeTooHigh(); + + // =================== + // Constructor + // =================== + + /** + * @notice Deploy the bonding curve + * @param _mycoToken Address of the $MYCO token + * @param _usdc Address of USDC token + * @param _treasury Treasury address for fees + * @param _basePrice Starting price (6 decimals) + * @param _coefficient Price growth coefficient + * @param _exponent Curve exponent (1 or 2) + * @param _feePercentage Fee in basis points + */ + constructor( + address _mycoToken, + address _usdc, + address _treasury, + uint256 _basePrice, + uint256 _coefficient, + uint256 _exponent, + uint256 _feePercentage + ) Ownable(msg.sender) { + if (_mycoToken == address(0) || _usdc == address(0) || _treasury == address(0)) { + revert ZeroAddress(); + } + if (_exponent == 0 || _exponent > 3) revert InvalidParameters(); + if (_feePercentage > 1000) revert FeeTooHigh(); // Max 10% + + mycoToken = MycoToken(_mycoToken); + usdc = IERC20(_usdc); + treasury = _treasury; + basePrice = _basePrice; + coefficient = _coefficient; + exponent = _exponent; + feePercentage = _feePercentage; + } + + // =================== + // View Functions + // =================== + + /** + * @notice Calculate the current token price + * @return price Current price in USDC (6 decimals) + */ + function getCurrentPrice() public view returns (uint256) { + return getPrice(mycoToken.totalSupply()); + } + + /** + * @notice Calculate price at a given supply + * @param supply Token supply (18 decimals) + * @return price Price in USDC (6 decimals) + */ + function getPrice(uint256 supply) public view returns (uint256) { + // Convert supply from 18 decimals to a reasonable number for calculation + // Using supply in millions of tokens for price calculation + uint256 supplyInMillions = supply / 1e24; // 1e18 * 1e6 = 1 million tokens + + uint256 priceComponent = coefficient; + for (uint256 i = 0; i < exponent; i++) { + priceComponent = priceComponent * supplyInMillions / 1e6; + } + + return basePrice + priceComponent; + } + + /** + * @notice Calculate tokens received for USDC input + * @param usdcAmount Amount of USDC to spend + * @return tokensOut Amount of tokens to receive + */ + function calculateBuyReturn(uint256 usdcAmount) public view returns (uint256 tokensOut) { + if (usdcAmount == 0) return 0; + + uint256 currentSupply = mycoToken.totalSupply(); + uint256 currentPrice = getPrice(currentSupply); + + // Simple approximation: tokens = usdcAmount / averagePrice + // For more accuracy, integrate the curve + uint256 estimatedTokens = (usdcAmount * 1e18) / currentPrice; + + // Calculate ending price and average + uint256 endPrice = getPrice(currentSupply + estimatedTokens); + uint256 avgPrice = (currentPrice + endPrice) / 2; + + tokensOut = (usdcAmount * 1e18) / avgPrice; + } + + /** + * @notice Calculate USDC received for token input + * @param tokenAmount Amount of tokens to sell + * @return usdcOut Amount of USDC to receive (after fees) + */ + function calculateSellReturn(uint256 tokenAmount) public view returns (uint256 usdcOut) { + if (tokenAmount == 0) return 0; + + uint256 currentSupply = mycoToken.totalSupply(); + if (tokenAmount > currentSupply) return 0; + + uint256 currentPrice = getPrice(currentSupply); + uint256 endPrice = getPrice(currentSupply - tokenAmount); + uint256 avgPrice = (currentPrice + endPrice) / 2; + + uint256 grossUsdc = (tokenAmount * avgPrice) / 1e18; + uint256 fee = (grossUsdc * feePercentage) / 10000; + + usdcOut = grossUsdc - fee; + } + + // =================== + // Buy/Sell Functions + // =================== + + /** + * @notice Buy tokens with USDC + * @param usdcAmount Amount of USDC to spend + * @param minTokensOut Minimum tokens to receive (slippage protection) + * @return tokensMinted Amount of tokens minted + */ + function buy(uint256 usdcAmount, uint256 minTokensOut) external nonReentrant returns (uint256 tokensMinted) { + if (usdcAmount == 0) revert ZeroAmount(); + + tokensMinted = calculateBuyReturn(usdcAmount); + if (tokensMinted < minTokensOut) revert SlippageExceeded(); + + // Transfer USDC from buyer + usdc.safeTransferFrom(msg.sender, address(this), usdcAmount); + reserveBalance += usdcAmount; + + // Mint tokens to buyer + mycoToken.mint(msg.sender, tokensMinted); + + emit TokensPurchased( + msg.sender, + usdcAmount, + tokensMinted, + mycoToken.totalSupply(), + getCurrentPrice() + ); + } + + /** + * @notice Sell tokens for USDC + * @param tokenAmount Amount of tokens to sell + * @param minUsdcOut Minimum USDC to receive (slippage protection) + * @return usdcReturned Amount of USDC returned + */ + function sell(uint256 tokenAmount, uint256 minUsdcOut) external nonReentrant returns (uint256 usdcReturned) { + if (tokenAmount == 0) revert ZeroAmount(); + + uint256 currentSupply = mycoToken.totalSupply(); + uint256 currentPrice = getPrice(currentSupply); + uint256 endPrice = getPrice(currentSupply - tokenAmount); + uint256 avgPrice = (currentPrice + endPrice) / 2; + + uint256 grossUsdc = (tokenAmount * avgPrice) / 1e18; + uint256 fee = (grossUsdc * feePercentage) / 10000; + usdcReturned = grossUsdc - fee; + + if (usdcReturned < minUsdcOut) revert SlippageExceeded(); + if (usdcReturned > reserveBalance) revert InsufficientReserve(); + + // Burn tokens from seller + mycoToken.burnFrom(msg.sender, tokenAmount); + + // Update reserves and fees + reserveBalance -= grossUsdc; + accumulatedFees += fee; + + // Transfer USDC to seller + usdc.safeTransfer(msg.sender, usdcReturned); + + emit TokensSold( + msg.sender, + tokenAmount, + usdcReturned, + mycoToken.totalSupply(), + getCurrentPrice() + ); + } + + // =================== + // Admin Functions + // =================== + + /** + * @notice Withdraw accumulated fees to treasury + */ + function withdrawFees() external { + uint256 fees = accumulatedFees; + if (fees == 0) revert ZeroAmount(); + + accumulatedFees = 0; + usdc.safeTransfer(treasury, fees); + + emit FeesWithdrawn(treasury, fees); + } + + /** + * @notice Update curve parameters (only owner) + * @param _basePrice New base price + * @param _coefficient New coefficient + * @param _exponent New exponent + */ + function updateCurveParameters( + uint256 _basePrice, + uint256 _coefficient, + uint256 _exponent + ) external onlyOwner { + if (_exponent == 0 || _exponent > 3) revert InvalidParameters(); + + basePrice = _basePrice; + coefficient = _coefficient; + exponent = _exponent; + + emit CurveParametersUpdated(_basePrice, _coefficient, _exponent); + } + + /** + * @notice Update fee percentage (only owner) + * @param _feePercentage New fee in basis points + */ + function updateFeePercentage(uint256 _feePercentage) external onlyOwner { + if (_feePercentage > 1000) revert FeeTooHigh(); + + uint256 oldFee = feePercentage; + feePercentage = _feePercentage; + + emit FeePercentageUpdated(oldFee, _feePercentage); + } + + /** + * @notice Update treasury address (only owner) + * @param _treasury New treasury address + */ + function updateTreasury(address _treasury) external onlyOwner { + if (_treasury == address(0)) revert ZeroAddress(); + + address oldTreasury = treasury; + treasury = _treasury; + + emit TreasuryUpdated(oldTreasury, _treasury); + } +} diff --git a/reference/MycoConditionalOrder.sol b/reference/MycoConditionalOrder.sol new file mode 100644 index 0000000..748b71b --- /dev/null +++ b/reference/MycoConditionalOrder.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import "./interfaces/GPv2Order.sol"; +import "./interfaces/IConditionalOrder.sol"; +import "./BondingCurveAdapter.sol"; + +/** + * @title MycoConditionalOrder + * @notice ComposableCoW handler for $MYCO bonding curve trades. + * + * Implements IConditionalOrder.getTradeableOrder() to generate GPv2Order.Data + * from static input (order parameters) and offchain input (live quotes from + * the cow-service Watch Tower integration). + * + * Stateless — all order parameters are encoded in staticInput. + * The Watch Tower polls this handler to discover executable orders. + */ +contract MycoConditionalOrder is IConditionalOrder { + // =================== + // Types + // =================== + + enum TradeDirection { + BUY, // USDC → MYCO + SELL // MYCO → USDC + } + + /// @dev Static input encoded per-order. Set once when creating the conditional order. + struct OrderData { + TradeDirection direction; + uint256 inputAmount; // USDC amount (buy) or MYCO amount (sell) + uint256 minOutputAmount; // Minimum output (slippage floor) + address receiver; // Recipient of output tokens + uint256 validityDuration; // Seconds from now the order remains valid + } + + /// @dev Offchain input provided by the Watch Tower at poll time. + struct OffchainData { + uint256 quotedOutput; // Live quote from adapter.quoteBuy/quoteSell + uint32 validTo; // Timestamp when this quote expires + } + + // =================== + // Immutables + // =================== + + BondingCurveAdapter public immutable adapter; + address public immutable usdcToken; + address public immutable mycoTokenAddr; + + /// @dev App data hash identifying orders from this handler. + bytes32 public constant APP_DATA = keccak256("myco-bonding-curve-v1"); + + // =================== + // Constructor + // =================== + + constructor( + address _adapter, + address _usdcToken, + address _mycoToken + ) { + adapter = BondingCurveAdapter(_adapter); + usdcToken = _usdcToken; + mycoTokenAddr = _mycoToken; + } + + // =================== + // IConditionalOrder + // =================== + + /// @inheritdoc IConditionalOrder + function getTradeableOrder( + address owner, + address, + bytes calldata staticInput, + bytes calldata offchainInput + ) external view override returns (GPv2Order.Data memory order) { + OrderData memory params = abi.decode(staticInput, (OrderData)); + OffchainData memory quote = abi.decode(offchainInput, (OffchainData)); + + // Validate quote hasn't expired + if (block.timestamp > quote.validTo) { + revert OrderNotValid("Quote expired"); + } + + // Validate quoted output meets minimum + if (quote.quotedOutput < params.minOutputAmount) { + revert OrderNotValid("Quote below minimum output"); + } + + // Determine receiver (default to owner if not set) + address receiver = params.receiver == address(0) ? owner : params.receiver; + + if (params.direction == TradeDirection.BUY) { + // Buy: sell USDC, buy MYCO + uint256 balance = IERC20(usdcToken).balanceOf(owner); + if (balance < params.inputAmount) { + revert PollTryNextBlock("Insufficient USDC balance"); + } + + order = GPv2Order.Data({ + sellToken: usdcToken, + buyToken: mycoTokenAddr, + receiver: receiver, + sellAmount: params.inputAmount, + buyAmount: quote.quotedOutput, + validTo: quote.validTo, + appData: APP_DATA, + feeAmount: 0, + kind: GPv2Order.KIND_SELL, + partiallyFillable: false, + sellTokenBalance: GPv2Order.BALANCE_ERC20, + buyTokenBalance: GPv2Order.BALANCE_ERC20 + }); + } else { + // Sell: sell MYCO, buy USDC + uint256 balance = IERC20(mycoTokenAddr).balanceOf(owner); + if (balance < params.inputAmount) { + revert PollTryNextBlock("Insufficient MYCO balance"); + } + + order = GPv2Order.Data({ + sellToken: mycoTokenAddr, + buyToken: usdcToken, + receiver: receiver, + sellAmount: params.inputAmount, + buyAmount: quote.quotedOutput, + validTo: quote.validTo, + appData: APP_DATA, + feeAmount: 0, + kind: GPv2Order.KIND_SELL, + partiallyFillable: false, + sellTokenBalance: GPv2Order.BALANCE_ERC20, + buyTokenBalance: GPv2Order.BALANCE_ERC20 + }); + } + } + + /// @inheritdoc IConditionalOrder + function verify( + address owner, + address sender, + bytes32, + bytes32, + bytes calldata staticInput, + bytes calldata offchainInput, + GPv2Order.Data calldata + ) external view override { + this.getTradeableOrder(owner, sender, staticInput, offchainInput); + } +} diff --git a/reference/MycoToken.sol b/reference/MycoToken.sol new file mode 100644 index 0000000..f3b1e14 --- /dev/null +++ b/reference/MycoToken.sol @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +/** + * @title MycoToken + * @notice $MYCO token for the MycoFi ecosystem + * @dev Mintable/burnable only by the bonding curve contract + */ +contract MycoToken is ERC20, ERC20Burnable, Ownable { + /// @notice Address of the bonding curve contract (only minter/burner) + address public bondingCurve; + + /// @notice Emitted when bonding curve address is updated + event BondingCurveUpdated(address indexed oldCurve, address indexed newCurve); + + error OnlyBondingCurve(); + error ZeroAddress(); + + constructor() ERC20("MycoFi", "MYCO") Ownable(msg.sender) {} + + /** + * @notice Set the bonding curve contract address + * @param _bondingCurve Address of the bonding curve contract + */ + function setBondingCurve(address _bondingCurve) external onlyOwner { + if (_bondingCurve == address(0)) revert ZeroAddress(); + + address oldCurve = bondingCurve; + bondingCurve = _bondingCurve; + + emit BondingCurveUpdated(oldCurve, _bondingCurve); + } + + /** + * @notice Mint tokens (only callable by bonding curve) + * @param to Recipient address + * @param amount Amount to mint + */ + function mint(address to, uint256 amount) external { + if (msg.sender != bondingCurve) revert OnlyBondingCurve(); + _mint(to, amount); + } + + /** + * @notice Burn tokens from sender (only callable by bonding curve) + * @param from Address to burn from + * @param amount Amount to burn + */ + function burnFrom(address from, uint256 amount) public override { + if (msg.sender != bondingCurve) { + // Standard burnFrom with allowance check + super.burnFrom(from, amount); + } else { + // Bonding curve can burn without allowance + _burn(from, amount); + } + } +} diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/commitments/__init__.py b/src/commitments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/commitments/labor.py b/src/commitments/labor.py new file mode 100644 index 0000000..552b084 --- /dev/null +++ b/src/commitments/labor.py @@ -0,0 +1,172 @@ +"""Proof-of-contribution labor issuance channel. + +Non-financial minting: verified labor/contribution units are converted +to $MYCO at a governed rate, subject to rate limits (flow dampening). + +Design: +- An attestation system (oracle, DAO vote, or peer review) reports + "contribution units" for a contributor +- Each unit converts to $MYCO at a governed rate (tokens_per_unit) +- Rate limits prevent gaming: max units per period, cooldown between claims +- Contribution types have different conversion rates (code, governance, community) + +This is the "subscription-based bonding curve" concept from the MycoFi paper, +extended to general labor contributions. +""" + +import numpy as np +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class ContributionType: + """A type of contribution with its conversion rate.""" + name: str + tokens_per_unit: float # $MYCO per contribution unit + max_units_per_period: float # Rate limit per period + period_length: float # Length of rate-limit period (time units) + cooldown: float = 0.0 # Minimum time between claims + decay_rate: float = 0.0 # Annual decay of unclaimed units (use-it-or-lose-it) + + +@dataclass +class ContributorState: + """State for a single contributor.""" + address: str + unclaimed_units: dict[str, float] = field(default_factory=dict) + claimed_this_period: dict[str, float] = field(default_factory=dict) + period_start: dict[str, float] = field(default_factory=dict) + last_claim_time: dict[str, float] = field(default_factory=dict) + total_claimed: float = 0.0 + + +@dataclass +class LaborIssuanceSystem: + """The labor issuance channel.""" + contribution_types: dict[str, ContributionType] + global_rate_multiplier: float = 1.0 # Governance can scale all rates + total_minted: float = 0.0 + max_total_mint: float = float("inf") # Cap on total labor-minted tokens + + +def attest_contribution( + system: LaborIssuanceSystem, + contributor: ContributorState, + contribution_type: str, + units: float, + current_time: float, +) -> ContributorState: + """Record a contribution attestation. + + Called by the oracle/attestation layer when a contribution is verified. + Does NOT mint tokens — just records units for later claiming. + """ + assert contribution_type in system.contribution_types + assert units > 0 + + ct = system.contribution_types[contribution_type] + + # Apply decay to existing unclaimed units + if contribution_type in contributor.unclaimed_units: + last_time = contributor.last_claim_time.get(contribution_type, current_time) + elapsed = current_time - last_time + if ct.decay_rate > 0 and elapsed > 0: + decay = np.exp(-ct.decay_rate * elapsed) + contributor.unclaimed_units[contribution_type] *= decay + + # Add new units + current = contributor.unclaimed_units.get(contribution_type, 0.0) + contributor.unclaimed_units[contribution_type] = current + units + + return contributor + + +def claim_tokens( + system: LaborIssuanceSystem, + contributor: ContributorState, + contribution_type: str, + current_time: float, + units_to_claim: float | None = None, +) -> tuple[LaborIssuanceSystem, ContributorState, float]: + """Claim $MYCO for verified contributions. + + Returns (updated_system, updated_contributor, tokens_minted). + """ + assert contribution_type in system.contribution_types + ct = system.contribution_types[contribution_type] + + # Check cooldown + last_claim = contributor.last_claim_time.get(contribution_type, -float("inf")) + if current_time - last_claim < ct.cooldown: + return system, contributor, 0.0 + + # Available units + available = contributor.unclaimed_units.get(contribution_type, 0.0) + if available <= 0: + return system, contributor, 0.0 + + # Check rate limit (reset period if needed) + period_start = contributor.period_start.get(contribution_type, current_time) + if current_time - period_start >= ct.period_length: + # New period + contributor.claimed_this_period[contribution_type] = 0.0 + contributor.period_start[contribution_type] = current_time + + claimed_so_far = contributor.claimed_this_period.get(contribution_type, 0.0) + remaining_allowance = ct.max_units_per_period - claimed_so_far + + # Determine how many units to claim + if units_to_claim is not None: + to_claim = min(units_to_claim, available, remaining_allowance) + else: + to_claim = min(available, remaining_allowance) + + if to_claim <= 0: + return system, contributor, 0.0 + + # Compute tokens + tokens = to_claim * ct.tokens_per_unit * system.global_rate_multiplier + + # Check global cap + tokens = min(tokens, system.max_total_mint - system.total_minted) + if tokens <= 0: + return system, contributor, 0.0 + + # Update state + contributor.unclaimed_units[contribution_type] -= to_claim + contributor.claimed_this_period[contribution_type] = claimed_so_far + to_claim + contributor.last_claim_time[contribution_type] = current_time + contributor.total_claimed += tokens + system.total_minted += tokens + + return system, contributor, tokens + + +def create_default_system() -> LaborIssuanceSystem: + """Create a default labor issuance system with common contribution types.""" + types = { + "code": ContributionType( + name="Code Contribution", + tokens_per_unit=10.0, # 10 MYCO per merged PR equivalent + max_units_per_period=50.0, # Max 50 units per period + period_length=30.0, # 30-day periods + cooldown=1.0, # 1-day cooldown between claims + ), + "governance": ContributionType( + name="Governance Participation", + tokens_per_unit=5.0, + max_units_per_period=20.0, + period_length=30.0, + cooldown=7.0, # Weekly claims + ), + "community": ContributionType( + name="Community Building", + tokens_per_unit=3.0, + max_units_per_period=100.0, + period_length=30.0, + cooldown=1.0, + decay_rate=0.1, # Unclaimed units decay + ), + } + return LaborIssuanceSystem(contribution_types=types) diff --git a/src/commitments/staking.py b/src/commitments/staking.py new file mode 100644 index 0000000..783ea66 --- /dev/null +++ b/src/commitments/staking.py @@ -0,0 +1,224 @@ +"""Time-weighted staking issuance channel. + +Locking existing $MYCO or approved assets for a duration earns bonus +$MYCO minting. Longer lockup = higher multiplier. + +Design: +- User locks tokens (MYCO or approved ERC20) for T time units +- At unlock (or continuously), bonus MYCO is minted: + bonus = staked_value * base_rate * lockup_multiplier(T) +- lockup_multiplier is a concave function: early duration gains a lot, + marginal benefit decreases (sqrt or log curve) +- Early withdrawal forfeits unvested bonus (vesting schedule) + +This creates demand-side pressure on MYCO (lock to earn more) while +adding a time-commitment dimension beyond pure financial reserve. +""" + +import numpy as np +from dataclasses import dataclass, field + + +@dataclass +class StakingParams: + """Parameters for the staking channel.""" + base_rate: float = 0.05 # 5% APR base + max_multiplier: float = 3.0 # Max 3x for longest lockup + max_lockup_duration: float = 365.0 # 1 year max lockup (in time units) + min_lockup_duration: float = 7.0 # 1 week minimum + multiplier_curve: str = "sqrt" # "sqrt", "log", or "linear" + early_withdrawal_penalty: float = 0.5 # Forfeit 50% of unvested bonus + vesting_period: float = 30.0 # Bonus vests over 30 days after unlock + + +@dataclass +class StakePosition: + """An active staking position.""" + staker: str + amount: float # Amount staked (in USD value terms) + token: str # What was staked (e.g., "MYCO", "USDC") + lockup_duration: float # Committed lockup (time units) + start_time: float + end_time: float # = start_time + lockup_duration + bonus_earned: float = 0.0 # Accumulated bonus tokens + bonus_claimed: float = 0.0 + is_active: bool = True + early_withdrawn: bool = False + + +@dataclass +class StakingSystem: + """The staking issuance channel.""" + params: StakingParams + positions: dict[str, StakePosition] = field(default_factory=dict) + total_staked: float = 0.0 + total_bonus_minted: float = 0.0 + + +def lockup_multiplier(duration: float, params: StakingParams) -> float: + """Compute the lockup multiplier for a given duration. + + Concave function: early duration gives large benefit, diminishing returns. + + sqrt: mult = 1 + (max-1) * sqrt(duration / max_duration) + log: mult = 1 + (max-1) * log(1 + duration) / log(1 + max_duration) + linear: mult = 1 + (max-1) * duration / max_duration + """ + clamped = min(duration, params.max_lockup_duration) + normalized = clamped / params.max_lockup_duration + + if params.multiplier_curve == "sqrt": + factor = np.sqrt(normalized) + elif params.multiplier_curve == "log": + factor = np.log1p(clamped) / np.log1p(params.max_lockup_duration) + elif params.multiplier_curve == "linear": + factor = normalized + else: + factor = normalized + + return 1.0 + (params.max_multiplier - 1.0) * factor + + +def create_stake( + system: StakingSystem, + staker: str, + amount: float, + token: str, + lockup_duration: float, + current_time: float, +) -> tuple[StakingSystem, StakePosition]: + """Create a new staking position.""" + params = system.params + assert lockup_duration >= params.min_lockup_duration + assert lockup_duration <= params.max_lockup_duration + assert amount > 0 + + position = StakePosition( + staker=staker, + amount=amount, + token=token, + lockup_duration=lockup_duration, + start_time=current_time, + end_time=current_time + lockup_duration, + ) + + system.positions[staker] = position + system.total_staked += amount + return system, position + + +def compute_pending_bonus( + position: StakePosition, params: StakingParams, current_time: float +) -> float: + """Compute how much bonus has been earned so far. + + Bonus accrues linearly over the lockup period: + bonus_rate = base_rate * lockup_multiplier(duration) + total_bonus = amount * bonus_rate * (duration / 365) + earned_so_far = total_bonus * min(elapsed / lockup_duration, 1) + """ + if not position.is_active: + return position.bonus_earned - position.bonus_claimed + + mult = lockup_multiplier(position.lockup_duration, params) + total_bonus = position.amount * params.base_rate * mult * ( + position.lockup_duration / 365.0 + ) + + elapsed = min(current_time - position.start_time, position.lockup_duration) + progress = elapsed / position.lockup_duration + earned = total_bonus * progress + + return earned - position.bonus_claimed + + +def claim_bonus( + system: StakingSystem, + staker: str, + current_time: float, +) -> tuple[StakingSystem, float]: + """Claim vested staking bonus. + + Bonus is only claimable after the lockup ends (or pro-rata if + the lockup has passed). + """ + position = system.positions.get(staker) + if position is None or not position.is_active: + return system, 0.0 + + pending = compute_pending_bonus(position, system.params, current_time) + + if current_time < position.end_time: + # Still locked — can't claim yet + return system, 0.0 + + # Claimable + tokens = max(0.0, pending) + position.bonus_claimed += tokens + position.bonus_earned = position.bonus_claimed + system.total_bonus_minted += tokens + + return system, tokens + + +def early_withdraw( + system: StakingSystem, + staker: str, + current_time: float, +) -> tuple[StakingSystem, float, float]: + """Withdraw early with penalty. + + Returns (system, staked_amount_returned, bonus_forfeited). + """ + position = system.positions.get(staker) + if position is None or not position.is_active: + return system, 0.0, 0.0 + + pending = compute_pending_bonus(position, system.params, current_time) + forfeited = pending * system.params.early_withdrawal_penalty + tokens_received = max(0.0, pending - forfeited) + + position.is_active = False + position.early_withdrawn = True + position.bonus_earned = tokens_received + position.bonus_claimed = tokens_received + + system.total_staked -= position.amount + system.total_bonus_minted += tokens_received + + return system, position.amount, forfeited + + +def simulate_staking( + params: StakingParams, + stakers: list[tuple[str, float, float]], # (name, amount, lockup_days) + duration: float, + dt: float = 1.0, +) -> dict[str, np.ndarray]: + """Simulate staking positions over time. + + Returns time series of total_staked, total_bonus, and individual positions. + """ + system = StakingSystem(params=params) + n_steps = int(duration / dt) + + times = np.zeros(n_steps) + total_staked = np.zeros(n_steps) + total_bonus = np.zeros(n_steps) + + # Create all stakes at t=0 + for name, amount, lockup in stakers: + system, _ = create_stake(system, name, amount, "MYCO", lockup, 0.0) + + for step in range(n_steps): + t = step * dt + times[step] = t + + # Try to claim bonuses + for name, _, _ in stakers: + system, _ = claim_bonus(system, name, t) + + total_staked[step] = system.total_staked + total_bonus[step] = system.total_bonus_minted + + return {"times": times, "total_staked": total_staked, "total_bonus": total_bonus} diff --git a/src/commitments/subscription.py b/src/commitments/subscription.py new file mode 100644 index 0000000..105c6c7 --- /dev/null +++ b/src/commitments/subscription.py @@ -0,0 +1,184 @@ +"""Subscription-based continuous issuance channel. + +From the MycoFi paper: "subscription-based bonding curves" where recurring +small payments create a continuous stream of token minting. + +Design: +- Subscribers commit to a recurring payment (e.g., $10/month in USDC) +- Each payment period, committed amount is pulled and $MYCO is minted +- Longer subscription history earns a loyalty multiplier +- Subscriptions can be paused/cancelled with no penalty +- The subscription flow feeds into the reserve as a predictable inflow + +This creates a stable, predictable minting channel separate from the +volatile spot-bonding-curve market. +""" + +import numpy as np +from dataclasses import dataclass, field + + +@dataclass +class SubscriptionTier: + """A subscription tier with its minting rate.""" + name: str + payment_per_period: float # USD per period + period_length: float # Time units per period + base_mint_rate: float # $MYCO per USD at base rate + loyalty_multiplier_max: float = 1.5 # Max loyalty bonus + loyalty_halflife: float = 180.0 # Days to reach 50% of max bonus + + +@dataclass +class Subscription: + """An active subscription.""" + subscriber: str + tier: str + start_time: float + last_payment_time: float + total_paid: float = 0.0 + total_minted: float = 0.0 + periods_paid: int = 0 + is_active: bool = True + + +@dataclass +class SubscriptionSystem: + """The subscription issuance channel.""" + tiers: dict[str, SubscriptionTier] + subscriptions: dict[str, Subscription] = field(default_factory=dict) + total_minted: float = 0.0 + total_revenue: float = 0.0 + + +def create_subscription( + system: SubscriptionSystem, + subscriber: str, + tier: str, + current_time: float, +) -> tuple[SubscriptionSystem, Subscription]: + """Create a new subscription.""" + assert tier in system.tiers + sub = Subscription( + subscriber=subscriber, + tier=tier, + start_time=current_time, + last_payment_time=current_time, + ) + system.subscriptions[subscriber] = sub + return system, sub + + +def process_payment( + system: SubscriptionSystem, + subscriber: str, + current_time: float, +) -> tuple[SubscriptionSystem, float]: + """Process a subscription payment and mint tokens. + + Returns (updated_system, tokens_minted). + """ + sub = system.subscriptions.get(subscriber) + if sub is None or not sub.is_active: + return system, 0.0 + + tier = system.tiers[sub.tier] + + # Check if a period has elapsed + elapsed = current_time - sub.last_payment_time + if elapsed < tier.period_length: + return system, 0.0 + + # How many periods to process + periods = int(elapsed / tier.period_length) + if periods == 0: + return system, 0.0 + + total_tokens = 0.0 + for _ in range(periods): + # Loyalty multiplier: grows with subscription duration + duration = current_time - sub.start_time + loyalty = 1.0 + (tier.loyalty_multiplier_max - 1.0) * ( + 1.0 - np.exp(-0.693 * duration / tier.loyalty_halflife) + ) + + # Mint tokens + tokens = tier.payment_per_period * tier.base_mint_rate * loyalty + total_tokens += tokens + + sub.total_paid += tier.payment_per_period + sub.total_minted += tokens + sub.periods_paid += 1 + + sub.last_payment_time += periods * tier.period_length + system.total_minted += total_tokens + system.total_revenue += periods * tier.payment_per_period + + return system, total_tokens + + +def cancel_subscription( + system: SubscriptionSystem, + subscriber: str, +) -> SubscriptionSystem: + """Cancel a subscription (no penalty).""" + if subscriber in system.subscriptions: + system.subscriptions[subscriber].is_active = False + return system + + +def simulate_subscriptions( + system: SubscriptionSystem, + duration: float, + dt: float = 1.0, +) -> dict[str, np.ndarray]: + """Simulate all subscriptions over a time period. + + Returns time series of total revenue and total minted. + """ + n_steps = int(duration / dt) + times = np.zeros(n_steps) + revenues = np.zeros(n_steps) + minted = np.zeros(n_steps) + + for step in range(n_steps): + t = step * dt + times[step] = t + + for subscriber in list(system.subscriptions.keys()): + system, _ = process_payment(system, subscriber, t) + + revenues[step] = system.total_revenue + minted[step] = system.total_minted + + return {"times": times, "revenues": revenues, "minted": minted} + + +def create_default_tiers() -> dict[str, SubscriptionTier]: + """Create default subscription tiers.""" + return { + "supporter": SubscriptionTier( + name="Supporter", + payment_per_period=10.0, + period_length=30.0, + base_mint_rate=1.5, # 1.5x vs spot (subscriber bonus) + loyalty_multiplier_max=1.3, + loyalty_halflife=180.0, + ), + "sustainer": SubscriptionTier( + name="Sustainer", + payment_per_period=50.0, + period_length=30.0, + base_mint_rate=1.8, # Better rate for higher commitment + loyalty_multiplier_max=1.5, + loyalty_halflife=120.0, + ), + "patron": SubscriptionTier( + name="Patron", + payment_per_period=200.0, + period_length=30.0, + base_mint_rate=2.0, + loyalty_multiplier_max=2.0, + loyalty_halflife=90.0, + ), + } diff --git a/src/composed/__init__.py b/src/composed/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/composed/myco_surface.py b/src/composed/myco_surface.py new file mode 100644 index 0000000..c8364c0 --- /dev/null +++ b/src/composed/myco_surface.py @@ -0,0 +1,394 @@ +"""Composed MYCO Bonding Surface. + +Assembles all primitives into the full $MYCO token issuance system: +- N-dimensional ellipsoidal bonding surface for financial reserves +- Commitment-based issuance (labor, subscription, staking) +- Reserve tranching with target weights and safety checks +- P-AMM redemption curve with flow dampening +- Imbalance fees and dynamic weights +""" + +import numpy as np +from numpy.typing import NDArray +from dataclasses import dataclass, field + +from src.primitives.n_dimensional_surface import ( + NDSurfaceParams, NDSurfaceState, + create_params as create_surface_params, + compute_invariant, mint as surface_mint, redeem as surface_redeem, +) +from src.primitives.reserve_tranching import ( + VaultMetadata, Vault, ReserveState, + is_safe_to_mint, is_safe_to_redeem, + optimal_deposit_split, optimal_withdrawal_split, + current_weights, target_weights, +) +from src.primitives.redemption_curve import ( + PAMMParams, PAMMState, + compute_redemption_rate, redeem as pamm_redeem, +) +from src.primitives.flow_dampening import ( + FlowTracker, update_flow, flow_penalty_multiplier, +) +from src.primitives.imbalance_fees import ( + compute_imbalance, surge_fee, compute_fee_adjusted_output, +) +from src.primitives.dynamic_weights import GradualWeightSchedule +from src.commitments.labor import ( + LaborIssuanceSystem, ContributorState, + attest_contribution, claim_tokens, create_default_system, +) +from src.commitments.subscription import ( + SubscriptionSystem, create_subscription, process_payment, + create_default_tiers, +) +from src.commitments.staking import ( + StakingParams, StakingSystem, + create_stake, claim_bonus, +) + + +@dataclass +class MycoSystemConfig: + """Configuration for the complete MYCO system.""" + # Bonding surface + n_reserve_assets: int = 3 + surface_lambdas: NDArray | None = None + surface_Q: NDArray | None = None + + # Reserve tranching + vault_names: list[str] = field(default_factory=lambda: ["USDC", "ETH", "DAI"]) + initial_target_weights: NDArray | None = None + + # Redemption + pamm_params: PAMMParams = field(default_factory=PAMMParams) + + # Flow dampening + flow_memory: float = 0.999 + flow_threshold: float = 0.1 + + # Imbalance fees + static_fee: float = 0.003 + surge_fee_rate: float = 0.05 + imbalance_threshold: float = 0.2 + + # Commitment caps + max_labor_mint_fraction: float = 0.1 # Max 10% of supply from labor + max_subscription_mint_fraction: float = 0.1 + max_staking_bonus_fraction: float = 0.05 + + +@dataclass +class MycoSystemState: + """Full state of the MYCO system.""" + # Core + supply: float = 0.0 + time: float = 0.0 + + # Bonding surface + surface_state: NDSurfaceState | None = None + surface_params: NDSurfaceParams | None = None + + # Reserve + reserve_state: ReserveState | None = None + + # Redemption + pamm_state: PAMMState | None = None + + # Flow + flow_tracker: FlowTracker | None = None + + # Commitments + labor_system: LaborIssuanceSystem | None = None + subscription_system: SubscriptionSystem | None = None + staking_system: StakingSystem | None = None + + # Metrics + total_financial_minted: float = 0.0 + total_commitment_minted: float = 0.0 + total_redeemed: float = 0.0 + + +class MycoSystem: + """The complete MYCO bonding surface system.""" + + def __init__(self, config: MycoSystemConfig): + self.config = config + + # Initialize bonding surface + n = config.n_reserve_assets + self.surface_params = create_surface_params( + n, lambdas=config.surface_lambdas, Q=config.surface_Q, + ) + + # Initialize state + initial_balances = np.zeros(n) + self.state = MycoSystemState( + surface_state=NDSurfaceState( + balances=initial_balances, + invariant=0.0, + supply=0.0, + ), + surface_params=self.surface_params, + pamm_state=PAMMState(reserve_value=0, myco_supply=0), + flow_tracker=FlowTracker( + memory=config.flow_memory, + threshold=config.flow_threshold, + ), + labor_system=create_default_system(), + subscription_system=SubscriptionSystem(tiers=create_default_tiers()), + staking_system=StakingSystem(params=StakingParams()), + ) + + # Initialize reserve tranching + weights = config.initial_target_weights + if weights is None: + weights = np.ones(n) / n + + vaults = [] + for i in range(n): + vaults.append(Vault( + metadata=VaultMetadata( + name=config.vault_names[i] if i < len(config.vault_names) else f"vault_{i}", + target_weight=weights[i], + price_at_calibration=1.0, + weight_at_calibration=weights[i], + weight_previous=weights[i], + calibration_time=0, + transition_duration=100, + ), + balance=0.0, current_price=1.0, + )) + self.state.reserve_state = ReserveState(vaults=vaults) + + def deposit( + self, amounts: NDArray, current_time: float + ) -> tuple[float, dict]: + """Deposit reserve assets and mint $MYCO. + + Returns (myco_minted, metadata). + """ + state = self.state + n = self.config.n_reserve_assets + assert len(amounts) == n + + # Safety check + safe, msg = is_safe_to_mint(state.reserve_state, amounts, current_time) + if not safe: + return 0.0, {"error": msg, "safe": False} + + # Compute imbalance fee + old_balances = state.surface_state.balances.copy() + new_balances = old_balances + amounts + fee_rate = surge_fee( + old_balances + 1e-10, # Avoid zero-balance issues + new_balances, + self.config.static_fee, + self.config.surge_fee_rate, + self.config.imbalance_threshold, + ) + + # Apply fee to deposit (reduce effective deposit) + effective_amounts = amounts * (1.0 - fee_rate) + fee_amounts = amounts * fee_rate + + # Mint via bonding surface + if state.supply == 0: + # Bootstrap + state.surface_state.balances = effective_amounts + state.surface_state.invariant = compute_invariant( + effective_amounts, self.surface_params + ) + myco_minted = state.surface_state.invariant + state.surface_state.supply = myco_minted + else: + new_surface_state, myco_minted = surface_mint( + state.surface_state, self.surface_params, effective_amounts + ) + state.surface_state = new_surface_state + + # Update reserve state + total_deposit_value = float(np.sum(amounts)) + for i, v in enumerate(state.reserve_state.vaults): + v.balance += amounts[i] + state.reserve_state.total_value += total_deposit_value + + # Update global state + state.supply += myco_minted + state.total_financial_minted += myco_minted + state.pamm_state = PAMMState( + reserve_value=state.reserve_state.total_value, + myco_supply=state.supply, + ) + state.time = current_time + + return myco_minted, { + "safe": True, + "fee_rate": fee_rate, + "fee_amounts": fee_amounts, + "effective_amounts": effective_amounts, + "new_supply": state.supply, + "backing_ratio": state.pamm_state.backing_ratio, + } + + def redeem( + self, myco_amount: float, current_time: float + ) -> tuple[NDArray, dict]: + """Redeem $MYCO for reserve assets. + + Returns (amounts_out, metadata). + """ + state = self.state + + if myco_amount > state.supply: + return np.zeros(self.config.n_reserve_assets), {"error": "Exceeds supply"} + + # Flow dampening + state.flow_tracker = update_flow( + state.flow_tracker, myco_amount, current_time + ) + state.flow_tracker.total_value_ref = state.reserve_state.total_value + penalty = flow_penalty_multiplier(state.flow_tracker) + + effective_amount = myco_amount * penalty + + # P-AMM redemption rate + state.pamm_state = PAMMState( + reserve_value=state.reserve_state.total_value, + myco_supply=state.supply, + ) + rate = compute_redemption_rate( + state.pamm_state, self.config.pamm_params, effective_amount + ) + + usd_to_return = effective_amount * rate + + # Compute optimal withdrawal split + withdrawal_split = optimal_withdrawal_split( + state.reserve_state, usd_to_return, current_time + ) + + # Safety check + safe, msg = is_safe_to_redeem( + state.reserve_state, withdrawal_split, current_time + ) + if not safe: + # Fall back to proportional + cw = current_weights(state.reserve_state) + withdrawal_split = usd_to_return * cw + + # Execute + for i, v in enumerate(state.reserve_state.vaults): + actual = min(withdrawal_split[i], v.balance) + withdrawal_split[i] = actual + v.balance -= actual + state.reserve_state.total_value -= float(np.sum(withdrawal_split)) + + # Burn MYCO + state.supply -= myco_amount + state.surface_state.supply -= myco_amount + state.surface_state.balances -= withdrawal_split + state.surface_state.balances = np.maximum(state.surface_state.balances, 0) + state.surface_state.invariant = compute_invariant( + state.surface_state.balances + 1e-30, self.surface_params + ) + + state.total_redeemed += myco_amount + state.time = current_time + + return withdrawal_split, { + "redemption_rate": rate, + "flow_penalty": penalty, + "effective_amount": effective_amount, + "usd_returned": float(np.sum(withdrawal_split)), + "new_supply": state.supply, + "backing_ratio": ( + state.reserve_state.total_value / state.supply + if state.supply > 0 else float("inf") + ), + } + + def mint_from_labor( + self, contributor: ContributorState, contribution_type: str, + current_time: float, + ) -> tuple[ContributorState, float]: + """Mint $MYCO from labor contributions.""" + cap = self.config.max_labor_mint_fraction * self.state.supply if self.state.supply > 0 else float("inf") + self.state.labor_system.max_total_mint = cap + + system, contributor, tokens = claim_tokens( + self.state.labor_system, contributor, contribution_type, current_time + ) + self.state.labor_system = system + + if tokens > 0: + self.state.supply += tokens + self.state.total_commitment_minted += tokens + self.state.time = current_time + + return contributor, tokens + + def mint_from_subscription( + self, subscriber: str, current_time: float + ) -> float: + """Process subscription payment and mint $MYCO.""" + system, tokens = process_payment( + self.state.subscription_system, subscriber, current_time + ) + self.state.subscription_system = system + + if tokens > 0: + self.state.supply += tokens + self.state.total_commitment_minted += tokens + # Subscription payments add to reserve + tier_name = system.subscriptions[subscriber].tier + payment = system.tiers[tier_name].payment_per_period + # Route to first vault (simplification) + self.state.reserve_state.vaults[0].balance += payment + self.state.reserve_state.total_value += payment + self.state.time = current_time + + return tokens + + def mint_from_staking( + self, staker: str, current_time: float + ) -> float: + """Claim staking bonus $MYCO.""" + system, tokens = claim_bonus( + self.state.staking_system, staker, current_time + ) + self.state.staking_system = system + + if tokens > 0: + self.state.supply += tokens + self.state.total_commitment_minted += tokens + self.state.time = current_time + + return tokens + + def get_metrics(self) -> dict: + """Get current system metrics.""" + state = self.state + return { + "supply": state.supply, + "reserve_value": state.reserve_state.total_value if state.reserve_state else 0, + "backing_ratio": ( + state.reserve_state.total_value / state.supply + if state.supply > 0 else float("inf") + ), + "financial_minted": state.total_financial_minted, + "commitment_minted": state.total_commitment_minted, + "total_redeemed": state.total_redeemed, + "reserve_weights": ( + current_weights(state.reserve_state) + if state.reserve_state else [] + ), + "imbalance": ( + compute_imbalance(state.surface_state.balances) + if state.surface_state is not None else 0 + ), + "flow_ratio": ( + state.flow_tracker.flow_ratio + if state.flow_tracker else 0 + ), + } diff --git a/src/composed/simulator.py b/src/composed/simulator.py new file mode 100644 index 0000000..e3dc2db --- /dev/null +++ b/src/composed/simulator.py @@ -0,0 +1,220 @@ +"""Scenario simulator for the composed MYCO system.""" + +import numpy as np +from numpy.typing import NDArray +from dataclasses import dataclass + +from src.composed.myco_surface import MycoSystem, MycoSystemConfig +from src.commitments.labor import ContributorState, attest_contribution + + +@dataclass +class SimulationResult: + """Results from a simulation run.""" + times: NDArray + supply: NDArray + reserve_value: NDArray + backing_ratio: NDArray + financial_minted: NDArray + commitment_minted: NDArray + total_redeemed: NDArray + imbalance: NDArray + + +def run_scenario( + config: MycoSystemConfig, + duration: float, + dt: float = 1.0, + deposit_schedule: list[tuple[float, NDArray]] | None = None, + redeem_schedule: list[tuple[float, float]] | None = None, + labor_schedule: list[tuple[float, str, str, float]] | None = None, +) -> SimulationResult: + """Run a simulation scenario. + + Args: + config: System configuration + duration: Total simulation time + dt: Time step + deposit_schedule: List of (time, amounts_per_asset) + redeem_schedule: List of (time, myco_amount_to_redeem) + labor_schedule: List of (time, contributor, type, units) + """ + system = MycoSystem(config) + n_steps = int(duration / dt) + + # Pre-process schedules into time-indexed dicts + deposits = {} + if deposit_schedule: + for t, amounts in deposit_schedule: + step = int(t / dt) + deposits[step] = amounts + + redeems = {} + if redeem_schedule: + for t, amount in redeem_schedule: + step = int(t / dt) + redeems[step] = amount + + labors = {} + if labor_schedule: + for t, contributor, ctype, units in labor_schedule: + step = int(t / dt) + labors.setdefault(step, []).append((contributor, ctype, units)) + + # Track contributors + contributors: dict[str, ContributorState] = {} + + # Arrays for results + times = np.zeros(n_steps) + supply = np.zeros(n_steps) + reserve_value = np.zeros(n_steps) + backing_ratio = np.zeros(n_steps) + financial_minted = np.zeros(n_steps) + commitment_minted = np.zeros(n_steps) + total_redeemed = np.zeros(n_steps) + imbalance = np.zeros(n_steps) + + for step in range(n_steps): + t = step * dt + times[step] = t + + # Process deposits + if step in deposits: + system.deposit(deposits[step], t) + + # Process redemptions + if step in redeems: + system.redeem(redeems[step], t) + + # Process labor + if step in labors: + for addr, ctype, units in labors[step]: + if addr not in contributors: + contributors[addr] = ContributorState(address=addr) + contributors[addr] = attest_contribution( + system.state.labor_system, contributors[addr], ctype, units, t + ) + contributors[addr], _ = system.mint_from_labor( + contributors[addr], ctype, t + ) + + # Record metrics + metrics = system.get_metrics() + supply[step] = metrics["supply"] + reserve_value[step] = metrics["reserve_value"] + backing_ratio[step] = min(metrics["backing_ratio"], 10.0) # Cap for plotting + financial_minted[step] = metrics["financial_minted"] + commitment_minted[step] = metrics["commitment_minted"] + total_redeemed[step] = metrics["total_redeemed"] + imbalance[step] = metrics["imbalance"] + + return SimulationResult( + times=times, + supply=supply, + reserve_value=reserve_value, + backing_ratio=backing_ratio, + financial_minted=financial_minted, + commitment_minted=commitment_minted, + total_redeemed=total_redeemed, + imbalance=imbalance, + ) + + +def scenario_token_launch( + n_assets: int = 3, + total_raise: float = 100_000.0, + n_depositors: int = 50, + duration: float = 90.0, +) -> SimulationResult: + """Simulate a token launch with staggered deposits. + + Deposits come in over the first 30 days, then + some redemptions in days 60-90. + """ + config = MycoSystemConfig(n_reserve_assets=n_assets) + per_deposit = total_raise / n_depositors + per_asset = per_deposit / n_assets + + deposits = [] + for i in range(n_depositors): + t = i * 30.0 / n_depositors # Spread over 30 days + amounts = np.full(n_assets, per_asset) + # Add some randomness to asset allocation + noise = np.random.uniform(0.5, 1.5, n_assets) + amounts = amounts * noise + amounts = amounts * (per_deposit / np.sum(amounts)) # Normalize to same total + deposits.append((t, amounts)) + + # Some redemptions in the later period + redeems = [] + for i in range(10): + t = 60.0 + i * 3.0 + redeems.append((t, per_deposit * 0.3)) + + return run_scenario( + config, duration, + deposit_schedule=deposits, + redeem_schedule=redeems, + ) + + +def scenario_bank_run( + initial_reserve: float = 100_000.0, + n_assets: int = 3, + redemption_fraction: float = 0.05, + duration: float = 100.0, +) -> SimulationResult: + """Simulate a bank run: everyone tries to redeem at once. + + First bootstrap the system, then continuous redemptions. + """ + config = MycoSystemConfig(n_reserve_assets=n_assets) + per_asset = initial_reserve / n_assets + + # Bootstrap deposit at t=0 + deposits = [(0.0, np.full(n_assets, per_asset))] + + # Continuous redemptions starting at t=10 + redeems = [] + estimated_supply = initial_reserve # Rough estimate + for t in np.arange(10, duration, 1.0): + amount = estimated_supply * redemption_fraction + redeems.append((float(t), amount)) + estimated_supply *= (1 - redemption_fraction * 0.5) # Rough decay + + return run_scenario( + config, duration, + deposit_schedule=deposits, + redeem_schedule=redeems, + ) + + +def scenario_mixed_issuance( + n_assets: int = 3, + duration: float = 365.0, +) -> SimulationResult: + """Simulate mixed financial + commitment issuance over a year. + + Deposits, subscriptions, and labor contributions all occurring. + """ + config = MycoSystemConfig(n_reserve_assets=n_assets) + + # Monthly deposits + deposits = [] + for month in range(12): + t = month * 30.0 + amounts = np.array([10000.0, 5000.0, 5000.0][:n_assets]) + deposits.append((t, amounts)) + + # Weekly labor attestations + labors = [] + for week in range(52): + t = week * 7.0 + labors.append((t, "dev_alice", "code", 5.0)) + labors.append((t, "gov_bob", "governance", 2.0)) + + return run_scenario( + config, duration, + deposit_schedule=deposits, + labor_schedule=labors, + ) diff --git a/src/primitives/__init__.py b/src/primitives/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/primitives/concentrated_2clp.py b/src/primitives/concentrated_2clp.py new file mode 100644 index 0000000..049e058 --- /dev/null +++ b/src/primitives/concentrated_2clp.py @@ -0,0 +1,176 @@ +"""Gyroscope 2-CLP: Two-Asset Concentrated Liquidity Pool. + +Source: gyrostable/concentrated-lps, Gyro2CLPMath.sol + gyrostable/gyro-pools, Gyro2CLPMath.sol + balancer/balancer-v3-monorepo, Gyro2CLPMath.sol + +A price-bounded variant of the constant product AMM using virtual reserves. +Liquidity is concentrated within a price range [α, β], giving deeper +liquidity near the target price. + +Invariant: + L² = (x + L/√β) · (y + L·√α) + +where L is the liquidity parameter (invariant), and: + a = L/√β (virtual offset for x) + b = L·√α (virtual offset for y) + +The virtual reserves transform the standard CPMM (x·y = k) into a +concentrated version where all liquidity exists within the price range. + +Similar to Uniswap V3 but with an analytically solvable invariant +(quadratic in L) rather than requiring tick-based discrete ranges. +""" + +import numpy as np + + +def compute_invariant( + x: float, y: float, sqrt_alpha: float, sqrt_beta: float +) -> float: + """Compute the 2-CLP invariant L by solving the quadratic. + + From Gyro2CLPMath._calculateInvariant: + a·L² + b·L + c = 0 + where: + a = 1 - √α/√β + b = -(y/√β + x·√α) + c = -x·y + + Solution: L = (-b + √(b² - 4ac)) / (2a) [Bhaskara's formula] + + Args: + x: Balance of token 0 + y: Balance of token 1 + sqrt_alpha: √α (square root of lower price bound) + sqrt_beta: √β (square root of upper price bound) + + Returns: + Invariant L (liquidity parameter) + """ + assert x >= 0 and y >= 0 + assert 0 < sqrt_alpha < sqrt_beta + + a = 1.0 - sqrt_alpha / sqrt_beta + b = -(y / sqrt_beta + x * sqrt_alpha) + c = -x * y + + discriminant = b * b - 4 * a * c + assert discriminant >= 0, f"Negative discriminant: {discriminant}" + + L = (-b + np.sqrt(discriminant)) / (2.0 * a) + return L + + +def virtual_offset_x(L: float, sqrt_beta: float) -> float: + """Virtual offset a = L/√β for token x.""" + return L / sqrt_beta + + +def virtual_offset_y(L: float, sqrt_alpha: float) -> float: + """Virtual offset b = L·√α for token y.""" + return L * sqrt_alpha + + +def calc_out_given_in( + x: float, y: float, sqrt_alpha: float, sqrt_beta: float, amount_in: float, + token_in: int = 0, +) -> float: + """Calculate output amount for a swap. + + Swap math on virtual reserves: + (x' + a)(y' - dy + b) = (x' + a + dx)(y' + b) ... wait + Actually: new virtual product = old virtual product + (x + a + dx)(y + b - dy) = (x + a)(y + b) + dy = (y + b) - (x + a)(y + b) / (x + a + dx) + dy = (y + b) · dx / (x + a + dx) + + This is standard CPMM on virtual reserves. + """ + L = compute_invariant(x, y, sqrt_alpha, sqrt_beta) + a = virtual_offset_x(L, sqrt_beta) + b = virtual_offset_y(L, sqrt_alpha) + + if token_in == 0: + # Selling x for y + virt_x = x + a + virt_y = y + b + amount_out = virt_y * amount_in / (virt_x + amount_in) + else: + # Selling y for x + virt_x = x + a + virt_y = y + b + amount_out = virt_x * amount_in / (virt_y + amount_in) + + return amount_out + + +def calc_in_given_out( + x: float, y: float, sqrt_alpha: float, sqrt_beta: float, amount_out: float, + token_out: int = 1, +) -> float: + """Calculate input amount for exact output swap.""" + L = compute_invariant(x, y, sqrt_alpha, sqrt_beta) + a = virtual_offset_x(L, sqrt_beta) + b = virtual_offset_y(L, sqrt_alpha) + + if token_out == 1: + # Buying y, paying x + virt_x = x + a + virt_y = y + b + amount_in = virt_x * amount_out / (virt_y - amount_out) + else: + # Buying x, paying y + virt_x = x + a + virt_y = y + b + amount_in = virt_y * amount_out / (virt_x - amount_out) + + return amount_in + + +def spot_price( + x: float, y: float, sqrt_alpha: float, sqrt_beta: float +) -> float: + """Spot price of token 0 in terms of token 1. + + price = (y + b) / (x + a) on virtual reserves. + """ + L = compute_invariant(x, y, sqrt_alpha, sqrt_beta) + a = virtual_offset_x(L, sqrt_beta) + b = virtual_offset_y(L, sqrt_alpha) + return (y + b) / (x + a) + + +def price_bounds(sqrt_alpha: float, sqrt_beta: float) -> tuple[float, float]: + """Return the [α, β] price range. + + The pool is only active when the spot price is within [α, β]. + Outside this range, the pool holds only one token. + """ + return sqrt_alpha**2, sqrt_beta**2 + + +def bpt_price( + L: float, supply: float, + px: float, py: float, + sqrt_alpha: float, sqrt_beta: float, +) -> float: + """Manipulation-resistant BPT pricing. + + From BalancerLPSharePricing.priceBpt2CLP: + When α < px/py < β: + bptPrice = L/S * (2·√(px·py) - px/√β - py·√α) + """ + price_ratio = px / py + alpha = sqrt_alpha**2 + beta = sqrt_beta**2 + + if price_ratio <= alpha: + # Pool is fully in x + return (L / supply) * px * (1.0 / sqrt_alpha - 1.0 / sqrt_beta) + elif price_ratio >= beta: + # Pool is fully in y + return (L / supply) * py * (sqrt_beta - sqrt_alpha) + else: + # Normal range + return (L / supply) * (2.0 * np.sqrt(px * py) - px / sqrt_beta - py * sqrt_alpha) diff --git a/src/primitives/dynamic_weights.py b/src/primitives/dynamic_weights.py new file mode 100644 index 0000000..c76841b --- /dev/null +++ b/src/primitives/dynamic_weights.py @@ -0,0 +1,192 @@ +"""Dynamic weight and parameter interpolation. + +Source: balancer/balancer-v2-monorepo — GradualValueChange.sol + balancer/balancer-v3-monorepo — GradualValueChange.sol, LBPool.sol + balancer/balancer-maths — quantamm_math.py + +Two mechanisms for time-varying parameters: + +1. GradualValueChange (Balancer): Linear interpolation between start/end + values over a time window. Used for weight schedules in LBPs and + managed pools. + +2. QuantAMM multiplier (Balancer): Oracle-driven per-parameter velocity. + weight(t) = base_weight + multiplier * dt + Updated per block by an external oracle. + +For MYCO: parameters of the bonding surface (concentration, price bounds, +tranche weights) can evolve over time — either on a schedule (governance) +or driven by oracle signals (adaptive). +""" + +import numpy as np +from numpy.typing import NDArray +from dataclasses import dataclass +from typing import Callable + + +@dataclass +class GradualChange: + """A scheduled gradual parameter change. + + From GradualValueChange.sol: linear interpolation between start and end + values, clamped to avoid discontinuities. + """ + start_value: float + end_value: float + start_time: float + end_time: float + + def value_at(self, time: float) -> float: + """Get the interpolated value at a given time. + + Before start_time: returns start_value + After end_time: returns end_value + Between: linear interpolation + """ + if time <= self.start_time: + return self.start_value + if time >= self.end_time: + return self.end_value + + progress = (time - self.start_time) / (self.end_time - self.start_time) + return self.start_value + progress * (self.end_value - self.start_value) + + +@dataclass +class GradualWeightSchedule: + """Schedule for N weights changing gradually over time. + + For an N-asset pool, all weights change simultaneously. + Weights are always normalized to sum to 1. + """ + changes: list[GradualChange] + + def weights_at(self, time: float) -> NDArray: + """Get normalized weights at a given time.""" + raw = np.array([c.value_at(time) for c in self.changes]) + total = np.sum(raw) + if total == 0: + return np.ones(len(raw)) / len(raw) + return raw / total + + +@dataclass +class OracleMultiplier: + """Oracle-driven weight multiplier (QuantAMM pattern). + + weight(t) = base_weight + multiplier * (t - last_update_time) + + The multiplier is set by an external oracle at each update. + Between updates, the weight drifts linearly. + """ + base_weight: float + multiplier: float # Rate of change per time unit + last_update_time: float + min_weight: float = 0.01 + max_weight: float = 0.99 + + def weight_at(self, time: float) -> float: + """Get the weight at a given time, clamped to bounds.""" + dt = time - self.last_update_time + raw = self.base_weight + self.multiplier * dt + return np.clip(raw, self.min_weight, self.max_weight) + + def update(self, new_multiplier: float, time: float) -> "OracleMultiplier": + """Oracle updates the multiplier (e.g., at each block).""" + return OracleMultiplier( + base_weight=self.weight_at(time), + multiplier=new_multiplier, + last_update_time=time, + min_weight=self.min_weight, + max_weight=self.max_weight, + ) + + +@dataclass +class OracleWeightSystem: + """N-asset oracle-driven weight system. + + All multipliers updated together, weights always normalized. + """ + multipliers: list[OracleMultiplier] + + def weights_at(self, time: float) -> NDArray: + """Get normalized weights at a given time.""" + raw = np.array([m.weight_at(time) for m in self.multipliers]) + total = np.sum(raw) + if total == 0: + return np.ones(len(raw)) / len(raw) + return raw / total + + def update_all( + self, new_multipliers: NDArray, time: float + ) -> "OracleWeightSystem": + """Oracle updates all multipliers.""" + return OracleWeightSystem( + multipliers=[ + m.update(new_multipliers[i], time) + for i, m in enumerate(self.multipliers) + ] + ) + + +def create_lbp_schedule( + n_tokens: int, + start_weights: NDArray, + end_weights: NDArray, + start_time: float, + end_time: float, +) -> GradualWeightSchedule: + """Create a Liquidity Bootstrapping Pool weight schedule. + + Typical LBP: token starts at high weight (e.g., 95%) and + decreases to low weight (e.g., 50%), creating selling pressure. + """ + assert len(start_weights) == n_tokens + assert len(end_weights) == n_tokens + + changes = [ + GradualChange(sw, ew, start_time, end_time) + for sw, ew in zip(start_weights, end_weights) + ] + return GradualWeightSchedule(changes) + + +def create_oracle_system( + n_tokens: int, + initial_weights: NDArray, + time: float = 0.0, +) -> OracleWeightSystem: + """Create an oracle-driven weight system with initial weights.""" + assert len(initial_weights) == n_tokens + + multipliers = [ + OracleMultiplier( + base_weight=w, + multiplier=0.0, + last_update_time=time, + ) + for w in initial_weights + ] + return OracleWeightSystem(multipliers) + + +def simulate_lbp( + schedule: GradualWeightSchedule, + start_time: float, + end_time: float, + n_steps: int = 100, +) -> dict[str, NDArray]: + """Simulate weight evolution over an LBP schedule. + + Returns dict with 'times' and 'weights' (n_steps × n_tokens). + """ + times = np.linspace(start_time, end_time, n_steps) + n_tokens = len(schedule.changes) + weights = np.zeros((n_steps, n_tokens)) + + for i, t in enumerate(times): + weights[i] = schedule.weights_at(t) + + return {"times": times, "weights": weights} diff --git a/src/primitives/elliptical_clp.py b/src/primitives/elliptical_clp.py new file mode 100644 index 0000000..53719cf --- /dev/null +++ b/src/primitives/elliptical_clp.py @@ -0,0 +1,293 @@ +"""Gyroscope E-CLP: Elliptic Concentrated Liquidity Pool. + +Source: gyrostable/gyro-pools, GyroECLPMath.sol + gyrostable/concentrated-lps, GyroECLPMath.sol + balancer/balancer-v3-monorepo, GyroECLPMath.sol + Technical paper: gyrostable/technical-papers/E-CLP/E-CLP Mathematics.pdf + +The most mathematically sophisticated AMM curve in production. Trading occurs +along part of an ELLIPSE rather than a circle (standard CPMM) or hyperbola. + +The ellipse is defined by 5 parameters: + α, β — price range bounds (same as 2-CLP) + c, s — cos(-φ), sin(-φ) rotation of the ellipse axis + λ — stretching factor (aspect ratio; λ=1 → circle, λ→∞ → line) + +The key insight is the A-matrix transformation: + A maps the ellipse to the unit circle + The invariant is: |A(v - offset)|² = r² + +where v = (x, y) are reserves, offset = (a, b) are virtual offsets, +and r is the scalar invariant. + +Python float reference: gyrostable/gyro-pools/tests/geclp/eclp_float.py +""" + +import numpy as np +from dataclasses import dataclass + +from src.utils.linear_algebra import make_eclp_A, make_eclp_A_inv + + +@dataclass +class ECLPParams: + """E-CLP pool parameters. + + Matches GyroECLPMath.Params struct. + """ + alpha: float # Lower price bound (α < 1 for stablecoin pairs) + beta: float # Upper price bound (β > 1) + c: float # cos(-φ), rotation angle cosine (c ≥ 0) + s: float # sin(-φ), rotation angle sine (s ≥ 0) + lam: float # λ ≥ 1, stretching factor + + def __post_init__(self): + assert self.alpha < self.beta, "α must be < β" + assert self.lam >= 1.0, "λ must be ≥ 1" + norm = np.sqrt(self.c**2 + self.s**2) + assert abs(norm - 1.0) < 1e-8, f"(c,s) must be unit vector, got norm={norm}" + + +@dataclass +class ECLPDerivedParams: + """Pre-computed derived parameters. + + Matches GyroECLPMath.DerivedParams struct. + Computed once at pool creation, stored as immutables. + """ + tau_alpha: np.ndarray # 2D vector: point on unit circle for price α + tau_beta: np.ndarray # 2D vector: point on unit circle for price β + u: float + v: float + w: float + z: float + dSq: float # c² + s² (should be ≈ 1, error correction) + + +def compute_derived_params(p: ECLPParams) -> ECLPDerivedParams: + """Compute derived parameters from pool params. + + From eclp_prec_implementation.py: calc_derived_values() + """ + dSq = p.c**2 + p.s**2 + d = np.sqrt(dSq) + + tau_alpha = _tau(p, p.alpha, d) + tau_beta = _tau(p, p.beta, d) + + # u, v, w, z from decomposition of A·χ (center direction) + w = p.s * p.c * (tau_beta[1] - tau_alpha[1]) + z = p.c**2 * tau_beta[0] + p.s**2 * tau_alpha[0] + u = p.s * p.c * (tau_beta[0] - tau_alpha[0]) + v = p.s**2 * tau_beta[1] + p.c**2 * tau_alpha[1] + + return ECLPDerivedParams( + tau_alpha=tau_alpha, + tau_beta=tau_beta, + u=u, v=v, w=w, z=z, + dSq=dSq, + ) + + +def _tau(p: ECLPParams, px: float, d: float) -> np.ndarray: + """Map a price on the ellipse to the corresponding point on the unit circle. + + tau(px) = normalize((px·c - s, (c + s·px)/λ)) + + From eclp_float.py: tau() + """ + c, s, lam = p.c / d, p.s / d, p.lam + tx = px * c - s + ty = (c + s * px) / lam + norm = np.sqrt(tx**2 + ty**2) + return np.array([tx / norm, ty / norm]) + + +def compute_invariant( + x: float, y: float, p: ECLPParams, dp: ECLPDerivedParams +) -> float: + """Compute the E-CLP invariant r. + + The invariant r satisfies: the point (x, y) with virtual offsets lies on + the ellipse defined by A. + + From GyroECLPMath.calculateInvariantWithError (Proposition 13): + r = (AtAChi + √(AtAChi² - (|AChi|²-1)·|At|²)) / (|AChi|² - 1) + + This is a quadratic formula in r. + """ + A = make_eclp_A(p.c, p.s, p.lam) + + # AChi direction (center of the ellipse arc in circle space) + # AChi = (w/λ + z, λ·u + v) — from derived params + AChi_x = dp.w / p.lam + dp.z + AChi_y = p.lam * dp.u + dp.v + AChi = np.array([AChi_x, AChi_y]) + AChi_sq = AChi @ AChi + + # At = A @ (x, y) — current reserves in circle space + t = np.array([x, y]) + At = A @ t + At_sq = At @ At + + # AtAChi = At · AChi (dot product) + AtAChi = At @ AChi + + # Solve quadratic: (|AChi|² - 1)·r² - 2·AtAChi·r + |At|² = 0 + # But we use the formula from Proposition 13: + # r = (AtAChi + √(AtAChi² - (|AChi|²-1)·|At|²)) / (|AChi|² - 1) + denom = AChi_sq - 1.0 + + if abs(denom) < 1e-15: + # Degenerate case (circle, λ=1): r² = |At|² + return np.sqrt(At_sq) + + discriminant = AtAChi**2 - denom * At_sq + assert discriminant >= -1e-10, f"Negative discriminant: {discriminant}" + discriminant = max(0.0, discriminant) + + r = (AtAChi + np.sqrt(discriminant)) / denom + return r + + +def virtual_offsets( + r: float, p: ECLPParams, dp: ECLPDerivedParams +) -> tuple[float, float]: + """Compute virtual offsets (a, b) from invariant r. + + From GyroECLPMath.virtualOffset0, virtualOffset1: + a = r · (A^{-1} · τ(β))_x + b = r · (A^{-1} · τ(α))_y + """ + A_inv = make_eclp_A_inv(p.c, p.s, p.lam) + + a_vec = A_inv @ dp.tau_beta + b_vec = A_inv @ dp.tau_alpha + + a = r * a_vec[0] + b = r * b_vec[1] + return a, b + + +def calc_out_given_in( + x: float, y: float, p: ECLPParams, dp: ECLPDerivedParams, + amount_in: float, token_in: int = 0, +) -> float: + """Calculate output for a swap on the E-CLP. + + Given new x (or y), solve for the corresponding y (or x) on the ellipse. + """ + r = compute_invariant(x, y, p, dp) + + if token_in == 0: + new_x = x + amount_in + new_y = _calc_y_given_x(new_x, r, p, dp) + return y - new_y + else: + new_y = y + amount_in + new_x = _calc_x_given_y(new_y, r, p, dp) + return x - new_x + + +def calc_in_given_out( + x: float, y: float, p: ECLPParams, dp: ECLPDerivedParams, + amount_out: float, token_out: int = 1, +) -> float: + """Calculate required input for exact output on E-CLP.""" + r = compute_invariant(x, y, p, dp) + + if token_out == 1: + new_y = y - amount_out + new_x = _calc_x_given_y(new_y, r, p, dp) + return new_x - x + else: + new_x = x - amount_out + new_y = _calc_y_given_x(new_x, r, p, dp) + return new_y - y + + +def _calc_y_given_x(x: float, r: float, p: ECLPParams, dp: ECLPDerivedParams) -> float: + """Solve for y given x and invariant r. + + The ellipse equation in original coordinates can be expressed as a + quadratic in y. We solve it using the A-matrix transformation. + + From GyroECLPMath.calcYGivenX / solveQuadraticSwap. + """ + a_off, b_off = virtual_offsets(r, p, dp) + A = make_eclp_A(p.c, p.s, p.lam) + + # Transform x to circle space, solve for y + # |A @ (x - a_off, y - b_off)|² = r² + # Let u = x - a_off, find v = y - b_off such that |A @ (u, v)|² = r² + u = x - a_off + + # A @ (u, v) = (A[0,0]*u + A[0,1]*v, A[1,0]*u + A[1,1]*v) + # |A @ (u,v)|² = (A00*u + A01*v)² + (A10*u + A11*v)² + # = (A00² + A10²)*u² + 2*(A00*A01 + A10*A11)*u*v + (A01² + A11²)*v² = r² + + a00, a01 = A[0, 0], A[0, 1] + a10, a11 = A[1, 0], A[1, 1] + + qa = a01**2 + a11**2 + qb = 2.0 * (a00 * a01 + a10 * a11) * u + qc = (a00**2 + a10**2) * u**2 - r**2 + + discriminant = qb**2 - 4.0 * qa * qc + assert discriminant >= -1e-10, f"Negative discriminant in y-solver: {discriminant}" + discriminant = max(0.0, discriminant) + + # Take the solution that gives positive y (the one with - sign for typical params) + v1 = (-qb + np.sqrt(discriminant)) / (2.0 * qa) + v2 = (-qb - np.sqrt(discriminant)) / (2.0 * qa) + + # Choose the solution closest to the expected range + y1 = v1 + b_off + y2 = v2 + b_off + + # The correct solution is the one where y ≥ 0 and gives a valid state + if y1 >= 0 and y2 >= 0: + return min(y1, y2) # Take the lower (post-swap) value + elif y1 >= 0: + return y1 + else: + return y2 + + +def _calc_x_given_y(y: float, r: float, p: ECLPParams, dp: ECLPDerivedParams) -> float: + """Solve for x given y and invariant r. Symmetric to _calc_y_given_x.""" + a_off, b_off = virtual_offsets(r, p, dp) + A = make_eclp_A(p.c, p.s, p.lam) + + v = y - b_off + + a00, a01 = A[0, 0], A[0, 1] + a10, a11 = A[1, 0], A[1, 1] + + qa = a00**2 + a10**2 + qb = 2.0 * (a00 * a01 + a10 * a11) * v + qc = (a01**2 + a11**2) * v**2 - r**2 + + discriminant = qb**2 - 4.0 * qa * qc + assert discriminant >= -1e-10, f"Negative discriminant in x-solver: {discriminant}" + discriminant = max(0.0, discriminant) + + u1 = (-qb + np.sqrt(discriminant)) / (2.0 * qa) + u2 = (-qb - np.sqrt(discriminant)) / (2.0 * qa) + + x1 = u1 + a_off + x2 = u2 + a_off + + if x1 >= 0 and x2 >= 0: + return min(x1, x2) + elif x1 >= 0: + return x1 + else: + return x2 + + +def spot_price(x: float, y: float, p: ECLPParams, dp: ECLPDerivedParams) -> float: + """Approximate spot price via infinitesimal swap.""" + dx = x * 1e-8 + dy = calc_out_given_in(x, y, p, dp, dx, token_in=0) + return dy / dx diff --git a/src/primitives/flow_dampening.py b/src/primitives/flow_dampening.py new file mode 100644 index 0000000..22244f2 --- /dev/null +++ b/src/primitives/flow_dampening.py @@ -0,0 +1,189 @@ +"""Exponential outflow memory for anti-bank-run protection. + +Source: gyrostable/gyd-core — Flow.sol + +Tracks recent redemption flow using an exponential moving sum. +The memory parameter controls how quickly past flow is forgotten: + flow(t) = flow(t-1) * memory^(dt) + new_flow + +- memory close to 1: long memory, slow decay (conservative) +- memory close to 0: short memory, fast decay (permissive) + +When cumulative recent flow exceeds a threshold, the system can: +1. Apply higher redemption fees (imbalance fee integration) +2. Reduce redemption rate (P-AMM integration) +3. Block redemptions entirely (circuit breaker) + +This prevents bank-run dynamics where early redeemers drain the reserve +at full price, leaving later redeemers with nothing. +""" + +from dataclasses import dataclass +import numpy as np + + +@dataclass +class FlowTracker: + """Tracks cumulative flow with exponential decay.""" + memory: float = 0.999 # Decay parameter per time unit + threshold: float = 0.1 # Max flow as fraction of total value + current_flow: float = 0.0 # Current tracked flow + last_update_time: float = 0.0 + total_value_ref: float = 1.0 # Reference total value for threshold calc + + @property + def flow_ratio(self) -> float: + """Current flow as fraction of reference value.""" + if self.total_value_ref <= 0: + return float("inf") if self.current_flow > 0 else 0.0 + return self.current_flow / self.total_value_ref + + @property + def is_above_threshold(self) -> bool: + return self.flow_ratio > self.threshold + + +def update_flow( + tracker: FlowTracker, new_flow: float, current_time: float, +) -> FlowTracker: + """Update the flow tracker with a new flow event. + + Applies exponential decay to existing flow, then adds new flow. + + Args: + tracker: Current flow state + new_flow: Amount of new flow (absolute value) + current_time: Current timestamp + + Returns: + Updated FlowTracker + """ + dt = max(0.0, current_time - tracker.last_update_time) + decay_factor = tracker.memory ** dt + decayed_flow = tracker.current_flow * decay_factor + abs(new_flow) + + return FlowTracker( + memory=tracker.memory, + threshold=tracker.threshold, + current_flow=decayed_flow, + last_update_time=current_time, + total_value_ref=tracker.total_value_ref, + ) + + +def flow_at_time(tracker: FlowTracker, time: float) -> float: + """Query what the flow would be at a future time (without new events).""" + dt = max(0.0, time - tracker.last_update_time) + return tracker.current_flow * tracker.memory ** dt + + +def time_to_decay_below_threshold(tracker: FlowTracker) -> float | None: + """How many time units until flow decays below threshold. + + Returns None if already below threshold or if it would never decay + (memory = 1.0). + """ + if not tracker.is_above_threshold: + return 0.0 + + if tracker.memory >= 1.0: + return None # Never decays + + target = tracker.threshold * tracker.total_value_ref + if target <= 0: + return None + + # flow * memory^t = target + # t = log(target / flow) / log(memory) + ratio = target / tracker.current_flow + if ratio >= 1.0: + return 0.0 + + return np.log(ratio) / np.log(tracker.memory) + + +def flow_penalty_multiplier(tracker: FlowTracker) -> float: + """Compute a penalty multiplier based on flow ratio. + + Returns a value in [0, 1] that can be multiplied with the redemption + rate to slow down outflows when flow is high. + + - flow_ratio < threshold/2: multiplier = 1.0 (no penalty) + - flow_ratio = threshold: multiplier = 0.5 + - flow_ratio > threshold: multiplier decreases further (quadratic) + """ + ratio = tracker.flow_ratio + half_threshold = tracker.threshold / 2 + + if ratio <= half_threshold: + return 1.0 + + if ratio >= tracker.threshold * 2: + return 0.1 # Floor — never fully block + + # Smooth quadratic penalty + normalized = (ratio - half_threshold) / (tracker.threshold * 1.5) + return max(0.1, 1.0 - normalized ** 2) + + +def simulate_bank_run( + initial_value: float, + supply: float, + memory: float, + threshold: float, + redemption_rate: float, + n_steps: int = 100, + dt: float = 1.0, +) -> dict[str, np.ndarray]: + """Simulate a bank-run scenario with flow dampening. + + Each step, a fraction (redemption_rate) of remaining supply tries to redeem. + Flow dampening reduces the effective redemption when flow is high. + + Returns dict of time series: times, values, supplies, flows, penalties. + """ + tracker = FlowTracker( + memory=memory, threshold=threshold, + total_value_ref=initial_value, + ) + value = initial_value + sup = supply + + times = np.zeros(n_steps) + values = np.zeros(n_steps) + supplies = np.zeros(n_steps) + flows = np.zeros(n_steps) + penalties = np.zeros(n_steps) + + for step in range(n_steps): + t = step * dt + times[step] = t + + # Attempted redemption + attempt = sup * redemption_rate + + # Apply flow penalty + penalty = flow_penalty_multiplier(tracker) + effective = attempt * penalty + + # Execute + backing = value / sup if sup > 0 else 0 + usd_out = effective * min(backing, 1.0) + + value -= usd_out + sup -= effective + tracker = update_flow(tracker, usd_out, t) + tracker.total_value_ref = value + + values[step] = value + supplies[step] = sup + flows[step] = tracker.current_flow + penalties[step] = penalty + + return { + "times": times, + "values": values, + "supplies": supplies, + "flows": flows, + "penalties": penalties, + } diff --git a/src/primitives/imbalance_fees.py b/src/primitives/imbalance_fees.py new file mode 100644 index 0000000..bf06a3d --- /dev/null +++ b/src/primitives/imbalance_fees.py @@ -0,0 +1,118 @@ +"""Imbalance-contingent fees (StableSurge). + +Source: balancer/balancer-v3-monorepo — StableSurgeHook.sol, + StableSurgeMedianMath.sol + +A fee mechanism that applies a "surge" fee when a swap increases pool +imbalance beyond a threshold. The imbalance is measured relative to the +median balance: + + imbalance = sum(|balance_i - median|) / sum(balance_i) + +When imbalance exceeds a threshold, the surge fee replaces the static fee, +making swaps that worsen imbalance more expensive. + +For MYCO: this incentivizes balanced reserve deposits and discourages +extractive behavior that skews the reserve composition. +""" + +import numpy as np +from numpy.typing import NDArray + + +def compute_imbalance(balances: NDArray) -> float: + """Compute the imbalance metric. + + imbalance = sum(|balance_i - median|) / sum(balance_i) + + Returns value in [0, 1]: + - 0 = perfectly balanced (all balances equal) + - close to 1 = extremely imbalanced (one token dominates) + """ + total = np.sum(balances) + if total == 0: + return 0.0 + + median = np.median(balances) + deviation = np.sum(np.abs(balances - median)) + return deviation / total + + +def surge_fee( + balances_before: NDArray, + balances_after: NDArray, + static_fee: float = 0.003, # 0.3% default + surge_fee_rate: float = 0.05, # 5% surge + threshold: float = 0.2, # 20% imbalance threshold +) -> float: + """Compute the effective fee for a swap. + + If the swap increases imbalance beyond the threshold, the surge fee + applies. Otherwise, the static fee applies. + + Returns the fee rate to charge. + """ + imbalance_before = compute_imbalance(balances_before) + imbalance_after = compute_imbalance(balances_after) + + if imbalance_after > threshold and imbalance_after > imbalance_before: + # Surge: swap worsens imbalance beyond threshold + # Interpolate between static and surge based on how far past threshold + excess = (imbalance_after - threshold) / (1.0 - threshold) + return static_fee + (surge_fee_rate - static_fee) * min(excess, 1.0) + else: + return static_fee + + +def compute_fee_adjusted_output( + amount_out_raw: float, + balances_before: NDArray, + balances_after: NDArray, + static_fee: float = 0.003, + surge_fee_rate: float = 0.05, + threshold: float = 0.2, +) -> tuple[float, float]: + """Apply imbalance fee to a swap output. + + Returns (amount_out_after_fee, fee_amount). + """ + fee_rate = surge_fee( + balances_before, balances_after, + static_fee, surge_fee_rate, threshold, + ) + fee_amount = amount_out_raw * fee_rate + return amount_out_raw - fee_amount, fee_amount + + +def optimal_deposit_fee( + current_balances: NDArray, + deposit_amounts: NDArray, + base_fee: float = 0.001, # 0.1% base + discount_rate: float = 1.0, # How much to discount rebalancing deposits +) -> NDArray: + """Compute per-asset deposit fees that incentivize rebalancing. + + Deposits that improve balance get a fee discount. + Deposits that worsen balance get a fee surcharge. + + Returns per-asset fee rates. + """ + n = len(current_balances) + total = np.sum(current_balances) + if total == 0: + return np.full(n, base_fee) + + target = total / n # Equal weight target + fees = np.zeros(n) + + for i in range(n): + if current_balances[i] < target: + # Underweight: discount for depositing here + shortfall = (target - current_balances[i]) / target + fees[i] = base_fee * max(0, 1.0 - discount_rate * shortfall) + else: + # Overweight: surcharge for depositing here + excess = (current_balances[i] - target) / target + fees[i] = base_fee * (1.0 + discount_rate * excess) + + return fees diff --git a/src/primitives/n_dimensional_surface.py b/src/primitives/n_dimensional_surface.py new file mode 100644 index 0000000..88348e7 --- /dev/null +++ b/src/primitives/n_dimensional_surface.py @@ -0,0 +1,365 @@ +"""N-Dimensional Ellipsoidal Bonding Surface. + +Novel generalization extending Gyroscope's E-CLP from 2D to arbitrary N dimensions. + +Source: Inspired by gyrostable E-CLP (2D) and 3-CLP (3D, cubic). + This module generalizes the A-matrix approach to N dimensions. + +The 2D E-CLP invariant is: + |A(v - offset)|² = r² + +where A is a 2×2 matrix encoding rotation and stretch. This generalizes +naturally to N dimensions: + + |A(v - offset)|² = r² + +where: + A = diag(1/λ_i) @ Q^T (N×N matrix) + Q is an N×N orthogonal rotation matrix + λ_i are per-axis stretch factors (concentration parameters) + v = (b_1, ..., b_N) are reserve balances + offset = (a_1, ..., a_N) are virtual offsets derived from r and price bounds + +The ellipsoid bonding surface is the set of valid reserve states for a given +invariant r. Trading moves along this surface, and minting/burning changes r. + +For MYCO: each reserve asset occupies one dimension. The ellipsoid geometry +determines how much $MYCO is minted for a given deposit mix. Concentration +parameters (λ_i) control how tightly priced each reserve pair is. +""" + +import numpy as np +from numpy.typing import NDArray +from dataclasses import dataclass, field +from typing import Optional + +from src.utils.linear_algebra import ( + make_nd_A, make_nd_A_inv, ellipsoid_norm_sq, random_rotation_matrix +) + + +@dataclass +class NDSurfaceParams: + """Parameters for an N-dimensional ellipsoidal bonding surface. + + Generalizes ECLPParams to N dimensions. + """ + n: int # Number of reserve assets + lambdas: NDArray # Per-axis stretch factors (each ≥ 1) + Q: NDArray # N×N orthogonal rotation matrix + price_bounds_lower: NDArray # Per-pair lower price bounds (α_i) + price_bounds_upper: NDArray # Per-pair upper price bounds (β_i) + + def __post_init__(self): + assert len(self.lambdas) == self.n + assert self.Q.shape == (self.n, self.n) + assert all(l >= 1.0 for l in self.lambdas) + # Verify Q is orthogonal + identity_check = self.Q @ self.Q.T + assert np.allclose(identity_check, np.eye(self.n), atol=1e-10), \ + "Q must be orthogonal" + + +@dataclass +class NDSurfaceState: + """State of the bonding surface.""" + balances: NDArray # Current reserve balances + invariant: float # Current invariant r + supply: float # $MYCO supply (BPT equivalent) + + +def create_params( + n: int, + lambdas: NDArray | None = None, + Q: NDArray | None = None, + price_bounds: tuple[float, float] = (0.5, 2.0), + rng: np.random.Generator | None = None, +) -> NDSurfaceParams: + """Create surface parameters with sensible defaults. + + Args: + n: Number of reserve assets + lambdas: Stretch factors (default: all 1.0 = hypersphere) + Q: Rotation matrix (default: identity = axis-aligned) + price_bounds: Default (lower, upper) for all pairs + rng: Random generator for random Q + """ + if lambdas is None: + lambdas = np.ones(n) + if Q is None: + Q = np.eye(n) + + lower = np.full(n, price_bounds[0]) + upper = np.full(n, price_bounds[1]) + + return NDSurfaceParams( + n=n, lambdas=lambdas, Q=Q, + price_bounds_lower=lower, + price_bounds_upper=upper, + ) + + +def compute_A(params: NDSurfaceParams) -> NDArray: + """Build the N×N transformation matrix A = diag(1/λ) @ Q^T.""" + return make_nd_A(params.lambdas, params.Q) + + +def compute_A_inv(params: NDSurfaceParams) -> NDArray: + """Build A^{-1} = Q @ diag(λ).""" + return make_nd_A_inv(params.lambdas, params.Q) + + +def compute_virtual_offsets( + r: float, params: NDSurfaceParams +) -> NDArray: + """Compute N-dimensional virtual offsets. + + For the N-D ellipsoid, the virtual offsets determine where the ellipsoid + is centered relative to the origin. They shift reserves so that the + surface passes through the positive orthant. + + We use a fraction of r scaled by the price bounds: + offset_i = r * sqrt(alpha_i) / (n * lambda_i) + + This ensures offsets are small relative to balances, concentrating + liquidity near the current price rather than consuming most of the + reserves as virtual padding. + """ + offsets = np.zeros(params.n) + n = params.n + + for i in range(n): + sqrt_alpha = np.sqrt(params.price_bounds_lower[i]) + offsets[i] = r * sqrt_alpha / (n * params.lambdas[i]) + + return offsets + + +def compute_invariant( + balances: NDArray, params: NDSurfaceParams +) -> float: + """Compute the N-dimensional invariant r. + + Solves: |A(v - offset(r))|² = r² + for r, where offset depends on r. + + Since offset is linear in r, this reduces to a quadratic in r. + We solve iteratively (Newton-like) since the N-D case has more + complex offset structure. + """ + A = compute_A(params) + n = params.n + + # Initial guess: geometric mean of balances (similar to weighted product) + r = np.prod(balances) ** (1.0 / n) + + for _ in range(100): + offsets = compute_virtual_offsets(r, params) + v_shifted = balances - offsets + + # |A @ v_shifted|² should equal r² + norm_sq = ellipsoid_norm_sq(v_shifted, A) + + r_new = np.sqrt(norm_sq) + + if abs(r_new - r) < 1e-12 * r: + return r_new + r = r_new + + return r + + +def calc_out_given_in( + balances: NDArray, params: NDSurfaceParams, + token_in: int, token_out: int, amount_in: float, +) -> float: + """Calculate output amount for a swap on the N-D surface. + + 1. Compute invariant r + 2. Update balance[token_in] += amount_in + 3. Solve for new balance[token_out] that preserves r + """ + r = compute_invariant(balances, params) + new_balances = balances.copy() + new_balances[token_in] += amount_in + + new_balance_out = _solve_for_balance( + new_balances, params, token_out, r + ) + + return balances[token_out] - new_balance_out + + +def calc_in_given_out( + balances: NDArray, params: NDSurfaceParams, + token_in: int, token_out: int, amount_out: float, +) -> float: + """Calculate input amount for exact output swap.""" + r = compute_invariant(balances, params) + new_balances = balances.copy() + new_balances[token_out] -= amount_out + + new_balance_in = _solve_for_balance( + new_balances, params, token_in, r + ) + + return new_balance_in - balances[token_in] + + +def _solve_for_balance( + balances: NDArray, params: NDSurfaceParams, + target_index: int, target_r: float, +) -> float: + """Solve for one balance given all others and the invariant. + + The equation |A(v - offset)|² = r² is quadratic in b_k (the target balance). + We solve it directly as a quadratic rather than iterating. + + Let v_shifted = balances - offsets, and let b_k vary. + |A @ v_shifted|² = sum_i (sum_j A[i,j] * v_shifted[j])² + + The b_k contribution: v_shifted[k] = b_k - offset[k] + Collect terms in b_k: quadratic a*b_k² + b*b_k + c = r² + """ + A = compute_A(params) + offsets = compute_virtual_offsets(target_r, params) + + k = target_index + n = params.n + + # Decompose: for each row i of A, + # (A @ v_shifted)[i] = A[i,k] * (b_k - offset[k]) + sum_{j≠k} A[i,j] * (balances[j] - offset[j]) + # Let c_i = sum_{j≠k} A[i,j] * (balances[j] - offsets[j]) + # Then (A @ v_shifted)[i] = A[i,k] * u + c_i, where u = b_k - offsets[k] + # |A @ v_shifted|² = sum_i (A[i,k] * u + c_i)² + # = (sum_i A[i,k]²) * u² + 2*(sum_i A[i,k]*c_i)*u + sum_i c_i² + + c_vec = np.zeros(n) + for i in range(n): + for j in range(n): + if j != k: + c_vec[i] += A[i, j] * (balances[j] - offsets[j]) + + # Quadratic coefficients in u = b_k - offsets[k] + qa = sum(A[i, k] ** 2 for i in range(n)) + qb = 2.0 * sum(A[i, k] * c_vec[i] for i in range(n)) + qc = sum(c_vec[i] ** 2 for i in range(n)) - target_r ** 2 + + discriminant = qb ** 2 - 4.0 * qa * qc + if discriminant < 0: + discriminant = 0.0 + + u1 = (-qb + np.sqrt(discriminant)) / (2.0 * qa) + u2 = (-qb - np.sqrt(discriminant)) / (2.0 * qa) + + b1 = u1 + offsets[k] + b2 = u2 + offsets[k] + + # Choose the root closest to the current balance (continuity) + current = balances[k] + if b1 >= 0 and b2 >= 0: + if abs(b1 - current) < abs(b2 - current): + return b1 + return b2 + elif b1 >= 0: + return b1 + elif b2 >= 0: + return b2 + else: + return max(b1, b2) # Fallback + + +def mint( + state: NDSurfaceState, params: NDSurfaceParams, + amounts_in: NDArray, +) -> tuple[NDSurfaceState, float]: + """Mint $MYCO by depositing reserve assets. + + Computes how much $MYCO to mint based on invariant increase. + Returns new state and amount of $MYCO minted. + + Key property: mint amount is proportional to invariant increase, + which ensures the bonding surface geometry determines pricing. + """ + old_r = state.invariant + new_balances = state.balances + amounts_in + new_r = compute_invariant(new_balances, params) + + # BPT-style minting: mint proportional to invariant increase + if state.supply == 0: + # Initial mint: supply = invariant (bootstrapping) + myco_minted = new_r + new_supply = new_r + else: + ratio = new_r / old_r + myco_minted = state.supply * (ratio - 1.0) + new_supply = state.supply + myco_minted + + new_state = NDSurfaceState( + balances=new_balances, + invariant=new_r, + supply=new_supply, + ) + return new_state, myco_minted + + +def redeem( + state: NDSurfaceState, params: NDSurfaceParams, + myco_amount: float, + token_out: int | None = None, +) -> tuple[NDSurfaceState, NDArray]: + """Redeem $MYCO for reserve assets. + + If token_out is None, returns proportional share of all assets. + If token_out is specified, returns single-asset (with penalty). + + Returns new state and amounts out. + """ + assert myco_amount <= state.supply + fraction = myco_amount / state.supply + + if token_out is None: + # Proportional redemption + amounts_out = state.balances * fraction + new_balances = state.balances - amounts_out + else: + # Single-token exit: compute how much of token_out to return + # such that the invariant decreases by the correct fraction + target_r = state.invariant * (1.0 - fraction) + new_balances = state.balances.copy() + new_balance = _solve_for_balance( + new_balances, params, token_out, target_r + ) + amounts_out = np.zeros(params.n) + amounts_out[token_out] = state.balances[token_out] - new_balance + new_balances[token_out] = new_balance + + new_r = compute_invariant(new_balances, params) + new_supply = state.supply - myco_amount + + new_state = NDSurfaceState( + balances=new_balances, + invariant=new_r, + supply=new_supply, + ) + return new_state, amounts_out + + +def spot_prices( + balances: NDArray, params: NDSurfaceParams, + numeraire: int = 0, +) -> NDArray: + """Spot prices of all tokens relative to token[numeraire]. + + Returns array where prices[i] = price of token i in terms of numeraire. + """ + prices = np.ones(params.n) + dx = balances[numeraire] * 1e-8 + + for i in range(params.n): + if i == numeraire: + continue + dy = calc_out_given_in(balances, params, numeraire, i, dx) + prices[i] = dy / dx + + return prices diff --git a/src/primitives/redemption_curve.py b/src/primitives/redemption_curve.py new file mode 100644 index 0000000..70858f2 --- /dev/null +++ b/src/primitives/redemption_curve.py @@ -0,0 +1,224 @@ +"""P-AMM: Primary AMM for reserve-level redemption pricing. + +Source: gyrostable/gyd-core — PrimaryAMMV1.sol, IPAMM.sol + Technical paper: gyrostable/technical-papers/P-AMM/P-AMM technical paper.pdf + +The P-AMM determines how much collateral a $MYCO holder gets back when +redeeming, as a function of the reserve's backing ratio. It is NOT a swap +curve — it is a redemption pricing curve. + +Key insight: when the reserve is fully backed (1:1), redemption is at par. +As backing drops, a discount applies to protect remaining holders. + +Three regions by backing ratio: +1. Fully backed (ba ≥ 1): Redeem at par (1:1) +2. Partially backed: Parabolic discount (smooth degradation) +3. Deeply underbacked: Linear floor (minimum guaranteed rate) + +Parameters: + ᾱ (alphaBar): Curvature of the discount curve + x̄_U (xuBar): Threshold below which discounts begin + θ̄ (thetaBar): Floor redemption rate (never redeem below this) + outflowMemory: Exponential decay for tracking recent redemptions +""" + +import numpy as np +from dataclasses import dataclass + + +@dataclass +class PAMMParams: + """P-AMM parameters matching IPAMM.Params.""" + alpha_bar: float = 10.0 # Redemption discount slope (curvature) + xu_bar: float = 0.8 # Upper threshold fraction (no-discount zone) + theta_bar: float = 0.5 # Floor redemption rate (minimum) + outflow_memory: float = 0.999 # Decay parameter for flow tracking + + +@dataclass +class PAMMState: + """State of the P-AMM.""" + reserve_value: float # Total USD value of reserves + myco_supply: float # Total $MYCO supply + cumulative_redeemed: float = 0.0 # Recent redemption volume (with decay) + last_redemption_time: float = 0.0 + + @property + def backing_ratio(self) -> float: + """ba = reserveValue / mycoSupply.""" + if self.myco_supply == 0: + return float("inf") + return self.reserve_value / self.myco_supply + + +def compute_redemption_rate( + state: PAMMState, params: PAMMParams, redemption_amount: float +) -> float: + """Compute the effective redemption rate for a given redemption. + + Returns rate ∈ [θ̄, 1.0] — how many USD per $MYCO redeemed. + + From PrimaryAMMV1 Propositions 1-4: + - Region I: ba ≥ 1, redeem at min(1, available_per_token) + - Region II: ba < 1, parabolic discount + - Region III: deep underbacking, linear floor + """ + ba = state.backing_ratio + ya = state.myco_supply + + if ya == 0: + return 1.0 + + # No backing → floor rate + if ba <= 0: + return params.theta_bar + + # Fully backed or overbacked + if ba >= 1.0: + return 1.0 + + # Underbacked: compute discount + delta = 1.0 - ba # Shortfall fraction + + # Compute alpha (dynamic curvature) + alpha = _compute_alpha(params, ya, delta) + + # Compute xu (no-discount threshold) + xu = _compute_xu(params, ya, alpha, delta) + + # Compute xl (transition to linear floor) + xl = _compute_xl(params, ya, xu, alpha) + + # Current redemption level (cumulative + this redemption) + x = (state.cumulative_redeemed + redemption_amount / 2) / ya + + # Determine region and compute rate + if x <= xu: + # No discount zone: redeem at ba (par for the current backing) + rate = ba + elif xl is not None and x <= xl: + # Parabolic discount zone + # b(x) = ba - x + alpha * (x - xu)² / 2 + # rate = db/dx evaluated at x + rate = ba - x + alpha * (x - xu) ** 2 / 2 + # Ensure positive + rate = max(rate, params.theta_bar) + else: + # Linear floor zone + rate = params.theta_bar + + return min(rate, 1.0) + + +def redeem( + state: PAMMState, params: PAMMParams, + myco_amount: float, current_time: float, +) -> tuple[PAMMState, float]: + """Execute a redemption: burn $MYCO, return collateral. + + Returns (new_state, usd_returned). + """ + # Update flow tracking + time_diff = current_time - state.last_redemption_time + decay = params.outflow_memory ** max(time_diff, 0) + decayed_flow = state.cumulative_redeemed * decay + + # Compute rate + state.cumulative_redeemed = decayed_flow + rate = compute_redemption_rate(state, params, myco_amount) + + usd_returned = myco_amount * rate + + # Safety: never return more than reserve + usd_returned = min(usd_returned, state.reserve_value) + + # Update state + new_state = PAMMState( + reserve_value=state.reserve_value - usd_returned, + myco_supply=state.myco_supply - myco_amount, + cumulative_redeemed=decayed_flow + myco_amount / state.myco_supply, + last_redemption_time=current_time, + ) + + return new_state, usd_returned + + +def _compute_alpha(params: PAMMParams, ya: float, delta: float) -> float: + """Dynamic alpha: max(ᾱ/ya, min curvature keeping redemption ≥ θ̄). + + From Proposition 3. + """ + base_alpha = params.alpha_bar / ya if ya > 0 else params.alpha_bar + + # Ensure the floor θ̄ is achievable + # The minimum alpha that keeps b(x) ≥ θ̄ for all x + if delta > 0: + min_alpha = 2.0 * delta # Simplified bound + return max(base_alpha, min_alpha) + + return base_alpha + + +def _compute_xu( + params: PAMMParams, ya: float, alpha: float, delta: float +) -> float: + """Compute xu: threshold where discounts begin. + + From Proposition 4: + xu = min(x̄_U * ya, ya - sqrt(2*delta/alpha)) / ya + (normalized to fraction of supply) + """ + xu_raw = params.xu_bar + + if alpha > 0 and delta > 0: + xu_from_delta = 1.0 - np.sqrt(2.0 * delta / alpha) + xu_raw = min(xu_raw, max(0, xu_from_delta)) + + return xu_raw + + +def _compute_xl( + params: PAMMParams, ya: float, xu: float, alpha: float +) -> float | None: + """Compute xl: transition point to linear floor. + + From Proposition 2: + xl = ya - sqrt((ya - xu)² - 2*(ya - ba)/alpha) + Returns None if no transition (always in parabolic zone). + """ + if alpha <= 0: + return None + + inner = (1.0 - xu) ** 2 - 2.0 * (1.0 - params.theta_bar) / alpha + if inner < 0: + return None # Floor never reached + + return 1.0 - np.sqrt(inner) + + +def backing_ratio_trajectory( + initial_state: PAMMState, + params: PAMMParams, + redemption_schedule: list[tuple[float, float]], +) -> list[tuple[float, float, float]]: + """Simulate a sequence of redemptions. + + Args: + redemption_schedule: List of (time, myco_amount) tuples + + Returns: + List of (time, backing_ratio, usd_returned) tuples + """ + state = PAMMState( + reserve_value=initial_state.reserve_value, + myco_supply=initial_state.myco_supply, + cumulative_redeemed=initial_state.cumulative_redeemed, + last_redemption_time=initial_state.last_redemption_time, + ) + trajectory = [] + + for time, amount in redemption_schedule: + state, usd_out = redeem(state, params, amount, time) + trajectory.append((time, state.backing_ratio, usd_out)) + + return trajectory diff --git a/src/primitives/reserve_tranching.py b/src/primitives/reserve_tranching.py new file mode 100644 index 0000000..2bae558 --- /dev/null +++ b/src/primitives/reserve_tranching.py @@ -0,0 +1,230 @@ +"""Multi-vault reserve tranching with target weights and safety checks. + +Source: gyrostable/gyd-core — ReserveManager.sol, VaultRegistry.sol, + ReserveSafetyManager.sol, DataTypes.sol, VaultMetadataExtension.sol + +The reserve is split into N heterogeneous vaults (tranches), each holding +different collateral with different risk profiles. Governance sets target +weights, and the system enforces that operations move toward targets. + +Key concepts: +- Each vault has a target weight that can change over time (linear interpolation) +- Target weights drift with asset performance (price-weighted rebalancing) +- Safety checks ensure operations don't push weights further from targets +- Short-term flow limits prevent rapid capital movement between tranches +""" + +import numpy as np +from numpy.typing import NDArray +from dataclasses import dataclass, field +from typing import Optional + + +@dataclass +class VaultMetadata: + """Metadata for a single reserve vault/tranche.""" + name: str + target_weight: float # Current target weight (fraction of total) + price_at_calibration: float # Price when vault was registered + weight_at_calibration: float # Target weight at registration + weight_previous: float # Target weight before last calibration + calibration_time: float # Timestamp of last calibration + transition_duration: float # Seconds to interpolate between old/new weights + max_deviation: float = 0.1 # Maximum allowed deviation from target (fraction) + short_flow_memory: float = 0.99 # Exponential decay for flow tracking + short_flow_threshold: float = 0.05 # Max flow as fraction of vault value + + +@dataclass +class Vault: + """A single reserve vault holding collateral.""" + metadata: VaultMetadata + balance: float # USD value of reserves in this vault + current_price: float # Current price of vault's LP/underlying + recent_flow: float = 0.0 # Tracked recent flow (exponential moving sum) + last_flow_time: float = 0.0 + + +@dataclass +class ReserveState: + """State of the entire reserve system.""" + vaults: list[Vault] + total_value: float = 0.0 + myco_supply: float = 0.0 + + def __post_init__(self): + self.total_value = sum(v.balance for v in self.vaults) + + +def current_weights(state: ReserveState) -> NDArray: + """Compute current weight of each vault.""" + if state.total_value == 0: + return np.zeros(len(state.vaults)) + return np.array([v.balance / state.total_value for v in state.vaults]) + + +def target_weights(state: ReserveState, current_time: float) -> NDArray: + """Compute current target weights with time interpolation and price drift. + + From VaultMetadataExtension.scheduleWeight + ReserveManager.getReserveState: + 1. Linearly interpolate between previous and current calibrated weight + 2. Apply price drift: weight drifts with asset performance + """ + raw_weights = [] + for v in state.vaults: + meta = v.metadata + # Step 1: Time interpolation + elapsed = current_time - meta.calibration_time + if meta.transition_duration > 0 and elapsed < meta.transition_duration: + progress = elapsed / meta.transition_duration + sched_weight = meta.weight_previous + progress * ( + meta.weight_at_calibration - meta.weight_previous + ) + else: + sched_weight = meta.weight_at_calibration + + # Step 2: Price drift — weighted return + if meta.price_at_calibration > 0: + price_ratio = v.current_price / meta.price_at_calibration + else: + price_ratio = 1.0 + + raw_weights.append(sched_weight * price_ratio) + + # Normalize + total = sum(raw_weights) + if total == 0: + return np.array([1.0 / len(state.vaults)] * len(state.vaults)) + return np.array([w / total for w in raw_weights]) + + +def weight_deviations(state: ReserveState, current_time: float) -> NDArray: + """Compute deviation of current weights from targets. + + Returns array of (current - target) for each vault. + Positive = overweight, negative = underweight. + """ + cw = current_weights(state) + tw = target_weights(state, current_time) + return cw - tw + + +def is_safe_to_mint( + state: ReserveState, deposit_amounts: NDArray, current_time: float +) -> tuple[bool, str]: + """Check if a deposit is safe (doesn't worsen weight imbalance). + + From ReserveSafetyManager._safeToExecuteOutsideEpsilon: + For each vault outside the epsilon band, the operation must move + the weight CLOSER to the target, not further away. + """ + tw = target_weights(state, current_time) + cw = current_weights(state) + + # Simulate post-deposit state + new_total = state.total_value + sum(deposit_amounts) + if new_total == 0: + return True, "ok" + + new_weights = np.array([ + (state.vaults[i].balance + deposit_amounts[i]) / new_total + for i in range(len(state.vaults)) + ]) + + for i, v in enumerate(state.vaults): + max_dev = v.metadata.max_deviation * tw[i] + current_dev = abs(cw[i] - tw[i]) + new_dev = abs(new_weights[i] - tw[i]) + + if current_dev > max_dev and new_dev > current_dev: + return False, f"Vault '{v.metadata.name}' deviation would increase from {current_dev:.4f} to {new_dev:.4f}" + + return True, "ok" + + +def is_safe_to_redeem( + state: ReserveState, withdrawal_amounts: NDArray, current_time: float +) -> tuple[bool, str]: + """Check if a redemption is safe.""" + tw = target_weights(state, current_time) + cw = current_weights(state) + + total_withdrawn = sum(withdrawal_amounts) + new_total = state.total_value - total_withdrawn + if new_total <= 0: + return False, "Would drain entire reserve" + + new_weights = np.array([ + (state.vaults[i].balance - withdrawal_amounts[i]) / new_total + for i in range(len(state.vaults)) + ]) + + for i, v in enumerate(state.vaults): + if withdrawal_amounts[i] > v.balance: + return False, f"Insufficient balance in vault '{v.metadata.name}'" + + max_dev = v.metadata.max_deviation * tw[i] + current_dev = abs(cw[i] - tw[i]) + new_dev = abs(new_weights[i] - tw[i]) + + if current_dev > max_dev and new_dev > current_dev: + return False, f"Vault '{v.metadata.name}' deviation would increase" + + return True, "ok" + + +def update_flow(vault: Vault, flow_amount: float, current_time: float) -> Vault: + """Update vault's flow tracking with exponential memory. + + From Flow.sol: flow_history * memory^(block_diff) + new_flow + """ + time_diff = current_time - vault.last_flow_time + decay = vault.metadata.short_flow_memory ** time_diff + vault.recent_flow = vault.recent_flow * decay + abs(flow_amount) + vault.last_flow_time = current_time + return vault + + +def check_flow_limit(vault: Vault) -> tuple[bool, str]: + """Check if recent flow exceeds the vault's short-term threshold.""" + limit = vault.balance * vault.metadata.short_flow_threshold + if vault.recent_flow > limit: + return False, f"Flow {vault.recent_flow:.2f} exceeds limit {limit:.2f}" + return True, "ok" + + +def optimal_deposit_split( + state: ReserveState, total_deposit: float, current_time: float +) -> NDArray: + """Compute deposit split that moves weights closest to targets. + + Simple greedy: allocate proportional to how underweight each vault is. + """ + tw = target_weights(state, current_time) + cw = current_weights(state) + underweight = np.maximum(tw - cw, 0) + + total_underweight = sum(underweight) + if total_underweight == 0: + # All at target, deposit proportionally + return total_deposit * tw + + return total_deposit * (underweight / total_underweight) + + +def optimal_withdrawal_split( + state: ReserveState, total_withdrawal: float, current_time: float +) -> NDArray: + """Compute withdrawal split that moves weights closest to targets. + + Withdraw more from overweight vaults. + """ + tw = target_weights(state, current_time) + cw = current_weights(state) + overweight = np.maximum(cw - tw, 0) + + total_overweight = sum(overweight) + if total_overweight == 0: + return total_withdrawal * cw # Proportional + + return total_withdrawal * (overweight / total_overweight) diff --git a/src/primitives/stableswap.py b/src/primitives/stableswap.py new file mode 100644 index 0000000..c9e2ce8 --- /dev/null +++ b/src/primitives/stableswap.py @@ -0,0 +1,176 @@ +"""Curve/Balancer StableSwap invariant. + +Source: balancer/balancer-v2-monorepo, StableMath.sol + balancer/balancer-v3-monorepo, StableMath.sol + curve.fi StableSwap whitepaper + +The StableSwap invariant interpolates between constant-sum (x+y=D) +and constant-product (x*y=k) using an amplification parameter A: + + A * n^n * S + D = A * D * n^n + D^(n+1) / (n^n * P) + +where: + S = sum of balances + P = product of balances + n = number of tokens + A = amplification coefficient (1-50000) + D = invariant (total "virtual liquidity") + +When A → ∞: behaves like constant sum (perfect stable peg) +When A → 0: behaves like constant product (standard AMM) + +Solved iteratively via Newton-Raphson (up to 255 steps, converges in ~5). +""" + +import numpy as np +from numpy.typing import NDArray + + +MAX_AMP = 50_000 # Balancer V3 max +MAX_ITERATIONS = 255 + + +def compute_invariant(balances: NDArray, amp: float) -> float: + """Compute the StableSwap invariant D. + + Uses Newton-Raphson iteration: + D_{n+1} = (A*n^n*S + n*D_n^(n+1)/(n^n*P)) / (A*n^n - 1 + (n+1)*D_n^n/(n^n*P)) + + Simplified iterative form (from Curve/Balancer): + D_{n+1} = (A*n^n*S + n*D_prod) / (A*n^n - 1 + (n+1)*D_prod/D_n) + where D_prod = D_n^(n+1) / (n^n * prod(b_i)) + + Args: + balances: Array of N token balances + amp: Amplification coefficient A + + Returns: + Invariant D + """ + n = len(balances) + S = float(np.sum(balances)) + + if S == 0: + return 0.0 + + Ann = amp * n**n + + D = S # Initial guess + for _ in range(MAX_ITERATIONS): + # D_prod = D^(n+1) / (n^n * prod(balances)) + D_prod = D + for b in balances: + D_prod = D_prod * D / (b * n) + + prev_D = D + # numerator = Ann * S + n * D_prod + # denominator = (Ann - 1) * D + (n + 1) * D_prod + # D = numerator * D / denominator (multiply by D to avoid division issues) + numerator = Ann * S + n * D_prod + denominator = (Ann - 1) + (n + 1) * D_prod / D + D = numerator / denominator + + if abs(D - prev_D) <= 1e-12: + return D + + raise ValueError(f"StableSwap invariant did not converge (S={S}, A={amp})") + + +def calc_out_given_in( + balances: NDArray, + amp: float, + token_in_index: int, + token_out_index: int, + amount_in: float, +) -> float: + """Calculate output amount for a swap. + + Updates balance[token_in] += amount_in, then solves for the new + balance[token_out] that preserves the invariant D. + + Returns: + amount_out (positive) + """ + D = compute_invariant(balances, amp) + + new_balances = balances.copy().astype(float) + new_balances[token_in_index] += amount_in + + new_balance_out = _get_balance_given_invariant_and_others( + new_balances, amp, D, token_out_index + ) + + return balances[token_out_index] - new_balance_out + + +def calc_in_given_out( + balances: NDArray, + amp: float, + token_in_index: int, + token_out_index: int, + amount_out: float, +) -> float: + """Calculate input amount for exact output swap.""" + D = compute_invariant(balances, amp) + + new_balances = balances.copy().astype(float) + new_balances[token_out_index] -= amount_out + + new_balance_in = _get_balance_given_invariant_and_others( + new_balances, amp, D, token_in_index + ) + + return new_balance_in - balances[token_in_index] + + +def _get_balance_given_invariant_and_others( + balances: NDArray, + amp: float, + D: float, + token_index: int, +) -> float: + """Find the balance of one token given D and all other balances. + + Solves the quadratic (for the target token y): + y^2 + (S' + D/(A*n^n) - D) * y = D^(n+1) / (A * n^(2n) * P') + + where S' = sum of OTHER balances, P' = product of OTHER balances. + + Uses Newton-Raphson: y_{k+1} = (y_k^2 + c) / (2*y_k + b) + """ + n = len(balances) + Ann = amp * n**n + + # S' and product term + S_prime = 0.0 + c = D + for i in range(n): + if i == token_index: + continue + S_prime += balances[i] + c = c * D / (balances[i] * n) + + c = c * D / (Ann * n) + b = S_prime + D / Ann # b = S' + D/(A*n^n) + + # Newton iteration for y: y = (y^2 + c) / (2y + b - D) + y = D + for _ in range(MAX_ITERATIONS): + prev_y = y + y = (y * y + c) / (2 * y + b - D) + if abs(y - prev_y) <= 1e-12: + return y + + raise ValueError("Balance solver did not converge") + + +def spot_price( + balances: NDArray, + amp: float, + token_in_index: int, + token_out_index: int, +) -> float: + """Approximate spot price via infinitesimal swap.""" + dx = balances[token_in_index] * 1e-8 + dy = calc_out_given_in(balances, amp, token_in_index, token_out_index, dx) + return dy / dx diff --git a/src/primitives/weighted_product.py b/src/primitives/weighted_product.py new file mode 100644 index 0000000..9cf584c --- /dev/null +++ b/src/primitives/weighted_product.py @@ -0,0 +1,154 @@ +"""Balancer Weighted Constant Product invariant. + +Source: balancer/balancer-v2-monorepo, WeightedMath.sol + balancer/balancer-v3-monorepo, WeightedMath.sol + +The simplest N-asset invariant: + + I = prod(b_i ^ w_i) for i in [0..N] + +where w_i are normalized weights (sum to 1) and b_i are token balances. + +Swap formula: + amountOut = balanceOut * (1 - (balanceIn / (balanceIn + amountIn))^(wIn/wOut)) + amountIn = balanceIn * ((balanceOut / (balanceOut - amountOut))^(wOut/wIn) - 1) + +Spot price of token i in terms of token j: + price_ij = (b_j / w_j) / (b_i / w_i) + +Key property: invariant is homogeneous of degree 1. + I(k*b_1, ..., k*b_n) = k * I(b_1, ..., b_n) +""" + +import numpy as np +from numpy.typing import NDArray + +from src.utils.fixed_point import safe_pow + + +def compute_invariant(balances: NDArray, weights: NDArray) -> float: + """Compute I = prod(b_i ^ w_i). + + Args: + balances: Array of N token balances (all positive) + weights: Array of N normalized weights (sum to 1) + + Returns: + Invariant value + """ + assert len(balances) == len(weights) + assert abs(sum(weights) - 1.0) < 1e-10, f"Weights must sum to 1, got {sum(weights)}" + assert all(b > 0 for b in balances), "All balances must be positive" + assert all(w > 0 for w in weights), "All weights must be positive" + + result = 1.0 + for b, w in zip(balances, weights): + result *= safe_pow(b, w) + return result + + +def spot_price( + balance_in: float, + weight_in: float, + balance_out: float, + weight_out: float, +) -> float: + """Spot price of token_in in terms of token_out. + + price = (balance_out / weight_out) / (balance_in / weight_in) + """ + return (balance_out / weight_out) / (balance_in / weight_in) + + +def calc_out_given_in( + balance_in: float, + weight_in: float, + balance_out: float, + weight_out: float, + amount_in: float, +) -> float: + """Calculate output amount for exact input swap. + + Formula: aO = bO * (1 - (bI / (bI + aI))^(wI/wO)) + """ + assert amount_in > 0 + assert balance_in > 0 and balance_out > 0 + + ratio = balance_in / (balance_in + amount_in) + power = safe_pow(ratio, weight_in / weight_out) + return balance_out * (1.0 - power) + + +def calc_in_given_out( + balance_in: float, + weight_in: float, + balance_out: float, + weight_out: float, + amount_out: float, +) -> float: + """Calculate input amount for exact output swap. + + Formula: aI = bI * ((bO / (bO - aO))^(wO/wI) - 1) + """ + assert 0 < amount_out < balance_out + assert balance_in > 0 + + ratio = balance_out / (balance_out - amount_out) + power = safe_pow(ratio, weight_out / weight_in) + return balance_in * (power - 1.0) + + +def calc_bpt_out_given_exact_tokens_in( + balances: NDArray, + weights: NDArray, + amounts_in: NDArray, + bpt_supply: float, + swap_fee: float = 0.0, +) -> float: + """Calculate BPT (LP tokens) minted for depositing exact token amounts. + + For proportional deposits (no fee): + bpt_out = bpt_supply * (new_invariant / old_invariant - 1) + + For unbalanced deposits, the non-proportional portion incurs swap fees. + """ + old_invariant = compute_invariant(balances, weights) + new_balances = balances + amounts_in + + if swap_fee > 0: + # Calculate proportional amounts + # The portion exceeding proportional is the "swap" portion + invariant_ratio = 0.0 + for i in range(len(balances)): + if amounts_in[i] > 0: + ratio = amounts_in[i] / balances[i] + if invariant_ratio == 0.0: + invariant_ratio = ratio + else: + invariant_ratio = min(invariant_ratio, ratio) + + for i in range(len(balances)): + proportional = balances[i] * invariant_ratio + excess = amounts_in[i] - proportional + if excess > 0: + new_balances[i] = balances[i] + proportional + excess * (1.0 - swap_fee) + + new_invariant = compute_invariant(new_balances, weights) + return bpt_supply * (new_invariant / old_invariant - 1.0) + + +def calc_token_out_given_exact_bpt_in( + balances: NDArray, + weights: NDArray, + token_index: int, + bpt_in: float, + bpt_supply: float, +) -> float: + """Calculate tokens received for burning BPT (single-token exit). + + new_balance = old_balance * (1 - bpt_in/bpt_supply)^(1/weight) + token_out = old_balance - new_balance + """ + invariant_ratio = 1.0 - bpt_in / bpt_supply + new_balance = balances[token_index] * safe_pow(invariant_ratio, 1.0 / weights[token_index]) + return balances[token_index] - new_balance diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/fixed_point.py b/src/utils/fixed_point.py new file mode 100644 index 0000000..c2172ab --- /dev/null +++ b/src/utils/fixed_point.py @@ -0,0 +1,59 @@ +"""Fixed-point arithmetic utilities matching Solidity precision levels. + +Two precision modes mirroring Balancer/Gyroscope conventions: +- ONE_18 = 1e18: Standard balance/price precision (Balancer WeightedMath, StableMath) +- ONE_38 = 1e38: High precision for derived parameters (Gyro E-CLP DerivedParams) + +In Python simulations we use float64 for speed. These constants exist for: +1. Documenting which precision level a calculation operates at +2. Converting to/from integer representations for Solidity cross-checks +3. Detecting precision loss in critical paths +""" + +import numpy as np + +ONE_18 = 1e18 +ONE_38 = 1e38 + +# Balancer V3 constants +MAX_WEIGHT = 0.99 # 99% max single-token weight +MIN_WEIGHT = 0.01 # 1% min weight +MAX_IN_RATIO = 0.3 # 30% max single swap as fraction of balance +MAX_OUT_RATIO = 0.3 +MAX_INVARIANT_RATIO = 3.0 # 300% max invariant growth in single op +MIN_INVARIANT_RATIO = 0.7 # 70% min invariant shrink + + +def to_fp18(x: float) -> int: + """Convert float to 18-decimal fixed-point integer.""" + return int(x * ONE_18) + + +def from_fp18(x: int) -> float: + """Convert 18-decimal fixed-point integer to float.""" + return x / ONE_18 + + +def to_fp38(x: float) -> int: + """Convert float to 38-decimal fixed-point integer.""" + return int(x * ONE_38) + + +def from_fp38(x: int) -> float: + """Convert 38-decimal fixed-point integer to float.""" + return x / ONE_38 + + +def safe_pow(base: float, exp: float) -> float: + """Power function matching Balancer LogExpMath domain. + + Domain: base in (0, 2^188.56), implemented as exp(exp * ln(base)). + """ + if base <= 0: + raise ValueError(f"base must be positive, got {base}") + return np.exp(exp * np.log(base)) + + +def complement(x: float) -> float: + """Return 1 - x, clamped to 0. Used for fee calculations.""" + return max(0.0, 1.0 - x) diff --git a/src/utils/linear_algebra.py b/src/utils/linear_algebra.py new file mode 100644 index 0000000..f72ff28 --- /dev/null +++ b/src/utils/linear_algebra.py @@ -0,0 +1,124 @@ +"""Linear algebra utilities for ellipsoidal bonding surfaces. + +The core operation is the A-matrix transform from Gyroscope E-CLP: + A maps an ellipse to the unit circle + A^{-1} maps from circle space back to reserve space + +For 2D (E-CLP): + A = [[c/λ, -s/λ], + [s, c ]] + +For N-D (generalized ellipsoid): + A = diag(1/λ_i) @ Q^T + where Q is an orthogonal rotation matrix and λ_i are stretch factors. +""" + +import numpy as np +from numpy.typing import NDArray + + +def make_eclp_A(c: float, s: float, lam: float) -> NDArray: + """Build the 2D E-CLP transformation matrix. + + Args: + c: cos(-φ), rotation parameter (c >= 0) + s: sin(-φ), rotation parameter (s >= 0) + lam: λ >= 1, stretching factor (1 = circle, larger = more concentrated) + + Returns: + 2x2 matrix A mapping ellipse → unit circle + """ + return np.array([ + [c / lam, -s / lam], + [s, c], + ]) + + +def make_eclp_A_inv(c: float, s: float, lam: float) -> NDArray: + """Build the 2D E-CLP inverse transformation matrix. + + Returns: + 2x2 matrix A^{-1} mapping unit circle → ellipse + """ + return np.array([ + [c * lam, s], + [-s * lam, c], + ]) + + +def make_nd_A(lambdas: NDArray, Q: NDArray) -> NDArray: + """Build an N-dimensional ellipsoid transformation matrix. + + A = diag(1/λ_i) @ Q^T + + Args: + lambdas: Array of N stretch factors (each >= 1) + Q: N×N orthogonal rotation matrix (Q @ Q^T = I) + + Returns: + N×N matrix A mapping ellipsoid → unit hypersphere + """ + n = len(lambdas) + assert Q.shape == (n, n), f"Q must be {n}x{n}" + diag_inv_lam = np.diag(1.0 / lambdas) + return diag_inv_lam @ Q.T + + +def make_nd_A_inv(lambdas: NDArray, Q: NDArray) -> NDArray: + """Build the inverse N-dimensional ellipsoid transformation. + + A^{-1} = Q @ diag(λ_i) + + Args: + lambdas: Array of N stretch factors + Q: N×N orthogonal rotation matrix + + Returns: + N×N matrix A^{-1} mapping unit hypersphere → ellipsoid + """ + n = len(lambdas) + assert Q.shape == (n, n) + return Q @ np.diag(lambdas) + + +def random_rotation_matrix(n: int, rng: np.random.Generator | None = None) -> NDArray: + """Generate a random N×N orthogonal matrix (uniform Haar measure). + + Uses QR decomposition of a random Gaussian matrix. + Useful for testing and parameter exploration. + """ + if rng is None: + rng = np.random.default_rng() + H = rng.standard_normal((n, n)) + Q, R = np.linalg.qr(H) + # Ensure proper rotation (det = +1) not reflection + Q = Q @ np.diag(np.sign(np.diag(R))) + if np.linalg.det(Q) < 0: + Q[:, 0] *= -1 + return Q + + +def givens_rotation(n: int, i: int, j: int, theta: float) -> NDArray: + """Build an N×N Givens rotation matrix in the (i,j) plane. + + For 2D E-CLP, this is just the standard rotation by -φ. + For N-D, composing multiple Givens rotations gives a + parameterized rotation matrix with N*(N-1)/2 angles. + """ + G = np.eye(n) + c, s = np.cos(theta), np.sin(theta) + G[i, i] = c + G[j, j] = c + G[i, j] = -s + G[j, i] = s + return G + + +def ellipsoid_norm_sq(v: NDArray, A: NDArray) -> float: + """Compute |A @ v|^2, the squared ellipsoid norm. + + This is the core of the E-CLP invariant: trading occurs on the surface + where |A(v - offset)|^2 = r^2. + """ + Av = A @ v + return float(Av @ Av) diff --git a/src/utils/newton.py b/src/utils/newton.py new file mode 100644 index 0000000..72516bd --- /dev/null +++ b/src/utils/newton.py @@ -0,0 +1,79 @@ +"""Generic Newton-Raphson solver. + +Used by StableSwap (solving for D), 3-CLP (cubic), E-CLP (invariant), +and P-AMM (inverse redemption). Matches the iteration pattern from +Balancer StableMath.sol and Gyro Gyro3CLPMath.sol. +""" + +from typing import Callable + +MAX_ITERATIONS = 255 # Matches Balancer StableMath + + +def newton_solve( + f: Callable[[float], float], + df: Callable[[float], float], + x0: float, + tol: float = 1e-12, + max_iter: int = MAX_ITERATIONS, +) -> float: + """Find root of f(x) = 0 via Newton-Raphson. + + Args: + f: Function whose root we seek + df: Derivative of f + x0: Initial guess + tol: Convergence tolerance (absolute) + max_iter: Maximum iterations + + Returns: + x such that |f(x)| < tol + + Raises: + ValueError: If solver fails to converge + """ + x = x0 + for i in range(max_iter): + fx = f(x) + if abs(fx) < tol: + return x + dfx = df(x) + if dfx == 0: + raise ValueError(f"Zero derivative at iteration {i}, x={x}") + x = x - fx / dfx + raise ValueError(f"Newton solver did not converge after {max_iter} iterations") + + +def newton_solve_increasing( + f: Callable[[float], float], + x0: float, + tol: float = 1e-12, + max_iter: int = MAX_ITERATIONS, +) -> float: + """Newton solver for monotonically increasing f, without explicit derivative. + + Uses the Balancer/Gyro pattern: for finding D in StableSwap or L in 2-CLP, + the function is monotonic so we can use the secant-like iteration: + x_{n+1} = x_n * f_target / f(x_n) + + This is equivalent to Newton on the invariant ratio. + + Args: + f: Monotonically increasing function + x0: Initial guess (must give f(x0) > 0) + tol: Relative convergence tolerance + max_iter: Maximum iterations + + Returns: + x such that |x_{n+1} - x_n| / x_n < tol + """ + x = x0 + for _ in range(max_iter): + fx = f(x) + if fx == 0: + return x + x_new = x * x0 / fx # Assumes target is x0 (identity scaling) + if abs(x_new - x) < tol * abs(x): + return x_new + x = x_new + raise ValueError(f"Monotonic Newton solver did not converge after {max_iter} iterations") diff --git a/src/utils/plotting.py b/src/utils/plotting.py new file mode 100644 index 0000000..5bdea71 --- /dev/null +++ b/src/utils/plotting.py @@ -0,0 +1,129 @@ +"""Visualization helpers for bonding curves and surfaces.""" + +import numpy as np +import matplotlib.pyplot as plt +from matplotlib.figure import Figure +from numpy.typing import NDArray + + +def plot_curve_2d( + f: callable, + x_range: tuple[float, float] = (0.01, 10.0), + n_points: int = 500, + title: str = "Bonding Curve", + xlabel: str = "Supply", + ylabel: str = "Price", + ax: plt.Axes | None = None, + **kwargs, +) -> plt.Axes: + """Plot a 1D bonding curve (price vs supply or similar).""" + if ax is None: + _, ax = plt.subplots(1, 1, figsize=(8, 5)) + x = np.linspace(x_range[0], x_range[1], n_points) + y = np.array([f(xi) for xi in x]) + ax.plot(x, y, **kwargs) + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + ax.set_title(title) + ax.grid(True, alpha=0.3) + return ax + + +def plot_surface_3d( + f: callable, + x_range: tuple[float, float] = (0.1, 10.0), + y_range: tuple[float, float] = (0.1, 10.0), + n_points: int = 80, + title: str = "Bonding Surface", + xlabel: str = "Reserve X", + ylabel: str = "Reserve Y", + zlabel: str = "Invariant", +) -> Figure: + """Plot a 2-asset bonding surface as a 3D mesh.""" + fig = plt.figure(figsize=(10, 7)) + ax = fig.add_subplot(111, projection="3d") + x = np.linspace(x_range[0], x_range[1], n_points) + y = np.linspace(y_range[0], y_range[1], n_points) + X, Y = np.meshgrid(x, y) + Z = np.zeros_like(X) + for i in range(n_points): + for j in range(n_points): + try: + Z[i, j] = f(X[i, j], Y[i, j]) + except (ValueError, ZeroDivisionError): + Z[i, j] = np.nan + ax.plot_surface(X, Y, Z, cmap="viridis", alpha=0.8) + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + ax.set_zlabel(zlabel) + ax.set_title(title) + return fig + + +def plot_level_curves( + f: callable, + x_range: tuple[float, float] = (0.1, 10.0), + y_range: tuple[float, float] = (0.1, 10.0), + levels: int | NDArray = 20, + n_points: int = 200, + title: str = "Level Curves (Iso-Invariant)", + xlabel: str = "Balance X", + ylabel: str = "Balance Y", + ax: plt.Axes | None = None, +) -> plt.Axes: + """Plot contour lines of a 2-asset invariant function.""" + if ax is None: + _, ax = plt.subplots(1, 1, figsize=(8, 8)) + x = np.linspace(x_range[0], x_range[1], n_points) + y = np.linspace(y_range[0], y_range[1], n_points) + X, Y = np.meshgrid(x, y) + Z = np.zeros_like(X) + for i in range(n_points): + for j in range(n_points): + try: + Z[i, j] = f(X[i, j], Y[i, j]) + except (ValueError, ZeroDivisionError): + Z[i, j] = np.nan + cs = ax.contour(X, Y, Z, levels=levels, cmap="viridis") + ax.clabel(cs, inline=True, fontsize=8) + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + ax.set_title(title) + ax.set_aspect("equal") + ax.grid(True, alpha=0.3) + return ax + + +def plot_reserve_composition( + weights: NDArray, + labels: list[str], + title: str = "Reserve Composition", + ax: plt.Axes | None = None, +) -> plt.Axes: + """Pie chart of reserve asset weights.""" + if ax is None: + _, ax = plt.subplots(1, 1, figsize=(8, 6)) + ax.pie(weights, labels=labels, autopct="%1.1f%%", startangle=90) + ax.set_title(title) + return ax + + +def plot_time_series( + times: NDArray, + series: dict[str, NDArray], + title: str = "Simulation", + xlabel: str = "Time", + ylabel: str = "Value", + ax: plt.Axes | None = None, +) -> plt.Axes: + """Plot multiple time series on one axis.""" + if ax is None: + _, ax = plt.subplots(1, 1, figsize=(10, 5)) + for label, values in series.items(): + ax.plot(times, values, label=label) + ax.set_xlabel(xlabel) + ax.set_ylabel(ylabel) + ax.set_title(title) + ax.legend() + ax.grid(True, alpha=0.3) + return ax diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_commitments.py b/tests/test_commitments.py new file mode 100644 index 0000000..6d617c9 --- /dev/null +++ b/tests/test_commitments.py @@ -0,0 +1,164 @@ +"""Tests for commitment-based issuance channels.""" + +import numpy as np +import pytest +from src.commitments.labor import ( + LaborIssuanceSystem, ContributorState, ContributionType, + attest_contribution, claim_tokens, create_default_system, +) +from src.commitments.subscription import ( + SubscriptionSystem, SubscriptionTier, + create_subscription, process_payment, cancel_subscription, + create_default_tiers, +) +from src.commitments.staking import ( + StakingParams, StakingSystem, + lockup_multiplier, create_stake, compute_pending_bonus, + claim_bonus, early_withdraw, +) + + +class TestLabor: + def test_attest_and_claim(self): + system = create_default_system() + contrib = ContributorState(address="alice") + + contrib = attest_contribution(system, contrib, "code", 5.0, current_time=0) + assert contrib.unclaimed_units["code"] == 5.0 + + system, contrib, tokens = claim_tokens(system, contrib, "code", current_time=0) + assert tokens == 5.0 * 10.0 # 5 units * 10 tokens/unit + assert contrib.unclaimed_units["code"] == 0.0 + + def test_rate_limit(self): + system = create_default_system() + contrib = ContributorState(address="bob") + + # Attest more than max_units_per_period + contrib = attest_contribution(system, contrib, "code", 100.0, current_time=0) + system, contrib, tokens = claim_tokens(system, contrib, "code", current_time=0) + + # Should be capped at 50 units (max_units_per_period for code) + assert tokens == 50.0 * 10.0 + + def test_cooldown(self): + system = create_default_system() + contrib = ContributorState(address="carol") + + contrib = attest_contribution(system, contrib, "code", 10.0, current_time=0) + system, contrib, t1 = claim_tokens(system, contrib, "code", current_time=0) + assert t1 > 0 + + # Try to claim again immediately — should fail (cooldown = 1 day) + contrib = attest_contribution(system, contrib, "code", 5.0, current_time=0.5) + system, contrib, t2 = claim_tokens(system, contrib, "code", current_time=0.5) + assert t2 == 0.0 + + def test_global_cap(self): + system = create_default_system() + system.max_total_mint = 100.0 + contrib = ContributorState(address="dave") + + contrib = attest_contribution(system, contrib, "code", 20.0, current_time=0) + system, contrib, tokens = claim_tokens(system, contrib, "code", current_time=0) + assert tokens <= 100.0 + + +class TestSubscription: + def test_create_and_process(self): + tiers = create_default_tiers() + system = SubscriptionSystem(tiers=tiers) + + system, sub = create_subscription(system, "alice", "supporter", 0.0) + assert sub.is_active + + # Process after one period + system, tokens = process_payment(system, "alice", 30.0) + assert tokens > 0 + assert system.total_revenue == 10.0 + + def test_loyalty_multiplier_grows(self): + tiers = create_default_tiers() + system = SubscriptionSystem(tiers=tiers) + + system, _ = create_subscription(system, "alice", "supporter", 0.0) + + # First payment + system, tokens_early = process_payment(system, "alice", 30.0) + + # Payment after 1 year (more loyalty) + system, tokens_later = process_payment(system, "alice", 395.0) + # Later payment should earn more due to loyalty + assert tokens_later > tokens_early + + def test_cancellation(self): + tiers = create_default_tiers() + system = SubscriptionSystem(tiers=tiers) + + system, _ = create_subscription(system, "alice", "supporter", 0.0) + system = cancel_subscription(system, "alice") + + system, tokens = process_payment(system, "alice", 30.0) + assert tokens == 0.0 + + def test_no_payment_before_period(self): + tiers = create_default_tiers() + system = SubscriptionSystem(tiers=tiers) + + system, _ = create_subscription(system, "alice", "supporter", 0.0) + system, tokens = process_payment(system, "alice", 15.0) # Half period + assert tokens == 0.0 + + +class TestStaking: + def test_lockup_multiplier_sqrt(self): + params = StakingParams(multiplier_curve="sqrt") + m_short = lockup_multiplier(30, params) + m_long = lockup_multiplier(365, params) + assert m_long > m_short + assert m_long == params.max_multiplier # Max at max duration + + def test_lockup_multiplier_concave(self): + """Sqrt curve should be concave (diminishing returns).""" + params = StakingParams(multiplier_curve="sqrt") + m_30 = lockup_multiplier(30, params) + m_60 = lockup_multiplier(60, params) + m_90 = lockup_multiplier(90, params) + # Gain from 30→60 should be > gain from 60→90 + gain_1 = m_60 - m_30 + gain_2 = m_90 - m_60 + assert gain_1 > gain_2 + + def test_create_and_claim(self): + system = StakingSystem(params=StakingParams()) + system, pos = create_stake(system, "alice", 1000.0, "MYCO", 90.0, 0.0) + assert system.total_staked == 1000.0 + + # Can't claim before lockup ends + system, tokens = claim_bonus(system, "alice", 50.0) + assert tokens == 0.0 + + # Can claim after lockup + system, tokens = claim_bonus(system, "alice", 91.0) + assert tokens > 0 + + def test_early_withdrawal_penalty(self): + system = StakingSystem(params=StakingParams()) + system, _ = create_stake(system, "bob", 1000.0, "MYCO", 90.0, 0.0) + + system, returned, forfeited = early_withdraw(system, "bob", 45.0) + assert returned == 1000.0 # Get staked amount back + assert forfeited > 0 # But forfeit some bonus + + def test_longer_lockup_more_bonus(self): + params = StakingParams() + system1 = StakingSystem(params=params) + system2 = StakingSystem(params=params) + + system1, pos1 = create_stake(system1, "a", 1000.0, "MYCO", 30.0, 0.0) + system2, pos2 = create_stake(system2, "b", 1000.0, "MYCO", 365.0, 0.0) + + bonus1 = compute_pending_bonus(pos1, params, 30.0) + bonus2 = compute_pending_bonus(pos2, params, 365.0) + + assert bonus2 > bonus1 diff --git a/tests/test_composed.py b/tests/test_composed.py new file mode 100644 index 0000000..a1c4158 --- /dev/null +++ b/tests/test_composed.py @@ -0,0 +1,102 @@ +"""Tests for the composed MYCO system.""" + +import numpy as np +import pytest +from src.composed.myco_surface import MycoSystem, MycoSystemConfig +from src.composed.simulator import ( + run_scenario, scenario_token_launch, scenario_bank_run, +) +from src.commitments.labor import ContributorState, attest_contribution + + +class TestMycoSystem: + def test_bootstrap_deposit(self): + """First deposit should bootstrap the system.""" + system = MycoSystem(MycoSystemConfig(n_reserve_assets=2)) + amounts = np.array([1000.0, 1000.0]) + minted, meta = system.deposit(amounts, current_time=0) + + assert minted > 0 + assert meta["safe"] + assert system.state.supply > 0 + assert system.state.reserve_state.total_value == 2000.0 + + def test_deposit_and_redeem(self): + """Deposit then partial redeem should work.""" + system = MycoSystem(MycoSystemConfig(n_reserve_assets=2)) + system.deposit(np.array([1000.0, 1000.0]), current_time=0) + + initial_supply = system.state.supply + amounts_out, meta = system.redeem(initial_supply * 0.1, current_time=1) + + assert np.sum(amounts_out) > 0 + assert system.state.supply < initial_supply + + def test_backing_ratio_positive(self): + """After deposit, backing ratio should be positive and finite.""" + system = MycoSystem(MycoSystemConfig(n_reserve_assets=3)) + system.deposit(np.array([1000.0, 1000.0, 1000.0]), current_time=0) + metrics = system.get_metrics() + # Backing ratio depends on invariant vs total value (geometry-dependent) + assert metrics["backing_ratio"] > 0 + assert metrics["backing_ratio"] < 100 + + def test_labor_minting(self): + """Labor contributions should mint tokens.""" + system = MycoSystem(MycoSystemConfig(n_reserve_assets=2)) + # Bootstrap with financial deposit + system.deposit(np.array([1000.0, 1000.0]), current_time=0) + + contrib = ContributorState(address="alice") + contrib = attest_contribution( + system.state.labor_system, contrib, "code", 5.0, current_time=1 + ) + contrib, tokens = system.mint_from_labor(contrib, "code", current_time=1) + assert tokens > 0 + + def test_multiple_deposits(self): + """Multiple deposits should increase supply monotonically.""" + system = MycoSystem(MycoSystemConfig(n_reserve_assets=2)) + supplies = [] + + for t in range(5): + system.deposit(np.array([100.0, 100.0]), current_time=float(t)) + supplies.append(system.state.supply) + + for i in range(len(supplies) - 1): + assert supplies[i + 1] > supplies[i] + + def test_metrics(self): + """Metrics should be consistent.""" + system = MycoSystem(MycoSystemConfig(n_reserve_assets=3)) + system.deposit(np.array([1000.0, 2000.0, 3000.0]), current_time=0) + metrics = system.get_metrics() + + assert metrics["supply"] > 0 + assert metrics["reserve_value"] > 0 + assert metrics["financial_minted"] > 0 + assert len(metrics["reserve_weights"]) == 3 + + +class TestScenarios: + def test_token_launch_runs(self): + """Token launch scenario should complete without errors.""" + result = scenario_token_launch(n_assets=2, n_depositors=10, duration=30) + assert len(result.times) > 0 + assert result.supply[-1] > 0 + assert result.reserve_value[-1] > 0 + + def test_bank_run_preserves_value(self): + """Flow dampening should preserve some reserve during bank run.""" + result = scenario_bank_run( + initial_reserve=10000, n_assets=2, + redemption_fraction=0.1, duration=50, + ) + # Reserve should not go to zero (flow dampening protects) + assert result.reserve_value[-1] > 0 + + def test_launch_backing_ratio(self): + """Backing ratio should stay reasonable during launch.""" + result = scenario_token_launch(n_assets=2, n_depositors=10, duration=30) + # Should be positive throughout + assert all(result.backing_ratio > 0) diff --git a/tests/test_concentrated_2clp.py b/tests/test_concentrated_2clp.py new file mode 100644 index 0000000..4238534 --- /dev/null +++ b/tests/test_concentrated_2clp.py @@ -0,0 +1,96 @@ +"""Tests for 2-CLP concentrated liquidity pool.""" + +import numpy as np +import pytest +from src.primitives.concentrated_2clp import ( + compute_invariant, + virtual_offset_x, + virtual_offset_y, + calc_out_given_in, + calc_in_given_out, + spot_price, + price_bounds, +) + + +class TestInvariant: + def test_balanced_pool(self): + """Balanced pool should have positive invariant.""" + L = compute_invariant(1000.0, 1000.0, 0.9, 1.1) + assert L > 0 + + def test_invariant_increases_with_reserves(self): + """More reserves → larger invariant.""" + L1 = compute_invariant(100.0, 100.0, 0.9, 1.1) + L2 = compute_invariant(200.0, 200.0, 0.9, 1.1) + assert L2 > L1 + + def test_narrow_range_higher_invariant(self): + """Narrower price range → higher L for same balances (more concentrated).""" + L_wide = compute_invariant(1000.0, 1000.0, 0.5, 2.0) + L_narrow = compute_invariant(1000.0, 1000.0, 0.95, 1.05) + assert L_narrow > L_wide + + def test_virtual_reserves_satisfy_cpmm(self): + """(x + a)(y + b) should equal L².""" + x, y = 500.0, 800.0 + sa, sb = 0.8, 1.2 + L = compute_invariant(x, y, sa, sb) + a = virtual_offset_x(L, sb) + b = virtual_offset_y(L, sa) + product = (x + a) * (y + b) + assert abs(product - L**2) < 1e-6 + + +class TestSwaps: + def test_invariant_preserved(self): + """Swap should preserve L.""" + x, y = 1000.0, 1000.0 + sa, sb = 0.9, 1.1 + + L_before = compute_invariant(x, y, sa, sb) + dy = calc_out_given_in(x, y, sa, sb, 100.0, token_in=0) + L_after = compute_invariant(x + 100.0, y - dy, sa, sb) + + assert abs(L_after - L_before) / L_before < 1e-10 + + def test_round_trip(self): + """calc_in_given_out inverts calc_out_given_in.""" + x, y = 1000.0, 1000.0 + sa, sb = 0.9, 1.1 + + amount_in = 50.0 + amount_out = calc_out_given_in(x, y, sa, sb, amount_in, token_in=0) + recovered = calc_in_given_out(x, y, sa, sb, amount_out, token_out=1) + assert abs(recovered - amount_in) < 1e-8 + + def test_spot_price_in_range(self): + """Spot price should be within [α, β].""" + x, y = 1000.0, 1000.0 + sa, sb = 0.9, 1.1 + sp = spot_price(x, y, sa, sb) + alpha, beta = price_bounds(sa, sb) + assert alpha <= sp <= beta + + def test_concentrated_less_slippage(self): + """Narrower range = less slippage for same-size swap.""" + x, y = 1000.0, 1000.0 + # Narrow range + out_narrow = calc_out_given_in(x, y, 0.95, 1.05, 50.0) + # Wide range + out_wide = calc_out_given_in(x, y, 0.5, 2.0, 50.0) + # Narrow range should give better price (more output) + assert out_narrow > out_wide + + def test_both_directions(self): + """Swap in both directions should work.""" + x, y = 1000.0, 1000.0 + sa, sb = 0.9, 1.1 + + # x → y + dy = calc_out_given_in(x, y, sa, sb, 100.0, token_in=0) + assert dy > 0 + + # y → x + dx = calc_out_given_in(x, y, sa, sb, 100.0, token_in=1) + assert dx > 0 diff --git a/tests/test_dynamic_weights.py b/tests/test_dynamic_weights.py new file mode 100644 index 0000000..d3ac719 --- /dev/null +++ b/tests/test_dynamic_weights.py @@ -0,0 +1,100 @@ +"""Tests for dynamic weights.""" + +import numpy as np +import pytest +from src.primitives.dynamic_weights import ( + GradualChange, GradualWeightSchedule, + OracleMultiplier, OracleWeightSystem, + create_lbp_schedule, create_oracle_system, + simulate_lbp, +) + + +class TestGradualChange: + def test_before_start(self): + gc = GradualChange(0.8, 0.5, 100.0, 200.0) + assert gc.value_at(50.0) == 0.8 + + def test_at_start(self): + gc = GradualChange(0.8, 0.5, 100.0, 200.0) + assert gc.value_at(100.0) == 0.8 + + def test_midpoint(self): + gc = GradualChange(0.8, 0.5, 100.0, 200.0) + assert abs(gc.value_at(150.0) - 0.65) < 1e-10 + + def test_at_end(self): + gc = GradualChange(0.8, 0.5, 100.0, 200.0) + assert gc.value_at(200.0) == 0.5 + + def test_after_end(self): + gc = GradualChange(0.8, 0.5, 100.0, 200.0) + assert gc.value_at(300.0) == 0.5 + + +class TestWeightSchedule: + def test_lbp_schedule(self): + schedule = create_lbp_schedule( + 2, + np.array([0.9, 0.1]), + np.array([0.5, 0.5]), + start_time=0, end_time=100, + ) + # Start: 90/10 + w_start = schedule.weights_at(0) + assert abs(w_start[0] - 0.9) < 1e-10 + + # End: 50/50 + w_end = schedule.weights_at(100) + assert abs(w_end[0] - 0.5) < 1e-10 + + # Mid: 70/30 + w_mid = schedule.weights_at(50) + assert abs(w_mid[0] - 0.7) < 1e-10 + + def test_weights_always_sum_to_one(self): + schedule = create_lbp_schedule( + 3, + np.array([0.6, 0.3, 0.1]), + np.array([0.33, 0.34, 0.33]), + start_time=0, end_time=100, + ) + for t in np.linspace(0, 100, 20): + w = schedule.weights_at(t) + assert abs(sum(w) - 1.0) < 1e-10 + + +class TestOracleWeights: + def test_initial_weights(self): + system = create_oracle_system(3, np.array([0.5, 0.3, 0.2])) + w = system.weights_at(0) + assert abs(w[0] - 0.5) < 1e-10 + assert abs(w[1] - 0.3) < 1e-10 + + def test_multiplier_drift(self): + system = create_oracle_system(2, np.array([0.5, 0.5])) + # Set multiplier: token 0 increases, token 1 decreases + system = system.update_all(np.array([0.01, -0.01]), time=0) + w_later = system.weights_at(10) + # Token 0 should now have higher weight + assert w_later[0] > 0.5 + assert w_later[1] < 0.5 + + def test_clamped_to_bounds(self): + mult = OracleMultiplier(0.5, 1.0, 0) # Very fast drift + # At t=100, raw would be 100.5 — should clamp to 0.99 + assert mult.weight_at(100) == 0.99 + + +class TestSimulate: + def test_lbp_simulation(self): + schedule = create_lbp_schedule( + 2, np.array([0.9, 0.1]), np.array([0.5, 0.5]), + start_time=0, end_time=100, + ) + result = simulate_lbp(schedule, 0, 100, n_steps=50) + assert result["times"].shape == (50,) + assert result["weights"].shape == (50, 2) + # First step: ~90/10, last step: 50/50 + assert result["weights"][0, 0] > 0.85 + assert abs(result["weights"][-1, 0] - 0.5) < 0.02 diff --git a/tests/test_elliptical_clp.py b/tests/test_elliptical_clp.py new file mode 100644 index 0000000..d66bc53 --- /dev/null +++ b/tests/test_elliptical_clp.py @@ -0,0 +1,136 @@ +"""Tests for E-CLP elliptical concentrated liquidity pool.""" + +import numpy as np +import pytest +from src.primitives.elliptical_clp import ( + ECLPParams, + compute_derived_params, + compute_invariant, + calc_out_given_in, + calc_in_given_out, + spot_price, + virtual_offsets, +) + + +def make_default_params(): + """Standard E-CLP params for testing (near-stable pair).""" + return ECLPParams( + alpha=0.97, + beta=1.03, + c=1.0, # No rotation + s=0.0, + lam=10.0, # Moderate concentration + ) + + +def make_rotated_params(): + """E-CLP with rotation (non-unit price target).""" + phi = np.pi / 6 # 30 degrees + return ECLPParams( + alpha=0.8, + beta=1.2, + c=np.cos(phi), + s=np.sin(phi), + lam=5.0, + ) + + +class TestInvariant: + def test_positive_invariant(self): + p = make_default_params() + dp = compute_derived_params(p) + r = compute_invariant(1000.0, 1000.0, p, dp) + assert r > 0 + + def test_invariant_scales(self): + """Invariant should be approximately homogeneous degree 1.""" + p = make_default_params() + dp = compute_derived_params(p) + r1 = compute_invariant(1000.0, 1000.0, p, dp) + r2 = compute_invariant(2000.0, 2000.0, p, dp) + # Should be approximately 2x + assert abs(r2 / r1 - 2.0) < 0.1 # Allow some tolerance + + def test_rotated_params(self): + """Rotated E-CLP should work.""" + p = make_rotated_params() + dp = compute_derived_params(p) + r = compute_invariant(1000.0, 1000.0, p, dp) + assert r > 0 + + +class TestSwaps: + def test_invariant_preserved(self): + """Swap preserves invariant.""" + p = make_default_params() + dp = compute_derived_params(p) + x, y = 1000.0, 1000.0 + + r_before = compute_invariant(x, y, p, dp) + dy = calc_out_given_in(x, y, p, dp, 50.0, token_in=0) + r_after = compute_invariant(x + 50.0, y - dy, p, dp) + + assert abs(r_after - r_before) / r_before < 1e-6 + + def test_round_trip(self): + """calc_in_given_out inverts calc_out_given_in.""" + p = make_default_params() + dp = compute_derived_params(p) + + amount_in = 30.0 + amount_out = calc_out_given_in(1000.0, 1000.0, p, dp, amount_in, token_in=0) + recovered = calc_in_given_out(1000.0, 1000.0, p, dp, amount_out, token_out=1) + assert abs(recovered - amount_in) < 1e-4 + + def test_lambda_changes_curve_shape(self): + """Higher λ should produce different swap outputs (curve shape change).""" + p_lo = ECLPParams(0.5, 2.0, 1.0, 0.0, 1.0) + p_hi = ECLPParams(0.5, 2.0, 1.0, 0.0, 20.0) + dp_lo = compute_derived_params(p_lo) + dp_hi = compute_derived_params(p_hi) + + out_lo = calc_out_given_in(1000.0, 1000.0, p_lo, dp_lo, 50.0) + out_hi = calc_out_given_in(1000.0, 1000.0, p_hi, dp_hi, 50.0) + + # λ should meaningfully change the output (different curve geometry) + assert out_lo != out_hi + assert abs(out_lo - out_hi) / out_lo > 0.001 # At least 0.1% difference + + def test_rotated_swap(self): + """Swap works with rotation.""" + p = make_rotated_params() + dp = compute_derived_params(p) + dy = calc_out_given_in(1000.0, 1000.0, p, dp, 50.0) + assert dy > 0 + + def test_spot_price_positive(self): + """Spot price should be positive.""" + p = make_default_params() + dp = compute_derived_params(p) + sp = spot_price(1000.0, 1000.0, p, dp) + assert sp > 0 + + +class TestDerivedParams: + def test_tau_normalized(self): + """tau vectors should be approximately unit length.""" + p = make_default_params() + dp = compute_derived_params(p) + assert abs(np.linalg.norm(dp.tau_alpha) - 1.0) < 1e-10 + assert abs(np.linalg.norm(dp.tau_beta) - 1.0) < 1e-10 + + def test_dSq_near_one(self): + """dSq = c² + s² should be ≈ 1.""" + p = make_default_params() + dp = compute_derived_params(p) + assert abs(dp.dSq - 1.0) < 1e-10 + + def test_virtual_offsets_positive(self): + """Virtual offsets should be positive for typical params.""" + p = make_default_params() + dp = compute_derived_params(p) + r = compute_invariant(1000.0, 1000.0, p, dp) + a, b = virtual_offsets(r, p, dp) + assert a > 0 + assert b > 0 diff --git a/tests/test_flow_dampening.py b/tests/test_flow_dampening.py new file mode 100644 index 0000000..bf2adf3 --- /dev/null +++ b/tests/test_flow_dampening.py @@ -0,0 +1,106 @@ +"""Tests for flow dampening.""" + +import numpy as np +import pytest +from src.primitives.flow_dampening import ( + FlowTracker, update_flow, flow_at_time, + time_to_decay_below_threshold, + flow_penalty_multiplier, + simulate_bank_run, +) + + +class TestFlowTracker: + def test_initial_flow_zero(self): + tracker = FlowTracker() + assert tracker.current_flow == 0.0 + assert not tracker.is_above_threshold + + def test_update_adds_flow(self): + tracker = FlowTracker(total_value_ref=1000.0) + tracker = update_flow(tracker, 50.0, current_time=0) + assert tracker.current_flow == 50.0 + + def test_flow_decays_over_time(self): + tracker = FlowTracker(memory=0.99, total_value_ref=1000.0) + tracker = update_flow(tracker, 100.0, current_time=0) + flow_later = flow_at_time(tracker, 100.0) + assert flow_later < 100.0 + assert flow_later > 0 + + def test_threshold_detection(self): + tracker = FlowTracker(threshold=0.1, total_value_ref=1000.0) + tracker = update_flow(tracker, 150.0, current_time=0) + assert tracker.is_above_threshold # 150/1000 = 0.15 > 0.1 + + +class TestDecay: + def test_decay_time_calculation(self): + tracker = FlowTracker( + memory=0.99, threshold=0.05, + current_flow=100.0, total_value_ref=1000.0, + ) + t = time_to_decay_below_threshold(tracker) + assert t is not None + assert t > 0 + # Verify: at time t, flow should be at threshold + expected_flow = 100.0 * 0.99 ** t + target = 0.05 * 1000.0 + assert abs(expected_flow - target) < 1.0 + + def test_already_below_threshold(self): + tracker = FlowTracker( + threshold=0.1, current_flow=10.0, total_value_ref=1000.0, + ) + assert time_to_decay_below_threshold(tracker) == 0.0 + + +class TestPenalty: + def test_no_penalty_below_half_threshold(self): + tracker = FlowTracker(threshold=0.1, total_value_ref=1000.0) + tracker = update_flow(tracker, 30.0, current_time=0) + # 30/1000 = 0.03 < 0.05 (half of 0.1) + assert flow_penalty_multiplier(tracker) == 1.0 + + def test_penalty_at_threshold(self): + tracker = FlowTracker(threshold=0.1, total_value_ref=1000.0) + tracker = update_flow(tracker, 100.0, current_time=0) + # 100/1000 = 0.1 = threshold + penalty = flow_penalty_multiplier(tracker) + assert penalty < 1.0 + assert penalty > 0.0 + + def test_penalty_floor(self): + tracker = FlowTracker(threshold=0.1, total_value_ref=1000.0) + tracker = update_flow(tracker, 500.0, current_time=0) + # Way above threshold + assert flow_penalty_multiplier(tracker) >= 0.1 + + +class TestBankRunSimulation: + def test_simulation_runs(self): + result = simulate_bank_run( + initial_value=10000, supply=10000, + memory=0.99, threshold=0.1, + redemption_rate=0.05, n_steps=50, + ) + assert len(result["times"]) == 50 + assert len(result["values"]) == 50 + assert all(result["values"] >= 0) + + def test_dampening_preserves_value(self): + """With dampening, more value should remain vs without.""" + # With dampening (low threshold = aggressive) + result_damped = simulate_bank_run( + initial_value=10000, supply=10000, + memory=0.99, threshold=0.05, + redemption_rate=0.1, n_steps=50, + ) + # Without dampening (high threshold = never triggers) + result_free = simulate_bank_run( + initial_value=10000, supply=10000, + memory=0.99, threshold=10.0, + redemption_rate=0.1, n_steps=50, + ) + # Dampened system should preserve more value + assert result_damped["values"][-1] > result_free["values"][-1] diff --git a/tests/test_imbalance_fees.py b/tests/test_imbalance_fees.py new file mode 100644 index 0000000..305c4ed --- /dev/null +++ b/tests/test_imbalance_fees.py @@ -0,0 +1,63 @@ +"""Tests for imbalance fees.""" + +import numpy as np +import pytest +from src.primitives.imbalance_fees import ( + compute_imbalance, surge_fee, + compute_fee_adjusted_output, + optimal_deposit_fee, +) + + +class TestImbalance: + def test_balanced_is_zero(self): + assert compute_imbalance(np.array([100.0, 100.0, 100.0])) == 0.0 + + def test_imbalanced_is_positive(self): + assert compute_imbalance(np.array([100.0, 200.0, 300.0])) > 0 + + def test_extreme_imbalance_near_one(self): + imb = compute_imbalance(np.array([1.0, 1.0, 1000.0])) + assert imb > 0.5 + + def test_two_tokens(self): + imb = compute_imbalance(np.array([100.0, 200.0])) + # median = 150, sum|dev| = 50+50 = 100, total = 300 + assert abs(imb - 100.0 / 300.0) < 1e-10 + + +class TestSurgeFee: + def test_balanced_gets_static_fee(self): + before = np.array([100.0, 100.0]) + after = np.array([110.0, 90.0]) # Small imbalance + fee = surge_fee(before, after, threshold=0.5) + assert fee == 0.003 # Static + + def test_worsening_imbalance_gets_surge(self): + before = np.array([100.0, 100.0, 100.0]) + after = np.array([200.0, 50.0, 50.0]) # Heavy imbalance + fee = surge_fee(before, after, threshold=0.1) + assert fee > 0.003 # Should be surged + + def test_improving_imbalance_no_surge(self): + before = np.array([200.0, 50.0, 50.0]) + after = np.array([150.0, 75.0, 75.0]) # Improving + fee = surge_fee(before, after, threshold=0.1) + assert fee == 0.003 # Static (improving) + + +class TestOptimalFees: + def test_underweight_gets_discount(self): + fees = optimal_deposit_fee( + np.array([500.0, 1000.0, 1000.0]), + np.array([100.0, 100.0, 100.0]), + ) + assert fees[0] < fees[1] # Underweight asset cheaper + assert fees[0] < fees[2] + + def test_balanced_gets_base_fee(self): + fees = optimal_deposit_fee( + np.array([1000.0, 1000.0]), + np.array([100.0, 100.0]), + ) + np.testing.assert_allclose(fees, [0.001, 0.001], atol=1e-10) diff --git a/tests/test_n_dimensional.py b/tests/test_n_dimensional.py new file mode 100644 index 0000000..5c6faa5 --- /dev/null +++ b/tests/test_n_dimensional.py @@ -0,0 +1,177 @@ +"""Tests for N-dimensional ellipsoidal bonding surface.""" + +import numpy as np +import pytest +from src.primitives.n_dimensional_surface import ( + NDSurfaceParams, + NDSurfaceState, + create_params, + compute_invariant, + calc_out_given_in, + calc_in_given_out, + mint, + redeem, + spot_prices, +) +from src.utils.linear_algebra import random_rotation_matrix + + +class TestInvariant: + def test_2d_positive(self): + """2D surface should have positive invariant.""" + params = create_params(2) + balances = np.array([1000.0, 1000.0]) + r = compute_invariant(balances, params) + assert r > 0 + + def test_3d_positive(self): + """3D surface should have positive invariant.""" + params = create_params(3) + balances = np.array([1000.0, 1000.0, 1000.0]) + r = compute_invariant(balances, params) + assert r > 0 + + def test_5d_positive(self): + """5D surface should work.""" + params = create_params(5) + balances = np.array([100.0, 200.0, 300.0, 400.0, 500.0]) + r = compute_invariant(balances, params) + assert r > 0 + + def test_invariant_increases_with_reserves(self): + """More reserves → larger invariant.""" + params = create_params(3) + r1 = compute_invariant(np.array([100.0, 100.0, 100.0]), params) + r2 = compute_invariant(np.array([200.0, 200.0, 200.0]), params) + assert r2 > r1 + + def test_stretched_surface(self): + """Surface with stretch factors should compute invariant.""" + params = create_params(3, lambdas=np.array([1.0, 5.0, 10.0])) + balances = np.array([1000.0, 1000.0, 1000.0]) + r = compute_invariant(balances, params) + assert r > 0 + + +class TestSwaps: + def test_swap_preserves_invariant(self): + """Swap should preserve invariant.""" + params = create_params(3) + balances = np.array([1000.0, 1000.0, 1000.0]) + r_before = compute_invariant(balances, params) + + dy = calc_out_given_in(balances, params, 0, 1, 50.0) + new_balances = balances.copy() + new_balances[0] += 50.0 + new_balances[1] -= dy + r_after = compute_invariant(new_balances, params) + + assert abs(r_after - r_before) / r_before < 1e-4 + + def test_round_trip(self): + """calc_in_given_out inverts calc_out_given_in.""" + params = create_params(3) + balances = np.array([1000.0, 1000.0, 1000.0]) + + amount_in = 30.0 + amount_out = calc_out_given_in(balances, params, 0, 2, amount_in) + recovered = calc_in_given_out(balances, params, 0, 2, amount_out) + assert abs(recovered - amount_in) < 0.1 # Slightly looser tolerance for N-D + + def test_swap_positive_output(self): + """Swap should produce positive output.""" + params = create_params(4) + balances = np.array([1000.0, 1000.0, 1000.0, 1000.0]) + dy = calc_out_given_in(balances, params, 0, 3, 100.0) + assert dy > 0 + + def test_all_pairs(self): + """Should be able to swap between any pair.""" + params = create_params(3) + balances = np.array([1000.0, 1000.0, 1000.0]) + for i in range(3): + for j in range(3): + if i != j: + dy = calc_out_given_in(balances, params, i, j, 10.0) + assert dy > 0 + + +class TestMintRedeem: + def test_mint_increases_supply(self): + """Minting should increase supply.""" + params = create_params(3) + state = NDSurfaceState( + balances=np.array([1000.0, 1000.0, 1000.0]), + invariant=compute_invariant(np.array([1000.0, 1000.0, 1000.0]), params), + supply=1000.0, + ) + amounts_in = np.array([100.0, 100.0, 100.0]) + new_state, minted = mint(state, params, amounts_in) + + assert minted > 0 + assert new_state.supply > state.supply + assert np.all(new_state.balances > state.balances) + + def test_redeem_proportional(self): + """Proportional redeem should return proportional assets.""" + params = create_params(3) + balances = np.array([1000.0, 2000.0, 3000.0]) + state = NDSurfaceState( + balances=balances, + invariant=compute_invariant(balances, params), + supply=1000.0, + ) + + new_state, amounts_out = redeem(state, params, 100.0) + + # Should get 10% of each asset + assert abs(amounts_out[0] - 100.0) < 1e-6 + assert abs(amounts_out[1] - 200.0) < 1e-6 + assert abs(amounts_out[2] - 300.0) < 1e-6 + assert abs(new_state.supply - 900.0) < 1e-6 + + def test_initial_mint(self): + """First mint with zero supply should bootstrap.""" + params = create_params(2) + state = NDSurfaceState( + balances=np.array([0.0, 0.0]), + invariant=0.0, + supply=0.0, + ) + # Bootstrap with initial reserves + amounts_in = np.array([1000.0, 1000.0]) + # Need to handle zero invariant case + state.balances = amounts_in + state.invariant = compute_invariant(amounts_in, params) + state.supply = state.invariant + + assert state.supply > 0 + + def test_mint_then_redeem_roundtrip(self): + """Mint then full redeem should return ~original reserves.""" + params = create_params(2) + initial_balances = np.array([1000.0, 1000.0]) + state = NDSurfaceState( + balances=initial_balances.copy(), + invariant=compute_invariant(initial_balances, params), + supply=1000.0, + ) + + # Mint 10% + new_state, minted = mint(state, params, np.array([100.0, 100.0])) + # Redeem all minted + final_state, out = redeem(new_state, params, minted) + + # Should be close to original + deposit - withdrawal ≈ original + np.testing.assert_allclose(final_state.balances, initial_balances, rtol=1e-4) + + +class TestSpotPrices: + def test_symmetric_prices(self): + """Balanced pool with identity Q should have ~equal prices.""" + params = create_params(3) + balances = np.array([1000.0, 1000.0, 1000.0]) + prices = spot_prices(balances, params, numeraire=0) + assert abs(prices[0] - 1.0) < 1e-10 + assert abs(prices[1] - 1.0) < 0.1 + assert abs(prices[2] - 1.0) < 0.1 diff --git a/tests/test_redemption_curve.py b/tests/test_redemption_curve.py new file mode 100644 index 0000000..7ece755 --- /dev/null +++ b/tests/test_redemption_curve.py @@ -0,0 +1,102 @@ +"""Tests for P-AMM redemption curve.""" + +import numpy as np +import pytest +from src.primitives.redemption_curve import ( + PAMMParams, PAMMState, + compute_redemption_rate, redeem, + backing_ratio_trajectory, +) + + +class TestRedemptionRate: + def test_fully_backed_redeems_at_par(self): + """When ba >= 1, rate should be 1.0.""" + state = PAMMState(reserve_value=10000, myco_supply=10000) + params = PAMMParams() + rate = compute_redemption_rate(state, params, 100.0) + assert abs(rate - 1.0) < 1e-10 + + def test_overbacked_redeems_at_par(self): + """When ba > 1, rate should be 1.0 (not more).""" + state = PAMMState(reserve_value=15000, myco_supply=10000) + params = PAMMParams() + rate = compute_redemption_rate(state, params, 100.0) + assert abs(rate - 1.0) < 1e-10 + + def test_underbacked_gives_discount(self): + """When ba < 1, rate should be less than 1.""" + state = PAMMState(reserve_value=8000, myco_supply=10000) + params = PAMMParams() + rate = compute_redemption_rate(state, params, 100.0) + assert rate < 1.0 + assert rate > 0 + + def test_deeply_underbacked_hits_floor(self): + """Very low backing should approach θ̄.""" + state = PAMMState(reserve_value=100, myco_supply=10000) + params = PAMMParams(theta_bar=0.5) + rate = compute_redemption_rate(state, params, 100.0) + assert rate >= params.theta_bar + + def test_rate_bounded(self): + """Rate should always be in [θ̄, 1.0].""" + params = PAMMParams(theta_bar=0.3) + for ba in [0.01, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0, 1.5]: + state = PAMMState( + reserve_value=ba * 10000, + myco_supply=10000, + ) + rate = compute_redemption_rate(state, params, 100.0) + assert params.theta_bar <= rate <= 1.0 + + +class TestRedeem: + def test_redeem_reduces_reserve(self): + state = PAMMState(reserve_value=10000, myco_supply=10000) + params = PAMMParams() + new_state, usd_out = redeem(state, params, 100.0, current_time=0) + assert new_state.reserve_value < state.reserve_value + assert new_state.myco_supply < state.myco_supply + assert usd_out > 0 + + def test_redeem_never_exceeds_reserve(self): + """Should never return more USD than available.""" + state = PAMMState(reserve_value=50, myco_supply=10000) + params = PAMMParams() + new_state, usd_out = redeem(state, params, 5000.0, current_time=0) + assert usd_out <= 50 + assert new_state.reserve_value >= 0 + + def test_sequential_redemptions_degrade(self): + """Sequential redemptions should get progressively worse rates.""" + state = PAMMState(reserve_value=8000, myco_supply=10000) + params = PAMMParams() + rates = [] + for t in range(5): + rate = compute_redemption_rate(state, params, 500.0) + state, _ = redeem(state, params, 500.0, current_time=float(t)) + rates.append(rate) + # Later redemptions should get same or worse rate + # (backing ratio decreases) + assert all(rates[i] >= rates[i+1] - 0.01 for i in range(len(rates)-1)) + + +class TestTrajectory: + def test_trajectory_length(self): + state = PAMMState(reserve_value=10000, myco_supply=10000) + params = PAMMParams() + schedule = [(float(t), 100.0) for t in range(10)] + traj = backing_ratio_trajectory(state, params, schedule) + assert len(traj) == 10 + + def test_trajectory_backing_decreases(self): + """Backing ratio should decrease with continuous redemptions.""" + state = PAMMState(reserve_value=10000, myco_supply=10000) + params = PAMMParams() + schedule = [(float(t), 200.0) for t in range(10)] + traj = backing_ratio_trajectory(state, params, schedule) + backing_ratios = [t[1] for t in traj] + # Each step should have lower or equal backing + for i in range(len(backing_ratios) - 1): + assert backing_ratios[i+1] <= backing_ratios[i] + 1e-10 diff --git a/tests/test_reserve_tranching.py b/tests/test_reserve_tranching.py new file mode 100644 index 0000000..9ab82c5 --- /dev/null +++ b/tests/test_reserve_tranching.py @@ -0,0 +1,103 @@ +"""Tests for reserve tranching.""" + +import numpy as np +import pytest +from src.primitives.reserve_tranching import ( + VaultMetadata, Vault, ReserveState, + current_weights, target_weights, weight_deviations, + is_safe_to_mint, is_safe_to_redeem, + optimal_deposit_split, update_flow, check_flow_limit, +) + + +def make_balanced_state(): + """Three vaults with equal balances and weights.""" + vaults = [ + Vault( + metadata=VaultMetadata( + name=f"vault_{i}", target_weight=1/3, + price_at_calibration=1.0, weight_at_calibration=1/3, + weight_previous=1/3, calibration_time=0, + transition_duration=100, + ), + balance=1000.0, current_price=1.0, + ) + for i in range(3) + ] + return ReserveState(vaults=vaults, myco_supply=3000.0) + + +class TestWeights: + def test_balanced_weights(self): + state = make_balanced_state() + cw = current_weights(state) + np.testing.assert_allclose(cw, [1/3, 1/3, 1/3], atol=1e-10) + + def test_target_weights_at_calibration(self): + state = make_balanced_state() + tw = target_weights(state, current_time=0) + np.testing.assert_allclose(tw, [1/3, 1/3, 1/3], atol=1e-10) + + def test_weight_deviations_balanced(self): + state = make_balanced_state() + dev = weight_deviations(state, current_time=0) + np.testing.assert_allclose(dev, [0, 0, 0], atol=1e-10) + + +class TestSafety: + def test_proportional_deposit_safe(self): + state = make_balanced_state() + amounts = np.array([100.0, 100.0, 100.0]) + safe, msg = is_safe_to_mint(state, amounts, current_time=0) + assert safe + + def test_extreme_imbalance_blocked(self): + state = make_balanced_state() + # Deposit everything into vault 0 — should be blocked if already overweight + state.vaults[0].balance = 2000.0 # Make overweight + state.total_value = 4000.0 + amounts = np.array([1000.0, 0.0, 0.0]) + safe, msg = is_safe_to_mint(state, amounts, current_time=0) + # With max_deviation=0.1 and target=0.33, this should fail + assert not safe + + def test_rebalancing_deposit_allowed(self): + state = make_balanced_state() + state.vaults[0].balance = 500.0 # Make underweight + state.total_value = 2500.0 + amounts = np.array([500.0, 0.0, 0.0]) # Rebalance + safe, msg = is_safe_to_mint(state, amounts, current_time=0) + assert safe + + +class TestOptimal: + def test_balanced_gets_proportional(self): + state = make_balanced_state() + split = optimal_deposit_split(state, 300.0, current_time=0) + np.testing.assert_allclose(split, [100, 100, 100], atol=1e-6) + + def test_underweight_gets_more(self): + state = make_balanced_state() + state.vaults[0].balance = 500.0 # Underweight + state.total_value = 2500.0 + split = optimal_deposit_split(state, 500.0, current_time=0) + # Vault 0 should get the most + assert split[0] > split[1] + assert split[0] > split[2] + + +class TestFlow: + def test_flow_decays(self): + v = make_balanced_state().vaults[0] + v = update_flow(v, 100.0, current_time=0) + assert v.recent_flow == 100.0 + + v = update_flow(v, 0.0, current_time=100) + assert v.recent_flow < 100.0 # Should have decayed + + def test_flow_limit_check(self): + v = make_balanced_state().vaults[0] + v = update_flow(v, 200.0, current_time=0) + ok, msg = check_flow_limit(v) + # 200 > 1000 * 0.05 = 50 → should exceed + assert not ok diff --git a/tests/test_stableswap.py b/tests/test_stableswap.py new file mode 100644 index 0000000..c1085aa --- /dev/null +++ b/tests/test_stableswap.py @@ -0,0 +1,111 @@ +"""Tests for StableSwap invariant.""" + +import numpy as np +import pytest +from src.primitives.stableswap import ( + compute_invariant, + calc_out_given_in, + calc_in_given_out, + spot_price, +) + + +class TestInvariant: + def test_balanced_pool_D_equals_sum(self): + """For balanced pools with high A, D ≈ sum of balances.""" + balances = np.array([1000.0, 1000.0]) + D = compute_invariant(balances, amp=1000) + assert abs(D - 2000.0) < 1.0 # Very close to sum + + def test_balanced_three_tokens(self): + """Three-token balanced pool.""" + balances = np.array([1000.0, 1000.0, 1000.0]) + D = compute_invariant(balances, amp=1000) + assert abs(D - 3000.0) < 1.0 + + def test_high_amp_approaches_constant_sum(self): + """As A increases, D approaches sum(balances) even for imbalanced pools.""" + balances = np.array([900.0, 1100.0]) + D_low = compute_invariant(balances, amp=10) + D_high = compute_invariant(balances, amp=10000) + assert abs(D_high - 2000.0) < abs(D_low - 2000.0) + + def test_low_amp_more_slippage(self): + """With lower A, swaps show more slippage (more constant-product-like).""" + balances = np.array([1000.0, 1000.0]) + # Low A should give worse exchange rate than high A + out_low_A = calc_out_given_in(balances, amp=1, token_in_index=0, + token_out_index=1, amount_in=100.0) + out_high_A = calc_out_given_in(balances, amp=5000, token_in_index=0, + token_out_index=1, amount_in=100.0) + # High A should give more output (less slippage) + assert out_high_A > out_low_A + + def test_homogeneity_degree_1(self): + """D(k*b) = k * D(b).""" + balances = np.array([500.0, 1500.0]) + amp = 200 + D_base = compute_invariant(balances, amp) + for k in [0.5, 2.0, 5.0]: + D_scaled = compute_invariant(k * balances, amp) + assert abs(D_scaled - k * D_base) < 1e-6 * k * D_base + + +class TestSwaps: + def test_invariant_preserved(self): + """Swap preserves D.""" + balances = np.array([1000.0, 1000.0]) + amp = 500 + D_before = compute_invariant(balances, amp) + + amount_out = calc_out_given_in(balances, amp, 0, 1, 100.0) + new_balances = np.array([1100.0, 1000.0 - amount_out]) + D_after = compute_invariant(new_balances, amp) + + assert abs(D_after - D_before) < 1e-6 + + def test_near_1_to_1_with_high_amp(self): + """High-A balanced pool should swap nearly 1:1.""" + balances = np.array([1000.0, 1000.0]) + amount_out = calc_out_given_in(balances, amp=5000, token_in_index=0, + token_out_index=1, amount_in=10.0) + # Should be very close to 10.0 for high A + assert abs(amount_out - 10.0) < 0.1 + + def test_round_trip(self): + """calc_in_given_out inverts calc_out_given_in.""" + balances = np.array([1000.0, 1000.0]) + amp = 200 + amount_in = 50.0 + amount_out = calc_out_given_in(balances, amp, 0, 1, amount_in) + recovered = calc_in_given_out(balances, amp, 0, 1, amount_out) + assert abs(recovered - amount_in) < 1e-6 + + def test_large_swap_high_slippage(self): + """Large swap on imbalanced pool should show significant slippage.""" + balances = np.array([100.0, 1900.0]) # Very imbalanced + amp = 100 + # Selling more of the scarce token should yield less than 1:1 + amount_out = calc_out_given_in(balances, amp, 0, 1, 50.0) + # Price should be > 1:1 since token 0 is scarce + assert amount_out > 50.0 + + def test_spot_price_balanced(self): + """Spot price should be ~1.0 for balanced pool.""" + balances = np.array([1000.0, 1000.0]) + sp = spot_price(balances, amp=500, token_in_index=0, token_out_index=1) + assert abs(sp - 1.0) < 0.01 + + def test_three_token_swap(self): + """Three-token pool swap preserves D.""" + balances = np.array([1000.0, 1000.0, 1000.0]) + amp = 500 + D_before = compute_invariant(balances, amp) + + amount_out = calc_out_given_in(balances, amp, 0, 2, 100.0) + new_balances = balances.copy() + new_balances[0] += 100.0 + new_balances[2] -= amount_out + D_after = compute_invariant(new_balances, amp) + + assert abs(D_after - D_before) < 1e-4 diff --git a/tests/test_weighted_product.py b/tests/test_weighted_product.py new file mode 100644 index 0000000..a8f8b02 --- /dev/null +++ b/tests/test_weighted_product.py @@ -0,0 +1,114 @@ +"""Tests for weighted constant product invariant.""" + +import numpy as np +import pytest +from src.primitives.weighted_product import ( + compute_invariant, + spot_price, + calc_out_given_in, + calc_in_given_out, + calc_bpt_out_given_exact_tokens_in, + calc_token_out_given_exact_bpt_in, +) + + +class TestInvariant: + def test_two_token_equal_weight(self): + """50/50 pool: I = sqrt(x * y).""" + balances = np.array([100.0, 100.0]) + weights = np.array([0.5, 0.5]) + inv = compute_invariant(balances, weights) + assert abs(inv - 100.0) < 1e-10 + + def test_two_token_unequal_weight(self): + """80/20 pool.""" + balances = np.array([100.0, 100.0]) + weights = np.array([0.8, 0.2]) + inv = compute_invariant(balances, weights) + expected = 100.0**0.8 * 100.0**0.2 + assert abs(inv - expected) < 1e-10 + + def test_three_tokens(self): + """33/33/34 pool.""" + balances = np.array([100.0, 200.0, 300.0]) + weights = np.array([0.33, 0.33, 0.34]) + inv = compute_invariant(balances, weights) + expected = 100.0**0.33 * 200.0**0.33 * 300.0**0.34 + assert abs(inv - expected) < 1e-8 + + def test_homogeneity_degree_1(self): + """I(k*b) = k * I(b) for all k > 0.""" + balances = np.array([50.0, 150.0, 75.0]) + weights = np.array([0.25, 0.5, 0.25]) + inv_base = compute_invariant(balances, weights) + for k in [0.5, 2.0, 10.0]: + inv_scaled = compute_invariant(k * balances, weights) + assert abs(inv_scaled - k * inv_base) < 1e-8 * k * inv_base + + +class TestSwaps: + def test_invariant_preserved_after_swap(self): + """Swap should preserve the invariant.""" + balances = np.array([1000.0, 1000.0]) + weights = np.array([0.5, 0.5]) + inv_before = compute_invariant(balances, weights) + + amount_in = 100.0 + amount_out = calc_out_given_in(1000.0, 0.5, 1000.0, 0.5, amount_in) + + new_balances = np.array([1000.0 + amount_in, 1000.0 - amount_out]) + inv_after = compute_invariant(new_balances, weights) + assert abs(inv_after - inv_before) < 1e-8 + + def test_round_trip(self): + """calc_in_given_out should invert calc_out_given_in.""" + amount_in = 50.0 + amount_out = calc_out_given_in(1000.0, 0.6, 500.0, 0.4, amount_in) + recovered_in = calc_in_given_out(1000.0, 0.6, 500.0, 0.4, amount_out) + assert abs(recovered_in - amount_in) < 1e-8 + + def test_small_swap_matches_spot_price(self): + """Very small swap should trade at approximately the spot price.""" + bi, wi, bo, wo = 1000.0, 0.5, 1000.0, 0.5 + sp = spot_price(bi, wi, bo, wo) + dx = 0.001 + dy = calc_out_given_in(bi, wi, bo, wo, dx) + assert abs(dy / dx - sp) < 1e-4 + + def test_unequal_weight_swap(self): + """80/20 swap prices should reflect weight asymmetry.""" + # In an 80/20 pool, token 0 (80%) is more price-stable + sp = spot_price(1000.0, 0.8, 1000.0, 0.2) + # price = (1000/0.2) / (1000/0.8) = 4.0 + assert abs(sp - 4.0) < 1e-10 + + +class TestLiquidity: + def test_proportional_deposit(self): + """Proportional deposit should mint BPT proportional to invariant increase.""" + balances = np.array([1000.0, 1000.0]) + weights = np.array([0.5, 0.5]) + bpt_supply = 1000.0 + + # 10% proportional deposit + amounts_in = np.array([100.0, 100.0]) + bpt_out = calc_bpt_out_given_exact_tokens_in( + balances, weights, amounts_in, bpt_supply + ) + assert abs(bpt_out - 100.0) < 1e-8 # 10% increase + + def test_single_sided_exit(self): + """Single-token exit: burn BPT, receive one token.""" + balances = np.array([1000.0, 1000.0]) + weights = np.array([0.5, 0.5]) + bpt_supply = 1000.0 + + # Burn 10% of supply, exit via token 0 + token_out = calc_token_out_given_exact_bpt_in( + balances, weights, 0, 100.0, bpt_supply + ) + # Should get less than 100 tokens due to single-sided penalty + assert 0 < token_out < 200.0 + # For 50/50 pool: new_balance = 1000 * 0.9^(1/0.5) = 1000 * 0.81 = 810 + # token_out = 1000 - 810 = 190 + assert abs(token_out - 190.0) < 1e-8