Percolator · Bounty4 · Dark Percolator

Dark Percolator

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.

Status: design, devnet-first Nothing deployed or funded Our composition, not a MagicBlock product Calculator values are examples, not live market data
Program
4ToDRr…fwfz
Slab
GSAT5f…92hj
Leverage
20×
Collateral
wSOL
Oracle
Pyth 3-leg
Dark layer
MagicBlock PER · TDX
Part I · The Engine

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.

01 · Pricing

The three-leg oracle

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.

raw  = Leg1 × Leg2 / Leg3  // STOXX50/EUR × EUR/USD ÷ SOL/USD
mark = 10¹² / raw_e6          // invert = 1 in config
raw_e6 (SOL per STOXX50)
—
mark_e6 (inverted)
—
Leg freshness · max 600 s: L1L2L3

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.

02 · Schedules

The clock problem

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.

24-hour feed map (UTC)

Leg 1 STOXX50
Leg 2 EUR/USD
Leg 3 SOL/USD
00:0003:0006:0009:0012:0015:0018:0021:0024:00
Market mode
Fee path
Pyth shard gaps of 27+ minutes have been observed, so Leg 1 can also go stale intraday.

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.

03 · Stale-oracle pricing

The EWMA mark keeps the market alive

decay    = 2^(−dt / halflife)  // halflife = 6,480,000 slots ≈ 30 days
ewma_new = ewma_prev × decay + target × (1 − decay)

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.

0 30 d 60 d 1.0 0.5 0.0
decay
—
ewma_new (e6)
—
fee
—
1 bps base + EWMA move

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.

04 · Defense

The hybrid fee makes manipulation lose money

fee_bps = min( base_bps + |ewma_now − ewma_ref| × 10⁴ / ewma_ref , 10,000 )
base_bps = 1

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.

Fee paid
Max profit from move
Fee charged
—
Max profit from move
—
≤ X bps × notional
Best-case net
—
Fee goes to the insurance fund · attack loses
05 · Memory map

Inside the slab

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%.

Pyth PriceUpdateV2, as the wrapper reads it

OffsetBytesFieldUse
08Anchor discriminator
4132feed_idMust match the leg’s feed ID in config
738price i64Mantissa
894exponent i32price × 10^exp
938publish_time i64Staleness check: now − publish_time > 600
1258posted_slot
06 · Liveness

The keeper crank

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
07 · Config

Bounty4 parameters

ParameterValueMeaning
maintenance_margin_bps5005% maintenance margin, 20× max
initial_margin_bps500No opening buffer (IM = MM)
trade_fee_base_bps10.01% base, plus EWMA move after hours
max_trading_fee_bps10,000100% fee cap
liquidation_fee_bps50.05% per liquidation
liquidation_fee_cap50,000,000,000 lam50 SOL cap (the README labels this “$50k”)
h_min / h_max0 / 6,480,000Profit warm-up from 0 to ~30 days
max_price_move_bps_per_slot49§1.4 solvency envelope
tvl_insurance_cap_mult50Deposits ≤ 50× insurance
max_staleness_secs600Per-leg freshness window
markEwmaHalflifeSlots6,480,000~30-day EWMA half-life
permissionless_resolve_stale_slots6,480,000~30 days before anyone can force-resolve
force_close_delay_slots216,000~24 h grace after resolution
insurance_fund5 SOLSeeded bounty target
08 · Bounty scope

Open findings

Listed in issue.md, ordered by expected exploitability on bounty4. Win condition: engine.insurance_fund.balance decreases via public instructions.

IDSeverityFindingApplies to bounty4
DMediumPartial liquidation can cascade to full close in the same crank after mark-PnL settlement drains capital.Yes
BMediumWarm-up settlement order is deterministic by account index, so early accounts get a better haircut.Yes
NMediumWarm-up slope floor of 1 lets 1-lamport PnL warm in one slot.Marginal; net negative after tx fees
OLowclose_account skips the freshness check.Only zero-position, zero-PnL accounts
PHighTradeCpi accepts an arbitrary mark from a malicious matcher.No matcher here; only TradeNoCpi
QMediumoracle_price_cap_e2bps = 0 lets the index jump instantly.Hyperp mode only
IMediumNo cross-parameter validation on admin config updates.Yes; admin key not burned

Provenance

Program
4ToDRrQW5j3oeQm8uTAwV9Rp6NhYfH5E5hMKcXkqfwfz
BPF sha256
408cbaa53403c54474c9b3e085f27571e7af293cfe12845316683bd6fdc5d7fc
BPF size
536,432 bytes
Engine pin
1dc4466e (percolator)
Prog pin
f626639 (percolator-prog)
SLAB_LEN
1,755,520 bytes (MAX_ACCOUNTS = 4096)
# verify
solana program dump -u m 4ToDRrQW5j3oeQm8uTAwV9Rp6NhYfH5E5hMKcXkqfwfz /tmp/bounty4.so
sha256sum /tmp/bounty4.so
Part II · Design Proposal
09 · Motivation

Dark Percolator

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.

Status: design, devnet-first Nothing deployed or funded Our composition, not a MagicBlock product

What a public slab leaks

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.

10 · Architecture

Two layers, one engine

Solana base layer · public

Market accountvault · insurance · params · agg OI
Trader position PDAscollateral; owned by delegation prog
Pyth PriceUpdateV2 ×3STOXX50/EUR · EUR/USD · SOL/USD
Phoenix perpsresidual hedge venue
ProgramsDelegation DELeGGvXpWV2fqJUhqcF5ZSYMS4JTLjteaAMARRSaeSh
Permission ACLseoPoyC3cBqoUtkbjZ4aDrkurZW86v19pXz2XQnp1

Private ER · TEE (Intel TDX)

Dark position PDAssize · entry · liq price · PnL
Sealed intent bookbatch-crossed at oracle mark
Risk engine + crankfunding · liquidation · EWMA
EphemeralPermission per PDAowner + auditor members
TEE validatorMTEWGuqxUpYZGFJQcp8tLN7x5v9BSeoFHYWQQ3n3xzo
delegate → delegate market · price reads → commit + Magic Action → agg commit → base

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.

11 · The key change

Break the slab apart

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:

Market account (one)

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.

Position PDA (one per trader)

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.

12 · Access control

Who sees what

Choose a viewer. Each column maps to member flags on the EphemeralPermission accounts.

Flags held:

DataPercolator todayDark Percolator ·
13 · Liquidity

Dark pools for Phoenix and Solana perps

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.

Batch cross simulator · SOL-PERP

IntentTraderSideSize (SOL)Filled in pool
Crossed in dark
—
at oracle mark, no book impact
Residual to Phoenix
—
via post-commit Magic Action
Public learns
Nothing yet. Intents are sealed until the batch crosses.

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.

14 · Lifecycle

One trader, one round trip

Program sketch

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<()>;
}
15 · Constraints

What this does not solve

ConstraintReality
Hardware trustPrivacy rests on Intel TDX and on MagicBlock running the validator honestly. It is a vendor trust assumption, not a cryptographic proof.
Hosted and gatedA 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 ingressMagicBlock blocks restricted jurisdictions by IP and screens flows with Range. Dark Percolator is private, not anonymous, and some users will be refused.
Commit costsAn 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 ERThe 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 bugsFindings 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.

16 · Plan

Build plan

D0

Run MagicBlock’s private-counter example end to end on devnet TEE: delegate, permission, auth token, private read, undelegate.

D1

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.

D2

Sealed intents and batch crossing at the composite mark. Publish aggregate OI and insurance on each commit.

D3

Residual hedge to Phoenix devnet through a post-commit Magic Action with an escrow-signer check.

D4

External audit, MagicBlock mainnet access, then a separate go-ahead and funding decision. Independent of Percolator Route A.