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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-01 00:12:00 -07:00
commit 9dce4e2855
63 changed files with 8381 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
__pycache__/
*.pyc
*.egg-info/
dist/
build/
.venv/
*.ipynb_checkpoints/
.pytest_cache/

2
.gitleaksignore Normal file
View File

@ -0,0 +1,2 @@
reference/MycoConditionalOrder.sol:generic-api-key:116
reference/MycoConditionalOrder.sol:generic-api-key:137

123
README.md Normal file
View File

@ -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 (0012)
├── 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)

89
docs/00-architecture.md Normal file
View File

@ -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.

View File

@ -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`

77
docs/02-stableswap.md Normal file
View File

@ -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`

View File

@ -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`

80
docs/04-elliptical-clp.md Normal file
View File

@ -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`

View File

@ -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`

View File

@ -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`

View File

@ -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`

View File

@ -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`

42
docs/09-flow-dampening.md Normal file
View File

@ -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`

44
docs/10-imbalance-fees.md Normal file
View File

@ -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`

View File

@ -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`

112
docs/12-composed-system.md Normal file
View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

26
pyproject.toml Normal file
View File

@ -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 = ["."]

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

62
reference/MycoToken.sol Normal file
View File

@ -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);
}
}
}

0
src/__init__.py Normal file
View File

View File

172
src/commitments/labor.py Normal file
View File

@ -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)

224
src/commitments/staking.py Normal file
View File

@ -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}

View File

@ -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,
),
}

0
src/composed/__init__.py Normal file
View File

View File

@ -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
),
}

220
src/composed/simulator.py Normal file
View File

@ -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,
)

View File

View File

@ -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:
= (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· + b·L + c = 0
where:
a = 1 - α/β
b = -(y/β + x·α)
c = -x·y
Solution: L = (-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)

View File

@ -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}

View File

@ -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)|² =
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

View File

@ -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,
}

View File

@ -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

View File

@ -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)|² =
where A is a 2×2 matrix encoding rotation and stretch. This generalizes
naturally to N dimensions:
|A(v - offset)|² =
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))|² =
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)|² = 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 =
"""
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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

0
src/utils/__init__.py Normal file
View File

59
src/utils/fixed_point.py Normal file
View File

@ -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)

124
src/utils/linear_algebra.py Normal file
View File

@ -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)

79
src/utils/newton.py Normal file
View File

@ -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")

129
src/utils/plotting.py Normal file
View File

@ -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

0
tests/__init__.py Normal file
View File

164
tests/test_commitments.py Normal file
View File

@ -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

102
tests/test_composed.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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)

177
tests/test_n_dimensional.py Normal file
View File

@ -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

View File

@ -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

View File

@ -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

111
tests/test_stableswap.py Normal file
View File

@ -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

View File

@ -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