Skip to main content

cast/cmd/tempo/zone/
earn.rs

1//! Callback builders for Earn's scoped SingleZoneEarnRouter.
2//!
3//! ABI mirrored from tempoxyz/earn at 0c0f141f.
4//!
5//! Source: <https://github.com/tempoxyz/earn/blob/0c0f141f76f1f3c1f56f414a413f21a9e677d93d/src/router/SingleZoneEarnRouter.sol>
6
7use super::{
8    abi::{DepositPayload, IZonePortal},
9    encryption,
10    l1::L1Args,
11};
12use alloy_primitives::{Address, B256, Bytes, keccak256};
13use alloy_provider::Provider;
14use alloy_rpc_types::BlockId;
15use alloy_sol_types::{SolValue, sol};
16use clap::Parser;
17use eyre::{Result, ensure};
18use foundry_cli::json::print_scalar;
19use foundry_common::sh_status;
20
21sol! {
22    struct ZoneReturn {
23        uint256 keyIndex;
24        DepositPayload encrypted;
25        address refundRecipient;
26    }
27
28    struct CallbackData {
29        uint8 flow;
30        uint128 minVaultAssets;
31        uint128 minEarnShares;
32        uint128 minOutputAmount;
33        bytes32 actionId;
34        ZoneReturn zoneReturn;
35    }
36
37    #[sol(rpc)]
38    interface IEarnRouter {
39        function allowedZoneId() external view returns (uint32);
40    }
41}
42
43/// Build callback bytes to pass to `zone withdraw --callback-data` with nonzero callback gas.
44#[derive(Debug, Parser)]
45pub(super) struct EarnArgs {
46    #[command(subcommand)]
47    command: EarnSubcommand,
48}
49
50#[derive(Debug, Parser)]
51enum EarnSubcommand {
52    /// Encode an Earn deposit. Withdraw the router's private asset to --router.
53    EncodeDeposit {
54        /// Minimum vault assets after conversion, in smallest units.
55        #[arg(long)]
56        min_vault_assets: u128,
57        /// Minimum Earn shares minted, in smallest units.
58        #[arg(long)]
59        min_earn_shares: u128,
60        #[command(flatten)]
61        args: BuilderArgs,
62    },
63    /// Encode an Earn redemption. Withdraw Earn shares to --router.
64    EncodeRedeem {
65        /// Minimum assets redeemed from the vault, in smallest units.
66        #[arg(long)]
67        min_vault_assets: u128,
68        /// Minimum private assets returned to the zone, in smallest units.
69        #[arg(long)]
70        min_output_amount: u128,
71        #[command(flatten)]
72        args: BuilderArgs,
73    },
74}
75
76#[derive(Debug, Parser)]
77struct BuilderArgs {
78    /// Scoped SingleZoneEarnRouter address on L1. Also use it as withdrawal --to.
79    #[arg(long)]
80    router: Address,
81    /// Recipient of the encrypted return deposit inside the zone.
82    #[arg(long)]
83    recipient: Address,
84    /// Public L1 refund address if the return deposit fails. This is not encrypted and should not
85    /// identify --recipient when recipient privacy matters.
86    #[arg(long)]
87    refund_recipient: Address,
88    /// Memo encrypted with the return recipient.
89    #[arg(long, default_value_t = B256::ZERO)]
90    memo: B256,
91    /// Operation identifier. Defaults to the hash of the randomized return ciphertext.
92    #[arg(long)]
93    action_id: Option<B256>,
94    /// Zone ID for the return deposit.
95    #[arg(long, env = "ZONE_ID")]
96    zone_id: u32,
97    /// Zone chain ID used to select the parent RPC.
98    #[arg(long, env = "ZONE_CHAIN_ID")]
99    zone_chain_id: u64,
100    #[command(flatten)]
101    l1: L1Args,
102}
103
104impl EarnArgs {
105    pub(super) async fn run(self) -> Result<()> {
106        let (args, flow, min_vault_assets, min_earn_shares, min_output_amount) = match self.command
107        {
108            EarnSubcommand::EncodeDeposit { args, min_vault_assets, min_earn_shares } => {
109                ensure!(
110                    min_vault_assets > 0 && min_earn_shares > 0,
111                    "Earn deposit minimum outputs must be greater than zero"
112                );
113                (args, 0, min_vault_assets, min_earn_shares, 0)
114            }
115            EarnSubcommand::EncodeRedeem { args, min_vault_assets, min_output_amount } => {
116                ensure!(
117                    min_vault_assets > 0 && min_output_amount > 0,
118                    "Earn redemption minimum outputs must be greater than zero"
119                );
120                (args, 1, min_vault_assets, 0, min_output_amount)
121            }
122        };
123        ensure!(
124            !args.router.is_zero() && !args.recipient.is_zero() && !args.refund_recipient.is_zero(),
125            "router, recipient, and refund recipient must be nonzero"
126        );
127        let provider = args.l1.provider(args.zone_chain_id, args.zone_id).await?;
128        ensure!(
129            IEarnRouter::new(args.router, &provider).allowedZoneId().call().await? == args.zone_id,
130            "Earn router belongs to a different zone"
131        );
132        let portal_address = args.l1.portal()?;
133        let portal = IZonePortal::new(portal_address, &provider);
134        let block = provider.get_block_number().await?;
135        let key = portal.encryptionKeyAtBlock(block).block(BlockId::number(block)).call().await?;
136        // The router submits the return deposit, so ECIES must bind to its address.
137        let encrypted = encryption::encrypt_deposit(
138            key.x,
139            key.yParity,
140            args.recipient,
141            args.memo,
142            args.router,
143            portal_address,
144            key.keyIndex,
145        )?;
146        let action_id = args.action_id.unwrap_or_else(|| keccak256(&encrypted.ciphertext));
147        let data = CallbackData {
148            flow,
149            minVaultAssets: min_vault_assets,
150            minEarnShares: min_earn_shares,
151            minOutputAmount: min_output_amount,
152            actionId: action_id,
153            zoneReturn: ZoneReturn {
154                keyIndex: key.keyIndex,
155                encrypted,
156                refundRecipient: args.refund_recipient,
157            },
158        };
159        sh_status!(
160            "Earn action ID: {action_id}. Use withdrawal --to {} with \
161             --callback-gas-limit 10000000.",
162            args.router
163        )?;
164        // abi.encode(struct), including its dynamic tuple offset, without a function selector.
165        print_scalar(Bytes::from(data.abi_encode()))
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use alloy_primitives::{U256, hex};
173
174    const ROUTER: &str = "0x1111111111111111111111111111111111111111";
175    const RECIPIENT: &str = "0x2222222222222222222222222222222222222222";
176    const REFUND_RECIPIENT: &str = "0x3333333333333333333333333333333333333333";
177
178    #[test]
179    fn requires_public_refund_recipient() {
180        let args = [
181            "earn",
182            "encode-deposit",
183            "--min-vault-assets",
184            "1",
185            "--min-earn-shares",
186            "1",
187            "--router",
188            ROUTER,
189            "--recipient",
190            RECIPIENT,
191            "--zone-id",
192            "1",
193            "--zone-chain-id",
194            "421700001",
195            "--portal",
196            ROUTER,
197        ];
198        assert!(EarnArgs::try_parse_from(args).is_err());
199
200        let mut args = args.to_vec();
201        args.extend(["--refund-recipient", REFUND_RECIPIENT]);
202        assert!(EarnArgs::try_parse_from(args).is_ok());
203    }
204
205    #[tokio::test]
206    async fn rejects_zero_minimum_outputs() {
207        for (subcommand, command, expected) in [
208            (
209                "encode-deposit",
210                ["--min-vault-assets", "0", "--min-earn-shares", "1"],
211                "Earn deposit minimum outputs must be greater than zero",
212            ),
213            (
214                "encode-deposit",
215                ["--min-vault-assets", "1", "--min-earn-shares", "0"],
216                "Earn deposit minimum outputs must be greater than zero",
217            ),
218            (
219                "encode-redeem",
220                ["--min-vault-assets", "0", "--min-output-amount", "1"],
221                "Earn redemption minimum outputs must be greater than zero",
222            ),
223            (
224                "encode-redeem",
225                ["--min-vault-assets", "1", "--min-output-amount", "0"],
226                "Earn redemption minimum outputs must be greater than zero",
227            ),
228        ] {
229            let args = ["earn", subcommand].into_iter().chain(command).chain([
230                "--router",
231                ROUTER,
232                "--recipient",
233                RECIPIENT,
234                "--refund-recipient",
235                REFUND_RECIPIENT,
236                "--zone-id",
237                "1",
238                "--zone-chain-id",
239                "421700001",
240                "--portal",
241                ROUTER,
242            ]);
243            assert_eq!(
244                EarnArgs::try_parse_from(args).unwrap().run().await.unwrap_err().to_string(),
245                expected
246            );
247        }
248    }
249
250    #[test]
251    fn callback_matches_solidity_struct_encoding() {
252        let encrypted = DepositPayload {
253            ephemeralPubkeyX: B256::repeat_byte(1),
254            ephemeralPubkeyYParity: 1,
255            ciphertext: Bytes::from(vec![2; 64]),
256            nonce: [3; 12].into(),
257            tag: [4; 16].into(),
258        };
259        let recipient = Address::repeat_byte(5);
260        let action = B256::repeat_byte(6);
261        // Generated by solc 0.8.35 from the EarnCallbackData definition pinned in the module docs.
262        // Keeping the independently generated bytes catches tuple, enum, and dynamic-offset drift.
263        let expected =
264            hex::decode(include_str!("../../../../tests/fixtures/tempo_earn_callback.hex").trim())
265                .unwrap();
266        let encoded = CallbackData {
267            flow: 1,
268            minVaultAssets: 7,
269            minEarnShares: 0,
270            minOutputAmount: 9,
271            actionId: action,
272            zoneReturn: ZoneReturn {
273                keyIndex: U256::from(10),
274                encrypted,
275                refundRecipient: recipient,
276            },
277        }
278        .abi_encode();
279        assert_eq!(encoded, expected);
280        assert_eq!(U256::from_be_slice(&encoded[..32]), U256::from(32));
281        assert_eq!(<CallbackData as SolValue>::abi_decode(&encoded).unwrap().flow, 1);
282    }
283}