How a 1.75 MB slab prices the STOXX 50 in SOL around the clock, why its after-hours fee makes mark manipulation a losing trade, and how the same engine could run inside a MagicBlock Private Ephemeral Rollup so positions, intents and liquidation levels stop being public.
Percolator is an on-chain perpetuals DEX built around a single slab account. Config, risk engine and up to 4,096 trader records sit in one 1,755,520-byte layout, so every trade, liquidation and funding accrual is a mutation of that one account.
Bounty4 (bounty_stoxx50_sol_20x_hybrid) is a 20× perp on the STOXX 50 priced in SOL. Its insurance fund was seeded with 5 SOL, and the bounty is won by making that balance go down through public instructions.
Solana runs 24/7; Euronext does not. Pricing STOXX 50 in SOL takes three Pyth PriceUpdateV2 feeds with different schedules, combined in e6 integer math, then inverted.
Unit check: Leg1 × Leg2 ÷ Leg3 is SOL per STOXX 50 share (about 38.29 SOL at the defaults). The inverted mark is therefore STOXX 50 shares per SOL (about 0.0261), not “SOL per STOXX50” as the README’s comment reads.
Leg 1 stops publishing when Euronext closes at 17:30 Paris time. In November that is 16:30 UTC; in summer it is 15:30 UTC. Once Leg 1 is older than 600 s the wrapper switches to HYBRID_AFTER_HOURS: EWMA mark and a dynamic fee.
Schematic: Leg 1 window shown as the Euronext cash session, 08:00–16:30 UTC in winter (09:00–17:30 Paris), one hour earlier in UTC during CEST. Legs 2 and 3 publish around the clock on weekdays.
With a 30-day half-life, an overnight gap of roughly 54,000 slots (6 hours at 400 ms) moves the EWMA by well under one percent of the gap to its target. The mark stays stable after hours and re-anchors once the live composite returns.
Defaults reproduce the README’s after-hours trade: dt 12,000 slots, ewma 38,420,000 → ≈38,417,500, a 0.65 bps move, so the fee is ≈1.65 bps.
Anyone who drags the after-hours mark by X bps can earn at most X bps of notional from it, and pays 1 + X bps to do so. The best case is a loss of the base fee.
Everything lives at fixed BPF offsets. The header and config are tiny next to the risk engine, which is almost all per-account records. Select a region.
Widths not to scale. Real share: header 0.008%, config 0.03%, engine 99.96%.
| Offset | Bytes | Field | Use |
|---|---|---|---|
| 0 | 8 | Anchor discriminator | |
| 41 | 32 | feed_id | Must match the leg’s feed ID in config |
| 73 | 8 | price i64 | Mantissa |
| 89 | 4 | exponent i32 | price × 10^exp |
| 93 | 8 | publish_time i64 | Staleness check: now − publish_time > 600 |
| 125 | 8 | posted_slot |
KeeperCrank is permissionless. Without it funding does not accrue and underwater accounts stay open. Step through one call.
# accounts: caller(signer) · slab(w) · clock · leg1 · leg2 · leg3
percolator-cli keeper-crank \ --slab GSAT5fTCUgB9sMMTBsVzhvALbkSv6p9CifWmShHf92hj \ --oracle C2Cf16vF6LX8GrWJwfZga5z5tjVsax5VWnL2T7Q8CF91
| Parameter | Value | Meaning |
|---|---|---|
| maintenance_margin_bps | 500 | 5% maintenance margin, 20× max |
| initial_margin_bps | 500 | No opening buffer (IM = MM) |
| trade_fee_base_bps | 1 | 0.01% base, plus EWMA move after hours |
| max_trading_fee_bps | 10,000 | 100% fee cap |
| liquidation_fee_bps | 5 | 0.05% per liquidation |
| liquidation_fee_cap | 50,000,000,000 lam | 50 SOL cap (the README labels this “$50k”) |
| h_min / h_max | 0 / 6,480,000 | Profit warm-up from 0 to ~30 days |
| max_price_move_bps_per_slot | 49 | §1.4 solvency envelope |
| tvl_insurance_cap_mult | 50 | Deposits ≤ 50× insurance |
| max_staleness_secs | 600 | Per-leg freshness window |
| markEwmaHalflifeSlots | 6,480,000 | ~30-day EWMA half-life |
| permissionless_resolve_stale_slots | 6,480,000 | ~30 days before anyone can force-resolve |
| force_close_delay_slots | 216,000 | ~24 h grace after resolution |
| insurance_fund | 5 SOL | Seeded bounty target |
Listed in issue.md, ordered by expected exploitability on bounty4. Win condition: engine.insurance_fund.balance decreases via public instructions.
| ID | Severity | Finding | Applies to bounty4 |
|---|---|---|---|
| D | Medium | Partial liquidation can cascade to full close in the same crank after mark-PnL settlement drains capital. | Yes |
| B | Medium | Warm-up settlement order is deterministic by account index, so early accounts get a better haircut. | Yes |
| N | Medium | Warm-up slope floor of 1 lets 1-lamport PnL warm in one slot. | Marginal; net negative after tx fees |
| O | Low | close_account skips the freshness check. | Only zero-position, zero-PnL accounts |
| P | High | TradeCpi accepts an arbitrary mark from a malicious matcher. | No matcher here; only TradeNoCpi |
| Q | Medium | oracle_price_cap_e2bps = 0 lets the index jump instantly. | Hyperp mode only |
| I | Medium | No cross-parameter validation on admin config updates. | Yes; admin key not burned |
# verify
solana program dump -u m 4ToDRrQW5j3oeQm8uTAwV9Rp6NhYfH5E5hMKcXkqfwfz /tmp/bounty4.so
sha256sum /tmp/bounty4.so
The same risk engine, executed inside a MagicBlock Private Ephemeral Rollup on Intel TDX. Positions, intents and liquidation prices are readable only by the accounts the program lists. Collateral, insurance and aggregate open interest still settle on Solana where anyone can audit them.
Every Percolator account record is world-readable. Anyone can decode the slab, sort accounts by leverage exactly as the crank does, and compute each trader’s liquidation price. That invites stop-hunting near thin after-hours books, copy-trading of size, and front-running of intent.
A Private Ephemeral Rollup changes who can read state, not what the program does. Delegated accounts execute in a TEE validator at ER speed; the permission program decides which wallets see logs, balances and messages; and state commits back to Solana on our schedule.
Price reads into the ER are an open question: delegated programs read base-layer accounts the validator clones, and MagicBlock also runs its own pricing oracle. Freshness rules must be re-validated for whichever path we use.
Permissions in a PER are per account. If we delegate the 1.75 MB slab as-is, its member list is all-or-nothing: every listed wallet sees every trader. So Dark Percolator splits state:
Vault, insurance fund, params, EWMA, aggregate long and short OI, funding index. Delegated with an empty member list while trading runs, and its aggregates are published on every commit so insurance solvency stays auditable.
Seeds ["pos", market, owner]. Holds size, entry, margin and PnL. Its EphemeralPermission lists the owner with TX_LOGS | TX_BALANCES | TX_MESSAGE plus an optional auditor key. Rent for the permission is pre-funded at open.
The crank no longer sorts accounts off-chain by public leverage. It runs inside the TEE over the PDAs the market tracks, so nobody outside can build the liquidation queue.
Choose a viewer. Each column maps to member flags on the EphemeralPermission accounts.
Flags held:
| Data | Percolator today | Dark Percolator · |
|---|
Traders submit sealed intents into the PER. Each batch crosses buys against sells at the oracle mark, pro rata on the heavier side. Only the net residual leaves the rollup, as a hedge order on Phoenix fired by a Magic Action after the commit lands. Matched flow never touches a public book.
| Intent | Trader | Side | Size (SOL) | Filled in pool |
|---|
Magic Action handlers are ordinary base-layer instructions that anyone can call directly. The hedge handler must verify the injected escrow signer before it routes size to Phoenix, or anyone could trigger hedges.
Shape only, against ephemeral-rollups-sdk v0.14+. Not compiled or audited.
#[ephemeral]
#[program]
pub mod dark_percolator {
/// base: create position PDA, take collateral, pre-fund permission rent
pub fn open_position(ctx: Context<Open>, collateral: u64) -> Result<()>;
/// base: delegate PDA to the TEE validator
pub fn delegate_position(ctx: Context<DelegatePos>) -> Result<()>;
/// ER: PDA-signed CreateEphemeralPermissionCpi, then make private
pub fn seal(ctx: Context<PermCtx>, auditor: Option<Pubkey>) -> Result<()> {
let mut members = vec![Member {
flags: TX_LOGS_FLAG | TX_BALANCES_FLAG | TX_MESSAGE_FLAG,
pubkey: ctx.accounts.position.owner,
}];
if let Some(a) = auditor {
members.push(Member { flags: TX_BALANCES_FLAG, pubkey: a });
}
UpdateEphemeralPermissionCpi { /* … */
args: EphemeralMembersArgs { is_private: true, members },
}.invoke_signed(&[&pos_seeds])?;
Ok(())
}
/// ER: sealed intent, readable only by owner + auditor
pub fn submit_intent(ctx: Context<Intent>, side: Side, size: u64, limit_e6: u64) -> Result<()>;
/// ER: cross the batch at the composite/EWMA mark, pro rata
pub fn cross_batch(ctx: Context<Cross>) -> Result<()>;
/// ER: funding, liquidation, EWMA — same engine as Percolator
pub fn crank(ctx: Context<Crank>) -> Result<()>;
/// ER: commit aggregates, then hedge residual on Phoenix
pub fn commit_and_hedge(ctx: Context<CommitHedge>) -> Result<()> {
MagicIntentBundleBuilder::new(payer, magic_context, magic_program)
.commit(&[ctx.accounts.market.to_account_info()])
.add_post_commit_actions([hedge_residual_action])
.build_and_invoke()?;
Ok(())
}
/// base: called by the post-commit action; MUST check the escrow signer
pub fn hedge_residual(ctx: Context<Hedge>) -> Result<()>;
}
| Constraint | Reality |
|---|---|
| Hardware trust | Privacy rests on Intel TDX and on MagicBlock running the validator honestly. It is a vendor trust assumption, not a cryptographic proof. |
| Hosted and gated | A PER cannot be self-hosted, and mainnet ER endpoints are team-gated. Devnet devnet-tee.magicblock.app is the only open path today. |
| Compliance at ingress | MagicBlock blocks restricted jurisdictions by IP and screens flows with Range. Dark Percolator is private, not anonymous, and some users will be refused. |
| Commit costs | An account without a fee payer stops after 10 commits; with one, live commit fees start at commit 26. Commit cadence sets how fresh public aggregates are. |
| Oracle inside the ER | The three-leg composite and its 600 s rule must hold on whatever price path the TEE reads, or the hybrid fee loses its anchor. |
| Same engine bugs | Findings D, B, N and I move with the engine. Hiding state makes them harder to observe, not fixed. |
Perps at 20× are high risk, dark or not. Nothing here is a recommendation to trade.
Run MagicBlock’s private-counter example end to end on devnet TEE: delegate, permission, auth token, private read, undelegate.
Split the slab: market account plus per-trader position PDAs on devnet with test collateral. Port the engine unchanged and diff outputs against public Percolator.
Sealed intents and batch crossing at the composite mark. Publish aggregate OI and insurance on each commit.
Residual hedge to Phoenix devnet through a post-commit Magic Action with an escrow-signer check.
External audit, MagicBlock mainnet access, then a separate go-ahead and funding decision. Independent of Percolator Route A.