/home/runner/work/lyquor/lyquor/lyquid-agent/src/harness/auth.rs
Line | Count | Source |
1 | | //! Caller authentication helpers for service-shaped Lyquids. |
2 | | //! |
3 | | //! Service Lyquids expose state-changing methods to two distinct categories of caller: |
4 | | //! |
5 | | //! 1. **Lyquid orchestrator** — another Lyquid (the caller in a UPC chain) is allowed to |
6 | | //! submit intents. Authenticate with [`assert_authorized_lyquid_caller`], passing a |
7 | | //! `RequiredLyquid` field from the service's state. |
8 | | //! 2. **EOA driver** — an off-chain runtime holding an EVM key records plans and drives |
9 | | //! operations. Authenticate with [`assert_authorized_eoa_caller`], passing an |
10 | | //! `Address` field. |
11 | | //! |
12 | | //! Both helpers reject the zero/default address; services should refuse state mutation |
13 | | //! until init explicitly seats the authorized identity. The `role` argument shows up in |
14 | | //! the error message ("expected authorized orchestrator") for grep-friendly debugging. |
15 | | //! |
16 | | //! These are deliberately plain functions rather than macros — trust boundaries should |
17 | | //! be visible at the call site in code review, not hidden inside an attribute. |
18 | | |
19 | | use lyquor_primitives::{Address, LyquidID, RequiredLyquid}; |
20 | | |
21 | | /// Returns `Ok(())` iff `caller` matches the Lyquid pinned in `expected`. Rejects an |
22 | | /// uninitialized (`RequiredLyquid::default()`) `expected` so a service refuses caller- |
23 | | /// gated methods until init has explicitly seated the orchestrator. |
24 | 3 | pub fn assert_authorized_lyquid_caller(caller: Address, expected: &RequiredLyquid, role: &str) -> Result<(), String> { |
25 | 3 | if expected.0 == LyquidID::default() { |
26 | 1 | return Err(format!("{role} not initialized")); |
27 | 2 | } |
28 | 2 | let expected_addr = Address::from(expected.0); |
29 | 2 | if caller != expected_addr { |
30 | 1 | return Err(format!( |
31 | 1 | "unauthorized caller {caller}; expected authorized {role} {expected_addr}" |
32 | 1 | )); |
33 | 1 | } |
34 | 1 | Ok(()) |
35 | 3 | } |
36 | | |
37 | | /// Returns `Ok(())` iff `caller == expected`. Rejects `Address::default()` so a service |
38 | | /// refuses caller-gated methods until init has explicitly seated the EOA. |
39 | 3 | pub fn assert_authorized_eoa_caller(caller: Address, expected: Address, role: &str) -> Result<(), String> { |
40 | 3 | if expected == Address::default() { |
41 | 1 | return Err(format!("{role} not initialized")); |
42 | 2 | } |
43 | 2 | if caller != expected { |
44 | 1 | return Err(format!( |
45 | 1 | "unauthorized caller {caller}; expected authorized {role} {expected}" |
46 | 1 | )); |
47 | 1 | } |
48 | 1 | Ok(()) |
49 | 3 | } |
50 | | |
51 | | /// Returns `Ok(())` iff `caller == deployer`. Used to gate admin-style ABIs |
52 | | /// (`set_self_dispatch_enabled`, `set_authorized_orchestrator`, etc.) so only the |
53 | | /// account that constructed the Lyquid can flip them. `action` shows up in the error |
54 | | /// message ("only deployer can configure self dispatch") for grep-friendly debugging. |
55 | 2 | pub fn assert_deployer_caller(caller: Address, deployer: Address, action: &str) -> Result<(), String> { |
56 | 2 | if caller != deployer { |
57 | 1 | return Err(format!( |
58 | 1 | "only deployer can {action}; caller={caller}, deployer={deployer}" |
59 | 1 | )); |
60 | 1 | } |
61 | 1 | Ok(()) |
62 | 2 | } |
63 | | |
64 | | #[cfg(test)] |
65 | | mod tests { |
66 | | use super::*; |
67 | | |
68 | 2 | fn lyquid(b: u8) -> RequiredLyquid { |
69 | 2 | RequiredLyquid(LyquidID([b; 20])) |
70 | 2 | } |
71 | | |
72 | 9 | fn addr_from_byte(b: u8) -> Address { |
73 | 9 | Address::from(LyquidID([b; 20])) |
74 | 9 | } |
75 | | |
76 | | #[test] |
77 | 1 | fn lyquid_caller_rejects_uninitialized() { |
78 | 1 | let caller = addr_from_byte(0x11); |
79 | 1 | let err = assert_authorized_lyquid_caller(caller, &RequiredLyquid::default(), "orchestrator") |
80 | 1 | .expect_err("default expected must be rejected"); |
81 | 1 | assert!(err.contains("orchestrator not initialized"), "got: {err}"); |
82 | 1 | } |
83 | | |
84 | | #[test] |
85 | 1 | fn lyquid_caller_rejects_mismatch() { |
86 | 1 | let expected = lyquid(0xaa); |
87 | 1 | let wrong = addr_from_byte(0xbb); |
88 | 1 | let err = |
89 | 1 | assert_authorized_lyquid_caller(wrong, &expected, "orchestrator").expect_err("mismatch must be rejected"); |
90 | 1 | assert!(err.contains("unauthorized caller"), "got: {err}"); |
91 | 1 | assert!(err.contains("orchestrator"), "role missing from error: {err}"); |
92 | 1 | } |
93 | | |
94 | | #[test] |
95 | 1 | fn lyquid_caller_accepts_match() { |
96 | 1 | let expected = lyquid(0xcc); |
97 | 1 | let matching = Address::from(expected.0); |
98 | 1 | assert_authorized_lyquid_caller(matching, &expected, "orchestrator").expect("match must accept"); |
99 | 1 | } |
100 | | |
101 | | #[test] |
102 | 1 | fn eoa_caller_rejects_uninitialized() { |
103 | 1 | let caller = addr_from_byte(0x11); |
104 | 1 | let err = assert_authorized_eoa_caller(caller, Address::default(), "driver") |
105 | 1 | .expect_err("default expected must be rejected"); |
106 | 1 | assert!(err.contains("driver not initialized"), "got: {err}"); |
107 | 1 | } |
108 | | |
109 | | #[test] |
110 | 1 | fn eoa_caller_rejects_mismatch() { |
111 | 1 | let expected = addr_from_byte(0x11); |
112 | 1 | let wrong = addr_from_byte(0x22); |
113 | 1 | let err = assert_authorized_eoa_caller(wrong, expected, "driver").expect_err("mismatch must be rejected"); |
114 | 1 | assert!(err.contains("unauthorized caller"), "got: {err}"); |
115 | 1 | assert!(err.contains("driver"), "role missing from error: {err}"); |
116 | 1 | } |
117 | | |
118 | | #[test] |
119 | 1 | fn eoa_caller_accepts_match() { |
120 | 1 | let expected = addr_from_byte(0x33); |
121 | 1 | assert_authorized_eoa_caller(expected, expected, "driver").expect("match must accept"); |
122 | 1 | } |
123 | | |
124 | | #[test] |
125 | 1 | fn deployer_caller_rejects_mismatch() { |
126 | 1 | let deployer = addr_from_byte(0x44); |
127 | 1 | let wrong = addr_from_byte(0x55); |
128 | 1 | let err = |
129 | 1 | assert_deployer_caller(wrong, deployer, "configure self dispatch").expect_err("mismatch must be rejected"); |
130 | 1 | assert!(err.contains("only deployer can configure self dispatch"), "got: {err}"); |
131 | 1 | assert!(err.contains("caller="), "caller missing from error: {err}"); |
132 | 1 | } |
133 | | |
134 | | #[test] |
135 | 1 | fn deployer_caller_accepts_match() { |
136 | 1 | let deployer = addr_from_byte(0x66); |
137 | 1 | assert_deployer_caller(deployer, deployer, "configure").expect("match must accept"); |
138 | 1 | } |
139 | | } |