diff --git a/CHANGELOG.md b/CHANGELOG.md index 63e2c9c..58b9c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,6 +70,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Issue #17: Platform Fee Mechanism - Added configurable fee basis points and fee recipient - Fee is deducted from each tip and sent to the recipient immediately +- Issue #87: Fee Recipient Compromise Runbook Test + - Added adversarial test `test_fee_recipient_compromise_response_runbook` + that validates the full admin-key-compromise recovery sequence (pause → + rotate admin → restore fee recipient → unpause → verify fee routing) + documented in `docs/ADMIN_RUNBOOK.md` - Issue #20: Profile Updates & Account Deletion - Added `update_profile()` for in-place profile edits - Added `unregister()` that requires zero balance across all tokens diff --git a/docs/ADMIN_RUNBOOK.md b/docs/ADMIN_RUNBOOK.md index 725703b..05435ac 100644 --- a/docs/ADMIN_RUNBOOK.md +++ b/docs/ADMIN_RUNBOOK.md @@ -357,6 +357,7 @@ cargo test test_set_admin cargo test test_set_fee_percentage cargo test test_set_fee_recipient cargo test unauthorized +cargo test test_fee_recipient_compromise_response_runbook ``` Expected result for each command: exit status `0` and a final line containing diff --git a/src/test.rs b/src/test.rs index 49bad2b..10217c4 100644 --- a/src/test.rs +++ b/src/test.rs @@ -2192,3 +2192,77 @@ fn gas_init() { assert_eq!(client.get_fee_percentage(), 500); assert!(used <= GAS_INIT_MAX, "init() consumed {used} CPU insns, limit is {GAS_INIT_MAX}"); } + +// --------------------------------------------------------------------------- +// Fee-recipient compromise adversarial test (issue #87) +// --------------------------------------------------------------------------- +// +// Cross-referenced from docs/ADMIN_RUNBOOK.md. This test validates the +// end-to-end recovery runbook for a compromised admin key that changed the +// fee recipient to an attacker-controlled address. + +/// Full recovery runbook: pause → rotate admin → restore fee recipient → +/// unpause → verify future tips route fees to the safe address. +#[test] +fn test_fee_recipient_compromise_response_runbook() { + // Set up with a non-zero fee so fee routing is observable. + let t = TestEnv::new_with_fee(500); // 5% + + let alice = Address::generate(&t.env); + let bob = Address::generate(&t.env); + + // Register a creator and send a pre-compromise tip. + t.tip_client().register( + &alice, + &Symbol::new(&t.env, "alice"), + &s(&t.env, "Alice"), + &s(&t.env, "Writer"), + ); + t.stellar_client().mint(&bob, &10_000); + + t.tip_client().tip(&bob, &alice, &t.token_id, &1_000, &s(&t.env, "pre-recovery")); + + // Sanity: original fee recipient got 50 (5% of 1000). + assert_eq!(t.tip_client().get_balance(&alice, &t.token_id), 950); + let orig_recipient_balance = t.token_client().balance(&t.fee_recipient); + assert_eq!(orig_recipient_balance, 50); + + // ── Step 1: Unauthorized set_fee_recipient panics with #11. ── + // The existing standalone test `test_set_fee_recipient_unauthorized_fails` + // already asserts this behaviour (NotAuthorized = #11). We rely on that + // coverage rather than duplicating it here, and proceed directly to the + // recovery sequence below. + + // ── Step 2: Admin pauses the contract (emergency stop). ── + t.tip_client().pause(&t.admin); + assert!(t.tip_client().is_paused()); + + // ── Step 3: Rotate admin to a new, safe address. ── + let new_admin = Address::generate(&t.env); + t.tip_client().set_admin(&t.admin, &new_admin); + assert_eq!(t.tip_client().get_admin(), Some(new_admin.clone())); + + // ── Step 4: New admin restores fee recipient to the approved address. ── + let safe_recipient = Address::generate(&t.env); + t.tip_client().set_fee_recipient(&new_admin, &safe_recipient); + assert_eq!(t.tip_client().get_fee_recipient(), Some(safe_recipient.clone())); + + // ── Step 5: New admin unpauses the contract. ── + t.tip_client().unpause(&new_admin); + assert!(!t.tip_client().is_paused()); + + // ── Step 6: Verify the next tip routes its fee to the safe recipient. ── + let charlie = Address::generate(&t.env); + t.stellar_client().mint(&charlie, &10_000); + + t.tip_client().tip(&charlie, &alice, &t.token_id, &1_000, &s(&t.env, "post-recovery")); + + // Creator gets another 950 (cumulative 1_900). + assert_eq!(t.tip_client().get_balance(&alice, &t.token_id), 1_900); + + // Fee for the post-recovery tip went to the safe recipient. + assert_eq!(t.token_client().balance(&safe_recipient), 50); + + // Original fee recipient balance is unchanged after recovery. + assert_eq!(t.token_client().balance(&t.fee_recipient), orig_recipient_balance); +}