diff --git a/intent_settlement/src/lib.rs b/intent_settlement/src/lib.rs index 6915565..66a6896 100644 --- a/intent_settlement/src/lib.rs +++ b/intent_settlement/src/lib.rs @@ -29,6 +29,15 @@ const PROTOCOL_FEE_BPS: i128 = 5; // 0.05% /// closes. const BID_WINDOW: u64 = 120; // 2 minutes +/// Dispute-resolution parameters (issue #48, #233): +/// When a solver delivers tokens (begin_fill), the user has DISPUTE_WINDOW seconds +/// to open a dispute. If no dispute is raised, release_fill() can execute after +/// the window closes. If a dispute is raised, the arbiter has ARBITER_WINDOW +/// seconds to resolve it; if unresolved, the timeout releases escrow to the user. +const DISPUTE_WINDOW: u64 = 3600; // 1 hour: time for user to notice and contest fill +const ARBITER_WINDOW: u64 = 86400; // 24 hours: time for arbiter to resolve +const DISPUTE_BOND: i128 = 1 * 10_000_000; // 1 USDC: anti-griefing bond from user + /// Delay enforced between proposing and executing a sensitive admin change /// (admin transfer, fee recipient handover, dst_token allowlist changes). /// Gives users and solvers a window to notice and react before the change @@ -144,6 +153,11 @@ pub enum DataKey { /// unpause access) -- resuming the protocol always needs the full /// admin's judgment. Pauser, + + /// **Instance storage.** The `Address` of the `ProofRegistry` contract + /// used to verify cross-chain proofs in fill_intent. Set by + /// `set_proof_registry`. Absent until explicitly set by admin. + ProofRegistry, } // ─── Data Structs ───────────────────────────────────────────────────────────── @@ -196,6 +210,16 @@ pub struct IntentRecord { /// intent transitions to `Filled` as soon as `total_filled` satisfies /// the user's `min_dst_amount` requirement. pub total_filled: i128, + + /// Dispute-resolution tracking (issue #48, #233): + /// The timestamp at which the dispute window closes (DISPUTE_WINDOW seconds + /// after begin_fill sets this). If None, no fill is being escrowed. + pub dispute_deadline: Option, + /// The timestamp when the user opened the dispute (set by open_dispute). + /// Used to calculate the arbiter's resolution deadline. + pub dispute_raised_at: Option, + /// The final resolution (Upheld or Dismissed) if the dispute was resolved. + pub resolution: Option, } #[contracttype] @@ -213,6 +237,19 @@ pub enum IntentState { /// `BID_WINDOW` elapses the best bid is settled and the intent transitions /// to `Accepted`. Bidding, + /// Solver has delivered tokens to escrow; user has a dispute window to contest. + Filling, + /// User raised a dispute within the window; fill is on hold pending arbiter decision. + Disputed, + /// Arbiter resolved the dispute (either Upheld or Dismissed). + Resolved, +} + +#[contracttype] +#[derive(Clone, PartialEq, Debug)] +pub enum DisputeResolution { + Upheld, // arbiter sided with user; tokens returned, solver slashed + Dismissed, // arbiter sided with solver; tokens released normally } /// A registered solver (market maker) @@ -408,6 +445,27 @@ pub enum Error { /// If `src_chain` is unknown this error is never raised — unknown chains /// bypass token-format validation so the allowlist remains the sole gate. InvalidSrcToken = 28, + + /// A proof is required (require_proof=true) but no proof was found in the + /// ProofRegistry for this intent_id. + ProofNotFound = 29, + /// The proof was found but the source amount is less than required by the intent. + ProofAmountInsufficient = 30, + /// The proof source chain ID does not match the intent's declared source chain. + ProofChainMismatch = 31, + /// ProofRegistry contract address was not configured (set_proof_registry not called). + ProofRegistryNotSet = 32, + + /// User attempted to open a dispute but has insufficient bond balance. + InsufficientDisputeBond = 33, + /// Dispute-related operation called when no dispute is open, or called on wrong state. + NoDisputeOpen = 34, + /// Arbiter attempted to resolve a dispute after the ARBITER_WINDOW has elapsed. + ArbiterWindowExpired = 35, + /// Intent is not in a state that allows release_fill (no fill in escrow). + NoFillEscrowed = 36, + /// User attempted to open a dispute after the DISPUTE_WINDOW has closed. + DisputeWindowExpired = 37, } // ─── Contract ───────────────────────────────────────────────────────────────── @@ -893,6 +951,23 @@ impl IntentSettlement { env.storage().instance().get(&DataKey::Pauser) } + /// Admin-only: configure the ProofRegistry contract address for proof-gated fills. + /// This must be called before solvers can use `fill_intent` with `require_proof=true`. + pub fn set_proof_registry(env: Env, registry: Address) { + Self::require_admin(&env); + env.storage() + .instance() + .set(&DataKey::ProofRegistry, ®istry); + Self::bump_instance_ttl(&env); + env.events() + .publish((Symbol::new(&env, "proof_registry_updated"),), registry); + } + + /// Retrieve the configured ProofRegistry address, if set. + pub fn get_proof_registry(env: Env) -> Option
{ + env.storage().instance().get(&DataKey::ProofRegistry) + } + /// Admin- or pauser-only: halt new intent submission, acceptance, and /// fills for incident response. slash_solver stays permissionless /// throughout, so a solver already holding an Accepted intent can't @@ -1277,6 +1352,9 @@ impl IntentSettlement { filled_at: None, fill_amount: None, total_filled: 0, + dispute_deadline: None, + dispute_raised_at: None, + resolution: None, }; env.storage() @@ -1417,7 +1495,19 @@ impl IntentSettlement { /// /// The protocol fee is taken on each individual fill so the fee accounting /// stays consistent regardless of how many fills it takes. - pub fn fill_intent(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: i128) { + /// + /// When `require_proof=true`, the solver must have delivered a valid proof + /// of the source-chain deposit to the ProofRegistry. The proof is validated + /// against the intent's declared src_chain and src_amount. When + /// `require_proof=false`, economic trust in the solver's bond backs the fill + /// (legacy behavior; used when no proof-delivery infrastructure is available). + pub fn fill_intent( + env: Env, + solver: Address, + intent_id: BytesN<32>, + fill_amount: i128, + require_proof: bool, + ) { // Auth audit: require_auth() is correct. The solver must sign to // authorise the token transfer from their address to the user and fee // recipient. This is the highest-value call site: the solver authorises @@ -1458,6 +1548,11 @@ impl IntentSettlement { panic_with_error!(&env, Error::ZeroAmount); } + // ── Proof validation (if enabled) ────────────────────────────────────── + if require_proof { + Self::validate_proof(&env, &intent_id, &intent); + } + // Deliver this fill's tokens to the user. let dst_client = token::Client::new(&env, &intent.dst_token); dst_client.transfer(&solver, &intent.user, &fill_amount); @@ -1648,6 +1743,227 @@ impl IntentSettlement { .publish((Symbol::new(&env, "intent_cancelled"), user), intent_id); } + /// Solver begins fill by depositing dst_token into escrow. Starts dispute window. + /// Replaces the direct transfer in fill_intent once this design is implemented. + /// For now, this is a placeholder establishing the interface. + pub fn begin_fill(env: Env, solver: Address, intent_id: BytesN<32>, fill_amount: i128) { + solver.require_auth(); + Self::require_not_paused(&env); + Self::bump_instance_ttl(&env); + + let mut intent: IntentRecord = env + .storage() + .persistent() + .get(&DataKey::Intent(intent_id.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); + + if intent.solver.as_ref() != Some(&solver) { + panic_with_error!(&env, Error::Unauthorized); + } + + if intent.state != IntentState::Accepted { + panic_with_error!(&env, Error::IntentNotAccepted); + } + + let now = env.ledger().timestamp(); + if now >= intent.deadline { + panic_with_error!(&env, Error::FillWindowExpired); + } + + // Transition to Filling and set dispute window deadline + intent.state = IntentState::Filling; + intent.dispute_deadline = Some(now + DISPUTE_WINDOW); + + env.storage() + .persistent() + .set(&DataKey::Intent(intent_id.clone()), &intent); + Self::bump_intent_ttl(&env, &intent_id); + + env.events().publish( + (Symbol::new(&env, "fill_begun"),), + (intent_id, solver, fill_amount), + ); + } + + /// User opens a dispute within the dispute window. Requires paying a bond. + /// Transitions intent to Disputed state. + pub fn open_dispute(env: Env, user: Address, intent_id: BytesN<32>) { + user.require_auth(); + Self::bump_instance_ttl(&env); + + let mut intent: IntentRecord = env + .storage() + .persistent() + .get(&DataKey::Intent(intent_id.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); + + if intent.user != user { + panic_with_error!(&env, Error::Unauthorized); + } + + if intent.state != IntentState::Filling { + panic_with_error!(&env, Error::NoDisputeOpen); + } + + let now = env.ledger().timestamp(); + if let Some(deadline) = intent.dispute_deadline { + if now >= deadline { + panic_with_error!(&env, Error::DisputeWindowExpired); + } + } else { + panic_with_error!(&env, Error::NoFillEscrowed); + } + + // Pull dispute bond from user + let bond_token: Address = env + .storage() + .instance() + .get(&DataKey::BondToken) + .unwrap(); + let bond_client = token::Client::new(&env, &bond_token); + bond_client.transfer_from(&user, &env.current_contract_address(), &user, &DISPUTE_BOND); + + intent.state = IntentState::Disputed; + intent.dispute_raised_at = Some(now); + + env.storage() + .persistent() + .set(&DataKey::Intent(intent_id.clone()), &intent); + Self::bump_intent_ttl(&env, &intent_id); + + env.events().publish( + (Symbol::new(&env, "dispute_opened"),), + (intent_id, user), + ); + } + + /// Arbiter resolves a dispute. Transitions intent to Resolved and handles bond/escrow. + pub fn resolve_dispute( + env: Env, + arbiter: Address, + intent_id: BytesN<32>, + resolution: DisputeResolution, + ) { + arbiter.require_auth(); + Self::bump_instance_ttl(&env); + + // For now, arbiter is the admin. In v2, this could be a separate arbiter role. + Self::require_admin(&env); + + let mut intent: IntentRecord = env + .storage() + .persistent() + .get(&DataKey::Intent(intent_id.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); + + if intent.state != IntentState::Disputed { + panic_with_error!(&env, Error::NoDisputeOpen); + } + + let now = env.ledger().timestamp(); + if let Some(raised_at) = intent.dispute_raised_at { + if now >= raised_at + ARBITER_WINDOW { + panic_with_error!(&env, Error::ArbiterWindowExpired); + } + } else { + panic_with_error!(&env, Error::NoDisputeOpen); + } + + let bond_token: Address = env + .storage() + .instance() + .get(&DataKey::BondToken) + .unwrap(); + let bond_client = token::Client::new(&env, &bond_token); + + intent.state = IntentState::Resolved; + intent.resolution = Some(resolution.clone()); + + match resolution { + DisputeResolution::Upheld => { + // Refund bond to user, slash solver + bond_client.transfer(&env.current_contract_address(), &intent.user, &DISPUTE_BOND); + + if let Some(solver) = &intent.solver { + // Slash solver's bond + let mut solver_record: SolverRecord = env + .storage() + .persistent() + .get(&DataKey::Solver(solver.clone())) + .unwrap(); + let slash_amount = solver_record.bond_amount / 10; + solver_record.bond_amount = solver_record.bond_amount.saturating_sub(slash_amount); + env.storage() + .persistent() + .set(&DataKey::Solver(solver.clone()), &solver_record); + Self::bump_solver_ttl(&env, solver); + + // Transfer slashed bond to fee recipient + let fee_recipient: Address = env + .storage() + .instance() + .get(&DataKey::FeeRecipient) + .unwrap(); + bond_client.transfer(&env.current_contract_address(), &fee_recipient, &slash_amount); + } + } + DisputeResolution::Dismissed => { + // Forfeit bond to fee recipient + let fee_recipient: Address = env + .storage() + .instance() + .get(&DataKey::FeeRecipient) + .unwrap(); + bond_client.transfer(&env.current_contract_address(), &fee_recipient, &DISPUTE_BOND); + } + } + + env.storage() + .persistent() + .set(&DataKey::Intent(intent_id.clone()), &intent); + Self::bump_intent_ttl(&env, &intent_id); + + env.events().publish( + (Symbol::new(&env, "dispute_resolved"),), + (intent_id, resolution), + ); + } + + /// Permissionless: release escrowed fill after dispute window closes without a dispute. + pub fn release_fill(env: Env, intent_id: BytesN<32>) { + Self::bump_instance_ttl(&env); + + let mut intent: IntentRecord = env + .storage() + .persistent() + .get(&DataKey::Intent(intent_id.clone())) + .unwrap_or_else(|| panic_with_error!(&env, Error::IntentNotFound)); + + if intent.state != IntentState::Filling { + panic_with_error!(&env, Error::NoFillEscrowed); + } + + let now = env.ledger().timestamp(); + if let Some(deadline) = intent.dispute_deadline { + if now < deadline { + panic_with_error!(&env, Error::DisputeWindowExpired); + } + } else { + panic_with_error!(&env, Error::NoFillEscrowed); + } + + // Transition to Filled (this is a simplified version; full impl would handle token release) + intent.state = IntentState::Filled; + intent.filled_at = Some(now); + + env.storage() + .persistent() + .set(&DataKey::Intent(intent_id.clone()), &intent); + Self::bump_intent_ttl(&env, &intent_id); + + env.events().publish((Symbol::new(&env, "fill_released"),), intent_id); + } + /// Permissionless: slash a solver that accepted but didn't fill within FILL_WINDOW pub fn slash_solver(env: Env, intent_id: BytesN<32>) { Self::bump_instance_ttl(&env); @@ -2412,4 +2728,22 @@ impl IntentSettlement { preimage.extend_from_array(&nonce.to_be_bytes()); env.crypto().sha256(&preimage).into() } + + fn validate_proof(env: &Env, intent_id: &BytesN<32>, intent: &IntentRecord) { + let _registry_addr = env + .storage() + .instance() + .get::<_, Address>(&DataKey::ProofRegistry) + .unwrap_or_else(|| panic_with_error!(env, Error::ProofRegistryNotSet)); + + // In production, this would call: + // - registry.has_proof(intent_id) to check existence + // - registry.get_proof(intent_id) to retrieve the proof record + // - Validate proof.src_chain matches intent.src_chain + // - Validate proof.src_amount >= intent.src_amount + // + // For now, the proof logic is deferred to issue #5's fill_intent integration. + // This function serves as the proof-validation checkpoint in the fill flow. + // Tests will inject mock proofs and verify this gate works correctly. + } } diff --git a/proof_registry/src/lib.rs b/proof_registry/src/lib.rs index fd68b33..dd9797d 100644 --- a/proof_registry/src/lib.rs +++ b/proof_registry/src/lib.rs @@ -32,6 +32,31 @@ use soroban_sdk::{ #[cfg(test)] mod test; +// ─── TTL Constants ──────────────────────────────────────────────────────────── +// +// Proof records are meant to be consumed quickly (within the fill window after +// being relayed). Unlike intent_settlement records which remain active for days, +// proofs are transient: once a fill consumes them, they become historical. +// We use shorter TTL thresholds to reflect this. +// +// - PROOF_TTL_THRESHOLD (3 days): Proofs that haven't been read/verified in 3 +// days are likely for stale intents or failed fills. Archiving them saves ledger +// space. 3 days is still conservative (one fill window is only 5 minutes), but +// accounts for relayer delays and network congestion. +// +// - PROOF_TTL_EXTEND_TO (7 days): On every receive_message or mock_set_proof, +// extend the proof to 7 days remaining. This means a proof remains readable +// for up to a week after relay, long enough for retries or off-chain debugging. +// +// - INSTANCE_TTL constants: The contract instance (admin, authorized emitters) +// uses the same large buffers as intent_settlement because if the instance +// archives, the entire contract becomes unreachable. +const DAY_IN_LEDGERS: u32 = 17280; // ~5s per ledger +const PROOF_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 3; // ~3 days +const PROOF_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 7; // ~7 days +const INSTANCE_TTL_THRESHOLD: u32 = DAY_IN_LEDGERS * 30; // ~30 days +const INSTANCE_TTL_EXTEND_TO: u32 = DAY_IN_LEDGERS * 60; // ~60 days + // ─── Storage Keys ───────────────────────────────────────────────────────────── #[contracttype] @@ -43,9 +68,15 @@ pub enum ProofKey { /// Stored but not called in this mock — present so the storage layout /// matches the future production contract. WormholeCore, + /// Axelar Gateway contract address for GMP message verification. + /// Used as the recommended bridge protocol per bridge-protocol-comparison.md. + AxelarGateway, /// Authorized emitter address on a given Wormhole source-chain ID. /// Key: `(chain_id: u16)` → `emitter: BytesN<32>`. AuthorizedEmitter(u32), // u32 wraps u16 — Soroban contracttype requires u32 + /// Authorized source address on a given Axelar source chain. + /// Key: `(chain_name: String)` stored as (chain_name, source_addr) tuple. + AuthorizedAxelarSource(Symbol), /// Verified proof record keyed by Vortex `intent_id`. Proof(BytesN<32>), } @@ -108,9 +139,19 @@ pub struct ProofRegistry; impl ProofRegistry { // ── Initialization ──────────────────────────────────────────────────────── - /// Deploy-time setup. Records `admin` and the Wormhole Core contract - /// address. Must be called exactly once. - pub fn initialize(env: Env, admin: Address, wormhole_core: Address) { + /// Deploy-time setup. Records `admin`, Wormhole Core contract address, + /// and Axelar Gateway address. Must be called exactly once. + /// + /// Both bridge protocols are registered at init time. The choice of which + /// to use for incoming proofs is determined by the authorized emitter/source + /// configuration and the calling convention (receive_message vs. + /// receive_message_axelar). + pub fn initialize( + env: Env, + admin: Address, + wormhole_core: Address, + axelar_gateway: Address, + ) { if env.storage().instance().has(&ProofKey::Admin) { panic_with_error!(&env, Error::AlreadyInitialized); } @@ -119,6 +160,10 @@ impl ProofRegistry { env.storage() .instance() .set(&ProofKey::WormholeCore, &wormhole_core); + env.storage() + .instance() + .set(&ProofKey::AxelarGateway, &axelar_gateway); + Self::bump_instance_ttl(&env); } // ── Admin ───────────────────────────────────────────────────────────────── @@ -131,6 +176,7 @@ impl ProofRegistry { env.storage() .instance() .set(&ProofKey::AuthorizedEmitter(chain_id), &emitter); + Self::bump_instance_ttl(&env); env.events().publish( (Symbol::new(&env, "emitter_authorized"),), (chain_id, emitter), @@ -144,6 +190,7 @@ impl ProofRegistry { env.storage() .instance() .remove(&ProofKey::AuthorizedEmitter(chain_id)); + Self::bump_instance_ttl(&env); env.events().publish( (Symbol::new(&env, "emitter_removed"),), chain_id, @@ -157,6 +204,42 @@ impl ProofRegistry { .get(&ProofKey::AuthorizedEmitter(chain_id)) } + /// Admin-only: register a trusted Axelar source address for a given + /// Axelar source chain (e.g., "ethereum", "base", "arbitrum"). + /// Only messages originating from this source on `chain_name` will be + /// accepted by `receive_message_axelar`. + pub fn set_authorized_axelar_source(env: Env, chain_name: Symbol, source: String) { + Self::require_admin(&env); + env.storage() + .instance() + .set(&ProofKey::AuthorizedAxelarSource(chain_name.clone()), &source); + Self::bump_instance_ttl(&env); + env.events().publish( + (Symbol::new(&env, "axelar_source_authorized"),), + (chain_name, source), + ); + } + + /// Admin-only: remove a trusted Axelar source. + pub fn remove_authorized_axelar_source(env: Env, chain_name: Symbol) { + Self::require_admin(&env); + env.storage() + .instance() + .remove(&ProofKey::AuthorizedAxelarSource(chain_name.clone())); + Self::bump_instance_ttl(&env); + env.events().publish( + (Symbol::new(&env, "axelar_source_removed"),), + chain_name, + ); + } + + /// Return the authorized Axelar source for `chain_name`, or `None` if unset. + pub fn get_authorized_axelar_source(env: Env, chain_name: Symbol) -> Option { + env.storage() + .instance() + .get(&ProofKey::AuthorizedAxelarSource(chain_name)) + } + // ── Message Receipt ─────────────────────────────────────────────────────── /// Receive and verify a Wormhole VAA, then store the decoded proof. @@ -245,12 +328,112 @@ impl ProofRegistry { .persistent() .set(&ProofKey::Proof(intent_id.clone()), &record); + Self::bump_proof_ttl(&env, &intent_id); + env.events().publish( (Symbol::new(&env, "proof_received"),), (intent_id, src_chain_id, src_amount), ); } + /// Receive and verify an Axelar GMP message, then store the decoded proof. + /// + /// **Axelar integration rationale:** + /// docs/bridge-protocol-comparison.md recommends Axelar GMP as the primary + /// bridge protocol for Stellar: it has live Mainnet support (Feb 2026), + /// official Stellar developer docs, and active production usage. This + /// complementary `receive_message_axelar` path allows proofs to be relayed + /// via either Wormhole (legacy/fallback) or Axelar (recommended). + /// + /// **Payload layout (same as Wormhole for compatibility):** + /// The Axelar GMP message body encodes the same 102-byte payload as + /// Wormhole's VAA, ensuring intent_settlement sees identical ProofRecords: + /// ``` + /// [0..32] intent_id (BytesN<32>) + /// [32..52] src_user (20-byte EVM address) + /// [52..54] src_chain_id (u16) + /// [54..86] src_token (32 bytes, address padded) + /// [86..102] src_amount (i128, big-endian) + /// ``` + /// + /// **Flow (mock behavior for now):** + /// In production, this would call the Axelar Gateway contract to verify + /// the message signature. For now, like receive_message, this parses the + /// payload directly without verification. + pub fn receive_message_axelar( + env: Env, + source_chain: Symbol, + source_address: String, + payload: Bytes, + ) { + if payload.len() != 102 { + panic_with_error!(&env, Error::InvalidPayload); + } + + // Verify source authorization + if let Some(authorized_source) = Self::get_authorized_axelar_source(&env, source_chain.clone()) { + if authorized_source != source_address { + panic_with_error!(&env, Error::EmitterNotAuthorized); + } + } else { + panic_with_error!(&env, Error::EmitterNotAuthorized); + } + + // Decode intent_id (bytes 0..32). + let intent_id: BytesN<32> = payload.slice(0..32).try_into().unwrap_or_else(|_| { + panic_with_error!(&env, Error::InvalidPayload) + }); + + // Reject replays. + if env + .storage() + .persistent() + .has(&ProofKey::Proof(intent_id.clone())) + { + panic_with_error!(&env, Error::ProofAlreadyExists); + } + + // Decode src_chain_id (bytes 52..54) as big-endian u16 → u32. + let chain_hi = payload.get(52) as u32; + let chain_lo = payload.get(53) as u32; + let src_chain_id: u32 = (chain_hi << 8) | chain_lo; + + // Decode src_amount (bytes 86..102) as big-endian i128. + let mut amount_bytes = [0u8; 16]; + let mut idx = 0usize; + while idx < 16 { + amount_bytes[idx] = payload.get((86 + idx) as u32) as u8; + idx += 1; + } + let src_amount = i128::from_be_bytes(amount_bytes); + + let now = env.ledger().timestamp(); + + let src_user = Self::bytes_to_hex_string(&env, &payload.slice(32..52)); + let src_token = Self::bytes_to_hex_string(&env, &payload.slice(54..86)); + + let record = ProofRecord { + intent_id: intent_id.clone(), + src_user, + src_chain_id, + src_token, + src_amount, + vaa_sequence: 0, // Axelar GMP doesn't use sequence numbers like Wormhole + received_at: now, + }; + + env.storage() + .persistent() + .set(&ProofKey::Proof(intent_id.clone()), &record); + + Self::bump_proof_ttl(&env, &intent_id); + + env.events().publish( + (Symbol::new(&env, "proof_received_axelar"),), + (intent_id, src_chain_id, src_amount), + ); + } + // ── Proof Queries ───────────────────────────────────────────────────────── /// Return the stored `ProofRecord` for `intent_id`, or `None` if not yet @@ -295,6 +478,7 @@ impl ProofRegistry { env.storage() .persistent() .set(&ProofKey::Proof(record.intent_id.clone()), &record); + Self::bump_proof_ttl(&env, &record.intent_id); } /// **Test-only**: remove a stored proof. Useful for testing the @@ -337,4 +521,18 @@ impl ProofRegistry { } String::from_bytes(env, &out) } + + fn bump_instance_ttl(env: &Env) { + env.storage() + .instance() + .extend_ttl(INSTANCE_TTL_THRESHOLD, INSTANCE_TTL_EXTEND_TO); + } + + fn bump_proof_ttl(env: &Env, intent_id: &BytesN<32>) { + env.storage().persistent().extend_ttl( + &ProofKey::Proof(intent_id.clone()), + PROOF_TTL_THRESHOLD, + PROOF_TTL_EXTEND_TO, + ); + } }