1use std::str::FromStr;
2
3use crate::{
4 cmd::{erc20::print_amount, rpc_provider, send::SendTxArgs},
5 tx::{SendTxOpts, TxParams},
6};
7use alloy_eips::BlockId;
8use alloy_ens::NameOrAddress;
9use alloy_network::AnyNetwork;
10use alloy_primitives::{Address, FixedBytes, U256, address};
11use alloy_provider::Provider;
12use alloy_sol_types::{SolCall, sol};
13use clap::Parser;
14use eyre::{Result, WrapErr};
15use foundry_cli::{
16 json::{
17 JsonError, JsonMessage, print_json_success, print_json_success_with_warnings, print_scalar,
18 },
19 opts::RpcOpts,
20};
21use foundry_common::{provider::RetryProvider, shell};
22use serde::Serialize;
23
24const NATIVE_ASSET: Address = address!("EeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE");
25const NATIVE_ASSET_DECIMALS: u8 = 18;
27const ERC7540_ASYNC_DEPOSIT_INTERFACE: FixedBytes<4> = FixedBytes::new([0xce, 0x3b, 0xbe, 0x50]);
28const ERC7540_ASYNC_REDEEM_INTERFACE: FixedBytes<4> = FixedBytes::new([0x62, 0x0e, 0xe8, 0xe4]);
29
30sol! {
31 #[sol(rpc)]
32 interface IERC4626 {
33 function asset() external view returns (address assetTokenAddress);
34 function totalAssets() external view returns (uint256 totalManagedAssets);
35 function convertToShares(uint256 assets) external view returns (uint256 shares);
36 function convertToAssets(uint256 shares) external view returns (uint256 assets);
37 function maxDeposit(address receiver) external view returns (uint256 maxAssets);
38 function previewDeposit(uint256 assets) external view returns (uint256 shares);
39 function deposit(uint256 assets, address receiver) external returns (uint256 shares);
40 function maxMint(address receiver) external view returns (uint256 maxShares);
41 function previewMint(uint256 shares) external view returns (uint256 assets);
42 function mint(uint256 shares, address receiver) external returns (uint256 assets);
43 function maxWithdraw(address owner) external view returns (uint256 maxAssets);
44 function previewWithdraw(uint256 assets) external view returns (uint256 shares);
45 function withdraw(uint256 assets, address receiver, address owner)
46 external
47 returns (uint256 shares);
48 function maxRedeem(address owner) external view returns (uint256 maxShares);
49 function previewRedeem(uint256 shares) external view returns (uint256 assets);
50 function redeem(uint256 shares, address receiver, address owner)
51 external
52 returns (uint256 assets);
53
54 function name() external view returns (string);
55 function symbol() external view returns (string);
56 function decimals() external view returns (uint8);
57 function totalSupply() external view returns (uint256 shares);
58 function balanceOf(address owner) external view returns (uint256 shares);
59 function allowance(address owner, address spender) external view returns (uint256 shares);
60 }
61
62 #[sol(rpc)]
63 interface IERC20Metadata {
64 function name() external view returns (string);
65 function symbol() external view returns (string);
66 function decimals() external view returns (uint8);
67 function balanceOf(address owner) external view returns (uint256 amount);
68 }
69
70 #[sol(rpc)]
71 interface IERC165 {
72 function supportsInterface(bytes4 interfaceId) external view returns (bool);
73 }
74}
75
76#[derive(Debug, Parser, Clone)]
78pub enum Erc4626Subcommand {
79 #[command(verbatim_doc_comment)]
105 Info {
106 #[arg(value_parser = NameOrAddress::from_str)]
108 vault: NameOrAddress,
109
110 #[arg(long)]
112 human: bool,
113
114 #[arg(long, short = 'B')]
116 block: Option<BlockId>,
117
118 #[command(flatten)]
119 rpc: RpcOpts,
120 },
121
122 #[command(verbatim_doc_comment)]
148 Position {
149 #[arg(value_parser = NameOrAddress::from_str)]
151 vault: NameOrAddress,
152
153 #[arg(value_parser = NameOrAddress::from_str)]
155 owner: NameOrAddress,
156
157 #[arg(long)]
159 human: bool,
160
161 #[arg(long, short = 'B')]
163 block: Option<BlockId>,
164
165 #[command(flatten)]
166 rpc: RpcOpts,
167 },
168
169 #[command(verbatim_doc_comment)]
209 Check {
210 #[arg(value_parser = NameOrAddress::from_str)]
212 vault: NameOrAddress,
213
214 #[arg(long, value_parser = NameOrAddress::from_str)]
216 account: Option<NameOrAddress>,
217
218 #[arg(long, short = 'B')]
220 block: Option<BlockId>,
221
222 #[command(flatten)]
223 rpc: RpcOpts,
224 },
225
226 #[command(verbatim_doc_comment)]
241 Asset {
242 #[arg(value_parser = NameOrAddress::from_str)]
244 vault: NameOrAddress,
245
246 #[arg(long, short = 'B')]
248 block: Option<BlockId>,
249
250 #[command(flatten)]
251 rpc: RpcOpts,
252 },
253
254 #[command(verbatim_doc_comment)]
269 TotalAssets {
270 #[arg(value_parser = NameOrAddress::from_str)]
272 vault: NameOrAddress,
273
274 #[arg(long, short = 'B')]
276 block: Option<BlockId>,
277
278 #[command(flatten)]
279 rpc: RpcOpts,
280 },
281
282 #[command(verbatim_doc_comment)]
297 ConvertToShares {
298 #[arg(value_parser = NameOrAddress::from_str)]
300 vault: NameOrAddress,
301
302 assets: U256,
304
305 #[arg(long, short = 'B')]
307 block: Option<BlockId>,
308
309 #[command(flatten)]
310 rpc: RpcOpts,
311 },
312
313 #[command(verbatim_doc_comment)]
329 ConvertToAssets {
330 #[arg(value_parser = NameOrAddress::from_str)]
332 vault: NameOrAddress,
333
334 shares: U256,
336
337 #[arg(long, short = 'B')]
339 block: Option<BlockId>,
340
341 #[command(flatten)]
342 rpc: RpcOpts,
343 },
344
345 #[command(verbatim_doc_comment)]
361 MaxDeposit {
362 #[arg(value_parser = NameOrAddress::from_str)]
364 vault: NameOrAddress,
365
366 #[arg(value_parser = NameOrAddress::from_str)]
368 receiver: NameOrAddress,
369
370 #[arg(long, short = 'B')]
372 block: Option<BlockId>,
373
374 #[command(flatten)]
375 rpc: RpcOpts,
376 },
377
378 #[command(verbatim_doc_comment)]
393 PreviewDeposit {
394 #[arg(value_parser = NameOrAddress::from_str)]
396 vault: NameOrAddress,
397
398 assets: U256,
400
401 #[arg(long, short = 'B')]
403 block: Option<BlockId>,
404
405 #[command(flatten)]
406 rpc: RpcOpts,
407 },
408
409 #[command(verbatim_doc_comment)]
426 Deposit {
427 #[arg(value_parser = NameOrAddress::from_str)]
429 vault: NameOrAddress,
430
431 assets: U256,
433
434 #[arg(value_parser = NameOrAddress::from_str)]
436 receiver: NameOrAddress,
437
438 #[command(flatten)]
439 send_tx: SendTxOpts,
440
441 #[command(flatten)]
442 tx: TxParams,
443 },
444
445 #[command(verbatim_doc_comment)]
461 MaxMint {
462 #[arg(value_parser = NameOrAddress::from_str)]
464 vault: NameOrAddress,
465
466 #[arg(value_parser = NameOrAddress::from_str)]
468 receiver: NameOrAddress,
469
470 #[arg(long, short = 'B')]
472 block: Option<BlockId>,
473
474 #[command(flatten)]
475 rpc: RpcOpts,
476 },
477
478 #[command(verbatim_doc_comment)]
494 PreviewMint {
495 #[arg(value_parser = NameOrAddress::from_str)]
497 vault: NameOrAddress,
498
499 shares: U256,
501
502 #[arg(long, short = 'B')]
504 block: Option<BlockId>,
505
506 #[command(flatten)]
507 rpc: RpcOpts,
508 },
509
510 #[command(verbatim_doc_comment)]
528 Mint {
529 #[arg(value_parser = NameOrAddress::from_str)]
531 vault: NameOrAddress,
532
533 shares: U256,
535
536 #[arg(value_parser = NameOrAddress::from_str)]
538 receiver: NameOrAddress,
539
540 #[command(flatten)]
541 send_tx: SendTxOpts,
542
543 #[command(flatten)]
544 tx: TxParams,
545 },
546
547 #[command(verbatim_doc_comment)]
563 MaxWithdraw {
564 #[arg(value_parser = NameOrAddress::from_str)]
566 vault: NameOrAddress,
567
568 #[arg(value_parser = NameOrAddress::from_str)]
570 owner: NameOrAddress,
571
572 #[arg(long, short = 'B')]
574 block: Option<BlockId>,
575
576 #[command(flatten)]
577 rpc: RpcOpts,
578 },
579
580 #[command(verbatim_doc_comment)]
595 PreviewWithdraw {
596 #[arg(value_parser = NameOrAddress::from_str)]
598 vault: NameOrAddress,
599
600 assets: U256,
602
603 #[arg(long, short = 'B')]
605 block: Option<BlockId>,
606
607 #[command(flatten)]
608 rpc: RpcOpts,
609 },
610
611 #[command(verbatim_doc_comment)]
627 Withdraw {
628 #[arg(value_parser = NameOrAddress::from_str)]
630 vault: NameOrAddress,
631
632 assets: U256,
634
635 #[arg(value_parser = NameOrAddress::from_str)]
637 receiver: NameOrAddress,
638
639 #[arg(value_parser = NameOrAddress::from_str)]
641 owner: NameOrAddress,
642
643 #[command(flatten)]
644 send_tx: SendTxOpts,
645
646 #[command(flatten)]
647 tx: TxParams,
648 },
649
650 #[command(verbatim_doc_comment)]
666 MaxRedeem {
667 #[arg(value_parser = NameOrAddress::from_str)]
669 vault: NameOrAddress,
670
671 #[arg(value_parser = NameOrAddress::from_str)]
673 owner: NameOrAddress,
674
675 #[arg(long, short = 'B')]
677 block: Option<BlockId>,
678
679 #[command(flatten)]
680 rpc: RpcOpts,
681 },
682
683 #[command(verbatim_doc_comment)]
699 PreviewRedeem {
700 #[arg(value_parser = NameOrAddress::from_str)]
702 vault: NameOrAddress,
703
704 shares: U256,
706
707 #[arg(long, short = 'B')]
709 block: Option<BlockId>,
710
711 #[command(flatten)]
712 rpc: RpcOpts,
713 },
714
715 #[command(verbatim_doc_comment)]
731 Redeem {
732 #[arg(value_parser = NameOrAddress::from_str)]
734 vault: NameOrAddress,
735
736 shares: U256,
738
739 #[arg(value_parser = NameOrAddress::from_str)]
741 receiver: NameOrAddress,
742
743 #[arg(value_parser = NameOrAddress::from_str)]
745 owner: NameOrAddress,
746
747 #[command(flatten)]
748 send_tx: SendTxOpts,
749
750 #[command(flatten)]
751 tx: TxParams,
752 },
753}
754
755#[derive(Debug, Serialize)]
756struct TokenAmount {
757 raw: String,
758 #[serde(skip_serializing_if = "Option::is_none")]
759 formatted: Option<String>,
760}
761
762impl TokenAmount {
763 fn new(value: U256, decimals: Option<u8>) -> Self {
764 Self {
765 raw: value.to_string(),
766 formatted: decimals
767 .and_then(|decimals| crate::args::format_units(&value.to_string(), decimals).ok()),
768 }
769 }
770}
771
772#[derive(Debug, Serialize)]
773struct VaultInfo {
774 vault: String,
775 name: Option<String>,
776 symbol: Option<String>,
777 decimals: Option<u8>,
778 asset: String,
779 asset_name: Option<String>,
780 asset_symbol: Option<String>,
781 asset_decimals: Option<u8>,
782 total_assets: TokenAmount,
783 total_supply: TokenAmount,
784 assets_per_share: Option<TokenAmount>,
785 shares_per_asset: Option<TokenAmount>,
786}
787
788#[derive(Debug, Serialize)]
789struct VaultPosition {
790 vault: String,
791 owner: String,
792 asset: String,
793 share_symbol: Option<String>,
794 share_decimals: Option<u8>,
795 asset_symbol: Option<String>,
796 asset_decimals: Option<u8>,
797 share_balance: TokenAmount,
798 assets_equivalent: TokenAmount,
799 max_withdraw: TokenAmount,
800 max_redeem: TokenAmount,
801}
802
803#[derive(Debug)]
804struct VaultWarning {
805 code: &'static str,
806 message: String,
807}
808
809#[derive(Debug, Clone, Copy, Serialize)]
810#[serde(rename_all = "lowercase")]
811enum CheckStatus {
812 Pass,
813 Warn,
814 Fail,
815}
816
817#[derive(Debug, Serialize)]
818struct CompatibilityCheck {
819 name: String,
820 status: CheckStatus,
821 detail: String,
822}
823
824#[derive(Debug, Serialize)]
825struct CompatibilityReport {
826 vault: String,
827 account: String,
828 read_compatible: bool,
829 disclaimer: &'static str,
830 passed: usize,
831 warnings: usize,
832 failed: usize,
833 checks: Vec<CompatibilityCheck>,
834}
835
836const CHECK_DISCLAIMER: &str = "This probes read-call behavior only; it does not prove state-changing selector coverage or \
837 semantic ERC-4626 compliance.";
838
839impl Erc4626Subcommand {
840 pub async fn run(self) -> Result<()> {
841 match self {
842 Self::Info { vault, human, block, rpc } => show_info(vault, human, block, rpc).await,
843 Self::Position { vault, owner, human, block, rpc } => {
844 show_position(vault, owner, human, block, rpc).await
845 }
846 Self::Check { vault, account, block, rpc } => {
847 check_compatibility(vault, account, block, rpc).await
848 }
849 Self::Asset { vault, block, rpc } => {
850 let (_, vault) = vault_at(&rpc, vault).await?;
851 let asset = vault.asset().block(block.unwrap_or_default()).call().await?;
852 warn_if_native_asset(asset)?;
853 print_scalar(asset.to_string())
854 }
855 Self::TotalAssets { vault, block, rpc } => {
856 let (_, vault) = vault_at(&rpc, vault).await?;
857 print_amount(vault.totalAssets().block(block.unwrap_or_default()).call().await?)
858 }
859 Self::ConvertToShares { vault, assets, block, rpc } => {
860 let (_, vault) = vault_at(&rpc, vault).await?;
861 let call = vault.convertToShares(assets).block(block.unwrap_or_default());
862 print_amount(call.call().await?)
863 }
864 Self::ConvertToAssets { vault, shares, block, rpc } => {
865 let (_, vault) = vault_at(&rpc, vault).await?;
866 let call = vault.convertToAssets(shares).block(block.unwrap_or_default());
867 print_amount(call.call().await?)
868 }
869 Self::MaxDeposit { vault, receiver, block, rpc } => {
870 let (provider, vault) = vault_at(&rpc, vault).await?;
871 let receiver = receiver.resolve(&provider).await?;
872 let assets =
873 vault.maxDeposit(receiver).block(block.unwrap_or_default()).call().await?;
874 warn_if_zero_entry_max("maxDeposit", assets)?;
875 print_amount(assets)
876 }
877 Self::PreviewDeposit { vault, assets, block, rpc } => {
878 let (_, vault) = vault_at(&rpc, vault).await?;
879 let call = vault.previewDeposit(assets).block(block.unwrap_or_default());
880 print_amount(call.call().await.wrap_err_with(|| preview_error("Deposit"))?)
881 }
882 Self::Deposit { vault, assets, receiver, send_tx, tx } => {
883 let [receiver] = prepare_write(&vault, [receiver], &send_tx).await?;
884 send_call(vault, IERC4626::depositCall { assets, receiver }, send_tx, tx).await
885 }
886 Self::MaxMint { vault, receiver, block, rpc } => {
887 let (provider, vault) = vault_at(&rpc, vault).await?;
888 let receiver = receiver.resolve(&provider).await?;
889 let shares =
890 vault.maxMint(receiver).block(block.unwrap_or_default()).call().await?;
891 warn_if_zero_entry_max("maxMint", shares)?;
892 print_amount(shares)
893 }
894 Self::PreviewMint { vault, shares, block, rpc } => {
895 let (_, vault) = vault_at(&rpc, vault).await?;
896 let call = vault.previewMint(shares).block(block.unwrap_or_default());
897 print_amount(call.call().await.wrap_err_with(|| preview_error("Mint"))?)
898 }
899 Self::Mint { vault, shares, receiver, send_tx, tx } => {
900 let [receiver] = prepare_write(&vault, [receiver], &send_tx).await?;
901 send_call(vault, IERC4626::mintCall { shares, receiver }, send_tx, tx).await
902 }
903 Self::MaxWithdraw { vault, owner, block, rpc } => {
904 let (provider, vault) = vault_at(&rpc, vault).await?;
905 let owner = owner.resolve(&provider).await?;
906 let block = block.unwrap_or_default();
907 let assets = vault.maxWithdraw(owner).block(block).call().await?;
908 if assets.is_zero() && has_shares(&vault, owner, block).await {
909 warn_if_zero_exit_max("maxWithdraw")?;
910 }
911 print_amount(assets)
912 }
913 Self::PreviewWithdraw { vault, assets, block, rpc } => {
914 let (_, vault) = vault_at(&rpc, vault).await?;
915 let call = vault.previewWithdraw(assets).block(block.unwrap_or_default());
916 print_amount(call.call().await.wrap_err_with(|| preview_error("Withdraw"))?)
917 }
918 Self::Withdraw { vault, assets, receiver, owner, send_tx, tx } => {
919 let [receiver, owner] = prepare_write(&vault, [receiver, owner], &send_tx).await?;
920 let call = IERC4626::withdrawCall { assets, receiver, owner };
921 send_call(vault, call, send_tx, tx).await
922 }
923 Self::MaxRedeem { vault, owner, block, rpc } => {
924 let (provider, vault) = vault_at(&rpc, vault).await?;
925 let owner = owner.resolve(&provider).await?;
926 let block = block.unwrap_or_default();
927 let shares = vault.maxRedeem(owner).block(block).call().await?;
928 if shares.is_zero() && has_shares(&vault, owner, block).await {
929 warn_if_zero_exit_max("maxRedeem")?;
930 }
931 print_amount(shares)
932 }
933 Self::PreviewRedeem { vault, shares, block, rpc } => {
934 let (_, vault) = vault_at(&rpc, vault).await?;
935 let call = vault.previewRedeem(shares).block(block.unwrap_or_default());
936 print_amount(call.call().await.wrap_err_with(|| preview_error("Redeem"))?)
937 }
938 Self::Redeem { vault, shares, receiver, owner, send_tx, tx } => {
939 let [receiver, owner] = prepare_write(&vault, [receiver, owner], &send_tx).await?;
940 let call = IERC4626::redeemCall { shares, receiver, owner };
941 send_call(vault, call, send_tx, tx).await
942 }
943 }
944 }
945}
946
947type Vault = IERC4626::IERC4626Instance<RetryProvider, AnyNetwork>;
948
949async fn vault_at(rpc: &RpcOpts, vault: NameOrAddress) -> Result<(RetryProvider, Vault)> {
950 let provider = rpc_provider(rpc)?;
951 let vault = vault.resolve(&provider).await?;
952 Ok((provider.clone(), IERC4626::new(vault, provider)))
953}
954
955async fn has_shares(vault: &Vault, owner: Address, block: BlockId) -> bool {
956 vault.balanceOf(owner).block(block).call().await.is_ok_and(|shares| !shares.is_zero())
957}
958
959fn preview_error(method: &str) -> String {
961 let kind = if matches!(method, "Deposit" | "Mint") { "deposit" } else { "redeem" };
962 format!(
963 "preview{method} failed; asynchronous ERC-7540 {kind} vaults intentionally revert this \
964 preview"
965 )
966}
967
968async fn show_info(
969 vault: NameOrAddress,
970 human: bool,
971 block: Option<BlockId>,
972 rpc: RpcOpts,
973) -> Result<()> {
974 let (provider, contract) = vault_at(&rpc, vault).await?;
975 let vault = *contract.address();
976 let block = block.unwrap_or_default();
977
978 let name_call = contract.name().block(block);
979 let symbol_call = contract.symbol().block(block);
980 let decimals_call = contract.decimals().block(block);
981 let asset_call = contract.asset().block(block);
982 let total_assets_call = contract.totalAssets().block(block);
983 let total_supply_call = contract.totalSupply().block(block);
984 let (name, symbol, decimals, asset, total_assets, total_supply) = tokio::join!(
985 name_call.call(),
986 symbol_call.call(),
987 decimals_call.call(),
988 asset_call.call(),
989 total_assets_call.call(),
990 total_supply_call.call(),
991 );
992
993 let asset = asset.wrap_err("asset() call failed")?;
994 let total_assets = total_assets.wrap_err("totalAssets() call failed")?;
995 let total_supply = total_supply.wrap_err("totalSupply() call failed")?;
996 let name = name.ok();
997 let symbol = symbol.ok();
998 let decimals = decimals.ok();
999
1000 let mut warnings = Vec::new();
1001 let (asset_name, asset_symbol, asset_decimals) = if asset == NATIVE_ASSET {
1002 warnings.push(native_asset_warning());
1003 (None, None, Some(NATIVE_ASSET_DECIMALS))
1004 } else {
1005 let asset_contract = IERC20Metadata::new(asset, &provider);
1006 let name_call = asset_contract.name().block(block);
1007 let symbol_call = asset_contract.symbol().block(block);
1008 let decimals_call = asset_contract.decimals().block(block);
1009 let (name, symbol, decimals) =
1010 tokio::join!(name_call.call(), symbol_call.call(), decimals_call.call());
1011 (name.ok(), symbol.ok(), decimals.ok())
1012 };
1013
1014 let assets_per_share = match decimals.and_then(decimal_unit) {
1015 Some(unit) => contract.convertToAssets(unit).block(block).call().await.ok(),
1016 None => None,
1017 }
1018 .map(|value| TokenAmount::new(value, asset_decimals));
1019 let shares_per_asset = match asset_decimals.and_then(decimal_unit) {
1020 Some(unit) => contract.convertToShares(unit).block(block).call().await.ok(),
1021 None => None,
1022 }
1023 .map(|value| TokenAmount::new(value, decimals));
1024
1025 print_info(
1026 VaultInfo {
1027 vault: vault.to_string(),
1028 name,
1029 symbol,
1030 decimals,
1031 asset: asset.to_string(),
1032 asset_name,
1033 asset_symbol,
1034 asset_decimals,
1035 total_assets: TokenAmount::new(total_assets, asset_decimals),
1036 total_supply: TokenAmount::new(total_supply, decimals),
1037 assets_per_share,
1038 shares_per_asset,
1039 },
1040 human,
1041 warnings,
1042 )
1043}
1044
1045async fn show_position(
1046 vault: NameOrAddress,
1047 owner: NameOrAddress,
1048 human: bool,
1049 block: Option<BlockId>,
1050 rpc: RpcOpts,
1051) -> Result<()> {
1052 let (provider, contract) = vault_at(&rpc, vault).await?;
1053 let vault = *contract.address();
1054 let owner = owner.resolve(&provider).await?;
1055 let block = block.unwrap_or_default();
1056
1057 let asset_call = contract.asset().block(block);
1058 let symbol_call = contract.symbol().block(block);
1059 let decimals_call = contract.decimals().block(block);
1060 let balance_call = contract.balanceOf(owner).block(block);
1061 let max_withdraw_call = contract.maxWithdraw(owner).block(block);
1062 let max_redeem_call = contract.maxRedeem(owner).block(block);
1063 let (asset, share_symbol, share_decimals, share_balance, max_withdraw, max_redeem) = tokio::join!(
1064 asset_call.call(),
1065 symbol_call.call(),
1066 decimals_call.call(),
1067 balance_call.call(),
1068 max_withdraw_call.call(),
1069 max_redeem_call.call(),
1070 );
1071
1072 let asset = asset.wrap_err("asset() call failed")?;
1073 let share_balance = share_balance.wrap_err("balanceOf() call failed")?;
1074 let max_withdraw = max_withdraw.wrap_err("maxWithdraw() call failed")?;
1075 let max_redeem = max_redeem.wrap_err("maxRedeem() call failed")?;
1076 let share_symbol = share_symbol.ok();
1077 let share_decimals = share_decimals.ok();
1078 let assets_equivalent = contract
1079 .convertToAssets(share_balance)
1080 .block(block)
1081 .call()
1082 .await
1083 .wrap_err("convertToAssets() call failed")?;
1084
1085 let mut warnings = Vec::new();
1086 let (asset_symbol, asset_decimals) = if asset == NATIVE_ASSET {
1087 warnings.push(native_asset_warning());
1088 (None, Some(NATIVE_ASSET_DECIMALS))
1089 } else {
1090 let asset_contract = IERC20Metadata::new(asset, &provider);
1091 let symbol_call = asset_contract.symbol().block(block);
1092 let decimals_call = asset_contract.decimals().block(block);
1093 let (symbol, decimals) = tokio::join!(symbol_call.call(), decimals_call.call());
1094 (symbol.ok(), decimals.ok())
1095 };
1096
1097 if !share_balance.is_zero() {
1098 if max_withdraw.is_zero() {
1099 warnings.push(zero_exit_warning("maxWithdraw"));
1100 }
1101 if max_redeem.is_zero() {
1102 warnings.push(zero_exit_warning("maxRedeem"));
1103 }
1104 }
1105
1106 print_position(
1107 VaultPosition {
1108 vault: vault.to_string(),
1109 owner: owner.to_string(),
1110 asset: asset.to_string(),
1111 share_symbol,
1112 share_decimals,
1113 asset_symbol,
1114 asset_decimals,
1115 share_balance: TokenAmount::new(share_balance, share_decimals),
1116 assets_equivalent: TokenAmount::new(assets_equivalent, asset_decimals),
1117 max_withdraw: TokenAmount::new(max_withdraw, asset_decimals),
1118 max_redeem: TokenAmount::new(max_redeem, share_decimals),
1119 },
1120 human,
1121 warnings,
1122 )
1123}
1124
1125async fn check_compatibility(
1126 vault: NameOrAddress,
1127 account: Option<NameOrAddress>,
1128 block: Option<BlockId>,
1129 rpc: RpcOpts,
1130) -> Result<()> {
1131 let (provider, contract) = vault_at(&rpc, vault).await?;
1132 let vault = *contract.address();
1133 let account = match account {
1134 Some(account) => account.resolve(&provider).await?,
1135 None => Address::ZERO,
1136 };
1137 let block = block.unwrap_or_default();
1138
1139 let code_call = provider.get_code_at(vault).block_id(block);
1140 let asset_call = contract.asset().block(block);
1141 let total_assets_call = contract.totalAssets().block(block);
1142 let convert_to_shares_call = contract.convertToShares(U256::ZERO).block(block);
1143 let convert_to_assets_call = contract.convertToAssets(U256::ZERO).block(block);
1144 let max_deposit_call = contract.maxDeposit(account).block(block);
1145 let preview_deposit_call = contract.previewDeposit(U256::ZERO).block(block);
1146 let max_mint_call = contract.maxMint(account).block(block);
1147 let preview_mint_call = contract.previewMint(U256::ZERO).block(block);
1148 let max_withdraw_call = contract.maxWithdraw(account).block(block);
1149 let preview_withdraw_call = contract.previewWithdraw(U256::ZERO).block(block);
1150 let max_redeem_call = contract.maxRedeem(account).block(block);
1151 let preview_redeem_call = contract.previewRedeem(U256::ZERO).block(block);
1152 let name_call = contract.name().block(block);
1153 let symbol_call = contract.symbol().block(block);
1154 let decimals_call = contract.decimals().block(block);
1155 let total_supply_call = contract.totalSupply().block(block);
1156 let balance_call = contract.balanceOf(account).block(block);
1157 let allowance_call = contract.allowance(account, vault).block(block);
1158 let erc165 = IERC165::new(vault, &provider);
1159 let async_deposit_call = erc165.supportsInterface(ERC7540_ASYNC_DEPOSIT_INTERFACE).block(block);
1160 let async_redeem_call = erc165.supportsInterface(ERC7540_ASYNC_REDEEM_INTERFACE).block(block);
1161 let (
1162 code,
1163 asset,
1164 total_assets,
1165 convert_to_shares,
1166 convert_to_assets,
1167 max_deposit,
1168 preview_deposit,
1169 max_mint,
1170 preview_mint,
1171 max_withdraw,
1172 preview_withdraw,
1173 max_redeem,
1174 preview_redeem,
1175 name,
1176 symbol,
1177 decimals,
1178 total_supply,
1179 balance,
1180 allowance,
1181 async_deposit,
1182 async_redeem,
1183 ) = tokio::join!(
1184 code_call,
1185 asset_call.call(),
1186 total_assets_call.call(),
1187 convert_to_shares_call.call(),
1188 convert_to_assets_call.call(),
1189 max_deposit_call.call(),
1190 preview_deposit_call.call(),
1191 max_mint_call.call(),
1192 preview_mint_call.call(),
1193 max_withdraw_call.call(),
1194 preview_withdraw_call.call(),
1195 max_redeem_call.call(),
1196 preview_redeem_call.call(),
1197 name_call.call(),
1198 symbol_call.call(),
1199 decimals_call.call(),
1200 total_supply_call.call(),
1201 balance_call.call(),
1202 allowance_call.call(),
1203 async_deposit_call.call(),
1204 async_redeem_call.call(),
1205 );
1206 let async_deposit = async_deposit.unwrap_or(false);
1207 let async_redeem = async_redeem.unwrap_or(false);
1208
1209 let mut checks = Vec::new();
1210 match code {
1211 Ok(code) if !code.is_empty() => push_check(
1212 &mut checks,
1213 "contract code",
1214 CheckStatus::Pass,
1215 "contract bytecode is present",
1216 ),
1217 Ok(_) => push_check(
1218 &mut checks,
1219 "contract code",
1220 CheckStatus::Fail,
1221 "no contract bytecode was found",
1222 ),
1223 Err(_) => push_check(
1224 &mut checks,
1225 "contract code",
1226 CheckStatus::Fail,
1227 "contract bytecode could not be read",
1228 ),
1229 }
1230
1231 if let Ok(asset) = asset {
1232 if asset.is_zero() {
1233 push_check(&mut checks, "asset()", CheckStatus::Fail, "returned the zero address");
1234 } else if asset == NATIVE_ASSET {
1235 push_check(
1236 &mut checks,
1237 "asset()",
1238 CheckStatus::Warn,
1239 "returned the ERC-7535 native-asset sentinel",
1240 );
1241 } else {
1242 push_check(&mut checks, "asset()", CheckStatus::Pass, format!("returned {asset}"));
1243 match provider.get_code_at(asset).block_id(block).await {
1244 Ok(code) if !code.is_empty() => push_check(
1245 &mut checks,
1246 "asset contract",
1247 CheckStatus::Pass,
1248 "underlying asset bytecode is present",
1249 ),
1250 Ok(_) => push_check(
1251 &mut checks,
1252 "asset contract",
1253 CheckStatus::Warn,
1254 "underlying asset has no bytecode and may be a system contract or precompile",
1255 ),
1256 Err(_) => push_check(
1257 &mut checks,
1258 "asset contract",
1259 CheckStatus::Warn,
1260 "underlying asset bytecode could not be read",
1261 ),
1262 }
1263 record_required(
1264 &mut checks,
1265 "asset balanceOf(address)",
1266 IERC20Metadata::new(asset, &provider).balanceOf(vault).block(block).call().await,
1267 );
1268 }
1269 } else {
1270 push_check(
1271 &mut checks,
1272 "asset()",
1273 CheckStatus::Fail,
1274 "call failed or returned incompatible data",
1275 );
1276 }
1277
1278 record_required(&mut checks, "totalAssets()", total_assets);
1279 record_required(&mut checks, "totalSupply()", total_supply);
1280 record_required(&mut checks, "balanceOf(address)", balance);
1281 record_required(&mut checks, "allowance(address,address)", allowance);
1282 record_zero_conversion(&mut checks, "convertToShares(0)", convert_to_shares);
1283 record_zero_conversion(&mut checks, "convertToAssets(0)", convert_to_assets);
1284 record_required(&mut checks, "maxDeposit(address)", max_deposit);
1285 record_preview(&mut checks, "previewDeposit(0)", "deposit", async_deposit, preview_deposit);
1286 record_required(&mut checks, "maxMint(address)", max_mint);
1287 record_preview(&mut checks, "previewMint(0)", "deposit", async_deposit, preview_mint);
1288 record_required(&mut checks, "maxWithdraw(address)", max_withdraw);
1289 record_preview(&mut checks, "previewWithdraw(0)", "redeem", async_redeem, preview_withdraw);
1290 record_required(&mut checks, "maxRedeem(address)", max_redeem);
1291 record_preview(&mut checks, "previewRedeem(0)", "redeem", async_redeem, preview_redeem);
1292 record_required(&mut checks, "name()", name);
1293 record_required(&mut checks, "symbol()", symbol);
1294 record_required(&mut checks, "decimals()", decimals);
1295
1296 let passed = checks.iter().filter(|check| matches!(check.status, CheckStatus::Pass)).count();
1297 let warnings = checks.iter().filter(|check| matches!(check.status, CheckStatus::Warn)).count();
1298 let failed = checks.iter().filter(|check| matches!(check.status, CheckStatus::Fail)).count();
1299 let report = CompatibilityReport {
1300 vault: vault.to_string(),
1301 account: account.to_string(),
1302 read_compatible: failed == 0,
1303 disclaimer: CHECK_DISCLAIMER,
1304 passed,
1305 warnings,
1306 failed,
1307 checks,
1308 };
1309 if failed > 0 {
1310 let message = format!("vault failed {failed} ERC-4626 compatibility probe(s)");
1311 if shell::is_json() {
1312 return Err(JsonError::new(
1313 report,
1314 JsonMessage::error("erc4626.compatibility_failed", message),
1315 )?
1316 .into());
1317 }
1318 print_compatibility_report(&report)?;
1319 eyre::bail!(message)
1320 }
1321 print_compatibility_report(&report)
1322}
1323
1324fn print_info(info: VaultInfo, human: bool, warnings: Vec<VaultWarning>) -> Result<()> {
1325 if shell::is_json() {
1326 return print_json_with_warnings(info, warnings);
1327 }
1328
1329 print_warnings(&warnings)?;
1330 let asset_symbol = info.asset_symbol.as_deref();
1331 let symbol = info.symbol.as_deref();
1332 print_field("Vault", &info.vault)?;
1333 print_field("Name", or_unavailable(info.name.as_ref()))?;
1334 print_field("Symbol", or_unavailable(symbol))?;
1335 print_field("Decimals", or_unavailable(info.decimals))?;
1336 print_field("Asset", &info.asset)?;
1337 print_field("Asset name", or_unavailable(info.asset_name.as_ref()))?;
1338 print_field("Asset symbol", or_unavailable(asset_symbol))?;
1339 print_field("Asset decimals", or_unavailable(info.asset_decimals))?;
1340 print_field("Total assets", display_amount(&info.total_assets, human, asset_symbol))?;
1341 print_field("Total supply", display_amount(&info.total_supply, human, symbol))?;
1342 print_field(
1343 "Assets per share",
1344 or_unavailable(
1345 info.assets_per_share.as_ref().map(|a| display_amount(a, human, asset_symbol)),
1346 ),
1347 )?;
1348 print_field(
1349 "Shares per asset",
1350 or_unavailable(info.shares_per_asset.as_ref().map(|a| display_amount(a, human, symbol))),
1351 )
1352}
1353
1354fn print_position(position: VaultPosition, human: bool, warnings: Vec<VaultWarning>) -> Result<()> {
1355 if shell::is_json() {
1356 return print_json_with_warnings(position, warnings);
1357 }
1358
1359 print_warnings(&warnings)?;
1360 let share_symbol = position.share_symbol.as_deref();
1361 let asset_symbol = position.asset_symbol.as_deref();
1362 print_field("Vault", &position.vault)?;
1363 print_field("Owner", &position.owner)?;
1364 print_field("Asset", &position.asset)?;
1365 print_field("Share symbol", or_unavailable(share_symbol))?;
1366 print_field("Share decimals", or_unavailable(position.share_decimals))?;
1367 print_field("Asset symbol", or_unavailable(asset_symbol))?;
1368 print_field("Asset decimals", or_unavailable(position.asset_decimals))?;
1369 print_field("Share balance", display_amount(&position.share_balance, human, share_symbol))?;
1370 print_field(
1371 "Assets equivalent",
1372 display_amount(&position.assets_equivalent, human, asset_symbol),
1373 )?;
1374 print_field("Max withdraw", display_amount(&position.max_withdraw, human, asset_symbol))?;
1375 print_field("Max redeem", display_amount(&position.max_redeem, human, share_symbol))
1376}
1377
1378fn print_compatibility_report(report: &CompatibilityReport) -> Result<()> {
1379 if shell::is_json() {
1380 return print_json_success(report);
1381 }
1382
1383 print_field("Vault", &report.vault)?;
1384 print_field("Account", &report.account)?;
1385 sh_println!("Note: {}", report.disclaimer)?;
1386 for check in &report.checks {
1387 let status = match check.status {
1388 CheckStatus::Pass => "PASS",
1389 CheckStatus::Warn => "WARN",
1390 CheckStatus::Fail => "FAIL",
1391 };
1392 sh_println!("{status:<4} {:<24} {}", check.name, check.detail)?;
1393 }
1394 sh_println!(
1395 "Summary: {} passed, {} warnings, {} failed",
1396 report.passed,
1397 report.warnings,
1398 report.failed
1399 )
1400}
1401
1402fn print_field(label: &str, value: impl std::fmt::Display) -> Result<()> {
1403 sh_println!("{label:<20} {value}")
1404}
1405
1406fn print_json_with_warnings<T: Serialize>(value: T, warnings: Vec<VaultWarning>) -> Result<()> {
1407 if warnings.is_empty() {
1408 print_json_success(value)
1409 } else {
1410 print_json_success_with_warnings(
1411 value,
1412 warnings
1413 .into_iter()
1414 .map(|warning| JsonMessage::warning(warning.code, warning.message))
1415 .collect(),
1416 )
1417 }
1418}
1419
1420fn print_warnings(warnings: &[VaultWarning]) -> Result<()> {
1421 for warning in warnings {
1422 sh_warn!("{}", warning.message)?;
1423 }
1424 Ok(())
1425}
1426
1427fn or_unavailable(value: Option<impl std::fmt::Display>) -> String {
1428 value.map_or_else(|| "<unavailable>".to_string(), |value| value.to_string())
1429}
1430
1431fn display_amount(amount: &TokenAmount, human: bool, symbol: Option<&str>) -> String {
1432 match (&amount.formatted, symbol.filter(|symbol| !symbol.is_empty())) {
1433 (Some(formatted), Some(symbol)) if human => format!("{formatted} {symbol}"),
1434 (Some(formatted), None) if human => formatted.clone(),
1435 _ => amount.raw.clone(),
1436 }
1437}
1438
1439fn decimal_unit(decimals: u8) -> Option<U256> {
1441 U256::from(10).checked_pow(U256::from(decimals))
1442}
1443
1444fn push_check(
1445 checks: &mut Vec<CompatibilityCheck>,
1446 name: impl Into<String>,
1447 status: CheckStatus,
1448 detail: impl Into<String>,
1449) {
1450 checks.push(CompatibilityCheck { name: name.into(), status, detail: detail.into() });
1451}
1452
1453fn record_required<T, E>(
1454 checks: &mut Vec<CompatibilityCheck>,
1455 name: &str,
1456 result: std::result::Result<T, E>,
1457) {
1458 let (status, detail) = if result.is_ok() {
1459 (CheckStatus::Pass, "call succeeded")
1460 } else {
1461 (CheckStatus::Fail, "call failed or returned incompatible data")
1462 };
1463 push_check(checks, name, status, detail);
1464}
1465
1466fn record_zero_conversion<E>(
1467 checks: &mut Vec<CompatibilityCheck>,
1468 name: &str,
1469 result: std::result::Result<U256, E>,
1470) {
1471 match result {
1472 Ok(value) if value.is_zero() => {
1473 push_check(checks, name, CheckStatus::Pass, "returned zero")
1474 }
1475 Ok(value) => push_check(
1476 checks,
1477 name,
1478 CheckStatus::Warn,
1479 format!("returned {value}; zero input normally converts to zero"),
1480 ),
1481 Err(_) => {
1482 push_check(checks, name, CheckStatus::Fail, "call failed or returned incompatible data")
1483 }
1484 }
1485}
1486
1487fn record_preview<E>(
1488 checks: &mut Vec<CompatibilityCheck>,
1489 name: &str,
1490 request_kind: &str,
1491 async_supported: bool,
1492 result: std::result::Result<U256, E>,
1493) {
1494 match result {
1495 Ok(_) if async_supported => push_check(
1496 checks,
1497 name,
1498 CheckStatus::Warn,
1499 format!(
1500 "vault advertises asynchronous ERC-7540 {request_kind} support, which requires \
1501 this preview to revert"
1502 ),
1503 ),
1504 Ok(value) if value.is_zero() => {
1505 push_check(checks, name, CheckStatus::Pass, "returned zero")
1506 }
1507 Ok(value) => push_check(
1508 checks,
1509 name,
1510 CheckStatus::Warn,
1511 format!("returned {value}; a zero-amount preview normally returns zero"),
1512 ),
1513 Err(_) if async_supported => push_check(
1514 checks,
1515 name,
1516 CheckStatus::Warn,
1517 format!(
1518 "reverted as required by advertised asynchronous ERC-7540 {request_kind} support"
1519 ),
1520 ),
1521 Err(_) => push_check(
1522 checks,
1523 name,
1524 CheckStatus::Fail,
1525 "call failed or returned incompatible data without advertised ERC-7540 support",
1526 ),
1527 }
1528}
1529
1530fn warn_if_zero_entry_max(method: &str, amount: U256) -> Result<()> {
1531 if amount.is_zero() {
1532 sh_warn!(
1533 "Vault reported zero from {method}; some ERC-4626 vaults intentionally return \
1534 conservative maxima or gate deposits, so this may not mean deposits are impossible."
1535 )?;
1536 }
1537 Ok(())
1538}
1539
1540fn warn_if_zero_exit_max(method: &str) -> Result<()> {
1541 sh_warn!("{}", zero_exit_warning(method).message)
1542}
1543
1544fn warn_if_native_asset(asset: Address) -> Result<()> {
1545 if asset == NATIVE_ASSET {
1546 sh_warn!("{}", native_asset_warning().message)?;
1547 }
1548 Ok(())
1549}
1550
1551fn zero_exit_warning(method: &str) -> VaultWarning {
1552 VaultWarning {
1553 code: match method {
1554 "maxWithdraw" => "erc4626_zero_max_withdraw",
1555 "maxRedeem" => "erc4626_zero_max_redeem",
1556 _ => "erc4626_zero_exit_max",
1557 },
1558 message: format!(
1559 "Vault reported zero from {method} even though the owner has shares; liquidity, \
1560 gates, withdrawal queues, or a conservative implementation may prevent the base \
1561 ERC-4626 exit."
1562 ),
1563 }
1564}
1565
1566fn native_asset_warning() -> VaultWarning {
1567 VaultWarning {
1568 code: "erc4626_native_asset",
1569 message: "Vault uses the ERC-7535 native-asset sentinel; base ERC-4626 write commands do \
1570 not attach native value, so use `cast send --value` when the vault requires it."
1571 .to_string(),
1572 }
1573}
1574
1575async fn prepare_write<const N: usize>(
1577 vault: &NameOrAddress,
1578 accounts: [NameOrAddress; N],
1579 send_tx: &SendTxOpts,
1580) -> Result<[Address; N]> {
1581 let (provider, vault) = vault_at(&send_tx.eth.rpc, vault.clone()).await?;
1582 if let Ok(asset) = vault.asset().call().await {
1583 warn_if_native_asset(asset)?;
1584 }
1585
1586 let mut resolved = [Address::ZERO; N];
1587 for (slot, account) in resolved.iter_mut().zip(accounts) {
1588 *slot = account.resolve(&provider).await?;
1589 }
1590 Ok(resolved)
1591}
1592
1593async fn send_call<C: SolCall>(
1594 vault: NameOrAddress,
1595 call: C,
1596 send_tx: SendTxOpts,
1597 tx: TxParams,
1598) -> Result<()> {
1599 Box::pin(SendTxArgs::contract_call(vault, call.abi_encode(), send_tx, tx).run()).await
1601}
1602
1603#[cfg(test)]
1604mod tests {
1605 use super::*;
1606 use clap::CommandFactory;
1607
1608 #[test]
1609 fn all_subcommands_document_example_output() {
1610 let command = Erc4626Subcommand::command();
1611 let subcommands = command.get_subcommands().collect::<Vec<_>>();
1612 assert_eq!(subcommands.len(), 19);
1613
1614 for subcommand in subcommands {
1615 let help = subcommand
1616 .get_long_about()
1617 .unwrap_or_else(|| panic!("{} is missing long help", subcommand.get_name()))
1618 .to_string();
1619 assert!(
1620 help.contains("Example:\n\n```text\n$ cast erc4626"),
1621 "{} is missing a fenced example command",
1622 subcommand.get_name()
1623 );
1624 assert!(
1625 help.contains("\n\nOutput:\n\n```text\n"),
1626 "{} is missing fenced example output",
1627 subcommand.get_name()
1628 );
1629 }
1630
1631 let info = command.find_subcommand("info").unwrap().get_long_about().unwrap().to_string();
1632 assert!(info.contains("--human"));
1633 }
1634}