Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ jobs:

- name: Run tests
run: cargo test --workspace

- name: Storage key snapshot test
run: cargo test -p split storage_snapshot
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,21 @@ Returns the full invoice struct.
PLACEHOLDER — update after deployment
```

## Storage Key Registry

Every storage key the contract uses is serialised to XDR and compared against a committed baseline in `tests/snapshots/storage_keys.json`. The snapshot runs as `cargo test -p split storage_snapshot`.

**Policy:**
- Adding, removing, or changing any storage key will intentionally fail the snapshot test.
- If the change is intentional (e.g. a new feature adds a key, or a migration renames an existing one), update the baseline file by running the test locally, copying the generated XDR into `tests/snapshots/storage_keys.json`, and **including a migration note in the PR description**.
- The test also asserts that **no two keys produce the same XDR** (collision-free).
- Each key entry in the snapshot is labelled by its Rust function name (e.g. `invoice_key`, `rep_key`).

To update the baseline:
```bash
cargo test -p split storage_snapshot 2>&1 | grep -A 999 "EXPECTED (generated)" | tail -n +2 | head -n -1 > tests/snapshots/storage_keys.json
```

## Contributing via Drips Wave

This project participates in the [Drips Wave Program](https://drips.network/wave) by the Stellar Development Foundation. Contributors can earn rewards by completing open issues.
Expand Down
13 changes: 6 additions & 7 deletions contracts/split/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ mod test;
#[cfg(test)]
mod fuzz_tests;

#[cfg(test)]
mod storage_snapshot;

use soroban_sdk::{
String,
contract, contractimpl, symbol_short, token, Address, Bytes, BytesN, Env, IntoVal, Map, Symbol, Val, Vec,
Expand Down Expand Up @@ -127,7 +130,7 @@ fn counter_key() -> Symbol {
symbol_short!("counter")
}
fn archive_after_ledgers_key() -> Symbol {
symbol_short!("arch_after")
symbol_short!("arch_af")
}
fn archive_marker_key(id: u64) -> (Symbol, u64) {
(symbol_short!("archv"), id)
Expand Down Expand Up @@ -568,8 +571,6 @@ fn archive_invoice_storage(env: &Env, id: u64, core: &InvoiceCore) {
cross_chain_ref: None, require_kyc: false, arbiter: None, disputed: false,
admin_frozen: false, auction_on_expiry: false, auction_end: 0, bids: Vec::new(env),
min_payment: 0, min_funding_amount: 0, priorities: Vec::new(env),
substitute_recipient_approvals: Vec::new(env),
creation_timestamp: 0, min_payment_increment: 0,
});

env.storage().instance().set(&invoice_key(id), core);
Expand Down Expand Up @@ -726,10 +727,6 @@ fn load_invoice(env: &Env, id: u64) -> Invoice {
target_usd_cents: None,
});

// Load compact representation if available
if let Some(compact) = env.storage().persistent().get::<_, CompactInvoice>(&invoice_compact_key(id))
.or_else(|| env.storage().instance().get(&invoice_compact_key(id)))
{
// Load compact representation if available, then overlay hot fields.
let mut invoice = if let Some(compact) = env.storage().persistent().get::<_, CompactInvoice>(&invoice_compact_key(id)) {
Invoice::from_compact(&compact, core, ext, ext2)
Expand Down Expand Up @@ -6830,6 +6827,8 @@ impl SplitContract {
let actor = env.current_contract_address();
Self::_release(&env, invoice_id, &mut invoice.clone(), &actor);
}
}

// Issue #308: Per-payer claim_refund after deadline
// -----------------------------------------------------------------------

Expand Down
191 changes: 191 additions & 0 deletions contracts/split/src/storage_snapshot.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#![cfg(test)]

use super::*;
use soroban_sdk::{xdr::ToXdr, Env};

fn hex_xdr(env: &Env, val: impl ToXdr) -> String {
let bytes = val.to_xdr(env);
bytes.iter().map(|b| format!("{:02x}", b)).collect::<Vec<_>>().concat()
}

#[test]
fn storage_key_snapshot() {
let env = Env::default();
// Deterministic address (all-zero G... strkey) — do not change.
let a = Address::from_str(&env, "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF");
let s = symbol_short!("x");

let mut keys: Vec<(&str, String)> = Vec::new();

// -----------------------------------------------------------------------
// Instance-tier keys (contract-level singletons, return Symbol)
// -----------------------------------------------------------------------

keys.push(("admin_key", hex_xdr(&env, admin_key())));
keys.push(("admins_key", hex_xdr(&env, admins_key())));
keys.push(("paused_key", hex_xdr(&env, paused_key())));
keys.push(("paused_fns_key", hex_xdr(&env, paused_fns_key())));
keys.push(("treasury_key", hex_xdr(&env, treasury_key())));
keys.push(("usdc_token_key", hex_xdr(&env, usdc_token_key())));
keys.push(("creation_fee_key", hex_xdr(&env, creation_fee_key())));
keys.push(("platform_fee_bps_key", hex_xdr(&env, platform_fee_bps_key())));
keys.push(("platform_fee_waiver_list_key", hex_xdr(&env, platform_fee_waiver_list_key())));
keys.push(("creator_fee_waiver_key", hex_xdr(&env, creator_fee_waiver_key())));
keys.push(("counter_key", hex_xdr(&env, counter_key())));
keys.push(("global_payer_limit_key", hex_xdr(&env, global_payer_limit_key())));
keys.push(("global_payer_window_key", hex_xdr(&env, global_payer_window_key())));
keys.push(("stream_contract_key", hex_xdr(&env, stream_contract_key())));
keys.push(("creator_whitelist_key", hex_xdr(&env, creator_whitelist_key())));
keys.push(("compliance_key", hex_xdr(&env, compliance_key())));
keys.push(("rate_limit_key", hex_xdr(&env, rate_limit_key())));
keys.push(("rate_window_key", hex_xdr(&env, rate_window_key())));
keys.push(("max_cancel_bps_key", hex_xdr(&env, max_cancel_bps_key())));
keys.push(("receipt_factory_key", hex_xdr(&env, receipt_factory_key())));
keys.push(("dashboard_contract_key", hex_xdr(&env, dashboard_contract_key())));
keys.push(("nft_gate_key", hex_xdr(&env, nft_gate_key())));
keys.push(("timelock_secs_key", hex_xdr(&env, timelock_secs_key())));
keys.push(("timelock_action_counter_key", hex_xdr(&env, timelock_action_counter_key())));
keys.push(("fee_tiers_key", hex_xdr(&env, fee_tiers_key())));
keys.push(("pending_admin_key", hex_xdr(&env, pending_admin_key())));
keys.push(("governance_contract_key", hex_xdr(&env, governance_contract_key())));
keys.push(("factories_key", hex_xdr(&env, factories_key())));
keys.push(("total_invoices_key", hex_xdr(&env, total_invoices_key())));
keys.push(("total_volume_key", hex_xdr(&env, total_volume_key())));
keys.push(("total_released_key", hex_xdr(&env, total_released_key())));
keys.push(("total_refunded_key", hex_xdr(&env, total_refunded_key())));
keys.push(("treasury_group_counter_key", hex_xdr(&env, treasury_group_counter_key())));
keys.push(("circuit_breaker_key", hex_xdr(&env, circuit_breaker_key())));
keys.push(("circuit_breaker_reason_key", hex_xdr(&env, circuit_breaker_reason_key())));
keys.push(("archive_after_ledgers_key", hex_xdr(&env, archive_after_ledgers_key())));
keys.push(("platform_vol_thresh_key", hex_xdr(&env, platform_vol_thresh_key())));
keys.push(("platform_vol_mile_key", hex_xdr(&env, platform_vol_mile_key())));
keys.push(("creator_vol_thresh_key", hex_xdr(&env, creator_vol_thresh_key())));
keys.push(("kyc_contract_key", hex_xdr(&env, kyc_contract_key())));
keys.push(("upgrade_proposal_key", hex_xdr(&env, upgrade_proposal_key())));

// -----------------------------------------------------------------------
// Persistent-tier keys (per-entity)
// -----------------------------------------------------------------------

// (Symbol, u64)
keys.push(("invoice_key", hex_xdr(&env, invoice_key(1))));
keys.push(("invoice_ext_key", hex_xdr(&env, invoice_ext_key(1))));
keys.push(("invoice_ext2_key", hex_xdr(&env, invoice_ext2_key(1))));
keys.push(("invoice_compact_key", hex_xdr(&env, invoice_compact_key(1))));
keys.push(("invoice_hot_key", hex_xdr(&env, invoice_hot_key(1))));
keys.push(("audit_log_key", hex_xdr(&env, audit_log_key(1))));
keys.push(("archive_marker_key", hex_xdr(&env, archive_marker_key(1))));
keys.push(("created_ledger_key", hex_xdr(&env, created_ledger_key(1))));
keys.push(("subscription_params_key", hex_xdr(&env, subscription_params_key(1))));
keys.push(("confidential_count_key", hex_xdr(&env, confidential_count_key(1))));
keys.push(("ext_vote_key", hex_xdr(&env, ext_vote_key(1))));
keys.push(("group_key", hex_xdr(&env, group_key(1))));
keys.push(("invoice_group_key", hex_xdr(&env, invoice_group_key(1))));
keys.push(("invoice_treasury_key", hex_xdr(&env, invoice_treasury_key(1))));
keys.push(("group_treasury_key", hex_xdr(&env, group_treasury_key(1))));
keys.push(("delegate_key", hex_xdr(&env, delegate_key(1))));
keys.push(("payment_window_key", hex_xdr(&env, payment_window_key(1))));
keys.push(("cert_key", hex_xdr(&env, cert_key(1))));
keys.push(("timelock_action_key", hex_xdr(&env, timelock_action_key(1))));
keys.push(("refunded_key", hex_xdr(&env, refunded_key(1))));

// (Symbol, Address)
keys.push(("pause_exempt_key", hex_xdr(&env, pause_exempt_key(&a))));
keys.push(("global_vel_key", hex_xdr(&env, global_vel_key(&a))));
keys.push(("rep_key", hex_xdr(&env, rep_key(&a))));
keys.push(("credit_key", hex_xdr(&env, credit_key(&a))));
keys.push(("referral_count_key", hex_xdr(&env, referral_count_key(&a))));
keys.push(("recipient_invoice_ids_key", hex_xdr(&env, recipient_invoice_ids_key(&a))));
keys.push(("delegate_pay_key", hex_xdr(&env, delegate_pay_key(&a))));
keys.push(("rate_usage_key", hex_xdr(&env, rate_usage_key(&a))));
keys.push(("invoice_count_key", hex_xdr(&env, invoice_count_key(&a))));
keys.push(("cancel_count_key", hex_xdr(&env, cancel_count_key(&a))));
keys.push(("creator_stats_count_key", hex_xdr(&env, creator_stats_count_key(&a))));
keys.push(("creator_stats_volume_key", hex_xdr(&env, creator_stats_volume_key(&a))));
keys.push(("creator_stats_released_key", hex_xdr(&env, creator_stats_released_key(&a))));
keys.push(("creator_stats_refunded_key", hex_xdr(&env, creator_stats_refunded_key(&a))));
keys.push(("creator_stats_payers_key", hex_xdr(&env, creator_stats_payers_key(&a))));
keys.push(("creator_stats_avg_funding_key", hex_xdr(&env, creator_stats_avg_funding_key(&a))));
keys.push(("creator_vol_mile_key", hex_xdr(&env, creator_vol_mile_key(&a))));
keys.push(("creator_volume_cap_key", hex_xdr(&env, creator_volume_cap_key(&a))));
keys.push(("creator_volume_used_key", hex_xdr(&env, creator_volume_used_key(&a))));

// (Symbol, u64, Address)
keys.push(("confidential_pay_key", hex_xdr(&env, confidential_pay_key(1, &a))));
keys.push(("reminder_key", hex_xdr(&env, reminder_key(1, &a))));
keys.push(("pending_payout_key", hex_xdr(&env, pending_payout_key(1, &a))));
keys.push(("channel_key", hex_xdr(&env, channel_key(1, &a))));
keys.push(("nonce_key", hex_xdr(&env, nonce_key(1, &a))));
keys.push(("vel_key", hex_xdr(&env, vel_key(1, &a))));
keys.push(("receipt_token_key", hex_xdr(&env, receipt_token_key(1, &a))));
keys.push(("accum_key", hex_xdr(&env, accum_key(1, &a))));

// (Symbol, u64, Address) where Address is owned
keys.push(("payer_cooldown_key", hex_xdr(&env, payer_cooldown_key(1, a.clone()))));

// (Symbol, u64, u64)
keys.push(("pay_shard_key", hex_xdr(&env, pay_shard_key(1, 1))));

// (Symbol, Address, Symbol)
keys.push(("template_key", hex_xdr(&env, template_key(&a, &s))));
keys.push(("template_version_count_key", hex_xdr(&env, template_version_count_key(&a, &s))));

// (Symbol, Address, Symbol, u32)
keys.push(("template_version_key", hex_xdr(&env, template_version_key(&a, &s, 1))));

// Sort by key name for deterministic output
keys.sort_by(|a, b| a.0.cmp(b.0));

// -----------------------------------------------------------------------
// Collision check
// -----------------------------------------------------------------------
{
let mut seen = std::collections::HashSet::new();
for (name, xdr) in &keys {
assert!(
seen.insert(xdr),
"Collision detected: '{}' has the same XDR as another key",
name,
);
}
}

// -----------------------------------------------------------------------
// Build expected snapshot JSON
// -----------------------------------------------------------------------
let mut lines: Vec<String> = Vec::new();
lines.push("{".to_string());
lines.push(format!(" \"_comment\": \"Storage key XDR snapshot — see README Storage Key Registry section for policy.\","));
lines.push(format!(" \"_keys_introduced\": \"Snapshot introduced with #331. Add your key name and XDR here.\","));
lines.push(format!(" \"version\": \"1\","));
lines.push(format!(" \"keys\": {{"));
for (i, (name, xdr)) in keys.iter().enumerate() {
let comma = if i == keys.len() - 1 { "" } else { "," };
lines.push(format!(" \"{}\": \"{}\"{}", name, xdr, comma));
}
lines.push(format!(" }}"));
lines.push(format!("}}"));
lines.push(String::new());
let generated = lines.join("\n");

// -----------------------------------------------------------------------
// Compare against committed baseline
// -----------------------------------------------------------------------
let baseline = include_str!("../../../tests/snapshots/storage_keys.json");

if generated != baseline {
panic!(
"\n=== SNAPSHOT MISMATCH ===\n\
Storage key XDR has changed from the committed baseline.\n\
If this is intentional, update tests/snapshots/storage_keys.json\n\
with the new output below, and include a migration note in your PR.\n\
\n\
=== EXPECTED (generated) ===\n\
{}\n\
=== ACTUAL (baseline) ===\n\
{}\n\
============================",
generated, baseline,
);
}
}
Loading