Skip to main content

polyoxide_relay/
types.rs

1use alloy::sol;
2use serde::{Deserialize, Serialize};
3
4/// Wallet type for the relayer API
5#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
6pub enum WalletType {
7    /// Safe wallet - requires explicit deployment before first transaction
8    #[default]
9    Safe,
10    /// Proxy wallet - auto-deploys on first transaction (Magic Link users)
11    Proxy,
12}
13
14impl WalletType {
15    /// Returns the API string representation ("SAFE" or "PROXY").
16    pub fn as_str(&self) -> &'static str {
17        match self {
18            WalletType::Safe => "SAFE",
19            WalletType::Proxy => "PROXY",
20        }
21    }
22}
23
24sol! {
25    /// A single transaction to execute through the relayer's Safe.
26    ///
27    /// This is the primary input to the relay client's `execute*` paths: one or
28    /// more of these are batched, signed, and submitted to `POST /submit`.
29    #[derive(Debug, PartialEq, Eq)]
30    struct SafeTransaction {
31        /// Target contract or recipient address.
32        address to;
33        /// Call type: `0` = CALL, `1` = DELEGATECALL.
34        uint8 operation;
35        /// ABI-encoded calldata for the call (empty for a plain value transfer).
36        bytes data;
37        /// Native token amount (in wei) to send with the call.
38        uint256 value;
39    }
40
41    #[derive(Debug, PartialEq, Eq)]
42    struct SafeTransactionArgs {
43        address from_address;
44        uint256 nonce;
45        uint256 chain_id;
46        SafeTransaction[] transactions;
47    }
48
49    /// The Gnosis Safe `execTransaction` payload that is EIP-712 signed.
50    ///
51    /// Mirrors the canonical Safe transaction struct; the relay client builds and
52    /// signs this from a [`SafeTransaction`] before submitting it.
53    #[derive(Debug, PartialEq, Eq)]
54    struct SafeTx {
55        /// Destination address of the Safe transaction.
56        address to;
57        /// Native token amount (in wei) transferred with the call.
58        uint256 value;
59        /// ABI-encoded calldata for the call.
60        bytes data;
61        /// Call type: `0` = CALL, `1` = DELEGATECALL.
62        uint8 operation;
63        /// Gas that should be used for the Safe transaction itself.
64        uint256 safeTxGas;
65        /// Gas costs independent of execution (base fee, signature checks, refund).
66        uint256 baseGas;
67        /// Gas price used for the refund calculation (`0` to disable refunds).
68        uint256 gasPrice;
69        /// Token used for the gas payment (`0x0` = native token).
70        address gasToken;
71        /// Address that receives the gas payment refund (`0x0` = `tx.origin`).
72        address refundReceiver;
73        /// Safe nonce for replay protection.
74        uint256 nonce;
75    }
76}
77
78/// Partial, legacy representation of a relayer submission payload.
79///
80/// This type is **not** the actual wire body sent to the relayer: the real
81/// `POST /submit` payloads are the private `SafeSubmitBody` / `ProxySubmitBody`
82/// structs in `client.rs` (built and serialized by the relay client's
83/// `execute_safe` / `execute_proxy` paths), which additionally carry
84/// `signatureParams`, `nonce`, `value`, and `metadata`. This struct is retained
85/// only for backwards compatibility and is exercised solely by its own serde
86/// unit tests.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct TransactionRequest {
89    #[serde(rename = "type")]
90    pub type_: String,
91    pub from: String,
92    pub to: String,
93    #[serde(rename = "proxyWallet")]
94    pub proxy_wallet: String,
95    pub data: String,
96    pub signature: String,
97    // Add signature params if needed
98}
99
100/// Response from `POST /submit` after a transaction submission.
101///
102/// The OpenAPI spec guarantees only `transactionID` and `state`; the onchain
103/// transaction hash must be fetched later via `GET /transaction`.
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct SubmitResponse {
106    #[serde(rename = "transactionID")]
107    pub transaction_id: String,
108    /// Current state of the transaction (e.g. `STATE_NEW`).
109    pub state: String,
110}
111
112/// Full relayer transaction record returned by `GET /transaction`.
113///
114/// Mirrors the OpenAPI `RelayerTransaction` schema.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase")]
117pub struct RelayerTransaction {
118    #[serde(rename = "transactionID")]
119    pub transaction_id: String,
120    pub transaction_hash: Option<String>,
121    pub from: Option<String>,
122    pub to: Option<String>,
123    pub proxy_address: Option<String>,
124    pub data: Option<String>,
125    pub nonce: Option<String>,
126    pub value: Option<String>,
127    pub signature: Option<String>,
128    /// Current state (e.g. `STATE_NEW`, `STATE_MINED`, `STATE_CONFIRMED`, ...).
129    pub state: String,
130    /// Transaction type (`SAFE` or `PROXY`).
131    #[serde(rename = "type")]
132    pub kind: Option<String>,
133    pub owner: Option<String>,
134    pub metadata: Option<String>,
135    pub created_at: Option<String>,
136    pub updated_at: Option<String>,
137}
138
139/// Deserialize a nonce that may be represented as either a JSON number or string.
140pub fn deserialize_nonce<'de, D>(deserializer: D) -> Result<u64, D::Error>
141where
142    D: serde::Deserializer<'de>,
143{
144    use serde::de;
145
146    struct NonceVisitor;
147
148    impl<'de> de::Visitor<'de> for NonceVisitor {
149        type Value = u64;
150
151        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
152            formatter.write_str("a u64 or string representing a u64")
153        }
154
155        fn visit_u64<E>(self, v: u64) -> Result<Self::Value, E> {
156            Ok(v)
157        }
158
159        fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
160        where
161            E: de::Error,
162        {
163            v.parse().map_err(de::Error::custom)
164        }
165    }
166
167    deserializer.deserialize_any(NonceVisitor)
168}
169
170/// Response from the relayer's nonce endpoint.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct NonceResponse {
173    #[serde(deserialize_with = "deserialize_nonce")]
174    pub nonce: u64,
175}
176
177/// A relayer API key record returned by `GET /relayer/api/keys`.
178///
179/// Mirrors the OpenAPI `RelayerApiKey` schema.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase")]
182pub struct RelayerApiKey {
183    /// The relayer API key identifier (UUID).
184    pub api_key: String,
185    /// The on-chain address that owns this key.
186    pub address: String,
187    /// RFC3339 timestamp when the key was created.
188    pub created_at: String,
189    /// RFC3339 timestamp when the key was last updated.
190    pub updated_at: String,
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    // ── deserialize_nonce ───────────────────────────────────────
198
199    #[test]
200    fn test_nonce_from_integer() {
201        let json = r#"{"nonce": 42}"#;
202        let resp: NonceResponse = serde_json::from_str(json).unwrap();
203        assert_eq!(resp.nonce, 42);
204    }
205
206    #[test]
207    fn test_nonce_from_string() {
208        let json = r#"{"nonce": "123"}"#;
209        let resp: NonceResponse = serde_json::from_str(json).unwrap();
210        assert_eq!(resp.nonce, 123);
211    }
212
213    #[test]
214    fn test_nonce_from_zero_integer() {
215        let json = r#"{"nonce": 0}"#;
216        let resp: NonceResponse = serde_json::from_str(json).unwrap();
217        assert_eq!(resp.nonce, 0);
218    }
219
220    #[test]
221    fn test_nonce_from_zero_string() {
222        let json = r#"{"nonce": "0"}"#;
223        let resp: NonceResponse = serde_json::from_str(json).unwrap();
224        assert_eq!(resp.nonce, 0);
225    }
226
227    #[test]
228    fn test_nonce_from_large_integer() {
229        let json = r#"{"nonce": 18446744073709551615}"#;
230        let resp: NonceResponse = serde_json::from_str(json).unwrap();
231        assert_eq!(resp.nonce, u64::MAX);
232    }
233
234    #[test]
235    fn test_nonce_from_large_string() {
236        let json = r#"{"nonce": "18446744073709551615"}"#;
237        let resp: NonceResponse = serde_json::from_str(json).unwrap();
238        assert_eq!(resp.nonce, u64::MAX);
239    }
240
241    #[test]
242    fn test_nonce_from_non_numeric_string_fails() {
243        let json = r#"{"nonce": "abc"}"#;
244        let result = serde_json::from_str::<NonceResponse>(json);
245        assert!(result.is_err());
246    }
247
248    #[test]
249    fn test_nonce_from_empty_string_fails() {
250        let json = r#"{"nonce": ""}"#;
251        let result = serde_json::from_str::<NonceResponse>(json);
252        assert!(result.is_err());
253    }
254
255    #[test]
256    fn test_nonce_from_null_fails() {
257        let json = r#"{"nonce": null}"#;
258        let result = serde_json::from_str::<NonceResponse>(json);
259        assert!(result.is_err());
260    }
261
262    #[test]
263    fn test_nonce_missing_field_fails() {
264        let json = r#"{}"#;
265        let result = serde_json::from_str::<NonceResponse>(json);
266        assert!(result.is_err());
267    }
268
269    // ── WalletType ──────────────────────────────────────────────
270
271    #[test]
272    fn test_wallet_type_as_str() {
273        assert_eq!(WalletType::Safe.as_str(), "SAFE");
274        assert_eq!(WalletType::Proxy.as_str(), "PROXY");
275    }
276
277    #[test]
278    fn test_wallet_type_default_is_safe() {
279        assert_eq!(WalletType::default(), WalletType::Safe);
280    }
281
282    // ── TransactionRequest serde ────────────────────────────────
283
284    #[test]
285    fn test_transaction_request_serialization() {
286        let tx = TransactionRequest {
287            type_: "SAFE".to_string(),
288            from: "0xabc".to_string(),
289            to: "0xdef".to_string(),
290            proxy_wallet: "0x123".to_string(),
291            data: "0xdeadbeef".to_string(),
292            signature: "0xsig".to_string(),
293        };
294        let json = serde_json::to_value(&tx).unwrap();
295        assert_eq!(json["type"], "SAFE");
296        assert_eq!(json["from"], "0xabc");
297        assert_eq!(json["proxyWallet"], "0x123");
298    }
299
300    #[test]
301    fn test_transaction_request_deserialization() {
302        let json = r#"{
303            "type": "PROXY",
304            "from": "0xabc",
305            "to": "0xdef",
306            "proxyWallet": "0x123",
307            "data": "0xdeadbeef",
308            "signature": "0xsig"
309        }"#;
310        let tx: TransactionRequest = serde_json::from_str(json).unwrap();
311        assert_eq!(tx.type_, "PROXY");
312        assert_eq!(tx.proxy_wallet, "0x123");
313    }
314
315    // ── SubmitResponse serde ────────────────────────────────────
316
317    #[test]
318    fn test_submit_response_new_state() {
319        let json = r#"{
320            "transactionID": "tx-123",
321            "state": "STATE_NEW"
322        }"#;
323        let resp: SubmitResponse = serde_json::from_str(json).unwrap();
324        assert_eq!(resp.transaction_id, "tx-123");
325        assert_eq!(resp.state, "STATE_NEW");
326    }
327
328    // ── RelayerTransaction serde ────────────────────────────────
329
330    #[test]
331    fn test_relayer_transaction_full() {
332        let json = r#"{
333            "transactionID": "0190b317-a1d3-7bec-9b91-eeb6dcd3a620",
334            "transactionHash": "0x38cbfbeae8fffa4e2b187ee5978d3ee9cafc53af0363ed90a35b7ea9016535d8",
335            "from": "0x6e0c80c90ea6c15917308f820eac91ce2724b5b5",
336            "to": "0x2791bca1f2de4661ed88a30c99a7a9449aa84174",
337            "proxyAddress": "0x6d8c4e9adf5748af82dabe2c6225207770d6b4fa",
338            "data": "0xdeadbeef",
339            "nonce": "60",
340            "value": "",
341            "signature": "0xabc",
342            "state": "STATE_CONFIRMED",
343            "type": "SAFE",
344            "owner": "0x6e0c80c90ea6c15917308f820eac91ce2724b5b5",
345            "metadata": "",
346            "createdAt": "2024-07-14T21:13:08.819782Z",
347            "updatedAt": "2024-07-14T21:13:46.576639Z"
348        }"#;
349        let resp: RelayerTransaction = serde_json::from_str(json).unwrap();
350        assert_eq!(resp.transaction_id, "0190b317-a1d3-7bec-9b91-eeb6dcd3a620");
351        assert_eq!(resp.state, "STATE_CONFIRMED");
352        assert_eq!(resp.kind.as_deref(), Some("SAFE"));
353        assert_eq!(resp.nonce.as_deref(), Some("60"));
354        assert!(resp.transaction_hash.is_some());
355        assert!(resp.created_at.is_some());
356    }
357
358    #[test]
359    fn test_relayer_transaction_pending_minimal() {
360        let json = r#"{
361            "transactionID": "tx-new",
362            "state": "STATE_NEW"
363        }"#;
364        let resp: RelayerTransaction = serde_json::from_str(json).unwrap();
365        assert_eq!(resp.transaction_id, "tx-new");
366        assert_eq!(resp.state, "STATE_NEW");
367        assert!(resp.transaction_hash.is_none());
368        assert!(resp.kind.is_none());
369    }
370
371    // ── RelayerApiKey serde ─────────────────────────────────────
372
373    #[test]
374    fn test_relayer_api_key_deserializes_openapi_example() {
375        // Example lifted from docs/specs/relay/openapi.yaml for
376        // `/relayer/api/keys` response schema.
377        let json = r#"{
378            "apiKey": "01967c03-b8c8-7000-8f68-8b8eaec6fd3d",
379            "address": "0xabc...",
380            "createdAt": "2026-02-24T18:20:11.237485Z",
381            "updatedAt": "2026-02-24T18:20:11.237485Z"
382        }"#;
383        let key: RelayerApiKey = serde_json::from_str(json).unwrap();
384        assert_eq!(key.api_key, "01967c03-b8c8-7000-8f68-8b8eaec6fd3d");
385        assert_eq!(key.address, "0xabc...");
386        assert_eq!(key.created_at, "2026-02-24T18:20:11.237485Z");
387        assert_eq!(key.updated_at, "2026-02-24T18:20:11.237485Z");
388    }
389
390    #[test]
391    fn test_relayer_api_key_roundtrip_preserves_camel_case() {
392        let key = RelayerApiKey {
393            api_key: "abc".to_string(),
394            address: "0xdef".to_string(),
395            created_at: "2026-01-01T00:00:00Z".to_string(),
396            updated_at: "2026-01-02T00:00:00Z".to_string(),
397        };
398        let serialized = serde_json::to_value(&key).unwrap();
399        assert_eq!(serialized["apiKey"], "abc");
400        assert_eq!(serialized["address"], "0xdef");
401        assert_eq!(serialized["createdAt"], "2026-01-01T00:00:00Z");
402        assert_eq!(serialized["updatedAt"], "2026-01-02T00:00:00Z");
403
404        let round: RelayerApiKey = serde_json::from_value(serialized).unwrap();
405        assert_eq!(round.api_key, "abc");
406        assert_eq!(round.updated_at, "2026-01-02T00:00:00Z");
407    }
408
409    #[test]
410    fn test_relayer_api_key_list_deserializes() {
411        let json = r#"[
412            {
413                "apiKey": "key-1",
414                "address": "0xa1",
415                "createdAt": "2026-01-01T00:00:00Z",
416                "updatedAt": "2026-01-01T00:00:00Z"
417            },
418            {
419                "apiKey": "key-2",
420                "address": "0xa2",
421                "createdAt": "2026-01-02T00:00:00Z",
422                "updatedAt": "2026-01-02T00:00:00Z"
423            }
424        ]"#;
425        let keys: Vec<RelayerApiKey> = serde_json::from_str(json).unwrap();
426        assert_eq!(keys.len(), 2);
427        assert_eq!(keys[0].api_key, "key-1");
428        assert_eq!(keys[1].api_key, "key-2");
429    }
430}