Skip to content
Open
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
22 changes: 15 additions & 7 deletions STORAGE_LAYOUT.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,22 @@ Instance storage is bundled with the contract instance. We store `AdminConfig` a
### 2. Persistent Storage for Large User Entities
`SavingsVault` and `LandlordEscrow` are placed in persistent storage because they vary in size, grow linearly with user count, and are unique to user accounts. Placing them in instance storage would make the instance size grow prohibitively large.

### 3. Dynamic TTL Extensions (`extend_ttl_info`)
To prevent data eviction and user fund lockups:
- When a user vault is created or updated, the contract triggers:
### 3. Dynamic TTL Extensions & Rent-Cycle Lifecycle Safeguards
To prevent storage archival and ensure user funds remain un-archived across real-world monthly rental cycles:
- Previous defaults (10,000 ledgers threshold / 100,000 ledgers extension) provided only ~16 hours minimum threshold and ~6.9 days maximum extension at ~6 seconds per Stellar ledger. For a monthly rent product where users interact once per 30 days, this introduced high risk of state eviction.
- **Updated Production Constants**:
- `VAULT_TTL_THRESHOLD`: `50_000` ledgers (~3.5 days minimum)
- `VAULT_TTL_EXTEND`: `500_000` ledgers (~35–40 days maximum extension)
- `ESCROW_TTL_THRESHOLD`: `50_000` ledgers
- `ESCROW_TTL_EXTEND`: `500_000` ledgers
- When a user vault is created, updated, or deposited into:
```rust
env.storage().persistent().extend_ttl_info(
env.storage().persistent().extend_ttl(
&DataKey::Vault(tenant),
10000, // minimum threshold (~1.3 days)
100000, // extend to ~13.8 days
VAULT_TTL_THRESHOLD,
VAULT_TTL_EXTEND,
);
```
- Similarly, creating or releasing a landlord escrow extends its lifetime. This ensures active vaults and escrows never expire due to storage rent rules.
- Creating a landlord escrow executes an identical TTL extension for `DataKey::Escrow(landlord, escrow_id)`.
- **On-Chain & Off-Chain Bump Instructions**:
- For long-lived vaults or escrows waiting for future claim dates without deposits, the contract exposes `bump_vault_ttl(env, tenant)` and `bump_escrow_ttl(env, landlord, escrow_id)` callable permissionlessly or via automated off-chain cron heartbeats to bump TTL before expiration.
36 changes: 33 additions & 3 deletions rentar-contract/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ fn set_admin_config(env: &Env, config: &AdminConfig) {
env.storage().instance().set(&DataKey::AdminConfig, config);
}

/// Storage TTL thresholds and extension bounds
/// Raised to 50,000 threshold (~3.5 days at 6s/ledger) and 500,000 extension (~35-40 days / rent-cycle scale)
/// to safeguard long-lived tenant vaults and landlord escrows against archival.
pub const VAULT_TTL_THRESHOLD: u32 = 50_000;
pub const VAULT_TTL_EXTEND: u32 = 500_000;
pub const ESCROW_TTL_THRESHOLD: u32 = 50_000;
pub const ESCROW_TTL_EXTEND: u32 = 500_000;

// Helper to fetch vault
fn get_vault_opt(env: &Env, tenant: &Address) -> Option<SavingsVault> {
env.storage().persistent().get(&DataKey::Vault(tenant.clone()))
Expand All @@ -53,8 +61,8 @@ fn set_vault(env: &Env, tenant: &Address, vault: &SavingsVault) {
// Extend TTL to optimize storage and prevent eviction (standard practice in Soroban)
env.storage().persistent().extend_ttl(
&DataKey::Vault(tenant.clone()),
10000, // minimum threshold
100000, // lifetime extension
VAULT_TTL_THRESHOLD,
VAULT_TTL_EXTEND,
);
}

Expand Down Expand Up @@ -343,7 +351,7 @@ impl RentarContract {

let key = DataKey::Escrow(landlord.clone(), id_counter);
env.storage().persistent().set(&key, &escrow);
env.storage().persistent().extend_ttl(&key, 10000, 100000);
env.storage().persistent().extend_ttl(&key, ESCROW_TTL_THRESHOLD, ESCROW_TTL_EXTEND);

// Emit SavingsCreated event (or Escrow created notice)
env.events().publish(
Expand Down Expand Up @@ -456,4 +464,26 @@ impl RentarContract {
pub fn get_admin_config(env: Env) -> Result<AdminConfig, Error> {
get_admin_config_opt(&env).ok_or(Error::NotInitialized)
}

/// Bumps the persistent storage TTL for a tenant's vault to ensure long-term availability across rent cycles.
pub fn bump_vault_ttl(env: Env, tenant: Address) -> Result<(), Error> {
check_paused(&env)?;
let key = DataKey::Vault(tenant.clone());
if !env.storage().persistent().has(&key) {
return Err(Error::VaultNotFound);
}
env.storage().persistent().extend_ttl(&key, VAULT_TTL_THRESHOLD, VAULT_TTL_EXTEND);
Ok(())
}

/// Bumps the persistent storage TTL for a landlord's escrow entry.
pub fn bump_escrow_ttl(env: Env, landlord: Address, escrow_id: u64) -> Result<(), Error> {
check_paused(&env)?;
let key = DataKey::Escrow(landlord.clone(), escrow_id);
if !env.storage().persistent().has(&key) {
return Err(Error::EscrowNotFound);
}
env.storage().persistent().extend_ttl(&key, ESCROW_TTL_THRESHOLD, ESCROW_TTL_EXTEND);
Ok(())
}
}
63 changes: 52 additions & 11 deletions rentar-contract/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ use soroban_sdk::{
};

// Create helper to deploy a mock token for testing
fn create_mock_token<'a>(env: &Env, admin: &Address) -> soroban_sdk::token::Client<'a> {
fn create_mock_token<'a>(env: &Env, admin: &Address) -> (soroban_sdk::token::Client<'a>, soroban_sdk::token::StellarAssetClient<'a>) {
let token_address = env.register_stellar_asset_contract(admin.clone());
soroban_sdk::token::Client::new(env, &token_address)
(
soroban_sdk::token::Client::new(env, &token_address),
soroban_sdk::token::StellarAssetClient::new(env, &token_address),
)
}

#[test]
Expand All @@ -22,10 +25,10 @@ fn test_full_contract_lifecycle() {
let landlord = Address::generate(&env);

// 1. Deploy & Initialize Mock Token
let token = create_mock_token(&env, &admin);
let (token, admin_token) = create_mock_token(&env, &admin);

// Mint some initial tokens to the tenant to fund their savings plan
token.mint(&tenant, &5000);
admin_token.mint(&tenant, &5000);
assert_eq!(token.balance(&tenant), 5000);

// 2. Deploy & Initialize Rentar Contract
Expand All @@ -46,7 +49,7 @@ fn test_full_contract_lifecycle() {
let target_goal = 2000;
// Set lock duration for 1 day in the future (86400 seconds)
let current_timestamp = 100000;
env.ledger().set_timestamp(current_timestamp);
env.ledger().with_mut(|li| li.timestamp = current_timestamp);
let lock_until = current_timestamp + 86400;
let monthly_target = 1000;

Expand Down Expand Up @@ -97,7 +100,7 @@ fn test_full_contract_lifecycle() {
assert_eq!(vault.goal_reached, false); // Balance fell below target goal (2000), state is updated

// 7. Fast forward time past the Lock-until threshold
env.ledger().set_timestamp(lock_until + 1);
env.ledger().with_mut(|li| li.timestamp = lock_until + 1);

// Authorized withdraw should succeed now
client.withdraw(&tenant, &100);
Expand All @@ -107,7 +110,7 @@ fn test_full_contract_lifecycle() {
assert_eq!(vault.balance, 995);

// 8. Create Secure Escrow payment for Landlord
token.mint(&tenant, &2000);
admin_token.mint(&tenant, &2000);
let escrow_id = client.create_escrow(&tenant, &landlord, &1500, &(lock_until + 10000));

let escrow = client.get_escrow(&landlord, &escrow_id);
Expand All @@ -121,7 +124,7 @@ fn test_full_contract_lifecycle() {
assert!(release_res.is_err());

// Advance ledger time past escrow lock
env.ledger().set_timestamp(lock_until + 10001);
env.ledger().with_mut(|li| li.timestamp = lock_until + 10001);
client.release_escrow(&landlord, &escrow_id);

// Landlord balance check (was 1000, now +1500 = 2500)
Expand Down Expand Up @@ -155,8 +158,8 @@ fn test_withdraw_underflow() {
let landlord = Address::generate(&env);

// Set up token, contract, and vault
let token = create_mock_token(&env, &admin);
token.mint(&tenant, &1000);
let (token, admin_token) = create_mock_token(&env, &admin);
admin_token.mint(&tenant, &1000);

let contract_id = env.register_contract(None, RentarContract);
let client = RentarContractClient::new(&env, &contract_id);
Expand All @@ -165,7 +168,7 @@ fn test_withdraw_underflow() {

// Lock period in the past so withdrawals are not blocked by SavingsLocked
let past_lock: u64 = 0;
env.ledger().set_timestamp(1000);
env.ledger().with_mut(|li| li.timestamp = 1000);

let description = String::from_str(&env, "Underflow Test Vault");
client.create_vault(&tenant, &landlord, &500, &past_lock, &100, &description);
Expand All @@ -192,3 +195,41 @@ fn test_withdraw_underflow() {
let vault = client.get_vault(&tenant);
assert_eq!(vault.balance, 0, "Balance should be zero after full withdrawal");
}

#[test]
fn test_storage_ttl_bump_lifecycle() {
let env = Env::default();
env.mock_all_auths();

let admin = Address::generate(&env);
let tenant = Address::generate(&env);
let landlord = Address::generate(&env);

let (token, admin_token) = create_mock_token(&env, &admin);
admin_token.mint(&tenant, &2000);

let contract_id = env.register_contract(None, RentarContract);
let client = RentarContractClient::new(&env, &contract_id);

client.initialize(&admin, &token.address, &10);

// Create vault
let description = String::from_str(&env, "TTL Bump Test Vault");
client.create_vault(&tenant, &landlord, &1000, &5000, &200, &description);

// Create escrow
let escrow_id = client.create_escrow(&tenant, &landlord, &500, &10000);

// Test bump_vault_ttl succeeds for existing vault
assert!(client.try_bump_vault_ttl(&tenant).is_ok());

// Test bump_escrow_ttl succeeds for existing escrow
assert!(client.try_bump_escrow_ttl(&landlord, &escrow_id).is_ok());

// Test bump_vault_ttl fails for nonexistent tenant
let non_tenant = Address::generate(&env);
assert!(client.try_bump_vault_ttl(&non_tenant).is_err());

// Test bump_escrow_ttl fails for nonexistent escrow ID
assert!(client.try_bump_escrow_ttl(&landlord, &999).is_err());
}
Loading