Skip to main content

cast/cmd/
tip403.rs

1use crate::{
2    cmd::tip20::{resolve_tip20_signer, send_tip20_transaction},
3    tempo::print_payload,
4    tx::{SendTxOpts, TxParams},
5};
6use alloy_ens::NameOrAddress;
7use alloy_primitives::Address;
8use clap::{Parser, ValueEnum};
9use eyre::Result;
10use foundry_cli::{
11    opts::RpcOpts,
12    utils::{LoadConfig, get_provider},
13};
14use serde_json::json;
15use std::str::FromStr;
16use tempo_contracts::precompiles::{ITIP403Registry, TIP403_REGISTRY_ADDRESS};
17use tempo_primitives::TempoAddressExt;
18
19/// TIP-403 policy registry operations (Tempo).
20///
21/// Policies created here are referenced by ID from `cast receive-policy set` (sender policy and
22/// token filter) and by TIP-20 token compliance configuration.
23#[derive(Debug, Parser, Clone)]
24pub enum Tip403Subcommand {
25    /// Create a new simple (whitelist or blacklist) policy.
26    Create {
27        /// Policy type to create.
28        #[arg(value_enum)]
29        policy_type: PolicyKind,
30
31        /// Address authorized to modify the policy.
32        #[arg(long, value_parser = NameOrAddress::from_str)]
33        admin: NameOrAddress,
34
35        /// Initial member(s) to seed the policy with. Can be specified multiple times.
36        #[arg(long = "member", value_name = "ADDRESS", value_parser = NameOrAddress::from_str)]
37        accounts: Vec<NameOrAddress>,
38
39        #[command(flatten)]
40        send_tx: SendTxOpts,
41
42        #[command(flatten)]
43        tx: TxParams,
44    },
45
46    /// Add or remove an account from a whitelist policy.
47    Whitelist {
48        #[command(flatten)]
49        args: MembershipArgs,
50    },
51
52    /// Add or remove an account from a blacklist policy.
53    Blacklist {
54        #[command(flatten)]
55        args: MembershipArgs,
56    },
57
58    /// Show a policy's type and admin.
59    Info {
60        /// Policy ID to inspect.
61        policy_id: u64,
62
63        #[command(flatten)]
64        rpc: RpcOpts,
65    },
66
67    /// Check whether an address is authorized by a policy.
68    Check {
69        /// Policy ID to evaluate.
70        policy_id: u64,
71
72        /// Address to check.
73        #[arg(value_parser = NameOrAddress::from_str)]
74        address: NameOrAddress,
75
76        /// Role to evaluate (defaults to the transfer check). Role variants require T2+.
77        #[arg(long, value_enum)]
78        role: Option<PolicyRole>,
79
80        #[command(flatten)]
81        rpc: RpcOpts,
82    },
83}
84
85#[derive(Debug, Clone, clap::Args)]
86pub struct MembershipArgs {
87    /// Whether to add or remove the account.
88    #[arg(value_enum)]
89    pub action: MembershipAction,
90
91    /// Policy ID to modify.
92    pub policy_id: u64,
93
94    /// Account to add or remove.
95    #[arg(value_parser = NameOrAddress::from_str)]
96    pub account: NameOrAddress,
97
98    #[command(flatten)]
99    pub send_tx: SendTxOpts,
100
101    #[command(flatten)]
102    pub tx: TxParams,
103}
104
105#[derive(Debug, Clone, Copy, ValueEnum)]
106pub enum PolicyKind {
107    Whitelist,
108    Blacklist,
109}
110
111#[derive(Debug, Clone, Copy, ValueEnum)]
112pub enum MembershipAction {
113    Add,
114    Remove,
115}
116
117#[derive(Debug, Clone, Copy, ValueEnum)]
118pub enum PolicyRole {
119    Sender,
120    Recipient,
121    MintRecipient,
122}
123
124impl Tip403Subcommand {
125    pub async fn run(self) -> Result<()> {
126        match self {
127            Self::Create { policy_type, admin, accounts, send_tx, tx } => {
128                create(policy_type, admin, accounts, send_tx, tx).await
129            }
130            Self::Whitelist { args } => modify(PolicyKind::Whitelist, args).await,
131            Self::Blacklist { args } => modify(PolicyKind::Blacklist, args).await,
132            Self::Info { policy_id, rpc } => info(policy_id, rpc).await,
133            Self::Check { policy_id, address, role, rpc } => {
134                check(policy_id, address, role, rpc).await
135            }
136        }
137    }
138}
139
140async fn create(
141    policy_type: PolicyKind,
142    admin: NameOrAddress,
143    accounts: Vec<NameOrAddress>,
144    send_tx: SendTxOpts,
145    tx: TxParams,
146) -> Result<()> {
147    let config = send_tx.eth.rpc.load_config()?;
148    let provider = get_provider(&config)?;
149    let admin = admin.resolve(&provider).await?;
150
151    let mut members = Vec::with_capacity(accounts.len());
152    for account in accounts {
153        let account = account.resolve(&provider).await?;
154        warn_if_virtual(account)?;
155        members.push(account);
156    }
157
158    // Preview the policy ID the registry would assign. This is the next counter value, so it is
159    // only accurate if no other policy is created before this transaction lands.
160    let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, &provider);
161    let policy_type_sol = policy_type.to_sol();
162    let expected_id = if members.is_empty() {
163        registry.createPolicy(admin, policy_type_sol).call().await?
164    } else {
165        registry.createPolicyWithAccounts(admin, policy_type_sol, members.clone()).call().await?
166    };
167    // Non-authoritative: the real ID is the one in the PolicyCreated event of this transaction.
168    sh_status!(
169        "Expected policy ID: {expected_id} (only if this tx is mined before any other policy \
170         creation; read the PolicyCreated event for the authoritative ID)"
171    )?;
172
173    let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?;
174    let type_arg = (policy_type_sol as u8).to_string();
175    let (sig, args) = if members.is_empty() {
176        ("createPolicy(address,uint8)", vec![admin.to_string(), type_arg])
177    } else {
178        (
179            "createPolicyWithAccounts(address,uint8,address[])",
180            vec![admin.to_string(), type_arg, address_array(&members)],
181        )
182    };
183    send_tip20_transaction(
184        NameOrAddress::Address(TIP403_REGISTRY_ADDRESS),
185        sig,
186        args,
187        send_tx,
188        tx,
189        signer,
190        access_key,
191    )
192    .await
193}
194
195async fn modify(kind: PolicyKind, args: MembershipArgs) -> Result<()> {
196    let MembershipArgs { action, policy_id, account, send_tx, tx } = args;
197    let config = send_tx.eth.rpc.load_config()?;
198    let provider = get_provider(&config)?;
199    let account = account.resolve(&provider).await?;
200    warn_if_virtual(account)?;
201
202    let flag = matches!(action, MembershipAction::Add);
203    let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?;
204    let sig = match kind {
205        PolicyKind::Whitelist => "modifyPolicyWhitelist(uint64,address,bool)",
206        PolicyKind::Blacklist => "modifyPolicyBlacklist(uint64,address,bool)",
207    };
208    send_tip20_transaction(
209        NameOrAddress::Address(TIP403_REGISTRY_ADDRESS),
210        sig,
211        vec![policy_id.to_string(), account.to_string(), flag.to_string()],
212        send_tx,
213        tx,
214        signer,
215        access_key,
216    )
217    .await
218}
219
220async fn info(policy_id: u64, rpc: RpcOpts) -> Result<()> {
221    let config = rpc.load_config()?;
222    let provider = get_provider(&config)?;
223    let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider);
224    let builtin = builtin_label(policy_id);
225
226    if !registry.policyExists(policy_id).call().await? {
227        let payload = json!({ "policy_id": policy_id, "exists": false, "builtin": builtin });
228        return print_payload(payload, |_| sh_println!("Policy {policy_id} does not exist"));
229    }
230
231    let data = registry.policyData(policy_id).call().await?;
232    let payload = json!({
233        "policy_id": policy_id,
234        "exists": true,
235        "builtin": builtin,
236        "policy_type": policy_type_label(data.policyType),
237        "admin": format!("{}", data.admin),
238    });
239    print_payload(payload, |payload| {
240        sh_println!(
241            "Policy ID: {}\n\
242             Built-in:  {}\n\
243             Type:      {}\n\
244             Admin:     {}",
245            payload["policy_id"],
246            payload["builtin"].as_str().unwrap_or("no"),
247            payload["policy_type"].as_str().unwrap_or_default(),
248            payload["admin"].as_str().unwrap_or_default(),
249        )
250    })
251}
252
253async fn check(
254    policy_id: u64,
255    address: NameOrAddress,
256    role: Option<PolicyRole>,
257    rpc: RpcOpts,
258) -> Result<()> {
259    let config = rpc.load_config()?;
260    let provider = get_provider(&config)?;
261    let address = address.resolve(&provider).await?;
262    let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider);
263    let authorized = match role {
264        None => registry.isAuthorized(policy_id, address).call().await?,
265        Some(PolicyRole::Sender) => registry.isAuthorizedSender(policy_id, address).call().await?,
266        Some(PolicyRole::Recipient) => {
267            registry.isAuthorizedRecipient(policy_id, address).call().await?
268        }
269        Some(PolicyRole::MintRecipient) => {
270            registry.isAuthorizedMintRecipient(policy_id, address).call().await?
271        }
272    };
273
274    let payload = json!({
275        "policy_id": policy_id,
276        "address": format!("{address}"),
277        "role": role_label(role),
278        "authorized": authorized,
279    });
280    print_payload(payload, |payload| {
281        sh_println!(
282            "Policy ID:  {}\n\
283             Address:    {}\n\
284             Role:       {}\n\
285             Authorized: {}",
286            payload["policy_id"],
287            payload["address"].as_str().unwrap_or_default(),
288            payload["role"].as_str().unwrap_or_default(),
289            payload["authorized"].as_bool().unwrap_or_default(),
290        )
291    })
292}
293
294/// Warn (but don't fail) on virtual members; only T3+ chains reject them on-chain.
295fn warn_if_virtual(account: Address) -> Result<()> {
296    if account.is_virtual() {
297        sh_warn!(
298            "{account} looks like a TIP-1022 virtual address; on T3+ chains it is rejected as a \
299             literal policy member. Resolve it to its master with `cast vaddr resolve {account}`."
300        )?;
301    }
302    Ok(())
303}
304
305fn address_array(accounts: &[Address]) -> String {
306    format!("[{}]", accounts.iter().map(Address::to_string).collect::<Vec<_>>().join(","))
307}
308
309impl PolicyKind {
310    const fn to_sol(self) -> ITIP403Registry::PolicyType {
311        match self {
312            Self::Whitelist => ITIP403Registry::PolicyType::WHITELIST,
313            Self::Blacklist => ITIP403Registry::PolicyType::BLACKLIST,
314        }
315    }
316}
317
318const fn policy_type_label(policy_type: ITIP403Registry::PolicyType) -> &'static str {
319    match policy_type {
320        ITIP403Registry::PolicyType::WHITELIST => "whitelist",
321        ITIP403Registry::PolicyType::BLACKLIST => "blacklist",
322        ITIP403Registry::PolicyType::COMPOUND => "compound",
323        _ => "unknown",
324    }
325}
326
327const fn builtin_label(policy_id: u64) -> Option<&'static str> {
328    match policy_id {
329        0 => Some("reject-all"),
330        1 => Some("allow-all"),
331        _ => None,
332    }
333}
334
335const fn role_label(role: Option<PolicyRole>) -> &'static str {
336    match role {
337        None => "transfer",
338        Some(PolicyRole::Sender) => "sender",
339        Some(PolicyRole::Recipient) => "recipient",
340        Some(PolicyRole::MintRecipient) => "mint-recipient",
341    }
342}