diff --git a/contracts/chainmove-pool/src/lib.rs b/contracts/chainmove-pool/src/lib.rs index 6361efea..68b653d5 100644 --- a/contracts/chainmove-pool/src/lib.rs +++ b/contracts/chainmove-pool/src/lib.rs @@ -87,6 +87,7 @@ pub struct TransitionEvent { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] +pub enum OperationKind { pub struct UnitAllocationEvent { pub version: u32, pub pool_id: u64, @@ -107,11 +108,26 @@ enum OperationKind { #[contracttype] #[derive(Clone, Debug, Eq, PartialEq)] -struct OperationReceipt { - kind: OperationKind, - pool_id: u64, - participant: Address, - amount: i128, +pub struct OperationReceipt { + pub kind: OperationKind, + pub pool_id: u64, + pub participant: Address, + pub amount: i128, + /// The result from the original operation. Retries must not return a + /// position that has since changed due to a repayment or refund. + pub result: InvestorPosition, + /// Ledger at which an operator must have archived this marker. + pub archive_required_at_ledger: u32, + /// The end of the financial retention policy. A restored receipt may not + /// be accepted after this ledger, even if an archive still contains it. + pub financial_retention_ends_at_ledger: u32, +} + +#[contracttype] +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ReferenceRetentionStatus { + pub receipt: OperationReceipt, + pub archive_required: bool, } #[contracttype] @@ -147,6 +163,12 @@ enum DataKey { const DAY_IN_LEDGERS: u32 = 17280; const RENT_THRESHOLD: u32 = 7 * DAY_IN_LEDGERS; const RENT_EXTEND_TO: u32 = 30 * DAY_IN_LEDGERS; +// Soroban persistent entries have a bounded rent window. Keep the online +// replay marker for six months and require the immutable event/archive path to +// retain it for the seven-year financial retention period. +const REPLAY_MARKER_THRESHOLD: u32 = 150 * DAY_IN_LEDGERS; +const REPLAY_MARKER_EXTEND_TO: u32 = 180 * DAY_IN_LEDGERS; +const FINANCIAL_RETENTION_LEDGERS: u32 = 7 * 365 * DAY_IN_LEDGERS; const REFERENCE_KEY_DOMAIN: u32 = 1; #[contractimpl] @@ -162,7 +184,9 @@ impl ChainMovePoolContract { target_amount: i128, ) -> Result { owner.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 || total_units == 0 || target_amount <= 0 || asset_label.is_empty() { return Err(ContractError::InvalidInput); @@ -188,7 +212,9 @@ impl ChainMovePoolContract { }; env.storage().persistent().set(&key, &pool); - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); publish_transition( &env, @@ -216,7 +242,9 @@ impl ChainMovePoolContract { reference: String, ) -> Result { investor.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 || amount <= 0 || reference.is_empty() { return Err(ContractError::InvalidInput); @@ -239,7 +267,9 @@ impl ChainMovePoolContract { .persistent() .get(&pool_key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); if !pool.active { return Err(ContractError::PoolInactive); @@ -265,7 +295,9 @@ impl ChainMovePoolContract { pool.total_invested = new_total; pool.funded_units = new_units; env.storage().persistent().set(&pool_key, &pool); - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); let position_key = DataKey::InvestorPosition(pool_id, investor.clone()); let mut position = @@ -284,7 +316,9 @@ impl ChainMovePoolContract { position.invested = checked_add_i128(position.invested, amount)?; position.units = checked_add_u64(position.units, units)?; env.storage().persistent().set(&position_key, &position); - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); // Any new funding starts a new exact refund epoch from the combined // principal/unit position. Subsequent partitioned refunds all resolve @@ -307,6 +341,7 @@ impl ChainMovePoolContract { pool_id, investor.clone(), amount, + position.clone(), ); publish_transition( &env, @@ -336,7 +371,9 @@ impl ChainMovePoolContract { reference: String, ) -> Result { payer.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 || amount <= 0 || reference.is_empty() { return Err(ContractError::InvalidInput); @@ -359,7 +396,9 @@ impl ChainMovePoolContract { .persistent() .get(&pool_key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); if pool.asset != asset { return Err(ContractError::WrongAsset); @@ -375,7 +414,9 @@ impl ChainMovePoolContract { .persistent() .get(&position_key) .ok_or(ContractError::InvestorPositionNotFound)?; - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); let outstanding = position .invested @@ -391,9 +432,13 @@ impl ChainMovePoolContract { position.repaid = checked_add_i128(position.repaid, amount)?; env.storage().persistent().set(&pool_key, &pool); - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); env.storage().persistent().set(&position_key, &position); - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); write_reference( &env, @@ -402,6 +447,7 @@ impl ChainMovePoolContract { pool_id, investor.clone(), amount, + position.clone(), ); publish_transition( &env, @@ -429,7 +475,9 @@ impl ChainMovePoolContract { reference: String, ) -> Result { owner.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 || amount <= 0 || reference.is_empty() { return Err(ContractError::InvalidInput); @@ -452,7 +500,9 @@ impl ChainMovePoolContract { .persistent() .get(&pool_key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); if pool.owner != owner { return Err(ContractError::InvalidInput); @@ -464,7 +514,9 @@ impl ChainMovePoolContract { .persistent() .get(&position_key) .ok_or(ContractError::InvestorPositionNotFound)?; - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); let refundable = checked_sub_i128(position.invested, position.repaid)?; if amount > refundable { @@ -490,10 +542,15 @@ impl ChainMovePoolContract { position.units = checked_sub_u64(position.units, refund_units)?; pool.total_invested = checked_sub_i128(pool.total_invested, amount)?; pool.funded_units = checked_sub_u64(pool.funded_units, refund_units)?; - + env.storage().persistent().set(&pool_key, &pool); - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); env.storage().persistent().set(&position_key, &position); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); if position.invested == 0 { env.storage().persistent().remove(&refund_basis_key); @@ -509,6 +566,7 @@ impl ChainMovePoolContract { pool_id, investor.clone(), amount, + position.clone(), ); publish_transition( &env, @@ -530,7 +588,9 @@ impl ChainMovePoolContract { /// Marks a pool inactive so no further funding is accepted. pub fn close_pool(env: Env, owner: Address, pool_id: u64) -> Result { owner.require_auth(); - env.storage().instance().extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .instance() + .extend_ttl(RENT_THRESHOLD, RENT_EXTEND_TO); if pool_id == 0 { return Err(ContractError::InvalidInput); @@ -549,7 +609,9 @@ impl ChainMovePoolContract { pool.active = false; env.storage().persistent().set(&key, &pool); - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); publish_transition( &env, @@ -593,7 +655,9 @@ impl ChainMovePoolContract { .get(&key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); Ok(pool) } @@ -613,7 +677,9 @@ impl ChainMovePoolContract { .get(&key) .ok_or(ContractError::InvestorPositionNotFound)?; - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); Ok(position) } @@ -633,7 +699,9 @@ impl ChainMovePoolContract { .persistent() .get(&pool_key) .ok_or(ContractError::PoolNotFound)?; - env.storage().persistent().extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&pool_key, RENT_THRESHOLD, RENT_EXTEND_TO); let position_key = DataKey::InvestorPosition(pool_id, investor); let position: InvestorPosition = env @@ -641,7 +709,9 @@ impl ChainMovePoolContract { .persistent() .get(&position_key) .ok_or(ContractError::InvestorPositionNotFound)?; - env.storage().persistent().extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); + env.storage() + .persistent() + .extend_ttl(&position_key, RENT_THRESHOLD, RENT_EXTEND_TO); let numerator = position .invested @@ -650,6 +720,58 @@ impl ChainMovePoolContract { Ok((numerator / pool.target_amount) as u64) } + /// Gives operators a deterministic archive deadline before the bounded + /// on-chain replay marker can expire. Archive the emitted receipt event and + /// use `restore_reference` before a late retry reaches the contract. + pub fn reference_retention_status( + env: Env, + reference: String, + ) -> Option { + let key = DataKey::Reference(reference); + let receipt = env + .storage() + .persistent() + .get::(&key)?; + let now = env.ledger().sequence(); + Some(ReferenceRetentionStatus { + archive_required: now >= receipt.archive_required_at_ledger, + receipt, + }) + } + + /// Restores a compact archived receipt for a late retry. Only the pool + /// owner can perform this recovery, and never beyond financial retention. + pub fn restore_reference( + env: Env, + owner: Address, + reference: String, + receipt: OperationReceipt, + ) -> Result<(), ContractError> { + owner.require_auth(); + if env.ledger().sequence() > receipt.financial_retention_ends_at_ledger { + return Err(ContractError::InvalidInput); + } + let pool_key = DataKey::Pool(receipt.pool_id); + let pool: Pool = env + .storage() + .persistent() + .get(&pool_key) + .ok_or(ContractError::PoolNotFound)?; + if pool.owner != owner { + return Err(ContractError::InvalidInput); + } + let key = DataKey::Reference(reference); + if env.storage().persistent().has(&key) { + return Err(ContractError::DuplicateReference); + } + env.storage().persistent().set(&key, &receipt); + env.storage().persistent().extend_ttl( + &key, + REPLAY_MARKER_THRESHOLD, + REPLAY_MARKER_EXTEND_TO, + ); + Ok(()) + } // --- Governed WASM upgrades ------------------------------------------------- // // No single signer can move the contract's active WASM: an upgrade must be @@ -798,6 +920,11 @@ fn read_idempotent_position( .persistent() .get::(&scoped_key) { + env.storage().persistent().extend_ttl( + &key, + REPLAY_MARKER_THRESHOLD, + REPLAY_MARKER_EXTEND_TO, + ); env.storage().persistent().extend_ttl(&scoped_key, RENT_THRESHOLD, RENT_EXTEND_TO); if receipt.kind != kind || receipt.pool_id != pool_id @@ -807,6 +934,7 @@ fn read_idempotent_position( return Err(ContractError::DuplicateReference); } + return Ok(Some(receipt.result)); return Ok(Some(load_investor_position(env, pool_id, participant)?)); } @@ -841,7 +969,32 @@ fn write_reference( pool_id: u64, participant: Address, amount: i128, + result: InvestorPosition, ) { + let key = DataKey::Reference(reference); + let current_ledger = env.ledger().sequence(); + let receipt = OperationReceipt { + kind, + pool_id, + participant, + amount, + result, + archive_required_at_ledger: current_ledger.saturating_add(REPLAY_MARKER_THRESHOLD), + financial_retention_ends_at_ledger: current_ledger + .saturating_add(FINANCIAL_RETENTION_LEDGERS), + }; + env.storage().persistent().set(&key, &receipt); + env.storage() + .persistent() + .extend_ttl(&key, REPLAY_MARKER_THRESHOLD, REPLAY_MARKER_EXTEND_TO); + // This immutable event is the archival hand-off for replay protection once + // the bounded persistent marker reaches its rent limit. + env.events().publish( + ( + Symbol::new(env, "chainmove_pool_v1"), + Symbol::new(env, "reference_receipt_v1"), + ), + receipt, let key = scoped_reference_key(env, &kind, pool_id, &participant, &reference); env.storage().persistent().set( &key, @@ -852,7 +1005,6 @@ fn write_reference( amount, }, ); - env.storage().persistent().extend_ttl(&key, RENT_THRESHOLD, RENT_EXTEND_TO); } fn allocate_units(pool: &Pool, new_total: i128) -> Result { diff --git a/docs/financial-idempotency-retention.md b/docs/financial-idempotency-retention.md new file mode 100644 index 00000000..685f470e --- /dev/null +++ b/docs/financial-idempotency-retention.md @@ -0,0 +1,27 @@ +# Financial idempotency retention + +Funding, repayment, and refund references are financial operation receipts. A +reference is never treated as reusable merely because its on-chain storage entry +has expired. + +## Policy + +- The on-chain replay marker is retained for 180 days, with archive action due + after 150 days. This bounds Soroban rent exposure. +- Each receipt preserves the original operation result, so a retry returns the + original result rather than the current, subsequently changed position. +- Receipt events must be archived by the operator archive pipeline for seven + years from their creation ledger. The archive is the authoritative source for + late replay protection after the bounded on-chain marker expires. +- Before processing a late retry, an operator restores the archived compact + receipt with `restore_reference`. Restoration is limited to the pool owner + and is rejected after the seven-year financial retention deadline. + +## Operator procedure + +1. Poll `reference_retention_status` for references approaching the archive + deadline; `archive_required` is the actionable signal. +2. Verify the immutable receipt event is present in the financial archive. +3. For a late retry whose marker is absent, restore the archived receipt before + retrying the financial operation. The retry returns its original result and + performs no transfer.