Skip to main content

cast/cmd/tip20/
role.rs

1use crate::{
2    cmd::send::SendTxArgs,
3    tempo::{print_payload, tempo_provider},
4    tx::{SendTxOpts, TxParams},
5};
6use alloy_ens::NameOrAddress;
7use alloy_primitives::{Address, B256, keccak256};
8use alloy_provider::Provider;
9use alloy_signer::Signer;
10use alloy_sol_types::SolCall;
11use eyre::{Result, WrapErr};
12use foundry_cli::opts::RpcOpts;
13use serde_json::json;
14use std::{fmt, str::FromStr};
15use tempo_alloy::TempoNetwork;
16use tempo_contracts::precompiles::IRolesAuth;
17
18/// Name of the root admin role, whose identifier is the zero hash.
19const DEFAULT_ADMIN_ROLE: &str = "DEFAULT_ADMIN_ROLE";
20
21/// Names of the TIP-20 roles whose identifier is the keccak256 hash of the name.
22const HASHED_ROLES: [&str; 5] =
23    ["ISSUER_ROLE", "PAUSE_ROLE", "UNPAUSE_ROLE", "BURN_BLOCKED_ROLE", "BURN_AT_ROLE"];
24
25/// A TIP-20 role identifier, parsed from a role name or a raw 32-byte hash.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct Tip20Role(B256);
28
29impl Tip20Role {
30    /// Returns the canonical name of a role defined by the TIP-20 precompile.
31    fn name(&self) -> Option<&'static str> {
32        if self.0.is_zero() {
33            return Some(DEFAULT_ADMIN_ROLE);
34        }
35        HASHED_ROLES.into_iter().find(|name| keccak256(name) == self.0)
36    }
37
38    /// Returns the role name, or its hash for roles the TIP-20 precompile does not define.
39    fn label(&self) -> String {
40        self.name().map_or_else(|| self.0.to_string(), str::to_string)
41    }
42}
43
44impl FromStr for Tip20Role {
45    type Err = String;
46
47    fn from_str(s: &str) -> Result<Self, Self::Err> {
48        if let Ok(hash) = s.parse() {
49            return Ok(Self(hash));
50        }
51
52        // Accept `issuer`, `burn-at`, `BURN_AT_ROLE`, and similar spellings.
53        let mut name = s.to_ascii_uppercase().replace('-', "_");
54        if !name.ends_with("_ROLE") {
55            name.push_str("_ROLE");
56        }
57        if name == DEFAULT_ADMIN_ROLE || name == "ADMIN_ROLE" {
58            return Ok(Self(B256::ZERO));
59        }
60        if HASHED_ROLES.contains(&name.as_str()) {
61            return Ok(Self(keccak256(&name)));
62        }
63        Err(format!(
64            "unknown TIP-20 role `{s}`; expected one of admin, issuer, pause, unpause, \
65             burn-blocked, burn-at, or a 32-byte role hash"
66        ))
67    }
68}
69
70impl fmt::Display for Tip20Role {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        match self.name() {
73            Some(name) => write!(f, "{name} ({})", self.0),
74            None => write!(f, "{}", self.0),
75        }
76    }
77}
78
79/// Whether a role membership update grants or revokes the role.
80#[derive(Clone, Copy, Debug)]
81pub(super) enum RoleUpdate {
82    Grant,
83    Revoke,
84}
85
86/// Grants or revokes `role` for `account` on `token`.
87pub(super) async fn update(
88    update: RoleUpdate,
89    token: NameOrAddress,
90    role: Tip20Role,
91    account: NameOrAddress,
92    send_tx: SendTxOpts,
93    tx: TxParams,
94) -> Result<()> {
95    let (_, provider) = tempo_provider(&send_tx.eth.rpc)?;
96    let token = token.resolve(&provider).await?;
97    let account = account.resolve(&provider).await?;
98
99    let (signer, access_key) = super::resolve_tip20_signer(&send_tx, &tx).await?;
100    // The sender is only known up front for local signers and access keys. Browser wallets skip
101    // the check, which the precompile still enforces on-chain.
102    let sender = match (&access_key, &signer) {
103        (Some(wallet), _) => Some(wallet.account()),
104        (None, Some(signer)) => Some(signer.address()),
105        (None, None) => None,
106    };
107    if let Some(sender) = sender {
108        ensure_role_admin(&provider, update, token, role, sender).await?;
109    }
110
111    let data = match update {
112        RoleUpdate::Grant => IRolesAuth::grantRoleCall { role: role.0, account }.abi_encode(),
113        RoleUpdate::Revoke => IRolesAuth::revokeRoleCall { role: role.0, account }.abi_encode(),
114    };
115    SendTxArgs::contract_call(NameOrAddress::Address(token), data, send_tx, tx)
116        .run_generic::<TempoNetwork>(signer, access_key)
117        .await
118}
119
120/// Prints whether `account` holds `role` on `token`.
121pub(super) async fn has_role(
122    token: NameOrAddress,
123    role: Tip20Role,
124    account: NameOrAddress,
125    rpc: RpcOpts,
126) -> Result<()> {
127    let (_, provider) = tempo_provider(&rpc)?;
128    let token = token.resolve(&provider).await?;
129    let account = account.resolve(&provider).await?;
130    let has_role = IRolesAuth::new(token, &provider)
131        .hasRole(account, role.0)
132        .call()
133        .await
134        .wrap_err_with(|| format!("failed to read roles of TIP-20 token {token}"))?;
135
136    let payload = json!({
137        "token": format!("{token}"),
138        "role": format!("{}", role.0),
139        "role_name": role.name(),
140        "account": format!("{account}"),
141        "has_role": has_role,
142    });
143    print_payload(payload, |_| {
144        sh_println!(
145            "Token:    {token}\n\
146             Role:     {role}\n\
147             Account:  {account}\n\
148             Has role: {has_role}"
149        )
150    })
151}
152
153/// Fails early when `sender` does not hold the admin role of `role`, instead of submitting a
154/// transaction the precompile rejects with a bare `Unauthorized()`.
155async fn ensure_role_admin<P: Provider<TempoNetwork>>(
156    provider: &P,
157    update: RoleUpdate,
158    token: Address,
159    role: Tip20Role,
160    sender: Address,
161) -> Result<()> {
162    let roles = IRolesAuth::new(token, provider);
163    let read_err = || format!("failed to read roles of TIP-20 token {token}");
164    let admin_role = Tip20Role(roles.getRoleAdmin(role.0).call().await.wrap_err_with(read_err)?);
165    if !roles.hasRole(sender, admin_role.0).call().await.wrap_err_with(read_err)? {
166        let action = match update {
167            RoleUpdate::Grant => "grant",
168            RoleUpdate::Revoke => "revoke",
169        };
170        eyre::bail!(
171            "{sender} cannot {action} {} on TIP-20 token {token}: it does not hold {}, the role's \
172             admin role",
173            role.label(),
174            admin_role.label()
175        );
176    }
177    Ok(())
178}
179
180#[cfg(test)]
181mod tests {
182
183    use super::*;
184
185    #[test]
186    fn parses_role_names_and_hashes() {
187        let burn_at = Tip20Role(keccak256("BURN_AT_ROLE"));
188        for spelling in ["burn-at", "burn_at", "BURN_AT", "BURN_AT_ROLE", "burn-at-role"] {
189            assert_eq!(spelling.parse(), Ok(burn_at), "{spelling}");
190        }
191        for spelling in ["admin", "default-admin", "DEFAULT_ADMIN_ROLE"] {
192            assert_eq!(spelling.parse(), Ok(Tip20Role(B256::ZERO)), "{spelling}");
193        }
194
195        let custom = B256::with_last_byte(0xab);
196        assert_eq!(custom.to_string().parse(), Ok(Tip20Role(custom)));
197        assert_eq!(Tip20Role(custom).name(), None);
198        assert_eq!(burn_at.name(), Some("BURN_AT_ROLE"));
199
200        assert!("minter".parse::<Tip20Role>().is_err());
201    }
202}