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
18const DEFAULT_ADMIN_ROLE: &str = "DEFAULT_ADMIN_ROLE";
20
21const HASHED_ROLES: [&str; 5] =
23 ["ISSUER_ROLE", "PAUSE_ROLE", "UNPAUSE_ROLE", "BURN_BLOCKED_ROLE", "BURN_AT_ROLE"];
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct Tip20Role(B256);
28
29impl Tip20Role {
30 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 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 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#[derive(Clone, Copy, Debug)]
81pub(super) enum RoleUpdate {
82 Grant,
83 Revoke,
84}
85
86pub(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 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
120pub(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
153async 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}