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
43 changes: 43 additions & 0 deletions .github/workflows/ttl-doctor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: Storage TTL Doctor

on:
schedule:
- cron: '0 2 * * *' # Run nightly at 2:00 AM UTC

jobs:
ttl-doctor:
name: Run Storage TTL Doctor
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'

- name: Install dependencies
run: npm install

- name: Run TTL Doctor Script
env:
SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org"
VAULT_CONTRACT_ID: ${{ secrets.VAULT_CONTRACT_ID }}
SETTLEMENT_CONTRACT_ID: ${{ secrets.SETTLEMENT_CONTRACT_ID }}
REVENUE_POOL_CONTRACT_ID: ${{ secrets.REVENUE_POOL_CONTRACT_ID }}
run: |
echo "Executing Storage TTL Doctor..."
if [ -n "$VAULT_CONTRACT_ID" ] || [ -n "$SETTLEMENT_CONTRACT_ID" ] || [ -n "$REVENUE_POOL_CONTRACT_ID" ]; then
npx ts-node scripts/storage-ttl-doctor.ts \
--rpc-url "$SOROBAN_RPC_URL" \
${VAULT_CONTRACT_ID:+--vault-id "$VAULT_CONTRACT_ID"} \
${SETTLEMENT_CONTRACT_ID:+--settlement-id "$SETTLEMENT_CONTRACT_ID"} \
${REVENUE_POOL_CONTRACT_ID:+--revenue-pool-id "$REVENUE_POOL_CONTRACT_ID"}
else
echo "WARNING: Contract IDs are not set in GitHub repository secrets."
echo "To configure, please set secrets: VAULT_CONTRACT_ID, SETTLEMENT_CONTRACT_ID, REVENUE_POOL_CONTRACT_ID."
echo "Running mock self-validation check..."
# We can run it against dummy IDs or just print a message so the workflow doesn't fail when no secrets are set yet
npx ts-node scripts/storage-ttl-doctor.ts --vault-id "CDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD"
fi
41 changes: 41 additions & 0 deletions contracts/revenue_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,19 @@ pub struct AdminBroadcast {
pub message: String,
}

/// Remaining storage TTL information for a storage category.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct StorageEntryTtl {
pub category: String,
pub key_desc: String,
pub storage_type: String,
pub ttl: u32,
pub threshold: u32,
pub bump_amount: u32,
}


/// TTL bump constants for instance storage archival risk mitigation.
/// Soroban archives ledger entries after ~7 days (631 ledgers) of inactivity.
/// Bumping TTL ensures state remains accessible for critical operations.
Expand Down Expand Up @@ -876,8 +889,36 @@ impl RevenuePool {
AdminBroadcast { severity, message },
);
}

/// Return the remaining TTL for each storage key category.
pub fn get_storage_ttl(env: Env) -> Vec<StorageEntryTtl> {
let mut result = Vec::new(&env);

// 1. Instance Storage
let instance_ttl = {
#[cfg(any(test, feature = "testutils"))]
{
env.storage().instance().get_ttl()
}
#[cfg(not(any(test, feature = "testutils")))]
{
BUMP_AMOUNT
}
};
result.push_back(StorageEntryTtl {
category: String::from_str(&env, "Instance"),
key_desc: String::from_str(&env, "Instance"),
storage_type: String::from_str(&env, "Instance"),
ttl: instance_ttl,
threshold: LIFETIME_THRESHOLD,
bump_amount: BUMP_AMOUNT,
});

result
}
}


mod events;
/// Split `payments` into consecutive chunks of at most `chunk_size` legs each,
/// preserving order.
Expand Down
111 changes: 111 additions & 0 deletions contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,117 @@ impl CalloraSettlement {
(result, next_cursor)
}

/// Return the remaining TTL for each storage key category.
///
/// # Parameters
/// - `developer_addresses` — optional list of developers to check. If empty, the index is used.
pub fn get_storage_ttl(env: Env, developer_addresses: Vec<Address>) -> Vec<StorageEntryTtl> {
let mut result = Vec::new(&env);

// 1. Instance Storage
let instance_ttl = {
#[cfg(any(test, feature = "testutils"))]
{
env.storage().instance().get_ttl()
}
#[cfg(not(any(test, feature = "testutils")))]
{
17_280 * 60
}
};
result.push_back(StorageEntryTtl {
category: String::from_str(&env, "Instance"),
key_desc: String::from_str(&env, "Instance"),
storage_type: String::from_str(&env, "Instance"),
ttl: instance_ttl,
threshold: 17_280 * 30,
bump_amount: 17_280 * 60,
});

// Determine which developer addresses to inspect
let devs = if developer_addresses.len() > 0 {
developer_addresses
} else {
env.storage()
.instance()
.get(&StorageKey::DeveloperIndex)
.unwrap_or_else(|| Vec::new(&env))
};

for dev in devs.iter() {
// Check DeveloperBalance (Persistent)
let bal_key = StorageKey::DeveloperBalance(dev.clone());
if env.storage().persistent().has(&bal_key) {
let ttl = {
#[cfg(any(test, feature = "testutils"))]
{
env.storage().persistent().get_ttl(&bal_key)
}
#[cfg(not(any(test, feature = "testutils")))]
{
50000
}
};
result.push_back(StorageEntryTtl {
category: String::from_str(&env, "DeveloperBalance"),
key_desc: String::from_str(&env, "DeveloperBalance"),
storage_type: String::from_str(&env, "Persistent"),
ttl,
threshold: 50000,
bump_amount: 50000,
});
}

// Check WithdrawalToday (Persistent)
let today_key = StorageKey::WithdrawalToday(dev.clone());
if env.storage().persistent().has(&today_key) {
let ttl = {
#[cfg(any(test, feature = "testutils"))]
{
env.storage().persistent().get_ttl(&today_key)
}
#[cfg(not(any(test, feature = "testutils")))]
{
50000
}
};
result.push_back(StorageEntryTtl {
category: String::from_str(&env, "WithdrawalToday"),
key_desc: String::from_str(&env, "WithdrawalToday"),
storage_type: String::from_str(&env, "Persistent"),
ttl,
threshold: 50000,
bump_amount: 50000,
});
}

// Check DailyWithdrawCap (Persistent)
let cap_key = StorageKey::DailyWithdrawCap(dev.clone());
if env.storage().persistent().has(&cap_key) {
let ttl = {
#[cfg(any(test, feature = "testutils"))]
{
env.storage().persistent().get_ttl(&cap_key)
}
#[cfg(not(any(test, feature = "testutils")))]
{
50000
}
};
result.push_back(StorageEntryTtl {
category: String::from_str(&env, "DailyWithdrawCap"),
key_desc: String::from_str(&env, "DailyWithdrawCap"),
storage_type: String::from_str(&env, "Persistent"),
ttl,
threshold: 50000,
bump_amount: 50000,
});
}
}

result
}

/// Return the pending admin address, or `None` if no two-step admin transfer is in progress.
///
/// Integrators can poll this to detect an in-flight admin handover
Expand Down
68 changes: 68 additions & 0 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ pub struct WithdrawEventData {
pub new_balance: i128,
}

/// Remaining storage TTL information for a storage category.
#[contracttype]
#[derive(Clone, Debug, PartialEq)]
pub struct StorageEntryTtl {
pub category: String,
pub key_desc: String,
pub storage_type: String,
pub ttl: u32,
pub threshold: u32,
pub bump_amount: u32,
}


/// Severity levels for admin broadcast messages.
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
Expand Down Expand Up @@ -499,6 +512,61 @@ impl CalloraVault {
.unwrap_or(Vec::new(&env))
}

/// Return the remaining TTL for each storage key category.
///
/// # Parameters
/// - `request_ids` — a list of processed request IDs to check.
pub fn get_storage_ttl(env: Env, request_ids: Vec<Symbol>) -> Vec<StorageEntryTtl> {
let mut result = Vec::new(&env);

// 1. Instance Storage
let instance_ttl = {
#[cfg(any(test, feature = "testutils"))]
{
env.storage().instance().get_ttl()
}
#[cfg(not(any(test, feature = "testutils")))]
{
INSTANCE_BUMP_AMOUNT
}
};
result.push_back(StorageEntryTtl {
category: String::from_str(&env, "Instance"),
key_desc: String::from_str(&env, "Instance"),
storage_type: String::from_str(&env, "Instance"),
ttl: instance_ttl,
threshold: INSTANCE_BUMP_THRESHOLD,
bump_amount: INSTANCE_BUMP_AMOUNT,
});

// 2. ProcessedRequest Storage (Persistent)
for rid in request_ids.iter() {
let key = StorageKey::ProcessedRequest(rid.clone());
if env.storage().persistent().has(&key) {
let ttl = {
#[cfg(any(test, feature = "testutils"))]
{
env.storage().persistent().get_ttl(&key)
}
#[cfg(not(any(test, feature = "testutils")))]
{
REQUEST_ID_BUMP_AMOUNT
}
};
result.push_back(StorageEntryTtl {
category: String::from_str(&env, "ProcessedRequest"),
key_desc: String::from_str(&env, "ProcessedRequest"),
storage_type: String::from_str(&env, "Persistent"),
ttl,
threshold: REQUEST_ID_BUMP_THRESHOLD,
bump_amount: REQUEST_ID_BUMP_AMOUNT,
});
}
}

result
}

// -----------------------------------------------------------------------
// Mutating functions
// -----------------------------------------------------------------------
Expand Down
Loading
Loading