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        let members =
179            format!("[{}]", members.iter().map(Address::to_string).collect::<Vec<_>>().join(","));
180        (
181            "createPolicyWithAccounts(address,uint8,address[])",
182            vec![admin.to_string(), type_arg, members],
183        )
184    };
185    send_tip20_transaction(
186        NameOrAddress::Address(TIP403_REGISTRY_ADDRESS),
187        sig,
188        args,
189        send_tx,
190        tx,
191        signer,
192        access_key,
193    )
194    .await
195}
196
197async fn modify(kind: PolicyKind, args: MembershipArgs) -> Result<()> {
198    let MembershipArgs { action, policy_id, account, send_tx, tx } = args;
199    let config = send_tx.eth.rpc.load_config()?;
200    let provider = get_provider(&config)?;
201    let account = account.resolve(&provider).await?;
202    warn_if_virtual(account)?;
203
204    let flag = matches!(action, MembershipAction::Add);
205    let (signer, access_key) = resolve_tip20_signer(&send_tx, &tx).await?;
206    let sig = match kind {
207        PolicyKind::Whitelist => "modifyPolicyWhitelist(uint64,address,bool)",
208        PolicyKind::Blacklist => "modifyPolicyBlacklist(uint64,address,bool)",
209    };
210    send_tip20_transaction(
211        NameOrAddress::Address(TIP403_REGISTRY_ADDRESS),
212        sig,
213        vec![policy_id.to_string(), account.to_string(), flag.to_string()],
214        send_tx,
215        tx,
216        signer,
217        access_key,
218    )
219    .await
220}
221
222async fn info(policy_id: u64, rpc: RpcOpts) -> Result<()> {
223    let config = rpc.load_config()?;
224    let provider = get_provider(&config)?;
225    let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider);
226    let builtin = builtin_label(policy_id);
227
228    if !registry.policyExists(policy_id).call().await? {
229        let payload = json!({ "policy_id": policy_id, "exists": false, "builtin": builtin });
230        return print_payload(payload, |_| sh_println!("Policy {policy_id} does not exist"));
231    }
232
233    let data = registry.policyData(policy_id).call().await?;
234    let payload = json!({
235        "policy_id": policy_id,
236        "exists": true,
237        "builtin": builtin,
238        "policy_type": policy_type_label(data.policyType),
239        "admin": format!("{}", data.admin),
240    });
241    print_payload(payload, |payload| {
242        sh_println!(
243            "Policy ID: {}\n\
244             Built-in:  {}\n\
245             Type:      {}\n\
246             Admin:     {}",
247            payload["policy_id"],
248            payload["builtin"].as_str().unwrap_or("no"),
249            payload["policy_type"].as_str().unwrap_or_default(),
250            payload["admin"].as_str().unwrap_or_default(),
251        )
252    })
253}
254
255async fn check(
256    policy_id: u64,
257    address: NameOrAddress,
258    role: Option<PolicyRole>,
259    rpc: RpcOpts,
260) -> Result<()> {
261    let config = rpc.load_config()?;
262    let provider = get_provider(&config)?;
263    let address = address.resolve(&provider).await?;
264    let registry = ITIP403Registry::new(TIP403_REGISTRY_ADDRESS, provider);
265    let authorized = match role {
266        None => registry.isAuthorized(policy_id, address).call().await?,
267        Some(PolicyRole::Sender) => registry.isAuthorizedSender(policy_id, address).call().await?,
268        Some(PolicyRole::Recipient) => {
269            registry.isAuthorizedRecipient(policy_id, address).call().await?
270        }
271        Some(PolicyRole::MintRecipient) => {
272            registry.isAuthorizedMintRecipient(policy_id, address).call().await?
273        }
274    };
275
276    let payload = json!({
277        "policy_id": policy_id,
278        "address": format!("{address}"),
279        "role": role_label(role),
280        "authorized": authorized,
281    });
282    print_payload(payload, |payload| {
283        sh_println!(
284            "Policy ID:  {}\n\
285             Address:    {}\n\
286             Role:       {}\n\
287             Authorized: {}",
288            payload["policy_id"],
289            payload["address"].as_str().unwrap_or_default(),
290            payload["role"].as_str().unwrap_or_default(),
291            payload["authorized"].as_bool().unwrap_or_default(),
292        )
293    })
294}
295
296/// Warn (but don't fail) on virtual members; only T3+ chains reject them on-chain.
297fn warn_if_virtual(account: Address) -> Result<()> {
298    if account.is_virtual() {
299        sh_warn!(
300            "{account} looks like a TIP-1022 virtual address; on T3+ chains it is rejected as a \
301             literal policy member. Resolve it to its master with `cast vaddr resolve {account}`."
302        )?;
303    }
304    Ok(())
305}
306
307impl PolicyKind {
308    const fn to_sol(self) -> ITIP403Registry::PolicyType {
309        match self {
310            Self::Whitelist => ITIP403Registry::PolicyType::WHITELIST,
311            Self::Blacklist => ITIP403Registry::PolicyType::BLACKLIST,
312        }
313    }
314}
315
316const fn policy_type_label(policy_type: ITIP403Registry::PolicyType) -> &'static str {
317    match policy_type {
318        ITIP403Registry::PolicyType::WHITELIST => "whitelist",
319        ITIP403Registry::PolicyType::BLACKLIST => "blacklist",
320        ITIP403Registry::PolicyType::COMPOUND => "compound",
321        _ => "unknown",
322    }
323}
324
325const fn builtin_label(policy_id: u64) -> Option<&'static str> {
326    match policy_id {
327        0 => Some("reject-all"),
328        1 => Some("allow-all"),
329        _ => None,
330    }
331}
332
333const fn role_label(role: Option<PolicyRole>) -> &'static str {
334    match role {
335        None => "transfer",
336        Some(PolicyRole::Sender) => "sender",
337        Some(PolicyRole::Recipient) => "recipient",
338        Some(PolicyRole::MintRecipient) => "mint-recipient",
339    }
340}