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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/ADMIN_RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}