diff --git a/WRITING-PLAN.md b/WRITING-PLAN.md index 5a35676b..811b71b9 100644 --- a/WRITING-PLAN.md +++ b/WRITING-PLAN.md @@ -57,7 +57,7 @@ Each incident is researched from primary sources and placed inside the chapter n - SPL token-lending rounding (about $2.6B at risk): the rounding section of The Arithmetic of Money - Cashio ($48M kept after refunds, unvalidated account chain): the validation section of The Adversary - Wormhole ($326M, bypassed signature verification): the validation section of The Adversary -- First-depositor inflation incidents (the ERC-4626 class): named incidents are the Hundred and Sonne stories below (their setup is the inflation attack); the empty-pool section of The Adversary teaches the class, and The Constant-Product AMM chapter teaches the locked-minimum defense without an incident box +- First-depositor inflation incidents (the ERC-4626 class): named incidents are the Hundred and Sonne stories below (their setup is the inflation attack); the empty-pool section of The Adversary teaches the class, The Constant-Product AMM chapter teaches the locked-minimum defense without an incident box, and The Vault Strategy chapter teaches ignoring donations - Mango Markets ($116M): the price-manipulation section of The Adversary - The Hundred and Sonne precision drains: the precision-exploits section of The Adversary diff --git a/building-financial-products-in-solana/perpetual-futures.md b/building-financial-products-in-solana/perpetual-futures.md index 65d33fd7..918157a2 100644 --- a/building-financial-products-in-solana/perpetual-futures.md +++ b/building-financial-products-in-solana/perpetual-futures.md @@ -33,7 +33,7 @@ The pool is the mint authority of the **LP share mint** (seeds `"lp_mint"`) and ## Liquidity Providers Fund the Pool and Get Shares Priced at Mark -Carol is a liquidity provider. Her income is every losing trader's loss and every funding payment; her risk is every winning trader's profit. She calls `add_liquidity()` with her whole 1,000 USDC. She is the first depositor, so her shares are her deposit minus `MINIMUM_LIQUIDITY`: a withheld 1,000 minor units, a tenth of a cent, that no one will ever own. That is the same first-depositor defense the *Constant-Product AMM* chapter built, and the one the *Vault Strategy* chapter named as its own missing guard. It prevents the share-inflation attack. Later deposits mint shares pro rata against **assets under management marked to market**, rather than against the vault balance or `liquidity` alone: `liquidity` minus the traders' current unrealized profit (or plus their unrealized loss). If LP shares were priced while ignoring open positions, a depositor could buy in cheap the moment traders were winning and dilute Carol at her worst moment. Marking AUM makes entering and leaving the pool fair at any instant, and both `add_liquidity()` and `remove_liquidity()` take a minimum-out argument so a price move between signing and execution fails with `SlippageExceeded` instead of filling badly. Eleven more providers follow Carol in over the morning, and the pool opens for trading with 12,000 USDC of liquidity. +Carol is a liquidity provider. Her income is every losing trader's loss and every funding payment; her risk is every winning trader's profit. She calls `add_liquidity()` with her whole 1,000 USDC. She is the first depositor, so her shares are her deposit minus `MINIMUM_LIQUIDITY`: a withheld 1,000 minor units, a tenth of a cent, that no one will ever own. That is the same first-depositor defense the *Constant-Product AMM* chapter built, and it prevents the share-inflation attack. The *Vault Strategy* chapter ignores donations instead. Later deposits mint shares pro rata against **assets under management marked to market**, rather than against the vault balance or `liquidity` alone: `liquidity` minus the traders' current unrealized profit (or plus their unrealized loss). If LP shares were priced while ignoring open positions, a depositor could buy in cheap the moment traders were winning and dilute Carol at her worst moment. Marking AUM makes entering and leaving the pool fair at any instant, and both `add_liquidity()` and `remove_liquidity()` take a minimum-out argument so a price move between signing and execution fails with `SlippageExceeded` instead of filling badly. Eleven more providers follow Carol in over the morning, and the pool opens for trading with 12,000 USDC of liquidity. Withdrawals have one extra gate: only **free** liquidity, `liquidity − reserved_liquidity`, may leave (`InsufficientLiquidity` otherwise, checked by `test_remove_liquidity_blocked_by_reserved`). Carol can never pull out money the pool has promised to a winner. diff --git a/building-financial-products-in-solana/vault-strategy.md b/building-financial-products-in-solana/vault-strategy.md index d36351a9..8c996c71 100644 --- a/building-financial-products-in-solana/vault-strategy.md +++ b/building-financial-products-in-solana/vault-strategy.md @@ -26,7 +26,7 @@ She then runs `add_asset()` twice: TSLAx at 4,000 basis points, NVDAx at 6,000. Financial state on Solana is accounts, and Maria's three transactions created the seven the fund needs. -The **strategy** PDA (seeds: `"strategy"`, an index) records the `manager`, the `registry` it is bound to, its `fee_bps` and `max_slippage_bps`, the `swap_router` it is allowed to trade through, its running `total_shares` and `total_weight_bps`, and `last_fee_accrual_timestamp` for the fee clock. The **share mint** is a PDA (seeds: `"share_mint"`, the strategy) with the strategy as mint authority: shares only come into existence when the program's own math says so. The strategy also owns a **USDC vault**, which every deposit passes through on its way into the basket. +The **strategy** PDA (seeds: `"strategy"`, an index) records the `manager`, the `registry` it is bound to, its `fee_bps` and `max_slippage_bps`, the `swap_router` it is allowed to trade through, its running `total_shares` and `total_weight_bps`, and `last_fee_accrual_timestamp` for the fee clock. It also records `usdc_holdings` and `asset_holdings`, the amounts the program has counted into its vaults. The **share mint** is a PDA (seeds: `"share_mint"`, the strategy) with the strategy as mint authority: shares only come into existence when the program's own math says so. The strategy also owns a **USDC vault**, which every deposit passes through on its way into the basket. Each basket asset gets an **asset config** PDA (seeds: `"asset"`, the strategy, an index byte) recording its mint, its feed (copied from its `ApprovedAsset` account), its weight in basis points, and its **vault**, a strategy-owned token account. The strategy holds up to `MAX_ASSETS` (16), and the cap is not about storage. A deposit has to reference every asset's accounts at once, five per asset, so a full basket runs to 94 accounts. That fits inside Solana's account-lock limit, but it is far past what a legacy transaction can hold, so clients of big baskets send v0 transactions with an address lookup table. @@ -36,9 +36,9 @@ The indexes guard the valuation: every handler that computes the fund's value wa Alice invests from the standard wallet of one SOL and 1,000 USDC. She runs `deposit()` with 900 USDC and a `minimum_shares` floor. The handler does three jobs in one transaction, and the order is what makes it safe. -**First it values the fund.** It walks every asset: reads the vault balance, loads the Pyth price, and accumulates value in u128 as amount × price ÷ 10^8, multiply before divide. The price load rejects a feed that is not the one recorded in the asset config, a price that is not positive, or a price older than 60 seconds. The USDC vault is added to the total. That total is the **net asset value**, the NAV. On the fund's first deposit the NAV is zero and shares are minted one-to-one: Alice's 900 USDC becomes 900 shares. +**First it values the fund.** It walks every asset: reads the holding the program has recorded for that vault, loads the Pyth price, and accumulates value in u128 as amount × price ÷ 10^8, multiply before divide. The price load rejects a feed that is not the one recorded in the asset config, a price that is not positive, or a price older than 60 seconds. The recorded USDC is added to the total. That total is the **net asset value**, the NAV. The program keeps these records itself and updates them with what each transfer actually moves; the failure-modes section says why it never values the fund from its vaults' token balances. On the fund's first deposit the NAV is zero and shares are minted one-to-one: Alice's 900 USDC becomes 900 shares. -**Then it deploys the money at the weights.** 40% of 900, $360, buys TSLAx at $250: 1.44 TSLAx into the TSLAx vault. 60%, $540, buys NVDAx at $180: exactly 3 NVDAx. The swaps run through the router fixed at strategy creation (the example ships a mock router standing in for Jupiter; a router that is not the configured one is rejected, checked by `test_deposit_rejects_unregistered_router`), and the strategy PDA signs. +**Then it deploys the money at the weights.** 40% of 900, $360, buys TSLAx at $250: 1.44 TSLAx into the TSLAx vault. 60%, $540, buys NVDAx at $180: exactly 3 NVDAx. The swaps run through the router fixed at strategy creation (the example ships a mock router standing in for Jupiter; a router that is not the configured one is rejected, checked by `test_deposit_rejects_unregistered_router`), and the strategy PDA signs. The handler measures what each swap moved on the vaults and adds it to the recorded holdings. A swap that returns none of its asset refuses the whole deposit (`DepositTooSmall`). **And the program, not the manager, decides the acceptable price.** For each leg, the handler computes the expected output *from the oracle price* and floors it by the strategy's slippage tolerance: the swap may return at most 1% less than the feed says it should, or the whole deposit reverts. A manager cannot approve a bad fill, because the minimum is never theirs to supply. **When an untrusted party routes the trade, the acceptable outcome must be computed from a price source they do not control.** @@ -54,7 +54,7 @@ But the weights have drifted: NVDAx's gain pushed it to 62.5% of the fund agains ## A Later Investor Buys In at the New Price -Bob, the second investor, deposits 480 USDC of his standard wallet. He is not early, and the share math prices that in: shares = deposit × total shares ÷ NAV, floored. With 900 shares outstanding against a $960 NAV, his 480 buys 450 shares, not 480. Alice's early position is not diluted by his arrival, and the test suite checks the fairness directly (`test_deposit_fair_pricing`). His money deploys 40/60 at current prices, same as hers, same slippage floor. +Bob, the second investor, deposits 480 USDC of his standard wallet. He is not early, and the share math prices that in: shares = deposit × total shares ÷ NAV, floored, with the NAV valued from the recorded holdings. With 900 shares outstanding against a $960 NAV, his 480 buys 450 shares, not 480. Alice's early position is not diluted by his arrival, and the test suite checks the fairness directly (`test_deposit_fair_pricing`). His money deploys 40/60 at current prices, same as hers, same slippage floor. ## The Manager's Fee Arrives by Dilution @@ -64,7 +64,7 @@ Traditional funds sell assets to pay the management fee. This program does what ## Withdrawing In Kind -Alice takes profit with `withdraw()`, burning her 900 shares. The program does not sell anything to pay her: it transfers to her **her proportional slice of every vault, in kind**. That is her share of the USDC vault, of the TSLAx vault (about 1.5), and of the NVDAx vault (about 2.8), each amount floored, each destination account verified to be hers and of the right mint (`InvalidRecipient`). The share count is reduced before any token moves (checks-effects-interactions, as in every chapter), and her `min_usdc_out` floor protects the USDC leg (`test_withdraw_rejects_slippage`). +Alice takes profit with `withdraw()`, burning her 900 shares. The program does not sell anything to pay her: it transfers to her **her proportional slice of every vault, in kind**. That is her share of the recorded holdings in the USDC vault, the TSLAx vault (about 1.5), and the NVDAx vault (about 2.8), each amount floored, each destination account verified to be hers and of the right mint (`InvalidRecipient`). The share count is reduced before any token moves (checks-effects-interactions, as in every chapter), and her `min_usdc_out` floor protects the USDC leg (`test_withdraw_rejects_slippage`). She entered with $900, rode NVIDIA up, paid Maria 1% by dilution, and left with about $950 of assets. Bob still holds 450 shares, unrealized. Maria holds 13.5 shares, about $14, for managing the fund. Victor took no fee on any of it. What he earned is what his platform sells: a track record of funds whose managers cannot take the assets. @@ -72,7 +72,7 @@ She entered with $900, rode NVIDIA up, paid Maria 1% by dilution, and left with ## The Math: One Valuation Loop, Floors Toward the Fund -Every formula in the program is a proportion, and they all round the same way. Shares minted: deposit × supply ÷ NAV, floored. Withdrawal legs: vault balance × shares ÷ supply, floored per asset. Fee shares: supply × rate × time over a year, floored. Deployment per leg: deposit × weight ÷ 10,000, floored. All u128 intermediates, all multiply-before-divide, remainders staying with the fund rather than the transactor. The one multiplication that is not a proportion is the valuation itself, amount × price at the feed's 10^8 precision, and the fund's integrity depends on that line running over every asset, every time, with a fresh price. +Every formula in the program is a proportion, and they all round the same way. Shares minted: deposit × supply ÷ NAV, floored. Withdrawal legs: recorded holding × shares ÷ supply, floored per asset, with each payout subtracted from the record. Fee shares: supply × rate × time over a year, floored. Deployment per leg: deposit × weight ÷ 10,000, floored. All u128 intermediates, all multiply-before-divide, remainders staying with the fund rather than the transactor. The one multiplication that is not a proportion is the valuation itself, amount × price at the feed's 10^8 precision, and the fund's integrity depends on that line running over every asset, every time, with a fresh price. ## The Invariants: Custody Never Moves @@ -81,17 +81,19 @@ Every formula in the program is a proportion, and they all round the same way. S - NAV computations include every asset index or refuse to run. - Share supply changes only through deposit, withdrawal, and the time-based fee, and `total_shares` moves before the corresponding CPIs. - Every swap leg, deposit or rebalance, clears at no worse than the oracle price minus the configured tolerance, through the router fixed at creation. +- Every share price and every payout comes from the recorded holdings, never from a vault's token balance. The recorded holdings never exceed the balances, and they equal them unless someone has transferred tokens in directly. ## The Failure Modes: The Manager, the Feed, and the First Deposit - **The manager is the threat model**, and the program treats them as such: assets only from Victor's list, feeds only from Victor's entries, swaps only through the fixed router, minimums only from the oracle, fee capped and immutable, custody never theirs. What remains to a careless manager is churn: pointless rebalancing that pays market slippage. That hurts depositors, but it does not enrich the manager, which is the design's limit. Aligning *that* incentive is what the fee-on-assets structure is for. - **Every number in the fund depends on the feed.** A stale price is refused (`StalePriceFeed`, 60-second window), as is a non-positive price (`NegativePrice`) and a substituted feed account (`InvalidPriceFeed`), so no deposit, withdrawal, or rebalance runs on a bad price. What this example does not check is the feed's confidence interval; a production strategy should, and the *Offchain Truth* chapter explains what confidence means and why "fresh but uncertain" is its own failure mode. -- **The first deposit has no dead-shares guard**: the empty-fund deposit mints one-to-one, with no locked minimum like the AMM's `MINIMUM_LIQUIDITY`. The design leans on two mitigations: deposits only open at full allocation, and every deposit deploys immediately, so there is no idle pot to donate into and skew. That narrows the classic inflation attack without formally closing it; a production strategy should add virtual shares or a dead-shares floor, and the empty-pool section of the *Adversary* chapter shows how. +- **Donations are ignored, which closes the inflation attack.** Anyone can transfer tokens straight into a vault. A program that valued the fund from its vaults' balances would let a one-minor-unit first deposit and a donation price one share above the next deposit, rounding it to zero shares. This program values the fund from `usdc_holdings` and `asset_holdings`, so a donation changes a balance and nothing the handlers read. Run the *Adversary* chapter's numbers against it: after the attacker's one minor unit and 1,000 USDC donation, the next depositor's 1,000 USDC still buys 1,000 shares. The donation stays in the vault, and no withdrawal ever pays it out. `rebalance` cannot sell or spend donated tokens either (`InsufficientHoldings`). +- **A deposit too small to buy anything is refused.** Ignoring donations leaves one gap: a deposit so small that a swap takes its USDC and returns none of the asset. It would mint shares against a fund recorded as worth nothing, and every later deposit would then divide by zero, so it fails with `DepositTooSmall`. The `minimum_shares` argument remains for what neither guard covers, a share price that moved between signing and landing. The empty-pool section of the *Adversary* chapter compares this defense with the others. - **Partial baskets are refused**, on deposit, withdrawal, and valuation alike (`IncompleteAssetAccounts`): an attacker who could get the fund valued without its best-performing vault could buy shares at a discount. ## Testing Considerations -The suite (with hand-built Pyth price-update accounts injected so prices are deterministic) covers the full lifecycle end to end (`test_full_lifecycle`). It then checks each rule separately. Under-allocated strategies refuse deposits, unapproved assets refuse listing, weight overflows refuse, the 17th asset refuses, and non-managers cannot set weights. Unregistered routers refuse, slippage floors reject on deposit and withdrawal, incomplete account lists refuse, and later depositors pay fair NAV. The pattern to copy is the price injection: valuation code cannot be tested with prices you cannot control, so the harness makes the feed a fixture and then moves it deliberately. +The suite (with hand-built Pyth price-update accounts injected so prices are deterministic) covers the full lifecycle end to end (`test_full_lifecycle`). It then checks each rule separately. Under-allocated strategies refuse deposits, unapproved assets refuse listing, weight overflows refuse, the 17th asset refuses, and non-managers cannot set weights. Unregistered routers refuse, slippage floors reject on deposit and withdrawal, incomplete account lists refuse, and later depositors pay fair NAV. `test_full_lifecycle` checks after every step that the recorded holdings equal the vaults' balances. `test_donation_does_not_inflate_share_price` runs the *Adversary* chapter's attack and checks that the victim gets exactly the shares they would have got without the donation. `test_deposit_rejects_leg_that_buys_nothing` and `test_rebalance_cannot_spend_donated_usdc` check the other two guards. The pattern to copy is the price injection: valuation code cannot be tested with prices you cannot control, so the harness makes the feed a fixture and then moves it deliberately. ## What You Learnt from Building the Vault Strategy @@ -102,7 +104,7 @@ The vault added five concepts: - **The manager as the threat model.** The invariant every program in this book keeps: no instruction handler, under any parameters, moves user deposits to the operator. Rebalancing moves tokens between program-owned vaults; the fee path mints shares and touches no vault. - **A curator separate from a manager.** Victor's approved-asset set binds each asset to its official feed, so a manager can neither list their own token nor pair a real asset with a feed they control. - **Oracle-anchored minimums.** When an untrusted party routes the trade, the acceptable outcome must be computed from a price source they do not control. -- **NAV as the share price.** Value every vault with a fresh feed or refuse to run; a valuation that can skip an asset is a discount for whoever exploits it. +- **NAV as the share price, from the program's own records.** Value every holding with a fresh feed or refuse to run; a valuation that can skip an asset is a discount for whoever exploits it. A valuation read off token balances can be moved by anyone who transfers tokens in, so value what the program recorded. - **Fees by dilution, snapshotted.** The fee mints shares against everyone proportionally, accrues by time rather than the manager's diligence, and has no setter, so the deal investors joined under cannot be worsened. In the *Options* chapter we will build a program that takes no credit risk at all. A writer posts the whole of what they might owe before the option is sold, so there is no margin, no liquidator, and no oracle, and you will see how much simpler the program is for it. The *Perpetual Futures* chapter then closes the book with a program that needs all of it at once: vaults, shares, oracles, funding, margin, and liquidation. diff --git a/financial-programming-concepts-in-solana/figures/inflation-attack.svg b/financial-programming-concepts-in-solana/figures/inflation-attack.svg new file mode 100644 index 00000000..e47a4425 --- /dev/null +++ b/financial-programming-concepts-in-solana/figures/inflation-attack.svg @@ -0,0 +1,31 @@ + + An attacker makes Bob's deposit buy no shares + + + 1. The attacker deposits 0.000001 USDC through the deposit handler + + 0.000001 USDC in the vault, one minor unit, not to scale + 1 share outstanding, the attacker's, worth 0.000001 USDC + + + 2. The attacker sends 1,000 USDC straight to the vault + + the attacker's donation + 1,000.000001 USDC in the vault + 1 share outstanding, still the attacker's, now worth 1,000.000001 USDC + + + 3. Bob deposits 1,000 USDC through the deposit handler + + the attacker's 1,000.000001 USDC + + Bob's 1,000 USDC + 2,000.000001 USDC in the vault + + the attacker's one share redeems for all 2,000.000001 USDC + Bob's shares = floor(deposit × shares outstanding ÷ pool value) = floor(1,000 × 1 ÷ 1,000.000001) = 0 + The attacker put in 1,000.000001 USDC and can take out 2,000.000001 USDC: Bob's 1,000 is theirs + + the handler prices shares off the vault's token balance, so a donation counts as value; + the share supply moves only through the handler, so the donation left it at one + diff --git a/financial-programming-concepts-in-solana/the-adversary.md b/financial-programming-concepts-in-solana/the-adversary.md index 3e8a1714..1036f4a2 100644 --- a/financial-programming-concepts-in-solana/the-adversary.md +++ b/financial-programming-concepts-in-solana/the-adversary.md @@ -1,77 +1,104 @@ # Chapter 9: The Adversary -The same half-dozen attack classes have drained onchain markets for a decade, on every chain and in every framework, and they will drain the next decade's markets too. +The same half-dozen kinds of attack have drained onchain markets for a decade, on every chain and in every framework. -## Every Mistake Is a Bounty, Claimable by Anyone +## Every Mistake Is Money Anyone Can Take -Deploying financial software on a public chain means accepting a threat model no other kind of software faces. Every instruction handler is callable by anyone on earth, pseudonymously, for a fraction of a cent. Your source, or at least the compiled program, is public. And every mistake is a bounty: a mispriced trade, a leaked minor unit, or an unchecked account is money, claimable by the first person to notice. +Financial software on a public chain faces a harsher threat model than most software. Every instruction handler can be called by anyone, under any name, for a fraction of a cent. Your compiled program is public, and often your source is too. A mispriced trade, a leaked minor unit, or an unchecked account is money, and it goes to whoever finds it first. Three consequences follow: -- **Attacks are industrialized.** A flaw worth a cent per call meets a loop that calls it a million times. Nothing is too small to steal; judge every leak by what it pays at full throughput. -- **Attackers see your transaction before it lands.** Ordering on a public chain is contestable: a pending swap can be sandwiched (front-run to move the price against it, then back-run to profit), and any profitable pending action can be copied and raced. This is MEV, extractable value from ordering, and it is why every trade in this book carries a caller-side bound: minimum output, maximum slippage. The program cannot promise the state it will execute against; it can only refuse to execute against a state worse than the user accepted. -- **The boundary values are where the money is.** Zero amounts, empty pools, dust quantities, exact thresholds: attacks target the edges of your ranges, because that is where comparisons flip and remainders round. +- **Attacks are automated.** A flaw worth a cent per call is worth $10,000 to a script that calls it a million times. Judge every leak by what it pays when called as fast as the network allows. +- **Attackers see your transaction before it lands.** Anyone can try to get their transaction ordered before or after yours. A pending swap can be sandwiched: the attacker buys first to push the price against it, then sells after it. Any other profitable pending action can be copied and sent ahead of the original. The profit taken this way is called MEV, maximal extractable value. It is why every trade in this book carries a caller-side bound: a minimum output, or a maximum slippage. The program cannot know the state it will run against; it can only refuse to run against a state worse than the user accepted. +- **Attacks start at the extremes.** Zero amounts, empty pools, dust quantities, and exact thresholds are where comparisons flip and remainders round. Every attack below starts from one of them. ## Validation: Signers, Owners, and the Confused Deputy -The most exploited bug class in Solana's history is a program trusting an account it should have checked. Callers supply the accounts an instruction runs against, so *every* account is a claim, and validation is the act of checking each claim against something the program already trusts: a signature, an owner, a stored address, a derivation. +The bug exploited most often on Solana is a program trusting an account it should have checked. The caller chooses which accounts an instruction runs against, so each account is only a claim until the program checks it against something it already trusts: a signature, an owner, a stored address, or a derivation. -The full checklist is short: +There are four checks: - Is the account that authorizes this action a **signer**? - Is each account **owned** by the program that should own it, so its data can be believed? - Does each account **match the one recorded** when the relationship was created? This is the **stored-address check**: the vault recorded on the market, the maker recorded on the offer.[^stored-address-per-framework] - Is each PDA **derived** from the seeds it claims, with its stored bump? -Anchor's constraints express all four declaratively, and the program chapters use them on every account of every instruction. +Anchor's account constraints perform all four, and the program chapters use them on every account of every instruction. -The attack that punishes a missing check is **substitution**, and its classic form has a name from the 1988 security literature: the **confused deputy**, an entity with authority tricked into using it on an attacker's behalf.[^confused-deputy] Your program is the deputy: it holds the vault keys, and the attacker's goal is to make it sign a transfer while looking at the wrong accounts. The attacker passes their own account where the payment destination belongs, or passes a lookalike data account they fabricated, full of numbers the program will trust. Or they pass the fee vault where the user vault belongs: the two hold the same mint under the same authority, and only a stored-address check tells them apart. That is why the order book checks every vault it is handed against the addresses its market recorded. +A missing check lets the attacker **substitute** an account: pass a different account from the one the program expects. The classic form was named in 1988 as the **confused deputy**, a program with authority that is tricked into using it for someone else.[^confused-deputy] Your program is the deputy. It can sign for its vaults, and the attacker wants it to sign a transfer while reading the wrong accounts. The attacker passes their own account where the payment destination belongs. Or they pass a data account they created themselves, with the same layout as the real one and numbers of their choosing. Or they pass the fee vault where the user vault belongs: the two hold the same token under the same authority, and only a stored-address check tells them apart. That is why the order book checks every vault it is handed against the addresses its market recorded. -> **War Story: Cashio, $48M, 2022.** Cashio's stablecoin was minted against deposited collateral, and the mint instruction handler validated its accounts in a chain: this account is trusted because that one vouches for it. One link, the collateral token's mint data, went unvalidated: the "root of trust" account was never checked against anything the program knew. An attacker fabricated the whole chain, ending it in a worthless token, deposited nothing of value, and minted two billion CASH against it, keeping about $48M after refunding the accounts under $100,000.[^cashio] Every link in an account chain must end in something the program itself derived or recorded. +> **War Story: Cashio, $48M, 2022.** Cashio's stablecoin was minted against deposited collateral. The mint instruction handler validated its accounts as a chain, trusting each account because the one before it named it. One link, the collateral token's mint, was never checked against anything the program already knew. An attacker created a fake account for every link, ending the chain in a worthless token, and minted two billion CASH against collateral of no value. They kept about $48M after refunding the accounts under $100,000.[^cashio] Every chain of accounts has to start from an account the program derived or recorded itself. -> **War Story: Wormhole, $326M, 2022.** The Wormhole bridge accepted a "verify signatures" result account as proof that guardians had approved a message. The instruction handler that created such accounts was meant to be the only source, but the check that the sysvar account passed to verification was the *real* sysvar used a deprecated, spoofable pattern. The attacker fabricated the verification account, presented it as proof, and minted 120,000 wrapped ETH backed by nothing.[^wormhole] A proof must be checked by the program consuming it, against accounts it validated itself. +> **War Story: Wormhole, $326M, 2022.** The Wormhole bridge accepted a "verify signatures" result account as proof that its guardians had approved a message. One handler created those accounts, and it was meant to be the only source. To check that the Instructions sysvar it was given was the real one, that handler called a deprecated function that never checked the account's address. The attacker passed a fake sysvar, got a verification account created from it, and presented that account as proof. They minted 120,000 wrapped ETH backed by nothing.[^wormhole] A program has to check a proof itself, against accounts it validated itself. ## The Empty Pool: Inflation and Donation Attacks -Every share-based pool (vault, LP pool, lending reserve) prices shares as pool value divided by share supply, and that formula has a degenerate zone: the beginning, when both numbers are tiny. The **inflation attack** exploits it. The attacker makes the first deposit, minuscule, receiving a minuscule share supply. Then they *donate* assets directly into the pool's vault, without going through deposit, inflating the value behind each of their few shares. The next depositor's shares are computed as deposit × supply ÷ value, and with value enormous relative to supply, the division floors to zero, or nearly: the victim's deposit buys nothing, and the attacker's shares absorb it. +Every share-based pool (vault, LP pool, lending reserve) prices a share as the pool's value divided by the number of shares. That formula breaks down at the start, when both numbers are tiny, and the **inflation attack** exploits it. The attacker makes a tiny first deposit and receives a tiny number of shares. Then they *donate* assets straight into the pool's vault with an ordinary token transfer, without calling the deposit handler. The donation raises the value behind each of their few shares. The next depositor's shares are computed as deposit × shares outstanding ÷ pool value, floored. With the pool value enormous compared with the shares outstanding, that division floors to zero or close to it: the victim's deposit buys nothing, and it now belongs to the attacker's shares. -Each defense targets that zone. **Lock a minimum liquidity** that is never minted to anyone, as the AMM does with its floor of 100, so share supply can never be dust-sized relative to the pool. **Use virtual shares and virtual assets**: add a constant to both sides of the exchange-rate division, so the division still behaves when the real supply is zero. **Make donations inert**: track pool size in program state (the lending market's `available_liquidity`) rather than reading the vault's token balance, so tokens pushed into the vault sideways do not count. And **require a meaningful first deposit**. One of these, deliberately chosen, is enough; the programs to worry about are the ones that rely on circumstance instead of a named defense, and the book's own vault strategy is one of them. +Take a pool that prices its shares from the token balance in its vault, as the book's vault strategy did before it chose a defense. The first deposit into an empty pool mints one share per minor unit. Every later deposit mints shares in proportion to the value it adds, so a deposit that doubles the pool's value doubles the share supply: + +shares minted = floor(deposit × shares outstanding ÷ pool value) + +The pool's value here is the USDC in its vault. USDC has six decimals, so a minor unit is a millionth of a dollar. Bob is about to make the first real deposit into a new pool, 1,000 USDC, and the attacker sees his transaction before it lands. + +1. **The attacker deposits one minor unit**, 0.000001 USDC, through the deposit handler. The pool is empty, so the first deposit mints one-to-one: one share, against a vault holding one minor unit. +2. **The attacker sends 1,000 USDC straight to the vault** with an ordinary token transfer. Anyone can transfer tokens to any token account, and the deposit handler never ran, so the share supply is still one. The vault now holds 1,000.000001 USDC, and the one share is priced at all of it. +3. **Bob's deposit lands.** The formula takes his 1,000 USDC deposit, times the one share outstanding, divided by the pool's value of 1,000.000001 USDC: floor(1,000 × 1 ÷ 1,000.000001) = floor(0.999999999) = 0. The handler mints him nothing, moves his 1,000 USDC into the vault, and returns success. The vault holds 2,000.000001 USDC against one share, and that share is the attacker's. They put in 1,000.000001 USDC and can now withdraw 2,000.000001: all of Bob's deposit is theirs. + +![No check failed at any step: the share formula floored Bob's 0.999999999 shares to zero, and the vault's one share is the attacker's.](figures/inflation-attack.svg) + +The attack pays exactly what Bob deposited. A larger deposit loses less, but still loses. Had Bob deposited 1,500 USDC, he would have received floor(1,500 × 1 ÷ 1,000.000001) = floor(1.4999) = 1 share. The two shares would then split 2,500.000001 USDC, and Bob's 1,500 would have become 1,250. The fraction of a share that the floor drops stays in the vault, so it adds to the value of the shares that already exist. The attacker can leave the pool in this state and collect that fraction from every later depositor. Taking a whole deposit needs a donation at least as large as the deposit, so the usual form of the attack is front-running: the attacker sees a deposit pending, sizes the donation to match it, and gets both of their transactions ordered ahead of it. + +Bob can protect his own deposit with a minimum-output bound. The vault strategy's deposit handler takes a `minimum_shares` argument and rejects with `SlippageTooHigh` when it would mint fewer shares. That protects only the depositors whose wallet or app sets the argument. The vault strategy keeps it, for a share price that moves between signing and landing, but does not rely on it. The defenses below are in the program, so they protect every depositor, and each one can be checked against the same numbers: + +- **Lock some shares that no one owns.** On the first deposit, the program adds a fixed number of shares to the supply without minting them to anyone, so the supply never starts at dust. Say 100 shares are locked, the size of the AMM's `MINIMUM_LIQUIDITY` floor, and every formula counts them in the supply it divides by. The attacker's smallest position is then one share out of 101. Pricing each share at 1,000 USDC takes a donation of about 101,000 USDC, and 100 of every 101 units of it belong to shares nobody can withdraw. The attacker spends 101,000 USDC to zero out Bob's 1,000, then can withdraw only about 1,010 USDC. +- **Add virtual shares and virtual value to the formula.** The program adds a fixed number of shares to the supply and a fixed amount to the pool's value, but only inside the division, so it works normally even when no real shares exist. Say a million virtual shares and one virtual minor unit. The attacker's one-minor-unit deposit now buys a million shares, and the donation is split between their million and the virtual million. Bob's 1,000 USDC buys 1,999,999 shares, which redeem for 999.99975 USDC. The attacker's shares redeem for about 500 USDC, and the other half of the donation belongs to the virtual shares, which nobody can withdraw. +- **Ignore donations.** The program records the pool's size in its own state, as the lending market's `available_liquidity` and the vault strategy's recorded holdings do, rather than reading the vault's token balance. Step 2 then changes the vault's balance and nothing the handler reads. The pool is still one minor unit against one share, and Bob's deposit mints a share per minor unit as it should. The donated 1,000 USDC sits in the vault outside the program's accounting, lost to the attacker, and the lending example tests this with `raw_token_donation_does_not_inflate_exchange_rate`. The vault strategy's `test_donation_does_not_inflate_share_price` runs these exact numbers. +- **Require a large first deposit.** If the first deposit must be at least 100 USDC, the attacker's smallest position is 100,000,000 shares. Rounding Bob's 1,000 USDC down to nothing would then take a share price above 1,000 USDC, a vault holding a hundred billion dollars. + +A program needs one of these four, chosen on purpose. The book's programs each name theirs: the AMM and the perpetual futures pool lock shares, and the lending market and the vault strategy ignore donations. ## A True Price Can Still Be a Manipulated Price -Everything in the *Offchain Truth* chapter assumed the fact was true, and its checks (publisher, freshness, confidence) defend against *fake* facts. This class is different: the fact is real, fresh, and correctly verified, and it is an accurate report of a market the attacker just manipulated. Push a thin market's price up for one slot, and every program consuming that price acts faithfully on it: lending markets extend credit against inflated collateral, exchanges settle at fantasy prices, liquidations fire on positions that were healthy a block ago. +The checks in the *Offchain Truth* chapter (publisher, freshness, confidence) reject a price that is fake or stale. This attack gets past all of them. The price is real, fresh, and correctly signed, and it accurately reports a market the attacker has just pushed. Push a thin market's price up for one slot, and every program that reads it acts on it. Lending markets extend credit against collateral that looks more valuable than it is. Exchanges settle at a price nobody could trade at a minute later. Liquidations fire on positions that were healthy one slot before. + +> **War Story: Mango Markets, $116M, 2022.** An attacker opened large perpetual positions in Mango's own thinly traded MNGO token. They then bought MNGO on the spot markets the oracle read from, pushing its price up more than thirteen-fold in about half an hour. The oracle reported the pushed price accurately. Mango's program used it to mark the attacker's positions as hugely profitable, and the attacker borrowed $116M of the platform's deposits against that unrealized profit and left.[^mango] The attacker described it as "a highly profitable trading strategy." No signature was forged and no account was substituted. -> **War Story: Mango Markets, $116M, 2022.** An attacker opened large perpetual positions in Mango's own thinly-traded MNGO token, then bought MNGO aggressively on the spot markets the oracle read from, moving the price up more than thirteen-fold in about half an hour. The oracle reported the manipulated price accurately. Mango's risk engine, believing it, marked the attacker's positions massively profitable, and the attacker borrowed $116M of the platform's deposits against that unrealized profit and left.[^mango] The attacker described it as "a highly profitable trading strategy," and in the program's terms they were not wrong: no signature was forged and no account spoofed. The input was true, and the market behind it had been manipulated. +The defenses make manipulation expensive and slow: -The defenses are about making price manipulation expensive and slow. **Time-weight the price**: a **time-weighted average price** (TWAP) over minutes forces the attacker to hold the manipulation, at cost, against arbitrage, instead of renting one block of it. **Respect the confidence interval**: a manipulated thin market shows its stress as widened confidence, and a consumer that degrades (pausing borrows, tightening limits) when confidence widens closes the profitable window. **Bound per-block state change**: caps on how much can be borrowed or withdrawn per window turn a one-shot drain into a multi-block campaign that defenders can see. Finally, **do not build leverage on a market thinner than the credit it collateralizes.** If the cost of moving the price is less than what the move unlocks, that trade will eventually be made. +- **Time-weight the price.** A **time-weighted average price** (TWAP) over several minutes forces the attacker to hold the pushed price for that long, paying arbitrageurs the whole time, instead of paying for one slot of it. +- **Act on the confidence interval.** When a thin market is being pushed, its oracle's confidence interval widens. A program that pauses borrowing or tightens limits when that happens takes away the profit. +- **Cap how much can move per window.** A limit on how much can be borrowed or withdrawn in each window forces the attacker to take the money over many slots, where people can see it happening and respond. +- **Do not lend more against a token than it would cost to move its market.** If pushing the price costs less than the loan the push unlocks, someone will eventually do it. -## A Rounding Error Becomes a Drain Once Someone Finds the Loop +## A Rounding Error Repeated Enough Times Drains the Pool -The *Arithmetic of Money* chapter set the policy: round against the user, floor once, ceil debt. This class is what happens to programs that did not. Each favorable rounding is under a cent. The exploit is the loop, and the loop works best in an **empty or near-empty market**, where the attacker controls both sides of the ratio and can shape the divisor to maximize what flooring forgives. +The *Arithmetic of Money* chapter set the rounding rules: round against the user, floor once, and round debt up. This attack targets programs that do not follow them. Each rounding in the attacker's favor is worth less than a cent, so the attack repeats it thousands of times. It works best in an **empty or near-empty market**, where the attacker controls both numbers in the ratio and can choose the divisor that makes flooring give away the most. -> **War Story: the Hundred and Sonne drains, $7.4M and $20M.** Hundred Finance (2023) and Sonne Finance (2024), both forks of the same lending design on EVM chains, fell to the same sequence. Find or create a freshly-listed, empty market; make a tiny deposit to mint dust-level shares; donate a large sum to skew the exchange rate (the inflation setup). Then exploit the rate calculation's rounding direction, redeeming shares sized so the floored share-burn undercounted by one, repeatedly, until the pool drained. Two lending markets, a year apart, with the first post-mortem public the whole time:[^hundred-sonne] exploit classes recur because the underlying arithmetic ships again in every fork. +> **War Story: the Hundred and Sonne drains, $7.4M and $20M.** Hundred Finance (2023) and Sonne Finance (2024) were both copies of the same lending design on EVM chains, and attackers drained both the same way. The attacker found or created a newly listed, empty market. They made a tiny deposit to mint a few shares, then donated a large sum to skew the exchange rate: the first two steps of the inflation attack. Then they redeemed shares in amounts chosen so the floored number of shares burned came out one short, again and again, until the pool was empty. An attacker drained Sonne a year after Hundred's post-mortem was published,[^hundred-sonne] because a copy of a program copies its arithmetic along with everything else. -The defense is the *Arithmetic of Money* chapter applied without exception, plus one test-suite habit: a **round-trip invariant test**. Deposit then immediately withdraw, swap in then swap back, at adversarial sizes (one minor unit, pool-sized, prime-numbered), and assert the caller never ends with more than they started. The lending example's `deposit_redeem_round_trip_creates_no_value` is eleven lines, and it is the test that would have failed on both programs above. +The defense is to follow the rounding rules in the *Arithmetic of Money* chapter everywhere, and to write one kind of test: a **round-trip invariant test**. Deposit then immediately withdraw, or swap in then swap back, at hostile sizes (one minor unit, the whole pool, a prime number), and assert the caller never ends with more than they started with. The lending example's `deposit_redeem_round_trip_creates_no_value` is eleven lines, and it would have failed on both programs above. ## Ordering and Composability: CPI Trust Boundaries -A transaction can invoke your program, which invokes another, which invokes another. Programs calling programs is what the flash loan in the *Core Building Blocks of Solana* chapter was built from. Every such composition is also a trust boundary: mid-transaction, between your instructions, or inside a CPI you make, code you did not write gets to run. +A transaction can call your program, which calls another, which calls another. The flash loan in the *Core Building Blocks of Solana* chapter is built from programs calling programs. Each call is also a point where code you did not write runs: between your instructions in the same transaction, or inside a CPI your program makes. -The rule is **checks-effects-interactions**: validate everything, then update your own state, and only then call out to other programs or move tokens. The order book's settlement zeroes a user's claimed balance *before* the transfer, so even if the token transfer could re-enter (token extensions can attach hooks to transfers), a re-entrant call finds the balance already spent. Solana's runtime forbids re-entering a program through another program, the pattern behind many EVM exploits; only direct self-recursion is allowed, and its depth is capped. The ordering is still worth keeping, because it also defends against what you cannot see coming: a callee that is upgraded tomorrow, a hook added to a mint next year, an instruction sequenced after yours in the same transaction. +The rule is **checks-effects-interactions**: validate everything, then update your own state, and only then call other programs or move tokens. The order book's settlement zeroes a user's claimed balance *before* the transfer. If the token transfer could call back into the program (token extensions can attach hooks to transfers), the second call would find the balance already spent. Solana's runtime forbids a program from being re-entered through another program. Only a program calling itself directly is allowed, and its depth is capped. Keep the order anyway, because it also protects against changes you cannot see yet: a callee upgraded tomorrow, a hook added to a mint next year, an instruction placed after yours in the same transaction. -Two more rules apply at the boundary: +Two more rules for these boundaries: -- **Validate what a CPI returns and what it was given.** Calling a program does not make the accounts it touched trustworthy; the vault strategy computes each swap's minimum output from the oracle because the router it calls is outside its trust boundary. -- **Assume hostile sequencing.** Any invariant your program needs must hold at the end of *your* instruction, not at the end of the transaction, because the instructions after yours belong to someone else. +- **Validate what a CPI returns and what it was given.** Calling a program does not make the accounts it touched trustworthy. The vault strategy computes each swap's minimum output from the oracle, because the router it calls is outside its control. +- **Assume the instructions after yours are hostile.** Any invariant your program needs must hold at the end of *your* instruction, not at the end of the transaction, because the instructions after yours belong to someone else. -The postcondition asserts from the *Arithmetic of Money* chapter (re-check K after the transfers land) are the same rule: your state, checked, before anyone else's code runs. +The *Arithmetic of Money* chapter's postcondition checks, such as re-checking K after the transfers land, follow the same rule: check your own state before anyone else's code runs. ## What You Learnt -- Every mistake on a public chain is a bounty: attacks are industrialized, ordering is contestable, and the boundary values are where the money is. -- Every supplied account is a claim to verify, and every trust chain must terminate in something the program itself derived or recorded. -- Empty pools need a named defense against inflation attacks, and a rounding leak becomes a drain once someone finds the loop. -- A true price can still be a manipulated price: time-weight it, respect the confidence interval, bound per-block change, and never build leverage on a market thinner than the credit it collateralizes. +- Every mistake on a public chain is money anyone can take: attacks are automated, anyone can get their transaction ordered around yours, and attacks start at the extremes of a range. +- Every supplied account is a claim to verify, and every chain of accounts must start from an account the program itself derived or recorded. +- A share-based pool needs a defense against the inflation attack built into the program: locked shares, virtual shares, a pool size kept in program state, or a large minimum first deposit. +- A true price can still be a manipulated price: time-weight it, act on the confidence interval, cap how much can move per window, and never lend more against a token than it would cost to move its market. +- A rounding error in the user's favor gets repeated until the pool is empty, and a round-trip invariant test catches it. - Checks-effects-interactions at every boundary: your invariants must hold at the end of your own instruction, because the instructions after yours belong to someone else. [^confused-deputy]: Norm Hardy named it in "The Confused Deputy (or why capabilities might have been invented)", ACM SIGOPS Operating Systems Review 22(4), October 1988, at [dl.acm.org/doi/10.1145/54289.871709](https://dl.acm.org/doi/10.1145/54289.871709).