1use alloy_primitives::{Address, B256, Bytes, U256};
4use alloy_sol_types::{SolCall, sol};
5
6use crate::errors::{Error, Result};
7
8sol! {
9 #[derive(Debug, PartialEq, Eq)]
10 struct WithdrawRequest {
11 uint16 chainId;
12 address zToken;
13 bytes withdrawDestination;
14 uint256 zAmount;
15 uint256 maxFee;
16 }
17
18 #[derive(Debug, PartialEq, Eq)]
19 struct GuardApprovalTuple {
20 uint192 nonceSpace;
21 uint256 deadline;
22 bytes signature;
23 }
24
25 function deposit(bytes32 uAssetId, uint256 uAmount);
26 function depositTo(address toAccount, bytes32 uAssetId, uint256 uAmount);
27 function withdrawToChain(WithdrawRequest request);
28 function setExternalDestinationAllowlistRequired(bool required, GuardApprovalTuple guardSigIfFalse);
29 function setInternalAccountAllowlistRequired(bool required, GuardApprovalTuple guardSigIfFalse);
30 function addAllowedExternalDestinations(
31 uint16 chainId,
32 bytes[] destinations,
33 GuardApprovalTuple approval
34 );
35 function removeAllowedExternalDestinations(
36 uint16 chainId,
37 bytes[] destinations,
38 GuardApprovalTuple approval
39 );
40 function addAllowedInternalAccounts(address[] accounts, GuardApprovalTuple approval);
41 function removeAllowedInternalAccounts(address[] accounts, GuardApprovalTuple approval);
42 function initializeSigner(address signer);
43 function rotateSigner(address newSigner, GuardApprovalTuple approval);
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ChainCall {
49 pub to: String,
50 pub data: Vec<u8>,
51 pub value: u128,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Default)]
56pub struct GuardApproval {
57 pub nonce_space: U256,
58 pub deadline: U256,
59 pub signature: Vec<u8>,
60}
61
62pub fn encode_trading_gateway_deposit(
64 trading_gateway: &str,
65 u_asset_id: &str,
66 quantity_scaled: U256,
67) -> Result<ChainCall> {
68 if quantity_scaled.is_zero() {
69 return Err(Error::validation("quantity_scaled must be > 0"));
70 }
71 let to = normalize_address(trading_gateway, "trading_gateway")?;
72 let asset = normalize_bytes32(u_asset_id, "u_asset_id")?;
73 let data = depositCall {
74 uAssetId: asset,
75 uAmount: quantity_scaled,
76 }
77 .abi_encode();
78 Ok(ChainCall { to, data, value: 0 })
79}
80
81pub fn encode_trading_gateway_deposit_to(
83 trading_gateway: &str,
84 to_account: &str,
85 u_asset_id: &str,
86 quantity_scaled: U256,
87) -> Result<ChainCall> {
88 if quantity_scaled.is_zero() {
89 return Err(Error::validation("quantity_scaled must be > 0"));
90 }
91 let to = normalize_address(trading_gateway, "trading_gateway")?;
92 let account = parse_address(to_account, "to_account")?;
93 let asset = normalize_bytes32(u_asset_id, "u_asset_id")?;
94 let data = depositToCall {
95 toAccount: account,
96 uAssetId: asset,
97 uAmount: quantity_scaled,
98 }
99 .abi_encode();
100 Ok(ChainCall { to, data, value: 0 })
101}
102
103pub fn encode_funding_withdraw_to_chain(
105 funding_account: &str,
106 chain_id: u16,
107 z_token: &str,
108 withdraw_destination: &[u8],
109 z_amount: U256,
110 max_fee: U256,
111) -> Result<ChainCall> {
112 if chain_id == 0 {
113 return Err(Error::validation("chain_id must be a uint16 > 0"));
114 }
115 if z_amount.is_zero() {
116 return Err(Error::validation("z_amount must be > 0"));
117 }
118 if z_amount <= max_fee {
119 return Err(Error::validation("z_amount must be greater than max_fee"));
120 }
121 if withdraw_destination.is_empty() {
122 return Err(Error::validation("withdraw_destination must not be empty"));
123 }
124
125 let to = normalize_address(funding_account, "funding_account")?;
126 let token = parse_address(z_token, "z_token")?;
127 let data = withdrawToChainCall {
128 request: WithdrawRequest {
129 chainId: chain_id,
130 zToken: token,
131 withdrawDestination: Bytes::copy_from_slice(withdraw_destination),
132 zAmount: z_amount,
133 maxFee: max_fee,
134 },
135 }
136 .abi_encode();
137 Ok(ChainCall { to, data, value: 0 })
138}
139
140fn resolve_guard_tuple(approval: Option<GuardApproval>) -> Result<GuardApprovalTuple> {
141 let guard = approval.unwrap_or_default();
142 if guard.nonce_space >= (U256::from(1u64) << 192) {
144 return Err(Error::validation(
145 "guard approval nonce_space exceeds uint192 range",
146 ));
147 }
148 let limbs = guard.nonce_space.into_limbs();
149 let nonce_space = alloy_primitives::Uint::<192, 3>::from_limbs([limbs[0], limbs[1], limbs[2]]);
150 Ok(GuardApprovalTuple {
151 nonceSpace: nonce_space,
152 deadline: guard.deadline,
153 signature: Bytes::from(guard.signature),
154 })
155}
156
157pub fn encode_set_external_destination_allowlist_required(
161 funding_account: &str,
162 required: bool,
163 approval: Option<GuardApproval>,
164) -> Result<ChainCall> {
165 let to = normalize_address(funding_account, "funding_account")?;
166 let data = setExternalDestinationAllowlistRequiredCall {
167 required,
168 guardSigIfFalse: resolve_guard_tuple(approval)?,
169 }
170 .abi_encode();
171 Ok(ChainCall { to, data, value: 0 })
172}
173
174pub fn encode_set_internal_account_allowlist_required(
176 funding_account: &str,
177 required: bool,
178 approval: Option<GuardApproval>,
179) -> Result<ChainCall> {
180 let to = normalize_address(funding_account, "funding_account")?;
181 let data = setInternalAccountAllowlistRequiredCall {
182 required,
183 guardSigIfFalse: resolve_guard_tuple(approval)?,
184 }
185 .abi_encode();
186 Ok(ChainCall { to, data, value: 0 })
187}
188
189pub fn encode_add_allowed_external_destinations(
191 funding_account: &str,
192 chain_id: u16,
193 destinations: &[Vec<u8>],
194 approval: Option<GuardApproval>,
195) -> Result<ChainCall> {
196 encode_external_destinations(
197 funding_account,
198 chain_id,
199 destinations,
200 approval,
201 |chain_id, destinations, approval| {
202 addAllowedExternalDestinationsCall {
203 chainId: chain_id,
204 destinations,
205 approval,
206 }
207 .abi_encode()
208 },
209 )
210}
211
212pub fn encode_remove_allowed_external_destinations(
214 funding_account: &str,
215 chain_id: u16,
216 destinations: &[Vec<u8>],
217 approval: Option<GuardApproval>,
218) -> Result<ChainCall> {
219 encode_external_destinations(
220 funding_account,
221 chain_id,
222 destinations,
223 approval,
224 |chain_id, destinations, approval| {
225 removeAllowedExternalDestinationsCall {
226 chainId: chain_id,
227 destinations,
228 approval,
229 }
230 .abi_encode()
231 },
232 )
233}
234
235fn encode_external_destinations(
236 funding_account: &str,
237 chain_id: u16,
238 destinations: &[Vec<u8>],
239 approval: Option<GuardApproval>,
240 pack: impl FnOnce(u16, Vec<Bytes>, GuardApprovalTuple) -> Vec<u8>,
241) -> Result<ChainCall> {
242 if chain_id == 0 {
243 return Err(Error::validation("chain_id must be a uint16 > 0"));
244 }
245 if destinations.is_empty() {
246 return Err(Error::validation("destinations must be non-empty"));
247 }
248 if destinations.iter().any(|d| d.is_empty()) {
249 return Err(Error::validation("destinations entries must not be empty"));
250 }
251 let to = normalize_address(funding_account, "funding_account")?;
252 let dest_bytes: Vec<Bytes> = destinations
253 .iter()
254 .map(|d| Bytes::copy_from_slice(d))
255 .collect();
256 let data = pack(chain_id, dest_bytes, resolve_guard_tuple(approval)?);
257 Ok(ChainCall { to, data, value: 0 })
258}
259
260pub fn encode_add_allowed_internal_accounts(
262 funding_account: &str,
263 accounts: &[&str],
264 approval: Option<GuardApproval>,
265) -> Result<ChainCall> {
266 encode_internal_accounts(funding_account, accounts, approval, |accounts, approval| {
267 addAllowedInternalAccountsCall { accounts, approval }.abi_encode()
268 })
269}
270
271pub fn encode_remove_allowed_internal_accounts(
273 funding_account: &str,
274 accounts: &[&str],
275 approval: Option<GuardApproval>,
276) -> Result<ChainCall> {
277 encode_internal_accounts(funding_account, accounts, approval, |accounts, approval| {
278 removeAllowedInternalAccountsCall { accounts, approval }.abi_encode()
279 })
280}
281
282fn encode_internal_accounts(
283 funding_account: &str,
284 accounts: &[&str],
285 approval: Option<GuardApproval>,
286 pack: impl FnOnce(Vec<Address>, GuardApprovalTuple) -> Vec<u8>,
287) -> Result<ChainCall> {
288 if accounts.is_empty() {
289 return Err(Error::validation("accounts must be non-empty"));
290 }
291 let to = normalize_address(funding_account, "funding_account")?;
292 let addrs = accounts
293 .iter()
294 .map(|a| parse_address(a, "accounts"))
295 .collect::<Result<Vec<_>>>()?;
296 let data = pack(addrs, resolve_guard_tuple(approval)?);
297 Ok(ChainCall { to, data, value: 0 })
298}
299
300pub fn encode_initialize_guard_signer(guard_registry: &str, signer: &str) -> Result<ChainCall> {
302 let to = normalize_address(guard_registry, "guard_registry")?;
303 let signer_addr = parse_address(signer, "signer")?;
304 let data = initializeSignerCall {
305 signer: signer_addr,
306 }
307 .abi_encode();
308 Ok(ChainCall { to, data, value: 0 })
309}
310
311pub fn encode_rotate_guard_signer(
313 guard_registry: &str,
314 new_signer: &str,
315 approval: Option<GuardApproval>,
316) -> Result<ChainCall> {
317 let to = normalize_address(guard_registry, "guard_registry")?;
318 let signer_addr = parse_address(new_signer, "new_signer")?;
319 let data = rotateSignerCall {
320 newSigner: signer_addr,
321 approval: resolve_guard_tuple(approval)?,
322 }
323 .abi_encode();
324 Ok(ChainCall { to, data, value: 0 })
325}
326
327fn normalize_address(value: &str, field: &str) -> Result<String> {
328 let addr = value.trim();
329 if !addr.starts_with("0x") || addr.len() != 42 {
330 return Err(Error::validation(format!(
331 "{field} must be a 20-byte 0x-prefixed address"
332 )));
333 }
334 if hex::decode(&addr[2..]).is_err() {
335 return Err(Error::validation(format!(
336 "{field} is not a valid hex address"
337 )));
338 }
339 Ok(addr.to_ascii_lowercase())
340}
341
342fn parse_address(value: &str, field: &str) -> Result<Address> {
343 let normalized = normalize_address(value, field)?;
344 normalized
345 .parse::<Address>()
346 .map_err(|_| Error::validation(format!("{field} is not a valid hex address")))
347}
348
349fn normalize_bytes32(value: &str, field: &str) -> Result<B256> {
350 let text = value.trim().strip_prefix("0x").unwrap_or(value.trim());
351 if text.len() != 64 {
352 return Err(Error::validation(format!(
353 "{field} must be 32 bytes (64 hex chars)"
354 )));
355 }
356 let raw =
357 hex::decode(text).map_err(|_| Error::validation(format!("{field} is not valid hex")))?;
358 Ok(B256::from_slice(&raw))
359}
360
361#[cfg(test)]
362mod tests {
363 use super::*;
364
365 const TRADING_GATEWAY: &str = "0x4444444444444444444444444444444444444444";
366 const FUNDING_ACCOUNT: &str = "0x1111111111111111111111111111111111111111";
367 const INTERNAL_ACCOUNT: &str = "0x3333333333333333333333333333333333333333";
368 const U_ASSET_ID: &str = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
369 const Z_TOKEN: &str = "0x5555555555555555555555555555555555555555";
370
371 #[test]
372 fn encode_deposit_selector_and_args() {
373 let call =
374 encode_trading_gateway_deposit(TRADING_GATEWAY, U_ASSET_ID, U256::from(1_000_000u64))
375 .unwrap();
376 assert_eq!(call.to, TRADING_GATEWAY.to_ascii_lowercase());
377 assert_eq!(call.value, 0);
378 assert_eq!(&call.data[..4], depositCall::SELECTOR.as_slice());
379 let decoded = depositCall::abi_decode(&call.data).unwrap();
380 assert_eq!(decoded.uAmount, U256::from(1_000_000u64));
381 }
382
383 #[test]
384 fn encode_deposit_to_selector() {
385 let call = encode_trading_gateway_deposit_to(
386 TRADING_GATEWAY,
387 INTERNAL_ACCOUNT,
388 U_ASSET_ID,
389 U256::from(1_000_000u64),
390 )
391 .unwrap();
392 assert_eq!(&call.data[..4], depositToCall::SELECTOR.as_slice());
393 let decoded = depositToCall::abi_decode(&call.data).unwrap();
394 assert_eq!(
395 decoded.toAccount,
396 parse_address(INTERNAL_ACCOUNT, "to").unwrap()
397 );
398 }
399
400 #[test]
401 fn encode_withdraw_to_chain_selector_and_tuple() {
402 let destination = hex::decode("1234").unwrap();
403 let call = encode_funding_withdraw_to_chain(
404 FUNDING_ACCOUNT,
405 56,
406 Z_TOKEN,
407 &destination,
408 U256::from(2_000_000u64),
409 U256::from(1000u64),
410 )
411 .unwrap();
412 assert_eq!(&call.data[..4], withdrawToChainCall::SELECTOR.as_slice());
413 let decoded = withdrawToChainCall::abi_decode(&call.data).unwrap();
414 assert_eq!(decoded.request.chainId, 56);
415 assert_eq!(
416 decoded.request.withdrawDestination.as_ref(),
417 destination.as_slice()
418 );
419 assert_eq!(decoded.request.zAmount, U256::from(2_000_000u64));
420 assert_eq!(decoded.request.maxFee, U256::from(1000u64));
421 }
422
423 #[test]
424 fn encode_allowlist_required() {
425 let call = encode_set_external_destination_allowlist_required(FUNDING_ACCOUNT, true, None)
426 .unwrap();
427 assert_eq!(
428 &call.data[..4],
429 setExternalDestinationAllowlistRequiredCall::SELECTOR.as_slice()
430 );
431 let decoded = setExternalDestinationAllowlistRequiredCall::abi_decode(&call.data).unwrap();
432 assert!(decoded.required);
433 assert_eq!(
434 decoded.guardSigIfFalse.nonceSpace,
435 alloy_primitives::Uint::ZERO
436 );
437 assert_eq!(decoded.guardSigIfFalse.deadline, U256::ZERO);
438 assert!(decoded.guardSigIfFalse.signature.is_empty());
439 }
440
441 #[test]
442 fn encode_internal_account_allowlist_required() {
443 let call =
444 encode_set_internal_account_allowlist_required(FUNDING_ACCOUNT, true, None).unwrap();
445 assert_eq!(
446 &call.data[..4],
447 setInternalAccountAllowlistRequiredCall::SELECTOR.as_slice()
448 );
449 let decoded = setInternalAccountAllowlistRequiredCall::abi_decode(&call.data).unwrap();
450 assert!(decoded.required);
451 }
452
453 #[test]
454 fn encode_add_remove_allowed_external_destinations() {
455 let destinations = vec![vec![0x12, 0x34], vec![0xab, 0xcd]];
456 let approval = GuardApproval {
457 nonce_space: U256::from(7u64),
458 deadline: U256::from(123u64),
459 signature: vec![0xab, 0xcd],
460 };
461 let add = encode_add_allowed_external_destinations(
462 FUNDING_ACCOUNT,
463 56,
464 &destinations,
465 Some(approval),
466 )
467 .unwrap();
468 assert_eq!(
469 &add.data[..4],
470 addAllowedExternalDestinationsCall::SELECTOR.as_slice()
471 );
472 let decoded = addAllowedExternalDestinationsCall::abi_decode(&add.data).unwrap();
473 assert_eq!(decoded.chainId, 56);
474 assert_eq!(decoded.destinations.len(), 2);
475 assert_eq!(decoded.destinations[0].as_ref(), &[0x12, 0x34]);
476 assert_eq!(decoded.approval.nonceSpace, alloy_primitives::Uint::from(7));
477
478 let remove =
479 encode_remove_allowed_external_destinations(FUNDING_ACCOUNT, 56, &destinations, None)
480 .unwrap();
481 assert_eq!(
482 &remove.data[..4],
483 removeAllowedExternalDestinationsCall::SELECTOR.as_slice()
484 );
485 }
486
487 #[test]
488 fn encode_add_remove_allowed_internal_accounts() {
489 let accounts = [
490 INTERNAL_ACCOUNT,
491 "0x6666666666666666666666666666666666666666",
492 ];
493 let add = encode_add_allowed_internal_accounts(FUNDING_ACCOUNT, &accounts, None).unwrap();
494 assert_eq!(
495 &add.data[..4],
496 addAllowedInternalAccountsCall::SELECTOR.as_slice()
497 );
498 let decoded = addAllowedInternalAccountsCall::abi_decode(&add.data).unwrap();
499 assert_eq!(decoded.accounts.len(), 2);
500 assert_eq!(
501 decoded.accounts[0],
502 parse_address(INTERNAL_ACCOUNT, "to").unwrap()
503 );
504
505 let remove =
506 encode_remove_allowed_internal_accounts(FUNDING_ACCOUNT, &accounts, None).unwrap();
507 assert_eq!(
508 &remove.data[..4],
509 removeAllowedInternalAccountsCall::SELECTOR.as_slice()
510 );
511 }
512
513 #[test]
514 fn encode_initialize_and_rotate_guard_signer() {
515 const GUARD_REGISTRY: &str = "0xd71F60FD6f784Cc0aD8c25441568C48705D95f64";
516 const SIGNER: &str = "0x7777777777777777777777777777777777777777";
517
518 let init = encode_initialize_guard_signer(GUARD_REGISTRY, SIGNER).unwrap();
519 assert_eq!(init.to, GUARD_REGISTRY.to_ascii_lowercase());
520 assert_eq!(&init.data[..4], initializeSignerCall::SELECTOR.as_slice());
521 let decoded = initializeSignerCall::abi_decode(&init.data).unwrap();
522 assert_eq!(decoded.signer, parse_address(SIGNER, "signer").unwrap());
523
524 let rotate = encode_rotate_guard_signer(
525 GUARD_REGISTRY,
526 SIGNER,
527 Some(GuardApproval {
528 nonce_space: U256::from(1u64),
529 deadline: U256::from(999u64),
530 signature: vec![0x01, 0x02],
531 }),
532 )
533 .unwrap();
534 assert_eq!(&rotate.data[..4], rotateSignerCall::SELECTOR.as_slice());
535 let decoded = rotateSignerCall::abi_decode(&rotate.data).unwrap();
536 assert_eq!(decoded.newSigner, parse_address(SIGNER, "signer").unwrap());
537 assert_eq!(decoded.approval.nonceSpace, alloy_primitives::Uint::from(1));
538 assert_eq!(decoded.approval.deadline, U256::from(999u64));
539 }
540
541 #[test]
542 fn oversized_nonce_space_is_validation_error_not_panic() {
543 let approval = GuardApproval {
545 nonce_space: U256::from(2u64).pow(U256::from(192u64)),
546 deadline: U256::from(1u64),
547 signature: vec![],
548 };
549 let err = encode_rotate_guard_signer(
550 "0xd71F60FD6f784Cc0aD8c25441568C48705D95f64",
551 "0x7777777777777777777777777777777777777777",
552 Some(approval),
553 )
554 .unwrap_err();
555 assert!(
556 err.to_string().contains("uint192"),
557 "unexpected error: {err}"
558 );
559 }
560
561 #[test]
562 fn deposit_rejects_zero() {
563 let err =
564 encode_trading_gateway_deposit(TRADING_GATEWAY, U_ASSET_ID, U256::ZERO).unwrap_err();
565 assert!(matches!(err, Error::Validation(_)));
566 }
567
568 #[test]
569 fn withdraw_rejects_amount_not_greater_than_fee() {
570 let err = encode_funding_withdraw_to_chain(
571 FUNDING_ACCOUNT,
572 1,
573 Z_TOKEN,
574 &[0x12, 0x34],
575 U256::from(100u64),
576 U256::from(100u64),
577 )
578 .unwrap_err();
579 assert!(matches!(err, Error::Validation(_)));
580 }
581}