Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ Lazy vaults have their `USER_VAULTS` index populated on first access via `initia

#### `create_vault_full(owner, amount, start_time, end_time) → u64`
- Admin-only.
- Requires `(end_time - start_time) ≤ MAX_DURATION` where `MAX_DURATION = 315,360,000` seconds (10 years). Panics otherwise.
- Deducts `amount` from `ADMIN_BALANCE`. Panics if insufficient.
- Writes full vault struct with `is_initialized = true`.
- Updates `USER_VAULTS[owner]`.
Expand All @@ -315,6 +316,7 @@ Lazy vaults have their `USER_VAULTS` index populated on first access via `initia

#### `create_vault_lazy(owner, amount, start_time, end_time) → u64`
- Admin-only.
- Requires `(end_time - start_time) ≤ MAX_DURATION` where `MAX_DURATION = 315,360,000` seconds (10 years). Panics otherwise.
- Same as above but sets `is_initialized = false` and skips `USER_VAULTS` write.
- Lower storage cost at creation time.

Expand All @@ -341,6 +343,7 @@ Lazy vaults have their `USER_VAULTS` index populated on first access via `initia
#### `batch_create_vaults_lazy(batch_data) → Vec<u64>`
- Admin-only.
- Validates total batch amount against `ADMIN_BALANCE` in a single check upfront.
- Requires each vault’s `(end_time - start_time) ≤ MAX_DURATION` where `MAX_DURATION = 315,360,000` seconds (10 years). Panics otherwise.
- Creates all vaults lazily in a loop. Updates `VAULT_COUNT` once at the end.

#### `batch_create_vaults_full(batch_data) → Vec<u64>`
Expand Down
66 changes: 46 additions & 20 deletions contracts/grant_contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const END_TIME: Symbol = symbol_short!("END");
const RECIPIENT: Symbol = symbol_short!("RECIPIENT");
const CLAIMED: Symbol = symbol_short!("CLAIMED");

// 10 years in seconds (Issue #44)
const MAX_DURATION: u64 = 315_360_000;

#[contractimpl]
impl GrantContract {
pub fn initialize_grant(
Expand All @@ -18,35 +21,47 @@ impl GrantContract {
total_amount: U256,
duration_seconds: u64,
) -> u64 {
assert!(
duration_seconds <= MAX_DURATION,
"duration exceeds MAX_DURATION"
);
let start_time = env.ledger().timestamp();
let end_time = start_time + duration_seconds;

env.storage().instance().set(&TOTAL_AMOUNT, &total_amount);
env.storage().instance().set(&START_TIME, &start_time);
env.storage().instance().set(&END_TIME, &end_time);
env.storage().instance().set(&RECIPIENT, &recipient);
env.storage().instance().set(&CLAIMED, &U256::from_u32(&env, 0));

env.storage()
.instance()
.set(&CLAIMED, &U256::from_u32(&env, 0));
end_time
}

pub fn claimable_balance(env: Env) -> U256 {
let current_time = env.ledger().timestamp();
let start_time = env.storage().instance().get(&START_TIME).unwrap_or(0);
let end_time = env.storage().instance().get(&END_TIME).unwrap_or(0);
let total_amount = env.storage().instance().get(&TOTAL_AMOUNT).unwrap_or(U256::from_u32(&env, 0));
let claimed = env.storage().instance().get(&CLAIMED).unwrap_or(U256::from_u32(&env, 0));

let total_amount = env
.storage()
.instance()
.get(&TOTAL_AMOUNT)
.unwrap_or(U256::from_u32(&env, 0));
let claimed = env
.storage()
.instance()
.get(&CLAIMED)
.unwrap_or(U256::from_u32(&env, 0));
if current_time <= start_time {
return U256::from_u32(&env, 0);
}

let elapsed = if current_time >= end_time {
end_time - start_time
} else {
current_time - start_time
};

let total_duration = end_time - start_time;
let vested = if total_duration > 0 {
let elapsed_u256 = U256::from_u32(&env, elapsed as u32);
Expand All @@ -55,36 +70,47 @@ impl GrantContract {
} else {
U256::from_u32(&env, 0)
};

if vested > claimed {
vested.sub(&claimed)
} else {
U256::from_u32(&env, 0)
}
}

pub fn claim(env: Env, recipient: Address) -> U256 {
recipient.require_auth();

let stored_recipient = env.storage().instance().get(&RECIPIENT).unwrap();
assert_eq!(recipient, stored_recipient, "Unauthorized recipient");

let claimable = Self::claimable_balance(env.clone());
assert!(claimable > U256::from_u32(&env, 0), "No tokens to claim");

let claimed = env.storage().instance().get(&CLAIMED).unwrap_or(U256::from_u32(&env, 0));

let claimed = env
.storage()
.instance()
.get(&CLAIMED)
.unwrap_or(U256::from_u32(&env, 0));
let new_claimed = claimed.add(&claimable);
env.storage().instance().set(&CLAIMED, &new_claimed);

claimable
}

pub fn get_grant_info(env: Env) -> (U256, u64, u64, U256) {
let total_amount = env.storage().instance().get(&TOTAL_AMOUNT).unwrap_or(U256::from_u32(&env, 0));
let total_amount = env
.storage()
.instance()
.get(&TOTAL_AMOUNT)
.unwrap_or(U256::from_u32(&env, 0));
let start_time = env.storage().instance().get(&START_TIME).unwrap_or(0);
let end_time = env.storage().instance().get(&END_TIME).unwrap_or(0);
let claimed = env.storage().instance().get(&CLAIMED).unwrap_or(U256::from_u32(&env, 0));

let claimed = env
.storage()
.instance()
.get(&CLAIMED)
.unwrap_or(U256::from_u32(&env, 0));
(total_amount, start_time, end_time, claimed)
}
}
Expand Down
19 changes: 17 additions & 2 deletions contracts/grant_contracts/src/test.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#![cfg(test)]

use super::*;
use soroban_sdk::{testutils::{Address as _, Ledger}, Address, Env, U256};
use soroban_sdk::{testutils::Address as _, Address, Env, U256};

#[test]
fn test_basic_grant() {
Expand All @@ -15,7 +15,22 @@ fn test_basic_grant() {
let duration = 100u64;

client.initialize_grant(&recipient, &total_amount, &duration);

let claimable = client.claimable_balance();
assert_eq!(claimable, U256::from_u32(&env, 0));
}

#[test]
#[should_panic(expected = "duration exceeds MAX_DURATION")]
fn test_initialize_rejects_duration_over_max() {
let env = Env::default();
env.mock_all_auths();
let contract_id = env.register(GrantContract, ());
let client = GrantContractClient::new(&env, &contract_id);

let recipient = Address::generate(&env);
let total_amount = U256::from_u32(&env, 1000);
let duration = super::MAX_DURATION + 1;

client.initialize_grant(&recipient, &total_amount, &duration);
}
83 changes: 54 additions & 29 deletions contracts/vesting_contracts/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ use soroban_sdk::{
Vec,
};

// 10 years in seconds (Issue #44)
pub const MAX_DURATION: u64 = 315_360_000;

// DataKey for whitelisted tokens
#[contracttype]
pub enum WhitelistDataKey {
Expand Down Expand Up @@ -51,6 +54,12 @@ pub struct Vault {
pub released_amount: i128,
pub start_time: u64,
pub end_time: u64,
pub creation_time: u64, // Timestamp of creation for clawback grace period
pub step_duration: u64, // Duration of each vesting step in seconds (0 = linear)

pub is_initialized: bool, // Lazy initialization flag
pub is_irrevocable: bool, // Security flag to prevent admin withdrawal
pub is_transferable: bool, // Can the beneficiary transfer this vault?
pub keeper_fee: i128, // Fee paid to anyone who triggers auto_claim
pub title: String, // Short human-readable title (max 32 chars)
pub is_initialized: bool, // Lazy initialization flag
Expand Down Expand Up @@ -115,6 +124,26 @@ pub struct VestingContract;
#[contractimpl]
#[allow(deprecated)]
impl VestingContract {
fn require_not_deprecated(env: &Env) {
let deprecated: bool = env
.storage()
.instance()
.get(&DataKey::IsDeprecated)
.unwrap_or(false);
if deprecated {
panic!("Contract is deprecated");
}
}

fn require_valid_duration(start_time: u64, end_time: u64) {
let duration = end_time
.checked_sub(start_time)
.unwrap_or_else(|| panic!("end_time must be >= start_time"));
if duration > MAX_DURATION {
panic!("duration exceeds MAX_DURATION");
}
}

// Admin-only: Add token to whitelist
pub fn add_to_whitelist(env: Env, token: Address) {
Self::require_admin(&env);
Expand Down Expand Up @@ -424,6 +453,7 @@ impl VestingContract {
) -> u64 {

Self::require_admin(&env);
Self::require_valid_duration(start_time, end_time);

let mut vault_count: u64 = env
.storage()
Expand Down Expand Up @@ -528,6 +558,7 @@ impl VestingContract {
step_duration: u64,
) -> u64 {
Self::require_admin(&env);
Self::require_valid_duration(start_time, end_time);

let mut vault_count: u64 = env
.storage()
Expand Down Expand Up @@ -673,24 +704,6 @@ impl VestingContract {
elapsed
};

if vault.step_duration > 0 {
// Periodic vesting: calculate vested = (elapsed / step_duration) * rate * step_duration
// Rate is total_amount / duration, so: vested = (elapsed / step_duration) * (total_amount / duration) * step_duration
// This simplifies to: vested = (elapsed / step_duration) * total_amount * step_duration / duration
let completed_steps = elapsed / vault.step_duration;
let rate_per_second = vault.total_amount / duration as i128;
let vested = completed_steps as i128 * rate_per_second * vault.step_duration as i128;

// Ensure we don't exceed total amount
if vested > vault.total_amount {
vault.total_amount
} else {
vested
}
} else {
// Linear vesting
(vault.total_amount * elapsed as i128) / duration as i128
}
(vault.total_amount * effective_elapsed as i128) / duration as i128
}

Expand Down Expand Up @@ -1157,19 +1170,19 @@ impl VestingContract {
let now = env.ledger().timestamp();
for i in 0..batch_data.recipients.len() {
let vault_id = initial_count + i as u64 + 1;
let start_time: u64 = batch_data.start_times.get(i).unwrap();
let end_time: u64 = batch_data.end_times.get(i).unwrap();
Self::require_valid_duration(start_time, end_time);

let vault = Vault {
title: String::from_slice(&env, ""),
owner: batch_data.recipients.get(i).unwrap(),
delegate: None,
total_amount: batch_data.amounts.get(i).unwrap(),
released_amount: 0,
start_time: batch_data.start_times.get(i).unwrap(),
end_time: batch_data.end_times.get(i).unwrap(),
start_time,
end_time,
keeper_fee: batch_data.keeper_fees.get(i).unwrap(),
title: String::from_slice(&env, ""),
is_initialized: false, // Lazy initialization
is_irrevocable: false, // Default to revocable for batch operations
is_initialized: false,
is_irrevocable: false,
creation_time: now,
Expand All @@ -1184,7 +1197,6 @@ impl VestingContract {
.set(&DataKey::VaultData(vault_id), &vault);
vault_ids.push_back(vault_id);

let start_time = batch_data.start_times.get(i).unwrap();
let cliff_duration = start_time.saturating_sub(now);
let vault_created = VaultCreated {
vault_id,
Expand Down Expand Up @@ -1244,15 +1256,18 @@ impl VestingContract {
let now = env.ledger().timestamp();
for i in 0..batch_data.recipients.len() {
let vault_id = initial_count + i as u64 + 1;
let start_time: u64 = batch_data.start_times.get(i).unwrap();
let end_time: u64 = batch_data.end_times.get(i).unwrap();
Self::require_valid_duration(start_time, end_time);

let vault = Vault {
title: String::from_slice(&env, ""),
owner: batch_data.recipients.get(i).unwrap(),
delegate: None,
total_amount: batch_data.amounts.get(i).unwrap(),
released_amount: 0,
start_time: batch_data.start_times.get(i).unwrap(),
end_time: batch_data.end_times.get(i).unwrap(),
start_time,
end_time,
keeper_fee: batch_data.keeper_fees.get(i).unwrap(),
title: String::from_slice(&env, ""),
is_initialized: true,
Expand Down Expand Up @@ -1280,7 +1295,6 @@ impl VestingContract {

vault_ids.push_back(vault_id);

let start_time = batch_data.start_times.get(i).unwrap();
let cliff_duration = start_time.saturating_sub(now);
let vault_created = VaultCreated {
vault_id,
Expand Down Expand Up @@ -1434,6 +1448,12 @@ impl VestingContract {
pub fn revoke_partial(env: Env, vault_id: u64, amount: i128) -> i128 {
Self::require_admin(&env);

let mut vault: Vault = env
.storage()
.instance()
.get(&DataKey::VaultData(vault_id))
.unwrap_or_else(|| panic!("Vault not found"));

let returned = Self::internal_revoke_partial(&env, vault_id, amount);

// Single admin balance update for this call
Expand Down Expand Up @@ -1593,6 +1613,10 @@ impl VestingContract {
if now > vault.creation_time + grace_period {
panic!("Grace period expired");
}
if vault.released_amount > 0 {
panic!("Tokens already claimed");
}


if vault.released_amount > 0 {
panic!("Tokens already claimed");
Expand Down Expand Up @@ -1908,7 +1932,6 @@ impl VestingContract {

// Calculate currently claimable tokens based on linear vesting
pub fn get_claimable_amount(env: Env, vault_id: u64) -> i128 {
let vault: Vault = env.storage().instance()
let vault: Vault = env
.storage()
.instance()
Expand All @@ -1927,7 +1950,6 @@ impl VestingContract {
// Auto-claim function that anyone can call.
// Tokens go to beneficiary, but keeper earns a fee.
pub fn auto_claim(env: Env, vault_id: u64, keeper: Address) {
let mut vault: Vault = env.storage().instance()
if Self::is_paused(env.clone()) {
panic!("Contract is paused - all withdrawals are disabled");
}
Expand Down Expand Up @@ -2105,4 +2127,7 @@ impl VestingContract {
}
}

// Unit tests for this contract are kept as integration tests under
// `contracts/vesting_contracts/tests/` to avoid `no_std` test-harness friction.
// mod test;
// mod test; // Disabled - tests need refactoring
Loading
Loading