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