Here is an o1 first version :
Below is an example Solana smart contract (using the Anchor framework) that implements an immutable vault for a single specified SPL token. This contract shows how to:
All the code is heavily commented for clarity. You can compile and deploy it with Anchor, or modify it for a native Solana program if you prefer.
This example has not been audited; it is meant as a reference starting point.
Program Overview
Example Code... /// Initializes the vault for a specific SPL token.
/// 1) Creates and seeds the VaultAccount (PDA).
/// 2) Creates an associated token account (PDA) that will store locked SPL tokens.
/// 3) Hardcodes MAX_SUPPLY for the chosen token.
///
/// This must be called once per vault instance.
pub fn initialize_vault(ctx: Context<InitializeVault>) -> Result<()> {
let vault_account = &mut ctx.accounts.vault_account;
// Store the token mint in the vault data structure
vault_account.token_mint = ctx.accounts.token_mint.key();
// Record the bump seed that is used to derive the vault's PDA
vault_account.bump = *ctx.bumps.get("vault_account").unwrap();
// Initialize locked tokens to zero
vault_account.locked_spl_tokens = 0;
// Store the vault's token account (the locked SPL token account) in the vault state
vault_account.vault_token_account = ctx.accounts.vault_token_account.key();
// Store the hardcoded max supply
vault_account.max_supply = MAX_SUPPLY;
Ok(())
}
/// Allows a user to deposit SOL into the vault.
/// The user must send lamports along with this instruction.
/// No upper or lower limit is enforced by the contract.
///
/// Important note: The system transfer is handled automatically
/// by Anchor if you specify `#[account(mut)]` for the payer and
/// have an associated CPI call to the system program.
pub fn deposit_sol(ctx: Context<DepositSol>, lamports: u64) -> Result<()> {
// The user is transferring 'lamports' SOL to the vault's
// program-owned account. Anchor will handle the actual
// system_program::transfer CPI based on the context.
// Safety checks: you can add checks to ensure 'lamports'
// matches the lamports passed, or skip them if you want
// fully trustless behavior. For example:
// require!(lamports > 0, VaultError::InvalidLamportsAmount);
// The "deposit" transaction is effectively letting the
// vault's system account accumulate SOL. There's no need
// to store an explicit "total_sol" in the vault state
// because you can always check the vault's account
// balance on-chain if needed.
// If you want a reference or an event, you could emit it here:
// emit!(DepositEvent { ... });
Ok(())
}
/// Swaps the user’s SPL tokens for SOL from the vault.
///
/// The formula used is:
/// SOL received = ( (Vault SOL) * (SPL tokens in) )
/// / (max_supply - locked_spl_tokens)
///
/// Steps:
/// 1) Transfer the user's SPL tokens to the vault's token account.
/// 2) Calculate how much SOL to give the user.
/// 3) Transfer that SOL from the vault to the user.
/// 4) Update the vault state to record locked SPL tokens.
pub fn swap_spl_for_sol(
ctx: Context<SwapSplForSol>,
spl_amount_in: u64
) -> Result<()> {
let vault_account = &mut ctx.accounts.vault_account;
// ---------------------------------------
// Step 1) Transfer SPL tokens from user to vault
// ---------------------------------------
// This CPI call moves 'spl_amount_in' from user's token account
// to the vault's token account (which is locked).
let cpi_ctx = CpiContext::new(
ctx.accounts.token_program.to_account_info(),
Transfer {
from: ctx.accounts.user_token_account.to_account_info(),
to: ctx.accounts.vault_token_account.to_account_info(),
authority: ctx.accounts.user_authority.to_account_info(),
},
);
token::transfer(cpi_ctx, spl_amount_in)?;
// ---------------------------------------
// Step 2) Calculate the SOL to send to user
// ---------------------------------------
// Retrieve the vault's current SOL balance. This is the
// lamport balance of the vault_account's system account.
//
// Usually in Anchor, the `vault_account` is not the same as
// a system-owned "vault for SOL" unless you used that approach.
// Alternatively, you might use another PDA for storing SOL.
//
// For simplicity, assume here that the vault_account itself
// is system-owned and holds the lamports. If not, you'd keep
// track of the lamport balance in the state or fetch it from
// a different account.
let vault_sol_balance = ctx.accounts.vault_account_pda.to_account_info().lamports();
// The formula:
// sol_out = (vault_sol_balance * spl_amount_in)
// / (max_supply - locked_spl_tokens)
let remaining_supply = vault_account
.max_supply
.checked_sub(vault_account.locked_spl_tokens)
.ok_or(VaultError::MathOverflow)?;
require!(
remaining_supply >= spl_amount_in,
VaultError::NotEnoughRemainingSupply
);
let sol_out = (vault_sol_balance as u128)
.checked_mul(spl_amount_in as u128)
.ok_or(VaultError::MathOverflow)?
.checked_div(remaining_supply as u128)
.ok_or(VaultError::MathOverflow)? as u64;
// ---------------------------------------
// Step 3) Transfer SOL from vault to user
// ---------------------------------------
// We do a system_program::transfer from the vault_account_pda to user_authority.
//
// Use the seeds of the vault_account to sign as the program-derived address
// (because the vault's account is PD-owned, not user-owned).
let vault_seeds = &[ b"vault_account", vault_account.token_mint.as_ref(), &[vault_account.bump],
];
let signer = &[&vault_seeds[..]];
// Build the transfer instruction
let ix = anchor_lang::solana_program::system_instruction::transfer(
&ctx.accounts.vault_account_pda.key(),
&ctx.accounts.user_authority.key(),
sol_out,
);
// Invoke the transfer
anchor_lang::solana_program::program::invoke_signed(
&ix,
&[
ctx.accounts.vault_account_pda.to_account_info(),
ctx.accounts.user_authority.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
signer,
)?;
// ---------------------------------------
// Step 4) Update locked SPL tokens
// ---------------------------------------
vault_account.locked_spl_tokens = vault_account
.locked_spl_tokens
.checked_add(spl_amount_in)
.ok_or(VaultError::MathOverflow)?;
Ok(())
}
...
Detailed Explanations- 1. MAX_SUPPLY Constant
- The vault uses a hardcoded constant MAX_SUPPLY to ensure the correct exchange rate is always used.
- In production, you would recompile the contract for each unique SPL token to embed the correct max supply.
- 2. No Admin Keys / Immutability
- There is no authority field with special privileges.
- Once deployed (without upgradeable features) on Solana, this contract is effectively immutable.
- 3. How Deposits Work
- A user calls deposit_sol with lamports to deposit.
- The standard system program transfer moves SOL from the user’s account to the vault’s program-owned account (the vault PDA).
- The vault accumulates SOL; it cannot be withdrawn except through the swap function (by giving up SPL tokens).
- 4. How Swaps Work
- User calls swap_spl_for_sol, providing:
- The user’s SPL Token Account.
- The vault’s token account (to lock tokens).
- The vault’s system account (holding SOL).
- The program:
- 5. Token Locking
- Once SPL tokens are transferred to vault_token_account, they remain there permanently.
- There is no mechanism to withdraw them, ensuring they stay locked indefinitely.
- 6. On-Chain Data
Optional Future Feature: Using a Currency BasketInstead of holding
only SOL, the vault could hold a
basket of assets (e.g., multiple SPL stablecoins like USDC, or even additional cryptocurrencies). The steps for extending this design to a basket:
- 1. Additional PDA Accounts
- For each asset in the basket, the vault would need an account (or token account) to hold that asset.
- 2. Weighted Exchange Rate
- The formula for determining how many SPL tokens to return (or how much asset to receive) would be more complex, taking into account the combined value of all assets.
- You would track each asset’s total quantity, price feed (from an oracle, if needed), and then compute an overall “vault value.”
- 3. Multi-Asset Swaps
- The user could specify which asset (or mix of assets) they want in exchange for their SPL tokens, leading to a more flexible swap mechanism.
- 4. Rebalancing
- If you want a truly dynamic basket, you might allow automated rebalancing of assets, which requires additional logic and possibly a governance mechanism (contradicting the “no admin” rule unless governed by token-based votes or an on-chain Weighted Automatic Market Maker).
These enhancements go beyond the scope of a simple, single-token vault but illustrate how the concept can expand to more sophisticated use cases.
Final Notes- This example demonstrates the core vault mechanics: deposit, swap, lock tokens, and keep an on-chain record of locked tokens.
- Real-world production code requires extensive testing and security audits.
- The contract is immutable if deployed without upgradeable features (e.g., Anchor toml set with [programs.local] <program-name> = <your-id> in a non-upgradeable manner).
- Always verify that your PDA derivations and signers are correct and that you handle Solana’s rent-exemption for all created accounts.
Use this reference as a starting point to build your own specialized vault program on Solana. Adjust the code, add features (like a currency basket), and carefully test before deploying to mainnet.