Version: 1.0.0
Target Audience: Security Auditors, Protocol Engineers
Network: Stellar / Soroban Smart Contracts
Language: Rust (#![no_std], soroban-sdk)
- Overview
- Architecture
- Contract: GrantContract
- Contract: VestingContract
- Security Model
- Invariants
- Error Codes & Panic Conditions
- Known Limitations & Auditor Notes
This system consists of two Soroban smart contracts deployed on Stellar:
GrantContract— A single-beneficiary, time-linear vesting contract. It accepts a total token amount and a duration, then exposes a claimable balance that grows linearly fromstart_timetoend_time.VestingContract— A multi-vault, admin-controlled vesting manager. An admin allocates tokens into discrete vaults for multiple beneficiaries, with support for lazy or full initialization, batch creation, revocation, and beneficiary transfer.
The two contracts are architecturally independent but conceptually complementary: GrantContract models a single grant issuance, while VestingContract manages an entire fleet of grants from a shared supply.
┌─────────────────────────────────────────────┐
│ Admin / Issuer │
└────────────┬──────────────────┬─────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ GrantContract │ │ VestingContract │
│ (single grant) │ │ (multi-vault pool) │
└────────┬─────────┘ └──────────┬───────────┘
│ │
▼ ▼
Beneficiary Vault[1..N]
claims linearly per beneficiary
All values are stored in instance storage (tied to contract lifetime).
| Key Symbol | Type | Description |
|---|---|---|
TOTAL |
U256 | Total tokens allocated to the grant |
START |
u64 | Unix timestamp when vesting begins |
END |
u64 | Unix timestamp when vesting completes |
RECIPIENT |
Address | The sole beneficiary of this grant |
CLAIMED |
U256 | Cumulative amount already claimed |
The claimable balance at any point in time is computed as follows:
Let:
T = total_amount
t0 = start_time
t1 = end_time
tn = current ledger timestamp
C = claimed (cumulative)
if tn <= t0:
claimable = 0
elif tn >= t1:
elapsed = t1 - t0 # capped at full duration
else:
elapsed = tn - t0
vested = T * elapsed / (t1 - t0)
claimable = max(vested - C, 0)
Key properties:
- Vesting is strictly linear — no cliff, no step function.
- The formula uses
U256arithmetic throughout to prevent overflow on large token amounts. - Integer division truncates (floors), so claimable values may be up to
1token less than the theoretical continuous value. Tests confirm this tolerance explicitly. - Once
tn >= t1,elapsedis frozen att1 - t0, so the claimable balance never exceedstotal_amount.
total_amount = 1,000,000 tokens
duration = 100 seconds
start_time = 1000 (unix)
At t=1050 (halfway):
elapsed = 50
vested = 1,000,000 * 50 / 100 = 500,000
claimed = 0
claimable = 500,000
At t=1100 (end):
elapsed = 100 (capped)
vested = 1,000,000
claimable = 1,000,000 - claimed
initialize_grant()
│
▼
┌─────────────────────┐
│ INITIALIZED │◄──────────────────────────┐
│ claimable = 0 │ │
│ (tn <= start_time) │ │
└────────┬────────────┘ │
│ time advances past start_time │
▼ │
┌─────────────────────┐ │
│ VESTING │──── claim() ─────────────►│
│ 0 < claimable < T │ (updates CLAIMED, │
│ (t0 < tn < t1) │ resets claimable to 0) │
└────────┬────────────┘ │
│ time advances past end_time │
▼ │
┌─────────────────────┐ │
│ FULLY VESTED │──── claim() ─────────────►┘
│ claimable = T - C │
│ (tn >= t1) │
└─────────────────────┘
│ all tokens claimed
▼
┌─────────────────────┐
│ EXHAUSTED │
│ claimable = 0 │
│ claimed = T │
└─────────────────────┘
- Sets all storage keys.
start_time= current ledger timestamp at time of call.end_time=start_time + duration_seconds.- Returns
end_time. - No re-initialization guard exists. Calling this a second time will overwrite the existing grant. Auditors should verify this is acceptable in the deployment model.
- Pure read — does not mutate state.
- Returns the currently claimable (unvested minus already-claimed) balance.
- Requires
recipient.require_auth(). - Asserts
recipient == stored RECIPIENT— panics otherwise. - Asserts
claimable > 0— panics if nothing to claim. - Increments
CLAIMEDby the claimable amount. - Returns the claimed amount.
- Does not perform actual token transfer — the contract records accounting only. Token disbursement is expected to be handled externally.
- Returns
(total_amount, start_time, end_time, claimed). - Pure read.
All values stored in instance storage.
| Key Symbol | Type | Description |
|---|---|---|
VAULT_COUNT |
u64 | Total number of vaults created (monotonic) |
VAULT_DATA |
Vault (struct) | Keyed by vault_id (u64); stores per-vault state |
USER_VAULTS |
Vec<u64> | Keyed by Address; lists vault IDs per user |
INITIAL_SUPPLY |
i128 | The total token supply set at initialization |
ADMIN_BALANCE |
i128 | Tokens not yet allocated to any vault |
ADMIN_ADDRESS |
Address | Current admin |
PROPOSED_ADMIN |
Address | Pending admin from two-step transfer (optional) |
pub struct Vault {
// i128 (largest)
pub total_amount: i128, // = initial_deposit_shares
pub released_amount: i128,
pub keeper_fee: i128,
pub staked_amount: i128,
// 8-byte values
pub owner: Address,
pub delegate: Option<Address>,
pub title: String,
pub start_time: u64,
pub end_time: u64,
pub creation_time: u64,
pub step_duration: u64,
// bools (smallest)
pub is_initialized: bool,
pub is_irrevocable: bool,
pub is_transferable: bool,
}Soroban serialization note:
#[contracttype]structs are serialized as an ordered tuple (field order matters). Reordering fields changes the on-ledger schema and requires a migration strategy if anyVaultentries already exist. Storage serialization has no alignment padding; this change primarily reduces Rust in-memory padding. For upgrade-safe evolution, prefer explicit versioning (e.g.,VaultV1/VaultV2) over reordering existing fields.
Note for auditors: The
VestingContractdoes not compute a vested amount internally. It trackstotal_amountandreleased_amountonly. The actual time-based vesting calculation — and any enforcement ofstart_time/end_timeat claim time — is not present inclaim_tokens(). Any caller can claim any unreleased amount regardless of the current time. This is a significant design note detailed further in Known Limitations.
| Mode | is_initialized at creation |
USER_VAULTS updated at creation |
|---|---|---|
create_vault_full |
true |
Yes |
create_vault_lazy |
false |
No (deferred) |
batch_create_vaults_full |
true |
Yes (per vault) |
batch_create_vaults_lazy |
false |
No (deferred) |
Lazy vaults have their USER_VAULTS index populated on first access via initialize_vault_metadata(), get_vault(), or get_user_vaults().
create_vault_full() / create_vault_lazy()
│
▼
┌────────────────────┐
│ CREATED │
│ (is_initialized │
│ = true or false) │
└──────┬─────────────┘
│
┌────────────┴─────────────────────┐
│ lazy │ full
▼ ▼
┌────────────────┐ ┌─────────────────┐
│ LAZY (index │ │ ACTIVE (index │
│ not written) │ │ written to │
│ │ │ USER_VAULTS) │
└───────┬────────┘ └────────┬─────────┘
│ initialize_vault_metadata() │
│ / get_vault() / get_user_vaults()│
└─────────────┬────────────────────┘
▼
┌─────────────────┐
│ ACTIVE │◄──────── transfer_beneficiary()
│ (fully indexed)│ (updates USER_VAULTS)
└──────┬──────────┘
│
┌──────────┴──────────────────┐
│ claim_tokens() │ revoke_tokens()
▼ ▼
┌───────────────┐ ┌──────────────────────┐
│ PARTIALLY │ │ REVOKED │
│ CLAIMED │ │ released_amount │
│ │ │ = total_amount │
└───────┬───────┘ └──────────────────────┘
│ all tokens claimed
▼
┌───────────────┐
│ EXHAUSTED │
│ released = │
│ total_amount │
└───────────────┘
- Sets
INITIAL_SUPPLY,ADMIN_BALANCE(=initial_supply),ADMIN_ADDRESS, andVAULT_COUNT = 0. - No re-initialization guard. Calling again resets all balances.
- Admin-only (see Security Model).
- Writes
new_admintoPROPOSED_ADMIN.
- Caller must match
PROPOSED_ADMIN. - Moves
PROPOSED_ADMIN→ADMIN_ADDRESS, clearsPROPOSED_ADMIN.
- Pure reads.
- Admin-only.
- Requires
(end_time - start_time) ≤ MAX_DURATIONwhereMAX_DURATION = 315,360,000seconds (10 years). Panics otherwise. - Deducts
amountfromADMIN_BALANCE. Panics if insufficient. - Writes full vault struct with
is_initialized = true. - Updates
USER_VAULTS[owner]. - Emits
VaultCreatedevent. - Returns new
vault_id.
- Admin-only.
- Requires
(end_time - start_time) ≤ MAX_DURATIONwhereMAX_DURATION = 315,360,000seconds (10 years). Panics otherwise. - Same as above but sets
is_initialized = falseand skipsUSER_VAULTSwrite. - Lower storage cost at creation time.
- Public (no auth required).
- If vault is lazy (
is_initialized = false), sets it totrueand writes toUSER_VAULTS. - Returns
trueif initialization occurred,falseif already initialized.
- No auth check — any caller can invoke this function.
- Requires
is_initialized == true. - Requires
claim_amount > 0. - Requires
claim_amount <= (total_amount - released_amount). - Increments
released_amount. Returnsclaim_amount. - Does not verify time-based vesting schedule — see Known Limitations.
- Admin-only.
- Updates
vault.owner. - If
is_initialized: removesvault_idfrom old owner'sUSER_VAULTS, adds to new owner's. - If lazy: skips index update (index will be correct when initialized later).
- Emits
BeneficiaryChangedevent.
- Admin-only.
- Validates total batch amount against
ADMIN_BALANCEin a single check upfront. - Requires each vault’s
(end_time - start_time) ≤ MAX_DURATIONwhereMAX_DURATION = 315,360,000seconds (10 years). Panics otherwise. - Creates all vaults lazily in a loop. Updates
VAULT_COUNTonce at the end.
- Same as above but with full initialization per vault (writes
USER_VAULTSper vault).
- Admin-only.
- Computes
unreleased = total_amount - released_amount. - Sets
released_amount = total_amount(marks vault as fully released). - Returns
unreleasedtoADMIN_BALANCE. - Emits
TokensRevokedevent. - Panics if
unreleased == 0(already exhausted or revoked).
- Auto-initializes lazy vaults on read.
- Returns vault ID list for user. Auto-initializes any lazy vaults found.
- Returns
(total_locked, total_claimed, admin_balance)across all vaults.
- Returns whether
total_locked + total_claimed + admin_balance == initial_supply.
- Admin-only emergency migration to a V2 architecture.
- Sets a global
is_deprecated = trueflag and pauses the contract. - Transfers all balances of whitelisted tokens held by the contract address to
v2_contract_address. - Returns a map of
token_address → migrated_amount.
- Pure read — returns whether the contract is deprecated (frozen).
- Pure read — returns the V2 contract address if migration has been executed.
fn require_admin(env: &Env) {
let admin = env.storage().instance().get(&ADMIN_ADDRESS)...;
let caller = env.current_contract_address();
require!(caller == admin, "Caller is not admin");
}Critical Auditor Note: The admin check compares
env.current_contract_address()against the stored admin.current_contract_address()returns the address of the contract itself, not the transaction invoker. This meansrequire_adminas implemented will always fail in practice unless the contract is calling itself (e.g., via cross-contract invocation). This pattern does not protect against unauthorized external callers in the way a traditionalrequire_auth()check would. This is a high-severity finding that should be reviewed before mainnet deployment.
The admin handover uses a propose-then-accept pattern to prevent accidental or malicious transfers to wrong addresses:
Admin calls propose_new_admin(X) → PROPOSED_ADMIN = X
X calls accept_ownership() → ADMIN_ADDRESS = X, PROPOSED_ADMIN cleared
This prevents the admin role from being transferred to an address that cannot sign transactions.
claim_tokens performs no require_auth() check and no time-based vesting check. Any address can call it for any vault. The only enforced constraint is that claim_amount ≤ unreleased. Combined with the broken require_admin check, this means the VestingContract's token accounting can be manipulated by any external actor.
claim() correctly calls recipient.require_auth() and verifies the caller matches the stored recipient. This is the correctly implemented auth pattern that should be replicated in VestingContract.
The VestingContract defines and exposes a global balance invariant:
INVARIANT: total_locked + total_claimed + admin_balance == initial_supply
Where:
total_locked = Σ (vault.total_amount - vault.released_amount) for all vaults
total_claimed = Σ vault.released_amount for all vaults
admin_balance = ADMIN_BALANCE
This invariant holds under all valid state transitions:
| Operation | Effect on invariant components |
|---|---|
create_vault_full/lazy |
admin_balance -= amount, total_locked += amount |
claim_tokens(id, x) |
total_locked -= x, total_claimed += x |
revoke_tokens(id) |
total_locked -= unreleased, admin_balance += unreleased |
batch_create_vaults_* |
Same as single create, repeated |
transfer_beneficiary |
No token amounts change; invariant unaffected |
initialize_vault_metadata |
No token amounts change; invariant unaffected |
The invariant can be verified on-chain by calling check_invariant().
Soroban contracts do not use typed error enums in this codebase. All errors are runtime panics with string messages. The following table documents all reachable panic conditions:
| Function | Condition | Panic Message |
|---|---|---|
require_admin |
Stored admin not set | "Admin not set" |
require_admin |
Caller is not admin | "Caller is not admin" |
propose_new_admin |
Caller is not admin | (via require_admin) |
accept_ownership |
No proposed admin in storage | "No proposed admin found" |
accept_ownership |
Caller is not the proposed admin | "Caller is not the proposed admin" |
create_vault_full |
admin_balance < amount |
"Insufficient admin balance" |
create_vault_lazy |
admin_balance < amount |
"Insufficient admin balance" |
batch_create_vaults_lazy |
admin_balance < sum(amounts) |
"Insufficient admin balance for batch" |
batch_create_vaults_full |
admin_balance < sum(amounts) |
"Insufficient admin balance for batch" |
claim_tokens |
Vault not found in storage | "Vault not found" |
claim_tokens |
vault.is_initialized == false |
"Vault not initialized" |
claim_tokens |
claim_amount <= 0 |
"Claim amount must be positive" |
claim_tokens |
claim_amount > unreleased |
"Insufficient tokens to claim" |
transfer_beneficiary |
Vault not found in storage | "Vault not found" |
revoke_tokens |
Vault not found in storage | "Vault not found" |
revoke_tokens |
unreleased_amount == 0 |
"No tokens available to revoke" |
| Function | Condition | Panic Message / Assert |
|---|---|---|
claim |
recipient != stored RECIPIENT |
"Unauthorized recipient" |
claim |
claimable == 0 |
"No tokens to claim" |
get_grant_info |
Storage key missing (first access) | Unwrap panic (no message) |
Several functions call .unwrap() on storage reads without a fallback. These will panic if the contract is queried before initialize / initialize_grant is called:
get_admin()— panics ifADMIN_ADDRESSnot setclaim()inGrantContract— panics ifRECIPIENTnot set
As noted above, env.current_contract_address() is the contract's own address, not the transaction signer. All admin-gated functions in VestingContract are therefore unprotected in practice. The correct pattern is admin.require_auth().
The VestingContract stores start_time and end_time on vaults but never checks them during claim_tokens(). A beneficiary (or any caller) can claim all tokens the moment the vault is created. The time parameters are currently only cosmetic / event metadata.
There is no owner.require_auth() or equivalent. Any address can call claim_tokens(vault_id, x) and drain a vault's accounting balance.
Both initialize() and initialize_grant() will overwrite existing state if called again. This can be used to reset ADMIN_BALANCE or CLAIMED to arbitrary values.
Neither contract integrates with a Soroban token contract (token::Client). All claim, revoke, and initialize operations update internal accounting only. The actual movement of tokens to/from beneficiaries is not implemented.
Any external caller can call initialize_vault_metadata(vault_id) on any lazy vault, triggering the USER_VAULTS index write. While not directly harmful to token balances, it may have unintended gas/storage side effects at scale.
get_vault() is named like a view function but calls initialize_vault_metadata() which writes to storage. Auditors and integrators should treat it as a state-mutating call.
GrantContract uses U256 for token arithmetic (safe for all realistic token amounts). VestingContract uses i128 (max ~1.7 × 10³⁸), which is sufficient but auditors should verify no negative values are introduced via unexpected call ordering.