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.
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_000bps. Negative weights are short legs;+5000 NVDA / −5000 AAPLis 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:
| margin | Trader collateral and maximum loss. At least MIN_MARGIN = 0.001 ether. |
|---|---|
| notional | Size the return is applied to. Bounded by margin ≤ notional ≤ 5 × margin, i.e. 1–5× leverage. |
| cap | margin × 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.
−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()addscaptoreserved, 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.
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()andclose()storelatestRoundData().roundIdfor each feed. The fill reads roundpinned + 1and requires itsupdatedAt ≥ 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 oransweredInRound < roundIdis rejected. - Pyth legs. The fill time is
T = max(requestedAt + 5 s, latest Chainlink updatedAt among the legs). The caller supplies signed Pyth updates whosepublishTimelies in[T, T + 10 s]; the desk pays the Pyth update fee frommsg.valueand refunds the excess. Confidence must be at most 2 % of price.
When the print never comes
cancelOpen()— the owner may withdraw a pending open 3 days after the request. Refund ismargin − 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 toOPENonceT + 10 s + 24 hhas passed.abandon()— if a Chainlink leg has not updated for 30 days (or the feed reverts), anyone may abandon the position formargin − funding. If the basket has a Pyth leg, the owner may abandon 180 days after open regardless.
marginHeld and reserved.3.5 Funding and fees
Fees
| Charge | Rate | Base | Paid | Goes to |
|---|---|---|---|---|
| Open fee | 50 bps | notional | With margin at open() | 80 % desk equity · 10 % activation keeper · 10 % close keeper |
| Close fee | 50 bps | notional | Deducted from equity at finalizeClose() | Desk equity |
| Floor bounty | 100 bps | margin | At settle() when the floor is hit | Settling keeper, from desk |
| Keeper bounty | 1 000 bps | open fee | At 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.
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.
| Property | Chainlink (push) | Pyth (pull) |
|---|---|---|
| Coverage | Stocks, ETFs and majors: 45 feeds, 8 decimals | 30 crypto price IDs |
| How a print arrives | Aggregator posts rounds on chain; the desk reads getRoundData(pinned + 1) | Keeper submits a signed update; the desk calls parsePriceFeedUpdates and pays the fee |
| Fill freshness | updatedAt ≥ requestedAt | publishTime ∈ [T, T + 10 s], T ≥ requestedAt + 5 s |
| Mark / settle freshness | At most 26 h old, positive answer, answeredInRound ≥ roundId | Mark: at most 26 h old. Settle: at most 60 s old via getPriceNoOlderThan |
| Quality gate | — | conf × 100 ≤ price × 2 (confidence ≤ 2 % of price) |
| Dead-feed exit | No update for 30 days, or the call reverts | Owner 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.
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.
| Deposit | deposit(assets, receiver) with receiver == msg.sender. Mints assets × (supply + 1e6) / (deskEquity + marginHeld + 1) shares. Reverts on zero assets or zero shares. |
|---|---|
| Commitment | Every 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. |
| Withdraw | withdraw(shares, receiver, minAssets) pays shares × (freeCapital + 1) / (supply + 1e6). Reverts if the result exceeds free capital or falls below minAssets. |
| Virtual shares | VIRTUAL_SHARES = 1e6 are added to supply in both formulas so that the first depositor cannot manipulate the share price. |
| Share price | Not 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
| Parameter | Value | Meaning | Source |
|---|---|---|---|
OPEN_FEE_BPS | 50 | Open fee on notional | BasketDesk.sol:21 |
CLOSE_FEE_BPS | 50 | Close fee on notional | BasketDesk.sol:22 |
FLOOR_BOUNTY_BPS | 100 | Settler's share of margin at floor | BasketDesk.sol:23 |
KEEPER_BOUNTY_BPS | 1 000 | Keeper's share of open fee, per fill | BasketDesk.sol:24 |
MIN_HOLD | 1 hour | Earliest close() after openedAt | BasketDesk.sol:25 |
LOCK_PERIOD | 24 hours | sDESK withdrawal and transfer lock per deposit | BasketDesk.sol:26 |
MAX_LEVERAGE_WAD | 5e18 | notional ≤ 5 × margin; minimum is 1× | BasketDesk.sol:34, 373–374 |
MIN_CAP_MULTIPLE_WAD / MAX | 1e18 / 3e18 | cap = margin × 1…3 | BasketDesk.sol:35–36 |
MAX_POSITION_NOTIONAL_BPS | 1 000 | notional ≤ 10 % of desk equity | BasketDesk.sol:37 |
MAX_POSITION_CAP_BPS | 1 000 | cap ≤ 10 % of desk equity | BasketDesk.sol:38 |
MAX_RESERVED_BPS | 5 000 | total reserved ≤ 50 % of desk equity | BasketDesk.sol:39 |
MIN_MARGIN | 0.001 ether | Smallest margin | BasketDesk.sol:40 |
CANCEL_AFTER | 3 days | Owner may cancel a pending open | BasketDesk.sol:41 |
VIRTUAL_SHARES | 1e6 | Share-price floor against first-depositor games | BasketDesk.sol:42 |
| Funding base / util / cap terms | 2 / 10 / 3 bps·day | 20_000 + 100_000·util + 30_000·(capMult − 1) in bps × 10⁴ | BasketDesk.sol:334–341 |
MAX_FEED_AGE | 26 hours | Oldest print accepted for mark and Chainlink settle | BasketIndex.sol:18 |
PYTH_DELAY / PYTH_WINDOW | 5 s / 10 s | Fill time offset and accepted publish window | BasketIndex.sol:19–20 |
PYTH_SETTLE_MAX_AGE | 60 s | Pyth freshness for settle() | BasketIndex.sol:21 |
STRANDED_GRACE | 24 hours | Wait before voidOpen() / reopen() | BasketIndex.sol:22 |
PYTH_STRANDED | 180 days | Owner abandon on a Pyth basket | BasketIndex.sol:23 |
DEAD_FEED_AGE | 30 days | Chainlink feed considered dead | BasketIndex.sol:24 |
| Pyth confidence | ≤ 2 % | conf × 100 ≤ price × 2 | BasketIndex.sol:125 |
| Legs per basket | 1–10 | |weights| sum to 10 000 bps | BasketIndex.sol:53, 94 |
| Allow-listed feeds | 45 + 30 | Chainlink aggregators + Pyth IDs, fixed at deploy | DeployBasketDesk.s.sol:17, 66 |