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.
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.
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
require!(!config.paused)
- Quote the fee, check staleness, confidence and band. Fail before writing anything.
require!(fee_bps <= 5000) — the 50% ceiling
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
- Init the four PDAs, rent paid by the creator
- Transfer the fee → treasury
- Transfer the stake → vault,
vault.pool_lamports = stake
position.shares = stake, position.cost = stake,
vault.share_total = stake → share price is exactly 1.0000
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
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
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
amount >= config.min_deposit
vault.cap == 0 || pool + amount <= vault.cap — zero means uncapped, checked
explicitly so reading the account tells you which it is
- private vault → the invite PDA must exist and be unspent
Then one of two paths
- 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.
- 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.
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
Decisions still open
- 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.
- Pyth or Switchboard for SOL/USD. Only matters for the creation fee, so the cheaper integration
wins; the band makes either safe enough.
- 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.
- 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.
- 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 →