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
34 changes: 29 additions & 5 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ pub fn upgrade(env: Env, caller: Address, new_wasm_hash: BytesN<32>)
**Version Function Signature:**

```rust
pub fn version(env: Env) -> Option<BytesN<32>>
pub fn get_version(env: Env) -> Option<BytesN<32>>
```

**Behavior note (pause semantics):**
Expand All @@ -135,6 +135,7 @@ pub fn version(env: Env) -> Option<BytesN<32>>
|-----|------|-------------|
| `admin` | `Address` | Admin address; may call `distribute` and `set_admin` |
| `usdc` | `Address` | USDC token contract address |
| `ContractVersion` | `BytesN<32>` | WASM hash set by `upgrade` function |

#### Upgradeability

Expand All @@ -151,7 +152,7 @@ soroban contract invoke --contract-id <REVENUE_POOL_ID> -- upgrade \
--caller <ADMIN> --new_wasm_hash <32-byte-hex>
```

The `version()` view returns the stored WASM hash; the contract emits an `upgraded`
The `get_version()` view returns the stored WASM hash; the contract emits an `upgraded`
event with the admin as a topic and the new version as data.

**Init signature** (`lib.rs:28-39`):
Expand All @@ -168,6 +169,25 @@ pub fn init(env: Env, admin: Address, usdc_token: Address)
| `vault` | `Address` | Registered vault address |
| `developer_balances` | `Map<Address, i128>` | Per-developer balance tracking |
| `global_pool` | `GlobalPool` | Total balance and last updated timestamp |
| `ContractVersion` | `BytesN<32>` | WASM hash set by `upgrade` function |

#### Upgradeability

The settlement contract supports in-place upgrades via an admin-gated `upgrade` function.
This method calls the host deployer to update the contract WASM code while
preserving existing instance storage.

```bash
# Build new WASM
cargo build --target wasm32-unknown-unknown --release -p callora-settlement

# Compute WASM hash and call upgrade via RPC or tooling
soroban contract invoke --contract-id <SETTLEMENT_ID> -- upgrade \
--caller <ADMIN> --new_wasm_hash <32-byte-hex>
```

The `get_version()` view returns the stored WASM hash (as an `Option<BytesN<32>>`); the contract emits an `upgraded`
event with the admin as a topic and the new version as data.

**GlobalPool structure** (`lib.rs:16-19`):

Expand Down Expand Up @@ -236,7 +256,7 @@ The vault now supports admin-gated in-place upgrades that preserve all existing
4. **Verify upgrade**
```bash
# Check version marker
soroban contract invoke --contract-id <VAULT_ID> -- version
soroban contract invoke --contract-id <VAULT_ID> -- get_version
# Should return <NEW_WASM_HASH>

# Verify state preserved
Expand All @@ -246,11 +266,15 @@ The vault now supports admin-gated in-place upgrades that preserve all existing

5. **Run post-upgrade migration** (if needed)
```bash
# If the new WASM includes a migrate function for schema changes
// If the new WASM includes a migrate function for schema changes
soroban contract invoke --contract-id <VAULT_ID> -- migrate \
--caller <ADMIN>
```

**Migration of `request_id` Deduplication (v1.2+):**
The vault now uses `persistent` storage for `ProcessedRequest` idempotency markers rather than `temporary` storage.
During the upgrade, no explicit state migration script is needed. The `require_not_duplicate` checks have been updated to query *both* temporary (legacy) and persistent (new) storage. Temporary markers will naturally expire over the next ~30 days. Going forward, the owner should periodically invoke `prune_processed_requests` to garbage-collect old persistent markers and recover storage deposits.

6. **Monitor and verify**
- Test a small transaction
- Verify all view functions return expected values
Expand All @@ -264,7 +288,7 @@ The `upgrade` function emits an `upgraded` event with:
- Data: New WASM hash (BytesN<32>)

**Version Tracking:**
- Call `version()` to retrieve the current WASM hash
- Call `get_version()` to retrieve the current WASM hash
- Returns `None` for contracts deployed before upgrade functionality
- Returns `Some(BytesN<32>)` after first upgrade

Expand Down
5 changes: 2 additions & 3 deletions contracts/revenue_pool/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -582,12 +582,11 @@ impl RevenuePool {

/// Read the stored contract version (WASM hash) as last set by `upgrade`.
///
/// Panics if no version has been stored yet.
pub fn version(env: Env) -> BytesN<32> {
/// Returns `None` if no version has been stored yet.
pub fn get_version(env: Env) -> Option<BytesN<32>> {
env.storage()
.instance()
.get(&Symbol::new(&env, VERSION_KEY))
.expect("version not set")
}
}

Expand Down
4 changes: 2 additions & 2 deletions contracts/revenue_pool/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1883,8 +1883,8 @@ fn upgrade_sets_version_and_emits_event() {
client.upgrade(&admin, &new_hash);

// version() should return stored value
let readback: BytesN<32> = client.version();
assert_eq!(readback, new_hash);
let readback: Option<BytesN<32>> = client.get_version();
assert_eq!(readback, Some(new_hash));

// An `upgraded` event should have been emitted
let events = env.events().all();
Expand Down
37 changes: 36 additions & 1 deletion contracts/settlement/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#![no_std]

use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, Env, Symbol, Vec};
use soroban_sdk::{contract, contracterror, contractimpl, contracttype, token, Address, BytesN, Env, Symbol, Vec};

/// Maximum number of items allowed in a single `batch_receive_payment` call.
pub const MAX_BATCH_SIZE: u32 = 50;
Expand Down Expand Up @@ -58,6 +58,7 @@ pub enum StorageKey {
DeveloperBalance(Address),
GlobalPool,
Usdc,
ContractVersion,
}

/// Developer balance record in settlement contract
Expand Down Expand Up @@ -791,6 +792,40 @@ impl CalloraSettlement {
env.panic_with_error(SettlementError::Unauthorized);
}
}

/// Admin-gated contract upgrade.
///
/// Only the current admin may call. This will instruct the host to update
/// the current contract WASM to `new_wasm_hash` and persist the version marker.
/// Emits an `upgraded` event with the admin as topic and the new version as data.
pub fn upgrade(env: Env, caller: Address, new_wasm_hash: BytesN<32>) {
caller.require_auth();
let admin = Self::get_admin(env.clone());
if caller != admin {
env.panic_with_error(SettlementError::Unauthorized);
}

// Perform the on-chain upgrade via the deployer interface.
env.deployer().update_current_contract_wasm(new_wasm_hash.clone());

// Persist the version marker for on-chain queries.
env.storage()
.instance()
.set(&StorageKey::ContractVersion, &new_wasm_hash);

// Emit an event for indexers / audit logs.
env.events()
.publish((Symbol::new(&env, "upgraded"), admin), new_wasm_hash);
}

/// Read the stored contract version (WASM hash) as last set by `upgrade`.
///
/// Returns `None` if no upgrade has been performed yet (initial deployment).
pub fn get_version(env: Env) -> Option<BytesN<32>> {
env.storage()
.instance()
.get(&StorageKey::ContractVersion)
}
}

#[cfg(test)]
Expand Down
17 changes: 17 additions & 0 deletions contracts/settlement/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1679,5 +1679,22 @@ mod settlement_tests {
pool.total_balance + sum_dev_balances,
"Conservation invariant violated: total credits must equal pool + developer balances"
);
#[test]
fn test_upgrade_and_get_version() {
let (env, addr, admin, _vault, _third_party) = setup_contract();
let client = CalloraSettlementClient::new(&env, &addr);

assert_eq!(client.get_version(), None);

let new_hash = BytesN::from_array(&env, &[1u8; 32]);
client.upgrade(&admin, &new_hash);

assert_eq!(client.get_version(), Some(new_hash.clone()));

// An `upgraded` event should have been emitted
let events = env.events().all();
let ev = events.last().unwrap();
let name = soroban_sdk::Symbol::try_from_val(&env, &ev.1.get(0).unwrap()).unwrap();
assert_eq!(name, soroban_sdk::Symbol::new(&env, "upgraded"));
}
}
10 changes: 5 additions & 5 deletions contracts/vault/STORAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ pub enum StorageKey {
PendingAdmin, // Address
DepositorList, // Vec<Address>
ContractVersion, // BytesN<32>
ProcessedRequest(Symbol), // bool — temporary storage, idempotency marker
ProcessedRequest(Symbol), // bool — persistent storage, idempotency marker
}
```

Expand All @@ -91,7 +91,7 @@ pub enum StorageKey {
| `PendingAdmin` | Instance | `Address` | Two-step admin transfer nominee | `set_admin()`, `accept_admin()` |
| `DepositorList` | Instance | `Vec<Address>` | Allowed depositor addresses | `set_allowed_depositor()`, `get_allowed_depositors()` |
| `ContractVersion` | Instance | `BytesN<32>` | WASM hash set by `upgrade()` | `upgrade()`, `version()` |
| `ProcessedRequest(Symbol)` | **Temporary** | `bool` | Idempotency marker for a processed deduct `request_id` | Written by `deduct()` / `batch_deduct()`; read by `is_request_processed()` |
| `ProcessedRequest(Symbol)` | **Persistent** | `bool` | Idempotency marker for a processed deduct `request_id` | Written by `deduct()` / `batch_deduct()`; read by `is_request_processed()` |

## Data Structures

Expand Down Expand Up @@ -342,7 +342,7 @@ Monitor storage-related events:
|---------|--------|
| 1.0 | Initial `StorageKey` enum with `Meta`, `AllowedDepositors`, `Admin`, `UsdcToken`, `Settlement`, `RevenuePool`, `MaxDeduct`, `Metadata(String)` |
| 1.1 | Renamed `StorageKey` → `DataKey`; added doc comments to all variants; removed stale `// Replaced by StorageKey enum variants` comment; updated STORAGE.md |
| 1.2 | Added `StorageKey::ProcessedRequest(Symbol)` in **temporary storage** for `request_id` idempotency in `deduct` and `batch_deduct`. Added `VaultError::DuplicateRequestId` (code 28). Added `is_request_processed(request_id)` view. TTL: threshold ~7 days, bump to ~30 days. |
| 1.2 | Added `StorageKey::ProcessedRequest(Symbol)` in **persistent storage** for `request_id` idempotency in `deduct` and `batch_deduct`. Added `VaultError::DuplicateRequestId` (code 28). Added `is_request_processed(request_id)` view. TTL: threshold ~7 days, bump to ~30 days. |

## Canonical Storage Keys

Expand All @@ -364,10 +364,10 @@ All storage is accessed via `StorageKey` enum.
| `PendingOwner` | Instance | Ownership transfer nominee |
| `PendingAdmin` | Instance | Admin transfer nominee |
| `ContractVersion` | Instance | WASM hash (set by `upgrade()`) |
| `ProcessedRequest(Symbol)` | **Temporary** | Idempotency marker; auto-expires after ~30 days |
| `ProcessedRequest(Symbol)` | **Persistent** | Idempotency marker; manually pruned |

### Migration

- Removes deprecated `AllowedDepositors`
- Ensures Admin fallback from Meta.owner
- `ProcessedRequest` uses temporary storage — no manual cleanup required; markers expire automatically
- `ProcessedRequest` uses persistent storage — markers must be explicitly pruned using `prune_processed_requests` to avoid state bloat
62 changes: 38 additions & 24 deletions contracts/vault/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@
/// treated as a fire-and-forget deduction with no idempotency guarantee.
///
/// ### Retention / TTL
/// Processed-request markers live in temporary storage and are bumped to
/// `REQUEST_ID_BUMP_AMOUNT` ledgers on every successful deduct. The threshold
/// for triggering a bump is `REQUEST_ID_BUMP_THRESHOLD`. After the TTL expires
/// the marker is archived and a previously-seen `request_id` can be reused —
/// callers must not rely on deduplication beyond the retention window.
/// Processed-request markers live in persistent storage and are bumped to
/// `REQUEST_ID_BUMP_AMOUNT` ledgers on every successful deduct. The threshold
/// for triggering a bump is `REQUEST_ID_BUMP_THRESHOLD`. Because they are now
/// persistent, they do not silently archive. To prevent state bloat, an owner
/// can explicitly prune old markers using `prune_processed_requests`.
use soroban_sdk::{
contract, contractclient, contracterror, contractimpl, contracttype, token, Address, BytesN,
Env, String, Symbol, Vec,
Expand Down Expand Up @@ -147,9 +147,8 @@ pub enum StorageKey {
ContractVersion,
/// Idempotency marker for a processed deduct request.
///
/// Stored in **temporary storage** so it expires automatically after
/// `REQUEST_ID_BUMP_AMOUNT` ledgers. The value is `true` (a `bool`);
/// presence of the key is the authoritative signal.
/// Stored in **persistent storage**. The value is `true` (a `bool`);
/// presence of the key is the authoritative signal. Must be pruned explicitly.
ProcessedRequest(Symbol),
}

Expand Down Expand Up @@ -177,9 +176,9 @@ pub const MAX_OFFERING_ID_LEN: u32 = 64;
pub const INSTANCE_BUMP_THRESHOLD: u32 = 17_280 * 30; // ~30 days
pub const INSTANCE_BUMP_AMOUNT: u32 = 17_280 * 60; // ~60 days

// Processed-request idempotency markers live in temporary storage.
// Processed-request idempotency markers live in persistent storage.
// Bump when fewer than 7 days remain; extend to 30 days.
// After the TTL expires the marker is archived and the request_id can be reused.
// Must be pruned via prune_processed_requests when they are no longer needed.
pub const REQUEST_ID_BUMP_THRESHOLD: u32 = 17_280 * 7; // ~7 days
pub const REQUEST_ID_BUMP_AMOUNT: u32 = 17_280 * 30; // ~30 days

Expand Down Expand Up @@ -613,7 +612,7 @@ impl CalloraVault {
/// When `request_id` is `Some(id)`, the contract checks whether `id` has
/// already been processed. If so, `VaultError::DuplicateRequestId` is
/// returned immediately — no funds are moved. On first success the marker
/// is persisted in temporary storage for `REQUEST_ID_BUMP_AMOUNT` ledgers.
/// is persisted in persistent storage for `REQUEST_ID_BUMP_AMOUNT` ledgers.
///
/// When `request_id` is `None`, no deduplication is performed.
///
Expand Down Expand Up @@ -1148,12 +1147,31 @@ impl CalloraVault {
/// Read the stored contract version (WASM hash) as last set by `upgrade`.
///
/// Returns `None` if no upgrade has been performed yet (initial deployment).
pub fn version(env: Env) -> Option<BytesN<32>> {
pub fn get_version(env: Env) -> Option<BytesN<32>> {
env.storage()
.instance()
.get(&StorageKey::ContractVersion)
}

/// Garbage-collect processed request markers from persistent storage.
/// Only the owner can call this.
/// Emits a `request_id_pruned` event for each removed ID.
pub fn prune_processed_requests(env: Env, caller: Address, ids: Vec<Symbol>) -> Result<(), VaultError> {
caller.require_auth();
Self::require_owner(env.clone(), caller.clone())?;

for id in ids.iter() {
let key = StorageKey::ProcessedRequest(id.clone());
if env.storage().persistent().has(&key) {
env.storage().persistent().remove(&key);
env.events()
.publish((Symbol::new(&env, "request_id_pruned"), caller.clone()), id.clone());
}
}

Ok(())
}

// -----------------------------------------------------------------------
// Private helpers
// -----------------------------------------------------------------------
Expand All @@ -1171,32 +1189,28 @@ impl CalloraVault {
}

/// Return `true` if `request_id` has already been processed (marker present
/// in temporary storage and not yet expired).
/// in persistent storage, or temporary storage for legacy markers).
pub fn is_request_processed(env: Env, request_id: Symbol) -> bool {
env.storage()
.temporary()
.has(&StorageKey::ProcessedRequest(request_id))
let key = StorageKey::ProcessedRequest(request_id);
env.storage().persistent().has(&key) || env.storage().temporary().has(&key)
}

/// Check that `request_id` has NOT been processed yet.
/// Returns `VaultError::DuplicateRequestId` if the marker exists.
fn require_not_duplicate(env: &Env, request_id: &Symbol) -> Result<(), VaultError> {
if env
.storage()
.temporary()
.has(&StorageKey::ProcessedRequest(request_id.clone()))
{
let key = StorageKey::ProcessedRequest(request_id.clone());
if env.storage().persistent().has(&key) || env.storage().temporary().has(&key) {
return Err(VaultError::DuplicateRequestId);
}
Ok(())
}

/// Persist a processed-request marker in temporary storage and set its TTL.
/// Persist a processed-request marker in persistent storage and set its TTL.
fn mark_request_processed(env: &Env, request_id: &Symbol) {
let key = StorageKey::ProcessedRequest(request_id.clone());
env.storage().temporary().set(&key, &true);
env.storage().persistent().set(&key, &true);
env.storage()
.temporary()
.persistent()
.extend_ttl(&key, REQUEST_ID_BUMP_THRESHOLD, REQUEST_ID_BUMP_AMOUNT);
}

Expand Down
Loading
Loading