A production-ready Soroban smart contract on the Stellar blockchain that locks XLM or any Stellar asset until a future timestamp is reached.
Table of Contents
- Overview
- How It Works
- Architecture
- Contract API
- Security Properties
- Getting Started
- Deployment Checklist
- Known Limitations
| Property | Value |
|---|---|
| Network | Stellar (Soroban) |
| Language | Rust |
| SDK | soroban-sdk v22 |
| Storage | Persistent (per-depositor) |
| Max deposit | 10^15 units (1 quadrillion) |
| Max lock duration | 5 years |
The deposit and withdrawal lifecycle:
- Deposit β A user calls
deposit(token, amount, unlock_time)β tokens transfer from their wallet into the contract - Storage β The contract stores a
VaultEntryin Persistent Storage keyed by the depositor's address - Verification β When the user calls
withdraw(), the contract checksenv.ledger().timestamp() >= unlock_time - Unlock β If the time has passed, tokens are returned. Otherwise the call fails with
FundsStillLocked - Admin Recovery β An admin can perform emergency withdrawals (funds always return to the depositor, never to the admin)
- Trustless Mode β Admin rights can be transferred via a two-step process, or permanently renounced to make the vault fully trustless
Depositor
Γ’ββ
Γ’βΕΓ’ββ¬Γ’βΒΊ deposit(token, amount, unlock_time)
Γ’ββ Γ’ββ
Γ’ββ Γ’βΕΓ’ββ¬ validate amount & unlock_time
Γ’ββ Γ’βΕΓ’ββ¬ token.transfer(depositor Γ’β β contract)
Γ’ββ Γ’βΕΓ’ββ¬ storage::set_deposit(VaultKey::Deposit(depositor) Γ’β β VaultEntry)
Γ’ββ Γ’ββΓ’ββ¬ emit "deposit" event
Γ’ββ
Γ’ββΓ’ββ¬Γ’βΒΊ withdraw(depositor)
Γ’ββ
Γ’βΕΓ’ββ¬ load VaultEntry
Γ’βΕΓ’ββ¬ assert now >= unlock_time
Γ’βΕΓ’ββ¬ storage::remove_deposit(depositor) Γ’β οΏ½ state cleared first (CEI)
Γ’βΕΓ’ββ¬ token.transfer(contract Γ’β β depositor)
Γ’ββΓ’ββ¬ emit "withdraw" event
Persistent Storage
Γ’βΕΓ’ββ¬Γ’ββ¬ VaultKey::Admin Γ’β β Address
Γ’ββ (set once on initialize; removed on renounce_admin)
Γ’ββ
Γ’βΕΓ’ββ¬Γ’ββ¬ VaultKey::PendingAdmin Γ’β β Address
Γ’ββ (set by transfer_admin; cleared by accept_admin / cancel_transfer_admin)
Γ’ββ
Γ’ββΓ’ββ¬Γ’ββ¬ VaultKey::Deposit(depositor: Address) Γ’β β VaultEntry
Γ’βΕΓ’ββ¬Γ’ββ¬ token: Address (SEP-41 token contract)
Γ’βΕΓ’ββ¬Γ’ββ¬ amount: i128 (locked units)
Γ’βΕΓ’ββ¬Γ’ββ¬ unlock_time: u64 (Unix seconds)
Γ’ββΓ’ββ¬Γ’ββ¬ depositor: Address (owner; stored for event emission)
All entries use TTL bump threshold Γ’β°Λ 30 days and target Γ’β°Λ 5.2 years so a max-duration deposit cannot expire before its unlock time.
.
Γ’βΕΓ’ββ¬Γ’ββ¬ Cargo.toml # Workspace manifest
Γ’βΕΓ’ββ¬Γ’ββ¬ Makefile # Build / test / lint / deploy helpers
Γ’βΕΓ’ββ¬Γ’ββ¬ rust-toolchain.toml # Pins stable Rust + wasm32 target
Γ’βΕΓ’ββ¬Γ’ββ¬ .cargo/
Γ’ββ Γ’ββΓ’ββ¬Γ’ββ¬ config.toml # Documents --target trade-off (default target intentionally unset)
Γ’βΕΓ’ββ¬Γ’ββ¬ .gitignore
Γ’βΕΓ’ββ¬Γ’ββ¬ README.md
Γ’βΕΓ’ββ¬Γ’ββ¬ .github/
Γ’ββ Γ’ββΓ’ββ¬Γ’ββ¬ workflows/
Γ’ββ Γ’ββΓ’ββ¬Γ’ββ¬ ci.yml # CI: lint Γ’β β test Γ’β β build WASM
Γ’βΕΓ’ββ¬Γ’ββ¬ scripts/
Γ’ββ Γ’ββΓ’ββ¬Γ’ββ¬ deploy_testnet.sh # Automated testnet deploy + smoke test
Γ’ββΓ’ββ¬Γ’ββ¬ contracts/time-lock-vault/
Γ’βΕΓ’ββ¬Γ’ββ¬ Cargo.toml
Γ’ββΓ’ββ¬Γ’ββ¬ src/
Γ’βΕΓ’ββ¬Γ’ββ¬ lib.rs # Crate root & module declarations
Γ’βΕΓ’ββ¬Γ’ββ¬ contract.rs # All public entry points
Γ’βΕΓ’ββ¬Γ’ββ¬ types.rs # VaultKey, VaultEntry, protocol constants
Γ’βΕΓ’ββ¬Γ’ββ¬ errors.rs # VaultError enum (9 typed codes)
Γ’βΕΓ’ββ¬Γ’ββ¬ events.rs # Event emission helpers
Γ’βΕΓ’ββ¬Γ’ββ¬ storage.rs # Persistent storage helpers + TTL bump logic
Γ’ββΓ’ββ¬Γ’ββ¬ test.rs # Full unit test suite (48+ tests)
Sets the admin address. Optionally overrides the compile-time limits for this deployment. Pass None to use the defaults (10^15 and 5 years). Must be called once after deployment.
Locks amount of token until unlock_time (Unix seconds).
| Param | Type | Constraint |
|---|---|---|
depositor |
Address |
Must sign |
token |
Address |
SEP-41 token contract |
amount |
i128 |
0 < amount Γ’β°Β€ 10^15 |
unlock_time |
u64 |
now < unlock_time Γ’β°Β€ now + 5 years |
penalty_bps |
u32 |
0Γ’β¬β10000 (basis points for early-exit penalty) |
Cancels an active deposit before the unlock time. The penalty (penalty_bps set at deposit time) is sent to the fee_recipient; the remainder is returned to the depositor. Fails with FundsStillLocked if the vault is already past its unlock time (use withdraw instead).
Withdraws funds if now >= unlock_time. Fails with FundsStillLocked otherwise.
Admin-only. Returns funds to the depositor regardless of lock time. Funds always go to the depositor Γ’β¬β never to the admin.
Admin-only. Processes emergency withdrawals for multiple depositors in a single transaction Γ’β¬β useful for contract migrations where many depositors need recovery at once.
| Param | Type | Description |
|---|---|---|
admin |
Address |
Must be the current admin. Signs once for the entire batch. |
depositors |
Vec<Address> |
Addresses to process. Max MAX_BATCH_SIZE (25) entries. |
Best-effort: depositors with no active deposit are skipped and recorded as success: false in the result Γ’β¬β the call never aborts due to a missing deposit, so all valid entries are always processed.
Returns Vec<WithdrawResult> Γ’β¬β one entry per input address:
| Field | Type | Meaning |
|---|---|---|
depositor |
Address |
The input address |
success |
bool |
true = funds transferred; false = no deposit found, skipped |
Instruction budget: Soroban caps each transaction at ~100M instructions. Each iteration costs roughly 1Γ’β¬β2M instructions (two storage removes, one token transfer, one event). The hard cap of 25 keeps the batch well within budget. For larger sets, page through depositors with get_depositors(offset, limit) and call this function multiple times.
Step 1 of a two-step admin transfer. Nominates new_admin as pending admin.
Step 2. The pending admin accepts and becomes the active admin.
Cancels a pending admin transfer. Only the current admin can cancel.
Permanently removes admin privileges. After this call, emergency_withdraw and all admin functions are disabled forever. Makes the vault fully trustless.
Returns the current vault entry. Does not bump storage TTL (no extra fees).
Returns seconds until unlock. Returns 0 if unlocked or no deposit exists. Does not bump TTL.
Returns the current ledger timestamp.
Returns the current admin, or None if renounced.
Returns the pending admin during a transfer, or None.
Returns true if address is the current admin. Returns false if admin has been renounced.
Returns the fee recipient address set at initialization.
Returns the effective (MAX_DEPOSIT_AMOUNT, MAX_LOCK_DURATION_SECS) for this deployment Γ’β¬β runtime-configured values if set at initialize, otherwise the compile-time defaults.
Returns the total number of addresses with an active deposit.
Returns a paginated slice of active depositor addresses.
| Param | Type | Description |
|---|---|---|
offset |
u32 |
Zero-based start index |
limit |
u32 |
Maximum number of addresses to return |
Use offset=0, limit=N for the first page, then increment offset by N for subsequent pages.
All events are emitted via env.events().publish(topics, data).
| Event | Topics | Data |
|---|---|---|
deposit |
("deposit", depositor, token) |
(deposit_id, amount, unlock_time) |
withdraw |
("withdraw", depositor, token) |
(deposit_id, amount) |
emrg_wdraw |
("emrg_wdraw", depositor) |
(deposit_id, admin, token, amount) |
dep_cancel |
("dep_cancel", depositor, token) |
(amount, penalty) |
adm_xfr_init |
("adm_xfr_init", current_admin) |
pending_admin |
adm_xfr_done |
("adm_xfr_done", new_admin) |
() |
adm_renounce |
("adm_renounce", former_admin) |
() |
All amount and penalty values are i128 token units. deposit_id is a u32 per-depositor sequence number.
All entries use Persistent Storage with TTL bump threshold Γ’β°Λ 30 days (BUMP_THRESHOLD = 518_400 ledgers) and target Γ’β°Λ 5.2 years (BUMP_TARGET = 33_000_000 ledgers), ensuring a max-duration deposit cannot expire before its unlock time.
| Key | Type | Lifetime |
|---|---|---|
VaultKey::Admin |
Address |
Set on initialize; removed on renounce_admin |
VaultKey::PendingAdmin |
Address |
Set by transfer_admin; cleared by accept_admin / cancel_transfer_admin |
VaultKey::Initialized |
bool |
Set once on initialize; never removed |
VaultKey::FeeRecipient |
Address |
Set on initialize; never removed |
VaultKey::MaxDeposit |
i128 |
Set on initialize if overridden; absent means use compile-time default |
VaultKey::MaxLockSecs |
u64 |
Set on initialize if overridden; absent means use compile-time default |
VaultKey::DepositCounter(depositor) |
u32 |
Incremented on each deposit; never decremented |
VaultKey::Deposit(depositor, id) |
VaultEntry |
Created on deposit; removed on withdraw / emergency_withdraw / cancel_deposit |
VaultKey::DepositorList |
Vec<Address> |
Updated on deposit and withdraw |
VaultEntry fields: token: Address, amount: i128, unlock_time: u64, depositor: Address, penalty_bps: u32.
TTL is bumped on every write. Read-only query functions (get_vault, time_remaining, get_time) skip the TTL bump to avoid charging callers extra fees.
| Code | Name | Meaning |
|---|---|---|
| 1 | InvalidAmount |
Amount Γ’β°Β€ 0 |
| 2 | UnlockTimeNotInFuture |
unlock_time Γ’β°Β€ current ledger time |
| 3 | NoDepositFound |
No active deposit for this address |
| 4 | FundsStillLocked |
Lock period not yet expired |
| 5 | DepositAlreadyExists |
Must withdraw before re-depositing |
| 6 | LockDurationTooLong |
Lock period exceeds 5 years |
| 7 | Unauthorized |
Caller is not the admin |
| 8 | AmountTooLarge |
Amount exceeds 10^15 |
| 9 | InvalidPenaltyBps |
penalty_bps > 10000 |
| 10 | LockDurationTooShort |
Lock period is shorter than the minimum (60 s) |
| 11 | InvalidAdmin |
Nominated admin is the same as the current admin |
| 12 | BatchTooLarge |
depositors.len() exceeds MAX_BATCH_SIZE (25) |
| Property | Implementation |
|---|---|
| Checks-Effects-Interactions | Storage cleared before token transfer on every withdrawal |
| Auth-first ordering | require_auth() is always the first statement in every mutating function |
| No re-entrancy surface | State removed before any external token call |
| Bounded inputs | Amount capped at 10^15; lock duration capped at 5 years |
| No admin fund theft | Emergency withdraw always sends to depositor, never to admin |
| Trustless mode | Admin can permanently renounce via renounce_admin() |
| Safe admin transfer | Two-step transfer prevents accidental key loss |
| TTL management | Persistent entries bumped to ~1 year on every write; view functions skip TTL bump |
| No testutils in production | features = ["testutils"] only in [dev-dependencies] |
| Initialize front-running | initialize() has no on-chain guard against a race: an attacker who observes the deploy transaction in the mempool can call initialize first with their own address. Mitigation: always call initialize in the same transaction as deploy (atomic deploy+init) so no intermediate state is visible. The deploy script does this by default. |
Soroban contracts are immutable by default Γ’β¬β once deployed, the contract code cannot be changed or patched.
| Implication | Detail |
|---|---|
| No in-place upgrades | There is no upgrade or set_code function; the deployed WASM is fixed forever |
| Bug fixes require redeployment | A new contract must be deployed and users must migrate their funds to it |
| Migration path | The admin can call emergency_withdraw(admin, depositor) for each active deposit to return funds to depositors, who can then re-deposit into the new contract |
| Trustless trade-off | If renounce_admin() has been called, no migration is possible Γ’β¬β the contract is fully trustless but also fully immutable with no escape hatch |
Plan deployments carefully. Audit the contract before going to mainnet, because there is no way to patch a live deployment.
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Add WASM target
rustup target add wasm32-unknown-unknown
# Install Soroban CLI
cargo install --locked soroban-cli
# Install cargo-watch (optional, for make watch)
cargo install cargo-watchmake buildWhy not just
cargo build? Runningcargo buildwithout--target wasm32-unknown-unknownproduces a native binary, not a WASM contract. The Makefile'sbuildtarget always passes the correct flag. A.cargo/config.tomlis included in the repo that documents this trade-off Γ’β¬β the default target is intentionally left commented out because setting it would breakcargo test(tests must run natively to use Soroban testutils).
make testTests run natively (no
--targetflag) so thatsoroban-sdk'stestutilsfeature works. Never runcargo test --target wasm32-unknown-unknown.
make checkmake auditRuns cargo audit to check all dependencies against the RustSec Advisory Database.
make denyRuns cargo deny check to enforce license allowlists and ban policies defined in deny.toml.
make optimizemake check-wasm-sizeFails if the optimized WASM exceeds MAX_WASM_BYTES (default 65 536 bytes / 64 KB).
Override the threshold at the command line:
make check-wasm-size MAX_WASM_BYTES=81920 # 80 KBThe same threshold is enforced in CI via the Check WASM size step in .github/workflows/ci.yml.
To update the limit, change MAX_WASM_BYTES in both places (or only in ci.yml if you don't use the Makefile target locally).
export SOROBAN_SECRET_KEY=S...
make deploy-testnetPushing a version tag triggers the deploy-testnet CI job automatically:
git tag v1.0.0
git push origin v1.0.0The job requires the SOROBAN_SECRET_KEY secret to be set in the repository's testnet environment (Settings Γ’β β Environments Γ’β β testnet Γ’β β Secrets). After the run, the deployed contract ID appears in the job's summary tab.
Runs a quick end-to-end test against a local Soroban standalone node Γ’β¬β no funded account or testnet access required.
# Build the WASM first, then run the smoke test
make smoke-test-localThe script (scripts/smoke_test_local.sh):
- Starts a local node via
stellar network start local - Generates a funded test identity
- Deploys the contract and calls
initialize,deposit,get_vault,time_remaining, andwithdraw - Asserts expected outputs at each step
- Stops the local node on exit
STELLAR_CLI_VERSION is defined as a top-level env variable in .github/workflows/ci.yml. Dependabot keeps GitHub Actions versions up to date automatically, but it does not track arbitrary binary downloads. When a new stellar-cli release is published at https://github.com/stellar/stellar-cli/releases, update the variable manually:
# .github/workflows/ci.yml
env:
STELLAR_CLI_VERSION: "<new-version>"Use this checklist when deploying to production.
- Deploy and call
initializein the same transaction to prevent front-running - Verify
get_adminreturns the expected admin address - Run
get_constantsto confirmMAX_DEPOSIT_AMOUNTandMAX_LOCK_DURATION_SECSmatch your intended parameters - Verify
get_fee_recipientreturns the correct fee recipient address - Consider calling
renounce_adminfor fully trustless operation once setup is complete - Monitor storage TTL for long-duration vaults Γ’β¬β entries are bumped on write but not on read
- Confirm the optimized WASM size is within the Stellar network limit (
make check-wasm-size)
Soroban charges fees for persistent storage operations. Here is what each call costs at a high level:
| Operation | Storage effect |
|---|---|
deposit |
Creates a new persistent entry + pays for initial TTL bump (~30-day threshold, ~5.2-year target) |
withdraw / cancel_deposit / emergency_withdraw |
Removes the persistent entry (storage freed) |
get_vault, time_remaining, get_time |
Read-only Γ’β¬β no TTL bump, no extra storage fee |
initialize |
Writes admin / fee-recipient entries once |
Key points:
- The depositor pays the storage-creation fee on
deposit. - View functions intentionally skip TTL bumps to avoid charging callers for reads.
- For very long locks (approaching 5 years) the TTL is set well beyond the unlock time, so no manual TTL extension is needed.
For current fee rates see the Stellar fee documentation.
| Limitation | Detail |
|---|---|
| One deposit per address | A depositor must withdraw or cancel_deposit before making a new deposit. |
| No partial withdrawals | The full locked amount is returned in one call; partial releases are not supported. |
| No early withdrawal without admin | Only cancel_deposit (with a penalty) or an admin emergency_withdraw can release funds before the unlock time. |
| Single admin address | Admin is one key Γ’β¬β no multisig or DAO governance. Use renounce_admin to go fully trustless. |
| Storage TTL | Persistent entries are bumped to ~5.2 years on every write. Deposits longer than that would require a TTL extension call (current max lock is 5 years, so this is not an issue in practice). |
make testcargo test test_deposit_success --features testutils -- --nocapturecargo test --features testutils -- --nocaptureTests run natively (without
--target wasm32-unknown-unknown) so thatsoroban-sdk'stestutilsfeature works correctly.
The suite (contracts/time-lock-vault/src/test.rs) contains 48+ tests covering:
| Category | What is tested |
|---|---|
| Deposit | Valid deposits, duplicate deposits, amount/time boundary checks |
| Withdraw | Successful withdrawal, early withdrawal rejection, missing deposit |
| Cancel deposit | Penalty calculation, fee recipient transfer, post-unlock rejection |
| Admin | transfer_admin, accept_admin, cancel_transfer_admin, renounce_admin |
| Emergency withdraw | Admin-only access, funds always go to depositor |
| Read-only queries | get_vault, time_remaining, get_time, get_constants, pagination |
| Error codes | Every VaultError variant is exercised |
- Savings accounts Γ’β¬β Lock funds for a fixed period to enforce saving discipline.
- Token vesting Γ’β¬β Team or investor tokens released on a schedule.
- HODL challenges Γ’β¬β Commit to not selling until a future date.
- Escrow Γ’β¬β Time-gated release of payment.
See CHANGELOG.md for the full version history.
MIT