Structured credit on Solana: a deposit into a lending pool is sliced into a senior tranche with a capped, predictable yield and a junior tranche that takes the residual yield and absorbs losses first. A separate loss-protection market will let anyone buy or sell cover against a loss event in a specific pool, settled automatically on chain.
The lending pool in this repository is a simulation: yield accrues at a configured rate on a scaled model clock, and loss events are recorded by the pool operator. Integration with external lending protocols (Kamino, MarginFi, …) is out of scope.
Status — v0.1.0: pool, tranches, yield and loss waterfall are live on devnet with a web
page and an operator sheet. The protection market (v0.2.0) and the "what if" cabinet
(v0.3.0) are next; their screens in the web app still run on mock data.
- The pool holds one base token (demo mint WUSD, 6 decimals). Deposits mint tranche
shares — sWUSD for senior, jWUSD for junior — at the tranche's current NAV
(
assets / supply, 1:1 while the tranche is empty). - Yield accrues on the whole pool at
yield_rate. A performance fee goes to the operator's treasury, senior gets at mostsenior_rateon its assets, junior takes everything left (including rounding remainders). - A loss of
Lhits junior first; senior loses onlyL − junioronce junior is wiped out. Every loss is written as an immutableLossEventaccount (ts, model time, bps, amount, junior/senior split, assets before) — the loss history is read from chain, no server. - A deposit into senior is refused if junior would drop below
min_junior_bpsof pool assets (the junior floor); the first deposit into an empty pool must therefore be junior. - Invariant after every instruction:
assets == senior_assets + junior_assets == vault.amount. Yield is minted into the vault, losses are burned from it. - Every user instruction starts with
accrue. Time is a model clock:model_time += elapsed × time_scale. The demo pool runs at43 200×, so one minute on chain is 30 model days — enough to watch NAV move during a demo.
All amounts are integers in micro-units (u64 on chain, bigint in TypeScript); products go
through u128; everything is checked arithmetic.
| Site | https://trancheyield.github.io/Wash-App/ — landing page (GitHub Pages, built from main) |
| Web app | https://trancheyield.github.io/Wash-App/app/ |
| Program | 2Yq39tVgTH5e8be8YdssyhvM6339f2WG6QweNmxGpBbf (SBPFv0, upgradeable) |
| Pool 0 | yield 8 %/yr · senior 5 %/yr · junior floor 20 % · fee 10 % · clock 43 200× |
| Faucet | 1 000 WUSD per request from the pool page (cap per call in Config) |
fixtures/params.json is the single source of the demo parameters for demo:init, the
program tests and the design brief.
programs/washapp Anchor 1.2 program: init_config, faucet, create_pool, accrue, deposit,
redeem, record_loss; pure waterfall math in src/math.rs
packages/chain Codama-generated client on @solana/kit, PDAs, account readers,
instruction builders (each with a round-trip decode test)
packages/shared TypeScript mirror of the waterfall math, micro-unit formatting, Zod schemas
apps/web React + Vite SPA: pool page, deposit/redeem, loss sheet with operator
panel (live on devnet); protection and position screens (mock data, M2/M3)
apps/landing static landing page served at the site root (no build)
tools/demo devnet init and the scripted end-to-end scenario
fixtures/ evidence generated by the Rust tests and the scenario (see below)
scripts/ WSL build/deploy wrappers and the trace sweep run in CI
- Node ≥ 22, pnpm 9.15 (
packageManageris pinned). - For the program: Rust
1.97.1(rust-toolchain.toml), Agave /cargo-build-sbf3.1.10, Anchor CLI 1.2.0 — only needed to emit the IDL. On Windows the on-chain toolchain runs in WSL. - A Solana RPC for devnet. The public endpoint rate-limits a deploy and the scripted scenario; a free Helius key is enough.
Copy .env.example to .env. SOLANA_RPC_URL and WASH_KEYS_DIR are read by the demo tools
and the deploy script; VITE_* by the web app. The operator, deployer and program keypairs live
in WASH_KEYS_DIR, outside the repository.
pnpm install
pnpm gate # biome + tsc + vitest across the workspace
pnpm dev # web on :5173
pnpm codama # IDL → packages/chain/src/generated (after wsl-build.sh idl)
pnpm demo:init # devnet: config, demo mint, treasury and pool 0 (idempotent)
pnpm demo:scenario # devnet: fresh buyer and seller → deposits → cover sold and bought → 60 s →
# loss → settle → expire → redeem → withdrawOn-chain, from PowerShell into WSL:
wsl.exe -e bash <repo>/scripts/wsl-build.sh gate # fmt-check, clippy, build-sbf, cargo test
wsl.exe -e bash <repo>/scripts/wsl-build.sh idl # anchor build for the IDL only
wsl.exe -e bash <repo>/scripts/wsl-build.sh fixtures # regenerate fixtures/*.json from the tests
wsl.exe -e bash <repo>/scripts/wsl-deploy.sh deploy # devnet deploy or upgrade, then verify
wsl.exe -e bash <repo>/scripts/wsl-deploy.sh verify # bytecode on chain == local .so, e_flags 0x0
The network artifact is built with cargo-build-sbf (SBPFv0 — Agave 3.1.10 on devnet does not
execute v3 bytecode). anchor build is used only to emit the IDL and overwrites the .so with a
v3 build, so build-sbf always follows it.
The site is static: there is no server, every number on it is read from chain in the browser.
.github/workflows/pages.yml runs on every push to main (or by hand from Actions → Pages →
Run workflow) and publishes one artifact of two folders:
https://<owner>.github.io/<repo>/ apps/landing — static landing page, copied as is
https://<owner>.github.io/<repo>/app/ apps/web — the app, built with base `/<repo>/app/`
apps/landing has no build and no dependencies (index.html, styles.css, assets/); every
figure on it comes from this README and fixtures/demo-run.json. Its og:image is an absolute
URL to the project site — change it together with the domain.
One-time setup in the repository:
-
Settings → Pages → Source: GitHub Actions (not "Deploy from a branch" — the branch root has no
index.html). -
Optionally, Settings → Secrets and variables → Actions → Variables:
Variable Unset means VITE_SOLANA_RPC_URLpublic https://api.devnet.solana.comVITE_WASH_DEFAULT_POOLpool 0VITE_SOLANA_CHAINsolana:devnet(wallet-standard chain id)These are variables, not secrets: every
VITE_*value is readable in the published bundle. An RPC URL with a key is public once it is here. Helius Free (October 2026) cannot lock a key to a domain, so use a key from a separate account that serves nothing else, and test any provider's origin lock withcurl -H "Origin: https://example.com"before relying on it. Re-run the workflow after changing a variable.
Notes:
- Do not publish a local
pnpm build: it reads the repo-root.envand would bake its RPC key into the bundle. CI has no.env. - The site path (
/Wash-App) comes fromactions/configure-pagesand the app is built under<site path>/app/, so a custom domain needs no code change in the app. Set the domain in Settings → Pages only — aCNAMEfile in the repository is ignored when Pages deploys from a workflow. - Deep links: the workflow copies the app's
index.htmlinto every route of the default pool (/app/pool/<id>/…,/app/me,/app/operator), so a direct link or a reload answers 200. Any other path — another pool id, or a link from before the app moved under/app/— renders the app through the site's root404.htmlwith status 404; the app moves an old link below/app/before the router reads it. - Pages has no response headers (no CSP) and no place to hide a key. It covers the devnet demo; a mainnet deployment with real funds needs a host with headers (Vercel, Cloudflare Pages) or IPFS.
Run from PowerShell into WSL, with SOLANA_RPC_URL and WASH_KEYS_DIR set in .env:
wsl.exe -e bash <repo>/scripts/wsl-build.sh build-sbf # SBPFv0 artifact in target/deploy
wsl.exe -e bash <repo>/scripts/wsl-deploy.sh deploy # first deploy or upgrade, then verify
wsl.exe -e bash <repo>/scripts/wsl-deploy.sh status # program account and deployer balance
pnpm demo:init # Config, mint, treasury, pool 0, its cover market
- The program keypair (
washapp-keypair.json) fixes the address and must matchdeclare_id!; the deployer keypair is the upgrade authority. Both live inWASH_KEYS_DIR; the deploy script creates the deployer if it is missing. deployrefuses to start until the deployer can pay: a first deploy needs rent for ProgramData (.so× 1.5, room for upgrades) plus a buffer of the.sosize; an upgrade needs only the buffer. The buffer is refunded after the write. The script prints the exact amounts.deployends withverify: the bytecode on chain must equaltarget/deploy/washapp.sobyte for byte and be SBPFv0.- An upgrade that keeps the instruction and account layout needs no site rebuild. If the IDL
changed, regenerate the client (
wsl-build.sh idl, thenbuild-sbf, thenpnpm codama) and push — Pages rebuilds frommain. - A program under a different address is a rebuild, not a setting: the address sits in
declare_id!(bytecode) and in the generated client (pnpm codama), which the site and the tools both use. Set the new keypair, updatedeclare_id!andWASH_PROGRAM_IDin.env, thenidl→build-sbf→pnpm codama→deploy, and push.
Program tests run the real .so under mollusk-svm in cargo test: unit tests on
math.rs with proptest invariants, one test file per instruction, a compute-unit gate
(every instruction < 200 000 CU; current peak 40 713 for record_loss), and 100 seeded random
sequences of deposit/redeem/accrue/loss that check the vault invariant and the loss split byte
for byte after every step.
fixtures/ holds what the Rust side generated so the TypeScript side can be checked against
the program rather than against itself:
waterfall.json— 152 cases frommath.rs;packages/sharedmust reproduce them exactly.pda.json— 30 PDA derivations at the edges of every numeric seed.accounts/m0.json— raw account bytes from the SVM before and after a 15 % loss; the readers decode them in vitest.demo-run.json— the lastdemo:scenariorun on devnet: per-step timings (signature → confirmed → change visible in RPC), the loss waterfall checked against the on-chain event, and the protection checks — premiums against the mirror, the buyer's balance aftersettle(+notional × loss), the expired contract's released reservation, the seller's result.
Guard tests fail cargo test when a fixture is stale, and vitest fails when a fixture breaks a
budget (scenario ≤ 180 s, each step ≤ 10 s) or records a failed check. The last run: 97.4 s end
to end over 16 transactions, slowest step 4.2 s; settle paid 150.00 on a 1,000 WUSD notional for a
15 % loss, to the unit.
CI (.github/workflows/ci.yml) runs the Rust gate on the SBPFv0 artifact, the Node gate, and a
sweep for keys and machine-specific traces over the whole history.
v0.1.0— pool, tranches, yield, loss waterfall; pool page, deposit/redeem, operator loss sheet; devnet scenario. This release.v0.2.0— protection market: sellers fund a cover pool, buyers pay a premium for cover against a loss above a trigger, settlement pays out from the cover pool when a loss is recorded.v0.3.0— position cabinet with a "what if the pool loses X %" forecast that must match the program to one micro-unit.