From e007fded7b9b3324e92ba0b743de37b79394d409 Mon Sep 17 00:00:00 2001 From: Gaina Slyvester Date: Sun, 18 Oct 2026 02:46:29 +0000 Subject: [PATCH 1/4] feat(clone): add partial cloning, clone state validation and audit trail (#926) - clone_invoice_partial copies only selected recipients and their per-recipient settings - validate cloned recipients, amounts and deadline before saving - reset approval/progress state (signatures, approved, condition_met, released_stages) on clones - record CloneRecord per clone and per-source clone history, append audit entries on source and clone, emit part_cln event --- .claude/settings.json | 20 +++ contracts/split/src/events.rs | 10 ++ contracts/split/src/lib.rs | 238 +++++++++++++++++++++++++++++++--- contracts/split/src/types.rs | 17 +++ 4 files changed, 269 insertions(+), 16 deletions(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..3c7ef49 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,20 @@ +{ + "permissions": { + "allow": [ + "Bash", + "Read", + "Edit", + "Write", + "WebFetch", + "Grep", + "Glob", + "LS", + "MultiEdit", + "NotebookRead", + "NotebookEdit", + "TodoRead", + "TodoWrite", + "WebSearch" + ] + } +} diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 8a8f631..bd5149e 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -365,6 +365,16 @@ pub fn invoice_cloned(env: &Env, source_id: u64, new_id: u64) { ); } +/// Issue #926: Emitted when an invoice is partially cloned. +/// Topics: (split, part_cln, source_id) +/// Data: (new_id, recipient_count) +pub fn invoice_partially_cloned(env: &Env, source_id: u64, new_id: u64, recipient_count: u32) { + env.events().publish( + (symbol_short!("split"), symbol_short!("part_cln"), source_id), + (new_id, recipient_count), + ); +} + /// Emitted when an invoice is paused. /// Topics: (split, paused, invoice_id) /// Data: (creator, reason, auto_resume_at) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index ab78604..0d2d115 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -88,7 +88,7 @@ const MAX_PARENT_DEPTH: u32 = 10; use types::{ AdminAction, AdminRole, AdminSet, AuditEntry, Bid, CircuitBreakerStatus, - CloneOverrides, CompactInvoice, CompactMigrateResult, CompletionProof, ComputeEstimate, + CloneOverrides, CloneRecord, CompactInvoice, CompactMigrateResult, CompletionProof, ComputeEstimate, ConfidentialPayment, ContributionResult, CreateInvoiceParams, CreatorStats, DelayedPayout, DisputeOutcome, DisputeRecord, DisputeStatus, FeeBracket, FeeSplit, FeeTier, InstalmentPlan, Invoice, InvoiceCore, InvoiceExt, InvoiceExt2, InvoiceExt3, InvoiceHot, InvoiceOptions, @@ -6366,6 +6366,55 @@ impl SplitContract { source_id: u64, overrides: CloneOverrides, ) -> u64 { + Self::clone_invoice_impl(&env, creator, source_id, overrides, None) + } + + /// Issue #926: Partially clone an invoice, copying only the recipients at + /// `recipient_indices` (and their amounts / per-recipient settings). + /// + /// `overrides.new_recipients` must be `None` — the recipient set is chosen + /// by index. `overrides.new_amounts`, when set, must have one entry per + /// selected index. Every other override applies as in [`clone_invoice`]. + /// + /// Panics with "empty clone selection" if no index is given, + /// "clone index out of range" for an index past the source recipients, + /// and "duplicate clone index" if an index repeats. + pub fn clone_invoice_partial( + env: Env, + creator: Address, + source_id: u64, + recipient_indices: Vec, + overrides: CloneOverrides, + ) -> u64 { + assert!( + overrides.new_recipients.is_none(), + "recipients override not allowed in partial clone" + ); + Self::clone_invoice_impl(&env, creator, source_id, overrides, Some(recipient_indices)) + } + + /// Issue #926: Audit record for a cloned invoice, if `clone_id` was + /// produced by `clone_invoice` / `clone_invoice_partial`. + pub fn get_clone_record(env: Env, clone_id: u64) -> Option { + env.storage().persistent().get(&clone_record_key(clone_id)) + } + + /// Issue #926: Every clone made from `source_id`, oldest first. + pub fn get_clone_history(env: Env, source_id: u64) -> Vec { + env.storage() + .persistent() + .get(&clone_history_key(source_id)) + .unwrap_or_else(|| Vec::new(&env)) + } + + fn clone_invoice_impl( + env: &Env, + creator: Address, + source_id: u64, + overrides: CloneOverrides, + recipient_indices: Option>, + ) -> u64 { + let env = env.clone(); require_not_paused(&env); creator.require_auth(); @@ -6374,13 +6423,96 @@ impl SplitContract { assert!(source.creator == creator, "not invoice creator"); assert!(source.clone_depth < 5, "max clone depth exceeded"); - let recipients = overrides - .new_recipients - .unwrap_or_else(|| source.recipients.clone()); - let amounts = overrides - .new_amounts - .unwrap_or_else(|| source.amounts.clone()); + // Track which fields were modified for the audit record. + let mut modified_fields: Vec = Vec::new(&env); + if overrides.new_recipients.is_some() { + modified_fields.push_back(symbol_short!("recipient")); + } + if overrides.new_amounts.is_some() { + modified_fields.push_back(symbol_short!("amounts")); + } + if overrides.new_deadline.is_some() { + modified_fields.push_back(symbol_short!("deadline")); + } + if overrides.new_overflow_behavior.is_some() { + modified_fields.push_back(symbol_short!("overflow")); + } + if overrides.new_metadata_hash.is_some() { + modified_fields.push_back(symbol_short!("metadata")); + } + + let source_len = source.recipients.len(); + let partial = recipient_indices.is_some(); + let indices: Vec = match recipient_indices { + Some(indices) => { + assert!(!indices.is_empty(), "empty clone selection"); + for i in 0..indices.len() { + let idx = indices.get(i).unwrap(); + assert!(idx < source_len, "clone index out of range"); + for j in (i + 1)..indices.len() { + assert!(indices.get(j).unwrap() != idx, "duplicate clone index"); + } + } + indices + } + None => Vec::new(&env), + }; + + let recipients = if partial { + select_by_indices(&env, &source.recipients, &indices) + } else { + overrides + .new_recipients + .unwrap_or_else(|| source.recipients.clone()) + }; + let amounts = match overrides.new_amounts { + Some(amounts) => amounts, + None if partial => select_by_indices(&env, &source.amounts, &indices), + None => source.amounts.clone(), + }; let deadline = overrides.new_deadline.unwrap_or(source.deadline); + + validate_cloned_state(&env, &recipients, &amounts, deadline); + + // Per-recipient parallel settings follow the selected recipients. When + // the recipient set changes shape they can no longer line up, so they + // are cleared rather than copied misaligned. + let new_len = recipients.len(); + let swap_tokens = if partial && source.swap_tokens.len() == source_len { + select_by_indices(&env, &source.swap_tokens, &indices) + } else if source.swap_tokens.len() == new_len { + source.swap_tokens.clone() + } else { + Vec::new(&env) + }; + let split_rules = if partial && source.split_rules.len() == source_len { + select_by_indices(&env, &source.split_rules, &indices) + } else if source.split_rules.len() == new_len { + source.split_rules.clone() + } else { + Vec::new(&env) + }; + let priorities = if partial && source.priorities.len() == source_len { + select_by_indices(&env, &source.priorities, &indices) + } else if source.priorities.len() == new_len { + source.priorities.clone() + } else { + Vec::new(&env) + }; + let recipient_max_payouts = + if partial && source.recipient_max_payouts.len() == source_len { + select_by_indices(&env, &source.recipient_max_payouts, &indices) + } else if source.recipient_max_payouts.len() == new_len { + source.recipient_max_payouts.clone() + } else { + Vec::new(&env) + }; + // Ratios must sum to 10 000, so a subset can never stay valid. + let ratios = if !partial && source.ratios.len() == new_len { + source.ratios.clone() + } else { + Vec::new(&env) + }; let overflow_behavior = overrides .new_overflow_behavior .map(|sym| { @@ -6448,21 +6580,23 @@ impl SplitContract { tranches: source.tranches.clone(), co_signers: source.co_signers.clone(), required_signatures: source.required_signatures, - signatures: source.signatures.clone(), + // Approval / progress state belongs to the source invoice and + // must not carry over into a fresh clone. + signatures: Vec::new(&env), approver: source.approver.clone(), - approved: source.approved, + approved: false, condition_oracle: source.condition_oracle.clone(), - condition_met: source.condition_met, + condition_met: false, penalty_bps: source.penalty_bps, penalty_deadline: source.penalty_deadline, penalty_tiers: source.penalty_tiers.clone(), allowed_callers: source.allowed_callers.clone(), min_funding_bps: source.min_funding_bps, release_stages: source.release_stages.clone(), - released_stages: source.released_stages, + released_stages: 0, allowed_payers: source.allowed_payers.clone(), price_oracle: source.price_oracle.clone(), - swap_tokens: source.swap_tokens.clone(), + swap_tokens, tax_bps: source.tax_bps, tax_authority: source.tax_authority.clone(), insurance_premium_bps: source.insurance_premium_bps, @@ -6472,7 +6606,7 @@ impl SplitContract { accepted_tokens: source.accepted_tokens.clone(), forward_to: source.forward_to.clone(), forward_invoice_id: source.forward_invoice_id, - split_rules: source.split_rules.clone(), + split_rules, auto_resolve_rules: source.auto_resolve_rules.clone(), creator_cosigner: source.creator_cosigner.clone(), velocity_limit: source.velocity_limit, @@ -6496,7 +6630,7 @@ impl SplitContract { disputed: false, admin_frozen: false, scheduled_release_at: source.scheduled_release_at, - priorities: source.priorities.clone(), + priorities, target_usd_cents: source.target_usd_cents, refunded_addresses: Vec::new(&env), oracle: source.oracle.clone(), @@ -6507,7 +6641,7 @@ impl SplitContract { held_until: None, milestones: source.milestones.clone(), milestones_released: 0, - recipient_max_payouts: source.recipient_max_payouts.clone(), + recipient_max_payouts, twafr_numerator: 0, twafr_last_ledger: 0, release_condition_hash: source.release_condition_hash.clone(), @@ -6521,7 +6655,7 @@ impl SplitContract { early_bird_fee_credit: 0, creator_fee_bps: source.creator_fee_bps, ratio_denominator: source.ratio_denominator, - ratios: source.ratios.clone(), + ratios, metadata_hash: source.metadata_hash.clone(), }; @@ -6531,6 +6665,33 @@ impl SplitContract { } events::invoice_cloned(&env, source_id, id); + // Issue #926: audit trail for the clone on both sides of the lineage. + append_audit_entry(&env, source_id, symbol_short!("cloned_to"), &creator); + append_audit_entry(&env, id, symbol_short!("cloned_fr"), &creator); + let record = CloneRecord { + source_id, + clone_id: id, + creator: creator.clone(), + partial, + recipient_indices: indices.clone(), + modified_fields, + cloned_at: env.ledger().timestamp(), + ledger: env.ledger().sequence(), + }; + env.storage().persistent().set(&clone_record_key(id), &record); + let mut history: Vec = env + .storage() + .persistent() + .get(&clone_history_key(source_id)) + .unwrap_or_else(|| Vec::new(&env)); + history.push_back(record); + env.storage() + .persistent() + .set(&clone_history_key(source_id), &history); + if partial { + events::invoice_partially_cloned(&env, source_id, id, indices.len()); + } + // Index each recipient -> invoice ID. for recipient in recipients.iter() { let key = recipient_invoice_ids_key(&recipient); @@ -14963,6 +15124,51 @@ fn transfer_audit_key(invoice_id: u64) -> (Symbol, u64) { (symbol_short!("audit_log"), invoice_id) } +fn clone_record_key(id: u64) -> (Symbol, u64) { + (symbol_short!("clone_rec"), id) +} + +fn clone_history_key(source_id: u64) -> (Symbol, u64) { + (symbol_short!("clone_his"), source_id) +} + +/// Issue #926: copy the entries of `src` at `indices`, in index order. +fn select_by_indices(env: &Env, src: &Vec, indices: &Vec) -> Vec +where + T: IntoVal + TryFromVal, +{ + let mut out: Vec = Vec::new(env); + for idx in indices.iter() { + if let Some(item) = src.get(idx) { + out.push_back(item); + } + } + out +} + +/// Issue #926: reject a clone whose resulting state could not have been +/// created through `create_invoice`. +fn validate_cloned_state(env: &Env, recipients: &Vec
, amounts: &Vec, deadline: u64) { + assert!(!recipients.is_empty(), "clone has no recipients"); + assert!( + recipients.len() == amounts.len(), + "clone recipients and amounts length mismatch" + ); + for amount in amounts.iter() { + assert!(amount > 0, "clone amount must be positive"); + } + for i in 0..recipients.len() { + let r = recipients.get(i).unwrap(); + for j in (i + 1)..recipients.len() { + assert!(recipients.get(j).unwrap() != r, "duplicate clone recipient"); + } + } + assert!( + deadline > env.ledger().timestamp(), + "clone deadline must be in the future" + ); +} + fn archived_key(id: u64) -> (Symbol, u64) { (symbol_short!("archv_inv"), id) } diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 564bc0b..84be4f0 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -1883,6 +1883,23 @@ pub struct PaymentRecord { } +/// Issue #926: Audit record written every time an invoice is cloned. +#[contracttype] +#[derive(Clone, Debug)] +pub struct CloneRecord { + pub source_id: u64, + pub clone_id: u64, + pub creator: Address, + /// True when produced by `clone_invoice_partial`. + pub partial: bool, + /// Source recipient indices copied (empty for a full clone). + pub recipient_indices: Vec, + /// Fields changed through `CloneOverrides`. + pub modified_fields: Vec, + pub cloned_at: u64, + pub ledger: u32, +} + #[cfg(test)] mod tests { use super::InvoiceStatus; From 887c9a8069b2f8f73a4063fffb578fbf8c619409 Mon Sep 17 00:00:00 2001 From: Gaina Slyvester Date: Sun, 18 Oct 2026 02:48:24 +0000 Subject: [PATCH 2/4] feat(archival): add scheduled archival, recovery, cleanup and status tracking (#927) - ArchivalPolicy (admin-configured) drives a rate-limited, cursor-based run_archival_sweep over completed invoices past archive_after_ledgers - restore_archived_invoice moves archived data back to persistent storage, with a restore hold that exempts it from re-archival - purge_archived_invoices / auto_purge drop payment shards of invoices archived longer than retention_ledgers - ArchivalRecord per invoice and ArchivalStats counters track status across lazy, explicit, batch and scheduled archival - audit entries and events for archive, restore, purge, sweep and policy updates --- contracts/split/src/events.rs | 40 ++++ contracts/split/src/lib.rs | 384 +++++++++++++++++++++++++++++++++- contracts/split/src/types.rs | 60 ++++++ 3 files changed, 482 insertions(+), 2 deletions(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index bd5149e..49edd2e 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -375,6 +375,46 @@ pub fn invoice_partially_cloned(env: &Env, source_id: u64, new_id: u64, recipien ); } +/// Issue #927: Emitted when an archived invoice is restored. +/// Topics: (split, restored, invoice_id) +/// Data: (caller, ledger) +pub fn invoice_restored(env: &Env, invoice_id: u64, caller: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("restored"), invoice_id), + (caller.clone(), env.ledger().sequence()), + ); +} + +/// Issue #927: Emitted when an archived invoice is purged by the cleanup policy. +/// Topics: (split, arch_purg, invoice_id) +/// Data: ledger +pub fn invoice_archive_purged(env: &Env, invoice_id: u64) { + env.events().publish( + (symbol_short!("split"), symbol_short!("arch_purg"), invoice_id), + env.ledger().sequence(), + ); +} + +/// Issue #927: Emitted at the end of a scheduled archival sweep. +/// Topics: (split, arch_swp) +/// Data: (archived_count, cursor) +pub fn archival_sweep_completed(env: &Env, archived_count: u32, cursor: u64) { + env.events().publish( + (symbol_short!("split"), symbol_short!("arch_swp")), + (archived_count, cursor), + ); +} + +/// Issue #927: Emitted when the archival policy changes. +/// Topics: (split, arch_pol, admin) +/// Data: (enabled, retention_ledgers) +pub fn archival_policy_updated(env: &Env, admin: &Address, enabled: bool, retention_ledgers: u32) { + env.events().publish( + (symbol_short!("split"), symbol_short!("arch_pol"), admin.clone()), + (enabled, retention_ledgers), + ); +} + /// Emitted when an invoice is paused. /// Topics: (split, paused, invoice_id) /// Data: (creator, reason, auto_resume_at) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index 0d2d115..eafdfdb 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -87,7 +87,8 @@ use soroban_sdk::{ const MAX_PARENT_DEPTH: u32 = 10; use types::{ - AdminAction, AdminRole, AdminSet, AuditEntry, Bid, CircuitBreakerStatus, + AdminAction, AdminRole, AdminSet, ArchivalPolicy, ArchivalRecord, ArchivalStats, + ArchivalStatus, AuditEntry, Bid, CircuitBreakerStatus, CloneOverrides, CloneRecord, CompactInvoice, CompactMigrateResult, CompletionProof, ComputeEstimate, ConfidentialPayment, ContributionResult, CreateInvoiceParams, CreatorStats, DelayedPayout, DisputeOutcome, DisputeRecord, DisputeStatus, FeeBracket, FeeSplit, FeeTier, InstalmentPlan, @@ -1801,7 +1802,13 @@ fn maybe_archive_invoice(env: &Env, id: u64) { return; } + // Issue #927: a recently restored invoice is held out of lazy archival. + if in_restore_hold(env, id) { + return; + } + archive_invoice_storage(env, id, &core); + record_archived(env, id, None); events::invoice_archived(env, id); } @@ -13372,6 +13379,7 @@ impl SplitContract { .persistent() .remove(&invoice_ext2_key(invoice_id)); + record_archived(&env, invoice_id, None); events::invoice_archived(&env, invoice_id); } @@ -13487,6 +13495,7 @@ impl SplitContract { env.storage().persistent().remove(&invoice_ext2_key(id)); archived.push_back(id); + record_archived(&env, id, None); events::invoice_archived(&env, id); } } @@ -15124,6 +15133,179 @@ fn transfer_audit_key(invoice_id: u64) -> (Symbol, u64) { (symbol_short!("audit_log"), invoice_id) } +fn archival_record_key(id: u64) -> (Symbol, u64) { + (symbol_short!("arch_rec"), id) +} + +fn archival_policy_key() -> Symbol { + symbol_short!("arch_pol") +} + +fn archival_stats_key() -> Symbol { + symbol_short!("arch_stat") +} + +/// Issue #927: default archival policy — sweeps disabled until an admin opts in. +fn default_archival_policy() -> ArchivalPolicy { + ArchivalPolicy { + enabled: false, + sweep_interval_ledgers: 17_280, + max_batch: 20, + retention_ledgers: 0, + auto_purge: false, + restore_hold_ledgers: ARCHIVE_AFTER_LEDGERS as u32, + } +} + +fn load_archival_policy(env: &Env) -> ArchivalPolicy { + env.storage() + .instance() + .get(&archival_policy_key()) + .unwrap_or_else(default_archival_policy) +} + +fn load_archival_stats(env: &Env) -> ArchivalStats { + env.storage() + .instance() + .get(&archival_stats_key()) + .unwrap_or(ArchivalStats { + total_archived: 0, + total_restored: 0, + total_purged: 0, + last_sweep_ledger: 0, + sweep_cursor: 0, + }) +} + +fn save_archival_stats(env: &Env, stats: &ArchivalStats) { + env.storage().instance().set(&archival_stats_key(), stats); +} + +fn load_archival_record(env: &Env, id: u64) -> Option { + env.storage().persistent().get(&archival_record_key(id)) +} + +fn save_archival_record(env: &Env, id: u64, record: &ArchivalRecord) { + env.storage().persistent().set(&archival_record_key(id), record); + env.storage().persistent().extend_ttl( + &archival_record_key(id), + INVOICE_HOT_TTL_LEDGERS / 2, + INVOICE_HOT_TTL_LEDGERS, + ); +} + +/// Issue #927: true while a restored invoice is still inside its hold window. +fn in_restore_hold(env: &Env, id: u64) -> bool { + match load_archival_record(env, id) { + Some(record) if record.status == ArchivalStatus::Restored => { + let restored_at = record.restored_at_ledger.unwrap_or(0); + let hold = load_archival_policy(env).restore_hold_ledgers; + env.ledger().sequence().saturating_sub(restored_at) < hold + } + _ => false, + } +} + +/// Issue #927: invoice data lives in instance storage once archived. +fn is_invoice_archived(env: &Env, id: u64) -> bool { + if env.storage().instance().has(&archive_marker_key(id)) + || env.storage().persistent().has(&archive_marker_key(id)) + { + return true; + } + matches!( + load_archival_record(env, id), + Some(ArchivalRecord { status: ArchivalStatus::Archived, .. }) + | Some(ArchivalRecord { status: ArchivalStatus::Purged, .. }) + ) +} + +/// Issue #927: track archival status, update stats and the audit trail. +fn record_archived(env: &Env, id: u64, actor: Option
) { + let previous = load_archival_record(env, id); + let archive_count = previous.as_ref().map(|r| r.archive_count).unwrap_or(0) + 1; + let record = ArchivalRecord { + status: ArchivalStatus::Archived, + archived_at_ledger: env.ledger().sequence(), + archived_by: actor.clone(), + restored_at_ledger: None, + restored_by: None, + purged_at_ledger: None, + archive_count, + }; + save_archival_record(env, id, &record); + + let mut stats = load_archival_stats(env); + stats.total_archived = stats.total_archived.saturating_add(1); + save_archival_stats(env, &stats); + + let audit_actor = actor.unwrap_or_else(|| env.current_contract_address()); + append_audit_entry(env, id, symbol_short!("archived"), &audit_actor); +} + +/// Issue #927: an invoice qualifies for scheduled archival when it is +/// completed, older than `archive_after_ledgers`, not already archived and +/// not inside a post-restore hold. +fn is_archival_eligible(env: &Env, id: u64) -> bool { + if is_invoice_archived(env, id) || in_restore_hold(env, id) { + return false; + } + let core: InvoiceCore = match env.storage().persistent().get(&invoice_key(id)) { + Some(core) => core, + None => return false, + }; + if core.status != InvoiceStatus::Released && core.status != InvoiceStatus::Refunded { + return false; + } + let created_ledger: u32 = env + .storage() + .persistent() + .get(&created_ledger_key(id)) + .or_else(|| env.storage().instance().get(&created_ledger_key(id))) + .unwrap_or_else(|| env.ledger().sequence()); + let archive_after: u64 = env + .storage() + .instance() + .get(&archive_after_ledgers_key()) + .unwrap_or(ARCHIVE_AFTER_LEDGERS); + (env.ledger().sequence() as u64).saturating_sub(created_ledger as u64) >= archive_after +} + +/// Issue #927: true when an archived invoice has outlived the retention period. +fn is_purge_eligible(env: &Env, id: u64, policy: &ArchivalPolicy) -> bool { + if policy.retention_ledgers == 0 { + return false; + } + match load_archival_record(env, id) { + Some(record) if record.status == ArchivalStatus::Archived => { + env.ledger().sequence().saturating_sub(record.archived_at_ledger) + >= policy.retention_ledgers + } + _ => false, + } +} + +/// Issue #927: cleanup — drop the bulky payment shards of an archived invoice +/// while keeping the invoice record, compact overlay and audit log readable. +fn purge_archived_invoice(env: &Env, id: u64, actor: &Address) { + for shard_id in 0..SHARD_COUNT { + let shard_key = pay_shard_key(id, shard_id); + env.storage().instance().remove(&shard_key); + env.storage().persistent().remove(&shard_key); + } + let mut record = load_archival_record(env, id).expect("archival record not found"); + record.status = ArchivalStatus::Purged; + record.purged_at_ledger = Some(env.ledger().sequence()); + save_archival_record(env, id, &record); + + let mut stats = load_archival_stats(env); + stats.total_purged = stats.total_purged.saturating_add(1); + save_archival_stats(env, &stats); + + append_audit_entry(env, id, symbol_short!("purged"), actor); + events::invoice_archive_purged(env, id); +} + fn clone_record_key(id: u64) -> (Symbol, u64) { (symbol_short!("clone_rec"), id) } @@ -16062,4 +16244,202 @@ fn archive_invoice(env: &Env, invoice_id: u64, invoice: &Invoice) { .persistent() .set(&archived_key(invoice_id), &core); remove_invoice(env, invoice_id); -} \ No newline at end of file +} + +// --------------------------------------------------------------------------- +// Issue #927: Invoice archival system — schedule, recovery, cleanup, status +// --------------------------------------------------------------------------- + +#[contractimpl] +impl SplitContract { + /// Configure the scheduled archival policy. Operator role or higher. + pub fn set_archival_policy(env: Env, admin: Address, policy: ArchivalPolicy) { + require_admin_role(&env, &admin, AdminRole::Operator); + assert!(policy.max_batch > 0 && policy.max_batch <= 50, "invalid archival batch size"); + assert!(policy.sweep_interval_ledgers > 0, "sweep interval must be positive"); + env.storage().instance().set(&archival_policy_key(), &policy); + events::archival_policy_updated(&env, &admin, policy.enabled, policy.retention_ledgers); + } + + /// Return the active archival policy (defaults when never configured). + pub fn get_archival_policy(env: Env) -> ArchivalPolicy { + load_archival_policy(&env) + } + + /// Return aggregate archival counters and the sweep cursor. + pub fn get_archival_stats(env: Env) -> ArchivalStats { + load_archival_stats(&env) + } + + /// Return the archival status record for an invoice. Invoices that were + /// never archived report `Active`. + pub fn get_archival_status(env: Env, invoice_id: u64) -> ArchivalRecord { + load_archival_record(&env, invoice_id).unwrap_or(ArchivalRecord { + status: ArchivalStatus::Active, + archived_at_ledger: 0, + archived_by: None, + restored_at_ledger: None, + restored_by: None, + purged_at_ledger: None, + archive_count: 0, + }) + } + + /// Scheduled archival sweep. Permissionless so keepers can drive it, but + /// rate-limited to once per `sweep_interval_ledgers`. + /// + /// Scans up to `max_batch` invoice IDs from the stored cursor, archives + /// every eligible one, and — when `auto_purge` is set — purges archived + /// invoices past the retention period. Returns the IDs archived this run. + pub fn run_archival_sweep(env: Env) -> Vec { + require_not_paused(&env); + let policy = load_archival_policy(&env); + assert!(policy.enabled, "archival sweep disabled"); + + let mut stats = load_archival_stats(&env); + let now = env.ledger().sequence(); + if stats.last_sweep_ledger != 0 { + assert!( + now.saturating_sub(stats.last_sweep_ledger) >= policy.sweep_interval_ledgers, + "archival sweep not due" + ); + } + + let last_id: u64 = env + .storage() + .persistent() + .get(&counter_key()) + .unwrap_or(0u64); + let mut archived: Vec = Vec::new(&env); + let mut cursor = stats.sweep_cursor; + let contract = env.current_contract_address(); + + for _ in 0..policy.max_batch { + if cursor >= last_id { + cursor = 0; + break; + } + cursor += 1; + let id = cursor; + if is_archival_eligible(&env, id) { + let core: InvoiceCore = env.storage().persistent().get(&invoice_key(id)).unwrap(); + archive_invoice_storage(&env, id, &core); + record_archived(&env, id, Some(contract.clone())); + events::invoice_archived(&env, id); + archived.push_back(id); + } else if policy.auto_purge && is_purge_eligible(&env, id, &policy) { + purge_archived_invoice(&env, id, &contract); + } + } + + // `record_archived` / `purge_archived_invoice` update the counters, so + // reload before writing the cursor back. + stats = load_archival_stats(&env); + stats.sweep_cursor = cursor; + stats.last_sweep_ledger = now; + save_archival_stats(&env, &stats); + + events::archival_sweep_completed(&env, archived.len(), cursor); + archived + } + + /// Recovery: move an archived invoice back into persistent storage so it + /// becomes fully mutable again. Callable by the invoice creator or an + /// Operator admin. The invoice is held out of re-archival for + /// `restore_hold_ledgers`. + pub fn restore_archived_invoice(env: Env, caller: Address, invoice_id: u64) { + require_not_paused(&env); + assert!(is_invoice_archived(&env, invoice_id), "invoice not archived"); + if let Some(record) = load_archival_record(&env, invoice_id) { + assert!( + record.status != ArchivalStatus::Purged, + "purged invoice cannot be restored" + ); + } + + let core: InvoiceCore = env + .storage() + .instance() + .get(&invoice_key(invoice_id)) + .or_else(|| env.storage().persistent().get(&invoice_key(invoice_id))) + .expect("invoice not found"); + if caller == core.creator { + caller.require_auth(); + } else { + require_admin_role(&env, &caller, AdminRole::Operator); + } + + let instance = env.storage().instance(); + let persistent = env.storage().persistent(); + persistent.set(&invoice_key(invoice_id), &core); + instance.remove(&invoice_key(invoice_id)); + if let Some(ext) = instance.get::<_, InvoiceExt>(&invoice_ext_key(invoice_id)) { + persistent.set(&invoice_ext_key(invoice_id), &ext); + instance.remove(&invoice_ext_key(invoice_id)); + } + if let Some(ext2) = instance.get::<_, InvoiceExt2>(&invoice_ext2_key(invoice_id)) { + persistent.set(&invoice_ext2_key(invoice_id), &ext2); + instance.remove(&invoice_ext2_key(invoice_id)); + } + if let Some(compact) = instance.get::<_, CompactInvoice>(&invoice_compact_key(invoice_id)) { + persistent.set(&invoice_compact_key(invoice_id), &compact); + instance.remove(&invoice_compact_key(invoice_id)); + } + for shard_id in 0..SHARD_COUNT { + let shard_key = pay_shard_key(invoice_id, shard_id); + if let Some(payments) = instance.get::<(Symbol, u64, u64), Vec>(&shard_key) { + persistent.set(&shard_key, &payments); + instance.remove(&shard_key); + } + } + if let Some(log) = instance.get::<_, Vec>(&audit_log_key(invoice_id)) { + persistent.set(&audit_log_key(invoice_id), &log); + instance.remove(&audit_log_key(invoice_id)); + } + if let Some(created) = instance.get::<_, u32>(&created_ledger_key(invoice_id)) { + persistent.set(&created_ledger_key(invoice_id), &created); + instance.remove(&created_ledger_key(invoice_id)); + } + instance.remove(&archive_marker_key(invoice_id)); + persistent.remove(&archive_marker_key(invoice_id)); + bump_invoice_entry_ttl(&env, invoice_id); + + let mut record = load_archival_record(&env, invoice_id).unwrap_or(ArchivalRecord { + status: ArchivalStatus::Archived, + archived_at_ledger: 0, + archived_by: None, + restored_at_ledger: None, + restored_by: None, + purged_at_ledger: None, + archive_count: 1, + }); + record.status = ArchivalStatus::Restored; + record.restored_at_ledger = Some(env.ledger().sequence()); + record.restored_by = Some(caller.clone()); + save_archival_record(&env, invoice_id, &record); + + let mut stats = load_archival_stats(&env); + stats.total_restored = stats.total_restored.saturating_add(1); + save_archival_stats(&env, &stats); + + append_audit_entry(&env, invoice_id, symbol_short!("restored"), &caller); + events::invoice_restored(&env, invoice_id, &caller); + } + + /// Cleanup policy: purge archived invoices that have outlived + /// `retention_ledgers`. Operator role or higher. Invoices not yet past + /// retention are skipped. Returns the IDs purged. + pub fn purge_archived_invoices(env: Env, admin: Address, invoice_ids: Vec) -> Vec { + require_admin_role(&env, &admin, AdminRole::Operator); + assert!(invoice_ids.len() <= 20, "batch limit exceeded"); + let policy = load_archival_policy(&env); + let mut purged: Vec = Vec::new(&env); + for id in invoice_ids.iter() { + if is_purge_eligible(&env, id, &policy) { + purge_archived_invoice(&env, id, &admin); + purged.push_back(id); + } + } + purged + } +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 84be4f0..5d4c846 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -1900,6 +1900,66 @@ pub struct CloneRecord { pub ledger: u32, } +/// Issue #927: Lifecycle of an invoice with respect to archival. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ArchivalStatus { + /// Never archived — lives in persistent storage. + Active, + /// Moved to cold (instance) storage. + Archived, + /// Recovered from the archive back into persistent storage. + Restored, + /// Archived and cleaned up past the retention period (payments dropped). + Purged, +} + +/// Issue #927: Per-invoice archival status tracking. +#[contracttype] +#[derive(Clone, Debug)] +pub struct ArchivalRecord { + pub status: ArchivalStatus, + pub archived_at_ledger: u32, + /// `None` for lazy / explicit archival with no authenticated actor. + pub archived_by: Option
, + pub restored_at_ledger: Option, + pub restored_by: Option
, + pub purged_at_ledger: Option, + /// Number of times this invoice has been archived. + pub archive_count: u32, +} + +/// Issue #927: Admin-configured archival schedule and cleanup policy. +#[contracttype] +#[derive(Clone, Debug)] +pub struct ArchivalPolicy { + /// Whether `run_archival_sweep` may run. + pub enabled: bool, + /// Minimum ledgers between two sweeps. + pub sweep_interval_ledgers: u32, + /// Invoice IDs scanned per sweep (1–50). + pub max_batch: u32, + /// Ledgers an archived invoice is retained before it may be purged; + /// 0 disables purging. + pub retention_ledgers: u32, + /// Purge eligible invoices during sweeps. + pub auto_purge: bool, + /// Ledgers a restored invoice is exempt from re-archival. + pub restore_hold_ledgers: u32, +} + +/// Issue #927: Aggregate archival counters. +#[contracttype] +#[derive(Clone, Debug)] +pub struct ArchivalStats { + pub total_archived: u64, + pub total_restored: u64, + pub total_purged: u64, + pub last_sweep_ledger: u32, + /// Last invoice ID scanned by the sweep; wraps to 0 at the end. + pub sweep_cursor: u64, +} + #[cfg(test)] mod tests { use super::InvoiceStatus; From a23e66d364a21f087d2043dc53f4f199091c67f8 Mon Sep 17 00:00:00 2001 From: Gaina Slyvester Date: Sun, 18 Oct 2026 02:49:44 +0000 Subject: [PATCH 3/4] feat(auth): add recipient challenge-response authentication before payout (#928) - RecipientAuth record and RecipientAuthStatus state machine (Unverified, ChallengeIssued, Verified, Failed, Expired, Revoked) - creators opt invoices in with set_recipient_auth_required and issue per-recipient challenges with a bounded TTL - recipients prove address control by authorizing respond_recipient_challenge with sha256(challenge || recipient_xdr) - timeouts mark challenges Expired; repeated wrong responses mark Failed - _release refuses to pay out and milestone payouts stay escrowed until every recipient is verified - per-invoice verification audit log, audit entries and rcp_auth events --- contracts/split/src/events.rs | 10 ++ contracts/split/src/lib.rs | 302 +++++++++++++++++++++++++++++++++- contracts/split/src/types.rs | 44 +++++ 3 files changed, 355 insertions(+), 1 deletion(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index 49edd2e..bd22bb7 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -415,6 +415,16 @@ pub fn archival_policy_updated(env: &Env, admin: &Address, enabled: bool, retent ); } +/// Issue #928: Emitted on every recipient authentication state change. +/// Topics: (split, rcp_auth, invoice_id) +/// Data: (recipient, action, ledger) +pub fn recipient_auth_changed(env: &Env, invoice_id: u64, recipient: &Address, action: soroban_sdk::Symbol) { + env.events().publish( + (symbol_short!("split"), symbol_short!("rcp_auth"), invoice_id), + (recipient.clone(), action, env.ledger().sequence()), + ); +} + /// Emitted when an invoice is paused. /// Topics: (split, paused, invoice_id) /// Data: (creator, reason, auto_resume_at) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index eafdfdb..e2615bb 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -94,7 +94,7 @@ use types::{ DisputeOutcome, DisputeRecord, DisputeStatus, FeeBracket, FeeSplit, FeeTier, InstalmentPlan, Invoice, InvoiceCore, InvoiceExt, InvoiceExt2, InvoiceExt3, InvoiceHot, InvoiceOptions, InvoiceOptions2, InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTemplate, - InvoiceTemplateRecord, LegacyInvoice, OverflowBehavior, OverfundingPolicy, Payment, + InvoiceTemplateRecord, LegacyInvoice, RecipientAuth, RecipientAuthLogEntry, RecipientAuthStatus, OverflowBehavior, OverfundingPolicy, Payment, PaymentCertificate, PaymentCommitment, PaymentProof, PaymentRecord, PendingAdminAction, ProtocolFeeConfig, QueuedAction, RebateTier, Recipient, RepScore, ResolveAction, ResolveRule, Role, SimulateReleaseResult, SplitRule, SubscriptionParams, TimelockAction, @@ -8614,6 +8614,11 @@ impl SplitContract { if invoice.status == InvoiceStatus::Disputed { panic!("{}", ContractError::InvoiceDisputed as u32); } + // Issue #928: no funds move to unverified recipients. + assert!( + recipients_verified(env, invoice_id, invoice), + "recipient not verified" + ); if invoice.tranches.is_empty() { Self::_release_full(env, invoice_id, invoice, actor); } else { @@ -8630,6 +8635,11 @@ impl SplitContract { if invoice.milestones.is_empty() { return; } + // Issue #928: hold milestone payouts in escrow until every recipient + // has completed authentication. + if !recipients_verified(env, invoice_id, invoice) { + return; + } let total: i128 = invoice.amounts.iter().sum(); if total <= 0 { return; @@ -15306,6 +15316,88 @@ fn purge_archived_invoice(env: &Env, id: u64, actor: &Address) { events::invoice_archive_purged(env, id); } +/// Issue #928: failed responses allowed before a challenge is locked out. +const MAX_RECIPIENT_AUTH_ATTEMPTS: u32 = 3; +/// Issue #928: bounds on a challenge's lifetime, in seconds. +const MIN_RECIPIENT_AUTH_TTL_SECS: u64 = 60; +const MAX_RECIPIENT_AUTH_TTL_SECS: u64 = 7 * 24 * 60 * 60; + +fn recipient_auth_key(invoice_id: u64, recipient: &Address) -> (Symbol, u64, Address) { + (symbol_short!("rcp_auth"), invoice_id, recipient.clone()) +} + +fn recipient_auth_required_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("rcp_areq"), invoice_id) +} + +fn recipient_auth_log_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("rcp_alog"), invoice_id) +} + +fn recipient_auth_nonce_key() -> Symbol { + symbol_short!("rcp_anon") +} + +fn is_recipient_auth_required(env: &Env, invoice_id: u64) -> bool { + env.storage() + .persistent() + .get(&recipient_auth_required_key(invoice_id)) + .unwrap_or(false) +} + +fn load_recipient_auth(env: &Env, invoice_id: u64, recipient: &Address) -> Option { + env.storage() + .persistent() + .get(&recipient_auth_key(invoice_id, recipient)) +} + +fn save_recipient_auth(env: &Env, auth: &RecipientAuth) { + env.storage() + .persistent() + .set(&recipient_auth_key(auth.invoice_id, &auth.recipient), auth); +} + +/// Issue #928: true when the invoice does not require authentication, or +/// every recipient is currently `Verified`. +fn recipients_verified(env: &Env, invoice_id: u64, invoice: &Invoice) -> bool { + if !is_recipient_auth_required(env, invoice_id) { + return true; + } + invoice.recipients.iter().all(|r| { + matches!( + load_recipient_auth(env, invoice_id, &r), + Some(RecipientAuth { status: RecipientAuthStatus::Verified, .. }) + ) + }) +} + +/// Issue #928: append to the per-invoice verification audit log. +fn log_recipient_auth(env: &Env, invoice_id: u64, recipient: &Address, action: Symbol, actor: &Address) { + let mut log: Vec = env + .storage() + .persistent() + .get(&recipient_auth_log_key(invoice_id)) + .unwrap_or_else(|| Vec::new(env)); + log.push_back(RecipientAuthLogEntry { + recipient: recipient.clone(), + action: action.clone(), + actor: actor.clone(), + timestamp: env.ledger().timestamp(), + ledger: env.ledger().sequence(), + }); + env.storage() + .persistent() + .set(&recipient_auth_log_key(invoice_id), &log); + events::recipient_auth_changed(env, invoice_id, recipient, action); +} + +/// Issue #928: expected response to `challenge` — `sha256(challenge || recipient_xdr)`. +fn recipient_challenge_response(env: &Env, challenge: &BytesN<32>, recipient: &Address) -> BytesN<32> { + let mut preimage = Bytes::from_array(env, &challenge.to_array()); + preimage.append(&recipient.clone().to_xdr(env)); + env.crypto().sha256(&preimage).into() +} + fn clone_record_key(id: u64) -> (Symbol, u64) { (symbol_short!("clone_rec"), id) } @@ -16443,3 +16535,211 @@ impl SplitContract { purged } } + +// --------------------------------------------------------------------------- +// Issue #928: Recipient authentication (challenge-response before payout) +// --------------------------------------------------------------------------- + +#[contractimpl] +impl SplitContract { + /// Require (or stop requiring) every recipient of `invoice_id` to pass + /// challenge-response verification before any funds are released to them. + /// Creator or co-creator only; the invoice must still be Pending. + pub fn set_recipient_auth_required(env: Env, caller: Address, invoice_id: u64, required: bool) { + require_not_paused(&env); + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + require_creator_or_cocreator(&invoice, &caller); + assert!(invoice.status == InvoiceStatus::Pending, "invoice not pending"); + env.storage() + .persistent() + .set(&recipient_auth_required_key(invoice_id), &required); + let action = if required { + symbol_short!("auth_on") + } else { + symbol_short!("auth_off") + }; + append_audit_entry(&env, invoice_id, action, &caller); + } + + /// Whether `invoice_id` requires recipient authentication before release. + pub fn get_recipient_auth_required(env: Env, invoice_id: u64) -> bool { + is_recipient_auth_required(&env, invoice_id) + } + + /// Issue a fresh challenge to `recipient`, valid for `ttl_secs`. + /// + /// The recipient proves control of its address by calling + /// [`respond_recipient_challenge`] with its own authorization and + /// `sha256(challenge || recipient_xdr)`. Re-issuing replaces any pending + /// challenge and resets the attempt counter. + pub fn issue_recipient_challenge( + env: Env, + caller: Address, + invoice_id: u64, + recipient: Address, + ttl_secs: u64, + ) -> BytesN<32> { + require_not_paused(&env); + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + require_creator_or_cocreator(&invoice, &caller); + assert!(invoice.recipients.contains(&recipient), "not an invoice recipient"); + assert!( + ttl_secs >= MIN_RECIPIENT_AUTH_TTL_SECS && ttl_secs <= MAX_RECIPIENT_AUTH_TTL_SECS, + "invalid challenge ttl" + ); + if let Some(existing) = load_recipient_auth(&env, invoice_id, &recipient) { + assert!( + existing.status != RecipientAuthStatus::Verified, + "recipient already verified" + ); + } + + let nonce: u64 = env + .storage() + .instance() + .get(&recipient_auth_nonce_key()) + .unwrap_or(0u64) + + 1; + env.storage().instance().set(&recipient_auth_nonce_key(), &nonce); + + let now = env.ledger().timestamp(); + let mut seed = env.current_contract_address().to_xdr(&env); + seed.append(&Bytes::from_array(&env, &invoice_id.to_be_bytes())); + seed.append(&recipient.clone().to_xdr(&env)); + seed.append(&Bytes::from_array(&env, &nonce.to_be_bytes())); + seed.append(&Bytes::from_array(&env, &now.to_be_bytes())); + seed.append(&Bytes::from_array(&env, &env.ledger().sequence().to_be_bytes())); + let challenge: BytesN<32> = env.crypto().sha256(&seed).into(); + + save_recipient_auth( + &env, + &RecipientAuth { + invoice_id, + recipient: recipient.clone(), + status: RecipientAuthStatus::ChallengeIssued, + challenge: Some(challenge.clone()), + issued_at: now, + expires_at: now + ttl_secs, + verified_at: None, + attempts: 0, + nonce, + }, + ); + log_recipient_auth(&env, invoice_id, &recipient, symbol_short!("chal_iss"), &caller); + challenge + } + + /// Answer an outstanding challenge. Requires `recipient`'s authorization. + /// + /// Returns `true` on success. A late response marks the challenge + /// `Expired`; a wrong response counts an attempt and, after + /// `MAX_RECIPIENT_AUTH_ATTEMPTS`, marks it `Failed`. Both return `false` + /// and persist the new state, so a new challenge must then be issued. + pub fn respond_recipient_challenge( + env: Env, + recipient: Address, + invoice_id: u64, + response: BytesN<32>, + ) -> bool { + require_not_paused(&env); + recipient.require_auth(); + let mut auth = + load_recipient_auth(&env, invoice_id, &recipient).expect("no challenge issued"); + assert!( + auth.status == RecipientAuthStatus::ChallengeIssued, + "no active challenge" + ); + let challenge = auth.challenge.clone().expect("no active challenge"); + + if env.ledger().timestamp() > auth.expires_at { + auth.status = RecipientAuthStatus::Expired; + auth.challenge = None; + save_recipient_auth(&env, &auth); + log_recipient_auth(&env, invoice_id, &recipient, symbol_short!("chal_exp"), &recipient); + return false; + } + + if response == recipient_challenge_response(&env, &challenge, &recipient) { + auth.status = RecipientAuthStatus::Verified; + auth.challenge = None; + auth.verified_at = Some(env.ledger().timestamp()); + save_recipient_auth(&env, &auth); + log_recipient_auth(&env, invoice_id, &recipient, symbol_short!("verified"), &recipient); + append_audit_entry(&env, invoice_id, symbol_short!("rcp_verif"), &recipient); + return true; + } + + auth.attempts += 1; + let action = if auth.attempts >= MAX_RECIPIENT_AUTH_ATTEMPTS { + auth.status = RecipientAuthStatus::Failed; + auth.challenge = None; + symbol_short!("auth_fail") + } else { + symbol_short!("bad_resp") + }; + save_recipient_auth(&env, &auth); + log_recipient_auth(&env, invoice_id, &recipient, action, &recipient); + false + } + + /// Revoke a recipient's verification (e.g. on a suspected compromise). + /// Creator or co-creator only. + pub fn revoke_recipient_verification( + env: Env, + caller: Address, + invoice_id: u64, + recipient: Address, + ) { + require_not_paused(&env); + caller.require_auth(); + let invoice = load_invoice(&env, invoice_id); + require_creator_or_cocreator(&invoice, &caller); + let mut auth = + load_recipient_auth(&env, invoice_id, &recipient).expect("no verification record"); + auth.status = RecipientAuthStatus::Revoked; + auth.challenge = None; + auth.verified_at = None; + save_recipient_auth(&env, &auth); + log_recipient_auth(&env, invoice_id, &recipient, symbol_short!("revoked"), &caller); + } + + /// Current authentication state for `recipient` on `invoice_id`. + /// A pending challenge past its deadline is reported as `Expired`. + pub fn get_recipient_auth(env: Env, invoice_id: u64, recipient: Address) -> Option { + load_recipient_auth(&env, invoice_id, &recipient).map(|mut auth| { + if auth.status == RecipientAuthStatus::ChallengeIssued + && env.ledger().timestamp() > auth.expires_at + { + auth.status = RecipientAuthStatus::Expired; + } + auth + }) + } + + /// Whether `recipient` is currently verified for `invoice_id`. + pub fn is_recipient_verified(env: Env, invoice_id: u64, recipient: Address) -> bool { + matches!( + load_recipient_auth(&env, invoice_id, &recipient), + Some(RecipientAuth { status: RecipientAuthStatus::Verified, .. }) + ) + } + + /// Full verification audit log for `invoice_id`, oldest first. + pub fn get_recipient_auth_log(env: Env, invoice_id: u64) -> Vec { + env.storage() + .persistent() + .get(&recipient_auth_log_key(invoice_id)) + .unwrap_or_else(|| Vec::new(&env)) + } + + /// Helper for clients: the response `recipient` must submit for `challenge`. + pub fn compute_recipient_challenge_response( + env: Env, + challenge: BytesN<32>, + recipient: Address, + ) -> BytesN<32> { + recipient_challenge_response(&env, &challenge, &recipient) + } +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 5d4c846..191469f 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -1960,6 +1960,50 @@ pub struct ArchivalStats { pub sweep_cursor: u64, } +/// Issue #928: State machine for recipient address verification. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum RecipientAuthStatus { + Unverified, + /// A challenge is outstanding and awaiting the recipient's response. + ChallengeIssued, + Verified, + /// Too many incorrect responses; a new challenge is required. + Failed, + /// The challenge was not answered before `expires_at`. + Expired, + /// Verification was withdrawn by the creator. + Revoked, +} + +/// Issue #928: Authentication record for one recipient of one invoice. +#[contracttype] +#[derive(Clone, Debug)] +pub struct RecipientAuth { + pub invoice_id: u64, + pub recipient: Address, + pub status: RecipientAuthStatus, + /// Outstanding challenge; cleared once answered, expired or failed. + pub challenge: Option>, + pub issued_at: u64, + pub expires_at: u64, + pub verified_at: Option, + /// Incorrect responses to the current challenge. + pub attempts: u32, + pub nonce: u64, +} + +/// Issue #928: Entry in the per-invoice recipient verification audit log. +#[contracttype] +#[derive(Clone, Debug)] +pub struct RecipientAuthLogEntry { + pub recipient: Address, + pub action: Symbol, + pub actor: Address, + pub timestamp: u64, + pub ledger: u32, +} + #[cfg(test)] mod tests { use super::InvoiceStatus; From 285dda2f0e45550ffe556d96946f2815af67ce8c Mon Sep 17 00:00:00 2001 From: Gaina Slyvester Date: Sun, 18 Oct 2026 02:50:43 +0000 Subject: [PATCH 4/4] feat(rollback): add reversible invoice operations with time-limited undo (#929) - ReversibleOp records with a Recorded -> Undone / Expired state machine - reversible_update_deadline, reversible_update_amounts and reversible_cancel_invoice capture an OperationSnapshot before mutating - undo_operation restores the snapshot within the admin-configurable rollback window (set_rollback_window, default 1 hour) - undo is validated against a hash of the post-operation state so it is rejected if the invoice changed since the operation - audit entries plus rv_rec / rv_undo events for every operation --- contracts/split/src/events.rs | 20 +++ contracts/split/src/lib.rs | 254 +++++++++++++++++++++++++++++++++- contracts/split/src/types.rs | 50 +++++++ 3 files changed, 322 insertions(+), 2 deletions(-) diff --git a/contracts/split/src/events.rs b/contracts/split/src/events.rs index bd22bb7..7225cfd 100644 --- a/contracts/split/src/events.rs +++ b/contracts/split/src/events.rs @@ -425,6 +425,26 @@ pub fn recipient_auth_changed(env: &Env, invoice_id: u64, recipient: &Address, a ); } +/// Issue #929: Emitted when a reversible operation is recorded. +/// Topics: (split, rv_rec, invoice_id) +/// Data: (op_id, kind) +pub fn reversible_op_recorded(env: &Env, invoice_id: u64, op_id: u64, kind: crate::types::ReversibleOpKind) { + env.events().publish( + (symbol_short!("split"), symbol_short!("rv_rec"), invoice_id), + (op_id, kind), + ); +} + +/// Issue #929: Emitted when a reversible operation is undone. +/// Topics: (split, rv_undo, invoice_id) +/// Data: (op_id, caller, ledger) +pub fn reversible_op_undone(env: &Env, invoice_id: u64, op_id: u64, caller: &Address) { + env.events().publish( + (symbol_short!("split"), symbol_short!("rv_undo"), invoice_id), + (op_id, caller.clone(), env.ledger().sequence()), + ); +} + /// Emitted when an invoice is paused. /// Topics: (split, paused, invoice_id) /// Data: (creator, reason, auto_resume_at) diff --git a/contracts/split/src/lib.rs b/contracts/split/src/lib.rs index e2615bb..8eb5524 100644 --- a/contracts/split/src/lib.rs +++ b/contracts/split/src/lib.rs @@ -94,10 +94,10 @@ use types::{ DisputeOutcome, DisputeRecord, DisputeStatus, FeeBracket, FeeSplit, FeeTier, InstalmentPlan, Invoice, InvoiceCore, InvoiceExt, InvoiceExt2, InvoiceExt3, InvoiceHot, InvoiceOptions, InvoiceOptions2, InvoicePayment, InvoiceStats, InvoiceStatus, InvoiceTemplate, - InvoiceTemplateRecord, LegacyInvoice, RecipientAuth, RecipientAuthLogEntry, RecipientAuthStatus, OverflowBehavior, OverfundingPolicy, Payment, + InvoiceTemplateRecord, LegacyInvoice, OperationSnapshot, RecipientAuth, RecipientAuthLogEntry, RecipientAuthStatus, OverflowBehavior, OverfundingPolicy, Payment, PaymentCertificate, PaymentCommitment, PaymentProof, PaymentRecord, PendingAdminAction, ProtocolFeeConfig, QueuedAction, RebateTier, Recipient, RepScore, ResolveAction, - ResolveRule, Role, SimulateReleaseResult, SplitRule, SubscriptionParams, TimelockAction, + ResolveRule, ReversibleOp, ReversibleOpKind, ReversibleOpStatus, Role, SimulateReleaseResult, SplitRule, SubscriptionParams, TimelockAction, Tombstone, Tranche, TransferRecord, TreasuryRecord, UpgradeProposal, }; @@ -15398,6 +15398,98 @@ fn recipient_challenge_response(env: &Env, challenge: &BytesN<32>, recipient: &A env.crypto().sha256(&preimage).into() } +/// Issue #929: default undo window for reversible operations (1 hour). +const DEFAULT_ROLLBACK_WINDOW_SECS: u64 = 3_600; +const MIN_ROLLBACK_WINDOW_SECS: u64 = 60; +const MAX_ROLLBACK_WINDOW_SECS: u64 = 7 * 24 * 60 * 60; + +fn rollback_window_key() -> Symbol { + symbol_short!("rb_window") +} + +fn rollback_counter_key() -> Symbol { + symbol_short!("rb_ctr") +} + +fn reversible_op_key(op_id: u64) -> (Symbol, u64) { + (symbol_short!("rb_op"), op_id) +} + +fn invoice_ops_key(invoice_id: u64) -> (Symbol, u64) { + (symbol_short!("rb_ops"), invoice_id) +} + +fn get_rollback_window_secs(env: &Env) -> u64 { + env.storage() + .instance() + .get(&rollback_window_key()) + .unwrap_or(DEFAULT_ROLLBACK_WINDOW_SECS) +} + +/// Issue #929: capture the invoice fields a reversible operation may touch. +fn snapshot_operation_state(invoice: &Invoice) -> OperationSnapshot { + OperationSnapshot { + deadline: invoice.deadline, + amounts: invoice.amounts.clone(), + base_amounts: invoice.base_amounts.clone(), + status: invoice.status.clone(), + funded: invoice.funded, + payment_count: invoice.payments.len(), + } +} + +fn hash_operation_snapshot(env: &Env, snapshot: &OperationSnapshot) -> BytesN<32> { + env.crypto().sha256(&snapshot.clone().to_xdr(env)).into() +} + +/// Issue #929: apply a reversible mutation to `invoice` and record it. +/// +/// `before` is the snapshot taken prior to the mutation; the post-mutation +/// snapshot hash is stored so `undo_operation` can detect any later change. +fn record_reversible_op( + env: &Env, + invoice_id: u64, + invoice: &Invoice, + kind: ReversibleOpKind, + actor: &Address, + before: OperationSnapshot, +) -> u64 { + let op_id: u64 = env + .storage() + .instance() + .get(&rollback_counter_key()) + .unwrap_or(0u64) + + 1; + env.storage().instance().set(&rollback_counter_key(), &op_id); + + let now = env.ledger().timestamp(); + let after_hash = hash_operation_snapshot(env, &snapshot_operation_state(invoice)); + let op = ReversibleOp { + op_id, + invoice_id, + kind: kind.clone(), + actor: actor.clone(), + before, + after_hash, + created_at: now, + expires_at: now.saturating_add(get_rollback_window_secs(env)), + status: ReversibleOpStatus::Recorded, + undone_at: None, + }; + env.storage().persistent().set(&reversible_op_key(op_id), &op); + + let mut ops: Vec = env + .storage() + .persistent() + .get(&invoice_ops_key(invoice_id)) + .unwrap_or_else(|| Vec::new(env)); + ops.push_back(op_id); + env.storage().persistent().set(&invoice_ops_key(invoice_id), &ops); + + events::reversible_op_recorded(env, invoice_id, op_id, kind); + op_id +} + fn clone_record_key(id: u64) -> (Symbol, u64) { (symbol_short!("clone_rec"), id) } @@ -16743,3 +16835,161 @@ impl SplitContract { recipient_challenge_response(&env, &challenge, &recipient) } } + +// --------------------------------------------------------------------------- +// Issue #929: Reversible operations (undo / rollback within a time window) +// --------------------------------------------------------------------------- + +#[contractimpl] +impl SplitContract { + /// Set the undo window for reversible operations, in seconds. + /// Operator role or higher. Applies to operations recorded afterwards. + pub fn set_rollback_window(env: Env, admin: Address, window_secs: u64) { + require_admin_role(&env, &admin, AdminRole::Operator); + assert!( + window_secs >= MIN_ROLLBACK_WINDOW_SECS && window_secs <= MAX_ROLLBACK_WINDOW_SECS, + "invalid rollback window" + ); + env.storage().instance().set(&rollback_window_key(), &window_secs); + } + + /// Current undo window for reversible operations, in seconds. + pub fn get_rollback_window(env: Env) -> u64 { + get_rollback_window_secs(&env) + } + + /// Change a Pending invoice's deadline, recording an undoable operation. + /// Creator or co-creator only. Returns the operation ID. + pub fn reversible_update_deadline( + env: Env, + caller: Address, + invoice_id: u64, + new_deadline: u64, + ) -> u64 { + require_not_paused(&env); + caller.require_auth(); + let mut invoice = load_invoice(&env, invoice_id); + require_creator_or_cocreator(&invoice, &caller); + assert!(invoice.status == InvoiceStatus::Pending, "invoice not pending"); + assert!(new_deadline > env.ledger().timestamp(), "deadline must be in the future"); + + let before = snapshot_operation_state(&invoice); + invoice.deadline = new_deadline; + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("rv_dline"), &caller); + record_reversible_op(&env, invoice_id, &invoice, ReversibleOpKind::UpdateDeadline, &caller, before) + } + + /// Change the recipient amounts of an unfunded Pending invoice, recording + /// an undoable operation. Creator or co-creator only. + pub fn reversible_update_amounts( + env: Env, + caller: Address, + invoice_id: u64, + new_amounts: Vec, + ) -> u64 { + require_not_paused(&env); + caller.require_auth(); + let mut invoice = load_invoice(&env, invoice_id); + require_creator_or_cocreator(&invoice, &caller); + assert!(invoice.status == InvoiceStatus::Pending, "invoice not pending"); + assert!(invoice.funded == 0, "invoice already funded"); + assert!( + new_amounts.len() == invoice.recipients.len(), + "amounts length mismatch" + ); + for amount in new_amounts.iter() { + assert!(amount > 0, "amount must be positive"); + } + + let before = snapshot_operation_state(&invoice); + invoice.amounts = new_amounts.clone(); + invoice.base_amounts = new_amounts; + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("rv_amts"), &caller); + record_reversible_op(&env, invoice_id, &invoice, ReversibleOpKind::UpdateAmounts, &caller, before) + } + + /// Cancel an unfunded Pending invoice, recording an undoable operation. + /// Creator or co-creator only. No funds move, so undo is always safe + /// within the window. + pub fn reversible_cancel_invoice(env: Env, caller: Address, invoice_id: u64) -> u64 { + require_not_paused(&env); + caller.require_auth(); + let mut invoice = load_invoice(&env, invoice_id); + require_creator_or_cocreator(&invoice, &caller); + assert!(invoice.status == InvoiceStatus::Pending, "invoice not pending"); + assert!(invoice.funded == 0, "invoice already funded"); + + let before = snapshot_operation_state(&invoice); + invoice.status = InvoiceStatus::Cancelled; + save_invoice(&env, invoice_id, &invoice); + append_audit_entry(&env, invoice_id, symbol_short!("rv_cancel"), &caller); + record_reversible_op(&env, invoice_id, &invoice, ReversibleOpKind::Cancel, &caller, before) + } + + /// Undo a recorded operation, restoring the invoice's pre-operation state. + /// + /// Callable by the operation's actor or the invoice creator, within the + /// rollback window. Rejected if the invoice has changed in any tracked + /// field since the operation (including a later reversible operation — + /// undo those first, most recent first). + pub fn undo_operation(env: Env, caller: Address, op_id: u64) { + require_not_paused(&env); + caller.require_auth(); + let mut op: ReversibleOp = env + .storage() + .persistent() + .get(&reversible_op_key(op_id)) + .expect("operation not found"); + assert!(op.status == ReversibleOpStatus::Recorded, "operation not undoable"); + assert!(env.ledger().timestamp() <= op.expires_at, "rollback window elapsed"); + + let mut invoice = load_invoice(&env, op.invoice_id); + assert!( + caller == op.actor || caller == invoice.creator, + "not authorized to undo" + ); + let current_hash = hash_operation_snapshot(&env, &snapshot_operation_state(&invoice)); + assert!(current_hash == op.after_hash, "invoice changed since operation"); + assert!( + op.before.amounts.len() == invoice.recipients.len(), + "snapshot inconsistent with invoice" + ); + + invoice.deadline = op.before.deadline; + invoice.amounts = op.before.amounts.clone(); + invoice.base_amounts = op.before.base_amounts.clone(); + invoice.status = op.before.status.clone(); + save_invoice(&env, op.invoice_id, &invoice); + + op.status = ReversibleOpStatus::Undone; + op.undone_at = Some(env.ledger().timestamp()); + env.storage().persistent().set(&reversible_op_key(op_id), &op); + + append_audit_entry(&env, op.invoice_id, symbol_short!("rv_undo"), &caller); + events::reversible_op_undone(&env, op.invoice_id, op_id, &caller); + } + + /// Fetch a reversible operation. A `Recorded` operation past its window + /// is reported as `Expired`. + pub fn get_operation(env: Env, op_id: u64) -> ReversibleOp { + let mut op: ReversibleOp = env + .storage() + .persistent() + .get(&reversible_op_key(op_id)) + .expect("operation not found"); + if op.status == ReversibleOpStatus::Recorded && env.ledger().timestamp() > op.expires_at { + op.status = ReversibleOpStatus::Expired; + } + op + } + + /// IDs of every reversible operation recorded on `invoice_id`, oldest first. + pub fn get_invoice_operations(env: Env, invoice_id: u64) -> Vec { + env.storage() + .persistent() + .get(&invoice_ops_key(invoice_id)) + .unwrap_or_else(|| Vec::new(&env)) + } +} diff --git a/contracts/split/src/types.rs b/contracts/split/src/types.rs index 191469f..5fb41c0 100644 --- a/contracts/split/src/types.rs +++ b/contracts/split/src/types.rs @@ -2004,6 +2004,56 @@ pub struct RecipientAuthLogEntry { pub ledger: u32, } +/// Issue #929: Kind of reversible operation. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ReversibleOpKind { + UpdateDeadline, + UpdateAmounts, + Cancel, +} + +/// Issue #929: Rollback state machine — `Recorded` → `Undone`, or +/// `Recorded` → `Expired` once the window elapses. +#[contracttype] +#[derive(Clone, Debug, PartialEq)] +pub enum ReversibleOpStatus { + Recorded, + Undone, + Expired, +} + +/// Issue #929: Invoice fields captured before a reversible operation. +#[contracttype] +#[derive(Clone, Debug)] +pub struct OperationSnapshot { + pub deadline: u64, + pub amounts: Vec, + pub base_amounts: Vec, + pub status: InvoiceStatus, + pub funded: i128, + pub payment_count: u32, +} + +/// Issue #929: A recorded, potentially undoable operation. +#[contracttype] +#[derive(Clone, Debug)] +pub struct ReversibleOp { + pub op_id: u64, + pub invoice_id: u64, + pub kind: ReversibleOpKind, + pub actor: Address, + /// State to restore on undo. + pub before: OperationSnapshot, + /// Hash of the state right after the operation; undo requires the + /// invoice to still match it. + pub after_hash: BytesN<32>, + pub created_at: u64, + pub expires_at: u64, + pub status: ReversibleOpStatus, + pub undone_at: Option, +} + #[cfg(test)] mod tests { use super::InvoiceStatus;