A production-ready Soroban smart contract on the Stellar blockchain that locks XLM or any Stellar asset until a future timestamp or ledger sequence number is reached.
Table of Contents
- Overview
- How It Works
- Ledger vs Timestamp Deposits
- Pause Semantics
- Architecture
- Contract API
- Security Properties
- Getting Started
- Local Standalone Node Integration Testing
- Deployment Checklist
- Known Limitations
| Property | Value |
|---|---|
| Network | Stellar (Soroban) |
| Language | Rust |
| SDK | soroban-sdk v22 |
| Storage | Persistent (per-depositor, per-deposit-id) |
| Max deposit | 10^15 units (1 quadrillion) |
| Max lock duration | 5 years |
| Min lock duration | 60 seconds |
| Max batch size | 20 depositors per batch call |
The contract supports two independent lock mechanisms:
- 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 and deposit id - 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(depositor, token, amount, unlock_time, penalty_bps)
| |
| |- validate pause / freeze / amount / unlock_time
| |- token.transfer(depositor -> contract)
| |- storage::set_deposit(VaultKey::Deposit(depositor, id))
| `- emit "deposit" event
|
|-> deposit_by_ledger(depositor, token, amount, unlock_ledger, penalty_bps)
| |
| |- validate pause / freeze / amount / ledger sequence
| |- token.transfer(depositor -> contract)
| |- storage::set_deposit_by_ledger(VaultKey::DepositByLedger(depositor, id))
| `- emit "deposit" event
|
`-> withdraw(depositor, deposit_id)
|
|- load VaultEntry or LedgerVaultEntry
|- assert unlock condition met (timestamp or ledger sequence)
|- storage::remove_deposit*(depositor, id) <- CEI: state cleared first
|- token.transfer(contract -> depositor)
`- emit "withdraw" event
The contract supports two deposit modes:
| Mode | Function | Unlock condition | Entry type |
|---|---|---|---|
| Timestamp | deposit, deposit_for |
env.ledger().timestamp() >= unlock_time |
VaultEntry (unlock_time: u64 Unix seconds) |
| Ledger sequence | deposit_by_ledger |
env.ledger().sequence() >= unlock_ledger |
LedgerVaultEntry (unlock_ledger: u32) |
Use timestamp mode for human-readable calendar deadlines. Use ledger-sequence mode when you need deterministic block-count-based locks without clock-skew exposure.
All withdrawal and cancellation paths (withdraw, withdraw_to, cancel_deposit, emergency_withdraw, batch_emergency_withdraw) transparently handle both modes: they first look for a timestamp entry, then a ledger entry for the same (depositor, deposit_id) key.
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, deposit_id: u32) → VaultEntry
│ token: Address (SEP-41 token contract)
│ amount: i128 (locked units)
│ unlock_time: u64 (Unix seconds)
│ depositor: Address (owner; stored for event emission)
│ penalty_bps: u32 (early-exit penalty basis points)
│
├── VaultKey::DepositByLedger(depositor: Address, deposit_id: u32) → LedgerVaultEntry
│ token: Address
│ amount: i128
│ unlock_ledger: u32
│ depositor: Address
│ penalty_bps: u32
│
├── VaultKey::ActiveDepositIds(depositor: Address) → Vec<u32>
│ (active deposit ids for a depositor)
│
├── VaultKey::ActiveDepositCount(depositor: Address) → u32
│ (active deposit count for a depositor)
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 (16 typed codes)
├── events.rs # Event emission helpers
├── storage.rs # Persistent storage helpers + TTL bump logic
└── test.rs # Full unit test suite (60+ tests)
Sets the admin and fee-recipient addresses. Optionally overrides compile-time limits. Must be called once after deployment.
| Param | Type | Description |
|---|---|---|
admin |
Address |
Contract administrator |
fee_recipient |
Address |
Receives early-exit penalty fees |
max_deposit |
Option<i128> |
Override max deposit amount; None uses default (10^15) |
max_lock_secs |
Option<u64> |
Override max lock duration; None uses default (5 years) |
Locks amount of token until unlock_time (Unix seconds) and returns a unique deposit_id for the depositor.
| Param | Type | Constraint |
|---|---|---|
depositor |
Address |
Must sign |
token |
Address |
SEP-41 token contract |
amount |
i128 |
0 < amount <= 10^15 |
unlock_time |
u64 |
now + 60s < unlock_time <= now + 5 years |
penalty_bps |
u32 |
0-10000 (basis points for early-exit penalty) |
Each depositor can create multiple active deposits. The returned deposit_id must be used for later withdraw, cancel_deposit, or get_vault calls.
Locks amount of token on behalf of depositor. The authenticated payer funds the deposit, while depositor remains the beneficiary.
Same as deposit but unlocks at unlock_ledger (ledger sequence number) instead of a Unix timestamp. Returns a deposit_id. Subject to the same pause/freeze and amount/penalty validations.
| Param | Type | Constraint |
|---|---|---|
depositor |
Address |
Must sign |
token |
Address |
SEP-41 token contract |
amount |
i128 |
0 < amount <= 10^15 |
unlock_ledger |
u32 |
current + min_ledgers < unlock_ledger <= current + max_ledgers |
penalty_bps |
u32 |
0-10000 |
Ledger duration bounds are derived from the same MIN_LOCK_DURATION_SECS / MAX_LOCK_DURATION_SECS limits using LEDGER_SECONDS = 5.
Identical to deposit but the payer transfers the tokens and depositor receives the vault entry. payer must sign; depositor does not need to be present.
Withdraws funds if the unlock condition is met. Works for both timestamp and ledger deposits. Blocked while the depositor is frozen.
Same as withdraw but transfers funds to recipient instead of depositor. Useful for routing unlocked funds directly to a different address.
Cancels an active deposit before its unlock time. The penalty_bps fraction goes to the fee_recipient; the remainder is returned to the depositor. Blocked while the depositor is frozen. Fails with FundsAlreadyUnlocked if the vault is past its unlock time (use withdraw instead).
| Param | Type | Constraint |
|---|---|---|
depositor |
Address |
Must sign |
token |
Address |
SEP-41 token contract |
amount |
i128 |
0 < amount ≤ 10^15 |
unlock_ledger |
u32 |
current_ledger < unlock_ledger |
penalty_bps |
u32 |
0–10000 |
Admin-only. Returns funds to the depositor regardless of lock time. Works for both timestamp and ledger deposits. Funds always go to the depositor — never to the admin. Works even when the depositor is frozen.
Admin-only. Processes emergency withdrawals for multiple (depositor, deposit_id) pairs in one transaction.
| Param | Type | Description |
|---|---|---|
admin |
Address |
Must be the current admin |
depositors |
Vec<(Address, u32)> |
(depositor, deposit_id) pairs. Max MAX_BATCH_SIZE (20) entries |
Returns Vec<WithdrawResult> — one entry per input pair:
| Field | Type | Meaning |
|---|---|---|
depositor |
Address |
The input address |
deposit_id |
u32 |
The input deposit ID |
success |
bool |
true = funds transferred; false = no deposit found, skipped |
Pauses or resumes the contract. While paused, deposit and deposit_by_ledger fail with ContractPaused. Withdrawals, cancellations, and emergency withdrawals still work.
Freezes or unfreezes a specific depositor. While frozen, the depositor cannot call deposit, deposit_by_ledger, withdraw, withdraw_to, or cancel_deposit. Emergency withdrawal by the admin is still allowed.
Admin-only. Converts an existing timestamp-based deposit to a ledger-sequence deposit.
Admin-only. Converts an existing ledger-sequence deposit to a timestamp-based deposit.
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, all admin functions are disabled forever.
Admin-only. Blocks depositor from making new deposits and from calling withdraw. Use emergency_withdraw to return their funds while frozen.
Admin-only. Lifts the freeze on depositor.
Returns true if the depositor is currently frozen.
Admin-only. Prevents new deposits of the specified token contract address. Existing deposits are unaffected and can still be withdrawn normally.
# Example: block new USDC deposits after a security incident
freeze_token(admin=ADMIN_ADDR, token=USDC_CONTRACT)
Admin-only. Re-enables deposits for a previously frozen token.
Returns true if new deposits of this token are blocked.
Admin-only. Sets the global upper bound on penalty_bps for new deposits (0–10000).
Any deposit whose penalty_bps exceeds this value is rejected with InvalidPenaltyBps.
Pass 10000 to effectively remove the cap.
# Restrict all new deposits to a maximum 20% early-exit penalty
set_max_penalty_bps(admin=ADMIN_ADDR, bps=2000)
Returns the configured penalty cap in basis points, or None if unset (defaults to 10000).
Admin-only. Sets a minimum flat fee (in token units) charged on every cancel_deposit call.
Effective penalty = max(bps_penalty, min_cancel_fee), capped at the full deposit amount.
Set to 0 to disable.
# Require at least 100 stroops fee on every early cancellation
set_min_cancel_fee(admin=ADMIN_ADDR, fee=100)
Returns the configured minimum cancel fee, or None if unset (defaults to 0).
Returns the timestamp-based vault entry. Does not bump storage TTL.
Returns the ledger-based vault entry. Does not bump TTL.
Returns timestamp entries for multiple depositors at once (max 20).
Returns the current vault entry for the given deposit_id. Does not bump storage TTL (no extra fees).
Returns seconds until unlock for either deposit type. For ledger deposits, remaining ledgers are converted using LEDGER_SECONDS = 5. Returns 0 if unlocked or not found.
Returns remaining ledgers for a ledger-based deposit. Returns 0 if unlocked or not found.
Returns the current ledger timestamp.
Returns the current admin, or None if renounced.
Returns the pending admin during a transfer, or None.
Returns the fee recipient address.
Returns the effective (MAX_DEPOSIT_AMOUNT, MAX_LOCK_DURATION_SECS) for this deployment.
Returns the number of addresses with at least one active deposit.
Returns a paginated slice of active depositor addresses. limit is capped at 100.
Returns true if the contract is currently paused.
Returns true if the depositor is currently frozen.
All events are emitted via env.events().publish(topics, data).
| Event | Topics | Data |
|---|---|---|
deposit |
("deposit", depositor, token) |
(deposit_id, amount, unlock_time) |
top_up |
("top_up", depositor, token) |
(deposit_id, added, new_total) |
withdraw |
("withdraw", depositor, token) |
(deposit_id, amount) |
withdraw_to |
("withdraw_to", depositor, recipient, token) |
(deposit_id, amount) |
emrg_wdraw |
("emrg_wdraw", depositor) |
(deposit_id, admin, token, amount) |
dep_cancel |
("dep_cancel", depositor, token) |
(amount, penalty) |
paused |
("paused", admin) |
() |
unpaused |
("unpaused", admin) |
() |
frozen |
("frozen", admin, depositor) |
() |
unfrozen |
("unfrozen", admin, depositor) |
() |
migrated |
("migrated", depositor) |
(deposit_id, to_ledger, to_time) |
adm_xfr_init |
("adm_xfr_init", current_admin) |
pending_admin |
adm_xfr_cancel |
("adm_xfr_cancel", current_admin) |
pending_admin |
adm_xfr_done |
("adm_xfr_done", new_admin) |
() |
adm_xfr_cancel |
("adm_xfr_cancel", admin) |
pending_admin |
adm_renounce |
("adm_renounce", former_admin) |
() |
paused |
("paused", admin) |
() |
unpaused |
("unpaused", admin) |
() |
lock_extended |
("lock_extended", depositor) |
(old_unlock_time, new_unlock_time) |
All amount and penalty values are i128 token units. deposit_id is a u32 per-depositor sequence number. For deposit_by_ledger, the unlock_time field in the deposit event carries unlock_ledger cast to u64.
All entries use Persistent Storage with TTL bump threshold ≈ 30 days (BUMP_THRESHOLD = 518_400 ledgers) and target ≈ 5.2 years (BUMP_TARGET derived from MAX_LOCK_DURATION_SECS / 5s).
| Key | Value 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::Paused |
bool |
Set by pause/unpause; absent means not paused |
VaultKey::FeeRecipient |
Address |
Set on initialize; never removed |
VaultKey::Paused |
bool |
Toggled by pause/unpause; absent → false |
VaultKey::MaxDeposit |
i128 |
Set on initialize if overridden; absent → compile-time default |
VaultKey::MaxLockSecs |
u64 |
Set on initialize if overridden; absent → compile-time default |
VaultKey::DepositCounter(depositor) |
u32 |
Incremented on each deposit; never decremented |
VaultKey::ActiveDepositIds(depositor) |
Vec<u32> |
Updated on deposit and removal; absent → empty |
VaultKey::Deposit(depositor, id) |
VaultEntry |
Created on deposit; removed on withdraw / emergency_withdraw / cancel_deposit |
VaultKey::ActiveDepositIds(depositor) |
Vec<u32> |
Active deposit IDs for a depositor |
VaultKey::ActiveDepositCount(depositor) |
u32 |
Active deposit count for a depositor |
VaultKey::DepositorAt(slot) |
Address |
Depositor index used for pagination |
VaultKey::DepositorIndex(depositor) |
u32 |
Slot index for an active depositor |
VaultEntry fields: token: Address, amount: i128, unlock_time: u64, depositor: Address, penalty_bps: u32.
LedgerVaultEntry fields: token: Address, amount: i128, unlock_ledger: u32, depositor: Address, penalty_bps: u32.
LedgerVaultEntry fields: token: Address, amount: i128, unlock_ledger: u32, depositor: Address, penalty_bps: u32.
TTL is bumped on every write. Read-only query functions skip the TTL bump to avoid charging callers extra fees.
| Code | Name | Meaning |
|---|---|---|
| 1 | InvalidAmount |
Amount ≤ 0 |
| 2 | UnlockTimeNotInFuture |
unlock_time ≤ current ledger time (or unlock_ledger ≤ current sequence) |
| 3 | NoDepositFound |
No active deposit for this depositor/id |
| 4 | FundsStillLocked |
Lock period not yet expired |
| 5 | DepositAlreadyExists |
Reserved error code |
| 6 | LockDurationTooLong |
Lock period exceeds 5 years |
| 7 | Unauthorized |
Caller is not the admin or pending 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) |
| 13 | FundsAlreadyUnlocked |
Funds are already past unlock time; use withdraw |
| 14 | DepositorFrozen |
Depositor is frozen; contact admin |
| 15 | MigrationNotAllowed |
Migration precondition failed |
| 16 | TokenFrozen |
Token is frozen; new deposits blocked (#331) |
| Property | Implementation |
|---|---|
| Checks-Effects-Interactions | Storage cleared before token transfer on every withdrawal path |
| 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 with a 60 s minimum |
| 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 |
| Pause mechanism | Admin can pause new deposits via pause() without affecting existing locked funds; unpause() resumes normal operation |
| Pause mechanism | Admin can pause new deposits via pause() without affecting existing locked funds; unpause() resumes normal operation |
| TTL management | Persistent entries bumped to ~5.2 years 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). 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, deposit_id) for each active deposit to return funds, who can then re-deposit into the new contract. Use get_depositors + get_deposit_ids to enumerate all live deposits. |
| Pause during migration | Call pause before starting migration to prevent new deposits during the transition. |
| 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 |
# 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 (also installs the stellar 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.
make testTests run natively (no
--targetflag) so thatsoroban-sdk'stestutilsfeature works.
make checkThe CI pipeline runs cargo audit on every push and pull request via the security-audit job
in .github/workflows/ci.yml (using rustsec/audit-check). This checks all dependencies
against the RustSec Advisory Database and fails the build if any
known-vulnerable dependency is detected.
Run the same check locally:
make audit
# or directly:
cargo auditcargo-audit is installed automatically as part of make check (which mirrors the full CI
pipeline: fmt-check → lint → test → audit → deny).
GitHub Release WASM builds — pushing a version tag (v*) also triggers
.github/workflows/release.yml, which builds an optimized WASM binary and attaches it to the
GitHub Release as time_lock_vault.optimized.wasm.
make denymake optimizemake check-wasm-sizeFails if the optimized WASM exceeds MAX_WASM_BYTES (default 65 536 bytes / 64 KB).
export SOROBAN_SECRET_KEY=S...
make deploy-testnetmake smoke-test-local
# Or invoke the script directly:
bash scripts/smoke_test_local.shThe script (scripts/smoke_test_local.sh) exercises the full deposit → query → withdraw lifecycle:
| Step | Action | What is verified |
|---|---|---|
| 1 | Check WASM exists | Fails fast if make build was not run |
| 2 | stellar network start local --background |
Local node is up and listening |
| 3 | stellar keys generate --fund |
A funded test identity is created |
| 4 | stellar contract deploy |
Contract deploys successfully; a contract ID is returned |
| 5 | initialize(admin, ...) |
Contract accepts the init call without error |
| 6 | stellar contract asset deploy --asset native |
Native XLM is wrapped as a SEP-41 token |
| 7 | deposit(depositor, token, 1000, now+120s, penalty_bps=0) |
Deposit returns a deposit_id; token balance decreases |
| 8 | get_vault(depositor, 0) |
Returned entry contains amount = 1000 |
| 9 | time_remaining(depositor, 0) |
Returns > 0 (lock has not expired) |
| 10 | withdraw(depositor, 0) |
Fails with FundsStillLocked (asserts error string) |
| EXIT | stellar network stop local |
Node is shut down cleanly via trap |
==> Checking WASM...
✓ WASM found: target/wasm32-unknown-unknown/release/time_lock_vault.wasm
==> Starting local Soroban node...
✓ Local node started
==> Setting up identity...
✓ Identity: GABC...XYZ
==> Deploying contract...
✓ Contract deployed: CCCC...AAAA
==> Calling initialize...
✓ initialize OK
==> Wrapping native XLM...
✓ Token: CDDD...BBBB
==> Calling deposit...
✓ deposit OK
==> Calling get_vault...
✓ get_vault returns amount 1000
==> Calling time_remaining...
✓ time_remaining > 0 (119)
==> Calling withdraw (expect FundsStillLocked)...
✓ withdraw fails while locked
All smoke tests passed.
==> Stopping local node...
To add assertions for additional contract functions, edit scripts/smoke_test_local.sh. The assert_contains helper makes it easy:
- Deploy and call
initializein the same transaction to prevent front-running - Pass the correct
fee_recipientaddress toinitialize(receives early-exit penalties) - Verify
get_adminreturns the expected admin address - Verify
is_initializedreturnstrue - Run
get_constantsto confirmMAX_DEPOSIT_AMOUNTandMAX_LOCK_DURATION_SECSmatch intended parameters - Verify
get_fee_recipientreturns the correct fee recipient address - Run
is_initializedto confirm the contract initialized successfully - 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)
| Limitation | Detail |
|---|---|
| Multiple deposits per address supported | Each deposit call returns a new deposit_id. Use get_deposit_ids to list all active IDs. |
| No partial withdrawals | The full locked amount is returned in one call. |
| Single admin address | Admin is one key — no multisig or DAO governance. Use renounce_admin to go fully trustless. |
MAX_BATCH_SIZE = 20 |
get_vault_batch accepts at most 20 depositors per call. |
make testcargo test test_deposit_success --features testutils -- --nocaptureThe suite (contracts/time-lock-vault/src/test.rs) contains 70+ tests covering:
| Category | What is tested |
|---|---|
| Deposit | Valid deposits, deposit_for, deposit_by_ledger, amount/time boundary checks |
| Withdraw | Successful withdrawal, early withdrawal rejection, withdraw_to, ledger-based withdraw |
| Cancel deposit | Penalty calculation, fee recipient transfer, post-unlock rejection |
| Pause / Unpause | Deposit blocked while paused; withdraw unaffected |
| Admin | transfer_admin, accept_admin, cancel_transfer_admin, renounce_admin |
| Pause / Unpause | Deposit blocked while paused, withdrawal unaffected, is_paused query |
| Emergency withdraw | Admin-only access, funds always go to depositor |
| Read-only queries | get_vault, get_vault_batch, get_deposit_ids, time_remaining, is_initialized, 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 or block height.
- Escrow — Time-gated release of payment.
- Third-party funding — Use
deposit_forto fund a vault on behalf of another address.
See CONTRIBUTING.md for guidelines.
See CHANGELOG.md for the full version history.
MIT