
Eth To Sol
- 2 installs
- 2 repo stars
- Updated June 19, 2026
- solana-foundation/eth-to-sol-skill
Translates Ethereum/Solidity contracts to Solana programs in two passes: a faithful Anchor port, then a Solana-native refactor with a teaching diff.
About
Translates Solidity contracts into production-grade Solana programs via a two-pass protocol: a faithful Anchor port, then a Solana-native refactor, with a structured diff and explanation log. A developer who knows Solidity uses it to learn Solana-native patterns while porting.
- Two-pass protocol: faithful port then Solana-native refactor
- Emits diff and explanation artifacts as teaching deliverables
Eth To Sol by the numbers
- 2 all-time installs (skills.sh)
- Ranked #408 of 479 Web3 & Blockchain skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/solana-foundation/eth-to-sol-skill --skill eth-to-solAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 2 |
| Last updated | June 19, 2026 |
| Repository | solana-foundation/eth-to-sol-skill ↗ |
What it does
Translates Ethereum/Solidity contracts to Solana programs in two passes: a faithful Anchor port, then a Solana-native refactor with a teaching diff.
Files
eth-to-sol
Translate Ethereum/Solidity contracts to production-grade Solana programs. The goal is not a 1:1 port — it is Solana-native code plus a teaching artifact that makes every decision legible to a developer who knows Solidity well and Solana barely.
Two-pass protocol (hard rule)
Every translation produces two outputs in sequence. Do not collapse them.
1. Pass 1 — Faithful port. A semantically identical Anchor program. No restructuring, no SPL CPI substitutions, no parallelism rework. It exists so the refactor's value is legible. Mark obviously un-Solana patterns with // SMELL: comments rather than fixing them. 2. Pass 2 — Solana-native refactor. Restructured for Solana primitives: SPL programs via CPI, per-entity PDAs, parallelism-friendly account layout, explicit rent/sizing, compute-budget awareness, program splitting where warranted. Production-ready.
If a contract is trivially served by an existing Solana program (e.g. a vanilla ERC-20), the optimized version will be drastically smaller than the naive port. That is the lesson.
Output contract
For an input named foo, produce exactly these artifacts:
| File | Contents |
|---|---|
01-original.<ext> | The input (Solidity, Vyper, etc.). Already present; do not rewrite. |
02-naive-port.rs | Pass 1. Compiles. Inline // SMELL: markers on antipatterns. |
03-optimized.rs | Pass 2. Production-ready, fully commented at non-obvious sites. |
04-diff.md | Structured diff. Group sections by theme (State model / Parallelism / Security / CPI & program reuse / Compute & rent / Idioms) — mirror the explanation log. Each section: short header, before/after snippets, file:line references to the two .rs files. |
05-explanation.md | The explanation log. One entry per change in 04-diff.md, grouped by theme. Schema below. |
When the optimized version meaningfully changes client-side integration (typically: SPL Token replaces a custom token surface, or balance/aggregate lookups move off the program), append a ## Frontend integration section at the bottom of 05-explanation.md containing: before/after TypeScript using @solana/web3.js + @solana/spl-token; the list of changed call sites a porting team will touch; migration scoping. If the integration shift is minor, fold it into a relevant entry's Tradeoff instead — don't bloat the file.
05-explanation.md is the teaching surface. Treat it as a first-class deliverable, not a comment block.
Read first
Before producing any translation, internalize the EVM → SVM mental shift in translation/mental-model.md. The one-line summary: on Ethereum the contract knows where its state lives; on Solana the caller brings it. Every translation rule below is a consequence — if a step ever feels wrong, return to that file.
Decision tree — which sub-files to load
Default-load: translation/mental-model.md, translation/type-mapping.md, translation/pattern-mapping.md, security/arithmetic.md, security/account-validation.md, security/pda-canonicalization.md.
The default loads are non-negotiable. The mental-model file frames every other decision; arithmetic, account validation, and PDA canonicalization are the three security classes that bite every ported contract.
| Source contains | Also load |
|---|---|
| ERC-20 / fungible token | translation/stdlib-mapping.md, optimization/account-model.md, security/cpi-safety.md |
ERC-20 with _update / _beforeTokenTransfer override (fee-on-transfer, blacklist, paused-transfer, rebasing) | translation/stdlib-mapping.md (Token-2022 section); target Token-2022 with the matching extension (transfer fee, transfer hook, default account state, interest-bearing). Do not target classic SPL — the semantics cannot be expressed. |
| ERC-721 / ERC-1155 / NFT | translation/stdlib-mapping.md, optimization/account-model.md, optimization/pdas.md |
mapping(...) storage | optimization/account-model.md, optimization/pdas.md, optimization/parallelism.md |
| Ownable / AccessControl / roles | translation/stdlib-mapping.md, security/signer-checks.md |
| Custom modifiers | translation/pattern-mapping.md, security/signer-checks.md |
| External calls / interfaces | security/cpi-safety.md, security/reentrancy.md, optimization/program-splitting.md |
| Heavy arithmetic / fixed-point | security/arithmetic.md (also default-loaded) |
Hot-write global state (counters, totalSupply) | optimization/parallelism.md, optimization/account-model.md |
| Dynamic-sized state (arrays, mappings of unknown size) | optimization/rent-and-size.md, optimization/account-model.md |
| Multi-contract system | optimization/program-splitting.md, security/cpi-safety.md |
| Anything writing state after an external call | security/reentrancy.md, security/cpi-safety.md |
| Compute-pressured paths (multi-CPI swaps, loops in hot path, >300 expected CU per call) | optimization/compute-budget.md |
| Multiple account types owned by the program (type-confusion risk surface) | security/account-validation.md (also default-loaded) |
| Any PDA the program will sign for | security/pda-canonicalization.md (also default-loaded), optimization/pdas.md |
| Protocol takes a user-supplied token Mint as configuration (vault, AMM, lending market) | security/reentrancy.md, security/account-validation.md, security/cpi-safety.md |
| Vault/AMM/4626-shaped protocol (share/asset conversion math, deposit + withdraw + redeem semantics) | security/arithmetic.md (rounding-direction discipline), optimization/account-model.md (read aggregates from SPL Token), optimization/parallelism.md (read-only vault pattern) |
Time-delta math (now - last_update, accumulator periods) | security/arithmetic.md (clock-skew + negative-delta-cast pitfall) |
Always load every security/* file relevant to the constructs present. Security is non-negotiable.
Pre-flight checklist (gate on the optimized version)
Every item must hold before emitting 03-optimized.rs. If one fails, fix and re-check.
- [ ] Every arithmetic op is
checked_*— or has an inline justification forsaturating_*/wrapping_*. No bare+ - * /on user-controlled values. - [ ] Every
Account<'info, T>either uses Anchor's typed checks or includes explicit owner + discriminator validation. NoAccountInfosmuggled through without checks. - [ ] Every signer-required path uses
Signer<'info>or a manualis_signercheck. No "the front end won't call it without a signer" reasoning. - [ ] Every PDA derivation either uses
seeds = [...], bump = stored_bump(preferred — saves ~1.5k CU per call) or bareseeds = [...], bump,(acceptable when CU is not pressured; both forms enforce canonicalization via Anchor'sfind_program_addresscheck). The cached form is strongly preferred — all reference examples use it. Do not usebump = <user_input>— that's the actual canonicalization vulnerability. - [ ] CPIs use
CpiContext::neworCpiContext::new_with_signer. The program arg is aPubkey(usectx.accounts.<program>.key()) — Anchor 1.0+ removed theAccountInfoform. No hand-rolledinvoke/invoke_signedwith manually assembledAccountInfoarrays unless raw Solana is justified. - [ ] No path mutates state after a CPI to an untrusted program without re-reading and re-validating. (See
security/reentrancy.mdfor why account locking is necessary but not sufficient.) - [ ] Account sizing is explicit:
space = 8 + <sum>; the 8 is the Anchor discriminator. Variable-size fields have hard caps. - [ ] Errors use
#[error_code]. NoProgramError::Custom(n)literals, nomsg!-then-fail. - [ ] No PDA shares a write lock with high-frequency unrelated state. Per-entity PDAs over global counters where the protocol allows.
- [ ] If the contract emits events that SPL programs already emit (Transfer for SPL Token), prefer not duplicating them.
- [ ] Re-init protection: PDAs that should only init once use
init(notinit_if_needed) and have unique seeds.
Tooling — Solana Developer MCP rust_autofixer
If your environment exposes the Solana Developer MCP (https://mcp.solana.com/mcp), the rust_autofixer tool is part of the workflow — not optional.
When to call it: every time you have produced or modified Anchor or Pinocchio Rust that you intend to ship. That includes 02-naive-port.rs and 03-optimized.rs, and any in-flight fix you apply after a failed cargo check.
How to call it: pass the full Rust source. Specify the framework (auto, anchor, or pinocchio) if the caller hasn't already.
The loop:
1. Call rust_autofixer on the current Rust. 2. Apply every suggested fix (they are mechanical, structured, and safe). 3. Call rust_autofixer again. 4. Repeat until require_another_tool_call_after_fixing is false.
Only emit the artifact once the loop terminates. This is in addition to — not a replacement for — the pre-flight checklist above; the autofixer catches the structural-safety class of bug, the checklist catches design/idiom issues.
Do not use any other Solana MCP tool (list_sections, read_section, search, etc.) for this workflow. Stay scoped to rust_autofixer.
Explanation log opener
Before the first ## Theme heading, the explanation log opens with a short prose preamble. The preamble must, in this order:
1. One paragraph: what the program does in EVM-developer terms. State the protocol the way you'd state it to a Solidity dev who's never seen the contract — "a one-shot ERC-20 crowdfund: supporters deposit tokens before a deadline; if the goal is met the creator claims the pot, otherwise supporters refund." Don't lead with what the example teaches; lead with what the program does. 2. One paragraph: the Solana shape it ports to. What the program looks like on Solana at the same height of abstraction — "On Solana, the same protocol becomes one PDA per supporter plus a singleton fundraiser account; SPL Token handles the actual token movement via CPIs." Still no per-line / per-symbol detail.
Any optional context that follows (vocabulary list, reference-implementation link, etc.) comes after these two paragraphs, not before.
Explanation log schema
Each entry is exactly five fields. Keep them tight — one to four sentences each.
### <short title>
- **Title rules.** When the change has a Solidity counterpart (most state-model / security / idiom entries), frame the title as `Solidity-side → Solana-side` — e.g. `mapping(address => uint256) ledger → per-supporter PDA`, not `Vec<Contribution> → per-supporter PDA`. A Solidity-fluent reader of `03-optimized.rs` has not opened the naive port; titles that name naive-port Rust types (`Vec<X>`, `iter_mut().find(...)`, etc.) read as gibberish to them. When the change is Solana-only hygiene (no Solidity counterpart — bump caching, PDA seed consolidation, account-size optimizations), use a plain descriptive title without the arrow.
- **What:** the concrete change as a diff between the naive port and the optimized port. Reference the diff section or `file:line` in the .rs files. This is the LOW-LEVEL diff view; cite specific identifiers, function names, line numbers. Written for someone reviewing the diff side-by-side.
- **Annotation:** a self-contained explanation of THIS code (the optimized version) for a Solidity-fluent reader who is looking only at the optimized file and has never seen the naive port. State what the optimized code does at this point, why a Solidity developer's mental model has to shift here, and — when meaningful — what a naive translation would have done and why this shape is preferable. Do NOT cite the naive port by filename or reference any line outside the optimized file. Two to four sentences.
- **Why:** the platform-level reasoning, structured as a two-sided contrast for a Solidity-fluent reader. Lead the first sentence(s) with **"On Ethereum, ..."** and describe the EVM/Solidity paradigm the developer is bringing with them. Then lead the next sentence(s) with **"On Solana, ..."** and describe the paradigm that diverges. Keep it HIGH-LEVEL — platform mechanics, mental model, what serializes / what doesn't, who owns what, what the runtime guarantees. Save the per-line / per-symbol detail for `What:` and `Annotation:`. Avoid backtick code fragments here unless absolutely necessary.
- **Benefit:** what is gained. Be specific: CU saved, parallelism unlocked, security class avoided, code deleted.
- **Tradeoff:** what is given up. If nothing meaningful, say so and justify briefly.What is the "code review" view of the diff; Annotation is the "reader of the final code" view. They cover different audiences — both are needed because the final code is shipped on its own, but the diff is also part of the artifact set.
Group entries under thematic headers: ## State model, ## Parallelism, ## Security, ## CPI & program reuse, ## Compute & rent, ## Idioms.
Explanation style — write for a Solidity-fluent reader who has never seen Solana
The reader knows Solidity well. They know financial systems. They have not internalized PDAs, the account model, SPL Token, rent, CPI, or Anchor's constraint vocabulary. The explanation log is where they bridge — every entry must land for that reader.
Rules
1. First-use translation, always inline. The first time any Solana-specific term appears in a given explanation log, give a short EVM analog in parentheses or em-dashes. Don't assume a glossary; weave it into the prose. After first use, the term is fair game.
Required glossing on first use (non-exhaustive):
- PDA — "PDA (Program-Derived Address — a deterministic account address derived from seeds the program controls; analog of a Solidity storage slot keyed by
(address, mapping)— but each PDA is its own account, not a slot inside the program)" - SPL Token — "SPL Token (the shared on-chain token program every fungible token reuses on Solana — instead of each ERC-20 deploying its own contract, every token is just configuration on this one program)"
- CPI — "CPI (cross-program invocation — Solana's version of one contract
call-ing another, but every account the callee will touch must already be in the caller's transaction)" - rent — "rent (a refundable SOL deposit every account pays to live on-chain; ~0.001 SOL per KB of account data, returned in full when the account is closed)"
- lamports — "lamports (1 SOL = 1e9 lamports — Solana's gwei equivalent, but at 9 decimals instead of 18)"
- ATA / Associated Token Account — "ATA (Associated Token Account — the canonical per-wallet token account for a given mint, with a deterministically derivable address; the analog of \"the wallet's balance for this token\")"
- Mint account — "Mint account (the on-chain configuration for a token: total supply, decimals, who can mint — owned by the SPL Token program, not by the issuer)"
- Signer<'info> — "Signer (an explicit
msg.sender— Solana requires every signing account to be declared up front in the instruction's account list, vs. Solidity's implicitmsg.sender)" - Anchor — "Anchor (the framework on top of raw Solana programs, similar to how Hardhat relates to raw EVM — provides macros, account validation, and the IDL)"
- init_if_needed — "init_if_needed (an Anchor constraint that creates the account on first call and is a no-op on subsequent calls — Solana's closest analog to Solidity's implicit
mapping[key] = value)" - close = X — "close = X (an Anchor constraint that tears the account down and refunds its rent to X when the instruction succeeds — there's no Solidity equivalent because Solidity storage slots can't be deleted)"
- discriminator — "discriminator (an 8-byte type tag Anchor prepends to every account it manages, so deserializing a
Vaultaccount as aMintfails loudly — no EVM analog because EVM has no typed account model)" - has_one — "has_one = authority (an Anchor constraint that verifies the account's stored
authorityfield equals theauthorityaccount passed in the same instruction — the declarative form ofrequire(state.authority == signer))" - seeds + bump — "seeds (the byte inputs the program uses to derive a PDA; the
bumpis a nonce that makes the address valid). Conceptually:keccak256(abi.encodePacked(...))with extra steps to keep the result off the secp256k1 curve." - write-lock / parallelism — "Solana's runtime locks every writable account a transaction touches, so two transactions that mutate different accounts run in parallel — the EVM-style global single-threaded execution is replaced by per-account locks (Sealevel)."
2. Comparative framing in Why bullets. Prefer "In Solidity, you would have written X because Y. On Solana, the equivalent shape is Z because W" over "the PDA stores X". The reader anchors on what they already know.
3. Plain-English code references. When citing a Rust line by file:line, briefly say what it does in EVM-flavored language. Not just f.contributors.iter_mut().find(|c| c.who == k) — say "scans the contributor list linearly to find the supporter's row (the on-chain analog of contributors[k] in Solidity, but more expensive)."
4. Spell out the consequence chain. The reader doesn't yet know why "one PDA per supporter" matters. Don't say "no serialization of cross-supporter activity" without first explaining that Solana serializes writes to the same account, so isolating writes to different accounts is what unlocks parallelism. Two sentences > one tight one if the second sentence is doing teaching work.
5. Tradeoff is honest. Rent, extra accounts, Anchor-specific idioms a non-Anchor reader has to learn — name them concretely. The reader is evaluating a real migration; gloss only hurts.
Side-by-side: bad → better
The current examples/token-fundraiser/05-explanation.md entry for §S1 reads:
Why: Solidity'smapping(address => uint256)is one slot per supporter inside one contract — addressable by key inside one storage tree. The Solana equivalent is one account per supporter, addressable by PDA derivation. AVecinside a state account is the wrong primitive: it bounds the supporter count, forces every contribute/refund to mutate the singleton state account, and scans linearly on lookup.
A Solidity-fluent reader who's never seen Solana parses "PDA derivation" as noise. Better — high-level, two clearly-marked sides:
Why: On Ethereum, contract storage is global to the contract: every entry in a mapping lives in the same storage tree, and the contract is the single writer. On Solana, the equivalent of a mapping is one on-chain account per entry — each entry owns its own slice of state, with its own deterministic address, its own owner, and its own write-lock. The naive port collapses every entry back into one shared state account; that imitates the Solidity layout but loses the per-entry property that lets unrelated writes execute concurrently.
Same content, lower code density, lands for the reader. Err on the side of teaching. Length is not a virtue; clarity for the assumed reader is. Code-level identifiers belong in What: and in inline annotations on the .rs file — the Why: field is for the platform mental model. If a section already used the term, you don't need to re-explain it — the rule is "first use in this explanation log."
Original example entry (for structure reference)
### Balances moved from on-chain map to SPL Token accounts
- **What:** Removed `balances: Vec<BalanceEntry>` from `TokenState` (`02-naive-port.rs:153`). Each holder now has an Associated Token Account (ATA — the canonical per-wallet token balance account, derived deterministically from `(wallet, mint)`); transfers go through `token::transfer` on the SPL Token program directly, not through this program (`03-optimized.rs` has no transfer instruction).
- **Annotation:** This program holds no balance ledger of its own — there's no `balanceOf` map, no `transferFrom`, no custody field anywhere in this file. On Solana, fungible-token balances live in the network's SPL Token program; each holder owns their own token account, and transfers run as direct CPIs to SPL Token rather than going through this program. Re-implementing a custom `mapping(address => uint256)` here would force every transfer in the system to write-lock this program's state and serialize them all.
- **Why:** On Ethereum, an ERC-20 contract holds the balance ledger inside its own storage — every holder is a slot in the contract's `balanceOf` map, and every transfer is a contract call to that contract. On Solana, balances live in the network-wide SPL Token program rather than inside any individual program — each holder has their own on-chain account, and transfers move balances directly between those accounts without going through the issuing program at all. Keeping a custom map would re-implement that shared infrastructure inside the program and force every transfer to write-lock the program's state.
- **Benefit:** Transfers between disjoint sender/recipient pairs run in parallel (Solana's runtime locks accounts, not the program — so writes to Alice→Bob and Carol→Dan don't block each other). ~150 lines of custom balance/allowance logic deleted. No path for a buggy custom balance update.
- **Tradeoff:** Holders create an ATA before first receipt — a one-time ~0.002 SOL rent deposit (refundable when the account is closed). Off-chain code computes ATA addresses to read balances instead of reading one contract account.Reference example
Trace the protocol on examples/token-fundraiser/ end-to-end before producing translations of new inputs. The example exists so you can verify the protocol produces the contract.
Ambiguities
See DECISIONS.md at the skill root for choices made during construction (Anchor version, SPL Token classic vs Token-2022, etc.). When in doubt on a new translation, prefer the choice consistent with the reference example unless the input forces otherwise.
.env
.env.*
.DS_Store
*.log
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
interface IERC20 {
function transfer(address to, uint256 amount) external returns (bool);
function transferFrom(address from, address to, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
function decimals() external view returns (uint8);
}
/// @title ExampleVault — minimal ERC-4626 with virtual-offset inflation defense and yield fee.
/// @notice Shares are themselves ERC-20-shaped (transfer/approve on the share token). This
/// contract focuses on the 4626-specific deposit/mint/withdraw/redeem/earn surface
/// and inherits the ERC-20 share-token plumbing from a base class. The naive Anchor
/// port omits the share-transfer/approve surface for brevity (already exercised by
/// the ERC-20 reference example); the optimized port reintroduces them via SPL Token.
/// @dev OpenZeppelin-style: virtualShares = 10**DECIMALS_OFFSET, virtualAssets = 1. The
/// conversion formula's +offset on numerator and denominator dilutes attacker-controlled
/// first deposits and bounds the donation-attack impact.
abstract contract ERC20Share {
string public name;
string public symbol;
uint8 public immutable shareDecimals;
uint256 public totalSupply;
mapping(address => uint256) public balanceOf;
mapping(address => mapping(address => uint256)) public allowance;
event Transfer(address indexed from, address indexed to, uint256 amount);
event Approval(address indexed owner, address indexed spender, uint256 amount);
constructor(string memory _name, string memory _symbol, uint8 _decimals) {
name = _name;
symbol = _symbol;
shareDecimals = _decimals;
}
function _mint(address to, uint256 amount) internal {
totalSupply += amount;
balanceOf[to] += amount;
emit Transfer(address(0), to, amount);
}
function _burn(address from, uint256 amount) internal {
balanceOf[from] -= amount;
totalSupply -= amount;
emit Transfer(from, address(0), amount);
}
function _spendAllowance(address owner_, address spender, uint256 amount) internal {
if (owner_ != spender) {
uint256 allowed = allowance[owner_][spender];
if (allowed != type(uint256).max) {
require(allowed >= amount, "InsufficientAllowance");
allowance[owner_][spender] = allowed - amount;
}
}
}
}
contract ExampleVault is ERC20Share {
IERC20 public immutable asset;
address public owner;
uint16 public feeBps; // fee on yield in basis points (10000 = 100%)
address public feeRecipient;
uint256 private _totalAssets; // total underlying asset balance under management
/// @dev Virtual-offset inflation defense.
/// virtualShares = 10 ** DECIMALS_OFFSET, virtualAssets = 1.
/// Larger offset = stronger defense (more rate dilution for attacker's seeding deposit)
/// at the cost of dust precision at very small supply.
uint8 public constant DECIMALS_OFFSET = 6;
event Deposit(address indexed sender, address indexed receiver, uint256 assets, uint256 shares);
event Withdraw(address indexed sender, address indexed receiver, address indexed owner_, uint256 assets, uint256 shares);
event Earn(uint256 grossYield, uint256 feeShares);
event FeeBpsUpdated(uint16 oldBps, uint16 newBps);
event FeeRecipientUpdated(address oldRecipient, address newRecipient);
event OwnershipTransferred(address indexed previousOwner, address indexed newOwner);
error NotOwner();
error ZeroAssets();
error ZeroShares();
error InvalidFee();
error InsufficientLiquidity();
error ZeroAddress();
modifier onlyOwner() {
if (msg.sender != owner) revert NotOwner();
_;
}
constructor(address _asset, uint16 _feeBps, address _feeRecipient)
ERC20Share("VaultShare", "vSHR", IERC20(_asset).decimals() + DECIMALS_OFFSET)
{
if (_asset == address(0) || _feeRecipient == address(0)) revert ZeroAddress();
if (_feeBps > 10000) revert InvalidFee();
asset = IERC20(_asset);
feeBps = _feeBps;
feeRecipient = _feeRecipient;
owner = msg.sender;
emit OwnershipTransferred(address(0), msg.sender);
}
// ---- ERC-4626 views ----
function totalAssets() public view returns (uint256) { return _totalAssets; }
function _virtualShares() internal pure returns (uint256) { return 10 ** DECIMALS_OFFSET; }
function _virtualAssets() internal pure returns (uint256) { return 1; }
/// @dev assets → shares, ROUND DOWN (favors vault). Used by deposit and convertToShares.
function convertToShares(uint256 assets) public view returns (uint256) {
return (assets * (totalSupply + _virtualShares())) / (_totalAssets + _virtualAssets());
}
/// @dev shares → assets, ROUND DOWN (favors vault). Used by redeem and convertToAssets.
function convertToAssets(uint256 shares) public view returns (uint256) {
return (shares * (_totalAssets + _virtualAssets())) / (totalSupply + _virtualShares());
}
function previewDeposit(uint256 assets) public view returns (uint256) { return convertToShares(assets); }
function previewRedeem(uint256 shares) public view returns (uint256) { return convertToAssets(shares); }
/// @dev shares → assets needed to mint, ROUND UP (user pays a bit more, favors vault).
function previewMint(uint256 shares) public view returns (uint256) {
uint256 num = shares * (_totalAssets + _virtualAssets());
uint256 den = totalSupply + _virtualShares();
return (num + den - 1) / den;
}
/// @dev assets → shares to burn, ROUND UP (user burns a bit more, favors vault).
function previewWithdraw(uint256 assets) public view returns (uint256) {
uint256 num = assets * (totalSupply + _virtualShares());
uint256 den = _totalAssets + _virtualAssets();
return (num + den - 1) / den;
}
// ---- ERC-4626 actions ----
function deposit(uint256 assets, address receiver) external returns (uint256 shares) {
if (assets == 0) revert ZeroAssets();
shares = previewDeposit(assets);
if (shares == 0) revert ZeroShares();
_totalAssets += assets;
_mint(receiver, shares);
require(asset.transferFrom(msg.sender, address(this), assets), "TransferFromFailed");
emit Deposit(msg.sender, receiver, assets, shares);
}
function mint(uint256 shares, address receiver) external returns (uint256 assets) {
if (shares == 0) revert ZeroShares();
assets = previewMint(shares);
_totalAssets += assets;
_mint(receiver, shares);
require(asset.transferFrom(msg.sender, address(this), assets), "TransferFromFailed");
emit Deposit(msg.sender, receiver, assets, shares);
}
function withdraw(uint256 assets, address receiver, address owner_) external returns (uint256 shares) {
if (assets == 0) revert ZeroAssets();
if (assets > _totalAssets) revert InsufficientLiquidity();
shares = previewWithdraw(assets);
_spendAllowance(owner_, msg.sender, shares);
_burn(owner_, shares);
_totalAssets -= assets;
require(asset.transfer(receiver, assets), "TransferFailed");
emit Withdraw(msg.sender, receiver, owner_, assets, shares);
}
function redeem(uint256 shares, address receiver, address owner_) external returns (uint256 assets) {
if (shares == 0) revert ZeroShares();
assets = previewRedeem(shares);
if (assets > _totalAssets) revert InsufficientLiquidity();
_spendAllowance(owner_, msg.sender, shares);
_burn(owner_, shares);
_totalAssets -= assets;
require(asset.transfer(receiver, assets), "TransferFailed");
emit Withdraw(msg.sender, receiver, owner_, assets, shares);
}
// ---- Yield realization ----
/// @notice Realize gross `yield` of underlying. In production a keeper or a strategy
/// contract pushes here after redeeming from the lending venue. For the example
/// the caller transfers `yield` underlying tokens to the vault and we mint fee
/// shares to feeRecipient at the *pre-yield* price.
/// @dev Owner-gated for simplicity; in production this is callable by the strategy.
function _earn(uint256 yield) external onlyOwner returns (uint256 feeShares) {
if (yield == 0) return 0;
if (feeBps > 0 && totalSupply > 0) {
// Fee in asset units, taken from gross yield.
uint256 feeAssets = (yield * feeBps) / 10000;
// Mint shares to feeRecipient at the pre-yield price = totalAssets / totalSupply.
// Using the same +offset formula so the math is consistent with deposits.
feeShares = (feeAssets * (totalSupply + _virtualShares())) / (_totalAssets + _virtualAssets());
_mint(feeRecipient, feeShares);
}
_totalAssets += yield;
require(asset.transferFrom(msg.sender, address(this), yield), "TransferFromFailed");
emit Earn(yield, feeShares);
}
// ---- Admin ----
function setFeeBps(uint16 newBps) external onlyOwner {
if (newBps > 10000) revert InvalidFee();
uint16 old = feeBps;
feeBps = newBps;
emit FeeBpsUpdated(old, newBps);
}
function setFeeRecipient(address newRecipient) external onlyOwner {
if (newRecipient == address(0)) revert ZeroAddress();
address old = feeRecipient;
feeRecipient = newRecipient;
emit FeeRecipientUpdated(old, newRecipient);
}
function transferOwnership(address newOwner) external onlyOwner {
if (newOwner == address(0)) revert ZeroAddress();
address prev = owner;
owner = newOwner;
emit OwnershipTransferred(prev, newOwner);
}
}
// Pass 1: Faithful Anchor port of ExampleVault (ERC-4626).
//
// BASELINE — semantically identical to the Solidity, translated construct-by-construct.
// `// SMELL:` markers flag the patterns the optimized version (03-optimized.rs) replaces.
//
// What's faithful:
// - One `VaultState` account holds asset_mint, owner, fee_bps, fee_recipient,
// _totalAssets, totalSupply, and per-user share balances — mirroring the Solidity
// contract's storage layout directly.
// - Share balances live in `Vec<BalanceEntry>` (the consolidated "ERC-20 inside the
// vault contract" pattern). The optimized version replaces this with an SPL Token
// Mint for shares.
// - Arithmetic uses u128 widening (no other way to do 4626 math without it) but the
// narrow-back-to-u64 cast is unchecked, mirroring an EVM dev's first attempt.
// - Rounding directions match the Solidity (Floor on deposit/redeem, Ceil on
// mint/withdraw) — but the ceiling computation `(num + den - 1)` itself can
// overflow without a check.
//
// What is NOT faithful, by necessity:
// - Asset reserve: a program-owned SPL TokenAccount (authority = vault_authority PDA).
// There is no Solana primitive other than SPL Token for moving fungible tokens.
// - The share-token ERC-20 surface (transfer/approve) is omitted from the naive port —
// already exercised by the ERC-20 reference example; reintroduced via SPL Token in
// the optimized version.
// - `withdraw`/`redeem` require `owner == msg.sender` (no delegate path). The
// optimized version uses SPL Token's built-in delegate model, which has no clean
// naive analog without reimplementing allowance maps.
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Mint, Token, TokenAccount, Transfer};
declare_id!("Vault4626Naive111111111111111111111111111111");
// Hard cap because Vec lives in a fixed-size account.
// SMELL: a real 4626 vault should not have a depositor cap; translation artifact.
const MAX_HOLDERS: usize = 100;
// Virtual-offset constants — match Solidity DECIMALS_OFFSET = 6.
const VIRTUAL_SHARES_OFFSET: u128 = 1_000_000; // 10^6
const VIRTUAL_ASSETS_OFFSET: u128 = 1;
#[program]
pub mod vault4626_naive {
use super::*;
/// Solidity `constructor(asset, feeBps, feeRecipient)`.
pub fn initialize(
ctx: Context<Initialize>,
fee_bps: u16,
fee_recipient: Pubkey,
) -> Result<()> {
require!(fee_bps <= 10_000, VaultError::InvalidFee);
require_keys_neq!(fee_recipient, Pubkey::default(), VaultError::ZeroAddress);
let vault = &mut ctx.accounts.vault;
vault.asset_mint = ctx.accounts.asset_mint.key();
vault.owner = ctx.accounts.owner.key();
vault.fee_bps = fee_bps;
vault.fee_recipient = fee_recipient;
vault.total_assets = 0;
vault.total_supply = 0;
vault.balances = Vec::new();
// SMELL: vault_authority bump not cached.
Ok(())
}
/// Solidity `deposit(assets, receiver)` — rounds shares DOWN (favors vault).
pub fn deposit(ctx: Context<MoveIn>, assets: u64, receiver: Pubkey) -> Result<()> {
require!(assets > 0, VaultError::ZeroAssets);
let vault = &mut ctx.accounts.vault;
let shares = preview_deposit(assets, vault.total_supply, vault.total_assets)?;
require!(shares > 0, VaultError::ZeroShares);
// SMELL: bare cast — silent truncation if computation ever exceeds u64::MAX.
// (preview_deposit returns u64 already cast from u128 without bounds check.)
// Update totals — SMELL: unchecked.
vault.total_assets += assets;
vault.total_supply += shares;
// Mint shares to receiver — SMELL: O(n) Vec scan + write-lock.
mint_to(vault, receiver, shares)?;
// Pull assets in.
token::transfer(
CpiContext::new(
ctx.accounts.token_program.key(),
Transfer {
from: ctx.accounts.user_asset_ata.to_account_info(),
to: ctx.accounts.asset_reserve.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
},
),
assets,
)?;
emit!(Deposit {
sender: ctx.accounts.user.key(),
receiver,
assets,
shares,
});
Ok(())
}
/// Solidity `mint(shares, receiver)` — rounds assets UP (favors vault).
pub fn mint_shares(ctx: Context<MoveIn>, shares: u64, receiver: Pubkey) -> Result<()> {
require!(shares > 0, VaultError::ZeroShares);
let vault = &mut ctx.accounts.vault;
let assets = preview_mint(shares, vault.total_supply, vault.total_assets)?;
vault.total_assets += assets; // SMELL: unchecked
vault.total_supply += shares; // SMELL: unchecked
mint_to(vault, receiver, shares)?;
token::transfer(
CpiContext::new(
ctx.accounts.token_program.key(),
Transfer {
from: ctx.accounts.user_asset_ata.to_account_info(),
to: ctx.accounts.asset_reserve.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
},
),
assets,
)?;
emit!(Deposit {
sender: ctx.accounts.user.key(),
receiver,
assets,
shares,
});
Ok(())
}
/// Solidity `withdraw(assets, receiver, owner_)` — rounds shares UP.
/// SMELL: no delegate path; naive port requires owner == msg.sender.
pub fn withdraw(
ctx: Context<MoveOut>,
assets: u64,
receiver: Pubkey,
) -> Result<()> {
require!(assets > 0, VaultError::ZeroAssets);
let vault = &mut ctx.accounts.vault;
require!(vault.total_assets >= assets, VaultError::InsufficientLiquidity);
let shares = preview_withdraw(assets, vault.total_supply, vault.total_assets)?;
let owner = ctx.accounts.user.key();
burn_from(vault, owner, shares)?;
vault.total_assets -= assets; // SMELL: unchecked
vault.total_supply -= shares; // SMELL: unchecked
// Send assets out — vault_authority PDA signs.
let bump = ctx.bumps.vault_authority;
let signer_seeds: &[&[u8]] = &[b"vault_authority", &[bump]];
token::transfer(
CpiContext::new_with_signer(
ctx.accounts.token_program.key(),
Transfer {
from: ctx.accounts.asset_reserve.to_account_info(),
to: ctx.accounts.receiver_asset_ata.to_account_info(),
authority: ctx.accounts.vault_authority.to_account_info(),
},
&[signer_seeds],
),
assets,
)?;
emit!(Withdraw {
sender: owner,
receiver,
owner,
assets,
shares,
});
Ok(())
}
/// Solidity `redeem(shares, receiver, owner_)` — rounds assets DOWN.
pub fn redeem(
ctx: Context<MoveOut>,
shares: u64,
receiver: Pubkey,
) -> Result<()> {
require!(shares > 0, VaultError::ZeroShares);
let vault = &mut ctx.accounts.vault;
let assets = preview_redeem(shares, vault.total_supply, vault.total_assets)?;
require!(vault.total_assets >= assets, VaultError::InsufficientLiquidity);
let owner = ctx.accounts.user.key();
burn_from(vault, owner, shares)?;
vault.total_assets -= assets; // SMELL: unchecked
vault.total_supply -= shares; // SMELL: unchecked
let bump = ctx.bumps.vault_authority;
let signer_seeds: &[&[u8]] = &[b"vault_authority", &[bump]];
token::transfer(
CpiContext::new_with_signer(
ctx.accounts.token_program.key(),
Transfer {
from: ctx.accounts.asset_reserve.to_account_info(),
to: ctx.accounts.receiver_asset_ata.to_account_info(),
authority: ctx.accounts.vault_authority.to_account_info(),
},
&[signer_seeds],
),
assets,
)?;
emit!(Withdraw {
sender: owner,
receiver,
owner,
assets,
shares,
});
Ok(())
}
/// Solidity `_earn(yield)` — owner pushes realized yield; fee minted to fee_recipient
/// at the pre-yield price.
pub fn earn(ctx: Context<Earn>, yield_amount: u64) -> Result<()> {
if yield_amount == 0 {
return Ok(());
}
let vault = &mut ctx.accounts.vault;
require_keys_eq!(vault.owner, ctx.accounts.owner.key(), VaultError::NotOwner);
let mut fee_shares: u64 = 0;
if vault.fee_bps > 0 && vault.total_supply > 0 {
// Fee in asset units. SMELL: bare math.
let fee_assets =
((yield_amount as u128) * (vault.fee_bps as u128)) / 10_000u128;
// Fee in share units, at pre-yield price.
let num = fee_assets
* ((vault.total_supply as u128) + VIRTUAL_SHARES_OFFSET);
let den = (vault.total_assets as u128) + VIRTUAL_ASSETS_OFFSET;
fee_shares = (num / den) as u64; // SMELL: silent truncation
let recipient = vault.fee_recipient;
mint_to(vault, recipient, fee_shares)?;
vault.total_supply += fee_shares; // SMELL: unchecked
}
vault.total_assets += yield_amount; // SMELL: unchecked
token::transfer(
CpiContext::new(
ctx.accounts.token_program.key(),
Transfer {
from: ctx.accounts.owner_asset_ata.to_account_info(),
to: ctx.accounts.asset_reserve.to_account_info(),
authority: ctx.accounts.owner.to_account_info(),
},
),
yield_amount,
)?;
emit!(EarnEvent {
gross_yield: yield_amount,
fee_shares,
});
Ok(())
}
pub fn set_fee_bps(ctx: Context<AdminAction>, new_bps: u16) -> Result<()> {
let vault = &mut ctx.accounts.vault;
require_keys_eq!(vault.owner, ctx.accounts.owner.key(), VaultError::NotOwner);
require!(new_bps <= 10_000, VaultError::InvalidFee);
let old = vault.fee_bps;
vault.fee_bps = new_bps;
emit!(FeeBpsUpdated { old, new_bps });
Ok(())
}
pub fn set_fee_recipient(ctx: Context<AdminAction>, new_recipient: Pubkey) -> Result<()> {
let vault = &mut ctx.accounts.vault;
require_keys_eq!(vault.owner, ctx.accounts.owner.key(), VaultError::NotOwner);
require_keys_neq!(new_recipient, Pubkey::default(), VaultError::ZeroAddress);
let old = vault.fee_recipient;
vault.fee_recipient = new_recipient;
emit!(FeeRecipientUpdated { old, new_recipient });
Ok(())
}
pub fn transfer_ownership(ctx: Context<AdminAction>, new_owner: Pubkey) -> Result<()> {
let vault = &mut ctx.accounts.vault;
require_keys_eq!(vault.owner, ctx.accounts.owner.key(), VaultError::NotOwner);
require_keys_neq!(new_owner, Pubkey::default(), VaultError::ZeroAddress);
let prev = vault.owner;
vault.owner = new_owner;
emit!(OwnershipTransferred {
previous_owner: prev,
new_owner,
});
Ok(())
}
}
// ---- Preview helpers (4626 conversion math) ----
//
// SMELL: each returns `result as u64` without bounds-check. For pathological
// inputs (very large totals × very large args), the cast silently
// truncates and the depositor either gets too few shares or the vault
// accepts too few assets.
fn preview_deposit(assets: u64, total_supply: u64, total_assets: u64) -> Result<u64> {
// Rounds DOWN — favors vault.
let num = (assets as u128) * ((total_supply as u128) + VIRTUAL_SHARES_OFFSET);
let den = (total_assets as u128) + VIRTUAL_ASSETS_OFFSET;
Ok((num / den) as u64) // SMELL: silent truncation
}
fn preview_redeem(shares: u64, total_supply: u64, total_assets: u64) -> Result<u64> {
// Rounds DOWN — favors vault.
let num = (shares as u128) * ((total_assets as u128) + VIRTUAL_ASSETS_OFFSET);
let den = (total_supply as u128) + VIRTUAL_SHARES_OFFSET;
Ok((num / den) as u64) // SMELL: silent truncation
}
fn preview_mint(shares: u64, total_supply: u64, total_assets: u64) -> Result<u64> {
// Rounds UP — user pays a bit more, favors vault.
let num = (shares as u128) * ((total_assets as u128) + VIRTUAL_ASSETS_OFFSET);
let den = (total_supply as u128) + VIRTUAL_SHARES_OFFSET;
// SMELL: bare `num + den - 1` — can overflow u128 at extreme magnitudes.
Ok(((num + den - 1) / den) as u64) // SMELL: silent truncation + add overflow
}
fn preview_withdraw(assets: u64, total_supply: u64, total_assets: u64) -> Result<u64> {
// Rounds UP — user burns a bit more, favors vault.
let num = (assets as u128) * ((total_supply as u128) + VIRTUAL_SHARES_OFFSET);
let den = (total_assets as u128) + VIRTUAL_ASSETS_OFFSET;
Ok(((num + den - 1) / den) as u64) // SMELL: silent truncation + add overflow
}
// ---- Internal share balance helpers (Vec-as-map antipattern) ----
//
// SMELL: linear scan + full-Vec serialize/deserialize on every share movement.
fn mint_to(vault: &mut VaultState, to: Pubkey, shares: u64) -> Result<()> {
if let Some(entry) = vault.balances.iter_mut().find(|e| e.holder == to) {
entry.amount += shares; // SMELL: unchecked
} else {
require!(vault.balances.len() < MAX_HOLDERS, VaultError::TooManyHolders);
vault.balances.push(BalanceEntry {
holder: to,
amount: shares,
});
}
Ok(())
}
fn burn_from(vault: &mut VaultState, from: Pubkey, shares: u64) -> Result<()> {
let entry = vault
.balances
.iter_mut()
.find(|e| e.holder == from)
.ok_or(error!(VaultError::InsufficientShares))?;
require!(entry.amount >= shares, VaultError::InsufficientShares);
entry.amount -= shares; // SMELL: unchecked
Ok(())
}
// ---- Accounts ----
#[derive(Accounts)]
pub struct Initialize<'info> {
#[account(
init,
payer = owner,
space = 8 + VaultState::SIZE,
seeds = [b"vault"],
bump,
)]
pub vault: Account<'info, VaultState>,
pub asset_mint: Account<'info, Mint>,
/// CHECK: PDA, not deserialized.
#[account(seeds = [b"vault_authority"], bump)]
pub vault_authority: UncheckedAccount<'info>,
#[account(
init,
payer = owner,
token::mint = asset_mint,
token::authority = vault_authority,
seeds = [b"asset_reserve"],
bump,
)]
pub asset_reserve: Account<'info, TokenAccount>,
#[account(mut)]
pub owner: Signer<'info>,
pub system_program: Program<'info, System>,
pub token_program: Program<'info, Token>,
pub rent: Sysvar<'info, Rent>,
}
#[derive(Accounts)]
pub struct MoveIn<'info> {
// SMELL: writable on every deposit / mint — single write-lock for the program.
#[account(mut, seeds = [b"vault"], bump)]
pub vault: Account<'info, VaultState>,
/// CHECK: PDA.
#[account(seeds = [b"vault_authority"], bump)]
pub vault_authority: UncheckedAccount<'info>,
#[account(mut, seeds = [b"asset_reserve"], bump)]
pub asset_reserve: Account<'info, TokenAccount>,
#[account(mut, token::mint = vault.asset_mint, token::authority = user)]
pub user_asset_ata: Account<'info, TokenAccount>,
pub user: Signer<'info>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct MoveOut<'info> {
#[account(mut, seeds = [b"vault"], bump)]
pub vault: Account<'info, VaultState>,
/// CHECK: PDA.
#[account(seeds = [b"vault_authority"], bump)]
pub vault_authority: UncheckedAccount<'info>,
#[account(mut, seeds = [b"asset_reserve"], bump)]
pub asset_reserve: Account<'info, TokenAccount>,
#[account(mut, token::mint = vault.asset_mint)]
pub receiver_asset_ata: Account<'info, TokenAccount>,
/// SMELL: no delegate support — owner == msg.sender enforced implicitly.
pub user: Signer<'info>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct Earn<'info> {
#[account(mut, seeds = [b"vault"], bump)]
pub vault: Account<'info, VaultState>,
/// CHECK: PDA.
#[account(seeds = [b"vault_authority"], bump)]
pub vault_authority: UncheckedAccount<'info>,
#[account(mut, seeds = [b"asset_reserve"], bump)]
pub asset_reserve: Account<'info, TokenAccount>,
#[account(mut, token::mint = vault.asset_mint, token::authority = owner)]
pub owner_asset_ata: Account<'info, TokenAccount>,
pub owner: Signer<'info>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct AdminAction<'info> {
#[account(mut, seeds = [b"vault"], bump)]
pub vault: Account<'info, VaultState>,
pub owner: Signer<'info>,
}
// ---- State ----
#[account]
pub struct VaultState {
pub asset_mint: Pubkey,
pub owner: Pubkey,
pub fee_bps: u16,
pub fee_recipient: Pubkey,
pub total_assets: u64,
pub total_supply: u64,
pub balances: Vec<BalanceEntry>, // SMELL: write-hot, capped, O(n) scan
}
impl VaultState {
pub const SIZE: usize = 32 + 32 + 2 + 32 + 8 + 8 + 4 + MAX_HOLDERS * BalanceEntry::SIZE;
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
pub struct BalanceEntry {
pub holder: Pubkey,
pub amount: u64,
}
impl BalanceEntry {
pub const SIZE: usize = 32 + 8; // 40
}
// ---- Events ----
#[event]
pub struct Deposit {
pub sender: Pubkey,
pub receiver: Pubkey,
pub assets: u64,
pub shares: u64,
}
#[event]
pub struct Withdraw {
pub sender: Pubkey,
pub receiver: Pubkey,
pub owner: Pubkey,
pub assets: u64,
pub shares: u64,
}
#[event]
pub struct EarnEvent {
pub gross_yield: u64,
pub fee_shares: u64,
}
#[event]
pub struct FeeBpsUpdated {
pub old: u16,
pub new_bps: u16,
}
#[event]
pub struct FeeRecipientUpdated {
pub old: Pubkey,
pub new_recipient: Pubkey,
}
#[event]
pub struct OwnershipTransferred {
pub previous_owner: Pubkey,
pub new_owner: Pubkey,
}
// ---- Errors ----
#[error_code]
pub enum VaultError {
#[msg("caller is not the owner")]
NotOwner,
#[msg("zero address")]
ZeroAddress,
#[msg("zero assets")]
ZeroAssets,
#[msg("zero shares")]
ZeroShares,
#[msg("invalid fee — must be ≤ 10000 bps")]
InvalidFee,
#[msg("insufficient asset liquidity in the vault")]
InsufficientLiquidity,
#[msg("insufficient share balance")]
InsufficientShares,
#[msg("too many holders for this account's capacity")]
TooManyHolders,
}
// Pass 2: Solana-native refactor of ExampleVault (ERC-4626).
//
// Structural moves:
// - Shares are an SPL Token Mint owned by the program via a `vault_authority`
// PDA. `mint_to` / `burn` flow through SPL Token; share transfer/approve
// happen on SPL Token directly (clients don't go through this program for
// share ERC-20 mechanics).
// - `totalAssets` and `totalSupply` are NOT stored on the vault. They read
// directly from `asset_reserve.amount` and `share_mint.supply` — the
// sources of truth maintained atomically by SPL Token.
// - `Vault` PDA holds only governance: asset_mint, share_mint, authority,
// fee_bps, fee_recipient, and cached bumps. Deposits/withdrawals do NOT
// write the vault account, which is a major parallelism win — only the
// share Mint and the asset reserve are write-locked per call, and those
// are inherent to having a single pool.
// - `mul_div(a, b, c, rounding)` is the single arithmetic primitive for the
// 4626 conversion math. All call sites pass the explicit `Rounding`
// direction required by the spec (deposit/redeem round Down; mint/withdraw
// round Up — both favor the vault).
// - Withdraw/redeem accept either the share owner *or* an SPL Token delegate
// as the signer. SPL Token's `burn` performs the authority check; our
// program does not re-implement allowance state.
//
// Security stance documented in code comments and in `05-explanation.md`:
// inflation-attack defense (virtual offset), rounding direction at every
// conversion site, and Token-2022 transfer-hook handling (rejected at the
// type level — see `DECISIONS.md`).
use anchor_lang::prelude::*;
use anchor_spl::token::{self, Burn, Mint, MintTo, Token, TokenAccount, Transfer};
declare_id!("Vault4626Native1111111111111111111111111111");
// Virtual-offset inflation defense (OpenZeppelin pattern).
// virtual_shares = 10**DECIMALS_OFFSET, virtual_assets = 1.
const DECIMALS_OFFSET: u8 = 6;
const VIRTUAL_SHARES_OFFSET: u128 = 1_000_000; // 10^6
const VIRTUAL_ASSETS_OFFSET: u128 = 1;
const BPS_DENOMINATOR: u128 = 10_000;
/// Rounding direction for 4626 conversions. Always passed explicitly at the
/// call site — no defaults — so a code reviewer can verify direction matches
/// the spec without guessing.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Rounding {
Down,
Up,
}
#[program]
pub mod vault4626_native {
use super::*;
pub fn initialize(
ctx: Context<Initialize>,
fee_bps: u16,
fee_recipient: Pubkey,
share_decimals: u8,
) -> Result<()> {
require!(fee_bps <= 10_000, VaultError::InvalidFee);
require_keys_neq!(fee_recipient, Pubkey::default(), VaultError::ZeroAddress);
// Validate share decimals match the spec: share_decimals = asset_decimals + offset.
let expected = ctx
.accounts
.asset_mint
.decimals
.checked_add(DECIMALS_OFFSET)
.ok_or(VaultError::InvalidShareDecimals)?;
require_eq!(
share_decimals,
expected,
VaultError::InvalidShareDecimals
);
let vault = &mut ctx.accounts.vault;
vault.asset_mint = ctx.accounts.asset_mint.key();
vault.share_mint = ctx.accounts.share_mint.key();
vault.authority = ctx.accounts.authority.key();
vault.fee_bps = fee_bps;
vault.fee_recipient = fee_recipient;
vault.bump = ctx.bumps.vault;
vault.vault_authority_bump = ctx.bumps.vault_authority;
vault.asset_reserve_bump = ctx.bumps.asset_reserve;
Ok(())
}
/// ERC-4626 `deposit(assets, receiver)`. Shares are rounded DOWN.
pub fn deposit(ctx: Context<Deposit>, assets: u64) -> Result<()> {
require!(assets > 0, VaultError::ZeroAssets);
let total_supply = ctx.accounts.share_mint.supply;
let total_assets = ctx.accounts.asset_reserve.amount;
let shares = convert_to_shares(assets, total_supply, total_assets, Rounding::Down)?;
require!(shares > 0, VaultError::ZeroShares);
// Pull asset tokens from depositor.
token::transfer(
CpiContext::new(
ctx.accounts.token_program.key(),
Transfer {
from: ctx.accounts.user_asset_ata.to_account_info(),
to: ctx.accounts.asset_reserve.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
},
),
assets,
)?;
// Mint shares to receiver — vault_authority PDA signs.
mint_shares(
&ctx.accounts.share_mint,
&ctx.accounts.receiver_share_ata,
&ctx.accounts.vault_authority,
&ctx.accounts.token_program,
&ctx.accounts.vault,
shares,
)?;
emit!(DepositEvent {
sender: ctx.accounts.user.key(),
receiver: ctx.accounts.receiver_share_ata.owner,
assets,
shares,
});
Ok(())
}
/// ERC-4626 `mint(shares, receiver)`. Assets are rounded UP.
pub fn mint(ctx: Context<Deposit>, shares: u64) -> Result<()> {
require!(shares > 0, VaultError::ZeroShares);
let total_supply = ctx.accounts.share_mint.supply;
let total_assets = ctx.accounts.asset_reserve.amount;
let assets = convert_to_assets(shares, total_supply, total_assets, Rounding::Up)?;
require!(assets > 0, VaultError::ZeroAssets);
token::transfer(
CpiContext::new(
ctx.accounts.token_program.key(),
Transfer {
from: ctx.accounts.user_asset_ata.to_account_info(),
to: ctx.accounts.asset_reserve.to_account_info(),
authority: ctx.accounts.user.to_account_info(),
},
),
assets,
)?;
mint_shares(
&ctx.accounts.share_mint,
&ctx.accounts.receiver_share_ata,
&ctx.accounts.vault_authority,
&ctx.accounts.token_program,
&ctx.accounts.vault,
shares,
)?;
emit!(DepositEvent {
sender: ctx.accounts.user.key(),
receiver: ctx.accounts.receiver_share_ata.owner,
assets,
shares,
});
Ok(())
}
/// ERC-4626 `withdraw(assets, receiver, owner_)`. Shares are rounded UP.
/// `signer` may be the share owner OR an SPL Token delegate of the share
/// ATA. SPL Token's `burn` enforces the authority check.
pub fn withdraw(ctx: Context<Withdraw>, assets: u64) -> Result<()> {
require!(assets > 0, VaultError::ZeroAssets);
require!(
ctx.accounts.asset_reserve.amount >= assets,
VaultError::InsufficientLiquidity
);
let total_supply = ctx.accounts.share_mint.supply;
let total_assets = ctx.accounts.asset_reserve.amount;
let shares = convert_to_shares(assets, total_supply, total_assets, Rounding::Up)?;
require!(shares > 0, VaultError::ZeroShares);
// Burn shares from owner's ATA — SPL Token verifies signer is owner or delegate.
token::burn(
CpiContext::new(
ctx.accounts.token_program.key(),
Burn {
mint: ctx.accounts.share_mint.to_account_info(),
from: ctx.accounts.owner_share_ata.to_account_info(),
authority: ctx.accounts.signer.to_account_info(),
},
),
shares,
)?;
// Transfer asset out — vault_authority PDA signs.
transfer_asset_out(
&ctx.accounts.asset_reserve,
&ctx.accounts.receiver_asset_ata,
&ctx.accounts.vault_authority,
&ctx.accounts.token_program,
&ctx.accounts.vault,
assets,
)?;
emit!(WithdrawEvent {
sender: ctx.accounts.signer.key(),
receiver: ctx.accounts.receiver_asset_ata.owner,
owner: ctx.accounts.owner_share_ata.owner,
assets,
shares,
});
Ok(())
}
/// ERC-4626 `redeem(shares, receiver, owner_)`. Assets are rounded DOWN.
pub fn redeem(ctx: Context<Withdraw>, shares: u64) -> Result<()> {
require!(shares > 0, VaultError::ZeroShares);
let total_supply = ctx.accounts.share_mint.supply;
let total_assets = ctx.accounts.asset_reserve.amount;
let assets = convert_to_assets(shares, total_supply, total_assets, Rounding::Down)?;
require!(assets > 0, VaultError::ZeroAssets);
require!(
ctx.accounts.asset_reserve.amount >= assets,
VaultError::InsufficientLiquidity
);
token::burn(
CpiContext::new(
ctx.accounts.token_program.key(),
Burn {
mint: ctx.accounts.share_mint.to_account_info(),
from: ctx.accounts.owner_share_ata.to_account_info(),
authority: ctx.accounts.signer.to_account_info(),
},
),
shares,
)?;
transfer_asset_out(
&ctx.accounts.asset_reserve,
&ctx.accounts.receiver_asset_ata,
&ctx.accounts.vault_authority,
&ctx.accounts.token_program,
&ctx.accounts.vault,
assets,
)?;
emit!(WithdrawEvent {
sender: ctx.accounts.signer.key(),
receiver: ctx.accounts.receiver_asset_ata.owner,
owner: ctx.accounts.owner_share_ata.owner,
assets,
shares,
});
Ok(())
}
/// Push realized yield into the vault. Mints fee shares to fee_recipient
/// at the pre-yield price.
pub fn earn(ctx: Context<Earn>, yield_amount: u64) -> Result<()> {
if yield_amount == 0 {
return Ok(());
}
// Snapshot pre-yield totals BEFORE the asset transfer — the fee is
// computed at the pre-yield price so existing holders capture the
// net-of-fee appreciation.
let total_supply_before = ctx.accounts.share_mint.supply;
let total_assets_before = ctx.accounts.asset_reserve.amount;
let mut fee_shares: u64 = 0;
if ctx.accounts.vault.fee_bps > 0 && total_supply_before > 0 {
// fee_assets = yield_amount * fee_bps / 10000, rounded DOWN (favor vault holders).
let fee_assets_u128 = (yield_amount as u128)
.checked_mul(ctx.accounts.vault.fee_bps as u128)
.ok_or(VaultError::Overflow)?
.checked_div(BPS_DENOMINATOR)
.ok_or(VaultError::DivByZero)?;
// fee_shares at pre-yield price, rounded DOWN.
fee_shares = mul_div_u128_to_u64(
fee_assets_u128,
(total_supply_before as u128)
.checked_add(VIRTUAL_SHARES_OFFSET)
.ok_or(VaultError::Overflow)?,
(total_assets_before as u128)
.checked_add(VIRTUAL_ASSETS_OFFSET)
.ok_or(VaultError::Overflow)?,
Rounding::Down,
)?;
if fee_shares > 0 {
mint_shares(
&ctx.accounts.share_mint,
&ctx.accounts.fee_recipient_share_ata,
&ctx.accounts.vault_authority,
&ctx.accounts.token_program,
&ctx.accounts.vault,
fee_shares,
)?;
}
}
// Transfer yield from the caller (authority) into the reserve.
token::transfer(
CpiContext::new(
ctx.accounts.token_program.key(),
Transfer {
from: ctx.accounts.authority_asset_ata.to_account_info(),
to: ctx.accounts.asset_reserve.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
),
yield_amount,
)?;
emit!(EarnEvent {
gross_yield: yield_amount,
fee_shares,
});
Ok(())
}
pub fn set_fee_bps(ctx: Context<AdminAction>, new_bps: u16) -> Result<()> {
require!(new_bps <= 10_000, VaultError::InvalidFee);
let vault = &mut ctx.accounts.vault;
let old = vault.fee_bps;
vault.fee_bps = new_bps;
emit!(FeeBpsUpdated { old, new_bps });
Ok(())
}
pub fn set_fee_recipient(
ctx: Context<AdminAction>,
new_recipient: Pubkey,
) -> Result<()> {
require_keys_neq!(new_recipient, Pubkey::default(), VaultError::ZeroAddress);
let vault = &mut ctx.accounts.vault;
let old = vault.fee_recipient;
vault.fee_recipient = new_recipient;
emit!(FeeRecipientUpdated {
old,
new_recipient,
});
Ok(())
}
pub fn set_authority(
ctx: Context<AdminAction>,
new_authority: Pubkey,
) -> Result<()> {
require_keys_neq!(new_authority, Pubkey::default(), VaultError::ZeroAddress);
ctx.accounts.vault.authority = new_authority;
Ok(())
}
}
// ---- 4626 conversion math ----
fn convert_to_shares(
assets: u64,
total_supply: u64,
total_assets: u64,
rounding: Rounding,
) -> Result<u64> {
mul_div_u128_to_u64(
assets as u128,
(total_supply as u128)
.checked_add(VIRTUAL_SHARES_OFFSET)
.ok_or(VaultError::Overflow)?,
(total_assets as u128)
.checked_add(VIRTUAL_ASSETS_OFFSET)
.ok_or(VaultError::Overflow)?,
rounding,
)
}
fn convert_to_assets(
shares: u64,
total_supply: u64,
total_assets: u64,
rounding: Rounding,
) -> Result<u64> {
mul_div_u128_to_u64(
shares as u128,
(total_assets as u128)
.checked_add(VIRTUAL_ASSETS_OFFSET)
.ok_or(VaultError::Overflow)?,
(total_supply as u128)
.checked_add(VIRTUAL_SHARES_OFFSET)
.ok_or(VaultError::Overflow)?,
rounding,
)
}
/// Compute `a * b / c` in u128, rounding per direction, then narrow to u64
/// with an explicit bounds check. Every step is checked; no silent wrap or
/// truncation is possible.
fn mul_div_u128_to_u64(a: u128, b: u128, c: u128, rounding: Rounding) -> Result<u64> {
require!(c > 0, VaultError::DivByZero);
let product = a.checked_mul(b).ok_or(VaultError::Overflow)?;
let result_u128 = match rounding {
Rounding::Down => product.checked_div(c).ok_or(VaultError::DivByZero)?,
Rounding::Up => {
// ceil(product / c) = (product + c - 1) / c
let c_minus_one = c.checked_sub(1).ok_or(VaultError::Overflow)?;
let raised = product.checked_add(c_minus_one).ok_or(VaultError::Overflow)?;
raised.checked_div(c).ok_or(VaultError::DivByZero)?
}
};
require!(
result_u128 <= u64::MAX as u128,
VaultError::Overflow
);
Ok(result_u128 as u64)
}
// ---- CPI helpers — vault_authority signs ----
fn mint_shares<'info>(
share_mint: &Account<'info, Mint>,
receiver_share_ata: &Account<'info, TokenAccount>,
vault_authority: &UncheckedAccount<'info>,
token_program: &Program<'info, Token>,
vault: &Account<'info, Vault>,
amount: u64,
) -> Result<()> {
let vault_key = vault.key();
let bump = vault.vault_authority_bump;
let signer_seeds: &[&[u8]] = &[b"vault_authority", vault_key.as_ref(), &[bump]];
token::mint_to(
CpiContext::new_with_signer(
token_program.key(),
MintTo {
mint: share_mint.to_account_info(),
to: receiver_share_ata.to_account_info(),
authority: vault_authority.to_account_info(),
},
&[signer_seeds],
),
amount,
)
}
fn transfer_asset_out<'info>(
asset_reserve: &Account<'info, TokenAccount>,
receiver_asset_ata: &Account<'info, TokenAccount>,
vault_authority: &UncheckedAccount<'info>,
token_program: &Program<'info, Token>,
vault: &Account<'info, Vault>,
amount: u64,
) -> Result<()> {
let vault_key = vault.key();
let bump = vault.vault_authority_bump;
let signer_seeds: &[&[u8]] = &[b"vault_authority", vault_key.as_ref(), &[bump]];
token::transfer(
CpiContext::new_with_signer(
token_program.key(),
Transfer {
from: asset_reserve.to_account_info(),
to: receiver_asset_ata.to_account_info(),
authority: vault_authority.to_account_info(),
},
&[signer_seeds],
),
amount,
)
}
// ---- Accounts ----
#[derive(Accounts)]
#[instruction(fee_bps: u16, fee_recipient: Pubkey, share_decimals: u8)]
pub struct Initialize<'info> {
#[account(
init,
payer = authority,
space = 8 + Vault::SIZE,
seeds = [b"vault", asset_mint.key().as_ref()],
bump,
)]
pub vault: Account<'info, Vault>,
/// Asset underlying. Classic SPL Token only — Token-2022 mints fail the
/// `Mint` deserialization (different owning program). This is the
/// intentional Token-2022 / transfer-hook rejection; see DECISIONS.md.
pub asset_mint: Account<'info, Mint>,
/// CHECK: PDA, signs both share-mint and asset-reserve operations.
/// Validated by seeds + bump; never deserialized.
#[account(
seeds = [b"vault_authority", vault.key().as_ref()],
bump,
)]
pub vault_authority: UncheckedAccount<'info>,
#[account(
init,
payer = authority,
mint::decimals = share_decimals,
mint::authority = vault_authority,
seeds = [b"share_mint", asset_mint.key().as_ref()],
bump,
)]
pub share_mint: Account<'info, Mint>,
#[account(
init,
payer = authority,
token::mint = asset_mint,
token::authority = vault_authority,
seeds = [b"asset_reserve", vault.key().as_ref()],
bump,
)]
pub asset_reserve: Account<'info, TokenAccount>,
#[account(mut)]
pub authority: Signer<'info>,
pub system_program: Program<'info, System>,
pub token_program: Program<'info, Token>,
pub rent: Sysvar<'info, Rent>,
}
/// Shared by `deposit` and `mint`. Vault is read-only — no field on the vault
/// changes during a deposit (`fee_bps`, `fee_recipient`, etc. are admin-only),
/// so cross-user deposits don't write-conflict on the vault account.
#[derive(Accounts)]
pub struct Deposit<'info> {
#[account(
seeds = [b"vault", asset_mint.key().as_ref()],
bump = vault.bump,
has_one = asset_mint,
has_one = share_mint,
)]
pub vault: Account<'info, Vault>,
pub asset_mint: Account<'info, Mint>,
#[account(mut)]
pub share_mint: Account<'info, Mint>,
/// CHECK: PDA, validated by seeds + cached bump. Signer for mint_to CPI.
#[account(
seeds = [b"vault_authority", vault.key().as_ref()],
bump = vault.vault_authority_bump,
)]
pub vault_authority: UncheckedAccount<'info>,
#[account(
mut,
seeds = [b"asset_reserve", vault.key().as_ref()],
bump = vault.asset_reserve_bump,
token::mint = asset_mint,
token::authority = vault_authority,
)]
pub asset_reserve: Account<'info, TokenAccount>,
#[account(mut, token::mint = asset_mint, token::authority = user)]
pub user_asset_ata: Account<'info, TokenAccount>,
#[account(mut, token::mint = share_mint)]
pub receiver_share_ata: Account<'info, TokenAccount>,
pub user: Signer<'info>,
pub token_program: Program<'info, Token>,
}
/// Shared by `withdraw` and `redeem`. Vault is read-only. `signer` may be
/// the share-ATA owner or an SPL Token delegate — SPL Token's `burn` enforces.
#[derive(Accounts)]
pub struct Withdraw<'info> {
#[account(
seeds = [b"vault", asset_mint.key().as_ref()],
bump = vault.bump,
has_one = asset_mint,
has_one = share_mint,
)]
pub vault: Account<'info, Vault>,
pub asset_mint: Account<'info, Mint>,
#[account(mut)]
pub share_mint: Account<'info, Mint>,
/// CHECK: PDA. Signer for transfer-out CPI.
#[account(
seeds = [b"vault_authority", vault.key().as_ref()],
bump = vault.vault_authority_bump,
)]
pub vault_authority: UncheckedAccount<'info>,
#[account(
mut,
seeds = [b"asset_reserve", vault.key().as_ref()],
bump = vault.asset_reserve_bump,
token::mint = asset_mint,
token::authority = vault_authority,
)]
pub asset_reserve: Account<'info, TokenAccount>,
/// Source of the burn. SPL Token verifies `signer` is `owner_share_ata.owner`
/// or `owner_share_ata.delegate` (with sufficient delegated_amount).
/// No `token::authority` constraint here — both paths must be allowed.
#[account(mut, token::mint = share_mint)]
pub owner_share_ata: Account<'info, TokenAccount>,
#[account(mut, token::mint = asset_mint)]
pub receiver_asset_ata: Account<'info, TokenAccount>,
pub signer: Signer<'info>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct Earn<'info> {
#[account(
seeds = [b"vault", asset_mint.key().as_ref()],
bump = vault.bump,
has_one = asset_mint,
has_one = share_mint,
has_one = authority,
constraint = fee_recipient_share_ata.owner == vault.fee_recipient
@ VaultError::FeeRecipientMismatch,
)]
pub vault: Account<'info, Vault>,
pub asset_mint: Account<'info, Mint>,
#[account(mut)]
pub share_mint: Account<'info, Mint>,
/// CHECK: PDA, signs share mint_to.
#[account(
seeds = [b"vault_authority", vault.key().as_ref()],
bump = vault.vault_authority_bump,
)]
pub vault_authority: UncheckedAccount<'info>,
#[account(
mut,
seeds = [b"asset_reserve", vault.key().as_ref()],
bump = vault.asset_reserve_bump,
token::mint = asset_mint,
token::authority = vault_authority,
)]
pub asset_reserve: Account<'info, TokenAccount>,
#[account(mut, token::mint = asset_mint, token::authority = authority)]
pub authority_asset_ata: Account<'info, TokenAccount>,
#[account(mut, token::mint = share_mint)]
pub fee_recipient_share_ata: Account<'info, TokenAccount>,
pub authority: Signer<'info>,
pub token_program: Program<'info, Token>,
}
#[derive(Accounts)]
pub struct AdminAction<'info> {
#[account(
mut,
seeds = [b"vault", vault.asset_mint.as_ref()],
bump = vault.bump,
has_one = authority,
)]
pub vault: Account<'info, Vault>,
pub authority: Signer<'info>,
}
// ---- State ----
#[account]
pub struct Vault {
pub asset_mint: Pubkey,
pub share_mint: Pubkey,
/// Governance authority — gates fee setters and `earn` (the yield source).
pub authority: Pubkey,
pub fee_bps: u16,
pub fee_recipient: Pubkey, // raw owner pubkey; ATA passed at call sites
pub bump: u8,
pub vault_authority_bump: u8,
pub asset_reserve_bump: u8,
}
impl Vault {
pub const SIZE: usize = 32 + 32 + 32 + 2 + 32 + 1 + 1 + 1; // 133 bytes
}
// ---- Events ----
#[event]
pub struct DepositEvent {
pub sender: Pubkey,
pub receiver: Pubkey,
pub assets: u64,
pub shares: u64,
}
#[event]
pub struct WithdrawEvent {
pub sender: Pubkey,
pub receiver: Pubkey,
pub owner: Pubkey,
pub assets: u64,
pub shares: u64,
}
#[event]
pub struct EarnEvent {
pub gross_yield: u64,
pub fee_shares: u64,
}
#[event]
pub struct FeeBpsUpdated {
pub old: u16,
pub new_bps: u16,
}
#[event]
pub struct FeeRecipientUpdated {
pub old: Pubkey,
pub new_recipient: Pubkey,
}
// ---- Errors ----
#[error_code]
pub enum VaultError {
#[msg("zero address")]
ZeroAddress,
#[msg("zero assets")]
ZeroAssets,
#[msg("zero shares")]
ZeroShares,
#[msg("invalid fee — must be ≤ 10000 bps")]
InvalidFee,
#[msg("share decimals must equal asset decimals + DECIMALS_OFFSET")]
InvalidShareDecimals,
#[msg("insufficient asset liquidity in the vault")]
InsufficientLiquidity,
#[msg("arithmetic overflow")]
Overflow,
#[msg("division by zero")]
DivByZero,
#[msg("fee recipient ATA owner does not match vault.fee_recipient")]
FeeRecipientMismatch,
}
Structured diff: 02-naive-port.rs → 03-optimized.rs
Each section names one meaningful change. Snippets are abridged; line references point at the canonical site of each change.
---
State model
S1. Vec<BalanceEntry> for shares → SPL Token Mint
Naive (02-naive-port.rs:474–483, :482):
#[account]
pub struct VaultState {
pub asset_mint: Pubkey,
pub owner: Pubkey,
pub fee_bps: u16,
pub fee_recipient: Pubkey,
pub total_assets: u64,
pub total_supply: u64,
pub balances: Vec<BalanceEntry>, // SMELL: write-hot, capped, O(n) scan
}Optimized (03-optimized.rs:673–683):
#[account]
pub struct Vault {
pub asset_mint: Pubkey,
pub share_mint: Pubkey, // <-- SPL Token Mint, not stored balances
pub authority: Pubkey,
pub fee_bps: u16,
pub fee_recipient: Pubkey,
pub bump: u8,
pub vault_authority_bump: u8,
}Shares live in user-owned SPL Token Accounts; the vault no longer tracks per-user share balances. See optimization/account-model.md — the general "per-account, not per-contract" rule applied to shares.
---
S2. total_assets and total_supply fields deleted
Naive (02-naive-port.rs:480–481): manually maintained on the vault, mutated at :78–:79, :114–:115, :155–:156, :198–:199, :248, :251.
Optimized: not stored. Every conversion reads them from SPL Token directly:
let total_supply = ctx.accounts.share_mint.supply; // 03-optimized.rs:91, :132, :174, :219
let total_assets = ctx.accounts.asset_reserve.amount; // 03-optimized.rs:92, :133, :175, :220SPL Token maintains both atomically as a side effect of mint_to/burn/transfer. Self-tracking is redundant and (per §P1 below) is what forces vault to be writable on every deposit. Deleting these fields is the load-bearing move.
---
S3. Share-token ERC-20 surface unified under SPL Token
Naive: no share_transfer/share_approve instructions (omitted for brevity). To use them in production would require a balance/allowance map.
Optimized: share transfer/approve happen via SPL Token directly. Clients call spl_token::transfer / spl_token::approve against their share ATA. Withdraw/redeem use SPL Token's built-in delegate (see §C2).
See translation/stdlib-mapping.md — same "transfers run on SPL Token, the program handles governance only" pattern, applied to vault shares.
---
Parallelism
P1. Vault is READ-ONLY during deposit / mint / withdraw / redeem
Naive — vault is writable on every user action (02-naive-port.rs:408):
#[account(mut, seeds = [b"vault"], bump)] // SMELL
pub vault: Account<'info, VaultState>,All four user-facing 4626 operations write the vault (to mutate total_assets, total_supply, and the share balances Vec). The vault is the cross-user serialization bottleneck.
Optimized — vault is not writable in Deposit (03-optimized.rs:531–537) or Withdraw (03-optimized.rs:574–580):
#[account(
seeds = [b"vault", asset_mint.key().as_ref()],
bump = vault.bump,
has_one = asset_mint,
has_one = share_mint,
)]
pub vault: Account<'info, Vault>, // no `mut`Vault only mutates in admin paths (set_fee_bps, set_fee_recipient, set_authority). The user-facing instructions read vault for bumps + has_one cross-checks, but write only the share Mint, the asset reserve, and the depositor's ATAs.
The result: deposits from disjoint users / redeems from disjoint users only conflict on the inherent globals — share_mint.supply and asset_reserve.amount — which are maintained by SPL Token and have no app-level contention beyond what a single fungible token always has.
This is a more dramatic parallelism win than the staking-vault example, which had an unavoidably write-hot vault (Synthetix accumulator). 4626's conversion math is pure — no per-call checkpoint to mutate — so the vault stays read-only.
---
P2. O(n) Vec scans eliminated
Naive (02-naive-port.rs:346–368): linear-scan find(|e| e.holder == x) on every share mint/burn.
Optimized: no scans. Per-user share ATAs are addressed by deterministic derivation; SPL Token does the rest.
---
Security
Sec1. Unchecked arithmetic → checked_* with mul_div helper
Naive — SMELL markers at 02-naive-port.rs:78, :79, :114, :115, :155, :156, :198, :199, :238 (fee math), :248, :251, :317, :324, :331–:332, :339, :348, :366. Example (:238):
let fee_assets = ((yield_amount as u128) * (vault.fee_bps as u128)) / 10_000u128;
let num = fee_assets * ((vault.total_supply as u128) + VIRTUAL_SHARES_OFFSET);
let den = (vault.total_assets as u128) + VIRTUAL_ASSETS_OFFSET;
fee_shares = (num / den) as u64; // SMELL: silent truncationOptimized (03-optimized.rs:399–418): single mul_div_u128_to_u64 helper used at every conversion site.
fn mul_div_u128_to_u64(a: u128, b: u128, c: u128, rounding: Rounding) -> Result<u64> {
require!(c > 0, VaultError::DivByZero);
let product = a.checked_mul(b).ok_or(VaultError::Overflow)?;
let result_u128 = match rounding {
Rounding::Down => product.checked_div(c).ok_or(VaultError::DivByZero)?,
Rounding::Up => {
let c_minus_one = c.checked_sub(1).ok_or(VaultError::Overflow)?;
let raised = product.checked_add(c_minus_one).ok_or(VaultError::Overflow)?;
raised.checked_div(c).ok_or(VaultError::DivByZero)?
}
};
require!(result_u128 <= u64::MAX as u128, VaultError::Overflow);
Ok(result_u128 as u64)
}Every multiply checked. Every divide checked (catches c = 0). Every add in the ceiling formula checked (catches product + (c - 1) overflow). Final cast guarded by explicit bounds check.
---
Sec2. Explicit Rounding direction at every conversion site
Naive — direction is implicit in two separate code paths: preview_deposit/preview_redeem round down via / (02-naive-port.rs:317, :324); preview_mint/preview_withdraw round up via ad-hoc (num + den - 1) / den (02-naive-port.rs:332, :339). A reviewer must read each helper to know its direction.
Optimized (03-optimized.rs:46–50, used at :93, :134, :177, :222, :284):
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Rounding { Down, Up }Every call site states direction explicitly:
let shares = convert_to_shares(assets, total_supply, total_assets, Rounding::Down)?; // deposit
let assets = convert_to_assets(shares, total_supply, total_assets, Rounding::Up)?; // mint
let shares = convert_to_shares(assets, total_supply, total_assets, Rounding::Up)?; // withdraw
let assets = convert_to_assets(shares, total_supply, total_assets, Rounding::Down)?; // redeemERC-4626 rounding direction is part of the spec — wrong direction is exploitable. The optimized version makes the spec audit a four-line check.
---
Sec3. PDA bumps cached + canonicalization enforced
Naive — bare bump on every PDA constraint (02-naive-port.rs:377, :381, :391, :399, :408, :412, :418, :428, :432, :438, :448, :452, :458, :466). Bump not stored on the vault.
Optimized — Vault.bump and Vault.vault_authority_bump stored at init (03-optimized.rs:75–76), supplied on every subsequent access (03-optimized.rs:530, :537, :546, :573, :580, :589, :617, :625, :634, :662).
See security/pda-canonicalization.md for the full pattern: storing the canonical bump pins the PDA identity at init, eliminates the non-canonical-bump bug class, and skips the ~1500 CU cost of find_program_address on every call.
---
Sec4. Vault token-account authority scoped to vault key
Naive — seeds = [b"vault_authority"] (singleton across the whole program).
Optimized — seeds = [b"vault_authority", vault.key().as_ref()] (per-vault). Supports the multi-vault future (one vault per asset_mint) without all vaults sharing a single signing authority.
---
Sec5. Token-2022 / transfer-hook rejection at the type level
Both versions use anchor_spl::token::{Mint, TokenAccount} (classic SPL Token). Anchor verifies the account's owner program is the classic SPL Token program — Token-2022 mints fail deserialization. Documented in DECISIONS.md.
This is intentional — it eliminates the cross-program-reentrancy attack vector where a Token-2022 asset_mint with a transfer hook could call back into the vault during deposit/withdraw's SPL Token CPI. Supporting Token-2022 underlyings requires either (a) explicit reentrancy-state machine in the vault, or (b) constraining underlying to a known-safe extension subset. Neither is in scope for this example.
---
Sec6. ERC-4626 inflation-attack defense preserved + tested
Both ports include the OZ virtual-offset defense: virtual_shares = 10^6, virtual_assets = 1. Numerator/denominator of every share-asset conversion includes these terms (02-naive-port.rs:39–40, 03-optimized.rs:37–38).
The optimized version's mul_div_u128_to_u64 makes the defense calculation overflow-safe (Sec1) and direction-explicit (Sec2), so the defense cannot be silently degraded by a future arithmetic bug. The explanation log §"Inflation defense" walks through a numeric example demonstrating the bounded loss.
---
Sec7. has_one constraints cross-validate vault references
Naive — no has_one. Vault is the only place asset_mint/share_mint live, so cross-checks aren't needed within the vault account itself, but if a future instruction trusts asset_mint without going through vault, no constraint catches a swap.
Optimized — has_one = asset_mint, has_one = share_mint on Deposit/Withdraw/Earn (03-optimized.rs:534–535, :578–:579, :619–:620); has_one = authority on Earn and AdminAction (03-optimized.rs:621, :662). Anchor enforces all four cross-links before the handler runs.
---
CPI & program reuse
C1. Share mint/burn via SPL Token CPI (PDA-signed mint)
Naive: balance updates are direct Vec mutations.
Optimized (03-optimized.rs:420–443 mint helper; :188–:198 burn at withdraw site): token::mint_to for share issuance (signed by vault_authority PDA); token::burn for share destruction (signed by the user or their delegate).
The canonical "program mints via CPI, signed by an authority PDA" pattern — see translation/stdlib-mapping.md.
---
C2. Withdraw-from-owner-by-delegate uses SPL Token's native delegate
Solidity withdraw(assets, receiver, owner_) allows msg.sender != owner_ if msg.sender has an allowance. The Solidity implementation maintains an allowance map and _spendAllowance's into it.
Naive port omits this entirely (02-naive-port.rs:441 notes the smell: requires owner == signer).
Optimized port uses SPL Token's per-ATA delegate field (03-optimized.rs:603–605):
/// No `token::authority` constraint here — both paths (owner-signs and delegate-signs)
/// must be allowed. SPL Token's `burn` does the auth check.
#[account(mut, token::mint = share_mint)]
pub owner_share_ata: Account<'info, TokenAccount>,The signer may be the ATA's owner OR its SPL delegate; SPL Token's burn enforces. No custom allowance state on the vault.
---
C3. Aggregates read from SPL Token, not stored locally
Diff §S2 is the state-model framing; here is the resulting CPI-side fact: every conversion reads share_mint.supply and asset_reserve.amount directly from SPL Token's deserialized state. SPL Token maintains them; we read them.
This is the single most important architectural insight in the 4626 port: a Solana 4626 vault doesn't need to track totals — SPL Token does.
---
Compute & rent
R1. Vault size shrinks from ~4 KB to 132 bytes
Naive (02-naive-port.rs:486): VaultState::SIZE = 32 + 32 + 2 + 32 + 8 + 8 + 4 + 100*40 = 4118 bytes. Rent: ~0.029 SOL, paid by owner at init.
Optimized (03-optimized.rs:686): Vault::SIZE = 132 bytes. Rent: ~0.0025 SOL. Each holder's share ATA (165 bytes, ~0.0019 SOL) is paid by the holder, not the vault.
R2. Per-call data load shrinks ~30×
Naive: every user instruction loads the full ~4 KB VaultState (including Vec deserialization).
Optimized: every user instruction loads Vault (132 bytes) + Mint (82 bytes) + TokenAccount (165 bytes) ≈ 380 bytes of program state, plus user ATAs (~330 bytes). The Vec deserialization cost is gone entirely.
---
Idioms
I1. Pure conversion helpers + Rounding enum
Naive: four separate preview functions, each with its own ad-hoc arithmetic.
Optimized (03-optimized.rs:360–397): two pure helpers (convert_to_shares, convert_to_assets) parameterized by Rounding. Each preview operation maps to one call. Single arithmetic site (mul_div_u128_to_u64); every overflow/division/narrowing concern in one place.
I2. Consolidated CPI helpers for vault_authority-signed operations
Naive: signer-seed construction inline at every call site (02-naive-port.rs:158–:159, :201–:202). Easy to drift between sites.
Optimized (03-optimized.rs:420–460): mint_shares and transfer_asset_out helpers. Signer seeds constructed once, in one place, using the cached bump.
I3. SPL Token's Mint/TokenAccount deserialized at instruction entry
Optimized leans hard on Anchor's typed account wrappers — every Account<'info, Mint> and Account<'info, TokenAccount> Anchor verifies owner (classic SPL Token program), parses fields, and exposes mint.supply/account.amount as plain u64. No try_borrow_data / manual deserialization.
I4. Errors: 9 typed variants covering every failure class
Naive (02-naive-port.rs:544–562): 7 variants, no Overflow / DivByZero.
Optimized (03-optimized.rs:728–748): 9 variants including Overflow, DivByZero, InvalidShareDecimals, FeeRecipientMismatch. Every conversion failure path maps to a specific error.
Explanation log: 02-naive-port.rs → 03-optimized.rs
One entry per change in 04-diff.md, grouped by theme. Each entry follows the schema: What / Why / Benefit / Tradeoff.
This is the ERC-4626 tokenized vault — depositors hand the vault an underlying asset and receive share tokens that grow in value as the vault earns. The example translates a textbook Solidity 4626 into a Solana-native shape and teaches three Solana lessons beyond what the ERC-20 and escrow examples cover:
1. Read aggregates from SPL Token, not from your own state. Solidity's totalSupply and totalAssets are fields the contract maintains itself. On Solana the SPL Token program already keeps both (a Mint's supply is totalSupply; the reserve Token Account's amount is totalAssets). Storing them yourself duplicates audited code and forces the vault account to be writable on every deposit/withdraw — which kills the parallelism win you'd otherwise get. 2. Rounding direction is a security property, not a style choice. ERC-4626 mandates floor on deposit/redeem and ceil on mint/withdraw — all favoring the vault, so a delegate can't drain dust by repeated redemptions. The optimized version makes this explicit with a Rounding enum at every call site. 3. Inflation-attack defense transfers verbatim from OpenZeppelin. The virtual-offset pattern still works; what changes is that the surrounding arithmetic has to be checked end-to-end. Silent truncation in a naive port would degrade the defense without warning.
Vocabulary that comes up below, with EVM analogs:
- SPL Token — The single shared on-chain token program on Solana. Every fungible token is just configuration on this one program; nobody deploys their own ERC-20 equivalent. Here, the vault's share token uses SPL Token rather than a custom contract.
- Mint account — The on-chain config for one token:
supply,decimals,mint_authority,freeze_authority. Owned by the SPL Token program. The closest Solidity analog is "the constant fields of an ERC-20 plustotalSupply, but all stored on the token program, not the issuer". - Token Account — One user's balance for one specific token. Owned by the SPL Token program, owned-by (the
ownerfield) the wallet that controls it. The Solidity analog is "thebalances[user]entry, except it's its own on-chain account". - ATA / Associated Token Account — The canonical per-wallet Token Account for a given mint. Its address is deterministically derivable from
(wallet, mint). The Solidity analog is "whatbalances[user]would be if Solidity gave it a deterministic address". - PDA (Program-Derived Address) — A deterministic on-chain account address derived from byte seeds the program controls. The vault, the share mint, the asset reserve, and the vault-authority signer are all PDAs.
- CPI (cross-program invocation) — One program calling another, the way one Solidity contract calls another. The vault CPIs into SPL Token to mint shares, burn shares, and transfer underlying.
- rent — A refundable SOL deposit every account pays to live on-chain. Refunded in full when the account is closed.
- Anchor — The framework around the raw Solana program API; provides macros, account validation, the IDL. Hardhat-to-EVM analog.
- `has_one = X` — An Anchor account constraint: "the account's stored
Xfield must equal theXaccount passed in this instruction". - `Account<'info, T>` — Anchor's typed account wrapper. Performs owner-program check, discriminator check, and deserialization automatically. Skipping it invites type-confusion bugs (treating a Mint as a Vault, etc.).
After first use, each term is fair game.
---
State model
Replace Vec<BalanceEntry> shares with an SPL Token Mint (diff §S1)
- What: Removed
balances: Vec<BalanceEntry>fromVaultState(02-naive-port.rs:482). Shares are now an SPL Token Mint (03-optimized.rs:675); each holder owns an SPL Token Account (their ATA) for the share mint. - Why: In Solidity, an ERC-4626 vault is also an ERC-20 — the contract holds a
mapping(address => uint256) balanceOffor shares. The Solana equivalent is to make shares a real SPL Token: a Mint account for "the share token" plus per-holder Token Accounts (typically ATAs, the canonical per-wallet Token Account for a given mint). Keeping theVecwould force every deposit/withdraw to write-lock the shared vault state account, serializing all deposits behind each other. With shares as an SPL Token, different holders' deposits/withdraws touch different Token Accounts and run in parallel. - Benefit: Holder count is unbounded. Per-holder share writes parallelize (Alice's ATA and Bob's ATA are disjoint accounts, so transactions touching them don't conflict). The vault account itself shrinks dramatically (§R1) because it no longer carries the per-holder balance table.
- Tradeoff: First-time share recipients pay ATA rent (~0.002 SOL — a refundable SOL deposit to keep the account alive). Off-chain code reads share balances by querying the holder's share ATA through SPL Token, not by reading a
balancesfield on the vault. Standard Solana UX for any token, including the share token here.
Delete total_assets / total_supply; read them from SPL Token (diff §S2)
- What: Removed both fields from the vault and their
+=/-=mutations on every deposit/withdraw path. Every conversion site now readsshare_mint.supply(the share token's total supply, maintained by SPL Token) andasset_reserve.amount(the vault's underlying balance, also an SPL Token field) directly (03-optimized.rs:91–92,:132–:133, etc.). - Why: SPL Token maintains both values atomically as side effects of
mint_to(mintsnand bumps supply bynin one operation),burn(the inverse), andtransfer(no supply change, but the source/destination amounts update atomically). Self-tracking duplicates audited code AND forces the vault to be writable on every deposit/withdraw — exactly the parallelism penalty §P1 below is designed to remove. - Benefit: One source of truth instead of two — no risk of
total_assetsdrifting fromasset_reserve.amountdue to a missed update path. The vault becomes read-only during all user-facing 4626 operations, which unlocks the parallelism win below. - Tradeoff: One extra account passed to each call (the
asset_reserveToken Account, so the program can read itsamount). Anchor already needs it asmutfor the underlying transfers, so the marginal cost is zero — the account was going to be in the list anyway.
Move share ERC-20 surface to SPL Token (diff §S3)
- What: Share transfer/approve are not vault instructions — clients call SPL Token directly with the share mint. The vault exposes only the 4626 operations (deposit, mint, withdraw, redeem) plus governance.
- Why: Same lesson as the ERC-20 example, applied to the share token. Reimplementing transfer/approve inside the vault would duplicate the SPL Token program — auditable code, audited primitives, a bigger surface for bugs, and no integration benefit. See Frontend integration below for the concrete client-side delta.
- Benefit: Vault code shrinks. The share token plugs into every Solana wallet, explorer, and indexer for free.
- Tradeoff: Clients have to know that "the share token" is an SPL Token with a known mint address; they read balances through SPL Token, not through the vault. Documented in the Frontend integration section.
---
Parallelism
Vault is READ-ONLY on every user-facing 4626 operation (diff §P1)
- What:
DepositandWithdrawaccount structs omitmuton the vault account (03-optimized.rs:528–537,:571–:580). The vault is only markedmutin admin instructions (set_fee_bps, etc.). Solana's runtime locks writable accounts for the duration of a transaction; declaring the vault read-only here means deposit/withdraw don't claim a write-lock on it. - Why: With
total_assets/total_supplydeleted (§S2) and balances moved to SPL Token (§S1), there is nothing left on the vault account for a deposit/withdraw to mutate. The vault stores only governance fields (fee_bps,authority, cached bumps) which change at admin-rate, not transaction-rate. - Benefit: Cross-user deposit/withdraw transactions don't write-conflict on the vault at all. Conflict remains only at the inherent globals —
share_mint.supply(every mint/burn touches it) andasset_reserve.amount(every transfer in/out touches it). Both are maintained by SPL Token; both are write-hot for any fungible-token system regardless of design. This is the minimum possible contention for a vault on Solana. - Tradeoff: None. This is the largest parallelism win in this example: in Solidity every deposit and every withdraw mutates the vault contract (the EVM serializes them anyway), but on Solana, removing the unnecessary self-tracking lets the runtime actually run them in parallel.
O(n) Vec scans eliminated (diff §P2)
- What: Removed the
iter_mut().find()lookups for share balance entries (the naive port scanned thebalancesVec linearly to find a holder's row). - Why: Same reasoning as in the escrow and token-fundraiser examples. A linear scan inside an account costs compute units linearly in the holder count and requires the full Vec to be deserialized at instruction entry. With shares as SPL Token, the holder's ATA is loaded directly by Anchor — no scan, fixed-size deserialization.
- Benefit: Constant-time lookup regardless of holder count.
- Tradeoff: None.
---
Security
Every arithmetic op uses checked_*; one mul_div_u128_to_u64 helper centralizes the 4626 math (diff §Sec1)
- What: Replaced bare
*///+/-andas u64casts with a single helper (03-optimized.rs:399–418) that does checked multiplication, checked division, checked ceiling-addition for the up-rounding case, and a bounds-guarded narrowing back tou64after the intermediateu128math. - Why: ERC-4626 conversion math is the single most security-sensitive code in a vault. Solidity 0.8+ checks arithmetic by default; Rust release builds wrap silently on overflow. A silent overflow in the share/asset conversion doesn't fail — it returns a wrong number that the depositor cheerfully accepts. The naive port had silent truncation on every preview path (
02-naive-port.rs:317,:324,:332,:339), silent overflow on the ceiling-add for up-rounding, and silent wrap on the balance updates. Each could individually be exploited to either dilute honest holders or extract value from rounding errors. - Benefit: Every arithmetic failure is now a typed error (
Overflow,DivByZero). The helper is one place to audit; auditors don't have to re-verify the math at every call site. Inflation-defense math (which addsVIRTUAL_SHARES_OFFSETto the numerator — see §Sec6) is checked end-to-end. - Tradeoff: Verbose helper body — about 30 lines. Acceptable; it's the highest-stakes 30 lines in the program.
Explicit Rounding enum at every conversion site (diff §Sec2)
- What: Added a
Rounding { Down, Up }enum (03-optimized.rs:46–50). Every preview/conversion call passes a direction explicitly. The helper signatures takerounding: Roundingand floor or ceiling accordingly. - Why: ERC-4626's rounding directions are part of the spec, not an implementation detail. The rules:
depositrounds shares down — the depositor gets at most their fair share.mintrounds assets up — the depositor pays at least their fair share.withdrawrounds shares up — the withdrawer burns at least their fair share.redeemrounds assets down — the withdrawer gets at most their fair share.
Every direction favors the vault, which prevents dust-extraction attacks (a delegate that repeatedly redeems 1 wei to drain rounding errors). The naive port computed direction implicitly inside each function; a future refactor that consolidates the helpers risks silently flipping a direction. An explicit Rounding arg at every call site makes the audit trivial: 4 lines, one per direction.
- Benefit: Wrong direction is a code review failure, not a silent runtime bug. The audit checklist is "scan for
Rounding::Downon deposit/redeem andRounding::Upon mint/withdraw" — minutes, not hours. - Tradeoff: Helper signature is longer. Worth it.
PDA bumps cached + canonicalization enforced (diff §Sec3)
- What: Store
Vault.bumpandVault.vault_authority_bumpat init, pass viabump = vault.bumpin account validation on every subsequent call. - Why: Re-deriving with
find_program_addresscosts ~1500 CU per call. Accepting a non-canonical bump opens a bug class where an attacker passes a different valid bump and the program signs for a different address than it thinks. Pinning the canonical bump at init eliminates both. - Benefit: Cheaper instructions; closes the non-canonical-bump attack class.
- Tradeoff: Two
u8s of account space. Seesecurity/pda-canonicalization.mdfor the full pattern.
Vault-authority signing scoped to the vault key (diff §Sec4)
- What: The signer PDA's seeds are
[b"vault_authority", vault.key().as_ref()](per-vault) instead of[b"vault_authority"](singleton). - Why: The
vault_authorityPDA is what signs CPIs to mint shares, burn shares, and move underlying out of the reserve. If the program ever grows to support multiple vaults (one per asset, say), a singleton authority across vaults would mean a bug in one vault's withdrawal flow could be used to move funds out of any other vault — because the same signing identity authorizes all of them. Includingvault.key()in the seeds makes each vault's authority cryptographically distinct. - Benefit: Per-vault authority isolation. The blast radius of any bug in CPI signing is one vault.
- Tradeoff: Seeds are slightly longer (one extra 32-byte pubkey). Negligible CU/storage cost.
Classic SPL Token only — Token-2022 transfer hooks rejected at the type level (diff §Sec5)
- What: Both ports use
anchor_spl::token::{Mint, TokenAccount}(the classic SPL Token bindings). Anchor's typed-account wrapper verifies that the account's owning program is the classic SPL Token program ID; a Token-2022 mint passed in would fail the typed-account check, and the instruction reverts before the handler runs. - Why: Token-2022 is the newer SPL Token variant with extensions (transfer fees, interest, confidential transfers, transfer hooks). The Transfer Hook extension specifically lets the mint specify an arbitrary program that runs on every transfer — including the vault's
token::transferCPI. That program could call back into the vault, which is a classic cross-program reentrancy surface. Allowing Token-2022 underlyings here would require either an explicit reentrancy state machine in the vault, or an allowlist of "safe" extensions. Both add real complexity. For this example, the right answer is to reject Token-2022 entirely at the type level and document the choice. - Benefit: An entire class of attacks (transfer-hook reentrancy) is structurally impossible. No defensive code required in the vault body.
- Tradeoff: Vaults cannot accept Token-2022 underlyings without a redesign. Noted in the
Initializedoc comment. A production deployment that needs both token flavors should split the program into two — one for classic SPL, one for Token-2022 — so the security stance is per-program, not per-vault.
ERC-4626 inflation-attack defense preserved (diff §Sec6)
- What: Both ports include the virtual-offset terms in every conversion. The optimized version's checked math means the defense cannot be silently degraded by an arithmetic bug.
- Why: The canonical 4626 attack: an attacker stakes 1 wei before any honest user and receives 1 share. They then donate a large amount of underlying directly to the vault's reserve. Now
totalAssetsis huge buttotalSupplyis 1. An honest user's deposit of N wei converts toN * 1 / huge ≈ 0shares — they got zero shares for their deposit, and the attacker still owns ~100% of the vault.
OpenZeppelin's mitigation is a virtual offset: pretend the vault has 10^DECIMALS_OFFSET = 10^6 virtual shares and 1 virtual asset already. The attacker's 1-wei first stake now mints 1 * (0 + 10^6) / (0 + 1) = 10^6 shares. After a 10^9-wei donation, an honest deposit of 10^6 assets still mints ~2000 real shares (not zero), so the honest user can redeem and get most of their assets back. The attack still bounds rounding losses but is no longer total-loss.
| Step | totalSupply (shares) | totalAssets | Note |
|---|---|---|---|
| Attacker deposits 1 asset | 10^6 | 1 | First deposit; virtual offset gives 10^6 shares |
| Attacker donates 10^9 directly to reserve | 10^6 | 10^9 + 1 | Donation skews the share price |
| Honest deposits 10^6 assets | ~10^6 + 2000 | 10^9 + 10^6 + 1 | Honest user gets ~2000 shares, not zero |
| Honest redeems 2000 shares | … | … | Receives ~10^6 assets back; loss is dust |
Without the offset, the honest user would receive zero shares for their 10^6 assets — full loss.
- Benefit: The standard 4626 attack class is mitigated. Translation is mechanical because the defense is preserved as-is from OpenZeppelin's Solidity; the optimized version's checked arithmetic ensures a future code change can't silently weaken it.
- Tradeoff: Share decimals = asset decimals + 6, which means the share token has 6 more decimal places than the underlying. The frontend should display whole shares (
amount / 10^share_decimals) rather than rawamount. Standard Solana token UX. Regression-test idea: attotal_supply = 0, total_assets = 0, depositing 1 asset must yield ≥10^6shares. If a future code change makes the first depositor get 1 share or fewer, the defense is broken — assertconvert_to_shares(1, 0, 0, Rounding::Down) >= 1_000_000in unit tests.
has_one cross-validation (diff §Sec7)
- What: Added
has_one = asset_mint,has_one = share_mint, andhas_one = authorityconstraints to the relevant account structs (03-optimized.rs:550,:592, etc.).has_one = Xis an Anchor constraint that tells the framework: "the account's storedXfield must equal theXaccount passed into this instruction" — declarative form ofrequire(vault.asset_mint == asset_mint_account). - Why: A future instruction author who adds an account to the program could forget to check that it links back to the vault correctly.
has_onemakes the linkage declarative — Anchor enforces it at account validation time before any handler code runs. The reviewer sees the access-control rules at the top of the struct, the same way a Solidity reviewer scans foronlyOwnermodifiers. - Benefit: Audits become local. Read the struct, see the constraints, done — no need to walk handler bodies to verify cross-references.
- Tradeoff: None.
---
CPI & program reuse
Share mint/burn via SPL Token CPI (diff §C1)
- What:
token::mint_toissues shares (signed by thevault_authorityPDA, since the share mint's authority is set to that PDA at init);token::burndestroys shares (signed by the holder or their delegate). Both happen via CPI into the SPL Token program. - Why: Same architectural move as the other examples — use audited SPL Token for token mechanics. The vault retains only the conversion math and governance gating; the token movements are SPL Token's responsibility. The Solidity ERC-4626 has
_mint(receiver, shares)and_burn(owner, shares)calling its inherited ERC-20; the Solana analog is the SPL Token CPI. - Benefit: No custom mint/burn arithmetic in the vault. Standard SPL Token wallet integration — every Solana wallet, explorer, and indexer recognizes the share token automatically.
- Tradeoff: ~5,000 CU per CPI (the cost of crossing a program boundary). Acceptable — the conversion math is the costly part, not the CPIs.
Withdraw-by-delegate uses SPL Token's native delegate (diff §C2)
- What: The
Withdrawinstruction accepts a genericsigner: Signer<'info>with notoken::authorityconstraint on the share ATA. SPL Token'sburninstruction accepts the signer if it's either the ATA's owner or its registered delegate (with sufficientdelegated_amount). - Why: Solidity's
withdraw(assets, receiver, owner_)lets a delegate spend the owner's allowance — this is a real workflow (yield aggregators, sweep accounts, etc.). SPL Token has the same primitive built in:spl_token::approvesets a single delegate per Token Account with a delegated amount. Reimplementing an allowance map in the vault would duplicate this and introduce a custom code path. The right answer is to lean on SPL Token's native delegate. - Benefit: No allowance map in the vault. Standard SPL Token approve/transferFrom-style flows work without per-vault integration.
- Tradeoff: SPL Token allows one delegate per Token Account, not a
(owner, spender) → amountmap. Same gap as the ERC-20 example — uncommon to need >1 active delegate per holder, but if your protocol does, you'd need a custom allowance PDA per(owner, spender)pair.
Aggregates read from SPL Token, not stored (diff §C3)
- What: Per-call reads of
share_mint.supplyandasset_reserve.amountduring every conversion. No self-tracked totals on the vault. - Why: SPL Token is the source of truth — anything the vault would store about totals is a duplicate that can drift. Eliminating the duplicate eliminates the divergence-bug class where a missed
+=/-=makes self-tracked totals lie about reality. - Benefit: Code shrinks. The vault is read-only on user actions (§P1) — self-tracking would have made the vault writable and undone the parallelism win.
- Tradeoff: None.
---
Compute & rent
Vault size: ~4 KB → 132 bytes (diff §R1)
- What:
VaultState::SIZE = 4118→Vault::SIZE = 132. - Why: Removing the 100-entry
Vec<BalanceEntry>saves ~4000 bytes; storing only governance fields and cached bumps leaves ~130. Rent on Solana scales with account size (~0.001 SOL per KB), so the protocol-paid rent shrinks ~30×. - Benefit: Significantly cheaper to deploy. Vault deserialization on every instruction entry is faster (smaller struct).
- Tradeoff: Each share holder pays ~0.002 SOL for their ATA when they first receive shares. Refunded if the ATA is later closed. Standard Solana UX.
Per-call data load: ~30× smaller (diff §R2)
- What: ~4 KB Vec deserialization on the naive's vault entry replaced by ~380 bytes total of SPL Token + vault account loads in the optimized version.
- Why: Anchor deserializes account data on instruction entry — it has to type-check it before the handler runs. Smaller accounts = faster entry = fewer compute units burned before any user logic.
- Benefit: Multi-kCU savings per call — meaningful inside Solana's 200K-CU per-instruction budget.
- Tradeoff: None.
---
Idioms
Pure conversion helpers + Rounding enum (diff §I1)
- What / Why / Benefit / Tradeoff: See §Sec1 and §Sec2. The structural and security wins are linked — the helpers are pure functions, take rounding direction explicitly, and live in one auditable place.
Consolidated CPI helpers (diff §I2)
- What:
mint_shares()andtransfer_asset_out()factor thevault_authority-signed CPIs. The signer seeds are constructed once inside the helper. - Why: Signer-seed construction is a common bug surface — a typo in one of multiple inline constructions can cause the program to sign for a different PDA than intended. One helper means one place to audit.
- Benefit: Auditable seed construction. Easier to add new
vault_authority-signed operations without drift. - Tradeoff: A few extra helper signatures in the file. Trivial.
Use Anchor's typed Mint / TokenAccount (diff §I3)
- What: Every SPL Token account in the program is
Account<'info, Mint>orAccount<'info, TokenAccount>. No rawAccountInfofor SPL accounts. - Why: Typed wrappers perform owner-program check (does this account belong to SPL Token?), structural check (does the data layout match a Mint or a Token Account?), and field deserialization automatically. Skipping them and using raw
AccountInfoinvites type-confusion bugs — passing a Token Account where a Mint was expected, etc. - Benefit: Audit cost drops — the type system carries the validation. Reviewers don't have to verify "is this account checked to be a real Mint?" at every use site.
- Tradeoff: None.
Typed errors (diff §I4)
- What: Nine
VaultErrorvariants, named per failure mode. - Why: Every failure mode has a name and a stable code in the program's IDL. Off-chain consumers (frontends, indexers) can match on error codes and surface human-readable messages without parsing log text.
- Benefit: Real diagnostics, not
msg!()strings buried in transaction logs. - Tradeoff: None.
---
Frontend integration
This is the section a porting team should read first.
The optimized 4626 vault changes the client-side integration shape compared to a Solidity 4626. The change is larger than the ERC-20 example's because 4626 has more entry points and the share token also moves to SPL Token.
Before (Solidity ERC-4626 + ethers/viem)
// Approve underlying
await asset.approve(vault.address, amount);
// Deposit
const sharesOut = await vault.deposit(amount, recipient);
// Read your share balance
const myShares = await vault.balanceOf(myAddress);
// Read total share supply, total assets
const ts = await vault.totalSupply();
const ta = await vault.totalAssets();
// Withdraw on behalf of someone
await vault.approve(delegate, sharesAmount); // delegate gets share allowance
await vault.connect(delegate).withdraw(assets, receiver, owner);After (this program + @solana/spl-token + @solana/web3.js)
import {
getAssociatedTokenAddressSync,
createAssociatedTokenAccountIdempotentInstruction,
createApproveInstruction,
} from "@solana/spl-token";
import { PublicKey, Transaction } from "@solana/web3.js";
// Derive the vault PDAs (deterministic from program ID + asset mint)
const [vaultPda] = PublicKey.findProgramAddressSync(
[Buffer.from("vault"), assetMint.toBuffer()],
program.programId
);
const [vaultAuthority] = PublicKey.findProgramAddressSync(
[Buffer.from("vault_authority"), vaultPda.toBuffer()],
program.programId
);
const [shareMint] = PublicKey.findProgramAddressSync(
[Buffer.from("share_mint"), assetMint.toBuffer()],
program.programId
);
const [assetReserve] = PublicKey.findProgramAddressSync(
[Buffer.from("asset_reserve"), vaultPda.toBuffer()],
program.programId
);
// User's ATAs (the canonical per-wallet Token Accounts for asset + share)
const myAssetAta = getAssociatedTokenAddressSync(assetMint, me.publicKey);
const myShareAta = getAssociatedTokenAddressSync(shareMint, me.publicKey);
// Deposit
const tx = new Transaction()
// Ensure your share ATA exists (idempotent — no-op if present)
.add(createAssociatedTokenAccountIdempotentInstruction(
me.publicKey, myShareAta, me.publicKey, shareMint))
// Call the vault's deposit instruction
.add(await program.methods.deposit(amount).accounts({
vault: vaultPda,
assetMint,
shareMint,
vaultAuthority,
assetReserve,
userAssetAta: myAssetAta,
receiverShareAta: myShareAta,
user: me.publicKey,
tokenProgram: TOKEN_PROGRAM_ID,
}).instruction());
// You never explicitly "approve" the asset — the depositor signs the transaction
// that includes the token::transfer CPI authority. SPL Token transfers are signed
// per-instruction, not approved-then-pulled like ERC-20's allowance pattern.
// Read share balance
const myShares = await connection.getTokenAccountBalance(myShareAta);
// Read total share supply / total assets (no vault method needed)
const mintInfo = await connection.getParsedAccountInfo(shareMint);
const reserveInfo = await connection.getParsedAccountInfo(assetReserve);
// .data.parsed.info.supply / .data.parsed.info.tokenAmount.amount
// Withdraw on behalf (delegate flow)
// 1. Owner pre-authorizes a delegate on their share ATA via SPL Token:
await sendAndConfirmTransaction(connection, new Transaction().add(
createApproveInstruction(myShareAta, delegate.publicKey, me.publicKey, sharesAmount)
), [me]);
// 2. Delegate calls withdraw — they sign as `signer`; SPL Token verifies delegate auth.
await program.methods.withdraw(assetsAmount).accounts({
vault: vaultPda,
assetMint,
shareMint,
vaultAuthority,
assetReserve,
ownerShareAta: myShareAta, // <- still the owner's ATA
receiverAssetAta: ...,
signer: delegate.publicKey, // <- delegate signs
tokenProgram: TOKEN_PROGRAM_ID,
}).signers([delegate]).rpc();What changes for your team
- No upfront approve of the underlying. Solana transactions sign for SPL Token transfers per-instruction; there is no persistent allowance to manage like ERC-20's
approve→transferFromtwo-step. - Reads happen against SPL Token accounts, not the vault. Balances →
getTokenAccountBalance(ata). Total supply →mint.supply. Total assets →reserve.amount. Novault.balanceOf()/vault.totalSupply()to call — those concepts moved to SPL Token's accounts. - ATA derivation is mandatory before any operation that returns or burns shares for a "fresh" recipient. Wallets and the
@solana/spl-tokenhelpers handle this; budget one extra instruction per first-time recipient. - Delegate (transferFrom-equivalent) is one `approve` on the share ATA, not a per-
(owner, spender)allowance map. If your dApp expected multi-spender support, see the §C2 tradeoff. - Event indexing changes. SPL Token emits its own logs for share movement (mint/burn). The vault emits
DepositEvent/WithdrawEvent/EarnEventvia Anchor's#[event]macro — parse via the Anchor IDL.
If your existing 4626 dApp's frontend assumes one contract call per operation, budget a sprint for the migration — the architecture is straightforward, but every call site touches.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
/// @title TokenSwapEscrow
/// @notice Two-party atomic ERC-20 swap. The maker locks `amountOffered` of
/// `tokenOffered`; any taker can fulfill by transferring `amountWanted`
/// of `tokenWanted` to the maker and receiving the locked tokens.
/// Neither party can be cheated — both legs settle in one transaction.
/// @dev Mirrors the canonical Solana program-examples escrow shape so the
/// translation walkthrough has a 1:1 reference on the Solana side. There
/// is no single OpenZeppelin contract for ERC-20-for-ERC-20 atomic swaps
/// — this is the standard pattern, using OZ's SafeERC20 wrapper for the
/// transfer-return-value handling.
contract TokenSwapEscrow {
using SafeERC20 for IERC20;
struct Offer {
address maker;
IERC20 tokenOffered;
uint256 amountOffered;
IERC20 tokenWanted;
uint256 amountWanted;
}
/// @notice Auto-incrementing offer id.
uint256 public nextOfferId;
/// @notice id => offer. Cleared (zeroed) when taken or cancelled.
mapping(uint256 => Offer) public offers;
event OfferMade(
uint256 indexed id,
address indexed maker,
IERC20 tokenOffered,
uint256 amountOffered,
IERC20 tokenWanted,
uint256 amountWanted
);
event OfferTaken(uint256 indexed id, address indexed taker);
event OfferCancelled(uint256 indexed id);
error ZeroAmount();
error SameToken();
error OfferDoesNotExist();
error NotMaker();
/// @notice Create an offer. Pulls `amountOffered` of `tokenOffered` from
/// the maker; held by this contract until taken or cancelled.
function makeOffer(
IERC20 tokenOffered,
uint256 amountOffered,
IERC20 tokenWanted,
uint256 amountWanted
) external returns (uint256 id) {
if (amountOffered == 0 || amountWanted == 0) revert ZeroAmount();
if (address(tokenOffered) == address(tokenWanted)) revert SameToken();
id = nextOfferId++;
offers[id] = Offer({
maker: msg.sender,
tokenOffered: tokenOffered,
amountOffered: amountOffered,
tokenWanted: tokenWanted,
amountWanted: amountWanted
});
tokenOffered.safeTransferFrom(msg.sender, address(this), amountOffered);
emit OfferMade(
id,
msg.sender,
tokenOffered,
amountOffered,
tokenWanted,
amountWanted
);
}
/// @notice Fulfil an offer. Pulls `amountWanted` from the taker (sent to
/// the maker), then releases `amountOffered` to the taker. Atomic.
function takeOffer(uint256 id) external {
Offer memory o = offers[id];
if (o.maker == address(0)) revert OfferDoesNotExist();
delete offers[id];
// Pull the wanted token from the taker, send directly to maker.
o.tokenWanted.safeTransferFrom(msg.sender, o.maker, o.amountWanted);
// Release the offered (escrowed) token to the taker.
o.tokenOffered.safeTransfer(msg.sender, o.amountOffered);
emit OfferTaken(id, msg.sender);
}
/// @notice Maker withdraws their offered tokens before any taker arrives.
function cancelOffer(uint256 id) external {
Offer memory o = offers[id];
if (o.maker == address(0)) revert OfferDoesNotExist();
if (o.maker != msg.sender) revert NotMaker();
delete offers[id];
o.tokenOffered.safeTransfer(o.maker, o.amountOffered);
emit OfferCancelled(id);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/// @title Crowdfund
/// @notice One-shot ERC-20 crowdfund. The creator declares a goal and a
/// deadline; supporters call `contribute()` to commit tokens. If the
/// goal is reached before the deadline, the creator can `claim()`
/// the entire pot. If it isn't, supporters can `refund()` whatever
/// they put in.
///
/// Reference: the Solana version is the `tokens/token-fundraiser` example
/// from solana-developers/program-examples — same lifecycle, different
/// state model.
contract Crowdfund {
IERC20 public immutable token;
address public immutable creator;
uint256 public immutable goal;
uint256 public immutable deadline;
uint256 public totalRaised;
bool public claimed;
/// Per-supporter ledger so refunds know who put in what.
mapping(address => uint256) public contributions;
error Ended();
error NotEnded();
error NotCreator();
error GoalMet();
error GoalNotMet();
error AlreadyClaimed();
error NothingToRefund();
error ZeroAmount();
event Contributed(address indexed supporter, uint256 amount);
event Claimed(address indexed creator, uint256 amount);
event Refunded(address indexed supporter, uint256 amount);
constructor(
IERC20 _token,
address _creator,
uint256 _goal,
uint256 _duration
) {
token = _token;
creator = _creator;
goal = _goal;
deadline = block.timestamp + _duration;
}
/// Supporter pulls tokens into the contract and ledger is updated.
function contribute(uint256 amount) external {
if (amount == 0) revert ZeroAmount();
if (block.timestamp >= deadline) revert Ended();
token.transferFrom(msg.sender, address(this), amount);
contributions[msg.sender] += amount;
totalRaised += amount;
emit Contributed(msg.sender, amount);
}
/// Creator sweeps the pot once the goal is met. Single-shot.
function claim() external {
if (msg.sender != creator) revert NotCreator();
if (claimed) revert AlreadyClaimed();
if (totalRaised < goal) revert GoalNotMet();
claimed = true;
uint256 amount = totalRaised;
token.transfer(creator, amount);
emit Claimed(creator, amount);
}
/// Supporter pulls their tokens back if the goal wasn't met by the
/// deadline.
function refund() external {
if (block.timestamp < deadline) revert NotEnded();
if (totalRaised >= goal) revert GoalMet();
uint256 amount = contributions[msg.sender];
if (amount == 0) revert NothingToRefund();
contributions[msg.sender] = 0;
token.transfer(msg.sender, amount);
emit Refunded(msg.sender, amount);
}
}