1use 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#[derive(Debug, Parser)]
45pub(super) struct EarnArgs {
46 #[command(subcommand)]
47 command: EarnSubcommand,
48}
49
50#[derive(Debug, Parser)]
51enum EarnSubcommand {
52 EncodeDeposit {
54 #[arg(long)]
56 min_vault_assets: u128,
57 #[arg(long)]
59 min_earn_shares: u128,
60 #[command(flatten)]
61 args: BuilderArgs,
62 },
63 EncodeRedeem {
65 #[arg(long)]
67 min_vault_assets: u128,
68 #[arg(long)]
70 min_output_amount: u128,
71 #[command(flatten)]
72 args: BuilderArgs,
73 },
74}
75
76#[derive(Debug, Parser)]
77struct BuilderArgs {
78 #[arg(long)]
80 router: Address,
81 #[arg(long)]
83 recipient: Address,
84 #[arg(long)]
87 refund_recipient: Address,
88 #[arg(long, default_value_t = B256::ZERO)]
90 memo: B256,
91 #[arg(long)]
93 action_id: Option<B256>,
94 #[arg(long, env = "ZONE_ID")]
96 zone_id: u32,
97 #[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 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 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 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}