From 674b7528deaeb7ddaede27dd910c65f111aea402 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 00:01:03 +0000 Subject: [PATCH 01/10] feat: add InventoryHedge MM inventory-hedging vault + tx-flow docs Perpetual, BTC-settled, fully-collateralized market-maker inventory hedge generalizing stability_vault.ark: a desk converts residual BTC inventory into a delta-flat fiat claim (claim leg) while a treasury holds the BTC long leg and earns funding. Oracle-marked, no liquidation, one instance per BTC/ pair. - examples/hedging/inventory_hedge.ark (+ compiled .json): transfer, updateFunding, addCapital, removeCapital, claimExit, longExit (12 tapleaves). - tests/inventory_hedge_test.rs: roundtrip (both variants, non-empty witness schemas), oracle-path checks, claimExit clamp branches, injection, updatedAt-stripped determinism. - docs/mm-residual-hedge.md (design note) + docs/hedging/inventory_hedge_tx_flows.md (per-function tx layouts). - playground: Hedging project folder wired in main.js. https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- docs/hedging/inventory_hedge_tx_flows.md | 239 ++++ docs/mm-residual-hedge.md | 221 ++++ examples/hedging/inventory_hedge.ark | 277 +++++ examples/hedging/inventory_hedge.json | 1441 ++++++++++++++++++++++ playground/main.js | 7 + tests/inventory_hedge_test.rs | 182 +++ 6 files changed, 2367 insertions(+) create mode 100644 docs/hedging/inventory_hedge_tx_flows.md create mode 100644 docs/mm-residual-hedge.md create mode 100644 examples/hedging/inventory_hedge.ark create mode 100644 examples/hedging/inventory_hedge.json create mode 100644 tests/inventory_hedge_test.rs diff --git a/docs/hedging/inventory_hedge_tx_flows.md b/docs/hedging/inventory_hedge_tx_flows.md new file mode 100644 index 0000000..29c77ca --- /dev/null +++ b/docs/hedging/inventory_hedge_tx_flows.md @@ -0,0 +1,239 @@ +# InventoryHedge — Transaction Flows + +Companion to [`docs/mm-residual-hedge.md`](../mm-residual-hedge.md) (design intent) +and the contract [`examples/hedging/inventory_hedge.ark`](../../examples/hedging/inventory_hedge.ark). + +This note walks each spend path as an actual transaction: who signs, what goes +in the witness, and the exact input/output layout the script enforces. It is the +operational view — the "what does the tx look like" that the contract comments +abbreviate. + +--- + +## The vault UTXO + +A single `InventoryHedge` UTXO holds the pooled BTC and commits all state in its +scriptPubKey via Taproot. Two parties share it: + +| Leg | Key | Holds | BTC delta | +|---|---|---|---| +| **claim** | `claimPk` | a fiat claim `targetFiat` | flat in fiat (short BTC) | +| **long** | `longPk` | residual BTC upside, posts the over-collateral | long BTC | + +Committed state (all in the scriptPubKey): + +- **Immutable** across the position's life: `claimPk` (until transferred), + `longPk`, `oraclePk`, `ticker`, `collateralRatioPct`, `exit`. +- **Mutable** — re-committed in the next-state output on every transition: + `targetFiat`, `totalCollateral`, `fundingRatePerSec`, `lastUpdate`. + +Every non-terminal function is a **self-replacement**: it asserts +`tx.outputs[0].scriptPubKey == new InventoryHedge(...)` with the next state and +`tx.outputs[0].value >= `. The vault thus walks forward as a chain +of UTXOs. + +### Two tapleaves per function + +`options { server = server; exit = exit; }` makes the compiler emit **two +variants** of every function: + +- **Cooperative** (`serverVariant=true`): the caller's signature **plus** the + Arkade Operator co-signature (``, auto-injected — never a + constructor param). Fast, off-chain-settled path. +- **Exit** (`serverVariant=false`): the caller's signature plus a CLTV timelock + of `exit` blocks — the unilateral fallback when the operator is unavailable. + +Both enforce identical settlement math. Settlement that price-introspects is only +meaningful on the cooperative leaf; the exit leaf is the liveness backstop. + +--- + +## Timebase and oracle + +- `tx.offchainTime` — TEE-introspector wallclock (unix seconds). Used for funding + accrual and oracle freshness. Not monotonic-guaranteed, so every use of + `elapsed = tx.offchainTime - lastUpdate` (and `oracleAge`) is paired with a + `>= 0` clock-regression guard. +- **Price oracle** (Fuji pattern): off-chain the oracle signs + `msg = sha256(ticker || price || timestamp)` with `price`/`timestamp` as 8-byte + LE. On-chain the contract rebuilds the digest and verifies + `checkSigFromStack(oracleSig, oraclePk, msg)`. Freshness window: 600 seconds. +- **Funding rate** is *not* oracle-attested on-chain: the dynamic, + imbalance-driven value is computed off-chain by the desk's risk engine (design + note §4) and supplied to `updateFunding`; the script only enforces `>= 0` and + the accrual roll-forward. + +Funding accrual, interleaved `/1e6` twice to stay inside int64: + +``` +elapsed = tx.offchainTime - lastUpdate +rateElapsed = fundingRatePerSec * elapsed / 1e6 +delta = targetFiat * rateElapsed / 1e6 +newTargetFiat = targetFiat + delta // = targetFiat × (1 + rate·elapsed/1e12) +``` + +--- + +## 1. `transfer` — reassign the claim leg + +The desk hands its claim to a new key (design note §6 "transfer" resize). Pure +key swap; no oracle, no funding roll. + +``` +Signers : claimSig (+ SERVER_KEY | + CLTV exit) +Witness : claimSig, newClaimPk + +in [0] : InventoryHedge UTXO +out[0] : InventoryHedge( claimPk:=newClaimPk, …all other state unchanged… ) + value >= totalCollateral +``` + +State change: `claimPk → newClaimPk`. Everything else (collateral, funding, +clock) is preserved. + +--- + +## 2. `updateFunding` — roll funding, set the new rate + +The long leg rolls accrued funding into the claim and adopts the next +off-chain-computed rate. + +``` +Signers : longSig (+ SERVER_KEY | + CLTV exit) +Witness : longSig, newFundingRatePerSec + +in [0] : InventoryHedge UTXO +out[0] : InventoryHedge( + targetFiat := newTargetFiat, // old rate accrued in + fundingRatePerSec:= newFundingRatePerSec, // must be >= 0 + lastUpdate := tx.offchainTime, + …collateral, keys, ticker unchanged… ) + value >= totalCollateral +``` + +Guards: `newFundingRatePerSec >= 0` (a negative rate would let the long leg drain +the desk), `newTargetFiat > 0` (claim not wiped), and the **anti-grief** rule — +if the *current* rate is non-zero the accrual must be non-zero, else advancing +`lastUpdate` would silently swallow funding owed to the desk. A zero-accrual roll +is legal only when the current rate is already 0 (resume-from-pause). + +--- + +## 3. `addCapital` — treasury tops up collateral + +More collateral is strictly better for the desk, so no ratio/oracle check. + +``` +Signers : longSig (+ SERVER_KEY | + CLTV exit) +Witness : longSig, amount + +in [0] : InventoryHedge UTXO +in [1+] : long-leg funding input(s) (≥ amount sats, off-script) +out[0] : InventoryHedge( totalCollateral := totalCollateral + amount, …rest unchanged… ) + value >= totalCollateral + amount +``` + +State change: `totalCollateral += amount`. Funding clock untouched. + +--- + +## 4. `removeCapital` — treasury reclaims excess, ratio-guarded + +The long leg withdraws surplus collateral, but the remainder must still cover the +claim at `collateralRatioPct` *at the current mark*. Accrual is computed **only +for the guard** — `targetFiat` and `lastUpdate` are deliberately **not** mutated, +so a withdrawal never truncates funding (only `updateFunding` moves the clock). + +``` +Signers : longSig (+ SERVER_KEY | + CLTV exit) +Witness : longSig, amount, oraclePrice, oracleTime, oracleSig + +in [0] : InventoryHedge UTXO +out[0] : InventoryHedge( totalCollateral := totalCollateral - amount, …rest unchanged… ) + value >= totalCollateral - amount +out[1] : SingleSig(longPk) value >= amount // reclaimed sats +``` + +Guard (oracle-priced): + +``` +accruedFiat = targetFiat + delta +claimSats = accruedFiat * 1e8 / oraclePrice +minCollateral = claimSats * (100 + collateralRatioPct) / 100 +require( totalCollateral - amount >= minCollateral ) // "would breach collateral ratio" +``` + +--- + +## 5. `claimExit` — desk settles to BTC (terminal) + +The desk unwinds the hedge at the oracle mark. BTC-native settlement at the index +price (design note §2: no perp-spot basis). The vault terminates. + +``` +Signers : claimSig (+ SERVER_KEY | + CLTV exit) +Witness : claimSig, oraclePrice, oracleTime, oracleSig + +in [0] : InventoryHedge UTXO +``` + +Payout is the claim clamped into `[0, totalCollateral]`: + +``` +claimRaw = newTargetFiat * 1e8 / oraclePrice +``` + +| Branch | Condition | out[0] | out[1] | +|---|---|---|---| +| claim wiped | `claimRaw <= 0` | `SingleSig(longPk)` ≥ `totalCollateral` | — | +| fully covered | `claimRaw >= totalCollateral` | `SingleSig(claimPk)` ≥ `totalCollateral` | — | +| split | otherwise | `SingleSig(claimPk)` ≥ `claimRaw` | `SingleSig(longPk)` ≥ `totalCollateral − claimRaw` *(only if > 330 sats)* | + +The 330-sat **Taproot dust floor**: the long-leg remainder output is asserted +only when it exceeds 330 sats; below that it routes to fees rather than a dust +output. "Fully collateralized" means this clamp always pays out of the pool — no +liquidation, no margin call on the claim side. + +--- + +## 6. `longExit` — treasury-driven settlement (terminal) + +Identical clamp math; the long leg initiates instead of the desk. Same output +table as §5 with `longSig` in place of `claimSig`. Lets the treasury close a +position the desk has gone quiet on, settling both legs fairly at the mark. + +--- + +## Lifecycle at a glance + +``` + ┌────────────── updateFunding (roll funding, reprice) ───────────────┐ + │ │ + ▼ │ + open ─▶ InventoryHedge UTXO ──▶ addCapital / removeCapital (resize collateral) ─┘ + │ │ + │ └──▶ transfer (reassign claim leg) + │ + ├──▶ claimExit ──▶ BTC to claim (+ remainder to long) [terminal] + └──▶ longExit ──▶ BTC to claim (+ remainder to long) [terminal] +``` + +Off-chain, the desk nets client flow internally and only adjusts the hedge when +aggregate BTC delta breaches a limit (design note §6): `addCapital` / +`removeCapital` / `transfer` reshape the live position instead of unwind-and-reopen. + +--- + +## Playground + +The contract ships in the WASM playground under the **Hedging** project folder +(`playground/main.js`), sourced from `playground/contracts.js` (regenerate with +`./playground/generate_contracts.sh` after editing the `.ark`). Build the WASM +bundle and serve with: + +``` +./playground/build.sh # wasm-pack build + contracts regen +./playground/serve.sh 8080 # static server +``` + +Then pick **Hedging → inventory_hedge.ark** to compile it live in the browser. diff --git a/docs/mm-residual-hedge.md b/docs/mm-residual-hedge.md new file mode 100644 index 0000000..f437c6c --- /dev/null +++ b/docs/mm-residual-hedge.md @@ -0,0 +1,221 @@ +# Market-Maker Inventory Hedging on a BTC-Settled Vault + +**Status:** design note. Not a spec. + +This note describes how a market-making / RFQ desk can hedge its net inventory +delta *natively and BTC-settled*, instead of mirroring fills on a centralized +exchange. The instrument is a generalization of `StabilityVault`: a +BTC-collateralized, oracle-marked vault with perpetual dynamic funding. The desk +holds one leg of the vault to convert residual BTC inventory into a +fiat-denominated claim — delta-flat, self-custodied, no CEX. + +Scope is **hedging only**. Turning this into a public, tradeable swap market is a +separate layer (§8) and explicitly out of scope here. + +--- + +## 1. The problem + +A desk that makes a two-sided BTC/fiat market accumulates inventory delta. When a +client sells the desk BTC (desk pays fiat), the desk is **long BTC** and wants to +be flat in fiat terms. The reflexive hedge is to short a BTC perpetual on a CEX, +which is: + +- **Capital intensive** — margin cannot be netted across venues, so the desk must + pre-fund collateral at every venue it hedges on; the short also ties up margin + and can be liquidated on a sharp rally. +- **Freeze-prone** — funds on a CEX are an unsecured custodial claim. Exchanges + have frozen withdrawals in insolvency, and even solvent venues halt withdrawals + during congestion or outages. Capital can be stranded or lost for years. + +The goal is a hedge that keeps BTC in self-custody and never routes capital to an +exchange. + +## 2. The instrument: the desk holds the claim leg + +A `StabilityVault`-style vault splits a BTC UTXO into a senior fiat claim and a +leveraged long: + +| Vault role | Holds | Delta | +|---|---|---| +| **Claim leg** | a fiat-denominated claim (`targetFiat`) backed by the pooled BTC | **flat in fiat** | +| **Long leg** | the residual BTC upside; posts the over-collateral | long BTC | +| funding | `fundingRate > 0` ⇒ **long leg pays claim leg** | — | + +A desk that is **net-long BTC takes the claim leg**: it deposits its residual +inventory and converts it to a fiat claim of equal value, delta-flat, while the +BTC stays in self-custody as collateral. A desk that is **net-short takes the long +leg.** The vault is symmetric enough to absorb residual delta either direction. + +Structurally this is the cash-and-carry hedge (long spot + offsetting synthetic), +executed peer-to-peer and BTC-settled, with two properties that beat a CEX perp: + +1. **No perp-spot basis.** Settlement is at the oracle/index price directly + (`seekerExit`: `claimRaw = newTargetFiat * 1e8 / P`), not against a separately + floating perp mark. +2. **No exchange custody.** The hedge is a self-custodied UTXO; there is no + withdrawal to freeze. + +### Downside protection is bounded by the collateral ratio + +At a 1.5:1 ratio the claim leg deposits `S`, the long leg adds `1.5·S`, total +collateral `2.5·S`. The claim in sats at price `P` is `targetFiat·1e8/P`, which +stays `<= 2.5·S` while `P >= 0.4·P0`. **The desk is made whole down to a ~60% +drawdown**; beyond that the buffer is exhausted and the desk carries the tail. +This bound, against the freshness of the funding/margin top-up cycle +(`addCapital` / `removeCapital`), is the number to size first. + +## 3. Multi-currency reduces to one BTC-delta book + +A desk that quotes several BTC/fiat pairs (USD, BRL, CHF, EUR, …) instantiates the +**same vault construction once per oracle ticker**. The currencies differ only in +which `BTC/` feed the vault reads. + +The key simplification: every fiat claim leg is, in BTC terms, **short BTC** — they +differ in *currency* but point the *same direction in BTC delta*. So the whole +desk collapses to: + +> **N currencies, one number to balance: aggregate net BTC delta**, over one shared +> BTC collateral pool. + +The currency dimension is handled by reading different oracles; the only thing +that must be *balanced* is BTC-longness vs BTC-shortness across the whole book. +Cross-currency exposure triangulates through BTC (`/` = +`(BTC/) / (BTC/)`), so no separate FX leg is needed. + +## 4. Perpetual dynamic funding + +Funding is **perpetual** (no maturity — dated hedges fragment liquidity across +tenors and add rate risk) and **dynamic**, driven by long/short imbalance: + +``` +fundingRate = clamp( premiumIndex + carryComponent , -cap, +cap ) +premiumIndex ∝ open-interest skew between claim and long legs +long leg crowded -> funding up -> longs pay the claim side (cools long demand) +claim crowded -> funding down -> claim side pays longs (recruits longs) +``` + +Dynamic funding is what clears a one-sided book without a permanent dedicated +counterparty: when the desk's residual is heavily one direction, funding moves to +recruit the other side. (This is the perpetual-funding mechanism that +everlasting-style instruments use to replace expiry.) `StabilityVault`'s current +funding is a fixed negotiated rate; making it imbalance-driven is the main +behavioral change for a hedging book. + +The funding **floor** is the key economic knob: floor at zero is desk-friendly but +drains long-side liquidity when the basis inverts; allowing negative funding keeps +both sides present. Choose per deployment. + +## 5. The counterparty: a willing BTC holder + +The one persistent constraint is that **someone must take the BTC-long leg.** For a +self-hosted hedge the natural provider is the firm's own **BTC treasury**: the +market-making book hedges *into* the treasury, the treasury holds the BTC-long leg +(exposure a BTC-long treasury wants anyway) and earns the funding. No external +provider, no CEX; funding is internal P&L allocation between books. + +State this honestly: if the treasury is the only provider, the **firm remains net +long BTC** — the delta is *transferred* from the market-making book to the book +that wants it, not eliminated firm-wide. That is exactly correct for a desk whose +mandate is to stay flat while the firm's directional view lives in treasury. It is +**risk-transfer to a willing holder, not risk-elimination** — do not represent it +as a firm-level hedge. + +When treasury appetite is exhausted, the fallbacks are: raise funding to recruit +external longs, or warehouse the residual unhedged (acceptable for a sophisticated +desk on a short horizon). + +## 6. The efficient flow: internalize → hedge residual → resize + +Do not open a vault per fill. Hedge only the residual swing: + +``` +1. INTERNALIZE + Net client buys against sells across the whole book. + Skew quotes (inventory-aware reservation price) to attract + inventory-reducing flow. Most flow never needs an external hedge. + +2. SIZE THE RESIDUAL + Track net BTC delta D (signed, aggregated across all currency pairs). + Adjust the hedge only when |D| breaches a position limit. + +3. HEDGE + D > 0 (net long) -> hold the claim leg of notional |D| + D < 0 (net short) -> hold the long leg of notional |D| + +4. RESIZE, don't churn + As D drifts, reshape the existing position: + split -> peel off part of the claim when D shrinks + merge -> combine positions when D grows + transfer -> reassign a leg + The hedge tracks moving inventory without unwind/reopen. +``` + +Internalisation is what makes the native hedge viable: it shrinks the residual +that needs a counterparty by a large factor, which is exactly the part that is +hard to source (§5, §9). + +## 7. What exists today vs. what's missing + +**Reusable as-is from `stability_vault.ark`:** +- Claim leg + long leg in one UTXO; funding accrual; oracle-priced settlement with + clamping (`seekerExit` / `providerExit`). +- `split` / `merge` / `transfer` for resizing. +- `addCapital` / `removeCapital` with a collateral-ratio guard. +- Fuji-style oracle (`sha256(ticker + price + time)`), `tx.offchainTime` freshness, + two-tapleaf cooperative/exit, 330-sat dust floor. +- Per-currency instances are just the same contract with a different `ticker`. + +**Net-new:** +- **Dynamic, imbalance-driven funding** (§4) — current funding is a static + negotiated rate. +- Aggregate **BTC-delta accounting** across currency instances — lives in the + desk's off-chain risk engine; no contract change. +- Treasury-as-provider tooling for the long leg (templates, internal funding + accounting). + +## 8. Scope boundary + +This note covers the **hedging core** only. A useful decomposition: + +> A public synthetic swap market = **(this hedging vault) + (a bootstrapping +> layer).** + +The bootstrapping layer — a fungible, freely-tradeable claim token, a maker +quoting tight near mid, and the two-sided flywheel that pulls in third-party +liquidity — is what a *public market* needs. A private hedge needs none of it: it +needs a counterparty (§5) and a fair funding rate (§4). Making the claim a fungible +token and adding a maker is the upgrade path *if and when* a tradeable market +becomes a goal; it is out of scope for hedging. + +## 9. Tradeoffs and risks + +1. **Counterparty in risk-off.** A one-sided book needs the other leg. Treasury + appetite is finite; when exhausted, funding spikes or the desk warehouses. A + desk can warehouse temporarily; this is the binding constraint to model. +2. **Tail collateralization.** Protection holds only to the ratio bound (~60% + drawdown at 1.5:1). Beyond it the claim leg carries the loss. For a hedging + desk this is a consciously-accepted basis risk, not a consumer guarantee. +3. **Oracle per currency.** Each `BTC/` feed adds oracle surface; thinner + pairs are more manipulable, and high-rate currencies embed carry that funding + must reflect. +4. **Oracle / operator liveness.** Settlement is enforceable only on the + cooperative tapleaf; the unilateral exit path cannot price-introspect. Define + the fallback for unwinding a hedge when the cooperative path is unavailable. +5. **Risk-type shift, not removal.** This trades CEX custody / freeze / basis risk + for counterparty, oracle, funding-availability, and operator-liveness risk. + Delta-flat means price-neutral, not risk-free. + +## 10. Open decisions + +1. **Funding floor** — allow negative funding (true two-sided clearing) or floor + at zero (desk-friendly, but longs thin on inverted basis)? +2. **Counterparty model** — treasury-only to start, or a small provider set for + redundancy? +3. **Operator-down unwind** — coarse block-height emergency exit, or a pre-signed + unilateral settlement path? + +The thinnest first build: a single-provider (treasury) hedge on one `BTC/` +pair, perpetual dynamic funding, cooperative-only settlement, with the multi- +currency aggregation and additional providers as fast-follows once the +counterparty model is validated. diff --git a/examples/hedging/inventory_hedge.ark b/examples/hedging/inventory_hedge.ark new file mode 100644 index 0000000..03e4e15 --- /dev/null +++ b/examples/hedging/inventory_hedge.ark @@ -0,0 +1,277 @@ +// InventoryHedge Contract +// +// A perpetual, BTC-settled, fully-collateralized market-maker inventory hedge. +// See docs/mm-residual-hedge.md. It generalizes StabilityVault: a BTC UTXO is +// split between two parties so a market-making desk can convert residual BTC +// inventory into a fiat-denominated claim — delta-flat, self-custodied, no CEX. +// +// Legs (doc §2): +// - CLAIM leg (claimPk): the desk. Holds a fiat claim `targetFiat`, +// delta-flat in fiat terms. +// - LONG leg (longPk): the provider / BTC treasury. Holds the residual BTC +// upside and posts the over-collateral. +// Funding flows LONG -> CLAIM: the cost of self-custodied leverage. +// +// Perpetual: no maturity. Oracle-marked. Fully collateralized — settlement is +// always a clamp into [0, totalCollateral], so there is no liquidation. +// +// Multi-currency (doc §3): `ticker` is the BTC/ feed id +// (e.g. sha256("BTC/USD")). One instance per pair; the same contract serves +// USD/BRL/CHF/EUR purely by parameterization. Every fiat claim leg is short BTC, +// so the whole desk collapses to one aggregate BTC-delta book balanced off-chain. +// +// Funding model (fiat-compound, interleaved /1e6 to stay in int64): +// elapsed = tx.offchainTime - lastUpdate +// newTargetFiat = targetFiat × (1 + fundingRatePerSec × elapsed / 1e12) +// fundingRatePerSec is a signed fixed-point fraction at scale 1e12. The +// dynamic, imbalance-driven rate (doc §4) is computed OFF-chain by the desk's +// risk engine and supplied to updateFunding; on-chain logic is the >= 0 guard +// plus the accrual roll-forward. +// +// Settlement math (single shape across claimExit, longExit, removeCapital): +// newTargetFiat = targetFiat × (1 + fundingRatePerSec × elapsed / 1e12) +// claimRaw = newTargetFiat × 1e8 / oraclePrice +// claimPayout = clamp(claimRaw, 0, totalCollateral) +// longPayout = totalCollateral − claimPayout +// At a 1.5:1 ratio the desk is made whole to a ~60% drawdown (doc §2); beyond +// that the buffer is exhausted and the claim leg carries the tail. +// +// Oracle model (Fuji-style signed feed): +// msg = sha256(ticker || price || timestamp), price/timestamp as 8-byte LE. +// At settlement the caller supplies (oraclePrice, oracleTime, oracleSig); +// freshness: tx.offchainTime - oracleTime <= 600 seconds. `tx.offchainTime` +// is the TEE-introspector wallclock (unix seconds), distinct from `tx.time` +// (Bitcoin nLockTime, block height). + +import "single_sig.ark"; + +options { + server = server; + exit = exit; +} + +contract InventoryHedge( + pubkey claimPk, // holder of the fiat claim leg (the hedging desk) + pubkey longPk, // collateral provider / BTC long leg (treasury) + pubkey oraclePk, // price feed key; only its signatures are accepted + bytes32 ticker, // BTC/ feed identifier (e.g. sha256("BTC/USD")) + int targetFiat, // claim leg's fiat value (minor units); mutates on funding + int totalCollateral, // sats locked; mutates on add/removeCapital + int fundingRatePerSec, // signed fixed-point at scale 1e12; mutates on update + int lastUpdate, // unix seconds; basis for funding accrual + int collateralRatioPct,// min collateral ratio for removeCapital (50 = 1.5:1) + int exit // exit timelock in blocks +) { + + // ------------------------------------------------------------------------- + // TRANSFER — desk reassigns the claim leg. Pure key swap, no oracle. + // ------------------------------------------------------------------------- + function transfer(signature claimSig, pubkey newClaimPk) { + require(checkSig(claimSig, claimPk), "invalid claim sig"); + + require( + tx.outputs[0].scriptPubKey == new InventoryHedge( + newClaimPk, longPk, oraclePk, ticker, + targetFiat, totalCollateral, fundingRatePerSec, lastUpdate, + collateralRatioPct, exit + ), + "invalid transfer output" + ); + require(tx.outputs[0].value >= totalCollateral, "collateral not preserved"); + } + + // ------------------------------------------------------------------------- + // UPDATE FUNDING — provider rolls accrued funding into targetFiat and sets a + // new rate. The rate must be >= 0: a negative rate would let the provider + // drain the desk. The dynamic, imbalance-driven value is computed off-chain + // (doc §4) and supplied here; on-chain we only guard the sign and the accrual. + // ------------------------------------------------------------------------- + function updateFunding(signature longSig, int newFundingRatePerSec) { + require(checkSig(longSig, longPk), "invalid long-leg sig"); + require(newFundingRatePerSec >= 0, "negative funding rate disallowed"); + + int elapsed = tx.offchainTime - lastUpdate; + require(elapsed >= 0, "clock regression"); + int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000; + int delta = targetFiat * rateElapsedScaled / 1000000; + int newTargetFiat = targetFiat + delta; + require(newTargetFiat > 0, "claim wiped by funding"); + + // Anti-grief: if the current rate is non-zero the accrual must be non-zero, + // else advancing lastUpdate would silently suppress funding owed the desk. + // A no-accrual update is only legitimate when the current rate is already 0. + if (fundingRatePerSec != 0) { + require(delta > 0, "no accrual; wait longer"); + } + + require( + tx.outputs[0].scriptPubKey == new InventoryHedge( + claimPk, longPk, oraclePk, ticker, + newTargetFiat, totalCollateral, newFundingRatePerSec, tx.offchainTime, + collateralRatioPct, exit + ), + "invalid update output" + ); + require(tx.outputs[0].value >= totalCollateral, "collateral not preserved"); + } + + // ------------------------------------------------------------------------- + // ADD CAPITAL — provider tops up collateral (doc §2 margin top-up cycle). + // More collateral is always strictly better for the desk; no ratio check. + // ------------------------------------------------------------------------- + function addCapital(signature longSig, int amount) { + require(checkSig(longSig, longPk), "invalid long-leg sig"); + require(amount > 0, "zero amount"); + + int newTotalCollateral = totalCollateral + amount; + + require( + tx.outputs[0].scriptPubKey == new InventoryHedge( + claimPk, longPk, oraclePk, ticker, + targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate, + collateralRatioPct, exit + ), + "invalid output" + ); + require(tx.outputs[0].value >= newTotalCollateral, "collateral not deposited"); + } + + // ------------------------------------------------------------------------- + // REMOVE CAPITAL — provider reclaims excess collateral, but the remaining + // position must still cover the claim at collateralRatioPct (doc §2 bound). + // Accrued funding is included in the check without rolling it into the vault. + // ------------------------------------------------------------------------- + function removeCapital( + signature longSig, + int amount, + int oraclePrice, + int oracleTime, + signature oracleSig + ) { + require(checkSig(longSig, longPk), "invalid long-leg sig"); + require(amount > 0, "zero amount"); + require(oraclePrice > 0, "invalid oracle price"); + + int oracleAge = tx.offchainTime - oracleTime; + require(oracleAge >= 0, "future-dated oracle"); + require(oracleAge <= 600, "stale oracle"); + + let oracleMsg = sha256(ticker + oraclePrice + oracleTime); + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle signature"); + + int elapsed = tx.offchainTime - lastUpdate; + require(elapsed >= 0, "clock regression"); + int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000; + int delta = targetFiat * rateElapsedScaled / 1000000; + int newTargetFiat = targetFiat + delta; + int currentClaimBase = newTargetFiat * 100000000 / oraclePrice; + int minCollateral = currentClaimBase * (100 + collateralRatioPct) / 100; + int newTotalCollateral = totalCollateral - amount; + require(newTotalCollateral >= minCollateral, "would breach collateral ratio"); + + require( + tx.outputs[0].scriptPubKey == new InventoryHedge( + claimPk, longPk, oraclePk, ticker, + targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate, + collateralRatioPct, exit + ), + "invalid vault output" + ); + require(tx.outputs[0].value >= newTotalCollateral, "vault underfunded"); + require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), "output 1 not long leg"); + require(tx.outputs[1].value >= amount, "long leg underpaid"); + } + + // ------------------------------------------------------------------------- + // CLAIM EXIT — desk settles the hedge to BTC at the oracle price. + // BTC-native at the index price (doc §2: no perp-spot basis). No exit fee. + // claimRaw = newTargetFiat × 1e8 / oraclePrice + // claimPayout = clamp(claimRaw, 0, totalCollateral); remainder to long leg. + // ------------------------------------------------------------------------- + function claimExit( + signature claimSig, + int oraclePrice, + int oracleTime, + signature oracleSig + ) { + require(checkSig(claimSig, claimPk), "invalid claim sig"); + require(oraclePrice > 0, "invalid oracle price"); + + int oracleAge = tx.offchainTime - oracleTime; + require(oracleAge >= 0, "future-dated oracle"); + require(oracleAge <= 600, "stale oracle"); + + let oracleMsg = sha256(ticker + oraclePrice + oracleTime); + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle signature"); + + int elapsed = tx.offchainTime - lastUpdate; + require(elapsed >= 0, "clock regression"); + int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000; + int delta = targetFiat * rateElapsedScaled / 1000000; + int newTargetFiat = targetFiat + delta; + int claimRaw = newTargetFiat * 100000000 / oraclePrice; + + if (claimRaw <= 0) { + require(tx.outputs[0].value >= totalCollateral, "long leg underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), "output 0 not long leg"); + } else { + if (claimRaw >= totalCollateral) { + require(tx.outputs[0].value >= totalCollateral, "claim underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), "output 0 not claim"); + } else { + require(tx.outputs[0].value >= claimRaw, "claim underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), "output 0 not claim"); + int longPayout = totalCollateral - claimRaw; + if (longPayout > 330) { + require(tx.outputs[1].value >= longPayout, "long leg underpaid"); + require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), "output 1 not long leg"); + } + } + } + } + + // ------------------------------------------------------------------------- + // LONG EXIT — provider-driven settlement; identical clamp math. + // ------------------------------------------------------------------------- + function longExit( + signature longSig, + int oraclePrice, + int oracleTime, + signature oracleSig + ) { + require(checkSig(longSig, longPk), "invalid long-leg sig"); + require(oraclePrice > 0, "invalid oracle price"); + + int oracleAge = tx.offchainTime - oracleTime; + require(oracleAge >= 0, "future-dated oracle"); + require(oracleAge <= 600, "stale oracle"); + + let oracleMsg = sha256(ticker + oraclePrice + oracleTime); + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle signature"); + + int elapsed = tx.offchainTime - lastUpdate; + require(elapsed >= 0, "clock regression"); + int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000; + int delta = targetFiat * rateElapsedScaled / 1000000; + int newTargetFiat = targetFiat + delta; + int claimRaw = newTargetFiat * 100000000 / oraclePrice; + + if (claimRaw <= 0) { + require(tx.outputs[0].value >= totalCollateral, "long leg underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), "output 0 not long leg"); + } else { + if (claimRaw >= totalCollateral) { + require(tx.outputs[0].value >= totalCollateral, "claim underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), "output 0 not claim"); + } else { + require(tx.outputs[0].value >= claimRaw, "claim underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), "output 0 not claim"); + int longPayout = totalCollateral - claimRaw; + if (longPayout > 330) { + require(tx.outputs[1].value >= longPayout, "long leg underpaid"); + require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), "output 1 not long leg"); + } + } + } + } +} diff --git a/examples/hedging/inventory_hedge.json b/examples/hedging/inventory_hedge.json new file mode 100644 index 0000000..e4f7e19 --- /dev/null +++ b/examples/hedging/inventory_hedge.json @@ -0,0 +1,1441 @@ +{ + "contractName": "InventoryHedge", + "constructorInputs": [ + { + "name": "claimPk", + "type": "pubkey" + }, + { + "name": "longPk", + "type": "pubkey" + }, + { + "name": "oraclePk", + "type": "pubkey" + }, + { + "name": "ticker", + "type": "bytes32" + }, + { + "name": "targetFiat", + "type": "int" + }, + { + "name": "totalCollateral", + "type": "int" + }, + { + "name": "fundingRatePerSec", + "type": "int" + }, + { + "name": "lastUpdate", + "type": "int" + }, + { + "name": "collateralRatioPct", + "type": "int" + }, + { + "name": "exit", + "type": "int" + } + ], + "functions": [ + { + "name": "transfer", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "newClaimPk", + "type": "pubkey" + } + ], + "witnessSchema": [ + { + "name": "claimSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "newClaimPk", + "type": "pubkey", + "encoding": "compressed-33" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,,,,,)>", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "transfer", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "newClaimPk", + "type": "pubkey" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + }, + { + "name": "newClaimPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "newClaimPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "3-of-3 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "updateFunding", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "newFundingRatePerSec", + "type": "int" + } + ], + "witnessSchema": [ + { + "name": "longSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "newFundingRatePerSec", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "", + "0", + "OP_GREATERTHAN", + "", + "OP_SCRIPTNUMTOLE64", + "0", + "OP_EQUAL", + "OP_NOT", + "OP_IF", + "", + "0", + "OP_GREATERTHAN", + "OP_ENDIF", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,,,,,)>", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "updateFunding", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "newFundingRatePerSec", + "type": "int" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "addCapital", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "amount", + "type": "int" + } + ], + "witnessSchema": [ + { + "name": "longSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "amount", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "0", + "OP_GREATERTHAN", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,,,,,)>", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "addCapital", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "amount", + "type": "int" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "removeCapital", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "amount", + "type": "int" + }, + { + "name": "oraclePrice", + "type": "int" + }, + { + "name": "oracleTime", + "type": "int" + }, + { + "name": "oracleSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "longSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "amount", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "oraclePrice", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "oracleTime", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "oracleSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "signatureFromStack" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "0", + "OP_GREATERTHAN", + "", + "0", + "OP_GREATERTHAN", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "600", + "OP_LESSTHANOREQUAL", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_CAT", + "", + "OP_SCRIPTNUMTOLE64", + "OP_CAT", + "OP_SHA256", + "", + "", + "", + "OP_CHECKSIGFROMSTACK", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "100000000", + "OP_MUL64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "100", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "OP_MUL64", + "OP_VERIFY", + "100", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,,,,,)>", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "1", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "removeCapital", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "amount", + "type": "int" + }, + { + "name": "oraclePrice", + "type": "int" + }, + { + "name": "oracleTime", + "type": "int" + }, + { + "name": "oracleSig", + "type": "signature" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "claimExit", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "oraclePrice", + "type": "int" + }, + { + "name": "oracleTime", + "type": "int" + }, + { + "name": "oracleSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "oraclePrice", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "oracleTime", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "oracleSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "signatureFromStack" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "0", + "OP_GREATERTHAN", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "600", + "OP_LESSTHANOREQUAL", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_CAT", + "", + "OP_SCRIPTNUMTOLE64", + "OP_CAT", + "OP_SHA256", + "", + "", + "", + "OP_CHECKSIGFROMSTACK", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "100000000", + "OP_MUL64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "0", + "OP_LESSTHANOREQUAL64", + "OP_VERIFY", + "OP_IF", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ELSE", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "OP_IF", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ELSE", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "330", + "OP_GREATERTHAN64", + "OP_VERIFY", + "OP_IF", + "1", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ENDIF", + "OP_ENDIF", + "OP_ENDIF", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "claimExit", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "oraclePrice", + "type": "int" + }, + { + "name": "oracleTime", + "type": "int" + }, + { + "name": "oracleSig", + "type": "signature" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "longExit", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "oraclePrice", + "type": "int" + }, + { + "name": "oracleTime", + "type": "int" + }, + { + "name": "oracleSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "longSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "oraclePrice", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "oracleTime", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "oracleSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "signatureFromStack" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "0", + "OP_GREATERTHAN", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "600", + "OP_LESSTHANOREQUAL", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_CAT", + "", + "OP_SCRIPTNUMTOLE64", + "OP_CAT", + "OP_SHA256", + "", + "", + "", + "OP_CHECKSIGFROMSTACK", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "100000000", + "OP_MUL64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "0", + "OP_LESSTHANOREQUAL64", + "OP_VERIFY", + "OP_IF", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ELSE", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "OP_IF", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ELSE", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "330", + "OP_GREATERTHAN64", + "OP_VERIFY", + "OP_IF", + "1", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ENDIF", + "OP_ENDIF", + "OP_ENDIF", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "longExit", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "oraclePrice", + "type": "int" + }, + { + "name": "oracleTime", + "type": "int" + }, + { + "name": "oracleSig", + "type": "signature" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + } + ], + "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract InventoryHedge(\n pubkey claimPk,\n pubkey longPk,\n pubkey oraclePk,\n bytes32 ticker,\n int targetFiat,\n int totalCollateral,\n int fundingRatePerSec,\n int lastUpdate,\n int collateralRatioPct,\n int exit\n) {\n\n function transfer(signature claimSig, pubkey newClaimPk) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n newClaimPk, longPk, oraclePk, ticker,\n targetFiat, totalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function updateFunding(signature longSig, int newFundingRatePerSec) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(newFundingRatePerSec >= 0, \"negative funding rate disallowed\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n\n if (fundingRatePerSec != 0) {\n require(delta > 0, \"no accrual; wait longer\");\n }\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n newTargetFiat, totalCollateral, newFundingRatePerSec, tx.offchainTime,\n collateralRatioPct, exit\n ),\n \"invalid update output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function addCapital(signature longSig, int amount) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n\n int newTotalCollateral = totalCollateral + amount;\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"collateral not deposited\");\n }\n\n function removeCapital(\n signature longSig,\n int amount,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int currentClaimBase = newTargetFiat * 100000000 / oraclePrice;\n int minCollateral = currentClaimBase * (100 + collateralRatioPct) / 100;\n int newTotalCollateral = totalCollateral - amount;\n require(newTotalCollateral >= minCollateral, \"would breach collateral ratio\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid vault output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"vault underfunded\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n require(tx.outputs[1].value >= amount, \"long leg underpaid\");\n }\n\n function claimExit(\n signature claimSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n\n function longExit(\n signature longSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n}", + "compiler": { + "name": "arkade-compiler", + "version": "0.1.0" + }, + "updatedAt": "2026-06-09T23:56:18.073624559+00:00", + "warnings": [ + "warning[type]: fn transfer: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn updateFunding: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn addCapital: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn removeCapital: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn removeCapital: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn claimExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn claimExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn claimExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn claimExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn longExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn longExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn longExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn longExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[output-invariant]: fn 'transfer' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newClaimPk'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'addCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTotalCollateral'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleMsg'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'currentClaimBase'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTotalCollateral'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'minCollateral'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTotalCollateral'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleMsg'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", + "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleMsg'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", + "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'" + ] +} \ No newline at end of file diff --git a/playground/main.js b/playground/main.js index 2cef903..25d3af7 100644 --- a/playground/main.js +++ b/playground/main.js @@ -29,6 +29,13 @@ const projects = { 'repayment_pool.ark': contracts.repayment_pool, 'bond_mint.ark': contracts.bond_mint, } + }, + hedging: { + name: 'Hedging', + description: 'Perpetual, BTC-settled, fully-collateralized market-maker inventory hedge: a desk converts residual BTC inventory into a delta-flat fiat claim (claim leg) while a treasury holds the BTC long leg and earns funding. Oracle-marked, no liquidation, one instance per BTC/ pair. See docs/hedging/inventory_hedge_tx_flows.md and docs/mm-residual-hedge.md', + files: { + 'inventory_hedge.ark': contracts.inventory_hedge, + } } }; diff --git a/tests/inventory_hedge_test.rs b/tests/inventory_hedge_test.rs new file mode 100644 index 0000000..6c42cdb --- /dev/null +++ b/tests/inventory_hedge_test.rs @@ -0,0 +1,182 @@ +// InventoryHedge: perpetual, BTC-settled, fully-collateralized MM inventory +// hedge (docs/mm-residual-hedge.md). A generalization of stability_vault.ark. +// +// These tests pin the compilation roundtrip (both tapleaf variants per +// function, non-empty witness schemas) and the behavioral invariants: +// oracle-gated settlement, the claimExit clamp branches, and the auto-injected +// on the cooperative path. + +use arkade_compiler::compile; +use arkade_compiler::opcodes::{OP_CHECKSIG, OP_CHECKSIGFROMSTACK, OP_DIV64}; + +const HEDGE_CODE: &str = include_str!("../examples/hedging/inventory_hedge.ark"); + +const FUNCTIONS: &[&str] = &[ + "transfer", + "updateFunding", + "addCapital", + "removeCapital", + "claimExit", + "longExit", +]; + +// Functions that settle against the oracle-attested price. +const ORACLE_FUNCTIONS: &[&str] = &["removeCapital", "claimExit", "longExit"]; + +#[test] +fn test_compiles_with_12_tapleaves() { + let out = compile(HEDGE_CODE).expect("inventory hedge compile"); + assert_eq!(out.name, "InventoryHedge"); + // 6 functions × 2 variants (cooperative + exit). + assert_eq!(out.functions.len(), 12); +} + +#[test] +fn test_both_variants_emit_with_nonempty_witness_schema() { + let out = compile(HEDGE_CODE).unwrap(); + for name in FUNCTIONS { + for server_variant in [true, false] { + let f = out + .functions + .iter() + .find(|f| &f.name == name && f.server_variant == server_variant) + .unwrap_or_else(|| panic!("missing {name} (serverVariant={server_variant})")); + assert!( + !f.asm.is_empty(), + "{name} (serverVariant={server_variant}): empty asm" + ); + assert!( + !f.witness_schema.is_empty(), + "{name} (serverVariant={server_variant}): empty witness schema" + ); + } + } +} + +#[test] +fn test_cooperative_path_injects_server_key() { + // options { server = server } must auto-inject on every + // cooperative (serverVariant=true) leaf; it is never a constructor param. + let out = compile(HEDGE_CODE).unwrap(); + for name in FUNCTIONS { + let f = out + .functions + .iter() + .find(|f| &f.name == name && f.server_variant) + .unwrap(); + assert!( + f.asm.iter().any(|s| s.contains("SERVER_KEY")), + "{name}: cooperative leaf must carry the auto-injected " + ); + } + // And SERVER_KEY must never appear as a constructor input. + assert!( + !out.parameters + .iter() + .any(|p| p.name.to_uppercase().contains("SERVER")), + "server key must not be a constructor parameter" + ); +} + +#[test] +fn test_oracle_paths_verify_price_and_divide() { + // Each settling path reconstructs the oracle message, verifies it via + // checkSigFromStack, and converts fiat->sats with a 64-bit division. + let out = compile(HEDGE_CODE).unwrap(); + for name in ORACLE_FUNCTIONS { + let f = out + .functions + .iter() + .find(|f| &f.name == name && f.server_variant) + .unwrap(); + let asm = f.asm.join(" "); + assert!( + asm.contains(OP_CHECKSIGFROMSTACK), + "{name}: missing oracle sig verify" + ); + assert!( + asm.contains(OP_DIV64), + "{name}: missing OP_DIV64 conversion" + ); + assert!( + f.asm.iter().any(|s| s == OP_CHECKSIG), + "{name}: missing user checksig" + ); + } +} + +#[test] +fn test_update_funding_is_offchain_rate_no_oracle() { + // The imbalance-driven rate is supplied off-chain; on-chain updateFunding is + // just the >=0 guard + accrual. It must NOT call the oracle. + let out = compile(HEDGE_CODE).unwrap(); + let f = out + .functions + .iter() + .find(|f| f.name == "updateFunding" && f.server_variant) + .unwrap(); + let asm = f.asm.join(" "); + assert!( + !asm.contains(OP_CHECKSIGFROMSTACK), + "updateFunding must not call the oracle" + ); + assert!( + f.asm.iter().any(|s| s == OP_CHECKSIG), + "updateFunding must verify the long-leg key" + ); +} + +#[test] +fn test_transfer_is_pure_keyswap() { + let out = compile(HEDGE_CODE).unwrap(); + let f = out + .functions + .iter() + .find(|f| f.name == "transfer" && f.server_variant) + .unwrap(); + let asm = f.asm.join(" "); + assert!( + !asm.contains(OP_CHECKSIGFROMSTACK), + "transfer must not call the oracle" + ); + assert!( + f.asm.iter().any(|s| s == OP_CHECKSIG), + "transfer must verify the claim key" + ); +} + +#[test] +fn test_claim_exit_has_clamp_branches() { + // claimRaw is clamped into [0, totalCollateral]: claimRaw<=0 (all to long), + // claimRaw>=collateral (all to claim), else split. That is three OP_IF + // branches plus the long-leg dust guard. + let out = compile(HEDGE_CODE).unwrap(); + let f = out + .functions + .iter() + .find(|f| f.name == "claimExit" && f.server_variant) + .unwrap(); + let if_count = f.asm.iter().filter(|s| s.as_str() == "OP_IF").count(); + assert!( + if_count >= 3, + "claimExit must encode the clamp branches, found {if_count} OP_IF" + ); +} + +#[test] +fn test_compile_is_deterministic_ignoring_updated_at() { + // updatedAt is a timestamp and must be stripped before any JSON comparison. + let a = compile(HEDGE_CODE).unwrap(); + let b = compile(HEDGE_CODE).unwrap(); + let mut va = serde_json::to_value(&a).unwrap(); + let mut vb = serde_json::to_value(&b).unwrap(); + for v in [&mut va, &mut vb] { + if let Some(obj) = v.as_object_mut() { + obj.remove("updatedAt"); + } + } + assert_eq!( + va, vb, + "compilation must be deterministic once updatedAt is removed" + ); +} From 83aef89f4a3e5819c68337c6dd92c9cd8916b31d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 00:05:18 +0000 Subject: [PATCH 02/10] docs: collapse tx-flow note into mm-residual-hedge.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge docs/hedging/inventory_hedge_tx_flows.md into docs/mm-residual-hedge.md as 'Part II — Transaction flows', cross-referencing the design sections (§2/§4/§6/ §9). Remove the standalone file and point the playground Hedging folder at the single doc. https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- docs/hedging/inventory_hedge_tx_flows.md | 239 ----------------------- docs/mm-residual-hedge.md | 217 ++++++++++++++++++++ playground/main.js | 2 +- 3 files changed, 218 insertions(+), 240 deletions(-) delete mode 100644 docs/hedging/inventory_hedge_tx_flows.md diff --git a/docs/hedging/inventory_hedge_tx_flows.md b/docs/hedging/inventory_hedge_tx_flows.md deleted file mode 100644 index 29c77ca..0000000 --- a/docs/hedging/inventory_hedge_tx_flows.md +++ /dev/null @@ -1,239 +0,0 @@ -# InventoryHedge — Transaction Flows - -Companion to [`docs/mm-residual-hedge.md`](../mm-residual-hedge.md) (design intent) -and the contract [`examples/hedging/inventory_hedge.ark`](../../examples/hedging/inventory_hedge.ark). - -This note walks each spend path as an actual transaction: who signs, what goes -in the witness, and the exact input/output layout the script enforces. It is the -operational view — the "what does the tx look like" that the contract comments -abbreviate. - ---- - -## The vault UTXO - -A single `InventoryHedge` UTXO holds the pooled BTC and commits all state in its -scriptPubKey via Taproot. Two parties share it: - -| Leg | Key | Holds | BTC delta | -|---|---|---|---| -| **claim** | `claimPk` | a fiat claim `targetFiat` | flat in fiat (short BTC) | -| **long** | `longPk` | residual BTC upside, posts the over-collateral | long BTC | - -Committed state (all in the scriptPubKey): - -- **Immutable** across the position's life: `claimPk` (until transferred), - `longPk`, `oraclePk`, `ticker`, `collateralRatioPct`, `exit`. -- **Mutable** — re-committed in the next-state output on every transition: - `targetFiat`, `totalCollateral`, `fundingRatePerSec`, `lastUpdate`. - -Every non-terminal function is a **self-replacement**: it asserts -`tx.outputs[0].scriptPubKey == new InventoryHedge(...)` with the next state and -`tx.outputs[0].value >= `. The vault thus walks forward as a chain -of UTXOs. - -### Two tapleaves per function - -`options { server = server; exit = exit; }` makes the compiler emit **two -variants** of every function: - -- **Cooperative** (`serverVariant=true`): the caller's signature **plus** the - Arkade Operator co-signature (``, auto-injected — never a - constructor param). Fast, off-chain-settled path. -- **Exit** (`serverVariant=false`): the caller's signature plus a CLTV timelock - of `exit` blocks — the unilateral fallback when the operator is unavailable. - -Both enforce identical settlement math. Settlement that price-introspects is only -meaningful on the cooperative leaf; the exit leaf is the liveness backstop. - ---- - -## Timebase and oracle - -- `tx.offchainTime` — TEE-introspector wallclock (unix seconds). Used for funding - accrual and oracle freshness. Not monotonic-guaranteed, so every use of - `elapsed = tx.offchainTime - lastUpdate` (and `oracleAge`) is paired with a - `>= 0` clock-regression guard. -- **Price oracle** (Fuji pattern): off-chain the oracle signs - `msg = sha256(ticker || price || timestamp)` with `price`/`timestamp` as 8-byte - LE. On-chain the contract rebuilds the digest and verifies - `checkSigFromStack(oracleSig, oraclePk, msg)`. Freshness window: 600 seconds. -- **Funding rate** is *not* oracle-attested on-chain: the dynamic, - imbalance-driven value is computed off-chain by the desk's risk engine (design - note §4) and supplied to `updateFunding`; the script only enforces `>= 0` and - the accrual roll-forward. - -Funding accrual, interleaved `/1e6` twice to stay inside int64: - -``` -elapsed = tx.offchainTime - lastUpdate -rateElapsed = fundingRatePerSec * elapsed / 1e6 -delta = targetFiat * rateElapsed / 1e6 -newTargetFiat = targetFiat + delta // = targetFiat × (1 + rate·elapsed/1e12) -``` - ---- - -## 1. `transfer` — reassign the claim leg - -The desk hands its claim to a new key (design note §6 "transfer" resize). Pure -key swap; no oracle, no funding roll. - -``` -Signers : claimSig (+ SERVER_KEY | + CLTV exit) -Witness : claimSig, newClaimPk - -in [0] : InventoryHedge UTXO -out[0] : InventoryHedge( claimPk:=newClaimPk, …all other state unchanged… ) - value >= totalCollateral -``` - -State change: `claimPk → newClaimPk`. Everything else (collateral, funding, -clock) is preserved. - ---- - -## 2. `updateFunding` — roll funding, set the new rate - -The long leg rolls accrued funding into the claim and adopts the next -off-chain-computed rate. - -``` -Signers : longSig (+ SERVER_KEY | + CLTV exit) -Witness : longSig, newFundingRatePerSec - -in [0] : InventoryHedge UTXO -out[0] : InventoryHedge( - targetFiat := newTargetFiat, // old rate accrued in - fundingRatePerSec:= newFundingRatePerSec, // must be >= 0 - lastUpdate := tx.offchainTime, - …collateral, keys, ticker unchanged… ) - value >= totalCollateral -``` - -Guards: `newFundingRatePerSec >= 0` (a negative rate would let the long leg drain -the desk), `newTargetFiat > 0` (claim not wiped), and the **anti-grief** rule — -if the *current* rate is non-zero the accrual must be non-zero, else advancing -`lastUpdate` would silently swallow funding owed to the desk. A zero-accrual roll -is legal only when the current rate is already 0 (resume-from-pause). - ---- - -## 3. `addCapital` — treasury tops up collateral - -More collateral is strictly better for the desk, so no ratio/oracle check. - -``` -Signers : longSig (+ SERVER_KEY | + CLTV exit) -Witness : longSig, amount - -in [0] : InventoryHedge UTXO -in [1+] : long-leg funding input(s) (≥ amount sats, off-script) -out[0] : InventoryHedge( totalCollateral := totalCollateral + amount, …rest unchanged… ) - value >= totalCollateral + amount -``` - -State change: `totalCollateral += amount`. Funding clock untouched. - ---- - -## 4. `removeCapital` — treasury reclaims excess, ratio-guarded - -The long leg withdraws surplus collateral, but the remainder must still cover the -claim at `collateralRatioPct` *at the current mark*. Accrual is computed **only -for the guard** — `targetFiat` and `lastUpdate` are deliberately **not** mutated, -so a withdrawal never truncates funding (only `updateFunding` moves the clock). - -``` -Signers : longSig (+ SERVER_KEY | + CLTV exit) -Witness : longSig, amount, oraclePrice, oracleTime, oracleSig - -in [0] : InventoryHedge UTXO -out[0] : InventoryHedge( totalCollateral := totalCollateral - amount, …rest unchanged… ) - value >= totalCollateral - amount -out[1] : SingleSig(longPk) value >= amount // reclaimed sats -``` - -Guard (oracle-priced): - -``` -accruedFiat = targetFiat + delta -claimSats = accruedFiat * 1e8 / oraclePrice -minCollateral = claimSats * (100 + collateralRatioPct) / 100 -require( totalCollateral - amount >= minCollateral ) // "would breach collateral ratio" -``` - ---- - -## 5. `claimExit` — desk settles to BTC (terminal) - -The desk unwinds the hedge at the oracle mark. BTC-native settlement at the index -price (design note §2: no perp-spot basis). The vault terminates. - -``` -Signers : claimSig (+ SERVER_KEY | + CLTV exit) -Witness : claimSig, oraclePrice, oracleTime, oracleSig - -in [0] : InventoryHedge UTXO -``` - -Payout is the claim clamped into `[0, totalCollateral]`: - -``` -claimRaw = newTargetFiat * 1e8 / oraclePrice -``` - -| Branch | Condition | out[0] | out[1] | -|---|---|---|---| -| claim wiped | `claimRaw <= 0` | `SingleSig(longPk)` ≥ `totalCollateral` | — | -| fully covered | `claimRaw >= totalCollateral` | `SingleSig(claimPk)` ≥ `totalCollateral` | — | -| split | otherwise | `SingleSig(claimPk)` ≥ `claimRaw` | `SingleSig(longPk)` ≥ `totalCollateral − claimRaw` *(only if > 330 sats)* | - -The 330-sat **Taproot dust floor**: the long-leg remainder output is asserted -only when it exceeds 330 sats; below that it routes to fees rather than a dust -output. "Fully collateralized" means this clamp always pays out of the pool — no -liquidation, no margin call on the claim side. - ---- - -## 6. `longExit` — treasury-driven settlement (terminal) - -Identical clamp math; the long leg initiates instead of the desk. Same output -table as §5 with `longSig` in place of `claimSig`. Lets the treasury close a -position the desk has gone quiet on, settling both legs fairly at the mark. - ---- - -## Lifecycle at a glance - -``` - ┌────────────── updateFunding (roll funding, reprice) ───────────────┐ - │ │ - ▼ │ - open ─▶ InventoryHedge UTXO ──▶ addCapital / removeCapital (resize collateral) ─┘ - │ │ - │ └──▶ transfer (reassign claim leg) - │ - ├──▶ claimExit ──▶ BTC to claim (+ remainder to long) [terminal] - └──▶ longExit ──▶ BTC to claim (+ remainder to long) [terminal] -``` - -Off-chain, the desk nets client flow internally and only adjusts the hedge when -aggregate BTC delta breaches a limit (design note §6): `addCapital` / -`removeCapital` / `transfer` reshape the live position instead of unwind-and-reopen. - ---- - -## Playground - -The contract ships in the WASM playground under the **Hedging** project folder -(`playground/main.js`), sourced from `playground/contracts.js` (regenerate with -`./playground/generate_contracts.sh` after editing the `.ark`). Build the WASM -bundle and serve with: - -``` -./playground/build.sh # wasm-pack build + contracts regen -./playground/serve.sh 8080 # static server -``` - -Then pick **Hedging → inventory_hedge.ark** to compile it live in the browser. diff --git a/docs/mm-residual-hedge.md b/docs/mm-residual-hedge.md index f437c6c..6f1f6e3 100644 --- a/docs/mm-residual-hedge.md +++ b/docs/mm-residual-hedge.md @@ -219,3 +219,220 @@ The thinnest first build: a single-provider (treasury) hedge on one `BTC/` pair, perpetual dynamic funding, cooperative-only settlement, with the multi- currency aggregation and additional providers as fast-follows once the counterparty model is validated. + +--- + +# Part II — Transaction flows (`InventoryHedge`) + +The reference implementation of the §10 thinnest build is +[`examples/hedging/inventory_hedge.ark`](../examples/hedging/inventory_hedge.ark). +This part is the operational view: each spend path as an actual transaction — +who signs, what goes in the witness, and the exact input/output layout the +script enforces. + +## The vault UTXO + +A single `InventoryHedge` UTXO holds the pooled BTC and commits all state in its +scriptPubKey via Taproot. Two parties share it: + +| Leg | Key | Holds | BTC delta | +|---|---|---|---| +| **claim** | `claimPk` | a fiat claim `targetFiat` | flat in fiat (short BTC) | +| **long** | `longPk` | residual BTC upside, posts the over-collateral | long BTC | + +Committed state (all in the scriptPubKey): + +- **Immutable** across the position's life: `claimPk` (until transferred), + `longPk`, `oraclePk`, `ticker`, `collateralRatioPct`, `exit`. +- **Mutable** — re-committed in the next-state output on every transition: + `targetFiat`, `totalCollateral`, `fundingRatePerSec`, `lastUpdate`. + +Every non-terminal function is a **self-replacement**: it asserts +`tx.outputs[0].scriptPubKey == new InventoryHedge(...)` with the next state and +`tx.outputs[0].value >= `. The vault thus walks forward as a chain +of UTXOs. + +### Two tapleaves per function + +`options { server = server; exit = exit; }` makes the compiler emit **two +variants** of every function: + +- **Cooperative** (`serverVariant=true`): the caller's signature **plus** the + Arkade Operator co-signature (``, auto-injected — never a + constructor param). Fast, off-chain-settled path. +- **Exit** (`serverVariant=false`): the caller's signature plus a CLTV timelock + of `exit` blocks — the unilateral fallback when the operator is unavailable. + +Both enforce identical settlement math. Settlement that price-introspects is only +meaningful on the cooperative leaf; the exit leaf is the liveness backstop +(this is the §9.4 operator-liveness caveat made concrete). + +## Timebase and oracle + +- `tx.offchainTime` — TEE-introspector wallclock (unix seconds). Used for funding + accrual and oracle freshness. Not monotonic-guaranteed, so every use of + `elapsed = tx.offchainTime - lastUpdate` (and `oracleAge`) is paired with a + `>= 0` clock-regression guard. +- **Price oracle** (Fuji pattern): off-chain the oracle signs + `msg = sha256(ticker || price || timestamp)` with `price`/`timestamp` as 8-byte + LE. On-chain the contract rebuilds the digest and verifies + `checkSigFromStack(oracleSig, oraclePk, msg)`. Freshness window: 600 seconds. +- **Funding rate** is *not* oracle-attested on-chain: the dynamic, + imbalance-driven value of §4 is computed off-chain by the desk's risk engine + and supplied to `updateFunding`; the script only enforces `>= 0` and the + accrual roll-forward. + +Funding accrual, interleaved `/1e6` twice to stay inside int64: + +``` +elapsed = tx.offchainTime - lastUpdate +rateElapsed = fundingRatePerSec * elapsed / 1e6 +delta = targetFiat * rateElapsed / 1e6 +newTargetFiat = targetFiat + delta // = targetFiat × (1 + rate·elapsed/1e12) +``` + +## 1. `transfer` — reassign the claim leg + +The desk hands its claim to a new key (the §6 "transfer" resize). Pure key swap; +no oracle, no funding roll. + +``` +Signers : claimSig (+ SERVER_KEY | + CLTV exit) +Witness : claimSig, newClaimPk + +in [0] : InventoryHedge UTXO +out[0] : InventoryHedge( claimPk:=newClaimPk, …all other state unchanged… ) + value >= totalCollateral +``` + +## 2. `updateFunding` — roll funding, set the new rate + +The long leg rolls accrued funding into the claim and adopts the next +off-chain-computed rate (§4). + +``` +Signers : longSig (+ SERVER_KEY | + CLTV exit) +Witness : longSig, newFundingRatePerSec + +in [0] : InventoryHedge UTXO +out[0] : InventoryHedge( + targetFiat := newTargetFiat, // old rate accrued in + fundingRatePerSec:= newFundingRatePerSec, // must be >= 0 + lastUpdate := tx.offchainTime, + …collateral, keys, ticker unchanged… ) + value >= totalCollateral +``` + +Guards: `newFundingRatePerSec >= 0` (a negative rate would let the long leg drain +the desk), `newTargetFiat > 0` (claim not wiped), and the **anti-grief** rule — +if the *current* rate is non-zero the accrual must be non-zero, else advancing +`lastUpdate` would silently swallow funding owed to the desk. A zero-accrual roll +is legal only when the current rate is already 0 (resume-from-pause). + +## 3. `addCapital` — treasury tops up collateral + +More collateral is strictly better for the desk, so no ratio/oracle check +(the top-up side of the §2 margin cycle). + +``` +Signers : longSig (+ SERVER_KEY | + CLTV exit) +Witness : longSig, amount + +in [0] : InventoryHedge UTXO +in [1+] : long-leg funding input(s) (≥ amount sats, off-script) +out[0] : InventoryHedge( totalCollateral := totalCollateral + amount, …rest unchanged… ) + value >= totalCollateral + amount +``` + +## 4. `removeCapital` — treasury reclaims excess, ratio-guarded + +The long leg withdraws surplus collateral, but the remainder must still cover the +claim at `collateralRatioPct` *at the current mark* (the §2 collateral bound). +Accrual is computed **only for the guard** — `targetFiat` and `lastUpdate` are +deliberately **not** mutated, so a withdrawal never truncates funding (only +`updateFunding` moves the clock). + +``` +Signers : longSig (+ SERVER_KEY | + CLTV exit) +Witness : longSig, amount, oraclePrice, oracleTime, oracleSig + +in [0] : InventoryHedge UTXO +out[0] : InventoryHedge( totalCollateral := totalCollateral - amount, …rest unchanged… ) + value >= totalCollateral - amount +out[1] : SingleSig(longPk) value >= amount // reclaimed sats +``` + +Guard (oracle-priced): + +``` +accruedFiat = targetFiat + delta +claimSats = accruedFiat * 1e8 / oraclePrice +minCollateral = claimSats * (100 + collateralRatioPct) / 100 +require( totalCollateral - amount >= minCollateral ) // "would breach collateral ratio" +``` + +## 5. `claimExit` — desk settles to BTC (terminal) + +The desk unwinds the hedge at the oracle mark. BTC-native settlement at the index +price (§2: no perp-spot basis). The vault terminates. + +``` +Signers : claimSig (+ SERVER_KEY | + CLTV exit) +Witness : claimSig, oraclePrice, oracleTime, oracleSig + +in [0] : InventoryHedge UTXO +``` + +Payout is the claim clamped into `[0, totalCollateral]`: + +``` +claimRaw = newTargetFiat * 1e8 / oraclePrice +``` + +| Branch | Condition | out[0] | out[1] | +|---|---|---|---| +| claim wiped | `claimRaw <= 0` | `SingleSig(longPk)` ≥ `totalCollateral` | — | +| fully covered | `claimRaw >= totalCollateral` | `SingleSig(claimPk)` ≥ `totalCollateral` | — | +| split | otherwise | `SingleSig(claimPk)` ≥ `claimRaw` | `SingleSig(longPk)` ≥ `totalCollateral − claimRaw` *(only if > 330 sats)* | + +The 330-sat **Taproot dust floor**: the long-leg remainder output is asserted +only when it exceeds 330 sats; below that it routes to fees rather than a dust +output. "Fully collateralized" means this clamp always pays out of the pool — no +liquidation, no margin call on the claim side (the §2 / §9.2 tail bound). + +## 6. `longExit` — treasury-driven settlement (terminal) + +Identical clamp math; the long leg initiates instead of the desk. Same output +table as §5 with `longSig` in place of `claimSig`. Lets the treasury close a +position the desk has gone quiet on, settling both legs fairly at the mark. + +## Lifecycle at a glance + +``` + ┌────────────── updateFunding (roll funding, reprice) ───────────────┐ + │ │ + ▼ │ + open ─▶ InventoryHedge UTXO ──▶ addCapital / removeCapital (resize collateral) ─┘ + │ │ + │ └──▶ transfer (reassign claim leg) + │ + ├──▶ claimExit ──▶ BTC to claim (+ remainder to long) [terminal] + └──▶ longExit ──▶ BTC to claim (+ remainder to long) [terminal] +``` + +Off-chain, the desk nets client flow internally and only adjusts the hedge when +aggregate BTC delta breaches a limit (§6): `addCapital` / `removeCapital` / +`transfer` reshape the live position instead of unwind-and-reopen. + +## Playground + +The contract ships in the WASM playground under the **Hedging** project folder +(`playground/main.js`), sourced from `playground/contracts.js` (regenerate with +`./playground/generate_contracts.sh` after editing the `.ark`). Build and serve: + +``` +./playground/build.sh # wasm-pack build + contracts regen +./playground/serve.sh 8080 # static server +``` + +Then pick **Hedging → inventory_hedge.ark** to compile it live in the browser. diff --git a/playground/main.js b/playground/main.js index 25d3af7..9194f81 100644 --- a/playground/main.js +++ b/playground/main.js @@ -32,7 +32,7 @@ const projects = { }, hedging: { name: 'Hedging', - description: 'Perpetual, BTC-settled, fully-collateralized market-maker inventory hedge: a desk converts residual BTC inventory into a delta-flat fiat claim (claim leg) while a treasury holds the BTC long leg and earns funding. Oracle-marked, no liquidation, one instance per BTC/ pair. See docs/hedging/inventory_hedge_tx_flows.md and docs/mm-residual-hedge.md', + description: 'Perpetual, BTC-settled, fully-collateralized market-maker inventory hedge: a desk converts residual BTC inventory into a delta-flat fiat claim (claim leg) while a treasury holds the BTC long leg and earns funding. Oracle-marked, no liquidation, one instance per BTC/ pair. See docs/mm-residual-hedge.md (design note + transaction flows)', files: { 'inventory_hedge.ark': contracts.inventory_hedge, } From dfce6fe5e54bbb07c31b24726e3899c959693175 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 00:07:38 +0000 Subject: [PATCH 03/10] refactor: rename exit functions to redeem/withdraw Drop the Exit suffix: claimExit -> redeem (desk redeems its fiat claim for BTC), longExit -> withdraw (treasury-driven settlement). Update the contract, tests, and the Part II transaction-flow doc. https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- docs/mm-residual-hedge.md | 12 ++-- examples/hedging/inventory_hedge.ark | 10 +-- examples/hedging/inventory_hedge.json | 92 +++++++++++++-------------- tests/inventory_hedge_test.rs | 14 ++-- 4 files changed, 64 insertions(+), 64 deletions(-) diff --git a/docs/mm-residual-hedge.md b/docs/mm-residual-hedge.md index 6f1f6e3..baa437a 100644 --- a/docs/mm-residual-hedge.md +++ b/docs/mm-residual-hedge.md @@ -371,10 +371,10 @@ minCollateral = claimSats * (100 + collateralRatioPct) / 100 require( totalCollateral - amount >= minCollateral ) // "would breach collateral ratio" ``` -## 5. `claimExit` — desk settles to BTC (terminal) +## 5. `redeem` — desk settles to BTC (terminal) -The desk unwinds the hedge at the oracle mark. BTC-native settlement at the index -price (§2: no perp-spot basis). The vault terminates. +The desk redeems its fiat claim for BTC at the oracle mark. BTC-native settlement +at the index price (§2: no perp-spot basis). The vault terminates. ``` Signers : claimSig (+ SERVER_KEY | + CLTV exit) @@ -400,7 +400,7 @@ only when it exceeds 330 sats; below that it routes to fees rather than a dust output. "Fully collateralized" means this clamp always pays out of the pool — no liquidation, no margin call on the claim side (the §2 / §9.2 tail bound). -## 6. `longExit` — treasury-driven settlement (terminal) +## 6. `withdraw` — treasury-driven settlement (terminal) Identical clamp math; the long leg initiates instead of the desk. Same output table as §5 with `longSig` in place of `claimSig`. Lets the treasury close a @@ -416,8 +416,8 @@ position the desk has gone quiet on, settling both legs fairly at the mark. │ │ │ └──▶ transfer (reassign claim leg) │ - ├──▶ claimExit ──▶ BTC to claim (+ remainder to long) [terminal] - └──▶ longExit ──▶ BTC to claim (+ remainder to long) [terminal] + ├──▶ redeem ──▶ BTC to claim (+ remainder to long) [terminal] + └──▶ withdraw ──▶ BTC to claim (+ remainder to long) [terminal] ``` Off-chain, the desk nets client flow internally and only adjusts the hedge when diff --git a/examples/hedging/inventory_hedge.ark b/examples/hedging/inventory_hedge.ark index 03e4e15..0ca97c4 100644 --- a/examples/hedging/inventory_hedge.ark +++ b/examples/hedging/inventory_hedge.ark @@ -28,7 +28,7 @@ // risk engine and supplied to updateFunding; on-chain logic is the >= 0 guard // plus the accrual roll-forward. // -// Settlement math (single shape across claimExit, longExit, removeCapital): +// Settlement math (single shape across redeem, withdraw, removeCapital): // newTargetFiat = targetFiat × (1 + fundingRatePerSec × elapsed / 1e12) // claimRaw = newTargetFiat × 1e8 / oraclePrice // claimPayout = clamp(claimRaw, 0, totalCollateral) @@ -183,12 +183,12 @@ contract InventoryHedge( } // ------------------------------------------------------------------------- - // CLAIM EXIT — desk settles the hedge to BTC at the oracle price. + // REDEEM — desk redeems its fiat claim for BTC at the oracle price. // BTC-native at the index price (doc §2: no perp-spot basis). No exit fee. // claimRaw = newTargetFiat × 1e8 / oraclePrice // claimPayout = clamp(claimRaw, 0, totalCollateral); remainder to long leg. // ------------------------------------------------------------------------- - function claimExit( + function redeem( signature claimSig, int oraclePrice, int oracleTime, @@ -231,9 +231,9 @@ contract InventoryHedge( } // ------------------------------------------------------------------------- - // LONG EXIT — provider-driven settlement; identical clamp math. + // WITHDRAW — treasury-driven settlement; identical clamp math. // ------------------------------------------------------------------------- - function longExit( + function withdraw( signature longSig, int oraclePrice, int oracleTime, diff --git a/examples/hedging/inventory_hedge.json b/examples/hedging/inventory_hedge.json index e4f7e19..bdcba6c 100644 --- a/examples/hedging/inventory_hedge.json +++ b/examples/hedging/inventory_hedge.json @@ -762,7 +762,7 @@ ] }, { - "name": "claimExit", + "name": "redeem", "functionInputs": [ { "name": "claimSig", @@ -999,7 +999,7 @@ ] }, { - "name": "claimExit", + "name": "redeem", "functionInputs": [ { "name": "claimSig", @@ -1062,7 +1062,7 @@ ] }, { - "name": "longExit", + "name": "withdraw", "functionInputs": [ { "name": "longSig", @@ -1299,7 +1299,7 @@ ] }, { - "name": "longExit", + "name": "withdraw", "functionInputs": [ { "name": "longSig", @@ -1362,26 +1362,26 @@ ] } ], - "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract InventoryHedge(\n pubkey claimPk,\n pubkey longPk,\n pubkey oraclePk,\n bytes32 ticker,\n int targetFiat,\n int totalCollateral,\n int fundingRatePerSec,\n int lastUpdate,\n int collateralRatioPct,\n int exit\n) {\n\n function transfer(signature claimSig, pubkey newClaimPk) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n newClaimPk, longPk, oraclePk, ticker,\n targetFiat, totalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function updateFunding(signature longSig, int newFundingRatePerSec) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(newFundingRatePerSec >= 0, \"negative funding rate disallowed\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n\n if (fundingRatePerSec != 0) {\n require(delta > 0, \"no accrual; wait longer\");\n }\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n newTargetFiat, totalCollateral, newFundingRatePerSec, tx.offchainTime,\n collateralRatioPct, exit\n ),\n \"invalid update output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function addCapital(signature longSig, int amount) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n\n int newTotalCollateral = totalCollateral + amount;\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"collateral not deposited\");\n }\n\n function removeCapital(\n signature longSig,\n int amount,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int currentClaimBase = newTargetFiat * 100000000 / oraclePrice;\n int minCollateral = currentClaimBase * (100 + collateralRatioPct) / 100;\n int newTotalCollateral = totalCollateral - amount;\n require(newTotalCollateral >= minCollateral, \"would breach collateral ratio\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid vault output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"vault underfunded\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n require(tx.outputs[1].value >= amount, \"long leg underpaid\");\n }\n\n function claimExit(\n signature claimSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n\n function longExit(\n signature longSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n}", + "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract InventoryHedge(\n pubkey claimPk,\n pubkey longPk,\n pubkey oraclePk,\n bytes32 ticker,\n int targetFiat,\n int totalCollateral,\n int fundingRatePerSec,\n int lastUpdate,\n int collateralRatioPct,\n int exit\n) {\n\n function transfer(signature claimSig, pubkey newClaimPk) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n newClaimPk, longPk, oraclePk, ticker,\n targetFiat, totalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function updateFunding(signature longSig, int newFundingRatePerSec) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(newFundingRatePerSec >= 0, \"negative funding rate disallowed\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n\n if (fundingRatePerSec != 0) {\n require(delta > 0, \"no accrual; wait longer\");\n }\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n newTargetFiat, totalCollateral, newFundingRatePerSec, tx.offchainTime,\n collateralRatioPct, exit\n ),\n \"invalid update output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function addCapital(signature longSig, int amount) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n\n int newTotalCollateral = totalCollateral + amount;\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"collateral not deposited\");\n }\n\n function removeCapital(\n signature longSig,\n int amount,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int currentClaimBase = newTargetFiat * 100000000 / oraclePrice;\n int minCollateral = currentClaimBase * (100 + collateralRatioPct) / 100;\n int newTotalCollateral = totalCollateral - amount;\n require(newTotalCollateral >= minCollateral, \"would breach collateral ratio\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid vault output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"vault underfunded\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n require(tx.outputs[1].value >= amount, \"long leg underpaid\");\n }\n\n function redeem(\n signature claimSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n\n function withdraw(\n signature longSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-06-09T23:56:18.073624559+00:00", + "updatedAt": "2026-06-10T00:07:27.836261397+00:00", "warnings": [ "warning[type]: fn transfer: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn updateFunding: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn addCapital: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn removeCapital: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn removeCapital: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn claimExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn claimExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn claimExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn claimExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn longExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn longExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn longExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", - "warning[type]: fn longExit: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn redeem: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn redeem: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn redeem: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn redeem: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn withdraw: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn withdraw: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn withdraw: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn withdraw: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[output-invariant]: fn 'transfer' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newClaimPk'", "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", @@ -1405,37 +1405,37 @@ "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTotalCollateral'", "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'minCollateral'", "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTotalCollateral'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleMsg'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", - "warning[output-invariant]: fn 'claimExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleMsg'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", - "warning[output-invariant]: fn 'longExit' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'" + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleMsg'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", + "warning[output-invariant]: fn 'redeem' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleMsg'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimRaw'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", + "warning[output-invariant]: fn 'withdraw' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'" ] } \ No newline at end of file diff --git a/tests/inventory_hedge_test.rs b/tests/inventory_hedge_test.rs index 6c42cdb..2909611 100644 --- a/tests/inventory_hedge_test.rs +++ b/tests/inventory_hedge_test.rs @@ -3,7 +3,7 @@ // // These tests pin the compilation roundtrip (both tapleaf variants per // function, non-empty witness schemas) and the behavioral invariants: -// oracle-gated settlement, the claimExit clamp branches, and the auto-injected +// oracle-gated settlement, the redeem clamp branches, and the auto-injected // on the cooperative path. use arkade_compiler::compile; @@ -16,12 +16,12 @@ const FUNCTIONS: &[&str] = &[ "updateFunding", "addCapital", "removeCapital", - "claimExit", - "longExit", + "redeem", + "withdraw", ]; // Functions that settle against the oracle-attested price. -const ORACLE_FUNCTIONS: &[&str] = &["removeCapital", "claimExit", "longExit"]; +const ORACLE_FUNCTIONS: &[&str] = &["removeCapital", "redeem", "withdraw"]; #[test] fn test_compiles_with_12_tapleaves() { @@ -146,7 +146,7 @@ fn test_transfer_is_pure_keyswap() { } #[test] -fn test_claim_exit_has_clamp_branches() { +fn test_redeem_has_clamp_branches() { // claimRaw is clamped into [0, totalCollateral]: claimRaw<=0 (all to long), // claimRaw>=collateral (all to claim), else split. That is three OP_IF // branches plus the long-leg dust guard. @@ -154,12 +154,12 @@ fn test_claim_exit_has_clamp_branches() { let f = out .functions .iter() - .find(|f| f.name == "claimExit" && f.server_variant) + .find(|f| f.name == "redeem" && f.server_variant) .unwrap(); let if_count = f.asm.iter().filter(|s| s.as_str() == "OP_IF").count(); assert!( if_count >= 3, - "claimExit must encode the clamp branches, found {if_count} OP_IF" + "redeem must encode the clamp branches, found {if_count} OP_IF" ); } From 9ed13de3dd17c280fcc6083caeab9231a326f7fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 00:13:15 +0000 Subject: [PATCH 04/10] fix(hedge): guard removeCapital against non-positive claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add require(newTargetFiat > 0) in removeCapital so a claim driven non-positive by a negative *initial* funding rate (not prevented at construction) can't make minCollateral <= 0 and trivially pass the ratio check — which would let the long leg drain the vault. Matches the existing updateFunding guard. Deliberately NOT added to redeem/withdraw: their claimRaw <= 0 clamp branch is the correct terminal settlement for a wiped claim (all collateral to the long leg); a hard require there would abort a legitimate withdraw. Documented inline. Also: clarify fundingRatePerSec doc (runtime >= 0; negative only possible at construction, unsupported), and note split/merge are deferred (design doc Part II). https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- docs/mm-residual-hedge.md | 6 ++++++ examples/hedging/inventory_hedge.ark | 19 ++++++++++++++++++- examples/hedging/inventory_hedge.json | 11 +++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/docs/mm-residual-hedge.md b/docs/mm-residual-hedge.md index baa437a..93b50e0 100644 --- a/docs/mm-residual-hedge.md +++ b/docs/mm-residual-hedge.md @@ -424,6 +424,12 @@ Off-chain, the desk nets client flow internally and only adjusts the hedge when aggregate BTC delta breaches a limit (§6): `addCapital` / `removeCapital` / `transfer` reshape the live position instead of unwind-and-reopen. +> **Deferred (fast-follow).** §6/§7 also list `split` (peel off part of the claim +> when delta shrinks) and `merge` (combine positions when delta grows). They are +> reusable from `stability_vault.ark` but **not** in this thinnest first build +> (§10) — `InventoryHedge` ships `transfer` for reassignment and leaves +> `split`/`merge` to a follow-up once the single-provider model is validated. + ## Playground The contract ships in the WASM playground under the **Hedging** project folder diff --git a/examples/hedging/inventory_hedge.ark b/examples/hedging/inventory_hedge.ark index 0ca97c4..b9e0890 100644 --- a/examples/hedging/inventory_hedge.ark +++ b/examples/hedging/inventory_hedge.ark @@ -57,7 +57,7 @@ contract InventoryHedge( bytes32 ticker, // BTC/ feed identifier (e.g. sha256("BTC/USD")) int targetFiat, // claim leg's fiat value (minor units); mutates on funding int totalCollateral, // sats locked; mutates on add/removeCapital - int fundingRatePerSec, // signed fixed-point at scale 1e12; mutates on update + int fundingRatePerSec, // signed fixed-point at scale 1e12; runtime updates enforce >= 0 (see updateFunding); a negative *initial* rate is not prevented at construction and should not be used for a hedging vault int lastUpdate, // unix seconds; basis for funding accrual int collateralRatioPct,// min collateral ratio for removeCapital (50 = 1.5:1) int exit // exit timelock in blocks @@ -164,6 +164,13 @@ contract InventoryHedge( int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000; int delta = targetFiat * rateElapsedScaled / 1000000; int newTargetFiat = targetFiat + delta; + // Defense-in-depth: a non-positive claim would make minCollateral <= 0 and + // the ratio check trivially pass, letting the long leg drain the vault. The + // runtime invariant is fundingRatePerSec >= 0 (enforced by updateFunding), + // but a negative *initial* rate is not prevented at construction, so guard + // here too — matching updateFunding. A wiped claim must settle via withdraw, + // not be reclaimed-around via removeCapital. + require(newTargetFiat > 0, "claim wiped by funding"); int currentClaimBase = newTargetFiat * 100000000 / oraclePrice; int minCollateral = currentClaimBase * (100 + collateralRatioPct) / 100; int newTotalCollateral = totalCollateral - amount; @@ -211,6 +218,11 @@ contract InventoryHedge( int newTargetFiat = targetFiat + delta; int claimRaw = newTargetFiat * 100000000 / oraclePrice; + // No require(newTargetFiat > 0) here, on purpose: if funding has driven the + // claim non-positive, claimRaw <= 0 and the clamp routes the ENTIRE + // collateral to the long leg — the correct terminal outcome for a wiped + // claim. A hard guard would abort this legitimate settlement, including the + // long leg's own withdraw. The clamp, not a require, is the invariant here. if (claimRaw <= 0) { require(tx.outputs[0].value >= totalCollateral, "long leg underpaid"); require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), "output 0 not long leg"); @@ -256,6 +268,11 @@ contract InventoryHedge( int newTargetFiat = targetFiat + delta; int claimRaw = newTargetFiat * 100000000 / oraclePrice; + // No require(newTargetFiat > 0) here, on purpose: if funding has driven the + // claim non-positive, claimRaw <= 0 and the clamp routes the ENTIRE + // collateral to the long leg — the correct terminal outcome for a wiped + // claim. A hard guard would abort this legitimate settlement, including the + // long leg's own withdraw. The clamp, not a require, is the invariant here. if (claimRaw <= 0) { require(tx.outputs[0].value >= totalCollateral, "long leg underpaid"); require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), "output 0 not long leg"); diff --git a/examples/hedging/inventory_hedge.json b/examples/hedging/inventory_hedge.json index bdcba6c..443322b 100644 --- a/examples/hedging/inventory_hedge.json +++ b/examples/hedging/inventory_hedge.json @@ -572,6 +572,9 @@ { "type": "comparison" }, + { + "type": "comparison" + }, { "type": "serverSignature" } @@ -642,6 +645,9 @@ "OP_ADD64", "OP_VERIFY", "", + "0", + "OP_GREATERTHAN", + "", "OP_SCRIPTNUMTOLE64", "100000000", "OP_MUL64", @@ -1362,12 +1368,12 @@ ] } ], - "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract InventoryHedge(\n pubkey claimPk,\n pubkey longPk,\n pubkey oraclePk,\n bytes32 ticker,\n int targetFiat,\n int totalCollateral,\n int fundingRatePerSec,\n int lastUpdate,\n int collateralRatioPct,\n int exit\n) {\n\n function transfer(signature claimSig, pubkey newClaimPk) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n newClaimPk, longPk, oraclePk, ticker,\n targetFiat, totalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function updateFunding(signature longSig, int newFundingRatePerSec) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(newFundingRatePerSec >= 0, \"negative funding rate disallowed\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n\n if (fundingRatePerSec != 0) {\n require(delta > 0, \"no accrual; wait longer\");\n }\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n newTargetFiat, totalCollateral, newFundingRatePerSec, tx.offchainTime,\n collateralRatioPct, exit\n ),\n \"invalid update output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function addCapital(signature longSig, int amount) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n\n int newTotalCollateral = totalCollateral + amount;\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"collateral not deposited\");\n }\n\n function removeCapital(\n signature longSig,\n int amount,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int currentClaimBase = newTargetFiat * 100000000 / oraclePrice;\n int minCollateral = currentClaimBase * (100 + collateralRatioPct) / 100;\n int newTotalCollateral = totalCollateral - amount;\n require(newTotalCollateral >= minCollateral, \"would breach collateral ratio\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid vault output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"vault underfunded\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n require(tx.outputs[1].value >= amount, \"long leg underpaid\");\n }\n\n function redeem(\n signature claimSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n\n function withdraw(\n signature longSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n}", + "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract InventoryHedge(\n pubkey claimPk,\n pubkey longPk,\n pubkey oraclePk,\n bytes32 ticker,\n int targetFiat,\n int totalCollateral,\n int fundingRatePerSec,\n int lastUpdate,\n int collateralRatioPct,\n int exit\n) {\n\n function transfer(signature claimSig, pubkey newClaimPk) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n newClaimPk, longPk, oraclePk, ticker,\n targetFiat, totalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function updateFunding(signature longSig, int newFundingRatePerSec) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(newFundingRatePerSec >= 0, \"negative funding rate disallowed\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n\n if (fundingRatePerSec != 0) {\n require(delta > 0, \"no accrual; wait longer\");\n }\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n newTargetFiat, totalCollateral, newFundingRatePerSec, tx.offchainTime,\n collateralRatioPct, exit\n ),\n \"invalid update output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function addCapital(signature longSig, int amount) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n\n int newTotalCollateral = totalCollateral + amount;\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"collateral not deposited\");\n }\n\n function removeCapital(\n signature longSig,\n int amount,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n int currentClaimBase = newTargetFiat * 100000000 / oraclePrice;\n int minCollateral = currentClaimBase * (100 + collateralRatioPct) / 100;\n int newTotalCollateral = totalCollateral - amount;\n require(newTotalCollateral >= minCollateral, \"would breach collateral ratio\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid vault output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"vault underfunded\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n require(tx.outputs[1].value >= amount, \"long leg underpaid\");\n }\n\n function redeem(\n signature claimSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n\n function withdraw(\n signature longSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-06-10T00:07:27.836261397+00:00", + "updatedAt": "2026-06-10T00:12:41.550585254+00:00", "warnings": [ "warning[type]: fn transfer: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn updateFunding: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", @@ -1401,6 +1407,7 @@ "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", + "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTargetFiat'", "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'currentClaimBase'", "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTotalCollateral'", "warning[output-invariant]: fn 'removeCapital' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'minCollateral'", From d93a3ef5d7ba298fda8dc52add9e42c15de43939 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 14:31:12 +0000 Subject: [PATCH 05/10] test+docs: harden oracle-path assertions, address review nitpicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tests: assert the oracle message is RECONSTRUCTED on-chain (>=2 OP_CAT + OP_SHA256), matching stability_vault_test, so a compiler regression that drops cat/hash can't slip the replay guard; add addCapital-must-not-call-oracle negative test (code-review finding 5). - tests: reword redeem clamp-branch comment — the dust guard IS the third OP_IF, not a fourth branch (CodeRabbit nitpick). - docs: add 'text' language tags to fenced code blocks (markdown-lint nitpick). https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- docs/mm-residual-hedge.md | 24 ++++++++-------- tests/inventory_hedge_test.rs | 52 +++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 18 deletions(-) diff --git a/docs/mm-residual-hedge.md b/docs/mm-residual-hedge.md index 93b50e0..5b597dc 100644 --- a/docs/mm-residual-hedge.md +++ b/docs/mm-residual-hedge.md @@ -88,7 +88,7 @@ Cross-currency exposure triangulates through BTC (`/` = Funding is **perpetual** (no maturity — dated hedges fragment liquidity across tenors and add rate risk) and **dynamic**, driven by long/short imbalance: -``` +```text fundingRate = clamp( premiumIndex + carryComponent , -cap, +cap ) premiumIndex ∝ open-interest skew between claim and long legs long leg crowded -> funding up -> longs pay the claim side (cools long demand) @@ -129,7 +129,7 @@ desk on a short horizon). Do not open a vault per fill. Hedge only the residual swing: -``` +```text 1. INTERNALIZE Net client buys against sells across the whole book. Skew quotes (inventory-aware reservation price) to attract @@ -284,7 +284,7 @@ meaningful on the cooperative leaf; the exit leaf is the liveness backstop Funding accrual, interleaved `/1e6` twice to stay inside int64: -``` +```text elapsed = tx.offchainTime - lastUpdate rateElapsed = fundingRatePerSec * elapsed / 1e6 delta = targetFiat * rateElapsed / 1e6 @@ -296,7 +296,7 @@ newTargetFiat = targetFiat + delta // = targetFiat × (1 + rate·elapse The desk hands its claim to a new key (the §6 "transfer" resize). Pure key swap; no oracle, no funding roll. -``` +```text Signers : claimSig (+ SERVER_KEY | + CLTV exit) Witness : claimSig, newClaimPk @@ -310,7 +310,7 @@ out[0] : InventoryHedge( claimPk:=newClaimPk, …all other state unchanged… ) The long leg rolls accrued funding into the claim and adopts the next off-chain-computed rate (§4). -``` +```text Signers : longSig (+ SERVER_KEY | + CLTV exit) Witness : longSig, newFundingRatePerSec @@ -334,7 +334,7 @@ is legal only when the current rate is already 0 (resume-from-pause). More collateral is strictly better for the desk, so no ratio/oracle check (the top-up side of the §2 margin cycle). -``` +```text Signers : longSig (+ SERVER_KEY | + CLTV exit) Witness : longSig, amount @@ -352,7 +352,7 @@ Accrual is computed **only for the guard** — `targetFiat` and `lastUpdate` are deliberately **not** mutated, so a withdrawal never truncates funding (only `updateFunding` moves the clock). -``` +```text Signers : longSig (+ SERVER_KEY | + CLTV exit) Witness : longSig, amount, oraclePrice, oracleTime, oracleSig @@ -364,7 +364,7 @@ out[1] : SingleSig(longPk) value >= amount // reclaimed sats Guard (oracle-priced): -``` +```text accruedFiat = targetFiat + delta claimSats = accruedFiat * 1e8 / oraclePrice minCollateral = claimSats * (100 + collateralRatioPct) / 100 @@ -376,7 +376,7 @@ require( totalCollateral - amount >= minCollateral ) // "would breach collater The desk redeems its fiat claim for BTC at the oracle mark. BTC-native settlement at the index price (§2: no perp-spot basis). The vault terminates. -``` +```text Signers : claimSig (+ SERVER_KEY | + CLTV exit) Witness : claimSig, oraclePrice, oracleTime, oracleSig @@ -385,7 +385,7 @@ in [0] : InventoryHedge UTXO Payout is the claim clamped into `[0, totalCollateral]`: -``` +```text claimRaw = newTargetFiat * 1e8 / oraclePrice ``` @@ -408,7 +408,7 @@ position the desk has gone quiet on, settling both legs fairly at the mark. ## Lifecycle at a glance -``` +```text ┌────────────── updateFunding (roll funding, reprice) ───────────────┐ │ │ ▼ │ @@ -436,7 +436,7 @@ The contract ships in the WASM playground under the **Hedging** project folder (`playground/main.js`), sourced from `playground/contracts.js` (regenerate with `./playground/generate_contracts.sh` after editing the `.ark`). Build and serve: -``` +```text ./playground/build.sh # wasm-pack build + contracts regen ./playground/serve.sh 8080 # static server ``` diff --git a/tests/inventory_hedge_test.rs b/tests/inventory_hedge_test.rs index 2909611..7356e8e 100644 --- a/tests/inventory_hedge_test.rs +++ b/tests/inventory_hedge_test.rs @@ -7,7 +7,7 @@ // on the cooperative path. use arkade_compiler::compile; -use arkade_compiler::opcodes::{OP_CHECKSIG, OP_CHECKSIGFROMSTACK, OP_DIV64}; +use arkade_compiler::opcodes::{OP_CAT, OP_CHECKSIG, OP_CHECKSIGFROMSTACK, OP_DIV64, OP_SHA256}; const HEDGE_CODE: &str = include_str!("../examples/hedging/inventory_hedge.ark"); @@ -80,8 +80,12 @@ fn test_cooperative_path_injects_server_key() { #[test] fn test_oracle_paths_verify_price_and_divide() { - // Each settling path reconstructs the oracle message, verifies it via - // checkSigFromStack, and converts fiat->sats with a 64-bit division. + // Each settling path must RECONSTRUCT the oracle message on-chain + // (sha256(ticker || price || time)) and verify the sig against it — not + // accept a caller-supplied digest. Pin the concatenation (>=2 OP_CAT for + // ticker+price+time), the hash (OP_SHA256), the sig check, and the + // fiat->sats division. Mirrors stability_vault_test's oracle assertions so + // a compiler regression that drops cat/hash can't slip the replay guard. let out = compile(HEDGE_CODE).unwrap(); for name in ORACLE_FUNCTIONS { let f = out @@ -90,6 +94,15 @@ fn test_oracle_paths_verify_price_and_divide() { .find(|f| &f.name == name && f.server_variant) .unwrap(); let asm = f.asm.join(" "); + let cat_count = f.asm.iter().filter(|s| s.as_str() == OP_CAT).count(); + assert!( + cat_count >= 2, + "{name}: expected >=2 OP_CAT (ticker+price, +time), found {cat_count}" + ); + assert!( + asm.contains(OP_SHA256), + "{name}: missing OP_SHA256 oracle hash" + ); assert!( asm.contains(OP_CHECKSIGFROMSTACK), "{name}: missing oracle sig verify" @@ -105,6 +118,32 @@ fn test_oracle_paths_verify_price_and_divide() { } } +#[test] +fn test_add_capital_does_no_oracle_call() { + // addCapital is a pure top-up: more collateral is always better for the + // claim leg, so it must NOT depend on an oracle price. Guards against a + // future refactor accidentally adding a price-introspecting check. + let out = compile(HEDGE_CODE).unwrap(); + let f = out + .functions + .iter() + .find(|f| f.name == "addCapital" && f.server_variant) + .unwrap(); + let asm = f.asm.join(" "); + assert!( + !asm.contains(OP_CHECKSIGFROMSTACK), + "addCapital must not call the oracle" + ); + assert!( + !asm.contains(OP_SHA256), + "addCapital must not reconstruct an oracle message" + ); + assert!( + f.asm.iter().any(|s| s == OP_CHECKSIG), + "addCapital must verify the long-leg key" + ); +} + #[test] fn test_update_funding_is_offchain_rate_no_oracle() { // The imbalance-driven rate is supplied off-chain; on-chain updateFunding is @@ -147,9 +186,10 @@ fn test_transfer_is_pure_keyswap() { #[test] fn test_redeem_has_clamp_branches() { - // claimRaw is clamped into [0, totalCollateral]: claimRaw<=0 (all to long), - // claimRaw>=collateral (all to claim), else split. That is three OP_IF - // branches plus the long-leg dust guard. + // claimRaw is clamped into [0, totalCollateral] via exactly three nested + // OP_IF branches: (1) claimRaw<=0 → all to long, (2) claimRaw>=collateral → + // all to claim, (3) else split, where the long-leg dust guard + // (longPayout > 330) is itself the third OP_IF (not a fourth branch). let out = compile(HEDGE_CODE).unwrap(); let f = out .functions From a1aeef06ebcfc0d469d497456bffb4e8565a7e05 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:28:56 +0000 Subject: [PATCH 06/10] docs(hedge): document negative-rate brick and int64 settlement ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per design decision (leave logic as-is, document only): - updateFunding: note the known limitation that a negative *initial* rate bricks repricing (require(delta > 0) can't pass); funds aren't locked since redeem/withdraw still settle; a validating factory would seal it off. - Settlement math: note the fail-closed int64 ceiling (~9.2e10 minor units / ~$922M) on claimRaw, inherited verbatim from stability_vault.ark — overflow aborts via OP_MUL64+OP_VERIFY, never a wrong payout. Split-division deferred (no modulo operator; avoid diverging from the audited reference). https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- examples/hedging/inventory_hedge.ark | 20 ++++++++++++++++++++ examples/hedging/inventory_hedge.json | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/examples/hedging/inventory_hedge.ark b/examples/hedging/inventory_hedge.ark index b9e0890..def3c45 100644 --- a/examples/hedging/inventory_hedge.ark +++ b/examples/hedging/inventory_hedge.ark @@ -36,6 +36,17 @@ // At a 1.5:1 ratio the desk is made whole to a ~60% drawdown (doc §2); beyond // that the buffer is exhausted and the claim leg carries the tail. // +// INT64 CEILING (fail-closed, inherited verbatim from stability_vault.ark): +// `newTargetFiat × 1e8` overflows signed int64 once newTargetFiat exceeds +// ~9.2e10 minor units (≈ $922M at cents). OP_MUL64 is emitted with OP_VERIFY, +// so an overflow ABORTS the settlement script — it never produces a wrong +// payout. The same holds for `currentClaimBase × (100+ratio)` in removeCapital. +// Consequence is a liveness ceiling on position size, not a theft vector; sizing +// well under that bound (and using a sane oracle feed) is an operational +// constraint. A split-division rewrite would raise the ceiling but the language +// has no modulo operator, so it is deferred rather than introducing unproven +// settlement math that diverges from the audited reference. +// // Oracle model (Fuji-style signed feed): // msg = sha256(ticker || price || timestamp), price/timestamp as 8-byte LE. // At settlement the caller supplies (oraclePrice, oracleTime, oracleSig); @@ -100,6 +111,15 @@ contract InventoryHedge( // Anti-grief: if the current rate is non-zero the accrual must be non-zero, // else advancing lastUpdate would silently suppress funding owed the desk. // A no-accrual update is only legitimate when the current rate is already 0. + // + // KNOWN LIMITATION (by design): with a NEGATIVE current rate, delta < 0, so + // `require(delta > 0)` always fails and repricing via updateFunding is + // bricked — the rate can never be moved back to >= 0. This is only reachable + // when a vault is constructed with a negative initial fundingRatePerSec, + // which is unsupported (see the constructor comment). Funds are NOT locked: + // redeem/withdraw still settle (their clamp handles a wiped claim). A + // factory that validates the initial rate would seal this off; out of scope + // for the thinnest first build. if (fundingRatePerSec != 0) { require(delta > 0, "no accrual; wait longer"); } diff --git a/examples/hedging/inventory_hedge.json b/examples/hedging/inventory_hedge.json index 443322b..971b0b7 100644 --- a/examples/hedging/inventory_hedge.json +++ b/examples/hedging/inventory_hedge.json @@ -1373,7 +1373,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-06-10T00:12:41.550585254+00:00", + "updatedAt": "2026-06-10T15:28:35.791886131+00:00", "warnings": [ "warning[type]: fn transfer: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn updateFunding: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", From 00b6aa1550fcd3dcdfb77697f055daa72828eaea Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:34:50 +0000 Subject: [PATCH 07/10] docs(hedge): document adverse-selection / oracle-AMM risk (review feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A passive provider settling at a lagging oracle mark is adversely selected: the settlement initiator holds a free option on the oracle delay, which is why oracle-priced AMMs died on Ethereum (picked off when oracle error > fee, ignored when below). Stability-style lending tolerates the 0.2-0.3% enter/exit premium because settlement is rare; a hedging book may not. - design doc: new §9.b spelling out the problem, why §6 internalization is load-bearing (not an optimization), that the dropped exit fee is only sound desk<->own-treasury, that 'no perp-spot basis' needs qualification, and that the §8 order-book/RFQ layer is closer to required than optional for any public version. - contract header: trust-scope note on fee-free redeem/withdraw — reinstate seekerExitFee for any external counterparty. https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- docs/mm-residual-hedge.md | 44 +++++++++++++++++++++++++++ examples/hedging/inventory_hedge.ark | 10 ++++++ examples/hedging/inventory_hedge.json | 2 +- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/docs/mm-residual-hedge.md b/docs/mm-residual-hedge.md index 5b597dc..0c7b52f 100644 --- a/docs/mm-residual-hedge.md +++ b/docs/mm-residual-hedge.md @@ -206,6 +206,50 @@ becomes a goal; it is out of scope for hedging. for counterparty, oracle, funding-availability, and operator-liveness risk. Delta-flat means price-neutral, not risk-free. +### 9.b Adverse selection: the oracle-AMM problem + +The sharpest objection to this design (credit: internal review): **a passive +provider settling at a lagging oracle mark is structurally adversely selected.** +BTC moves between the oracle's timestamp and the settlement transaction, so +whoever initiates settlement holds a free option on that delay. The provider must +charge for it — an enter/exit fee priced off the maximum accepted oracle delay +plus feed reliability, easily **0.2–0.3%** (StabilityVault's `seekerExitFee` is +exactly this premium). The failure mode is well documented on Ethereum, where +oracle-priced AMMs have effectively died out: when oracle error exceeds the fee, +bots pick the pool off (toxic flow / LVR); when it is below the fee, flow routes +to tighter venues and the pool earns nothing. The surviving venues do price +discovery by competitive quoting, not by oracle — Uniswap v3's concentrated +liquidity is effectively an order book, Hyperliquid is literally one. + +Why lending-style stability tolerates this and a hedging book may not: +**settlement frequency.** A stability seeker enters once, holds, exits once — +the fee is amortized over a long hold. A market-making desk's residual is the +thing that moves constantly; if the hedge re-strikes at anything near quote +frequency, it pays the adverse-selection premium repeatedly *and* re-exposes to +lag-arbitrage on every resize. + +Consequences for this design: + +- **The §6 internalize-first flow is load-bearing, not an optimization.** The + construction is only viable if internalization makes the *net* residual drift + slow enough that vault settlement is rare — i.e. the hedge must behave like the + lending case, not the trading case. This is asserted, not proven; the viability + inequality (net-residual re-hedge frequency × fee vs. CEX-perp all-in cost) is + the first thing to measure before building more. +- **`InventoryHedge` deliberately drops the exit fee** (`redeem`/`withdraw` + settle fee-free). That is defensible only between mutually-trusting books + (desk ↔ own treasury, where lag-arbitrage against yourself is pointless). For + ANY external counterparty the fee must come back — it is the adverse-selection + premium, not a cost to optimize away. +- **§2's "no perp-spot basis" claim needs qualification:** settling at a stale + oracle is not basis-free; the staleness *is* a basis, paid as adverse selection + instead of as a spread. +- **The §8 bootstrapping layer is closer to required than optional** for + anything beyond the private desk↔treasury hedge: a public version of this + instrument needs order-book/RFQ-style price discovery (maker quoting near mid), + with the vault as the settlement container — not an oracled pool quoting to + the open market. + ## 10. Open decisions 1. **Funding floor** — allow negative funding (true two-sided clearing) or floor diff --git a/examples/hedging/inventory_hedge.ark b/examples/hedging/inventory_hedge.ark index def3c45..4df4d10 100644 --- a/examples/hedging/inventory_hedge.ark +++ b/examples/hedging/inventory_hedge.ark @@ -53,6 +53,16 @@ // freshness: tx.offchainTime - oracleTime <= 600 seconds. `tx.offchainTime` // is the TEE-introspector wallclock (unix seconds), distinct from `tx.time` // (Bitcoin nLockTime, block height). +// +// ADVERSE SELECTION / TRUST SCOPE (doc §9.b): unlike StabilityVault, redeem and +// withdraw settle FEE-FREE at the oracle mark. Whoever initiates settlement +// holds a free option on the oracle delay (BTC moves between the signed +// timestamp and the spend), so fee-free oracle settlement is only sound between +// mutually-trusting parties — the intended desk <-> own-treasury deployment, +// where lag-arbitraging yourself is pointless. For ANY external counterparty an +// exit fee (StabilityVault's seekerExitFee, ~0.2-0.3% sized to max oracle delay +// + feed reliability) must be reinstated: it is the adverse-selection premium, +// not an optional cost. import "single_sig.ark"; diff --git a/examples/hedging/inventory_hedge.json b/examples/hedging/inventory_hedge.json index 971b0b7..00aac95 100644 --- a/examples/hedging/inventory_hedge.json +++ b/examples/hedging/inventory_hedge.json @@ -1373,7 +1373,7 @@ "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-06-10T15:28:35.791886131+00:00", + "updatedAt": "2026-06-10T15:34:32.707257588+00:00", "warnings": [ "warning[type]: fn transfer: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn updateFunding: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", From 8f498c1682c740f699ee5baa92d554007f7e603f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 15:39:08 +0000 Subject: [PATCH 08/10] fix(hedge): reject negative collateralRatioPct in removeCapital MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit finding: the constructor cannot validate collateralRatioPct, and a negative ratio (<= -100) makes minCollateral non-positive so the ratio check trivially passes — the long leg could drain a live claim. Add require(collateralRatioPct >= 0), the same in-function defense-in-depth pattern as the existing newTargetFiat guard. StabilityOffer seals this at take() time; with no factory here the in-function guard is the equivalent. https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- examples/hedging/inventory_hedge.ark | 6 ++++++ examples/hedging/inventory_hedge.json | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/examples/hedging/inventory_hedge.ark b/examples/hedging/inventory_hedge.ark index 4df4d10..8039c25 100644 --- a/examples/hedging/inventory_hedge.ark +++ b/examples/hedging/inventory_hedge.ark @@ -181,6 +181,12 @@ contract InventoryHedge( require(checkSig(longSig, longPk), "invalid long-leg sig"); require(amount > 0, "zero amount"); require(oraclePrice > 0, "invalid oracle price"); + // Defense-in-depth (same class as the newTargetFiat guard below): the + // constructor cannot validate collateralRatioPct, and a negative ratio + // (<= -100) makes minCollateral non-positive, letting the long leg drain a + // live claim. StabilityOffer seals this at take() time; with no factory the + // in-function guard is the equivalent. + require(collateralRatioPct >= 0, "invalid collateral ratio"); int oracleAge = tx.offchainTime - oracleTime; require(oracleAge >= 0, "future-dated oracle"); diff --git a/examples/hedging/inventory_hedge.json b/examples/hedging/inventory_hedge.json index 00aac95..22942a3 100644 --- a/examples/hedging/inventory_hedge.json +++ b/examples/hedging/inventory_hedge.json @@ -551,6 +551,9 @@ { "type": "comparison" }, + { + "type": "comparison" + }, { "type": "signatureFromStack" }, @@ -589,6 +592,9 @@ "", "0", "OP_GREATERTHAN", + "", + "OP_GREATERTHANOREQUAL", + "0", "", "", "OP_SCRIPTNUMTOLE64", @@ -1368,12 +1374,12 @@ ] } ], - "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract InventoryHedge(\n pubkey claimPk,\n pubkey longPk,\n pubkey oraclePk,\n bytes32 ticker,\n int targetFiat,\n int totalCollateral,\n int fundingRatePerSec,\n int lastUpdate,\n int collateralRatioPct,\n int exit\n) {\n\n function transfer(signature claimSig, pubkey newClaimPk) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n newClaimPk, longPk, oraclePk, ticker,\n targetFiat, totalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function updateFunding(signature longSig, int newFundingRatePerSec) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(newFundingRatePerSec >= 0, \"negative funding rate disallowed\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n\n if (fundingRatePerSec != 0) {\n require(delta > 0, \"no accrual; wait longer\");\n }\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n newTargetFiat, totalCollateral, newFundingRatePerSec, tx.offchainTime,\n collateralRatioPct, exit\n ),\n \"invalid update output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function addCapital(signature longSig, int amount) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n\n int newTotalCollateral = totalCollateral + amount;\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"collateral not deposited\");\n }\n\n function removeCapital(\n signature longSig,\n int amount,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n int currentClaimBase = newTargetFiat * 100000000 / oraclePrice;\n int minCollateral = currentClaimBase * (100 + collateralRatioPct) / 100;\n int newTotalCollateral = totalCollateral - amount;\n require(newTotalCollateral >= minCollateral, \"would breach collateral ratio\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid vault output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"vault underfunded\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n require(tx.outputs[1].value >= amount, \"long leg underpaid\");\n }\n\n function redeem(\n signature claimSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n\n function withdraw(\n signature longSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n}", + "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract InventoryHedge(\n pubkey claimPk,\n pubkey longPk,\n pubkey oraclePk,\n bytes32 ticker,\n int targetFiat,\n int totalCollateral,\n int fundingRatePerSec,\n int lastUpdate,\n int collateralRatioPct,\n int exit\n) {\n\n function transfer(signature claimSig, pubkey newClaimPk) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n newClaimPk, longPk, oraclePk, ticker,\n targetFiat, totalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function updateFunding(signature longSig, int newFundingRatePerSec) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(newFundingRatePerSec >= 0, \"negative funding rate disallowed\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n\n if (fundingRatePerSec != 0) {\n require(delta > 0, \"no accrual; wait longer\");\n }\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n newTargetFiat, totalCollateral, newFundingRatePerSec, tx.offchainTime,\n collateralRatioPct, exit\n ),\n \"invalid update output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function addCapital(signature longSig, int amount) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n\n int newTotalCollateral = totalCollateral + amount;\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"collateral not deposited\");\n }\n\n function removeCapital(\n signature longSig,\n int amount,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n require(oraclePrice > 0, \"invalid oracle price\");\n require(collateralRatioPct >= 0, \"invalid collateral ratio\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n require(newTargetFiat > 0, \"claim wiped by funding\");\n int currentClaimBase = newTargetFiat * 100000000 / oraclePrice;\n int minCollateral = currentClaimBase * (100 + collateralRatioPct) / 100;\n int newTotalCollateral = totalCollateral - amount;\n require(newTotalCollateral >= minCollateral, \"would breach collateral ratio\");\n\n require(\n tx.outputs[0].scriptPubKey == new InventoryHedge(\n claimPk, longPk, oraclePk, ticker,\n targetFiat, newTotalCollateral, fundingRatePerSec, lastUpdate,\n collateralRatioPct, exit\n ),\n \"invalid vault output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"vault underfunded\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n require(tx.outputs[1].value >= amount, \"long leg underpaid\");\n }\n\n function redeem(\n signature claimSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n\n function withdraw(\n signature longSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = targetFiat * rateElapsedScaled / 1000000;\n int newTargetFiat = targetFiat + delta;\n int claimRaw = newTargetFiat * 100000000 / oraclePrice;\n\n if (claimRaw <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (claimRaw >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= claimRaw, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - claimRaw;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n}", "compiler": { "name": "arkade-compiler", "version": "0.1.0" }, - "updatedAt": "2026-06-10T15:34:32.707257588+00:00", + "updatedAt": "2026-06-10T15:38:51.159632771+00:00", "warnings": [ "warning[type]: fn transfer: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", "warning[type]: fn updateFunding: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", From a0e67455964c8df6b4fe6694bc9c72ee6e534ee4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:54:43 +0000 Subject: [PATCH 09/10] =?UTF-8?q?feat(hedge):=20sketch=20CappedSynth=20?= =?UTF-8?q?=E2=80=94=20margined,=20capped,=20RFQ-settled=20synthetic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design alternative to InventoryHedge explored in discussion: a perpetual BTC-settled *margined* synthetic (bounded CFD) rather than a fully-funded forward. Three deliberate changes over InventoryHedge: 1. PnL struck against an entryPrice with per-leg margin, so the posted pot is margin (< notional) — the capital efficiency a desk wants. Payout clamps into [0, totalCollateral], so each leg's loss is bounded by its own margin and there is no liquidation engine to run (the rung that fits Arkade's settlement-container model). 2. Headline path settle() is COOPERATIVE: both legs co-sign the price, no oracle, hence zero adverse selection (doc 9.b). 3. The oracle path survives only as a fee'd fallback (settleOracleClaim), charging the adverse-selection premium to the initiator. Marked SKETCH: funding resizes notional (inherited) rather than transferring margin; only the claim-initiated fallback is written; no removeMargin, no price collar / cooperative-settle timeout. Compiles; covered by the example- enumerating roundtrip/asm tests. Not wired into a dedicated test or the playground registry yet. https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- examples/hedging/capped_synth.ark | 256 ++++++ examples/hedging/capped_synth.json | 1231 ++++++++++++++++++++++++++++ 2 files changed, 1487 insertions(+) create mode 100644 examples/hedging/capped_synth.ark create mode 100644 examples/hedging/capped_synth.json diff --git a/examples/hedging/capped_synth.ark b/examples/hedging/capped_synth.ark new file mode 100644 index 0000000..29077e5 --- /dev/null +++ b/examples/hedging/capped_synth.ark @@ -0,0 +1,256 @@ +// CappedSynth Contract (SKETCH — design alternative to InventoryHedge) +// +// A perpetual, BTC-settled, *margined* synthetic — a bounded contract-for- +// difference between two parties. It is InventoryHedge with three changes that +// turn a fully-funded forward into a capital-efficient, capped "perp": +// +// 1. PnL is struck against an entryPrice (not an absolute fiat claim), so the +// posted pot can be MARGIN (< notional). That is the capital efficiency a +// market maker actually wants when hedging residual delta. +// 2. The headline path `settle` is COOPERATIVE: both legs co-sign the +// settlement price. No oracle, hence zero adverse selection (doc §9.b) — +// a co-signed price cannot be lag-arbitraged because neither side will +// sign a transaction whose outputs use a price it did not agree to. +// 3. The oracle path survives only as a FALLBACK for an unresponsive +// counterparty, and it CHARGES the adverse-selection premium (exitFeeBps) +// to the initiator instead of waving it away. +// +// Why no liquidation engine (the whole point): +// Every settlement clamps each leg's payout into [0, totalCollateral]. The +// pot bounds the loss by construction — the claim leg can lose at most its own +// margin, the long leg at most its own. There is therefore NOTHING to +// liquidate and no keeper to run. This is the design rung that fits Arkade's +// settlement-container model: on Bitcoin you can have self-custody OR fast +// liquidations, not both, so build the instrument that needs no liquidations. +// The price of that is a payout CAP (the tail beyond the pot is unhedged) — +// acceptable for a delta-flat hedger whose underlying loss is itself bounded. +// +// Margin / PnL model (sats), claim leg = SHORT BTC / long fiat: +// newNotionalFiat = notionalFiat × (1 + fundingRatePerSec × elapsed / 1e12) +// entrySats = newNotionalFiat × 1e8 / entryPrice (value at strike) +// settleSats = newNotionalFiat × 1e8 / settlePrice (value now) +// claimPnL = settleSats − entrySats (short gains as price falls) +// gross = claimMargin + claimPnL +// claimPayout = clamp(gross, 0, totalCollateral) +// longPayout = totalCollateral − claimPayout +// => claim net ∈ [−claimMargin, longMargin]; symmetric, fully bounded. +// +// INT64 CEILING (fail-closed, inherited verbatim from inventory_hedge.ark): +// `newNotionalFiat × 1e8` overflows signed int64 past ~9.2e10 minor units. +// OP_MUL64 is emitted with OP_VERIFY, so an overflow ABORTS settlement rather +// than producing a wrong payout — a liveness ceiling on size, not a theft +// vector. Size positions well under the bound. +// +// Funding: inherited from InventoryHedge — accrues into notionalFiat, guarded +// >= 0 at every update. (A truer perp would transfer margin between legs rather +// than resize notional; deferred — the resize model is the already-audited one.) +// +// Oracle model (Fuji-style): msg = sha256(ticker || price || timestamp), price +// and timestamp as 8-byte LE; freshness tx.offchainTime − oracleTime <= 600s. + +import "single_sig.ark"; + +options { + server = server; + exit = exit; +} + +contract CappedSynth( + pubkey claimPk, // SHORT-BTC / long-fiat leg (the hedging desk) + pubkey longPk, // LONG-BTC leg (treasury / counterparty) + pubkey oraclePk, // price feed key; only its signatures are accepted + bytes32 ticker, // BTC/ feed id (e.g. sha256("BTC/USD")) + int entryPrice, // BTC/fiat strike, RFQ-signed at open; immutable + int notionalFiat, // synthetic size, fiat minor units; mutates on funding + int totalCollateral, // margin pot in sats (claimMargin + longMargin) + int claimMargin, // sats the claim leg posted; long posted the rest + int fundingRatePerSec, // signed fixed-point scale 1e12; updates enforce >= 0 + int lastUpdate, // unix seconds; basis for funding accrual + int exitFeeBps, // adverse-selection premium charged on the oracle fallback + int exit // exit timelock in blocks +) { + + // ------------------------------------------------------------------------- + // TRANSFER — desk reassigns its leg to a new key. Pure key swap, no oracle. + // ------------------------------------------------------------------------- + function transfer(signature claimSig, pubkey newClaimPk) { + require(checkSig(claimSig, claimPk), "invalid claim sig"); + + require( + tx.outputs[0].scriptPubKey == new CappedSynth( + newClaimPk, longPk, oraclePk, ticker, entryPrice, + notionalFiat, totalCollateral, claimMargin, fundingRatePerSec, + lastUpdate, exitFeeBps, exit + ), + "invalid transfer output" + ); + require(tx.outputs[0].value >= totalCollateral, "collateral not preserved"); + } + + // ------------------------------------------------------------------------- + // ADD MARGIN — either leg tops up the pot. More margin is always safe for the + // counterparty (it can only raise the cap), so no oracle / ratio check. The + // depositor also raises its own claimMargin iff it is the claim leg. + // ------------------------------------------------------------------------- + function addMargin(signature longSig, int amount) { + require(checkSig(longSig, longPk), "invalid long-leg sig"); + require(amount > 0, "zero amount"); + + int newTotalCollateral = totalCollateral + amount; + + require( + tx.outputs[0].scriptPubKey == new CappedSynth( + claimPk, longPk, oraclePk, ticker, entryPrice, + notionalFiat, newTotalCollateral, claimMargin, fundingRatePerSec, + lastUpdate, exitFeeBps, exit + ), + "invalid output" + ); + require(tx.outputs[0].value >= newTotalCollateral, "margin not deposited"); + } + + // ------------------------------------------------------------------------- + // UPDATE FUNDING — long leg rolls accrued funding into notionalFiat and sets a + // new rate. Inherited verbatim from InventoryHedge, including the >= 0 guard + // and the negative-initial-rate KNOWN LIMITATION (unsupported by convention; + // a validating factory would seal it — out of scope for the sketch). + // ------------------------------------------------------------------------- + function updateFunding(signature longSig, int newFundingRatePerSec) { + require(checkSig(longSig, longPk), "invalid long-leg sig"); + require(newFundingRatePerSec >= 0, "negative funding rate disallowed"); + + int elapsed = tx.offchainTime - lastUpdate; + require(elapsed >= 0, "clock regression"); + int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000; + int delta = notionalFiat * rateElapsedScaled / 1000000; + int newNotionalFiat = notionalFiat + delta; + require(newNotionalFiat > 0, "notional wiped by funding"); + if (fundingRatePerSec != 0) { + require(delta > 0, "no accrual; wait longer"); + } + + require( + tx.outputs[0].scriptPubKey == new CappedSynth( + claimPk, longPk, oraclePk, ticker, entryPrice, + newNotionalFiat, totalCollateral, claimMargin, newFundingRatePerSec, + tx.offchainTime, exitFeeBps, exit + ), + "invalid update output" + ); + require(tx.outputs[0].value >= totalCollateral, "collateral not preserved"); + } + + // ------------------------------------------------------------------------- + // SETTLE — COOPERATIVE close at a co-signed price. THE primary path. + // Both legs sign the spending tx, whose outputs are derived from settlePrice; + // neither will sign outputs computed from a price it rejects, so settlePrice + // is agreed by construction. No oracle => zero adverse selection, no fee. + // ------------------------------------------------------------------------- + function settle( + signature claimSig, + signature longSig, + int settlePrice + ) { + require(checkSig(claimSig, claimPk), "invalid claim sig"); + require(checkSig(longSig, longPk), "invalid long-leg sig"); + require(settlePrice > 0, "invalid settle price"); + + int elapsed = tx.offchainTime - lastUpdate; + require(elapsed >= 0, "clock regression"); + int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000; + int delta = notionalFiat * rateElapsedScaled / 1000000; + int newNotionalFiat = notionalFiat + delta; + require(newNotionalFiat > 0, "notional wiped by funding"); + + int entrySats = newNotionalFiat * 100000000 / entryPrice; + int settleSats = newNotionalFiat * 100000000 / settlePrice; + int gross = claimMargin + settleSats - entrySats; + + // clamp(gross, 0, totalCollateral) with dust-aware second output. + if (gross <= 0) { + // claim lost >= its margin: long takes the whole pot. + require(tx.outputs[0].value >= totalCollateral, "long leg underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), "output 0 not long leg"); + } else { + if (gross >= totalCollateral) { + // claim gained >= long's margin: claim takes the whole pot. + require(tx.outputs[0].value >= totalCollateral, "claim underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), "output 0 not claim"); + } else { + require(tx.outputs[0].value >= gross, "claim underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), "output 0 not claim"); + int longPayout = totalCollateral - gross; + if (longPayout > 330) { + require(tx.outputs[1].value >= longPayout, "long leg underpaid"); + require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), "output 1 not long leg"); + } + } + } + } + + // ------------------------------------------------------------------------- + // SETTLE (ORACLE FALLBACK) — claim-initiated close when the long leg is + // unresponsive. Same clamp math, but the claim leg pays exitFeeBps of its + // gross payout to the long leg: the adverse-selection premium for holding the + // oracle-delay timing option (doc §9.b). A symmetric long-initiated variant + // mirrors this with the fee flowing the other way (omitted in the sketch). + // A production build would also collar settlePrice and/or gate this leaf + // behind a cooperative-settle timeout in blocks (tx.time) — deferred. + // ------------------------------------------------------------------------- + function settleOracleClaim( + signature claimSig, + int oraclePrice, + int oracleTime, + signature oracleSig + ) { + require(checkSig(claimSig, claimPk), "invalid claim sig"); + require(oraclePrice > 0, "invalid oracle price"); + require(exitFeeBps >= 0, "invalid exit fee"); + + int oracleAge = tx.offchainTime - oracleTime; + require(oracleAge >= 0, "future-dated oracle"); + require(oracleAge <= 600, "stale oracle"); + + let oracleMsg = sha256(ticker + oraclePrice + oracleTime); + require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), "invalid oracle signature"); + + int elapsed = tx.offchainTime - lastUpdate; + require(elapsed >= 0, "clock regression"); + int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000; + int delta = notionalFiat * rateElapsedScaled / 1000000; + int newNotionalFiat = notionalFiat + delta; + require(newNotionalFiat > 0, "notional wiped by funding"); + + int entrySats = newNotionalFiat * 100000000 / entryPrice; + int settleSats = newNotionalFiat * 100000000 / oraclePrice; + int gross = claimMargin + settleSats - entrySats; + + if (gross <= 0) { + // claim wiped: nothing to fee, long takes the whole pot. + require(tx.outputs[0].value >= totalCollateral, "long leg underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), "output 0 not long leg"); + } else { + if (gross >= totalCollateral) { + // claim maxed: skim the fee off the cap, remainder of pot to long. + int feeMax = totalCollateral * exitFeeBps / 10000; + int claimMaxNet = totalCollateral - feeMax; + require(tx.outputs[0].value >= claimMaxNet, "claim underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), "output 0 not claim"); + if (feeMax > 330) { + require(tx.outputs[1].value >= feeMax, "long leg fee underpaid"); + require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), "output 1 not long leg"); + } + } else { + int fee = gross * exitFeeBps / 10000; + int claimNet = gross - fee; + int longPayout = totalCollateral - claimNet; + require(tx.outputs[0].value >= claimNet, "claim underpaid"); + require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), "output 0 not claim"); + if (longPayout > 330) { + require(tx.outputs[1].value >= longPayout, "long leg underpaid"); + require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), "output 1 not long leg"); + } + } + } + } +} diff --git a/examples/hedging/capped_synth.json b/examples/hedging/capped_synth.json new file mode 100644 index 0000000..6e0a2c6 --- /dev/null +++ b/examples/hedging/capped_synth.json @@ -0,0 +1,1231 @@ +{ + "contractName": "CappedSynth", + "constructorInputs": [ + { + "name": "claimPk", + "type": "pubkey" + }, + { + "name": "longPk", + "type": "pubkey" + }, + { + "name": "oraclePk", + "type": "pubkey" + }, + { + "name": "ticker", + "type": "bytes32" + }, + { + "name": "entryPrice", + "type": "int" + }, + { + "name": "notionalFiat", + "type": "int" + }, + { + "name": "totalCollateral", + "type": "int" + }, + { + "name": "claimMargin", + "type": "int" + }, + { + "name": "fundingRatePerSec", + "type": "int" + }, + { + "name": "lastUpdate", + "type": "int" + }, + { + "name": "exitFeeBps", + "type": "int" + }, + { + "name": "exit", + "type": "int" + } + ], + "functions": [ + { + "name": "transfer", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "newClaimPk", + "type": "pubkey" + } + ], + "witnessSchema": [ + { + "name": "claimSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "newClaimPk", + "type": "pubkey", + "encoding": "compressed-33" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,,,,,,,)>", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "transfer", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "newClaimPk", + "type": "pubkey" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + }, + { + "name": "newClaimPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "newClaimPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "3-of-3 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "addMargin", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "amount", + "type": "int" + } + ], + "witnessSchema": [ + { + "name": "longSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "amount", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "0", + "OP_GREATERTHAN", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,,,,,,,)>", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "addMargin", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "amount", + "type": "int" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "updateFunding", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "newFundingRatePerSec", + "type": "int" + } + ], + "witnessSchema": [ + { + "name": "longSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "newFundingRatePerSec", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "", + "0", + "OP_GREATERTHAN", + "", + "OP_SCRIPTNUMTOLE64", + "0", + "OP_EQUAL", + "OP_NOT", + "OP_IF", + "", + "0", + "OP_GREATERTHAN", + "OP_ENDIF", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ",,,,,,,,,,,)>", + "OP_EQUAL", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "updateFunding", + "functionInputs": [ + { + "name": "longSig", + "type": "signature" + }, + { + "name": "newFundingRatePerSec", + "type": "int" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "settle", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "longSig", + "type": "signature" + }, + { + "name": "settlePrice", + "type": "int" + } + ], + "witnessSchema": [ + { + "name": "claimSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "settlePrice", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "", + "OP_CHECKSIG", + "", + "0", + "OP_GREATERTHAN", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "", + "0", + "OP_GREATERTHAN", + "", + "OP_SCRIPTNUMTOLE64", + "100000000", + "OP_MUL64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "100000000", + "OP_MUL64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "0", + "OP_LESSTHANOREQUAL64", + "OP_VERIFY", + "OP_IF", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ELSE", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "OP_IF", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ELSE", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "330", + "OP_GREATERTHAN64", + "OP_VERIFY", + "OP_IF", + "1", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ENDIF", + "OP_ENDIF", + "OP_ENDIF", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "settle", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "longSig", + "type": "signature" + }, + { + "name": "settlePrice", + "type": "int" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + }, + { + "name": "settleOracleClaim", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "oraclePrice", + "type": "int" + }, + { + "name": "oracleTime", + "type": "int" + }, + { + "name": "oracleSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "oraclePrice", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "oracleTime", + "type": "int", + "encoding": "scriptnum" + }, + { + "name": "oracleSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "serverSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": true, + "require": [ + { + "type": "signature" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "signatureFromStack" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "comparison" + }, + { + "type": "serverSignature" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIG", + "", + "0", + "OP_GREATERTHAN", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "600", + "OP_LESSTHANOREQUAL", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_CAT", + "", + "OP_SCRIPTNUMTOLE64", + "OP_CAT", + "OP_SHA256", + "", + "", + "", + "OP_CHECKSIGFROMSTACK", + "", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_GREATERTHANOREQUAL", + "0", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "1000000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "", + "0", + "OP_GREATERTHAN", + "", + "OP_SCRIPTNUMTOLE64", + "100000000", + "OP_MUL64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "100000000", + "OP_MUL64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_ADD64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "0", + "OP_LESSTHANOREQUAL64", + "OP_VERIFY", + "OP_IF", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ELSE", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "OP_IF", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "10000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "", + "OP_SCRIPTNUMTOLE64", + "330", + "OP_GREATERTHAN64", + "OP_VERIFY", + "OP_IF", + "1", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ENDIF", + "OP_ELSE", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_MUL64", + "OP_VERIFY", + "10000", + "OP_DIV64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "", + "OP_SCRIPTNUMTOLE64", + "", + "OP_SCRIPTNUMTOLE64", + "OP_SUB64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "0", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "", + "OP_SCRIPTNUMTOLE64", + "330", + "OP_GREATERTHAN64", + "OP_VERIFY", + "OP_IF", + "1", + "OP_INSPECTOUTPUTVALUE", + "", + "OP_GREATERTHANOREQUAL64", + "OP_VERIFY", + "1", + "OP_INSPECTOUTPUTSCRIPTPUBKEY", + ")>", + "OP_EQUAL", + "OP_ENDIF", + "OP_ENDIF", + "OP_ENDIF", + "", + "", + "OP_CHECKSIG" + ] + }, + { + "name": "settleOracleClaim", + "functionInputs": [ + { + "name": "claimSig", + "type": "signature" + }, + { + "name": "oraclePrice", + "type": "int" + }, + { + "name": "oracleTime", + "type": "int" + }, + { + "name": "oracleSig", + "type": "signature" + }, + { + "name": "claimPkSig", + "type": "signature" + }, + { + "name": "longPkSig", + "type": "signature" + } + ], + "witnessSchema": [ + { + "name": "claimPkSig", + "type": "signature", + "encoding": "schnorr-64" + }, + { + "name": "longPkSig", + "type": "signature", + "encoding": "schnorr-64" + } + ], + "serverVariant": false, + "require": [ + { + "type": "nOfNMultisig", + "message": "2-of-2 signatures required (introspection fallback)" + }, + { + "type": "older", + "message": "Exit timelock of exit blocks" + } + ], + "asm": [ + "", + "", + "OP_CHECKSIGVERIFY", + "", + "", + "OP_CHECKSIG", + "", + "OP_CHECKSEQUENCEVERIFY", + "OP_DROP" + ] + } + ], + "source": "\nimport \"single_sig.ark\";\n\noptions {\n server = server;\n exit = exit;\n}\n\ncontract CappedSynth(\n pubkey claimPk,\n pubkey longPk,\n pubkey oraclePk,\n bytes32 ticker,\n int entryPrice,\n int notionalFiat,\n int totalCollateral,\n int claimMargin,\n int fundingRatePerSec,\n int lastUpdate,\n int exitFeeBps,\n int exit\n) {\n\n function transfer(signature claimSig, pubkey newClaimPk) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n\n require(\n tx.outputs[0].scriptPubKey == new CappedSynth(\n newClaimPk, longPk, oraclePk, ticker, entryPrice,\n notionalFiat, totalCollateral, claimMargin, fundingRatePerSec,\n lastUpdate, exitFeeBps, exit\n ),\n \"invalid transfer output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function addMargin(signature longSig, int amount) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(amount > 0, \"zero amount\");\n\n int newTotalCollateral = totalCollateral + amount;\n\n require(\n tx.outputs[0].scriptPubKey == new CappedSynth(\n claimPk, longPk, oraclePk, ticker, entryPrice,\n notionalFiat, newTotalCollateral, claimMargin, fundingRatePerSec,\n lastUpdate, exitFeeBps, exit\n ),\n \"invalid output\"\n );\n require(tx.outputs[0].value >= newTotalCollateral, \"margin not deposited\");\n }\n\n function updateFunding(signature longSig, int newFundingRatePerSec) {\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(newFundingRatePerSec >= 0, \"negative funding rate disallowed\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = notionalFiat * rateElapsedScaled / 1000000;\n int newNotionalFiat = notionalFiat + delta;\n require(newNotionalFiat > 0, \"notional wiped by funding\");\n if (fundingRatePerSec != 0) {\n require(delta > 0, \"no accrual; wait longer\");\n }\n\n require(\n tx.outputs[0].scriptPubKey == new CappedSynth(\n claimPk, longPk, oraclePk, ticker, entryPrice,\n newNotionalFiat, totalCollateral, claimMargin, newFundingRatePerSec,\n tx.offchainTime, exitFeeBps, exit\n ),\n \"invalid update output\"\n );\n require(tx.outputs[0].value >= totalCollateral, \"collateral not preserved\");\n }\n\n function settle(\n signature claimSig,\n signature longSig,\n int settlePrice\n ) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n require(checkSig(longSig, longPk), \"invalid long-leg sig\");\n require(settlePrice > 0, \"invalid settle price\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = notionalFiat * rateElapsedScaled / 1000000;\n int newNotionalFiat = notionalFiat + delta;\n require(newNotionalFiat > 0, \"notional wiped by funding\");\n\n int entrySats = newNotionalFiat * 100000000 / entryPrice;\n int settleSats = newNotionalFiat * 100000000 / settlePrice;\n int gross = claimMargin + settleSats - entrySats;\n\n if (gross <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (gross >= totalCollateral) {\n require(tx.outputs[0].value >= totalCollateral, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n } else {\n require(tx.outputs[0].value >= gross, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n int longPayout = totalCollateral - gross;\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n\n function settleOracleClaim(\n signature claimSig,\n int oraclePrice,\n int oracleTime,\n signature oracleSig\n ) {\n require(checkSig(claimSig, claimPk), \"invalid claim sig\");\n require(oraclePrice > 0, \"invalid oracle price\");\n require(exitFeeBps >= 0, \"invalid exit fee\");\n\n int oracleAge = tx.offchainTime - oracleTime;\n require(oracleAge >= 0, \"future-dated oracle\");\n require(oracleAge <= 600, \"stale oracle\");\n\n let oracleMsg = sha256(ticker + oraclePrice + oracleTime);\n require(checkSigFromStack(oracleSig, oraclePk, oracleMsg), \"invalid oracle signature\");\n\n int elapsed = tx.offchainTime - lastUpdate;\n require(elapsed >= 0, \"clock regression\");\n int rateElapsedScaled = fundingRatePerSec * elapsed / 1000000;\n int delta = notionalFiat * rateElapsedScaled / 1000000;\n int newNotionalFiat = notionalFiat + delta;\n require(newNotionalFiat > 0, \"notional wiped by funding\");\n\n int entrySats = newNotionalFiat * 100000000 / entryPrice;\n int settleSats = newNotionalFiat * 100000000 / oraclePrice;\n int gross = claimMargin + settleSats - entrySats;\n\n if (gross <= 0) {\n require(tx.outputs[0].value >= totalCollateral, \"long leg underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(longPk), \"output 0 not long leg\");\n } else {\n if (gross >= totalCollateral) {\n int feeMax = totalCollateral * exitFeeBps / 10000;\n int claimMaxNet = totalCollateral - feeMax;\n require(tx.outputs[0].value >= claimMaxNet, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n if (feeMax > 330) {\n require(tx.outputs[1].value >= feeMax, \"long leg fee underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n } else {\n int fee = gross * exitFeeBps / 10000;\n int claimNet = gross - fee;\n int longPayout = totalCollateral - claimNet;\n require(tx.outputs[0].value >= claimNet, \"claim underpaid\");\n require(tx.outputs[0].scriptPubKey == new SingleSig(claimPk), \"output 0 not claim\");\n if (longPayout > 330) {\n require(tx.outputs[1].value >= longPayout, \"long leg underpaid\");\n require(tx.outputs[1].scriptPubKey == new SingleSig(longPk), \"output 1 not long leg\");\n }\n }\n }\n }\n}", + "compiler": { + "name": "arkade-compiler", + "version": "0.1.0" + }, + "updatedAt": "2026-06-10T16:50:14.398368751+00:00", + "warnings": [ + "warning[type]: fn transfer: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn addMargin: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn updateFunding: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn settle: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn settle: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn settle: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn settle: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn settleOracleClaim: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn settleOracleClaim: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn settleOracleClaim: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn settleOracleClaim: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[type]: fn settleOracleClaim: comparison '>=' mixes uint64le ('uint64le') with scriptnum ('int') — implicit conversion applied; use le64ToScriptNum() for explicit control", + "warning[output-invariant]: fn 'transfer' (serverVariant=false): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newClaimPk'", + "warning[output-invariant]: fn 'addMargin' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newTotalCollateral'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newNotionalFiat'", + "warning[output-invariant]: fn 'updateFunding' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newNotionalFiat'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newNotionalFiat'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newNotionalFiat'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'settleSats'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'entrySats'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'gross'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'gross'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'gross'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'gross'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", + "warning[output-invariant]: fn 'settle' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleAge'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'oracleMsg'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'tx.offchainTime '", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'elapsed'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'rateElapsedScaled'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'delta'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newNotionalFiat'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newNotionalFiat'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'newNotionalFiat'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'settleSats'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'entrySats'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'gross'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'gross'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'feeMax'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimMaxNet'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'feeMax'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'feeMax'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'gross'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'gross'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'fee'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimNet'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'claimNet'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'", + "warning[output-invariant]: fn 'settleOracleClaim' (serverVariant=true): placeholder is not in witnessSchema or constructorInputs; this transaction cannot be constructed without a binding for 'longPayout'" + ] +} \ No newline at end of file From a31599de917bf31fc31b121ffc94a55144a9d520 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 17:07:22 +0000 Subject: [PATCH 10/10] docs(hedge): add CappedSynth design doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone doc for the capped-synthetic sketch, cross-linking mm-residual-hedge.md §9.b (adverse selection). Covers: why a margined instrument, the forward/capped/perp design-rung table, the margin/PnL bound that removes the need for a liquidation engine, cooperative settle vs. fee'd oracle fallback, the RFQ price-discovery boundary, the leaf table, and the deliberately-deferred list. https://claude.ai/code/session_01E3fpVJfLdM3ye3ZWy5VxGg --- docs/capped-synth.md | 151 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 docs/capped-synth.md diff --git a/docs/capped-synth.md b/docs/capped-synth.md new file mode 100644 index 0000000..f618a5f --- /dev/null +++ b/docs/capped-synth.md @@ -0,0 +1,151 @@ +# The Capped Synthetic — a margined, RFQ-settled alternative to `InventoryHedge` + +> **Status: design sketch.** Contract: [`examples/hedging/capped_synth.ark`](../examples/hedging/capped_synth.ark). +> This is a *response* to the adverse-selection critique in +> [`mm-residual-hedge.md` §9.b](./mm-residual-hedge.md). Read that first — this +> doc assumes the problem statement, the two-leg vault structure, the Fuji +> oracle pattern, and the int64 settlement ceiling described there. + +## 1. Why another instrument + +`InventoryHedge` is a **fully-funded forward**: the long leg over-collateralizes +a 1:1 fiat claim, and settlement clamps the payout into `[0, totalCollateral]`. +Two things motivate an alternative: + +1. **Capital efficiency.** Locking ≥ 1:1 BTC to hedge residual delta is + expensive for a market maker. Posting *margin* (a fraction of notional) frees + the rest for market-making — the single strongest reason to want a "perp." +2. **Adverse selection (§9.b).** Settling at a lagging oracle hands the + initiator a free option on the oracle delay. A true perp makes this *worse*, + because it moves that lag onto every **liquidation** — the place where a + manipulator's payoff is largest, and the reason oracle-priced perps died on + Ethereum while order-book venues (dYdX, Hyperliquid) survived. + +The capped synthetic takes the capital efficiency of a perp **without** building +the liquidation engine a perp needs — because on Bitcoin/Arkade you can have +self-custody *or* fast liquidations, not both. + +## 2. The design rung + +Think of a spectrum of BTC-settled synthetics for the same delta: + +| Instrument | Collateral | Liquidation engine | Tail beyond pot | Fits Arkade? | +|---|---|---|---|---| +| **Forward** (`InventoryHedge`) | ≥ notional (over-coll.) | none | long leg eats it (capped) | yes, but capital-heavy | +| **Capped synthetic** (`CappedSynth`) | margin < notional | **none** | **unhedged (the cap)** | **yes — this doc** | +| **True perp** | margin < notional | required keeper | margin-called away | poorly — needs fast liquidation | + +The capped synthetic keeps the forward's clamp as its *primary* mechanism rather +than a fallback: **the pot bounds the loss, so there is nothing to liquidate.** +The cost is an explicit payout **cap** — the tail beyond the pot is unhedged. +That is acceptable for a *delta-flat hedger*, whose underlying inventory loss is +itself bounded; it is not a consumer guarantee. + +## 3. Margin / PnL model + +Claim leg = short BTC / long fiat. Both legs post into one pot +`totalCollateral = claimMargin + longMargin`. With a strike `entryPrice` fixed at +open (RFQ-signed — see §5): + +``` +newNotionalFiat = notionalFiat × (1 + fundingRatePerSec × elapsed / 1e12) +entrySats = newNotionalFiat × 1e8 / entryPrice // notional value at strike +settleSats = newNotionalFiat × 1e8 / settlePrice // notional value now +claimPnL = settleSats − entrySats // short gains as price falls +gross = claimMargin + claimPnL +claimPayout = clamp(gross, 0, totalCollateral) +longPayout = totalCollateral − claimPayout +``` + +The clamp gives the bound that makes the whole thing work: + +``` +claim net = claimPayout − claimMargin ∈ [ −claimMargin , +longMargin ] +``` + +Each leg can lose **at most its own margin** and gain at most the other's. +Neither side can ever go underwater, so no margin call and no liquidation are +reachable — by construction, not by monitoring. + +Funding is inherited verbatim from `InventoryHedge`: it accrues into +`notionalFiat`, guarded `>= 0` at every `updateFunding`. (A truer perp transfers +margin between legs each interval rather than resizing notional; deferred, since +the resize roll-forward is the already-audited path.) + +## 4. Settlement: cooperative first, oracle as a fee'd fallback + +This is the §9.b fix in contract form. + +**`settle` (primary, oracle-free).** Both legs co-sign the spending transaction, +whose outputs are derived from `settlePrice`. Neither party will sign a +transaction whose payouts use a price it rejects, so the price is **agreed by +construction** — there is no oracle to lag and therefore **zero adverse +selection**, and no fee. This is the bilateral analogue of an RFQ fill: the +counterparties quote and agree a price, the vault is just the settlement +container. + +**`settleOracleClaim` (fallback, fee'd).** For an unresponsive counterparty, the +claim leg can still force settlement at a fresh oracle mark — but it **pays +`exitFeeBps` of its gross payout to the long leg**. That fee is the +adverse-selection premium named in §9.b (StabilityVault's `seekerExitFee`, +≈ 0.2–0.3 %), charged to the initiator who holds the timing option, not waved +away. The oracle here is a *guardrail*, not the mark. + +> A symmetric long-initiated fallback mirrors this with the fee flowing the other +> way; omitted in the sketch. A production build would additionally **collar** +> `settlePrice` and/or gate the fallback behind a cooperative-settle **timeout** +> in blocks (`tx.time`), so the fee'd oracle path is only reachable after a +> genuine cooperative failure — see §6. + +## 5. Open (RFQ) and the price-discovery boundary + +The contract does **not** discover a price — it settles one. Pricing lives where +adverse selection lives: at open and at cooperative settle, both via signed +counterparty quotes (RFQ), not an oracle. At open, a dealer signs +`sha256(quoteId ‖ entryPrice ‖ fundingRate ‖ size ‖ expiry)`; the opening +transaction verifies it with `checkSigFromStack(quoteSig, dealerPk, …)` and +`tx.offchainTime <= expiry` — the *same opcodes* as the oracle path, but the +signer is the counterparty and the expiry is seconds, not 600. Multiple dealers +streaming quotes is an order book in the bilateral limit (this is how FX dealer +markets actually hedge — nobody hedges EUR/USD against an "oracle"). The opening +factory that mints a `CappedSynth` from such a quote is the natural next artifact +(≈ `stability_offer.ark` with the oracle key swapped for a dealer key). + +## 6. Leaves + +| Leaf | Role | Oracle | Fee | +|---|---|:--:|:--:| +| `transfer` | reassign the desk's leg (key swap) | — | — | +| `addMargin` | top up the pot (only raises the cap → always safe) | — | — | +| `updateFunding` | roll funding into notional, set rate ≥ 0 | — | — | +| **`settle`** | **cooperative close, both legs co-sign the price** | — | — | +| `settleOracleClaim` | fallback when the counterparty is dark | ✅ | `exitFeeBps` | + +As in `InventoryHedge`, the oracle op (`checkSigFromStack`) compiles only into +the **server (cooperative) variant** of the oracle leaf; the unilateral exit +variant cannot price-introspect (`mm-residual-hedge.md` §9.4). The cooperative +`settle` carries no oracle in either variant. + +## 7. Deliberately deferred + +This is a sketch; the following are intentionally out of scope: + +- **Margin-transfer funding** (vs. notional resize) — the truer perp model. +- **Long-initiated oracle fallback** — only the claim side is written. +- **`removeMargin`** — needs an oracle mark-to-market check (like + `InventoryHedge.removeCapital`); `addMargin` needs none because more margin + only raises the cap. +- **Price collar + cooperative-settle timeout** gating the oracle fallback. +- **Opening factory / RFQ offer contract** (§5) and a dedicated test + + playground registration. The contract is currently covered only by the + example-enumerating compilation/ASM suites. +- **int64 ceiling**: identical fail-closed behavior to `InventoryHedge` + (`newNotionalFiat × 1e8` aborts the script on overflow rather than mispaying). + +## 8. The honest one-liner + +On Bitcoin you can have self-custody or fast liquidations, not both — so build +the instrument that needs no liquidations. The capped synthetic is that +instrument: a hedger trades an unhedged tail (the cap) for the removal of the +entire liquidation/keeper surface, and prices it by counterparty quote rather +than by oracle.