Account lifecycle
Ethereum storage slots can't be deleted - you zero them and gate on a sentinel. Solana accounts come and go: init allocates them, close = X deletes them and refunds the rent.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// Solidity storage slots can't be deleted. The user opens a position, then
/// `close` zeroes the entry - but `positions[user]` still exists, just with
/// a zero `amount`. Every future operation has to gate on `amount > 0`.
contract Deposits {
mapping(address => uint256) public positions;
function open(uint256 amount) external {
require(positions[msg.sender] == 0, "already open");
positions[msg.sender] = amount;
// ... pull tokens ...
}
The contract has no real "create" step. open checks that the existing slot is zero (meaning "not yet opened"), then writes a non-zero amount. The slot is the live/dead flag and the data, both in one field.
function close() external {
uint256 amount = positions[msg.sender];
require(amount > 0, "no position");
positions[msg.sender] = 0;
// ... refund tokens ...
}
}
Solidity storage slots can't be deleted. Clearing the entry just sets positions[msg.sender] = 0 - the slot remains, holding zero. Every later operation has to gate on amount > 0 to know whether the position is actually live. Get the sentinel wrong and you have a replay or double-spend bug.
// On Solana, accounts come and go. `open` creates the position PDA with
// `init`; `close_position` closes it with `close = user`, refunding the
// rent. After close, the PDA doesn't exist - a third call to `open` would
// succeed (start a new position) and a third call to `close_position`
// would fail at account validation. No `amount > 0` sentinel needed.
use anchor_lang::prelude::*;
declare_id!("11111111111111111111111111111111");
#[program]
pub mod deposits {
use super::*;
pub fn open(ctx: Context<Open>, amount: u64) -> Result<()> {
ctx.accounts.position.amount = amount;
// ... pull tokens ...
Ok(())
}
The handler just writes the amount; account creation happens at validation time via init on the constraint (see below). A second open for the same user fails before the handler runs because the PDA already exists - no require(positions == 0) needed in the body.
pub fn close_position(_ctx: Context<ClosePosition>) -> Result<()> {
// ... refund tokens ...
// Account closure happens automatically via `close = user`.
Ok(())
}
}
#[derive(Accounts)]
pub struct Open<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
init,
payer = user,
space = 8 + 8,
seeds = [b"position", user.key().as_ref()],
bump,
)]
pub position: Account<'info, Position>,
pub system_program: Program<'info, System>,
}
init allocates the position PDA at the start of the instruction. If the account already exists, validation rejects the call - the "is this slot already open?" check is structural, not arithmetic. The supporter pays the refundable rent (~0.001 SOL per KB) for the account to live on-chain.
#[derive(Accounts)]
pub struct ClosePosition<'info> {
#[account(mut)]
pub user: Signer<'info>,
#[account(
mut,
seeds = [b"position", user.key().as_ref()],
bump,
close = user, // <- deletes the account, refunds rent to `user`
)]
pub position: Account<'info, Position>,
}
close = user tears the account down at the end of a successful instruction and refunds its rent to user. After close, the PDA doesn't exist - a third call to close_position fails at validation, not at a runtime amount > 0 check. Replay protection becomes a property of the account graph rather than a flag you have to maintain. (Caveat: close = X only fires on a successful instruction - if the handler errors, the account stays live.)
#[account]
pub struct Position {
pub amount: u64,
}