Skip to main content

o2_deploy/
eval_shop.rs

1//! Optional EvalShop deployment and explicit, compare-first configuration.
2
3use 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    /// Base units as a decimal string; explicit null removes the listing.
32    /// Required even when null, to prevent a missing field from disabling sales.
33    #[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    /// Required address:0x... or contract:0x... identity. Only this payee receives withdrawals.
57    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    // The caller may deliberately retire its pricing authority in the same run
208    // that updates prices. Apply explicit revocations only after those writes.
209    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}