1use anyhow::{
4 Context,
5 Result,
6 ensure,
7};
8use fuels::{
9 prelude::*,
10 types::Identity,
11};
12use o2_tools::eval_shop::{
13 DEFAULT_ADMIN_ROLE,
14 EvalShopDeployConfig,
15 EvalShopDeployment,
16 FEE_WITHDRAWER_ROLE,
17 PAUSER_ROLE,
18 PRICE_MANAGER_ROLE,
19};
20use serde::{
21 Deserialize,
22 Serialize,
23};
24use std::collections::HashSet;
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct EvalShopPriceConfig {
29 pub tier_id: u64,
30 pub asset_id: AssetId,
31 #[serde(deserialize_with = "read_price", serialize_with = "write_price")]
34 pub price: Option<u64>,
35}
36
37fn read_price<'de, D: serde::Deserializer<'de>>(
38 deserializer: D,
39) -> Result<Option<u64>, D::Error> {
40 Option::<String>::deserialize(deserializer)?
41 .map(|value| value.parse().map_err(serde::de::Error::custom))
42 .transpose()
43}
44
45fn write_price<S: serde::Serializer>(
46 price: &Option<u64>,
47 serializer: S,
48) -> Result<S::Ok, S::Error> {
49 price.map(|value| value.to_string()).serialize(serializer)
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(deny_unknown_fields)]
54pub struct EvalShopConfig {
55 pub id: Option<ContractId>,
56 pub treasury: String,
58 #[serde(default)]
59 pub prices: Vec<EvalShopPriceConfig>,
60 #[serde(default)]
61 pub price_managers: Vec<String>,
62 #[serde(default)]
63 pub remove_price_managers: Vec<String>,
64 #[serde(default)]
65 pub fee_withdrawers: Vec<String>,
66 #[serde(default)]
67 pub remove_fee_withdrawers: Vec<String>,
68 #[serde(default)]
69 pub pausers: Vec<String>,
70 #[serde(default)]
71 pub remove_pausers: Vec<String>,
72}
73
74fn identity(value: &str) -> Result<Identity> {
75 let id = super::parse_margin_identity(value)?;
76 let nonzero = match id {
77 Identity::Address(address) => address != Address::zeroed(),
78 Identity::ContractId(contract) => contract != ContractId::zeroed(),
79 };
80 ensure!(nonzero, "EvalShop identity cannot be zero");
81 Ok(id)
82}
83
84pub(crate) fn validate_config(config: &EvalShopConfig) -> Result<()> {
85 identity(&config.treasury).context("invalid EvalShop treasury")?;
86 ensure!(
87 config.id != Some(ContractId::zeroed()),
88 "EvalShop id cannot be zero"
89 );
90 let mut prices = HashSet::new();
91 for price in &config.prices {
92 ensure!(
93 prices.insert((price.tier_id, price.asset_id)),
94 "duplicate EvalShop tier/asset price: {} / {}",
95 price.tier_id,
96 price.asset_id
97 );
98 }
99 for (grants, revokes) in [
100 (&config.price_managers, &config.remove_price_managers),
101 (&config.fee_withdrawers, &config.remove_fee_withdrawers),
102 (&config.pausers, &config.remove_pausers),
103 ] {
104 let grants = grants
105 .iter()
106 .map(|s| identity(s))
107 .collect::<Result<Vec<_>>>()?;
108 for revoke in revokes {
109 ensure!(
110 !grants.contains(&identity(revoke)?),
111 "conflicting EvalShop role grant/revoke for {revoke}"
112 );
113 }
114 }
115 Ok(())
116}
117
118pub(crate) async fn deploy_and_reconcile<W: Account + Clone>(
119 wallet: &W,
120 config: &EvalShopConfig,
121 registry: ContractId,
122 salt: Salt,
123 params: &super::DeployParams,
124) -> Result<EvalShopConfig> {
125 validate_config(config)?;
126 let deployment = EvalShopDeployment::deploy(
127 wallet,
128 &EvalShopDeployConfig {
129 contract_id: config.id,
130 salt,
131 upgrade_bytecode: params.upgrade_bytecode,
132 ..Default::default()
133 },
134 )
135 .await?;
136 let shop = &deployment.shop;
137 let treasury = identity(&config.treasury)?;
138 if shop
139 .methods()
140 .registry()
141 .simulate(Execution::state_read_only())
142 .await?
143 .value
144 != Some(registry)
145 {
146 shop.methods()
147 .set_registry(registry)
148 .call()
149 .await
150 .context("set EvalShop registry")?;
151 }
152 if shop
153 .methods()
154 .treasury()
155 .simulate(Execution::state_read_only())
156 .await?
157 .value
158 != Some(treasury)
159 {
160 shop.methods()
161 .set_treasury(treasury)
162 .call()
163 .await
164 .context("set EvalShop treasury")?;
165 }
166 for (role, grants) in [
167 (PRICE_MANAGER_ROLE, &config.price_managers),
168 (FEE_WITHDRAWER_ROLE, &config.fee_withdrawers),
169 (PAUSER_ROLE, &config.pausers),
170 ] {
171 for value in grants {
172 let who = identity(value)?;
173 if !shop
174 .methods()
175 .has_role(role, who)
176 .simulate(Execution::state_read_only())
177 .await?
178 .value
179 {
180 shop.methods().grant_role(role, who).call().await?;
181 }
182 }
183 }
184 for entry in &config.prices {
185 let current = shop
186 .methods()
187 .price(entry.tier_id, entry.asset_id)
188 .simulate(Execution::state_read_only())
189 .await?
190 .value;
191 if current != entry.price {
192 ensure!(
193 shop.methods()
194 .has_role(PRICE_MANAGER_ROLE, Identity::Address(wallet.address()))
195 .simulate(Execution::state_read_only())
196 .await?
197 .value,
198 "EvalShop price reconciliation requires PRICE_MANAGER_ROLE; include the deployment wallet in eval_shop.price_managers or grant it explicitly"
199 );
200 shop.methods()
201 .set_price(entry.tier_id, entry.asset_id, entry.price)
202 .call()
203 .await
204 .context("set EvalShop price")?;
205 }
206 }
207 for (role, revokes) in [
210 (PRICE_MANAGER_ROLE, &config.remove_price_managers),
211 (FEE_WITHDRAWER_ROLE, &config.remove_fee_withdrawers),
212 (PAUSER_ROLE, &config.remove_pausers),
213 ] {
214 for value in revokes {
215 let who = identity(value)?;
216 if shop
217 .methods()
218 .has_role(role, who)
219 .simulate(Execution::state_read_only())
220 .await?
221 .value
222 {
223 shop.methods().revoke_role(role, who).call().await?;
224 }
225 }
226 }
227 if let Some(owner) = params.new_proxy_owner {
228 let desired =
229 o2_tools::generic_proxy::State::Initialized(Identity::Address(owner));
230 if deployment
231 .proxy
232 .methods()
233 .proxy_owner()
234 .simulate(Execution::state_read_only())
235 .await?
236 .value
237 != desired
238 {
239 deployment
240 .proxy
241 .methods()
242 .set_owner(Identity::Address(owner))
243 .call()
244 .await?;
245 }
246 }
247 if let Some(owner) = params.new_contract_owner {
248 let new_admin = Identity::Address(owner);
249 let caller = Identity::Address(wallet.address());
250 if !shop
251 .methods()
252 .has_role(DEFAULT_ADMIN_ROLE, new_admin)
253 .simulate(Execution::state_read_only())
254 .await?
255 .value
256 {
257 shop.methods()
258 .grant_role(DEFAULT_ADMIN_ROLE, new_admin)
259 .call()
260 .await?;
261 }
262 if caller != new_admin
263 && shop
264 .methods()
265 .has_role(DEFAULT_ADMIN_ROLE, caller)
266 .simulate(Execution::state_read_only())
267 .await?
268 .value
269 {
270 shop.methods()
271 .revoke_role(DEFAULT_ADMIN_ROLE, caller)
272 .call()
273 .await?;
274 }
275 }
276 Ok(EvalShopConfig {
277 id: Some(deployment.contract_id),
278 ..config.clone()
279 })
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn optional_prices_preserve_zero_and_require_explicit_null_for_removal() {
288 let asset = AssetId::new([1; 32]);
289 let value = serde_json::json!({"tier_id": 1, "asset_id": asset, "price": "0"});
290 let mut config: EvalShopPriceConfig = serde_json::from_value(value).unwrap();
291 assert_eq!(config.price, Some(0));
292 config.price = None;
293 let value = serde_json::to_value(&config).unwrap();
294 assert!(value["price"].is_null());
295 assert_eq!(
296 serde_json::from_value::<EvalShopPriceConfig>(value)
297 .unwrap()
298 .price,
299 None
300 );
301 assert!(
302 serde_json::from_value::<EvalShopPriceConfig>(
303 serde_json::json!({"tier_id": 1, "asset_id": asset})
304 )
305 .is_err()
306 );
307 }
308
309 #[test]
310 fn duplicate_prices_and_conflicting_roles_are_rejected() {
311 let owner = format!("address:{}", Address::new([1; 32]));
312 let mut config = EvalShopConfig {
313 id: None,
314 treasury: owner.clone(),
315 prices: vec![],
316 price_managers: vec![owner.clone()],
317 remove_price_managers: vec![owner],
318 fee_withdrawers: vec![],
319 remove_fee_withdrawers: vec![],
320 pausers: vec![],
321 remove_pausers: vec![],
322 };
323 assert!(
324 validate_config(&config)
325 .unwrap_err()
326 .to_string()
327 .contains("conflicting")
328 );
329 config.remove_price_managers.clear();
330 let price = EvalShopPriceConfig {
331 tier_id: 1,
332 asset_id: AssetId::new([2; 32]),
333 price: Some(0),
334 };
335 config.prices = vec![price.clone(), price];
336 assert!(
337 validate_config(&config)
338 .unwrap_err()
339 .to_string()
340 .contains("duplicate")
341 );
342 }
343}