A production-ready decentralized vault on the Stellar blockchain (Soroban) — with a full React/TypeScript frontend.
Tokens are locked in the smart contract until a future timestamp. Early exits are possible with a configurable penalty. Admin rights can be permanently renounced for fully trustless operation.
SAFE-HAVEN/
├── contracts/safe-haven/ Smart contract (Rust / Soroban)
│ ├── Cargo.toml
│ └── src/
│ ├── lib.rs Crate root
│ ├── contract.rs All public entry points
│ ├── types.rs VaultKey, VaultEntry, constants
│ ├── errors.rs VaultError enum (12 codes)
│ ├── events.rs Event emission helpers
│ ├── storage.rs Persistent storage + TTL helpers
│ └── test.rs 48+ unit tests
│
├── frontend/ React + TypeScript + Vite (UI)
│ ├── src/
│ │ ├── App.tsx
│ │ ├── config.ts Contract ID, RPC URLs
│ │ ├── context/ Freighter wallet
│ │ ├── hooks/ useDeposits, useContractInfo
│ │ ├── lib/ Stellar SDK helpers, formatting
│ │ ├── components/ Header, TabNav, DepositCard, etc.
│ │ └── pages/ Dashboard, Deposit, Withdraw, Admin
│ ├── .env.example
│ └── README.md
│
├── Cargo.toml Rust workspace
├── Makefile Build / test / lint / deploy
└── STRUCTURE.md Detailed project layout
# Prerequisites
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
cargo install --locked soroban-cli
# Build
make build
# Test
make test
# Deploy to testnet
export SOROBAN_SECRET_KEY=S...
make deploy-testnetcd frontend
npm install
cp .env.example .env # set VITE_CONTRACT_ID to your deployed contract
npm run dev # -> http://localhost:5173See frontend/README.md for the full frontend guide.
| Property | Value |
|---|---|
| Network | Stellar (Soroban) |
| Language | Rust |
| SDK | soroban-sdk v22 |
| Storage | Persistent (per-depositor) |
| Max deposit | 10^15 units |
| Max lock duration | 5 years |
| Min lock duration | 60 seconds |
| Early-exit penalty | 0-100% (basis points, set at deposit time) |
- Deposit - User calls
deposit(token, amount, unlock_time, penalty_bps)— tokens transfer into the contract - Storage - Contract stores a
VaultEntryin persistent storage keyed by(depositor, deposit_id) - Verification - On
withdraw(), contract checksledger.timestamp() >= unlock_time - Unlock - Tokens returned to depositor. Otherwise call fails with
FundsStillLocked - Early exit -
cancel_deposit()returns funds minus penalty; penalty goes tofee_recipient - Admin recovery - Admin can emergency-withdraw any deposit (funds always go to depositor, never admin)
- Trustless mode - Admin can be permanently renounced via
renounce_admin()
One-time setup. Sets admin and fee recipient. Optionally overrides compile-time limits.
Locks tokens. Returns the deposit ID.
Payer funds a vault for a different beneficiary.
Locks tokens until a specific ledger sequence number.
Withdraws if now >= unlock_time.
Withdraws to a different address.
Early exit with penalty. Penalty goes to fee_recipient; remainder returned to depositor.
Admin-only. Returns funds to depositor regardless of lock time.
Halts / restores deposits.
Two-step admin transfer.
Cancels a pending transfer.
Permanently removes admin. Contract becomes fully trustless.
| Function | Returns |
|---|---|
get_vault(depositor, id) |
Option<VaultEntry> |
get_deposit_ids(depositor) |
Vec<u32> |
time_remaining(depositor, id) |
u64 seconds |
get_time() |
Current ledger timestamp |
get_admin() |
Option<Address> |
get_pending_admin() |
Option<Address> |
get_fee_recipient() |
Option<Address> |
get_constants() |
(max_deposit, max_lock_secs) |
get_depositor_count() |
u32 |
get_depositors(offset, limit) |
Vec<Address> |
is_paused() |
bool |
is_initialized() |
bool |
| Code | Name | Meaning |
|---|---|---|
| 1 | InvalidAmount |
Amount <= 0 |
| 2 | UnlockTimeNotInFuture |
unlock_time <= now |
| 3 | NoDepositFound |
No active deposit |
| 4 | FundsStillLocked |
Lock not yet expired |
| 5 | DepositAlreadyExists |
(reserved) |
| 6 | LockDurationTooLong |
Exceeds 5 years |
| 7 | Unauthorized |
Not the admin |
| 8 | AmountTooLarge |
Exceeds 10^15 |
| 9 | InvalidPenaltyBps |
penalty_bps > 10000 |
| 10 | InvalidAdmin |
Same as current admin |
| 11 | LockDurationTooShort |
Less than 60 seconds |
| 12 | ContractPaused |
Deposits paused |
| Property | Implementation |
|---|---|
| Checks-Effects-Interactions | Storage cleared before token transfer on every withdrawal |
| Auth-first | require_auth() is the first call in every mutating function |
| No re-entrancy | State removed before any external token call |
| Bounded inputs | Amount capped at 10^15; lock 60s-5yr |
| No admin theft | Emergency withdraw always sends to depositor |
| Trustless mode | renounce_admin() permanently removes admin |
| Safe transfer | Two-step admin transfer prevents key loss |
make build # Compile to WASM
make test # Run all tests
make lint # Clippy
make fmt # Format
make check # fmt + lint + test + audit + deny
make optimize # Optimize WASM with soroban CLI
make check-wasm-size # Fail if WASM > 64 KB
make deploy-testnet # Deploy to Stellar testnet
make smoke-test-local # End-to-end test against local node
make audit # cargo audit (security)
make deny # cargo deny (licenses)- Savings - Lock funds for a fixed period to enforce discipline
- Token vesting - Team/investor tokens released on a schedule
- HODL commitments - Commit to not selling until a future date
- Escrow - Time-gated release of payment
See CONTRIBUTING.md and CHANGELOG.md.
MIT