Skip to content

Instantly share code, notes, and snippets.

@koeppelmann
Last active June 8, 2026 14:52
Show Gist options
  • Select an option

  • Save koeppelmann/761036d8ee854255c031065257534e35 to your computer and use it in GitHub Desktop.

Select an option

Save koeppelmann/761036d8ee854255c031065257534e35 to your computer and use it in GitHub Desktop.
Fully collateralized symmetric options reference contract

Fully Collateralized On-Chain Options

Reference Solidity implementation for a draft symmetric exchange-right options protocol. The full protocol writeup is included in SPEC.md.

The design is an oracle-free, fully collateralized, physically settled options primitive. A market contains two ERC-20 assets and an expiry; each pool represents one exchange right at a fixed ratio. Long CLAIM tokens are fungible, raw-backed shorts receive fungible RESIDUAL tokens, and linked/cascade-backed shorts are represented as individual Position records.

The core contract is in src/SymmetricOptions.sol. It intentionally keeps the protocol oracle-free and immutable: there is no owner, no upgrade hook, no price input, and no keeper needed for solvency.

Implemented draft choices:

  • ERC-1155-style fungible ids for CLAIM(pool) and RESIDUAL(pool).
  • Ratio represented as actual token amounts per 1e18 option units: collateralUnit and exerciseUnit.
  • Minting into a fully exercised residual pool is disallowed.
  • Fee-on-transfer and rebasing ERC-20s are rejected by strict balance-delta checks.
  • Assignment currently consumes raw pool backing first, then linked positions in deterministic position-id order. This is a concrete draft policy for the open section 10 decision, not a claim that the assignment question is fully settled.

Not implemented yet:

  • Structural box redemption.
  • ERC-20 wrappers for individual claim ids.
  • On-chain strike grids or expiry calendars.

Build:

forge build
[profile.default]
src = "src"
out = "out"
libs = ["lib"]
solc_version = "0.8.24"
optimizer = true
optimizer_runs = 200
via_ir = true

Fully-Collateralized On-Chain Options — Protocol Specification

Status: Draft v0.2 (symmetric "exchange-right" formulation) Intent: A single, immutable, oracle-free options primitive meant to live unchanged for a long time. This version drops the artificial underlying/quote and call/put distinctions: there are two assets and a fully-collateralized exchange right between them, and everything else is a relabeling. Points still requiring a decision before freezing are marked [DECISION] and collected in §14.


1. Design goals and non-goals

Goals

  • Oracle-free. The contract never reads a price. Solvency and settlement depend only on on-chain balances, fixed ratios, and user actions.
  • Fully collateralized. Every right is backed at all times. No liquidations, no margin engine, no bad debt.
  • Physically settled. Exercise is a real swap of one asset for the other at the fixed ratio. Rational exercise is the price signal.
  • Symmetric. Neither asset is privileged. "Call/put/underlying/quote" are views, not structure (§2.1). One mirrored code path covers all of them.
  • Composable & fungible. The tradeable long tokens are fungible ERC-20s.
  • Capital-efficient. A claim may collateralize a further-out claim of the same expiry (the cascade), giving spreads at their defined max loss and ultimately near-zero new capital.
  • Immutable. No admin, no upgrade, no oracle, no keeper required for correctness.

Non-goals

  • Pricing / premium discovery (the market's job — AMM, CoW batch auction, RFQ).
  • Perpetuals, funding, leverage beyond full collateral.
  • Cross-expiry netting. All capital efficiency is within one expiry; calendar structures are out of scope (they'd need a price model).

2. The symmetric primitive

2.1 Two assets, one exchange right

A market is a tuple (A, B, expiry) of two ERC-20s and an expiry. Neither A nor B is special; the whole system is invariant under the reflection A ↔ B, K ↔ 1/K.

The atom is a fully-collateralized exchange right. At a ratio K (units of B per unit of A) there are two mirror-image pools:

Pool Holds (per unit) Long CLAIM may… Reads as
A-pool(K) 1 A deliver K B → take 1 A a call on A at strike K
B-pool(K) K B deliver 1 A → take K B a put on A at strike K

The two are the same construction with A↔B and K↔1/K swapped. So you implement one pool type and instantiate it twice per ratio. "Call on A" and "put on B" are literally the same token (A-pool(K).CLAIM); the label depends only on which asset you call the numéraire.

Generic terms used throughout: a pool is collateralized in asset C and exercised by delivering asset D (its mirror swaps C↔D). For an A-pool, C=A, D=B; for a B-pool, C=B, D=A.

2.2 The four tokens are two mirror pairs

Per pool there are two token roles; per ratio K there are two pools, giving four tokens that pair up under the reflection:

  • CLAIM(pool) — the long exchange right. Fungible ERC-20. (A-pool.CLAIM = call; B-pool.CLAIM = put.)
  • RESIDUAL(pool) — the raw-backed writer's residual. Fungible ERC-20, pooled, redeemed pro-rata after the window. (A-pool.RESIDUAL = covered-call writer; B-pool.RESIDUAL = cash-secured-put writer.)
ETH/USDC view reflected (USDC/ETH) view
A-pool(K).CLAIM (call) B-pool(1/K).CLAIM (put)
B-pool(K).CLAIM (put) A-pool(1/K).CLAIM (call)
A-pool(K).RESIDUAL B-pool(1/K).RESIDUAL
B-pool(K).RESIDUAL A-pool(1/K).RESIDUAL

Subtlety — do not over-merge. A-pool(K).RESIDUAL and B-pool(K).RESIDUAL have the same payoff (min(spot, K)) and settle to the same asset in each branch, but they are different positions with different collateral (one is short the B→A direction backed by A, the other short A→B backed by B). Their pools must stay separate: you can't honor an A→B exercise out of an A-only pool, and merging them would let a whipsaw around K flip the pool A→B→A, breaking monotonicity (I2). The symmetry halves your code and mental model, not your number of pools.

Unit convention. A pool unit corresponds to 1 unit of its collateral bundle (1 A for A-pools, K B for B-pools). All amounts are 1e18-scaled and fully divisible.


3. Tokens and identity

Token Standard Fungible over Role
CLAIM(pool) ERC-20 / ERC-1155 id a pool the long exchange right; freely traded
RESIDUAL(pool) ERC-20 / ERC-1155 id a pool raw-backed writer residual; pooled
Position record / ERC-721 not fungible linked-backed (cascade) writer

Fungibility rule: a CLAIM(pool) is identical regardless of whether its backing is RAW or LINKED, because exercise always yields the full collateral bundle via the cascade (§7). Backing heterogeneity is hidden inside the pool and never fragments the long token.

[DECISION] Token standard. ERC-1155 with deterministic ids per (market, side, K) is the natural fit (one contract, cheap pool creation); ERC-20 wrappers can be minted on demand for venues that need them.


4. Time and lifecycle

A market has one expiry and a window length W (target 24h).

  1. Open phase t < expiry: mint, trade, unwind, build cascades. No exercise.
  2. Exercise window expiry ≤ t < expiry + W: any CLAIM of any pool may be exercised at any time (American within the window). Minting (proportional, §6.1), unwind, structural redemption, and upgradeBacking are all available.
  3. Settlement phase t ≥ expiry + W: no more exercise; pools frozen in final composition; RESIDUAL holders redeem pro-rata; Position writers reclaim/realize (§8).

[DECISION] Window length / shape. W = 24h, identical for all pools in a market (chosen over staggered windows for simplicity and fungibility). Consequence (§12): a 24h American window makes these rights strictly richer than single-instant ones; writers carry intra-window path risk and must price it.


5. Collateral, the cascade, and the self-dual linked list

A pool's backing is what guarantees its CLAIMs can be honored:

  • RAW: an A-pool holds 1 A per claim; a B-pool holds K B per claim.
  • LINKED (cascade): a pool's collateral unit is another pool's CLAIM that pays out the same collateral asset:
    • A-pool(K) may be backed by A-pool(K').CLAIM with K' ≤ K (a lower-strike call's claim yields 1 A).
    • B-pool(K) may be backed by B-pool(K').CLAIM with K' ≥ K (a higher-strike put's claim yields K B).

These two are the same rule under reflection (A↔B, K↔1/K). Within each collateral asset, the pools form a linked list ordered by ratio, bottoming out in RAW collateral.

Self-funding condition. A LINKED backing is allowed only when the incoming exercise payment covers exercising the backing:

  • A-pool: receive K B on exercise, need K' ≤ K B to exercise the backing. ✓
  • B-pool: receive 1 A, the backing returns K' ≥ K B for that 1 A. ✓

New ratios can be inserted at any time before exercise; insertion creates an (empty) pool node and never disturbs existing nodes. Using a node as backing requires mintLinked (§6.2).


6. Operations

6.1 mint — write a raw-backed claim (proportional)

Writes n claims of a pool backed by raw collateral, never diluting existing holders, even mid-window after partial exercise. Stated for a generic pool collateralized in C, exercised by delivering D.

Pool state: P claims outstanding, R residuals outstanding, raw backing B_C = P (by I1), accrued counter-asset Y_D (from prior exercises; 0 before any exercise). To mint n claims:

deposit:  n            C       (backs the n new claims)
        + n * (Y_D / P) D      (buys the proportional slice of accrued D)
receive:  n            CLAIM(pool)
        + n * (R / P)  RESIDUAL(pool)

This preserves per-residual composition exactly (C/residual = B_C/R and D/residual = Y_D/R both unchanged) and keeps B_C = P (each claim backed by one collateral unit). Before any exercise (Y_D = 0, R = P) it reduces to "deposit n C → get n claims + n residuals."

Because A-pools and B-pools are mirror instances of "a pool collateralized in C," this one formula covers both calls and puts — substitute (C,D) = (A,B) or (B,A).

Consequence (UX, not soundness): once a pool has drifted (partial exercise), minting hands you more residuals than claims; the surplus is a fair-priced slice of the existing book. If you only want a clean written position, sell the surplus residuals afterward.

Fully-settled edge (P = 0): proportional ratio undefined. [DECISION] disallow minting into a fully-exercised pool, or treat such a mint as a fresh 1:1 write in an empty sub-pool.

6.2 mintLinked — write a claim collateralized by another claim (cascade)

Caller pledges one CLAIM of an eligible neighbor pool (per §5's strike rule) and receives one CLAIM of the target pool plus a Position recording {pool, backing: LINKED→pledged CLAIM, size}. No raw collateral; the pledged claim is frozen (I5). Mirror-symmetric across A-pools and B-pools.

The minted CLAIM is fungible with all other claims of its pool (§3 rule). The short side here is a Position, not a fungible residual, because its backing is a specific pledged claim the writer manages.

[DECISION] Short-side representation. Raw writes → fungible RESIDUAL; linked writes → individual Position. Alternative: all-positions (simpler invariant story, no pooled residual) at the cost of a fungible short token. See §14.

6.3 exercise — physical settlement with cascade (window only)

For a CLAIM of a pool collateralized in C, exercised by delivering D:

  1. Caller transfers in the D-bundle (n·K of B for an A-pool; n·1 of A for a B-pool).
  2. Contract sources n collateral bundles of C and transfers them to the caller:
    • take from RAW C held by the pool if available; else
    • take a LINKED backing unit: exercise the pledged neighbor CLAIM (route the required strike down the list, receive 1 C-bundle), deliver it, and retain the positive difference. Recurse down the list until raw collateral is reached.
  3. Burn n CLAIM; assign n backing units (residual pool and/or positions) per §10; update pools preserving I1–I2.

The cascade is one atomic transaction; by conservation (I1) the collateral needed is always present. A-pool exercise walks down in strike to raw A; B-pool exercise walks up to raw B — the same walk under reflection.

6.4 upgradeBacking — convert LINKED backing to RAW (window only)

For a Position backed by a neighbor CLAIM: the writer pays that neighbor's strike, the pledged CLAIM is exercised (returns 1 C-bundle), and the position's backing becomes RAW C. This is the mid-range capture primitive — it lets a writer realize an in-the-money pledged leg when their written leg will expire worthless.

It is always safe and allowed the full window: swapping a claim-backing for raw collateral only strengthens the backing above it (I1 preserved, I5 satisfied — nothing freed, only replaced), and it never reads a price (I4). Capture should generally be paired atomically with a sale of the freed value (external flash route) to avoid re-exposing freshly-exercised collateral.

6.5 unwind — recombine long + short to reclaim backing

  • Raw-backed: burn 1 CLAIM(pool) + 1 RESIDUAL(pool) → release one backing unit (1 C-bundle). Valid any time; conservation-preserving (I2/I3).
  • Linked-backed: burn 1 CLAIM(pool) against your Position → reclaim the pledged backing claim.

This is the writer's exit and the basis of the "buy back the cheap out-of-the-money short, reclaim collateral" strategy.

6.6 redeemStructural — collapse a defined-risk bundle to its constant

If a caller holds a set of legs in one market that provably net to a constant independent of price, they may burn the set and withdraw the constant. Canonical case: the boxA-pool(K1).CLAIM (long) + short A-pool(K2) + B-pool(K2).CLAIM (long) + short B-pool(K1) nets to exactly K2 − K1 of B on every path. The contract verifies the leg set structurally (by strikes), never by price, and releases the constant. Collars / two-leg structures net only to a range (floor = a strike), so they are not instantly redeemable for a fixed amount — only a box is (see §12 on why price-based release is forbidden). Boxes are self-dual under reflection.

6.7 Settlement reads (post-window)

  • redeemResidual(pool, n): burn n RESIDUAL(pool), receive n / R_final of the pool's final composition (a blend of C and D). Pure pro-rata; price-independent.
  • closePosition(id): a Position resolves to either the strike proceeds (if its backing was assigned/consumed during the window) or its reclaimed backing (if not). See §8 for the one rule writers must observe.

7. Exercise cascade — worked walk

Exercise an A-pool(4000).CLAIM whose unit is LINKED A-pool(3500).CLAIM → A-pool(3000).CLAIM → RAW A, in the money:

  1. Exerciser delivers 4000 B, wants 1 A.
  2. 4000 pool: pays 3500 B down to exercise its 3500 backing → keeps 500 B, receives 1 A, delivers it.
  3. 3500 pool: pays 3000 B down → keeps 500 B, passes 1 A up.
  4. 3000 pool: releases RAW 1 A → keeps 3000 B; that unit converts A→B.

The 4000 B splits across layers as 3000 / 500 / 500, each accruing to that layer's short side. The mirror (a B-pool cascade walking up in strike to raw B) is identical under A↔B.


8. Settlement and the one writer rule

Under identical windows with American exercise, the cascade handles "exercise the outer claim → pull the inner." The dual case — your written (outer) leg expires worthless while your pledged (inner) backing is in-the-money — does not auto-resolve oracle-free: a pledged long is only valuable if exercised, and the contract can't know it's in-the-money without a price.

Rule W1 (writer responsibility): a writer holding a LINKED Position whose backing is in-the-money must call upgradeBacking (§6.4) during the window to capture it. After the window, an un-upgraded, un-assigned, in-the-money backing leg expires worthless and its value accrues to the inner pool's residuals. This is normal permissionless exercise made safe (no race, since upgradeBacking only strengthens backing) but it is an action the writer (or a bot) must take. It is fully automatable: race-free and price-trivial (do it iff the strike paid is less than what the freed collateral sells for).

[DECISION] (the hard one). To remove Rule W1 entirely you must adopt single-instant (European) settlement (one expiry moment, no continuous exercise), which makes the held-inner-leg resolution trivial and also removes the whipsaw premium (§12) — but contradicts "exercise every strike across the full 24h." Offered as the alternative, not the default.


9. Fungibility summary

  • CLAIM(pool): fully fungible per pool, regardless of backing (RAW or LINKED). The traded instrument.
  • RESIDUAL(pool): fungible per pool (raw-backed shorts); proportional mint keeps every unit identical.
  • Position: non-fungible (each pins a specific pledged backing); writers exit by unwind, not by trading the position.

10. Assignment policy

When a CLAIM is exercised, some short(s) must give up backing. Across a pool's raw residual pool and its linked positions:

[DECISION] Assignment rule. Use a rule that cannot be dodged or aimed:

  • Pro-rata at the instant of exercise across all outstanding shorts of the pool (raw residuals share pro-rata via the pool automatically; linked positions assigned pro-rata or by a deterministic, mint-time-independent ordering).
  • Avoid queues a late mint can jump; note that exiting to dodge assignment is self-penalizing (you must buy back the now-expensive claim), but still pick the policy explicitly.

In the pure raw-only case there is no per-position assignment: exercise converts pool C→D and all residuals share the resulting blend pro-rata at settlement.


11. Worked examples

11.1 Bull call spread 3000/4000 (capital efficiency)

  • Hold A-pool(3000).CLAIM. mintLinked it as backing → receive A-pool(4000).CLAIM + Position{A-pool(4000) ← A-pool(3000).CLAIM}. Zero new capital.
  • Sell the A-pool(4000).CLAIM for premium → reduces your 3000-claim cost basis. Max payoff capped at 1000 B.
  • spot ≥ 4000: your 4000 short is exercised → cascade consumes your pledged 3000 → you keep 1000 B.
  • 3000 < spot < 4000: your 4000 expires worthless; upgradeBacking to capture the in-the-money 3000 leg (Rule W1).
  • spot ≤ 3000: everything expires; you lose only the net premium.

The reflected reading (a put spread in the B/A market) is the identical position viewed from B.

11.2 Collar / floor

A-pool(4000).RESIDUAL (covered call, worth min(spot,4000)) + B-pool(2000).CLAIM (a 2000 put, worth max(2000−spot,0)) = a position worth clamp(spot, 2000, 4000). Floor 2000, cap 4000. The floor is guaranteed but not instantly withdrawable as a fixed amount (only a box is); borrowing against the floor is possible externally because the bundle resolves to ≥ floor on every path, with the legs frozen while borrowed (I5).

11.3 Whipsaw (1000 → 5000 → 1000 during the window)

No state breaks (§12). Each exercise is a one-way, fully-backed swap; pools move monotonically; nothing reads a price. The only effects are economic: holders can harvest intra-window extremes (the American premium); writers can be assigned early.


12. Security analysis

Path-independence (solvency cannot break on any price path). Three structural reasons:

  1. No price is ever read (I4). A whipsaw touches no price-dependent branch.
  2. Exercise is monotone (I2). Claims burn once; pools only move forward; oscillating prices cannot oscillate pool state. (This is also why mirror-pools must stay separate — §2.2.)
  3. Conservation holds every instant (I1). A pool's deliverable always equals its outstanding claims, so any remaining claim is honorable regardless of history.

Hence the safety invariant "collateral ≥ structural worst-case obligation" — itself price-independent — is preserved by every operation and, by induction, on every sequence and trajectory.

Mint safety (proportional). A proportional mint (§6.1) is a pure (1+λ) scaling of the pool; all per-unit values and ratios are invariant, so it can neither create nor extract value on any path. This closes the mint-side dilution vector. Non-proportional minting into a drifted pool would let a fresh single-asset deposit dilute accrued counter-asset; the proportional rule is therefore mandatory.

The American window is an economic cost, not a bug. A 24h continuous-exercise window lets a long harvest transient extremes, so these rights are strictly richer than single-instant ones and writers bear intra-window path risk. Design for: (a) early assignment any time in the window; (b) capture should be atomic-with-sale, never exercise-and-hold on a transient.

Why price-based collateral release is forbidden. Sizing any redemption by the current price reintroduces both an oracle and a whipsaw griefing vector (release collateral on a momentarily-out-of-the-money leg, then the price reverses and the residual is undercollateralized). All releases must be sized by the price-independent structural worst case (full collateral, vertical-spread width, or box constant). This single discipline keeps the system simultaneously oracle-free and whipsaw-safe.

Implementation hazards.

  • Token hygiene: reject fee-on-transfer and rebasing tokens for A or B — they break conservation (I1).
  • Reentrancy: the cascade makes external token calls; use checks-effects-interactions and reentrancy guards; the cascade walk must complete atomically with all pool updates.
  • Cascade depth / gas: exercise cost scales with ladder depth; bound or document it.
  • Rounding: proportional mint and pro-rata redemption divide; round to always favor the pool so dust never breaks I1.
  • Liquidity fragmentation: each (side, K, expiry) is a separate pool; to keep claims liquid (and the "buy back the cheap short" exit cheap), standardize a small strike grid and fixed expiry calendar. A usage guideline, but load-bearing for real-world viability.

13. Invariants

  • I1 — Full backing. For every pool: rawCollateralHeld + linkedNeighborClaimsHeld == claimsOutstanding (in collateral-bundle units). Each outstanding claim is honorable for its full bundle.
  • I2 — Monotone exercise. Exercise burns exactly one claim and consumes exactly one backing unit; never reversed. Pools move one-way. (Requires mirror-pools to stay separate.)
  • I3 — Proportional mint. Minting changes no per-residual composition and no backing : claims ratio; it is pure scaling.
  • I4 — Price independence. No function reads or depends on a spot price.
  • I5 — Frozen pledged backing. Anything pledged as LINKED backing or to a structural redemption is locked; freed only by upgradeBacking (replacement) or closing the structure.
  • I6 — Self-funding strike order. LINKED backing obeys §5 (A-pool K ← K′≤K; B-pool K ← K′≥K), self-dual under reflection.

14. Open design decisions (resolve before freezing)

  1. Window length & shape (§4): 24h identical (default) vs staggered per-strike vs single-instant European. European removes Rule W1 and the whipsaw premium but kills continuous exercise.
  2. Short-side representation (§6.2): fungible RESIDUAL + Position (default) vs all-positions vs all-pooled (no clean cascade).
  3. Assignment policy (§10): exact non-dodgeable, mint-time-independent rule.
  4. Rule W1 automation (§8): ship a canonical keeper/delegate for upgradeBacking or leave it to writers.
  5. Fully-settled pool minting (§6.1): disallow vs fresh sub-pool.
  6. Token standard (§3): ERC-1155 ids (recommended) vs per-pool ERC-20 vs both.
  7. Fees: none (pure primitive, max composability/neutrality) vs small protocol fee.
  8. Strike grid / expiry calendar (§12): enforced on-chain vs convention.
  9. Ratio representation: store the symmetric bundle (amountA, amountB) per pool rather than a one-sided "strike K," to keep A/B fully symmetric on-chain and make reflection a no-op.

15. Out of scope

Option pricing / premium discovery; any AMM/orderbook/RFQ for the claims themselves (external venues — e.g., a CoW batch auction over the fungible claim tokens); cross-expiry structures; keepers required for correctness (only for writer convenience, Rule W1).


Appendix A — Symmetry cheat-sheet

Reflection:  A ↔ B,  K ↔ 1/K
  A-pool(K).CLAIM  (call on A)  ==  B-pool(1/K).CLAIM  (put on B)
  cascade: A-pool K ← K′≤K      <->  B-pool K ← K′≥K
  box, mint, exercise, unwind  : all self-dual
Implement once (a pool collateralized in C, exercised in D); instantiate (C,D)=(A,B) and (B,A).

Appendix B — Operation summary

Op Phase Effect
mint open + window proportional raw-backed write
mintLinked open + window claim-collateralized write (cascade)
exercise window physical settle, cascade routing
upgradeBacking window LINKED→RAW, mid-range capture (Rule W1)
unwind any recombine claim+short → reclaim backing
redeemStructural any collapse a box to its constant
redeemResidual settlement pro-rata pool claim
closePosition settlement realize linked-write outcome
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
interface IERC20Minimal {
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
interface IERC1155ReceiverMinimal {
function onERC1155Received(
address operator,
address from,
uint256 id,
uint256 value,
bytes calldata data
) external returns (bytes4);
function onERC1155BatchReceived(
address operator,
address from,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external returns (bytes4);
}
/// @title Symmetric fully-collateralized, oracle-free options
/// @notice Draft reference implementation of the exchange-right primitive.
/// @dev Amounts of claims/residuals use 1e18 option units. Pool unit amounts are
/// expressed in the underlying ERC-20's native decimals per 1e18 option units.
contract SymmetricOptions {
uint256 public constant UNIT = 1e18;
uint256 private constant MAX_CASCADE_STEPS = 64;
struct Market {
address assetA;
address assetB;
uint64 expiry;
uint64 exerciseWindow;
bool exists;
}
struct Pool {
uint256 marketId;
bool collateralIsA;
uint256 collateralUnit;
uint256 exerciseUnit;
uint256 totalClaims;
uint256 totalResiduals;
uint256 rawUnits;
uint256 accruedExercise;
uint256 firstOpenPosition;
bool exists;
}
struct Position {
address owner;
uint256 targetPoolId;
uint256 backingPoolId;
uint256 linkedUnits;
uint256 rawUnits;
uint256 proceedsA;
uint256 proceedsB;
bool closed;
}
mapping(uint256 => Market) public markets;
mapping(uint256 => Pool) public pools;
mapping(uint256 => Position) public positions;
uint256 public marketCount;
uint256 public poolCount;
uint256 public positionCount;
mapping(address => mapping(uint256 => uint256)) private _balances;
mapping(address => mapping(address => bool)) public isApprovedForAll;
bool private _entered;
event MarketCreated(
uint256 indexed marketId,
address indexed assetA,
address indexed assetB,
uint64 expiry,
uint64 exerciseWindow
);
event PoolCreated(
uint256 indexed poolId,
uint256 indexed marketId,
bool collateralIsA,
uint256 collateralUnit,
uint256 exerciseUnit
);
event RawMinted(uint256 indexed poolId, address indexed writer, uint256 units, uint256 residualUnits);
event LinkedMinted(
uint256 indexed positionId,
uint256 indexed targetPoolId,
uint256 indexed backingPoolId,
address writer,
uint256 units
);
event Exercised(uint256 indexed poolId, address indexed exerciser, uint256 units);
event BackingUpgraded(uint256 indexed positionId, uint256 units);
event RawUnwound(uint256 indexed poolId, address indexed writer, uint256 units);
event LinkedUnwound(uint256 indexed positionId, uint256 units);
event PositionRawUnwound(uint256 indexed positionId, uint256 units);
event ResidualRedeemed(
uint256 indexed poolId,
address indexed holder,
uint256 residualUnits,
uint256 collateralOut,
uint256 exerciseOut
);
event PositionProceedsWithdrawn(uint256 indexed positionId, address indexed owner, uint256 amountA, uint256 amountB);
event PositionClosed(uint256 indexed positionId);
event TransferSingle(address indexed operator, address indexed from, address indexed to, uint256 id, uint256 value);
event TransferBatch(address indexed operator, address indexed from, address indexed to, uint256[] ids, uint256[] values);
event ApprovalForAll(address indexed account, address indexed operator, bool approved);
event URI(string value, uint256 indexed id);
error Reentrancy();
error InvalidMarket();
error InvalidPool();
error InvalidPosition();
error InvalidPhase();
error InvalidAmount();
error IncompatibleBacking();
error PoolFullyExercised();
error Slippage();
error InsufficientBalance();
error NotAuthorized();
error UnsafeToken();
error CascadeTooDeep();
modifier nonReentrant() {
if (_entered) revert Reentrancy();
_entered = true;
_;
_entered = false;
}
function createMarket(
address assetA,
address assetB,
uint64 expiry,
uint64 exerciseWindow
) external returns (uint256 marketId) {
if (assetA == address(0) || assetB == address(0) || assetA == assetB) revert InvalidMarket();
if (expiry <= block.timestamp || exerciseWindow == 0) revert InvalidMarket();
marketId = ++marketCount;
markets[marketId] = Market({
assetA: assetA,
assetB: assetB,
expiry: expiry,
exerciseWindow: exerciseWindow,
exists: true
});
emit MarketCreated(marketId, assetA, assetB, expiry, exerciseWindow);
}
function createPool(
uint256 marketId,
bool collateralIsA,
uint256 collateralUnit,
uint256 exerciseUnit
) external returns (uint256 poolId) {
Market storage market = markets[marketId];
if (!market.exists) revert InvalidMarket();
if (collateralUnit == 0 || exerciseUnit == 0) revert InvalidAmount();
poolId = ++poolCount;
pools[poolId] = Pool({
marketId: marketId,
collateralIsA: collateralIsA,
collateralUnit: collateralUnit,
exerciseUnit: exerciseUnit,
totalClaims: 0,
totalResiduals: 0,
rawUnits: 0,
accruedExercise: 0,
firstOpenPosition: 1,
exists: true
});
emit PoolCreated(poolId, marketId, collateralIsA, collateralUnit, exerciseUnit);
}
/// @notice Writes raw-backed claims. If the raw residual pool has drifted,
/// the writer must also deposit a proportional slice of accrued exercise asset.
function mintRaw(
uint256 poolId,
uint256 units,
uint256 maxExerciseDeposit
) external nonReentrant returns (uint256 residualUnits, uint256 exerciseDeposit) {
Pool storage pool = _requirePool(poolId);
_requireBeforeSettlement(pool.marketId);
if (units == 0) revert InvalidAmount();
if (pool.rawUnits == 0) {
if (pool.totalResiduals != 0 || pool.accruedExercise != 0) revert PoolFullyExercised();
residualUnits = units;
} else {
exerciseDeposit = _mulDivUp(units, pool.accruedExercise, pool.rawUnits);
residualUnits = (units * pool.totalResiduals) / pool.rawUnits;
if (residualUnits == 0) revert InvalidAmount();
}
if (exerciseDeposit > maxExerciseDeposit) revert Slippage();
_pullToken(_collateralToken(poolId), msg.sender, _unitAmount(units, pool.collateralUnit));
_pullToken(_exerciseToken(poolId), msg.sender, exerciseDeposit);
pool.rawUnits += units;
pool.totalClaims += units;
pool.totalResiduals += residualUnits;
_mint(msg.sender, claimId(poolId), units);
_mint(msg.sender, residualId(poolId), residualUnits);
emit RawMinted(poolId, msg.sender, units, residualUnits);
}
/// @notice Writes target claims backed by already-held claims of an eligible neighbor pool.
function mintLinked(
uint256 targetPoolId,
uint256 backingPoolId,
uint256 units
) external nonReentrant returns (uint256 positionId) {
Pool storage target = _requirePool(targetPoolId);
_requirePool(backingPoolId);
_requireBeforeSettlement(target.marketId);
if (units == 0) revert InvalidAmount();
_requireEligibleBacking(targetPoolId, backingPoolId);
_transfer1155(msg.sender, address(this), claimId(backingPoolId), units, "");
target.totalClaims += units;
_mint(msg.sender, claimId(targetPoolId), units);
positionId = ++positionCount;
positions[positionId] = Position({
owner: msg.sender,
targetPoolId: targetPoolId,
backingPoolId: backingPoolId,
linkedUnits: units,
rawUnits: 0,
proceedsA: 0,
proceedsB: 0,
closed: false
});
emit LinkedMinted(positionId, targetPoolId, backingPoolId, msg.sender, units);
}
/// @notice Exercises claims during the market's exercise window.
function exercise(uint256 poolId, uint256 units) external nonReentrant {
Pool storage pool = _requirePool(poolId);
_requireExerciseWindow(pool.marketId);
if (units == 0) revert InvalidAmount();
_burn(msg.sender, claimId(poolId), units);
pool.totalClaims -= units;
_pullToken(_exerciseToken(poolId), msg.sender, _unitAmount(units, pool.exerciseUnit));
_sourceCollateral(poolId, units, 0);
_pushToken(_collateralToken(poolId), msg.sender, _unitAmount(units, pool.collateralUnit));
emit Exercised(poolId, msg.sender, units);
}
/// @notice Exercises a position's pledged backing claim and replaces it with raw collateral.
/// This is the Rule W1 capture primitive from the specification.
function upgradeBacking(uint256 positionId, uint256 units) external nonReentrant {
Position storage position = _requirePosition(positionId);
if (position.owner != msg.sender) revert NotAuthorized();
if (units == 0 || units > position.linkedUnits) revert InvalidAmount();
Pool storage target = pools[position.targetPoolId];
Pool storage backing = pools[position.backingPoolId];
_requireExerciseWindow(target.marketId);
position.linkedUnits -= units;
position.rawUnits += units;
_burn(address(this), claimId(position.backingPoolId), units);
backing.totalClaims -= units;
_pullToken(_exerciseToken(position.backingPoolId), msg.sender, _unitAmount(units, backing.exerciseUnit));
uint256 released = _sourceCollateral(position.backingPoolId, units, 1);
uint256 required = _unitAmount(units, target.collateralUnit);
if (released < required) revert IncompatibleBacking();
_creditPosition(position, _collateralIsAssetA(position.targetPoolId), released - required);
emit BackingUpgraded(positionId, units);
}
/// @notice Recombines raw-backed claim and residual units to reclaim raw collateral.
function unwindRaw(uint256 poolId, uint256 units) external nonReentrant {
Pool storage pool = _requirePool(poolId);
_requireBeforeSettlement(pool.marketId);
if (units == 0 || units > pool.rawUnits) revert InvalidAmount();
_burn(msg.sender, claimId(poolId), units);
_burn(msg.sender, residualId(poolId), units);
pool.totalClaims -= units;
pool.totalResiduals -= units;
pool.rawUnits -= units;
_pushToken(_collateralToken(poolId), msg.sender, _unitAmount(units, pool.collateralUnit));
emit RawUnwound(poolId, msg.sender, units);
}
/// @notice Burns written target claims against still-linked position backing and returns the backing claims.
function unwindLinkedPosition(uint256 positionId, uint256 units) external nonReentrant {
Position storage position = _requirePosition(positionId);
if (position.owner != msg.sender) revert NotAuthorized();
Pool storage target = pools[position.targetPoolId];
_requireBeforeSettlement(target.marketId);
if (units == 0 || units > position.linkedUnits) revert InvalidAmount();
position.linkedUnits -= units;
_burn(msg.sender, claimId(position.targetPoolId), units);
target.totalClaims -= units;
_transfer1155(address(this), msg.sender, claimId(position.backingPoolId), units, "");
emit LinkedUnwound(positionId, units);
}
/// @notice Burns written target claims against upgraded raw position backing and returns raw collateral.
function unwindRawPosition(uint256 positionId, uint256 units) external nonReentrant {
Position storage position = _requirePosition(positionId);
if (position.owner != msg.sender) revert NotAuthorized();
Pool storage target = pools[position.targetPoolId];
_requireBeforeSettlement(target.marketId);
if (units == 0 || units > position.rawUnits) revert InvalidAmount();
position.rawUnits -= units;
_burn(msg.sender, claimId(position.targetPoolId), units);
target.totalClaims -= units;
_pushToken(_collateralToken(position.targetPoolId), msg.sender, _unitAmount(units, target.collateralUnit));
emit PositionRawUnwound(positionId, units);
}
/// @notice Post-window pro-rata redemption of raw residuals.
function redeemResidual(uint256 poolId, uint256 residualUnits) external nonReentrant {
Pool storage pool = _requirePool(poolId);
_requireSettlement(pool.marketId);
if (residualUnits == 0 || residualUnits > pool.totalResiduals) revert InvalidAmount();
uint256 collateralOut = _mulDivDown(_unitAmount(pool.rawUnits, pool.collateralUnit), residualUnits, pool.totalResiduals);
uint256 exerciseOut = _mulDivDown(pool.accruedExercise, residualUnits, pool.totalResiduals);
_burn(msg.sender, residualId(poolId), residualUnits);
pool.totalResiduals -= residualUnits;
uint256 rawUnitsBurned = _mulDivDown(pool.rawUnits, residualUnits, pool.totalResiduals + residualUnits);
pool.rawUnits -= rawUnitsBurned;
pool.accruedExercise -= exerciseOut;
_pushToken(_collateralToken(poolId), msg.sender, collateralOut);
_pushToken(_exerciseToken(poolId), msg.sender, exerciseOut);
emit ResidualRedeemed(poolId, msg.sender, residualUnits, collateralOut, exerciseOut);
}
function withdrawPositionProceeds(uint256 positionId) public nonReentrant {
Position storage position = _requirePosition(positionId);
if (position.owner != msg.sender) revert NotAuthorized();
uint256 amountA = position.proceedsA;
uint256 amountB = position.proceedsB;
position.proceedsA = 0;
position.proceedsB = 0;
Market storage market = markets[pools[position.targetPoolId].marketId];
_pushToken(market.assetA, msg.sender, amountA);
_pushToken(market.assetB, msg.sender, amountB);
emit PositionProceedsWithdrawn(positionId, msg.sender, amountA, amountB);
}
/// @notice Post-window close. Upgraded raw units are reclaimed; unassigned linked backing claims are returned.
function closePosition(uint256 positionId) external nonReentrant {
Position storage position = _requirePosition(positionId);
if (position.owner != msg.sender) revert NotAuthorized();
if (position.closed) revert InvalidPosition();
_requireSettlement(pools[position.targetPoolId].marketId);
position.closed = true;
uint256 linkedUnits = position.linkedUnits;
uint256 rawUnits = position.rawUnits;
position.linkedUnits = 0;
position.rawUnits = 0;
if (linkedUnits != 0) {
_transfer1155(address(this), msg.sender, claimId(position.backingPoolId), linkedUnits, "");
}
if (rawUnits != 0) {
_pushToken(
_collateralToken(position.targetPoolId),
msg.sender,
_unitAmount(rawUnits, pools[position.targetPoolId].collateralUnit)
);
}
uint256 amountA = position.proceedsA;
uint256 amountB = position.proceedsB;
position.proceedsA = 0;
position.proceedsB = 0;
Market storage market = markets[pools[position.targetPoolId].marketId];
_pushToken(market.assetA, msg.sender, amountA);
_pushToken(market.assetB, msg.sender, amountB);
emit PositionProceedsWithdrawn(positionId, msg.sender, amountA, amountB);
emit PositionClosed(positionId);
}
function balanceOf(address account, uint256 id) public view returns (uint256) {
if (account == address(0)) revert InvalidAmount();
return _balances[account][id];
}
function balanceOfBatch(
address[] calldata accounts,
uint256[] calldata ids
) external view returns (uint256[] memory batchBalances) {
if (accounts.length != ids.length) revert InvalidAmount();
batchBalances = new uint256[](accounts.length);
for (uint256 i = 0; i < accounts.length; ++i) {
batchBalances[i] = balanceOf(accounts[i], ids[i]);
}
}
function setApprovalForAll(address operator, bool approved) external {
isApprovedForAll[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
function safeTransferFrom(
address from,
address to,
uint256 id,
uint256 value,
bytes calldata data
) external {
if (msg.sender != from && !isApprovedForAll[from][msg.sender]) revert NotAuthorized();
_transfer1155(from, to, id, value, data);
}
function safeBatchTransferFrom(
address from,
address to,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) external {
if (ids.length != values.length) revert InvalidAmount();
if (msg.sender != from && !isApprovedForAll[from][msg.sender]) revert NotAuthorized();
if (to == address(0)) revert InvalidAmount();
for (uint256 i = 0; i < ids.length; ++i) {
uint256 id = ids[i];
uint256 value = values[i];
if (_balances[from][id] < value) revert InsufficientBalance();
_balances[from][id] -= value;
_balances[to][id] += value;
}
emit TransferBatch(msg.sender, from, to, ids, values);
_checkBatchReceiver(from, to, ids, values, data);
}
function onERC1155Received(
address,
address,
uint256,
uint256,
bytes calldata
) external pure returns (bytes4) {
return IERC1155ReceiverMinimal.onERC1155Received.selector;
}
function onERC1155BatchReceived(
address,
address,
uint256[] calldata,
uint256[] calldata,
bytes calldata
) external pure returns (bytes4) {
return IERC1155ReceiverMinimal.onERC1155BatchReceived.selector;
}
function supportsInterface(bytes4 interfaceId) external pure returns (bool) {
return interfaceId == 0x01ffc9a7 || interfaceId == 0xd9b67a26 || interfaceId == 0x4e2312e0;
}
function claimId(uint256 poolId) public pure returns (uint256) {
return uint256(keccak256(abi.encodePacked("SYMMETRIC_OPTIONS_CLAIM", poolId)));
}
function residualId(uint256 poolId) public pure returns (uint256) {
return uint256(keccak256(abi.encodePacked("SYMMETRIC_OPTIONS_RESIDUAL", poolId)));
}
function collateralToken(uint256 poolId) external view returns (address) {
return _collateralToken(poolId);
}
function exerciseToken(uint256 poolId) external view returns (address) {
return _exerciseToken(poolId);
}
function isExerciseWindow(uint256 marketId) external view returns (bool) {
Market storage market = markets[marketId];
return
market.exists &&
block.timestamp >= market.expiry &&
block.timestamp < market.expiry + market.exerciseWindow;
}
function isSettlement(uint256 marketId) external view returns (bool) {
Market storage market = markets[marketId];
return market.exists && block.timestamp >= market.expiry + market.exerciseWindow;
}
function _sourceCollateral(uint256 poolId, uint256 units, uint256 depth) private returns (uint256 releasedAmount) {
if (depth > MAX_CASCADE_STEPS) revert CascadeTooDeep();
Pool storage pool = pools[poolId];
uint256 remaining = units;
uint256 rawUnits = remaining < pool.rawUnits ? remaining : pool.rawUnits;
if (rawUnits != 0) {
pool.rawUnits -= rawUnits;
pool.accruedExercise += _unitAmount(rawUnits, pool.exerciseUnit);
releasedAmount += _unitAmount(rawUnits, pool.collateralUnit);
remaining -= rawUnits;
}
while (remaining != 0) {
uint256 positionId = pool.firstOpenPosition;
if (positionId > positionCount) revert InsufficientBalance();
Position storage position = positions[positionId];
if (position.targetPoolId != poolId || position.closed || (position.linkedUnits == 0 && position.rawUnits == 0)) {
pool.firstOpenPosition = positionId + 1;
continue;
}
uint256 fromRawPosition = remaining < position.rawUnits ? remaining : position.rawUnits;
if (fromRawPosition != 0) {
position.rawUnits -= fromRawPosition;
_creditPosition(position, !_collateralIsAssetA(poolId), _unitAmount(fromRawPosition, pool.exerciseUnit));
releasedAmount += _unitAmount(fromRawPosition, pool.collateralUnit);
remaining -= fromRawPosition;
continue;
}
uint256 fromLinked = remaining < position.linkedUnits ? remaining : position.linkedUnits;
if (fromLinked == 0) {
pool.firstOpenPosition = positionId + 1;
continue;
}
position.linkedUnits -= fromLinked;
Pool storage backing = pools[position.backingPoolId];
_burn(address(this), claimId(position.backingPoolId), fromLinked);
backing.totalClaims -= fromLinked;
uint256 backingReleased = _sourceCollateral(position.backingPoolId, fromLinked, depth + 1);
uint256 targetCollateral = _unitAmount(fromLinked, pool.collateralUnit);
uint256 targetExercise = _unitAmount(fromLinked, pool.exerciseUnit);
uint256 backingExercise = _unitAmount(fromLinked, backing.exerciseUnit);
if (backingReleased < targetCollateral || targetExercise < backingExercise) revert IncompatibleBacking();
releasedAmount += targetCollateral;
_creditPosition(position, _collateralIsAssetA(poolId), backingReleased - targetCollateral);
_creditPosition(position, !_collateralIsAssetA(poolId), targetExercise - backingExercise);
remaining -= fromLinked;
}
}
function _requireEligibleBacking(uint256 targetPoolId, uint256 backingPoolId) private view {
Pool storage target = pools[targetPoolId];
Pool storage backing = pools[backingPoolId];
if (!target.exists || !backing.exists || targetPoolId == backingPoolId) revert IncompatibleBacking();
if (target.marketId != backing.marketId) revert IncompatibleBacking();
if (target.collateralIsA != backing.collateralIsA) revert IncompatibleBacking();
if (backing.collateralUnit < target.collateralUnit) revert IncompatibleBacking();
if (backing.exerciseUnit > target.exerciseUnit) revert IncompatibleBacking();
}
function _requirePool(uint256 poolId) private view returns (Pool storage pool) {
pool = pools[poolId];
if (!pool.exists) revert InvalidPool();
}
function _requirePosition(uint256 positionId) private view returns (Position storage position) {
position = positions[positionId];
if (position.owner == address(0)) revert InvalidPosition();
}
function _requireBeforeSettlement(uint256 marketId) private view {
Market storage market = markets[marketId];
if (!market.exists || block.timestamp >= market.expiry + market.exerciseWindow) revert InvalidPhase();
}
function _requireExerciseWindow(uint256 marketId) private view {
Market storage market = markets[marketId];
if (!market.exists || block.timestamp < market.expiry || block.timestamp >= market.expiry + market.exerciseWindow) {
revert InvalidPhase();
}
}
function _requireSettlement(uint256 marketId) private view {
Market storage market = markets[marketId];
if (!market.exists || block.timestamp < market.expiry + market.exerciseWindow) revert InvalidPhase();
}
function _collateralToken(uint256 poolId) private view returns (address) {
Pool storage pool = pools[poolId];
Market storage market = markets[pool.marketId];
return pool.collateralIsA ? market.assetA : market.assetB;
}
function _exerciseToken(uint256 poolId) private view returns (address) {
Pool storage pool = pools[poolId];
Market storage market = markets[pool.marketId];
return pool.collateralIsA ? market.assetB : market.assetA;
}
function _collateralIsAssetA(uint256 poolId) private view returns (bool) {
return pools[poolId].collateralIsA;
}
function _creditPosition(Position storage position, bool assetA, uint256 amount) private {
if (amount == 0) return;
if (assetA) {
position.proceedsA += amount;
} else {
position.proceedsB += amount;
}
}
function _mint(address to, uint256 id, uint256 value) private {
if (to == address(0)) revert InvalidAmount();
_balances[to][id] += value;
emit TransferSingle(msg.sender, address(0), to, id, value);
}
function _burn(address from, uint256 id, uint256 value) private {
if (_balances[from][id] < value) revert InsufficientBalance();
_balances[from][id] -= value;
emit TransferSingle(msg.sender, from, address(0), id, value);
}
function _transfer1155(address from, address to, uint256 id, uint256 value, bytes memory data) private {
if (to == address(0)) revert InvalidAmount();
if (_balances[from][id] < value) revert InsufficientBalance();
_balances[from][id] -= value;
_balances[to][id] += value;
emit TransferSingle(msg.sender, from, to, id, value);
_checkReceiver(from, to, id, value, data);
}
function _checkReceiver(address from, address to, uint256 id, uint256 value, bytes memory data) private {
if (to.code.length == 0) return;
bytes4 response = IERC1155ReceiverMinimal(to).onERC1155Received(msg.sender, from, id, value, data);
if (response != IERC1155ReceiverMinimal.onERC1155Received.selector) revert UnsafeToken();
}
function _checkBatchReceiver(
address from,
address to,
uint256[] calldata ids,
uint256[] calldata values,
bytes calldata data
) private {
if (to.code.length == 0) return;
bytes4 response = IERC1155ReceiverMinimal(to).onERC1155BatchReceived(msg.sender, from, ids, values, data);
if (response != IERC1155ReceiverMinimal.onERC1155BatchReceived.selector) revert UnsafeToken();
}
function _pullToken(address token, address from, uint256 amount) private {
if (amount == 0) return;
uint256 beforeBalance = IERC20Minimal(token).balanceOf(address(this));
(bool ok, bytes memory data) = token.call(
abi.encodeWithSelector(IERC20Minimal.transferFrom.selector, from, address(this), amount)
);
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert UnsafeToken();
uint256 afterBalance = IERC20Minimal(token).balanceOf(address(this));
if (afterBalance - beforeBalance != amount) revert UnsafeToken();
}
function _pushToken(address token, address to, uint256 amount) private {
if (amount == 0) return;
uint256 beforeBalance = IERC20Minimal(token).balanceOf(address(this));
(bool ok, bytes memory data) = token.call(abi.encodeWithSelector(IERC20Minimal.transfer.selector, to, amount));
if (!ok || (data.length != 0 && !abi.decode(data, (bool)))) revert UnsafeToken();
uint256 afterBalance = IERC20Minimal(token).balanceOf(address(this));
if (beforeBalance - afterBalance != amount) revert UnsafeToken();
}
function _unitAmount(uint256 units, uint256 unitAmount) private pure returns (uint256) {
return _mulDivUp(units, unitAmount, UNIT);
}
function _mulDivDown(uint256 x, uint256 y, uint256 denominator) private pure returns (uint256) {
return (x * y) / denominator;
}
function _mulDivUp(uint256 x, uint256 y, uint256 denominator) private pure returns (uint256) {
if (x == 0 || y == 0) return 0;
return ((x * y) - 1) / denominator + 1;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
import {SymmetricOptions} from "../src/SymmetricOptions.sol";
interface Vm {
function warp(uint256 timestamp) external;
function prank(address caller) external;
}
contract MockERC20 {
string public name;
string public symbol;
uint8 public immutable decimals = 18;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
constructor(string memory name_, string memory symbol_) {
name = name_;
symbol = symbol_;
}
function mint(address to, uint256 amount) external {
balanceOf[to] += amount;
totalSupply += amount;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
return true;
}
function transfer(address to, uint256 amount) external returns (bool) {
balanceOf[msg.sender] -= amount;
balanceOf[to] += amount;
return true;
}
function transferFrom(address from, address to, uint256 amount) external returns (bool) {
uint256 allowed = allowance[from][msg.sender];
if (allowed != type(uint256).max) {
allowance[from][msg.sender] = allowed - amount;
}
balanceOf[from] -= amount;
balanceOf[to] += amount;
return true;
}
}
contract SymmetricOptionsTest {
Vm private constant vm = Vm(address(uint160(uint256(keccak256("hevm cheat code")))));
uint256 private constant UNIT = 1e18;
SymmetricOptions private options;
MockERC20 private assetA;
MockERC20 private assetB;
address private writer = address(0xA11CE);
address private buyer = address(0xB0B);
function setUp() public {
options = new SymmetricOptions();
assetA = new MockERC20("Asset A", "A");
assetB = new MockERC20("Asset B", "B");
assetA.mint(writer, 10 * UNIT);
assetB.mint(writer, 100_000 * UNIT);
assetA.mint(buyer, 10 * UNIT);
assetB.mint(buyer, 100_000 * UNIT);
}
function testRawBackedExerciseAndResidualRedemption() public {
uint256 marketId = options.createMarket(address(assetA), address(assetB), uint64(block.timestamp + 1 days), 1 days);
uint256 poolId = options.createPool(marketId, true, UNIT, 4_000 * UNIT);
_approve(writer);
vm.prank(writer);
options.mintRaw(poolId, UNIT, 0);
assertEq(options.balanceOf(writer, options.claimId(poolId)), UNIT);
assertEq(options.balanceOf(writer, options.residualId(poolId)), UNIT);
uint256 rawClaimId = options.claimId(poolId);
vm.prank(writer);
options.safeTransferFrom(writer, buyer, rawClaimId, UNIT, "");
vm.warp(block.timestamp + 1 days);
_approve(buyer);
vm.prank(buyer);
options.exercise(poolId, UNIT);
assertEq(assetA.balanceOf(buyer), 11 * UNIT);
assertEq(assetB.balanceOf(address(options)), 4_000 * UNIT);
vm.warp(block.timestamp + 1 days);
vm.prank(writer);
options.redeemResidual(poolId, UNIT);
assertEq(assetB.balanceOf(writer), 104_000 * UNIT);
}
function testLinkedCascadeSplitsExercisePaymentAcrossLayers() public {
uint256 marketId = options.createMarket(address(assetA), address(assetB), uint64(block.timestamp + 1 days), 1 days);
uint256 lowPool = options.createPool(marketId, true, UNIT, 3_000 * UNIT);
uint256 highPool = options.createPool(marketId, true, UNIT, 4_000 * UNIT);
_approve(writer);
vm.prank(writer);
options.mintRaw(lowPool, UNIT, 0);
vm.prank(writer);
options.mintLinked(highPool, lowPool, UNIT);
uint256 highClaimId = options.claimId(highPool);
vm.prank(writer);
options.safeTransferFrom(writer, buyer, highClaimId, UNIT, "");
vm.warp(block.timestamp + 1 days);
_approve(buyer);
vm.prank(buyer);
options.exercise(highPool, UNIT);
assertEq(assetA.balanceOf(buyer), 11 * UNIT);
assertEq(assetB.balanceOf(address(options)), 4_000 * UNIT);
vm.prank(writer);
options.withdrawPositionProceeds(1);
assertEq(assetB.balanceOf(writer), 101_000 * UNIT);
vm.warp(block.timestamp + 1 days);
vm.prank(writer);
options.redeemResidual(lowPool, UNIT);
assertEq(assetB.balanceOf(writer), 104_000 * UNIT);
}
function _approve(address owner) private {
vm.prank(owner);
assetA.approve(address(options), type(uint256).max);
vm.prank(owner);
assetB.approve(address(options), type(uint256).max);
}
function assertEq(uint256 actual, uint256 expected) private pure {
require(actual == expected, "assertEq failed");
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment