Skip to content
Open
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
25 changes: 25 additions & 0 deletions contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,5 +209,30 @@ The accompanying test suite in [`contracts/sharibo/src/test.rs`](sharibo/src/tes
- `cancel_refunds_partial_funders_and_closes_circle`: Verifies that a circle admin can cancel an underfunded circle, automatically refunding all current round contributors in FIFO order and closing the circle permanently.
6. **State Persistence**:
- `instance_ttl_extended_after_create_fund_claim`: Ensures that `extend_ttl` is executed on all write operations (`create_circle`, `fund`, `claim`) to prevent instance-storage archival issues.
- `persistent_circle_survives_multiple_rounds_with_ttl_refresh`: Verifies that Circle and Nullifier entries remain accessible across multiple fund/claim rounds when ledger advances by LEDGER_THRESHOLD, confirming TTL is actively re-extended (not once-at-creation).
- `circle_and_nullifier_entries_individually_extended`: Asserts that both the Circle persistent entry AND the Nullifier persistent entry are independently extended, surviving ledger advancement past LEDGER_THRESHOLD.
- `ttl_survives_fund_after_ledger_advance`: Confirms that fund operations trigger TTL re-extension even after ledger has advanced past LEDGER_THRESHOLD, allowing indefinite circle activity.
7. **Gas / CPU Benchmarking**:
- `cpu_instruction_benchmarks`: Benchmarks and prints the precise CPU instructions consumed by write operations (e.g., `create_circle`, `fund`, `claim`) and asserts that they remain safely under the 100M limit.

### TTL (Time-To-Live) & State Archival

The contract uses Soroban's ledger TTL mechanism to manage circle entry lifespan. The following constants govern TTL behavior:

- **`LEDGER_THRESHOLD = 100`**: The minimum ledger distance at which an entry's TTL should be re-extended. At ~5 seconds per ledger, this is ~8.3 minutes. Active circles are re-extended every ~500 seconds of operation.
- **`LEDGER_EXTEND_TO = 500_000`**: The target TTL (in ledgers) after each extension. At ~5 seconds per ledger:
```
500_000 ledgers × 5 sec/ledger = 2_500_000 seconds ≈ 28.9 days ≈ 29 days
```
This gives circles **~1 month of inactivity** before archival risk.

#### Archival & Restoration

If a circle's persistent entry (or any Nullifier) is not written to for 29+ days:

1. **Archival**: The entry moves to the Soroban state archive (temporary inaccessibility). On-chain reads/writes fail with `CircleNotFound`.
2. **Restoration**: The entry can be restored via `RestoreFootprintOp` on the Stellar network (Ledger 50M+ supports historical recovery).
3. **Recovery**: Restoration is **permissionless** — any party can restore an archived circle; no admin key is needed (only network validator consensus).
4. **State Preservation**: Upon restoration, the entry reappears with its last-written value intact (round number, pot, contributors, etc. are preserved).

See the [Soroban Documentation](https://developers.stellar.org/) for "Temporary State" and "State Archival" (Soroban 23.0+).
23 changes: 23 additions & 0 deletions contracts/sharibo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,30 @@ pub enum Error {
CircleCancelled = 8,
}

/// Ledger distance at which an entry's TTL should be re-extended.
///
/// At Stellar's nominal 5 seconds per ledger, LEDGER_THRESHOLD = 100 means:
/// 100 ledgers * 5 sec/ledger = 500 seconds ≈ 8.3 minutes.
///
/// Every time a Circle or Nullifier entry is written, extend_ttl is called
/// if the current TTL falls within this threshold. This ensures active circles
/// stay fresh indefinitely without requiring manual re-creation.
const LEDGER_THRESHOLD: u32 = 100;

/// Target TTL (in ledgers) after each extension.
///
/// At Stellar's nominal 5 seconds per ledger:
/// 500_000 ledgers * 5 sec/ledger = 2_500_000 seconds
/// ≈ 28.9 days ≈ 29 days
///
/// This gives circles approximately 1 month of inactivity before archival risk.
/// If an entry is not written to for 29+ days:
/// 1. The Soroban network moves it to the state archive (temporary inaccessibility).
/// 2. The entry is recoverable via permissionless restoration on the Stellar network
/// (Ledger 50M+ will support historical recovery).
/// 3. Until restoration, fund/claim calls on that circle_id fail with CircleNotFound.
///
/// See Soroban documentation: "Temporary State" and "State Archival" (Soroban 23.0+).
const LEDGER_EXTEND_TO: u32 = 500_000;

/// Sharibo contract: permissionless Semaphore-style contribution circles on
Expand Down
262 changes: 261 additions & 1 deletion contracts/sharibo/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1104,13 +1104,273 @@ fn instance_ttl_extended_after_create_fund_claim() {

// Verify the instance entry is still live (has a TTL > 0) after all
// three write paths have run. If extend_ttl were missing, the entry
// would have lapsed and NextCircleId would behave unpredictably.
// would have lapse and NextCircleId would behave unpredictably.
// The test env raises an error if a live entry is accessed after
// its TTL expires, so a successful get_circle here is our proof.
let circle = client.get_circle(&0u64);
assert_eq!(circle.round, 1, "claim should have advanced round to 1");
}

// ---- Issue #90: ledger TTL persistence & extension ----
//
// Context:
// - The contract sets LEDGER_THRESHOLD = 100 and LEDGER_EXTEND_TO = 500_000
// on every persistent write (Circle, Nullifier entries).
// - LEDGER_THRESHOLD: minimum ledger distance that triggers re-extension.
// - LEDGER_EXTEND_TO: target TTL after extension (measured in ledgers).
//
// TTL Numbers Rationale:
// - At Stellar's nominal 5 seconds per ledger:
// 500_000 ledgers * 5 sec/ledger = 2_500_000 seconds ≈ 28.9 days ≈ 29 days.
// - This gives circles ~1 month of inactivity before archival risk.
// - LEDGER_THRESHOLD = 100 means extension is re-triggered every 100 ledgers
// (~8.3 minutes), ensuring active circles stay fresh indefinitely.
//
// Restoration Story (Archival Scenario):
// If a circle's entry expires (all fields untouched for 29+ days):
// 1. The entry moves to the Soroban state archive (temporary inaccessibility).
// 2. On the Stellar network, a future restoration operation can recover it
// from the archive (Ledger 50M+ will support historical recovery).
// 3. Until restoration, fund/claim calls on that circle_id fail with
// CircleNotFound (the entry is inaccessible, not deleted).
// 4. Restoration is permissionless — any party can restore an archived
// circle; no admin private key needed (only validator consensus).
// See Soroban Docs: "Temporary State" and "State Archival" (Ledger 21.5+).

#[test]
fn persistent_circle_survives_multiple_rounds_with_ttl_refresh() {
// Scenario: create a circle, fund/claim multiple rounds, and verify
// that each operation refreshes both the Circle and Nullifier entry TTLs.
// We advance the ledger by LEDGER_THRESHOLD between operations to ensure
// TTL re-extension is triggered on every write.
//
// This test pins that:
// 1. Circle state persists across multiple rounds.
// 2. TTL is actively refreshed (not once-and-done at creation).
// 3. Fund and claim both trigger TTL extension.
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(Contract, ());
let client = ContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let token_admin = Address::generate(&env);
let token = create_token(&env, &token_admin);
let token_admin_client = token::StellarAssetClient::new(&env, &token);
let root = real_root(&env);
let vk = real_verification_key(&env);

// Round 0: create and claim
client.create_circle(&admin, &token, &root, &100i128, &5u32, &vk);

// Advance ledger by LEDGER_THRESHOLD to trigger TTL re-extension logic
// (if extend_ttl is working, the next operation will succeed).
env.ledger().with_mut(|l| {
l.sequence_number += LEDGER_THRESHOLD;
l.timestamp += u64::from(LEDGER_THRESHOLD) * 5;
l.min_persistent_entry_ttl = LEDGER_THRESHOLD;
l.min_temp_entry_ttl = LEDGER_THRESHOLD;
});

// Fund round 0 (5 members)
for i in 0..5 {
let m = Address::generate(&env);
token_admin_client.mint(&m, &100i128);
client.fund(&0u64, &m);

// Micro-advance ledger between fundings to simulate real chain
env.ledger().with_mut(|l| {
l.sequence_number += 1;
l.timestamp += 5;
});
}

// Advance again before claim
env.ledger().with_mut(|l| {
l.sequence_number += LEDGER_THRESHOLD;
l.timestamp += u64::from(LEDGER_THRESHOLD) * 5;
});

// Claim round 0 (should succeed; Circle TTL was refreshed by fund calls)
let recipient_0 = Address::generate(&env);
client.claim(
&0u64,
&recipient_0,
&real_nullifier_hash(&env),
&real_external_nullifier_round0(&env),
&real_valid_proof(&env),
);

let circle = client.get_circle(&0u64);
assert_eq!(circle.round, 1, "round should advance to 1 after first claim");
assert_eq!(circle.pot, 0, "pot should reset to 0 after claim");

// Advance ledger again
env.ledger().with_mut(|l| {
l.sequence_number += LEDGER_THRESHOLD;
l.timestamp += u64::from(LEDGER_THRESHOLD) * 5;
l.min_persistent_entry_ttl = LEDGER_THRESHOLD;
l.min_temp_entry_ttl = LEDGER_THRESHOLD;
});

// Round 1: fund and claim again to verify persistence and re-extension
for i in 0..5 {
let m = Address::generate(&env);
token_admin_client.mint(&m, &100i128);
client.fund(&0u64, &m);

env.ledger().with_mut(|l| {
l.sequence_number += 1;
l.timestamp += 5;
});
}

env.ledger().with_mut(|l| {
l.sequence_number += LEDGER_THRESHOLD;
l.timestamp += u64::from(LEDGER_THRESHOLD) * 5;
});

// Round 1 claim (verifies nullifier map was also persisted and TTL extended)
let recipient_1 = Address::generate(&env);
let nullifier_round1 = round_reuse_nullifier_hash_round1(&env);
let proof_round1 = round_reuse_proof_round1(&env);

client.claim(
&0u64,
&recipient_1,
&nullifier_round1,
&expected_external_nullifier(&env, 0u64, 1),
&proof_round1,
);

let circle_after = client.get_circle(&0u64);
assert_eq!(
circle_after.round, 2,
"round should advance to 2 after second claim"
);
assert_eq!(circle_after.pot, 0, "pot should remain 0 after second claim");
}

#[test]
fn circle_and_nullifier_entries_individually_extended() {
// Scenario: verify that BOTH the Circle persistent entry AND the Nullifier
// persistent entry are extended independently. A single claim operation
// writes to both keys, and both must have TTL extended.
//
// This test pins:
// 1. Circle(id) gets extend_ttl called.
// 2. Nullifier(id, hash) gets extend_ttl called.
// 3. After advancement past LEDGER_THRESHOLD, both remain accessible.
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(Contract, ());
let client = ContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let token_admin = Address::generate(&env);
let token = create_token(&env, &token_admin);
let token_admin_client = token::StellarAssetClient::new(&env, &token);
let root = real_root(&env);
let vk = real_verification_key(&env);

client.create_circle(&admin, &token, &root, &100i128, &5u32, &vk);

// Fund and claim (writes to both Circle and Nullifier)
for _ in 0..5 {
let m = Address::generate(&env);
token_admin_client.mint(&m, &100i128);
client.fund(&0u64, &m);
}

let recipient = Address::generate(&env);
let nullifier_hash = real_nullifier_hash(&env);
client.claim(
&0u64,
&recipient,
&nullifier_hash,
&real_external_nullifier_round0(&env),
&real_valid_proof(&env),
);

// Advance past LEDGER_THRESHOLD
env.ledger().with_mut(|l| {
l.sequence_number += LEDGER_THRESHOLD + 50;
l.timestamp += u64::from(LEDGER_THRESHOLD + 50) * 5;
l.min_persistent_entry_ttl = LEDGER_THRESHOLD;
l.min_temp_entry_ttl = LEDGER_THRESHOLD;
});

// If Nullifier entry wasn't extended: has_claimed would fail (entry expired).
// If Circle entry wasn't extended: get_circle would fail.
assert!(
client.has_claimed(&0u64, &nullifier_hash),
"Nullifier entry should still be accessible after ledger advancement"
);
let circle = client.get_circle(&0u64);
assert_eq!(
circle.round, 1,
"Circle entry should still be accessible after ledger advancement"
);
}

#[test]
fn ttl_survives_fund_after_ledger_advance() {
// Scenario: create a circle, advance ledger past LEDGER_THRESHOLD, then
// fund and verify the circle entry is still readable and TTL is refreshed.
//
// This test pins:
// - Advance past threshold does NOT expire the entry if extend_ttl was
// called at creation.
// - Fund after threshold-advance does trigger re-extension.
let env = Env::default();
env.mock_all_auths();

let contract_id = env.register(Contract, ());
let client = ContractClient::new(&env, &contract_id);

let admin = Address::generate(&env);
let token_admin = Address::generate(&env);
let token = create_token(&env, &token_admin);
let token_admin_client = token::StellarAssetClient::new(&env, &token);
let root = real_root(&env);
let vk = real_verification_key(&env);

client.create_circle(&admin, &token, &root, &100i128, &5u32, &vk);

// Advance by LEDGER_THRESHOLD (threshold itself) and then some
env.ledger().with_mut(|l| {
l.sequence_number += LEDGER_THRESHOLD + 25;
l.timestamp += u64::from(LEDGER_THRESHOLD + 25) * 5;
l.min_persistent_entry_ttl = LEDGER_THRESHOLD;
l.min_temp_entry_ttl = LEDGER_THRESHOLD;
});

// Fund should succeed; the circle entry should still be accessible.
let member = Address::generate(&env);
token_admin_client.mint(&member, &100i128);
client.fund(&0u64, &member);

// Verify the circle is readable and pot was updated.
let circle = client.get_circle(&0u64);
assert_eq!(circle.pot, 100, "fund should have incremented pot");

// Advance again to re-test that multiple TTL extensions work
env.ledger().with_mut(|l| {
l.sequence_number += LEDGER_THRESHOLD + 25;
l.timestamp += u64::from(LEDGER_THRESHOLD + 25) * 5;
});

// Another fund should still work
let member2 = Address::generate(&env);
token_admin_client.mint(&member2, &100i128);
client.fund(&0u64, &member2);

let circle = client.get_circle(&0u64);
assert_eq!(circle.pot, 200, "second fund should have incremented pot to 200");
}

// ---- Proptest: apply_fee rounding invariant ----
//
// For every (amount, fee_bps) pair in the valid domain, the split must be
Expand Down
Loading