Imported from LaiTszKin/fructus (
AGENTS.md). Install upstream withnpx skills add LaiTszKin/fructus. Copyright stays with the author.
Fructus — Solana yield-futures protocol (on-chain order book + collateral vault)
Build & Test
cargo test --workspace— full Rust program suite (200 lib tests: oracle/CLOB/vault/ positions + funding/liquidation/settlement invariants, plus bank-style CPI tests)cargo test --workspace <name>— single test (e.g.funding,liquidatable)anchor build— compile program to.so(needscargo-build-sbf); rebuild before running CPI tests —tests/collateral_cpi.rsloads the SBF binary and a stale.sofails thecpi_binary_is_present_and_freshguardcd publisher && npm test— publisher suite (8 tests, cross-language vector)cd sdk && npm test— trader SDK suite (52 tests; funding/PnL/layout vector)cd cli && npm test— trader CLI suite (16 smoke + R-1 regression)cd scripts && npm run e2e— offline devnet lifecyle dry-run (RUN_E2E=1= live)cd scripts && npm run setup— self-contained devnet bootstrap: wallets + airdrop SOL + self-owned collateral mint + own SPL stake pool (INDEX_SOURCE), then validate + print the e2e env (--preflight= check only)cd integration && npm test— SDK/CLI -> protocol integration PBT: drives the real program (solana-test-validator + SDK builders) and asserts on-chain invariants (--test-force-exit)cd trident-tests && cargo run --bin fuzz_0— on-chain stateful fuzz smoke runcargo fmt --check— format check
Tech Stack
- Language: Rust (MSRV 1.89) + TypeScript (ESM, Node ≥ 18)
- Framework: anchor-lang 1.1.2 / anchor-spl 1.1.2
- Solana crates:
solana-sdk-ids3.1,solana-instructions-sysvar3.0,sha20.11,bytemuck1.17 (zero-copy accounts) - npm deps (publisher/sdk/cli/scripts):
@solana/web3.js^1.95,tsx - Testing:
proptest1,solana-instruction3.0 (dev),solana-program-test3.1 (dev), Trident 0.12 - Package managers: cargo (root +
trident-tests/) and npm (publisher/,sdk/,cli/,scripts/)
Project Structure
programs/fructus/src/— on-chain Anchor program: oracle (state,ed25519), settlement (exchange), CLOB order book + mark/twap (orderbook), collateral vault (collateral), position lifecycle (positions), funding engine (funding), liquidation engine (liquidation), top-level instructions (lib), pure-logic invariants + adversarial review invariants (tests, per-module#[cfg(test)])programs/fructus/tests/— bank-style CPI integration tests (collateral_cpi.rs,positions_cpi.rs)publisher/— off-chain TypeScript APY keeper (fetch → sign → submit)sdk/— trader TypeScript SDK (instruction builders, typed account decoders, funding/PnL mirrors)cli/— trader CLI over the SDK (open/close/deposit/withdraw/position/funding/mark/index)scripts/— devnet deploy + e2e lifecycle (deploy.sh,e2e.mts),Anchor.tomldevnet profiletrident-tests/— fuzz harness (separate cargo workspace)docs/— documentation hub (docs/README.md)target/,*/node_modules,*/dist/,.review/— build/review artifacts (gitignored)
Key Constraints
- Never depend on the
solana-programumbrella crate — use granular 3.x crates. Compare pubkeys at byte level (as_ref()/to_bytes()), not by type (anchor 1.x "Address" migration makes the types version-fragile). - Fixed-point APY/yield scale is
1_000_000(APY_SCALE); useu128+checked_*/saturating_*arithmetic — no panicking math. Funding / premium / realized PnL are signed half the time: usei128+checked_*/saturating_*, neveru128/saturating. - Funding sign convention:
premium = mark − index;funding_rate = clamp(funding_k·premium/APY_SCALE, ±max_funding);premium > 0 ⇒ **longs pay shorts**(long flow−1, short flow+1, exact opposites). Epoch =slot / funding_epoch_slots; settlement is idempotent (same epoch ⇒ no-op). - Design A no-mint invariant — all PnL/funding settlement goes through
programs/fructus/src/settlement.rs: a loser's debit is collected intoPerpMarket.pnl_pool(clamped atdeposited), a winner is paid only up to the pool (min(credit, pool)), and the unfunded remainder becomes a pending claim (UserCollateral.claimable, never directly withdrawable — only viaclaim_payoutat deposit/withdraw).pool ≥ 0⟺Σ deposited ≤ vault real balance(no mint);liquidatealso books the victim's realized loss into the pool, capped atdeposited − reserved_after − reward(never touches other positions' reserved backing; reward payable first).positions::apply_pnlstays a per-account pure transition — never wire it directly onto a winner's ledger (that is the original minting bug). - Position collateral invariant =
position.collateral == margin_required(notional, initial_margin_bps), maintained on open (apply_open_fills), close (apply_close_fills), AND liquidate (apply_liquidationre-derives the surviving collateral at the initial margin ratio).maintenance_margin_bpsis the health threshold (liquidatable, strict<), never the release ratio. Any liquidation change must keep the surviving collateral equal tomargin_required(notional − amount, initial_margin_bps)and never create value (remaining + reward ≤ position_collateral). A fully liquidated (notional == 0) position holds zero collateral. - Liquidation reward is zero-sum: the
liquidatehandler debits the victim'sUserCollateral.depositedby the reward and credits the liquidator's by the same amount (the reward is drawn out of the victim's released margin,reward ≤ position_collateral − remaining), so Σdepositedacross victim + liquidator is conserved — a liquidation never mints collateral. - Close is priced at its own (close-time) entry basis:
apply_close_fillscapturesclosed_notional's basis intoclosed_entry_n_sum/closed_entry_d_sum;settle_closeprices it against those (never the liveentry_*, which a re-open resets). A re-open also re-baseslast_funding_epochto the re-open epoch so funding never accrues over a closed interval.Position::LEN = 170; any layout change must be mirrored insdk/src/account/{layout,decode}.ts+docs/{data-models,modules/positions, modules/settlement}.md. - Canonical signed message =
sha256("fructus::update_apy" ‖ oracle ‖ apy_le ‖ version_le). Rustupdate_messageand TSupdateMessagemust stay byte-identical; any change updates the cross-language vector test on both sides. - Cross-language funding/PnL mirrors —
sdk/src/{funding,positions,mark-index}.tsandclimust stay byte-identical to Rustfunding.rs/positions.rs/orderbook.rs(sign, clamp, truncate-toward-zero, annualize,mid().unwrap_or(index)fallback). trident-tests/fuzz_0/{types.rs,fuzz_accounts.rs}are generated — edit onlytest_fuzz.rs.- Stake-pool offsets are 258/266 (with
account_typeprefix) — do not "fix" to 257/265. - Large accounts (> 4 KiB) must be
#[account(zero_copy)]— borsh deserialization overflows the SBF 4 KiB stack. Access viaAccountLoader::load_mut()/load_init()(no.exit()); sub-structs use#[zero_copy]with#[repr(C)], reordered fields + explicit_pad(bytemuckPodforbids implicit padding);bool→u8,u128→[u8; 16]. OrderBookmust stay under the 10 KiB per-tx data-growth cap (MAX_PERMITTED_DATA_INCREASE) —initialize_order_book's inner-CPI allocation fails withInvalidReallocfor a larger account (breaks on-chain init; the bank CPI tests seed the account manually to avoid it). Current layout:MAX_ORDERS_PER_SIDE = 16,EVENT_QUEUE_LEN = 32,TWAP_OBSERVATIONS = 16→OrderBook::LEN = 6_232(account =8 + LEN = 6_240B). Any capacity/size change must keep8 + OrderBook::LEN ≤ 10_240and be mirrored insdk/src/constants.ts+account/{layout,decode}.ts+docs/{data-models,modules/order-book}.md.- Devnet deploy —
scripts/deploy.shbuilds + deploys and records the program id /PerpMarketPDA; align[programs.devnet]withdeclare_id!(PDA derivation depends on the program id). A real deploy needs the program keypair + a funded devnet wallet.
Testing
- Pure logic →
proptestinvariants inprograms/fructus/src/tests.rsand the per-module#[cfg(test)](funding/liquidation/positions/collateral); the adversarial-review probes live in those same per-module#[cfg(test)]blocks and intests.rs. - Signature verification → mock instruction sysvar (
construct_instructions_data). - Cross-language consistency → shared hex vector (Rust + TS) + SDK/cli vector tests.
- Stateful on-chain → Trident
trident-tests/. - Vault CPI / bank-style →
solana-program-testinprograms/fructus/tests/(needs a freshly built.so).
Git Workflow
- Conventional commits:
feat:,fix:,refactor:,docs:,test:,chore:. - Commit in dependency order:
docs:/chore:→refactor:→feat:/fix:→test:.
Documentation
docs/— architecture, modules, API, data models, setup, testing, workflows (docs/README.md).- Keep "one home per fact": root
README.mdlinks in; it does not duplicate deep content. - Mark inferred rationale
[INFERRED]— never present inference as fact.
Boundaries
Always:
- Run
cargo test --workspacebefore committing program changes (andanchor buildso the CPI guard stays green). - Add/adjust property tests for any changed pure logic (
proptest). - Keep the cross-language message vector (oracle) and the funding/PnL mirrors in sync across Rust + TypeScript.
Ask first:
- Adding new Solana/Anchor dependencies (version-sensitivity is high).
- Changing the stake-pool offsets or the canonical message format.
- Deploying/upgrading the program or rotating the publisher key.
Never:
- Commit
.env, keypairs (*.keypair.json),target/,dist/,.review/, or secrets. - Edit generated files (
trident-tests/fuzz_0/types.rs,fuzz_accounts.rs). - Skip pre-commit hooks with
--no-verifywithout explicit request.