03 · Synthetic basket desk Live on Robinhood Chain

One position, one margin,
both sides funded before the fill.

The BasketDesk is a permissionless synthetic desk. Anyone defines a weighted long/short basket over allow-listed Chainlink and Pyth feeds, opens it as a single WETH-margined position at 1–5× notional, and is filled at the next oracle print. The desk reserves the trader's maximum payout at open, so no position is ever undercollateralised and there is no liquidation engine. Counterparty capital comes from the sDESK vault.

  • Contract BasketDesk · sDESK
  • Collateral WETH
  • Oracles 45 Chainlink feeds · 30 Pyth price IDs
  • Admin None · no owner, no pause, no upgrade

3.1 Purpose

A basket trade — long one set of assets against another — normally means several perpetual positions, several funding rates and several liquidation prices. The desk collapses that into one contract call: a basket is a list of up to ten legs with signed weights, and a position is one direction, one notional, one margin and one funding rate against that basket.

Prices come from public oracle prints. Notional and margin are WETH; leg prices are USD. P&L is quanto: the position pays the basket's percentage return multiplied by the WETH notional, with no FX conversion.

The contract has no owner. The feed allow-list is fixed in the constructor, basket definitions are permissionless, and every state transition is callable by its rightful party — the trader, or, for oracle-dependent steps, anyone willing to submit the print and collect a bounty.

3.2 Position model

Baskets

A basket is an ordered array of Leg { bytes32 id; int16 weightBps; uint8 source }. source is 0 for a Chainlink aggregator (the id is the feed address) or 1 for a Pyth price ID. Validation in BasketIndex.validateLegs requires:

  • 1 to 10 legs, sorted by source then id, no zero weights.
  • Absolute weights sum to exactly 10_000 bps. Negative weights are short legs; +5000 NVDA / −5000 AAPL is a valid pair.
  • Every Chainlink feed reports 8 decimals; every feed or price ID is on the desk's allow-list.

The basket id is keccak256(abi.encode(legs)), so the same recipe has one id regardless of who defines it. defineBasket is permissionless and idempotent.

Positions

open(id, direction, notional, margin, capMultipleWad, receiver) takes a direction of +1 or −1 — which flips the whole basket — and three WETH figures:

marginTrader collateral and maximum loss. At least MIN_MARGIN = 0.001 ether.
notionalSize the return is applied to. Bounded by margin ≤ notional ≤ 5 × margin, i.e. 1–5× leverage.
capmargin × capMultiple, with capMultiple ∈ [1×, 3×]. The maximum profit; must not exceed notional.

Basket return is the weighted sum of per-leg percentage moves from the fill prices: Σ weightBps_i / 10_000 × (price_i − base_i) / base_i. Position P&L is direction × notional × return. Equity is margin + pnl − funding, floored at zero and capped at margin + cap.

Trader equity at settlement as a function of basket return Trader equity in WETH Basket return × direction 0 margin margin + cap −margin / notional 0 +cap / notional floor hit · payout 0 cap hit · payout margin + cap Example 3× leverage · cap multiple 2× floor at −33.3 % · cap at +66.7 %
Fig 3.1Equity is linear in basket return between the floor (−margin/notional) and the cap (+cap/notional), before funding and the close fee. Outside those points the position is settleable by anyone at 0 or at margin + cap.

3.3 Pre-funding: why nothing is liquidated

A liquidation engine exists to stop a position's loss from exceeding the collateral behind it. The desk removes the need by bounding both sides at open and holding the bound in WETH before the fill:

  • The trader's maximum loss is the margin, transferred in by open(). Equity is floored at zero; a position can never owe the desk.
  • The desk's maximum loss is the cap. open() adds cap to reserved, and reserved capital cannot be withdrawn by sDESK holders.

Because margin + cap is on the desk's balance sheet from the moment the request is made, the position can always be paid at any price. When the mark crosses a bound, anyone calls settle() and the position closes at that bound.

Maximum trader payout is funded by trader margin plus desk reserve at open Maximum payout to the trader = margin + cap · held in WETH from open() Trader margin marginHeld += margin · plus 0.50 % open fee Desk reserve reserved += cap · cap = margin × capMultiple, 1–3× settle() · floor finalizeClose() settle() · cap Floor · equity ≤ 0 Trader payout: 0 Desk keeps the margin Keeper bounty: 1 % of margin STATUS_SETTLED_FLOOR In range margin + pnl − funding − 0.50 % close fee on notional cap released to free capital STATUS_CLOSED Cap · pnl − funding ≥ cap Trader payout: margin + cap Desk loses the cap, keeps fees No close fee on settle STATUS_SETTLED_CAP
Fig 3.2Both bounds are funded before the fill. The lime segment is trader collateral; the grey segment is desk equity set aside for this position. Every terminal outcome pays from those two amounts and nothing else.

Three desk-level limits, checked only at open, keep any single position and the aggregate reserve small relative to equity: notional at most 10 % of desk equity, cap at most 10 % of desk equity, and total reserved at most 50 % of desk equity.

3.4 Execution: fills at the next oracle print

No transaction in the desk is priced at the print that is already on chain. A request records where the oracles stand, and the fill uses the first print that arrives afterwards. This removes the ability to open or close against a stale mark.

  • Chainlink legs. open() and close() store latestRoundData().roundId for each feed. The fill reads round pinned + 1 and requires its updatedAt ≥ requestedAt; if the feed has rolled to a new phase, round 1 of the current phase is used instead. A round with a non-positive answer or answeredInRound < roundId is rejected.
  • Pyth legs. The fill time is T = max(requestedAt + 5 s, latest Chainlink updatedAt among the legs). The caller supplies signed Pyth updates whose publishTime lies in [T, T + 10 s]; the desk pays the Pyth update fee from msg.value and refunds the excess. Confidence must be at most 2 % of price.
Open and close sequence across trader, desk, oracles and keeper Trader BasketDesk Oracles Keeper · anyone open() margin + 0.50 % open fee in WETH · 1–5× notional PENDING_OPEN pins latest roundId per Chainlink leg Next oracle print Chainlink: next round, updatedAt ≥ request activate(id, updates) Pyth publish ∈ [T, T+10s] earns 10 % of open fee OPEN basePrices = that print openedAt = now close(id) owner only ≥ 1 h after openedAt PENDING_CLOSE pins latest roundId funding keeps accruing Next oracle print same pinned-round rule as at open finalizeClose(id, upd.) Pyth publish ∈ [T, T+10s] earns 10 % of open fee CLOSED equity − 0.50 % close fee cap and margin released Payout in WETH 0 ≤ payout ≤ margin + cap minus close fee
Fig 3.3Open and close are each two transactions: a request that pins the oracle state, and a fill by any keeper once the next print exists. Lime edges move WETH; dashed edges are waits on external prints. The keeper is paid from the open fee.

When the print never comes

  • cancelOpen() — the owner may withdraw a pending open 3 days after the request. Refund is margin − funding accrued; the open fee is not returned.
  • voidOpen() / reopen() — if no valid print lands within the Pyth window, anyone may void a pending open or return a pending close to OPEN once T + 10 s + 24 h has passed.
  • abandon() — if a Chainlink leg has not updated for 30 days (or the feed reverts), anyone may abandon the position for margin − funding. If the basket has a Pyth leg, the owner may abandon 180 days after open regardless.
Position state machine cancelOpen() ≥ 3 d after request (owner) · voidOpen() after T + 10 s + 24 h (anyone) SETTLED_FLOOR (2) payout 0 SETTLED_CAP (3) payout margin + cap settle() · floor or cap hit · anyone open() PENDING_OPEN (5) margin + fee held activate() OPEN (0) basePrices fixed close() ≥ 1 h PENDING_CLOSE (6) awaiting next print finalizeClose() CLOSED (1) payout − close fee reopen() · stranded > 24 h abandon() · Chainlink leg dead ≥ 30 d (anyone) · Pyth leg and ≥ 180 d since open (owner) ABANDONED (4) margin − funding
Fig 3.4Seven statuses. Solid edges are trader or keeper calls; the dashed edge is the stranded-print recovery. Every exit releases the position's margin and cap from marginHeld and reserved.

3.5 Funding and fees

Fees

ChargeRateBasePaidGoes to
Open fee50 bpsnotionalWith margin at open()80 % desk equity · 10 % activation keeper · 10 % close keeper
Close fee50 bpsnotionalDeducted from equity at finalizeClose()Desk equity
Floor bounty100 bpsmarginAt settle() when the floor is hitSettling keeper, from desk
Keeper bounty1 000 bpsopen feeAt activate() and finalizeClose()Filling keeper

If a position ends by settle(), cancelOpen(), voidOpen() or abandon(), the unpaid keeper share of the open fee is released to desk equity rather than paid out. No close fee is charged on a settled position.

Funding

Each position is quoted a funding rate at open() and keeps it for life. The rate is stored as bps/day × 10⁴; in bps per day:

rate = 2 + 10 × utilization + 3 × (capMultiple − 1)

utilization = reserved / deskEquity      (0 … 0.5 at open)
capMultiple = cap / margin               (1 … 3)

A 1× cap on an idle desk pays 2 bps/day; a 3× cap on a desk at the 50 % reserve limit pays 13 bps/day. Funding accrues continuously from requestedAt — including while a request is pending — as ceil(notional × rate × elapsed / 864e10), and is deducted from equity at every valuation, close, settle and refund.

Payer

Funding is always paid by the trader to the desk. There is no long/short funding skew; the desk is the counterparty on every position, and its exposure is priced by utilization and by how much upside the trader has bought.

3.6 Oracle sources

The desk accepts two kinds of feed, allow-listed at deployment: 45 Chainlink aggregators and 30 Pyth price IDs. Four assets (BTC, ETH, LINK, ENA) appear in both lists, giving 71 unique assets. The interface lists 65 of them; six wrapped-BTC and staked-ETH variants remain resolvable for existing baskets but are not offered in the composer.

PropertyChainlink (push)Pyth (pull)
CoverageStocks, ETFs and majors: 45 feeds, 8 decimals30 crypto price IDs
How a print arrivesAggregator posts rounds on chain; the desk reads getRoundData(pinned + 1)Keeper submits a signed update; the desk calls parsePriceFeedUpdates and pays the fee
Fill freshnessupdatedAt ≥ requestedAtpublishTime ∈ [T, T + 10 s], T ≥ requestedAt + 5 s
Mark / settle freshnessAt most 26 h old, positive answer, answeredInRound ≥ roundIdMark: at most 26 h old. Settle: at most 60 s old via getPriceNoOlderThan
Quality gateconf × 100 ≤ price × 2 (confidence ≤ 2 % of price)
Dead-feed exitNo update for 30 days, or the call revertsOwner may abandon 180 days after open

Chainlink prices are scaled from 8 to 18 decimals; Pyth prices are scaled from their exponent to 18 decimals. Every stale, non-positive or malformed answer reverts the call — a position is never marked against a print the contract cannot trust.

Market hours

Stock and ETF feeds follow their underlying markets. A request placed outside trading hours is filled at the first print after the market reopens, and funding accrues in the meantime. The interface shows the age of the last print for every leg and flags anything older than 26 h.

3.7 The sDESK vault

The desk is itself an ERC-20: StockYield Basket Desk, symbol sDESK. Depositing WETH mints shares; shares are the claim on desk equity, which is the WETH balance net of trader margin and unpaid keeper bounties. The desk's own accounting splits its balance as follows.

Desk balance-sheet composition and sDESK deposit and withdrawal pricing WETH.balanceOf(desk) marginHeld trader collateral bounty unpaid reserved Σ cap of live positions · ≤ 50 % equity freeCapital withdrawable · funds new caps deskEquity = balance − marginHeld − bountyHeld = reserved + freeCapital deposit(assets, self) shares = assets × (S + 1e6) ÷ (equity + marginHeld + 1) unlockAt = now + 24 h sDESK · ERC-20 S = totalSupply 1e6 virtual shares no transfer while locked withdraw(shares, to, min) assets = shares × (free + 1) ÷ (S + 1e6) after unlockAt · assets ≤ free What a share is exposed to + open and close fees · funding on every live position · released keeper bounties · margin of positions settled at the floor − cap on positions settled or closed in profit · floor bounties · keeper bounties paid at fill Upside per position is bounded by its margin; downside per position is bounded by its cap (≤ 10 % of equity at open).
Fig 3.5Deposits are priced against equity plus held margin; withdrawals against free capital only. Both round in favour of remaining holders, and reserved capital can never leave the desk while the positions it backs are live.
Depositdeposit(assets, receiver) with receiver == msg.sender. Mints assets × (supply + 1e6) / (deskEquity + marginHeld + 1) shares. Reverts on zero assets or zero shares.
CommitmentEvery deposit sets unlockAt = now + 24 h for the depositor. Until then withdraw() reverts and sDESK cannot be transferred out of that address. A new deposit restarts the clock.
Withdrawwithdraw(shares, receiver, minAssets) pays shares × (freeCapital + 1) / (supply + 1e6). Reverts if the result exceeds free capital or falls below minAssets.
Virtual sharesVIRTUAL_SHARES = 1e6 are added to supply in both formulas so that the first depositor cannot manipulate the share price.
Share priceNot a single number. A depositor is credited at (equity + marginHeld) / (supply + 1e6); a withdrawer is paid at freeCapital / (supply + 1e6). While positions are live the second is lower, and the difference stays in the desk for remaining holders.

sDESK holders are the counterparty to every basket. They earn fees and funding, and they pay the cap on positions that finish in profit. Because MAX_RESERVED_BPS limits total reserve to half of equity at open, at least half of desk equity is always outside any live position's reach.

3.8 Risks

  • Oracle dependence. Every fill, mark and settlement is a Chainlink round or a Pyth update. A wrong print that passes the freshness and confidence gates is a valid print to the contract. A feed that stops updating stalls fills until the dead-feed exit (30 days) or the Pyth exit (180 days).
  • Capped, quanto exposure. A position is not a perpetual. Profit stops at the cap, loss stops at the margin, and P&L is percentage return on a WETH notional rather than a USD-denominated claim. Both ends are settleable by third parties the moment they are crossed.
  • Funding while pending. Funding runs from the request, not the fill. A request placed before a long market closure pays funding for the closure.
  • Depositor risk. sDESK equity falls when traders win. Reserve is capped at 50 % of equity at open, but a run of capped wins on many positions can consume that half. Withdrawals are limited to free capital and gated by the 24-hour lock.
  • Immutability. There is no admin to pause a bad feed, extend a window or refund a mistake. The recovery paths above — cancel, void, reopen, abandon — are the only ones.
  • Collateral is WETH. Positions do not touch Stock Tokens; issuer controls on Stock Tokens do not affect the desk. Positions are exposed to WETH's own value against USD, since notional and margin are denominated in it.

3.9 Key parameters

ParameterValueMeaningSource
OPEN_FEE_BPS50Open fee on notionalBasketDesk.sol:21
CLOSE_FEE_BPS50Close fee on notionalBasketDesk.sol:22
FLOOR_BOUNTY_BPS100Settler's share of margin at floorBasketDesk.sol:23
KEEPER_BOUNTY_BPS1 000Keeper's share of open fee, per fillBasketDesk.sol:24
MIN_HOLD1 hourEarliest close() after openedAtBasketDesk.sol:25
LOCK_PERIOD24 hourssDESK withdrawal and transfer lock per depositBasketDesk.sol:26
MAX_LEVERAGE_WAD5e18notional ≤ 5 × margin; minimum is 1×BasketDesk.sol:34, 373–374
MIN_CAP_MULTIPLE_WAD / MAX1e18 / 3e18cap = margin × 1…3BasketDesk.sol:35–36
MAX_POSITION_NOTIONAL_BPS1 000notional ≤ 10 % of desk equityBasketDesk.sol:37
MAX_POSITION_CAP_BPS1 000cap ≤ 10 % of desk equityBasketDesk.sol:38
MAX_RESERVED_BPS5 000total reserved ≤ 50 % of desk equityBasketDesk.sol:39
MIN_MARGIN0.001 etherSmallest marginBasketDesk.sol:40
CANCEL_AFTER3 daysOwner may cancel a pending openBasketDesk.sol:41
VIRTUAL_SHARES1e6Share-price floor against first-depositor gamesBasketDesk.sol:42
Funding base / util / cap terms2 / 10 / 3 bps·day20_000 + 100_000·util + 30_000·(capMult − 1) in bps × 10⁴BasketDesk.sol:334–341
MAX_FEED_AGE26 hoursOldest print accepted for mark and Chainlink settleBasketIndex.sol:18
PYTH_DELAY / PYTH_WINDOW5 s / 10 sFill time offset and accepted publish windowBasketIndex.sol:19–20
PYTH_SETTLE_MAX_AGE60 sPyth freshness for settle()BasketIndex.sol:21
STRANDED_GRACE24 hoursWait before voidOpen() / reopen()BasketIndex.sol:22
PYTH_STRANDED180 daysOwner abandon on a Pyth basketBasketIndex.sol:23
DEAD_FEED_AGE30 daysChainlink feed considered deadBasketIndex.sol:24
Pyth confidence≤ 2 %conf × 100 ≤ price × 2BasketIndex.sol:125
Legs per basket1–10|weights| sum to 10 000 bpsBasketIndex.sol:53, 94
Allow-listed feeds45 + 30Chainlink aggregators + Pyth IDs, fixed at deployDeployBasketDesk.s.sol:17, 66