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 {
pub owner: Address, // Current beneficiary
pub total_amount: i128, // Total tokens in this vault
pub released_amount: i128, // Tokens already claimed or revoked
pub start_time: u64, // Vesting start (unix timestamp)
pub end_time: u64, // Vesting end (unix timestamp)
pub is_initialized: bool, // Lazy init flag
}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.
- 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.
- 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. - 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.
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.