Skip to main content

polyester/chain/
calldata.rs

1//! ABI calldata encoders matching TypeScript polyester-features chain-actions.
2
3use 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/// Contract call payload for a smart-account UserOperation.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ChainCall {
49    pub to: String,
50    pub data: Vec<u8>,
51    pub value: u128,
52}
53
54/// Guard approval tuple `(uint192 nonceSpace, uint256 deadline, bytes signature)`.
55#[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
62/// Encode `TradingGateway.deposit(bytes32 uAssetId, uint256 uAmount)`.
63pub 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
81/// Encode `TradingGateway.depositTo(address,bytes32,uint256)`.
82pub 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
103/// Encode `FundingAccount.withdrawToChain((uint16,address,bytes,uint256,uint256))`.
104pub 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    // Uint::<192>::from(U256) panics on overflow; validate then truncate via limbs.
143    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
157/// Encode `setExternalDestinationAllowlistRequired(bool,(uint192,uint256,bytes))`.
158///
159/// When `required` is true, `approval` may be `None` (empty guard tuple).
160pub 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
174/// Encode `setInternalAccountAllowlistRequired(bool,(uint192,uint256,bytes))`.
175pub 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
189/// Encode `addAllowedExternalDestinations(uint16,bytes[],(uint192,uint256,bytes))`.
190pub 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
212/// Encode `removeAllowedExternalDestinations(uint16,bytes[],(uint192,uint256,bytes))`.
213pub 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
260/// Encode `addAllowedInternalAccounts(address[],(uint192,uint256,bytes))`.
261pub 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
271/// Encode `removeAllowedInternalAccounts(address[],(uint192,uint256,bytes))`.
272pub 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
300/// Encode `GuardRegistry.initializeSigner(address)`.
301pub 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
311/// Encode `GuardRegistry.rotateSigner(address,(uint192,uint256,bytes))`.
312pub 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        // 2^192 is one past uint192 max.
544        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}