build spec · part one

VAULT CREATION

What one create_vault transaction produces, how the $100 is actually charged, how a leader trades the pool without ever having custody of it, and how a deposit turns SOL into shares. One program serves every vault — nothing is deployed per vault.

What creation produces

Three accounts, all PDAs, all owned by the program. Sizes are padded so a field added later does not force a migration of every existing vault.

Rent is a deposit, not a fee. Closing a position account returns its rent to the depositor; closing a token account on exit returns it to the pool. The only money that does not come back is the $100.

The $100, and why it is not a fee

Opening a vault is not charged for. The $100 is the minimum of the creator's own money that has to be in the pot, and it stays theirs.

That changes the engineering, not just the pricing. A charge taken in SOL has to convert from dollars, and a wrong conversion costs somebody money — so it must fail closed. A minimum expressed in dollars only mis-sizes a threshold on funds that never leave the creator, so it can fail open to a hard lamport floor. An oracle outage should not stop people opening vaults when nothing is being taken from them.
A fee fails closed
Price stale or outside the band → the instruction refuses. Charging the wrong amount is worse than not charging. Still live for the $1,000 launch, which is a real charge.
A minimum tracks the price
$100 is $100 at any SOL price: 0.47 SOL at $212, 2.50 SOL at $40, 0.10 SOL at $1,000. Only a price far enough out to be nonsense — 20× from the reference, against 4× for a charge — falls back to a fixed value, and even then creation still works.
The fee machinery below is still in the program, set to zero. Turning charging back on is a config change, not a redeploy — and the signer's own ceiling, described at the end, is what makes it safe to turn on.

What follows is how a charge works when there is one — which is the $1,000 launch today, and would be the vault fee if it were ever switched on.

Three ways to do it

ApproachCost
Fixed lamports, admin-updated No oracle, no failure mode — but the dollar price drifts between updates and is simply wrong in between. A 30% SOL move makes it a $70 or $130 fee.
Charge in USDC Exact, no oracle. But the stake is in SOL, so creating a vault needs two tokens and an ATA the creator may not have.
Oracle-quoted SOL chosen Correct in dollars, one token, one transaction. Adds a dependency that can go stale or wrong — which is what the band below is for.

The rule, exactly

// inside create_vault, before anything is written
let px = pyth::load(config.oracle)?;
require!(now - px.publish_time <= 60,        StalePrice);
require!(px.conf * 50 <= px.price,             WidePrice);  // conf ≤ 2%

let lamports = config.create_fee_usd * LAMPORTS / px.price;
let lo = config.create_fee_usd * LAMPORTS / (config.ref_price * 4);
let hi = config.create_fee_usd * LAMPORTS * 4 / config.ref_price;
require!(lamports >= lo && lamports <= hi,     FeeOutOfBand);

// and whatever the band allows, the signer's own ceiling wins
require!(lamports <= args.max_fee,            FeeAboveMax);

transfer(creator -> treasury, lamports)?;        // never into the pool
The band is expressed against a reference price the admin sets beside the fee, not as absolute lamports — an absolute band cannot serve a $100 and a $1,000 tier at the same time. Four times in either direction.
The band is not the real protection. It still leaves a 16× spread between its floor and its ceiling, so it rules out a broken oracle, not a bad price. What actually binds is max_fee: the creator signs for the exact number their wallet showed them, and the instruction refuses anything above it. That is what makes the price source untrusted rather than trusted — a wrong feed, or an admin repricing between quote and signature, costs the creator nothing.

Try a price

SOL / USD —

Where it goes

Creation feeTreasury PDA
Creator stakeVault pool
Rentthe accounts it opens
The fee must not land in the pool. If it did it would become share-backed value — the platform's revenue silently converted into depositors' money, and the share price at genesis would no longer be exactly 1.0000.
The $1,000 launch tier is the same code path with launch_fee_usd in place of create_fee_usd. Nothing else about it differs.

The creation instruction

create_vault

(name: [u8;32], access: u8, fee_bps: u16, stake: u64, cfg: RailCfg) → ()

Signers

  • the creator, who is also the transaction fee payer. A PDA cannot pay for a transaction, so there is no version of this where the vault funds its own creation.

Accounts

  • config — read, for the fee, the oracle and the reference price
  • oracle — read
  • treasury — write, receives the fee
  • vault — init, PDA ["vault", creator, seed]
  • vault_pending — init, PDA ["pending", vault]
  • position — init, PDA ["position", vault, creator]
  • leader — init, PDA ["leader", vault, creator]
  • creator — signer, write
  • system_program

Order of operations

  1. require!(!config.paused)
  2. Quote the fee, check staleness, confidence and band. Fail before writing anything.
  3. require!(fee_bps <= 5000) — the 50% ceiling
  4. require!(stake >= $100 at the posted price) — the creator's own money. Not max(floor, …): a lamport floor competing with the dollar figure silently turns a $100 minimum into $125 above $400 a SOL, and $250 at $1,000
  5. Init the four PDAs, rent paid by the creator
  6. Transfer the fee → treasury
  7. Transfer the stake → vault, vault.pool_lamports = stake
  8. position.shares = stake, position.cost = stake, vault.share_total = stake → share price is exactly 1.0000
  9. vault.cap = 0 — uncapped. A cap would hold the creator at a minimum percentage, but a percentage is not money: a deposit dilutes a share without touching its value, so there is nothing for a cap to protect
  10. leader.active_at = now — the creator trades immediately; every later leader is timelocked 24h

Atomicity

  • One instruction, so there is no reachable state where the fee is paid and the vault does not exist, or the vault exists and the fee was not paid.

Why 1 SOL minimum

It is not a seriousness test. It is the share-inflation defence.
// genesis with a 1-lamport stake
share_total = 1
pool        = 1          // price 1.0

// creator sends 100 SOL straight to the PDA
pool        = 100e9      // price 100e9

// a 50 SOL depositor now mints
shares = 50e9 * 1 / 100e9 = 0   // floored
The depositor pays 50 SOL for zero shares and the creator owns the whole pot. This is the ERC-4626 inflation attack, and a 1 SOL genesis plus a minimum deposit makes the floored division unreachable.

Rounding direction

Share maths is u128 mul-then-div, floored.
Minting sharesround down
Burning sharesround down
Floor in both directions means every rounding remainder stays in the pool, so dust always accrues to the people already in it and never to the person transacting. A single consistent direction is what stops a loop of tiny deposits and withdrawals extracting value.

Lamports vs rent

The Vault PDA holds its own rent and the pool. Paying out the account's whole lamport balance would de-rent it and the account could be purged with the ledger in it.
// invariant asserted at the end of every instruction
vault.lamports() >= rent_minimum + vault.pool_lamports
pool_lamports is an explicit field, not lamports() - rent, so the pool is never inferred from a balance an outsider can inflate by sending SOL to the PDA. Unsolicited SOL is simply not pool.

What the pool actually is

vault.pool_lamports

u64, a field on the Vault PDA

It is not the account balance

  • The Vault account's lamports are rent + pool. Anyone can send SOL to a PDA, so inferring the pool from the balance would let an outsider inflate it by donating.
  • Unsolicited SOL is simply not pool. It sits there, owned by nobody, and no share is backed by it.
  • Only four things ever move the number: creation, deposit, withdrawal, and (later) a trade settling. Every one of them also moves share_total or is a trade — never neither.

Three numbers, and everything falls out of them

// on the vault
pool_lamports : u64    // what the vault is worth, in SOL
share_total   : u128   // every share in existence

// on each depositor's own Position PDA
shares        : u128   // what they hold
cost          : u64    // what they paid, for the fee only

// therefore
your share of the vault = shares / share_total
your money              = shares * pool / share_total
share price             = pool / share_total
There is no per-depositor balance anywhere. A percentage is never stored, only derived — which is why nothing has to be recalculated for anyone else when one person moves.

At creation

  • pool_lamports = stake, share_total = stake, creator shares = stake.
  • So the price is stake / stake = exactly 1.0000, and the creator owns stake / stake = 100%. There is no other holder yet; the first deposit is a separate transaction, signed by somebody else, that may never come.

The whole rule, in two lines

Deposits and withdrawals
move pool and share_total by the same ratio. The price cannot change. Everyone's percentage moves; nobody's money does.
Trading
moves pool alone. The price changes. Everyone's money moves by the same percentage; nobody's percentage does.
That separation is the design. It is why a late depositor cannot buy into a run they did not fund, and why an early one cannot be diluted out of a gain they did.

Worked, with no cap

EventPoolSharesPriceCreator %Creator SOL
Creation, 2 SOL2.002.0e91.0000100%2.00
Alice deposits 0.42.402.4e91.000083.3%2.00
Bob deposits 198200.40200.4e91.00001.0%2.00
A trade makes 50%300.60200.4e91.50001.0%3.00
Alice withdraws all300.00200.0e91.50001.0%3.00
Three deposits and a withdrawal, and the creator's SOL only ever moved on the row where something was traded. Their percentage fell from 100% to 1% and cost them nothing — which is what makes removing the cap safe.

How a deposit becomes shares

deposit

(amount: u64) → ()

Signers

  • the depositor, and only the depositor. There is no deposit-on-behalf-of.

Accounts

  • vault — write
  • position — init_if_needed, PDA ["position", vault, depositor], rent paid by the depositor
  • vault_pending — write, only touched on the queued path
  • invite — required only when vault.access == PRIVATE, PDA ["invite", vault, depositor]
  • depositor — signer, write

Checks

  1. amount >= config.min_deposit
  2. vault.cap == 0 || pool + amount <= vault.cap — zero means uncapped, checked explicitly so reading the account tells you which it is
  3. private vault → the invite PDA must exist and be unspent

Then one of two paths

  1. Vault is flat (positions_open == 0). NAV is just pool_lamports — a number, not an estimate — so shares mint immediately: shares = amount * share_total / pool.
  2. Vault is holding. SOL goes to vault_pending, which trade is never passed and therefore cannot reach. Nothing mints. settle converts it at the next flat price.

Side effect that matters

  • If the depositor is not the owner and vault.frozen == 0, set frozen = 1. From that moment fee_bps and access are immutable. Depositors decided on those numbers; they are not the owner's to revise.

Why queue at all

While the vault holds tokens, any share price is a guess, and a guess moves value between the newcomer and the people already in.
// pool holds 100 SOL of a token mid-run
// mark it at the last trade  -> newcomer buys the run cheap
// mark it at exit value      -> newcomer overpays for slippage
// either way somebody is moved value they did not fund
Queueing is not caution, it is the absence of a valuation. Memecoin positions last minutes, so a vault is flat often, and settle is permissionless so nobody can withhold it.

Worked numbers

Who is in a vault

There is no list

a depositor is an address you derive, not a row you look up
  • Depositing creates a Position PDA at ["position", vault, wallet]. The address is the identity — you never search for a depositor, you compute where their account must be.
  • Nothing is registered by connecting a wallet. The browser just knows a pubkey.
  • No limit on depositor count, because nothing holds them all. The vault stores a counter for display and that is the only place a number of depositors appears.
  • A depositor checking their own position derives one address and reads one account. They never need the list.

The list, when the leader does want it

getProgramAccounts(program, { filters: [
  { dataSize: 136 },                              // Position accounts
  { memcmp: { offset: 1, bytes: vault } },        // belonging to this vault
]})
One call, the node does the filtering. Some RPC providers restrict or disable getProgramAccounts — a production UI should index positions from transaction logs rather than ask for them on every page load. Fine for a leader's own dashboard.

set_label — optional, and it proves nothing

  • A depositor may write up to 32 bytes plus a kind (Telegram, X, other) into their own Position, signed by their own key, and change or clear it whenever they like.
  • Trying to label somebody else's position is refused with WrongPda — the account has to derive from the signer.
  • Nothing verifies the handle. Anyone can type anyone's name. No instruction reads the label and nothing behaves differently because of it; it is a convenience for a leader who already knows their depositors, not an identity claim, and the interface says so where it is entered.

What a Position holds

vaultwhich vault
ownerthe depositor's wallet
sharesu128
costu64 — fee basis only
label / kind32 bytes, optional
No percentage. No balance. Both are derived at read time from shares / share_total and shares × pool / share_total, which is why one person moving never requires recalculating anything for anybody else.

Two checks on every withdrawal

  • ✓The Position PDA must derive from (vault, signer), so another depositor's account cannot be passed in its place.
  • ✓The stored owner must equal the signer — belt as well as braces.
  • ✓Payout goes to the wallet recorded in the position, not to whoever signed.
  • ✕No instruction lets a leader, an admin or the program itself move one depositor's shares.
Losing the wallet means losing the position. It is self-custody, with the same consequence as any other.

How the leader trades the pool

The leader never holds the money. They sign an authorisation; the Vault PDA signs the swap. That distinction is the whole custody model.

trade

(side: u8, amount_in: u64, min_out: u64) → ()

The signature that counts

// the leader is A signer. the vault is THE signer.
let seeds = &[b"vault", owner.as_ref(), &seed.to_le_bytes(), &[bump]];
amm::swap(
    CpiContext::new_with_signer(amm_program, accts, &[seeds]),
    amount_in, min_out,
)?;
The leader's key authorises the instruction. The PDA's seeds sign the transfer. There is no instruction in which the leader's key is the source authority over vault funds, so a compromised leader key cannot move money anywhere a legitimate leader could not.

wSOL, and why it never persists

  • The pool is native lamports, so NAV is one number. AMMs want wSOL.
  • Inside trade: debit pool_lamports → vault wSOL ATA → sync_native → swap → unwrap the remainder → close the ATA.
  • Net effect: no wSOL balance exists between transactions, so NAV never has to add two kinds of SOL together, and a stranded wrapped balance cannot drift out of the accounting.

Post-swap assertion

  • Every account passed to the instruction has its lamport and token balance compared before and after. Anything credited that is not a vault-owned account aborts the transaction. This is the backstop for a whitelisted AMM behaving in a way we did not anticipate.

Budget

  • Rail checks, extension parsing, the swap CPI and the assertions come to roughly 180–250k CU. The default is 200k, so the client must request 400k via ComputeBudget.
  • ~20 accounts. Fine against the 64-account limit, but with a CLMM route the 1232-byte transaction limit is the real constraint, so the AMM accounts go in an Address Lookup Table.

Rails the program enforces

Rails it cannot

Decisions still open

  1. Who runs the attestor, and what happens when it is down. Fail closed and trading stops when our signer is offline; fail open and the four off-chain rails quietly stop applying. Neither is good. Leaning fail closed with a per-vault opt-out the depositors can see.
  2. Pyth or Switchboard for SOL/USD. Only matters for the creation fee, so the cheaper integration wins; the band makes either safe enough.
  3. Whether the creator's position can be withdrawn below 5% of the vault. Today the cap is set from the stake at creation and never revisited, so a creator who withdraws drops under 5% without the cap moving. Either block the withdrawal or lower the cap with it.
  4. Invite PDAs versus a Merkle root for private vaults. A PDA per invite costs 0.001 SOL and is simple; a root is one field but needs the list published somewhere to be usable.
  5. Position account closure. Reclaiming rent on full withdrawal is correct but means a depositor who re-enters pays it again. Probably right anyway.
This is built and running. Everything above except the trading path exists as a deployed program; the live test page drives it from your browser.
Part two is the launchpad path — the mint, the allocation transfers, the supply lock and the fee route. It shares create_vault entirely and adds one instruction in front of it. The live account map → · Launchpad →