peard
Built and deployed

Path A: peard_perps perps

A synthetic perpetual on something nobody can hold, where funding manufactures the counterparty.


peard_perps is the venue's own perpetuals engine: a virtual AMM where funding manufactures the counterparty, so a market opens with no liquidity provider, no counterparty waiting, and nothing to custody.

That is what makes it the right instrument for the 67 index-grade registry entries with no tokenised version to hold. It is also, structurally, a perp venue for anything the registry can price, whether or not a spot market exists anywhere.

ProgramAZdLGAaYiie1mwuhHUfHM9zA9CC3F3BP5tJoUiAE3umZ
Live on devnet48 markets, all migrated to the backing layout on 2026-08-25
Fundingfired on 2026-08-25; 32 of the 48 carry a non-zero cumulative_funding
Pairables routing here67 of 111, on 2026-08-25
Instructions17 in target/idl/peard_perps.json
Market PDA["market", pairable], so a market can never be re-created

The problem funding solves

A perpetual has no expiry, so nothing forces its price back to the thing it tracks. Worse, on an underlying nobody can arbitrage, there is no natural counterparty at all. If everybody wants to be long pears, who is short?

Nobody is, and that is fine, because funding manufactures the counterparty. When the curve trades above the index, longs pay shorts. That payment is what makes being short attractive at exactly the moment nobody wants to be, and it is what drags the curve back toward the index without anyone having to deliver a pear.

This is not novel; it is how every perpetual futures market works. What is unusual here is the underlying. Normally a perp tracks something with a deep spot market, and arbitrage does most of the work while funding trims the rest. Here funding does all of it, because there is no spot market to arbitrage against. That puts far more weight on the index than a typical perp does, and most of the caution in this program is downstream of that fact.

The curve

Each market holds a constant-product curve with virtual reserves.

pub base_reserve: u128,   // 9dp
pub quote_reserve: u128,  // 6dp
pub k: u128,              // base_reserve * quote_reserve

Nothing is in them. They are not a vault and no one can withdraw from them. Their only job is to price size, so that a large order costs more than a small one and the mark moves when people trade. state.rs says so directly: "Reserves exist only to price size against."

pub fn mark_price(&self) -> u64 {
    ((self.quote_reserve * BASE_ONE) / self.base_reserve) as u64
}

At launch the reserves are sized so the mark starts exactly at the index:

base_reserve  = depth_usd_e6 * 1e9 / price_twap
quote_reserve = depth_usd_e6

Collateral is real, and it is separate. It lives in a token vault, it is counted in collateral_total, and it is denominated in the cluster's dollar mint. The curve is imaginary; the money is not.

Depth is the free parameter, and also the refusal

depth_usd decides how much a trade moves the price. The router derives it as max(50_000, 100 * price), with a ceiling of $10,000,000.

That formula is also where the router refuses. A pairable whose one unit is worth $419,200, like HOME-US-MED, would need $41.9m of depth to be tradeable at sane impact, so the router says so and skips it rather than launching something unusable. The reasoning is stated as a rule: a curve holding fewer than a hundred units cannot price a small trade, and a unit you cannot hold a hundred of is not a unit anyone can trade.

Funding, and where it nearly went wrong

Every funding_interval_secs, anyone may call settle_funding.

let mut premium_bps = ((mark - index) * BPS) / index;
premium_bps = premium_bps.clamp(-cap, cap);
let step = (index * premium_bps * FUNDING_ONE) / (BPS * USD_ONE as i128);
m.cumulative_funding += step;

The clamp. max_funding_bps bounds one step, at 100bps by default. One print far from the mark must not be able to empty a position in a single crank. An unclamped rate is the difference between a manipulated index being a bad quote and being a theft.

The cumulative index. Nothing iterates positions. Funding is a single number on the market, and a position pays the difference between that number now and what it was when the position last touched it:

pub fn funding_owed(&self, cumulative: i128) -> i64 {
    let delta = cumulative - self.funding_snapshot;
    ((self.base_size * delta) / (FUNDING_ONE * BASE_ONE as i128 / USD_ONE as i128)) as i64
}

O(1) per position and O(1) per crank, which is what makes a market with many positions affordable. It is the same lazy-index trick as peard's roll and floorlaunch's debt.

The divisor in that expression is the whole of a bug that shipped and was caught numerically rather than by a test. It was originally missing the BASE_ONE / USD_ONE term, so funding came out a million times too small: on a notional of $8,500, a 1% funding charge produced $0.000085 instead of $85. Every test passed, because the test asserted cumulativeFunding > 0, and a sign check passes at any magnitude. The lesson is in the test suite now: assert the number, not its sign.

The index, and why it is treated as dangerous

sync_index copies price_twap from the pairable account in the peard registry. It reads that account by hand, never by CPI, checking three things: the program that owns it, the id it carries, and the address the market names.

The registry program is pinned in Global at init and there is no setter, because a market naming its own price source is a market writing its own liquidation trigger.

pub fn index_ok(&self, now: i64) -> bool {
    self.index_price > 0
        && self.index_ts > 0
        && (self.max_index_age_secs == 0
            || now - self.index_ts <= self.max_index_age_secs as i64)
}

Age is measured against the pairable's own timestamp, never the sync time. A registry nobody has pushed to in an hour should look an hour old however recently somebody cranked this market.

index_ok gates funding, opening, and liquidation. A market whose index has aged out does not trade on the last good number. It stops.

max_index_age_secs may never be zero. Zero disables the check, and the one place that matters is liquidation, where a stale index costs somebody their position. init_market refuses a zero, and so does a zero max_open_base, which would create a market on which no position could ever be opened. Both refusals exist because the market PDA seed is [b"market", pairable]: a market can never be re-created, a typo at creation is permanent, and the only defence is refusing the typo.

Margin and liquidation

collateral  = min(collateral, funding_owed) charged first, CLAMPED at zero
equity      = collateral_after_funding + unrealised pnl
notional    = |base_size| * index / 1e9
maintenance = notional * maintenance_margin_bps / 10000

The clamp on the first line is not a detail. liquidate charges funding through apply_funding before it computes equity, and apply_funding pays at most what the position holds: pay = owed.min(collateral), with the remainder going to bad_debt. A position owing more funding than it has collateral lands at exactly zero, never below. Reading it the naive way liquidates positions the program keeps.

Below maintenance, anyone may liquidate. liquidation_fee_bps goes to whoever did it, which is the only reason anybody would run a liquidator at all, and it is a cut of what survives unwinding against the virtual reserves rather than a slice of remaining collateral. At an index of $90 against a curve still marking $100 the fee is $0.990009 rather than the $1.00 the simpler reading predicts. It is exactly zero when the unwind settles below zero, so the incentive vanishes precisely where the damage is.

Leverage is 1x today. initial_margin_bps is 10000, meaning a position must be fully collateralised. At 1x a long can never be liquidated by price at all, and a short only past a 90% rise, further than any breaker in the registry admits, so no legal print jumps a healthy position straight into bad debt. At that setting the 500bps maintenance margin is nearly unreachable, and that is the point.

There is a liquidator, and no insurance fund. relayer/src/liquidate.ts exists and is dry by default, but it has never been run against devnet; that only became possible on 2026-08-25, when the first real position was opened. The insurance fund is a prerequisite for leverage and for mainnet and does not exist. Neither is needed at 1x on devnet, where a position cannot realistically go under water. Market.bad_debt counts what the vault has had to absorb, and it is not a rounding counter: a non-zero figure means liquidation was too slow at least once, and it belongs in front of anyone depositing.

Parameters, and where they come from

Derived from the pairable, because the registry already knows how fast and how volatile each underlying is.

ParameterDerivation
max_index_age_secsmin(30d, params.max_price_age_secs * 2), never equal
funding_interval_secs3600, never faster than the index can move
max_divergence_bpsmax(3000, breaker_bps + 1000)
base_reservedepth_usd_e6 * 1e9 / price_twap
max_open_basebase_reserve / 10

The max_index_age_secs rule is worth spelling out. sync_index refuses anything older than max_price_age_secs, so an equal value would leave zero window in which an open position could still be closed against the last good print. Since nothing in the registry expires today, settle_market can never fire to rescue it. Equal here means trapped forever.

max_open_base at a tenth of the virtual base bounds the mark one side can push to about +23.5%, which is inside every tier's divergence gate by construction, so the cap is always the binding constraint.

Policy, the same for every market at launch:

ParameterValueWhy
initial_margin_bps100001x. No leverage yet
maintenance_margin_bps500near unreachable at 1x, which is the point
liquidation_fee_bps1000, or 2000 on a volatile breakerthe only reason anyone liquidates
max_funding_bps100one step cannot empty a position
fee_bps10, 20 or 30tiered by the breaker: a more volatile underlying costs more to trade

Backing: turning "backed by" into an enforced ceiling

Added and deployed to devnet on 2026-08-25. What does not exist is anything to point at: peard_vault is on no cluster, Global.backing_program is still Pubkey::default(), and every market's backing_value_usd is zero.

A market may point at a reserve in a backing program, and then it can never carry more open interest than there is real backing for. That turns "backed" from a claim on a website into a constraint the program enforces.

pub fn effective_open_cap(&self, now: i64) -> Option<u128> {
    if !self.is_backed() { return Some(self.max_open_base); }
    if !self.backing_ok(now) { return None; }
    Some(self.max_open_base.min(self.backing_cap_base()?))
}

Three properties are deliberate.

  • It takes the lower of the two. Declaring backing can only tighten a market, never inflate one. Backing is a constraint, not a licence.
  • The ceiling moves with the price. Backing is marked in dollars and the cap is in base units, so the index converts between them. The same reserve of pears backs more contracts when pears are cheap. Given that the September harvest collapses the pear price every year, that is a real and slightly funny consequence rather than a quirk.
  • Stale backing refuses opens only. Closing is never gated on it. A reserve nobody has marked lately is a reason to stop taking new risk, never a reason to trap risk already taken. This mirrors how index_ok is used, on purpose.

set_market_backing is authority-gated and sync_backing is permissionless. That split is only safe because the address is pinned first: without it, anyone could stand up their own reserve, name someone else's market inside it, and take over that market's ceiling.

migrate_market exists because Market grew by 52 bytes, from 303 to 355, and the PDA seed means a market can never be re-created. realloc::zero = false keeps the existing bytes and the runtime zeroes the new tail, which is exactly the unbacked default, so a migrated market behaves precisely as it did before.

All 48 devnet markets were migrated on 2026-08-25. Re-reading them confirms every one is 355 bytes and decodes against the current IDL, which is a different and stronger check than the absence of an error. That is what unblocked confirming funding fires.

What is not solved by any of this: somebody still has to buy the pears and rent the cold storage. The mechanism is ready before any reserve exists to point it at.

What is honest to say about a Path A market today

Funding has fired. On 2026-08-25 a position was opened on PEAR-EA to create a 40.1bps premium and the next interval charged it: cumulativeFunding moved to 2,400,000,000 and a 166 pear long owed $0.399999, against a prediction of 2.4e9 and about $0.40 computed beforehand from the program's own arithmetic. As of that evening 32 of the 48 devnet markets carry a non-zero cumulative_funding; the other 16 correctly carry zero, because mark equals index on a market nobody has traded.

Ten of the 48 markets live on devnet are priced by a number a human typed into a config file, and an eleventh, MINWAGE-HR, by a pinned constant. Fifteen such attestations exist across the registry as of 2026-08-25, down from twenty, and they now expire thirty days after the date they carry rather than publishing forever; the app carries a visible badge on every market sitting on one. The pinned MINWAGE-HR at $7.25 deliberately does not expire: a pinned constant is a convention rather than a measurement, and it is true until governance says otherwise.

The check that finds them does not use the resolver field, because 13 of the 15 hand-typed attestations are registered as http pairables and the other two as optimistic. It compares the on-chain TWAP against the operator's typed figure instead.

A synthetic perp is a real instrument and a hand-typed index is not a real price. Both things are true at once, and the interface has to say which one you are looking at.