After building a hello world and a price oracle, this exercise brings together several Solana concepts: time-based logic, SOL transfers via CPI, direct lamport manipulation, and account lifecycle management.
The idea is simple: a piggy bank where you lock SOL until a specific date. Before the expiration, nobody can touch the funds. After it, the SOL gets sent to a pre-defined destination address. It’s a pattern that shows up in vesting contracts, escrows, and time-locked governance.
How It Works
Two instructions:
- Lock — Deposit SOL with an expiration timestamp and a destination address
- Unlock — After expiration, withdraw everything to the destination
The program enforces that you can’t unlock early. No admin override, no exceptions. The rules are in the code, and the code is on-chain.
The Lock Account
#[account]
pub struct Lock {
pub dst: Pubkey, // where funds go on unlock
pub exp: u64, // unix timestamp
}
impl Lock {
pub const LEN: usize = 8 + 32 + 8; // discriminator + pubkey + u64
}
Minimal state: a destination and an expiration. The SOL itself is stored as the account’s lamport balance — no separate field needed.
Locking SOL
pub fn lock(ctx: Context<LockCtx>, amt: u64, exp: u64) -> Result<()> {
let clock = Clock::get()?;
require!(amt > 0, error::Error::InvalidAmount);
require!(
exp > u64::try_from(clock.unix_timestamp).unwrap(),
error::Error::InvalidExpiration
);
let lock = &mut ctx.accounts.lock;
lock.dst = ctx.accounts.dst.key();
lock.exp = exp;
let ix = system_instruction::transfer(
&ctx.accounts.payer.key(),
&ctx.accounts.lock.key(),
amt,
);
invoke(
&ix,
&[
ctx.accounts.payer.to_account_info(),
ctx.accounts.lock.to_account_info(),
ctx.accounts.system_program.to_account_info(),
],
)?;
Ok(())
}
A few things happening here:
Clock::get() reads the cluster time from the Clock sysvar. This is Solana’s way of giving programs access to the current timestamp. Unlike Time.now in Ruby, this comes from validator consensus — it’s deterministic and verifiable.
CPI (Cross-Program Invocation) is how one program calls another. Here, our program calls the System Program to transfer SOL from the payer to the lock account. It’s like calling an external API, except it’s another on-chain program. The invoke function executes the transfer within the same transaction.
Validation happens before any state changes. Amount must be positive, expiration must be in the future. If either fails, the entire transaction reverts. No partial state updates.
Unlocking
pub fn unlock(ctx: Context<UnlockCtx>) -> Result<()> {
let clock = Clock::get()?;
let lock = &ctx.accounts.lock;
require!(
u64::try_from(clock.unix_timestamp).unwrap() >= lock.exp,
error::Error::LockNotExpired
);
let amt = ctx.accounts.lock.to_account_info().lamports();
**ctx.accounts.lock.to_account_info().try_borrow_mut_lamports()? -= amt;
**ctx.accounts.dst.to_account_info().try_borrow_mut_lamports()? += amt;
Ok(())
}
The unlock uses direct lamport manipulation instead of CPI. This is possible because the program owns the lock account and can modify its balance directly. It’s more efficient than going through the System Program.
The close = dst constraint in the accounts struct handles cleanup — after the instruction executes, the lock account is closed and any remaining rent goes to the destination.
The account validation also uses has_one = dst, which checks that the destination account passed in the transaction matches the one stored in the lock. This prevents someone from redirecting funds to a different address.
Error Handling
#[error_code]
pub enum Error {
#[msg("Amount must be greater than 0")]
InvalidAmount,
#[msg("Expiration must be in the future")]
InvalidExpiration,
#[msg("Lock has not expired yet")]
LockNotExpired,
}
Three possible failures, all explicit. Coming from Ruby where you might rescue a generic StandardError, having the compiler enforce that every error case is handled feels much safer.
Testing Time-Based Logic
Testing time-dependent code on a local Solana validator has a gotcha: the validator’s clock doesn’t advance in perfect sync with wall time. A lock set to expire in 3 seconds might need 6 seconds of actual waiting before the validator clock catches up.
it("locks SOL and unlocks after expiration", async () => {
const expiration = Math.floor(Date.now() / 1000) + 3;
await program.methods
.lock(new anchor.BN(lockAmount), new anchor.BN(expiration))
.accounts({ payer: provider.wallet.publicKey, dst: dst.publicKey, lock: lockKp.publicKey })
.signers([lockKp])
.rpc();
// Wait extra time for validator clock to catch up
await new Promise((resolve) => setTimeout(resolve, 6000));
await program.methods
.unlock()
.accounts({ lock: lockKp.publicKey, dst: dst.publicKey })
.rpc();
});
The test for premature unlock is equally important — it confirms the time lock actually works:
it("fails to unlock before expiration", async () => {
const expiration = Math.floor(Date.now() / 1000) + 3600; // 1 hour from now
// Lock, then immediately try to unlock
try {
await program.methods.unlock()
.accounts({ lock: earlyLockKp.publicKey, dst: dst.publicKey })
.rpc();
expect.fail("Should have thrown");
} catch (err) {
expect(err.message).to.include("LockNotExpired");
}
});
What I Learned
CPI vs direct manipulation. Two ways to move SOL, each with trade-offs. CPI is safer and more explicit but costs more compute units. Direct lamport manipulation is cheaper but only works on accounts your program owns.
Time on a blockchain is different. It’s not a system clock. It’s consensus time from validators. You can’t rely on sub-second precision. Good enough for “unlock after this date,” not good enough for “execute at exactly 3:00 PM.”
Account lifecycle matters. Creating, funding, and closing accounts all cost money (rent). Programs need to think about cleanup. The close constraint is Anchor’s way of handling this — it zeroes out the account and returns the rent to a specified address.
This pattern is everywhere. Time-locked accounts show up in token vesting, escrow services, governance timelocks, and subscription models. The piggy bank is a toy example, but the mechanics are production-grade.
The Progression So Far
- Hello World — Basic program structure
- Price Oracle — State management and access control
- Piggy Bank — Time logic, CPI, lamport manipulation
Each exercise builds on the previous one. The next step is working with PDAs (Program Derived Addresses) and token programs — the building blocks for DeFi.
The code is on GitHub: anchor-piggy-bank