use super::*;
use crate::{
CallOption,
call_data,
fn_selector,
generic_proxy::*,
trade_account_deploy::{
CallContractArg,
CallParams,
DeployConfig,
TradeAccountDeploy,
TradeAccountDeployConfig,
TradingAccount,
},
trade_account_registry::TradeAccountRegistryManager,
};
use fuels::{
core::Configurables,
programs::responses::CallResponse,
test_helpers::{
AssetConfig,
WalletsConfig,
launch_custom_provider_and_get_wallets,
},
tx::StorageSlot,
types::{
Bits256,
Bytes,
Identity,
},
};
const ASSET: AssetId = AssetId::new([0x81; 32]);
const SECOND_ASSET: AssetId = AssetId::new([0x82; 32]);
struct Fixture {
admin: Wallet,
buyer: Wallet,
stranger: Wallet,
treasury: Wallet,
deployment: EvalShopDeployment<Wallet>,
registry: TradeAccountRegistryManager<Wallet>,
accounts: TradeAccountDeploy<Wallet>,
}
impl Fixture {
async fn bare() -> Self {
let mut wallets = launch_custom_provider_and_get_wallets(
WalletsConfig::new_multiple_assets(
4,
[AssetId::zeroed(), ASSET, SECOND_ASSET]
.into_iter()
.map(|id| AssetConfig {
id,
num_coins: 4,
coin_amount: 100_000_000_000,
})
.collect(),
),
None,
Some(fuels::test_helpers::ChainConfig::local_testnet()),
)
.await
.unwrap();
let treasury = wallets.pop().unwrap();
let stranger = wallets.pop().unwrap();
let buyer = wallets.pop().unwrap();
let admin = wallets.pop().unwrap();
let accounts = TradeAccountDeploy::deploy(
&admin,
&DeployConfig::Latest(TradeAccountDeployConfig::default()),
)
.await
.unwrap();
let registry = TradeAccountRegistryManager::deploy(
&admin,
accounts.oracle_id,
ContractId::zeroed(),
&Default::default(),
)
.await
.unwrap();
let deployment = EvalShopDeployment::deploy(&admin, &Default::default())
.await
.unwrap();
Self {
admin,
buyer,
stranger,
treasury,
deployment,
registry,
accounts,
}
}
async fn ready() -> Self {
let f = Self::bare().await;
f.shop()
.methods()
.set_registry(f.registry.contract_id)
.call()
.await
.unwrap();
f.shop()
.methods()
.set_treasury(Identity::Address(f.treasury.address()))
.call()
.await
.unwrap();
f.shop()
.methods()
.grant_role(PRICE_MANAGER_ROLE, Identity::Address(f.admin.address()))
.call()
.await
.unwrap();
f.set_price(1, ASSET, Some(100)).await;
f
}
fn shop(&self) -> &EvalShopContract<Wallet> {
&self.deployment.shop
}
fn assert_error(&self, error: impl Into<anyhow::Error>, expected: &str) {
let error = error.into();
if error.to_string().contains(expected) {
return;
}
use fuels::types::errors::{
Error,
transaction::Reason,
};
let Some(Error::Transaction(Reason::Failure { receipts, .. })) =
error.downcast_ref::<Error>()
else {
panic!("expected {expected}, got {error:#}");
};
let errors = self
.shop()
.log_decoder()
.decode_logs_with_type::<EvalShopError>(receipts)
.unwrap();
assert!(
errors.iter().any(|error| format!("{error:?}") == expected),
"expected {expected}, got {error:#}"
);
}
async fn set_price(&self, tier: u64, asset: AssetId, price: Option<u64>) {
self.shop()
.methods()
.set_price(tier, asset, price)
.call()
.await
.unwrap();
}
async fn allow_discounts(&self) {
self.shop()
.methods()
.grant_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(self.admin.address()),
)
.call()
.await
.unwrap();
}
async fn set_discount(&self, tier: Option<u64>, bps: u64, count: u64) {
self.shop()
.methods()
.set_user_discount(
self.buyer.address(),
tier,
Some(EvalShopUserDiscount {
discount_bps: bps,
remaining_purchases: count,
}),
)
.call()
.await
.unwrap();
}
async fn discount(&self, tier: Option<u64>) -> Option<EvalShopUserDiscount> {
self.shop()
.methods()
.get_user_discount(self.buyer.address(), tier)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
async fn increase_discount(
&self,
tier: Option<u64>,
amount: u64,
initial_bps: u64,
) -> CallResponse<()> {
self.shop()
.methods()
.increase_user_discount_purchases(
self.buyer.address(),
tier,
amount,
initial_bps,
)
.call()
.await
.unwrap()
}
async fn decrease_discount(
&self,
tier: Option<u64>,
amount: u64,
) -> CallResponse<()> {
self.shop()
.methods()
.decrease_user_discount_purchases(self.buyer.address(), tier, amount)
.call()
.await
.unwrap()
}
async fn quote(&self, tier: u64, asset: AssetId) -> Option<u64> {
self.shop()
.methods()
.quote_price(self.buyer.address(), tier, asset)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
async fn next(&self, owner: &Wallet) -> u64 {
self.shop()
.methods()
.get_next_buyer_nonce(owner.address())
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
}
async fn balance(&self, contract: ContractId, asset: AssetId) -> u64 {
self.admin
.provider()
.get_contract_asset_balance(&contract, &asset)
.await
.unwrap()
}
async fn buy(
&self,
caller: &Wallet,
tier: u64,
asset: AssetId,
amount: u64,
) -> Result<CallResponse<u64>> {
Ok(self
.shop()
.clone()
.with_account(caller.clone())
.methods()
.buy_account(tier)
.call_params(
CallParameters::default()
.with_asset_id(asset)
.with_amount(amount),
)?
.with_contract_ids(&[self.registry.contract_id])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await?)
}
async fn account(&self, owner: Identity, register: bool) -> ContractId {
let config = DeployConfig::Latest(TradeAccountDeployConfig::default());
let account = self
.accounts
.deploy_with_account(&owner, &config, &CallOption::AwaitBlock, None, &[])
.await
.unwrap()
.proxy_id
.unwrap();
if register {
self.registry
.register_trade_account(
owner,
account,
self.accounts.oracle_id,
&CallOption::AwaitBlock,
&config,
)
.await
.unwrap();
}
self.buyer
.force_transfer_to_contract(account, 1_000, ASSET, TxPolicies::default())
.await
.unwrap();
account
}
fn buy_call(&self, amount: u64) -> CallContractArg {
CallContractArg {
contract_id: self.deployment.contract_id,
function_selector: Bytes(fn_selector!(buy_account(u64))),
call_params: CallParams {
coins: amount,
asset_id: ASSET,
gas: 10_000_000,
},
call_data: Some(Bytes(call_data!(1u64))),
}
}
async fn account_buy(
&self,
account: ContractId,
amount: u64,
) -> Result<CallResponse<()>> {
Ok(TradingAccount::new(account, self.buyer.clone())
.methods()
.call_contract(None, self.buy_call(amount))
.with_contracts(&[self.shop(), &self.registry.registry])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await?)
}
}
#[tokio::test]
async fn wallet_receipts_refunds_prices_and_owner_nonces() {
let f = Fixture::ready().await;
assert_eq!(f.next(&f.buyer).await, 1);
let balance = f.buyer.get_asset_balance(&ASSET).await.unwrap();
let response = f.buy(&f.buyer, 1, ASSET, 160).await.unwrap();
assert_eq!(response.value, 1);
let events = response
.decode_logs_with_type::<EvalAccountPurchasedV2>()
.unwrap();
assert_eq!(events.len(), 1);
let event = &events[0];
assert_eq!(event.owner, f.buyer.address());
assert_eq!(event.sender, Identity::Address(f.buyer.address()));
assert_eq!(event.trade_account, None);
assert_eq!((event.nonce, event.tier_id, event.asset_id), (1, 1, ASSET));
assert_eq!(
(event.price, event.amount_received, event.refund),
(100, 160, 60)
);
assert_eq!((event.paid_price, event.discount_bps), (100, 0));
assert_eq!(event.metadata, None);
assert!(
response
.decode_logs_with_type::<EvalAccountPurchased>()
.unwrap()
.is_empty()
);
assert_eq!(
f.buyer.get_asset_balance(&ASSET).await.unwrap(),
balance - 100
);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 100);
assert_eq!(f.next(&f.buyer).await, 2);
f.set_price(2, SECOND_ASSET, Some(20)).await;
assert_eq!(f.buy(&f.buyer, 2, SECOND_ASSET, 20).await.unwrap().value, 2);
assert_eq!(f.buy(&f.stranger, 1, ASSET, 100).await.unwrap().value, 1);
f.set_price(1, ASSET, Some(75)).await;
assert_eq!(f.buy(&f.buyer, 1, ASSET, 100).await.unwrap().value, 3);
assert_eq!(f.next(&f.buyer).await, 4);
}
#[tokio::test]
async fn rejected_payments_do_not_move_funds_or_consume_nonces_and_zero_is_a_listing() {
let f = Fixture::ready().await;
let before = f.buyer.get_asset_balance(&ASSET).await.unwrap();
for (tier, asset, amount, error) in [
(1, ASSET, 99, "InsufficientPayment"),
(7, ASSET, 100, "PriceNotSet"),
(1, SECOND_ASSET, 100, "PriceNotSet"),
] {
let result = f.buy(&f.buyer, tier, asset, amount).await.unwrap_err();
f.assert_error(result, error);
assert_eq!(f.next(&f.buyer).await, 1);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 0);
assert_eq!(f.buyer.get_asset_balance(&ASSET).await.unwrap(), before);
}
f.set_price(1, ASSET, None).await;
f.assert_error(
f.buy(&f.buyer, 1, ASSET, 100).await.unwrap_err(),
"PriceNotSet",
);
f.set_price(1, ASSET, Some(0)).await;
let receipt = f.buy(&f.buyer, 1, ASSET, 10).await.unwrap();
assert_eq!(receipt.value, 1);
assert_eq!(
receipt
.decode_logs_with_type::<EvalAccountPurchasedV2>()
.unwrap()[0]
.refund,
10
);
assert_eq!(f.buyer.get_asset_balance(&ASSET).await.unwrap(), before);
assert_eq!(f.buy(&f.buyer, 1, ASSET, 0).await.unwrap().value, 2);
f.set_price(1, AssetId::zeroed(), Some(0)).await;
assert_eq!(
f.buy(&f.buyer, 1, AssetId::zeroed(), 0)
.await
.unwrap()
.value,
3
);
}
#[tokio::test]
async fn registered_account_and_wallet_share_nonce_and_refund_goes_to_the_account() {
let f = Fixture::ready().await;
f.allow_discounts().await;
f.set_discount(None, 2_500, 2).await;
let account = f.account(Identity::Address(f.buyer.address()), true).await;
let receipt = f.buy(&f.buyer, 1, ASSET, 100).await.unwrap();
assert_eq!(
receipt
.decode_logs_with_type::<EvalAccountPurchasedV2>()
.unwrap()[0]
.trade_account,
Some(account)
);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 1);
let wallet_balance = f.buyer.get_asset_balance(&ASSET).await.unwrap();
let receipt = f.account_buy(account, 150).await.unwrap();
let event = &receipt
.decode_logs_with_type::<EvalAccountPurchasedV2>()
.unwrap()[0];
assert_eq!(event.sender, Identity::ContractId(account));
assert_eq!(event.owner, f.buyer.address());
assert_eq!(event.trade_account, Some(account));
assert_eq!((event.nonce, event.refund), (2, 75));
assert_eq!(
(event.price, event.paid_price, event.discount_bps),
(100, 75, 2_500)
);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 0);
assert_eq!(f.next(&f.buyer).await, 3);
assert_eq!(f.balance(account, ASSET).await, 925);
assert_eq!(
f.buyer.get_asset_balance(&ASSET).await.unwrap(),
wallet_balance
);
}
#[tokio::test]
async fn discount_management_requires_its_role_and_replaces_or_removes_grants() {
let f = Fixture::ready().await;
let grant = EvalShopUserDiscount {
discount_bps: 1_000,
remaining_purchases: 5,
};
for caller in [&f.admin, &f.stranger] {
assert!(
f.shop()
.clone()
.with_account(caller.clone())
.methods()
.set_user_discount(f.buyer.address(), None, Some(grant.clone()))
.call()
.await
.is_err()
);
}
f.shop()
.methods()
.grant_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(f.stranger.address()),
)
.call()
.await
.unwrap();
let manager = f.shop().clone().with_account(f.stranger.clone());
let receipt = manager
.methods()
.set_user_discount(f.buyer.address(), None, Some(grant.clone()))
.call()
.await
.unwrap();
let event = &receipt
.decode_logs_with_type::<EvalShopUserDiscountChanged>()
.unwrap()[0];
assert_eq!(event.user, f.buyer.address());
assert_eq!(event.tier_id, None);
assert_eq!(event.discount, Some(grant.clone()));
assert_eq!(f.discount(None).await, Some(grant.clone()));
let error = manager
.methods()
.set_user_discount(
f.buyer.address(),
None,
Some(EvalShopUserDiscount {
discount_bps: 10_001,
remaining_purchases: 1,
}),
)
.call()
.await
.unwrap_err();
f.assert_error(error, "InvalidDiscount");
assert_eq!(f.discount(None).await, Some(grant));
let replacement = EvalShopUserDiscount {
discount_bps: 500,
remaining_purchases: 2,
};
manager
.methods()
.set_user_discount(f.buyer.address(), None, Some(replacement.clone()))
.call()
.await
.unwrap();
assert_eq!(f.discount(None).await, Some(replacement.clone()));
manager
.methods()
.set_user_discount(
f.buyer.address(),
Some(1),
Some(EvalShopUserDiscount {
discount_bps: 0,
remaining_purchases: 1,
}),
)
.call()
.await
.unwrap();
assert_eq!(f.quote(1, ASSET).await, Some(100));
let receipt = manager
.methods()
.set_user_discount(f.buyer.address(), Some(1), None)
.call()
.await
.unwrap();
assert_eq!(
receipt
.decode_logs_with_type::<EvalShopUserDiscountChanged>()
.unwrap()[0]
.discount,
None
);
assert_eq!(f.discount(Some(1)).await, None);
assert_eq!(
f.shop()
.methods()
.resolve_user_discount(f.buyer.address(), 1)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(replacement)
);
manager
.methods()
.set_user_discount(f.buyer.address(), None, None)
.call()
.await
.unwrap();
assert_eq!(f.discount(None).await, None);
assert_eq!(f.quote(1, ASSET).await, Some(100));
f.shop()
.methods()
.revoke_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(f.stranger.address()),
)
.call()
.await
.unwrap();
assert!(
manager
.methods()
.set_user_discount(f.buyer.address(), None, None)
.call()
.await
.is_err()
);
}
#[tokio::test]
async fn discount_counter_changes_require_the_role_and_work_while_paused() {
let f = Fixture::ready().await;
for caller in [&f.admin, &f.stranger] {
let shop = f.shop().clone().with_account(caller.clone());
assert!(
shop.methods()
.increase_user_discount_purchases(f.buyer.address(), None, 1, 1_000)
.call()
.await
.is_err()
);
assert!(
shop.methods()
.decrease_user_discount_purchases(f.buyer.address(), None, 1)
.call()
.await
.is_err()
);
}
assert_eq!(f.discount(None).await, None);
f.shop()
.methods()
.grant_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(f.stranger.address()),
)
.call()
.await
.unwrap();
f.shop().methods().pause().call().await.unwrap();
let manager = f.shop().clone().with_account(f.stranger.clone());
let receipt = manager
.methods()
.increase_user_discount_purchases(f.buyer.address(), None, 3, 1_000)
.call()
.await
.unwrap();
let event = &receipt
.decode_logs_with_type::<EvalShopUserDiscountChanged>()
.unwrap()[0];
assert_eq!(event.user, f.buyer.address());
assert_eq!(event.tier_id, None);
assert_eq!(
event.discount,
Some(EvalShopUserDiscount {
discount_bps: 1_000,
remaining_purchases: 3,
})
);
let receipt = manager
.methods()
.decrease_user_discount_purchases(f.buyer.address(), None, 1)
.call()
.await
.unwrap();
assert_eq!(
f.discount(None).await,
Some(EvalShopUserDiscount {
discount_bps: 1_000,
remaining_purchases: 2,
})
);
assert_eq!(
receipt
.decode_logs_with_type::<EvalShopUserDiscountChanged>()
.unwrap()[0]
.discount,
f.discount(None).await
);
let receipt = manager
.methods()
.decrease_user_discount_purchases(f.buyer.address(), Some(1), u64::MAX)
.call()
.await
.unwrap();
let event = &receipt
.decode_logs_with_type::<EvalShopUserDiscountChanged>()
.unwrap()[0];
assert_eq!(event.tier_id, Some(1));
assert_eq!(event.discount, None);
assert_eq!(f.discount(Some(1)).await, None);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 2);
assert!(f.buy(&f.buyer, 1, ASSET, 90).await.is_err());
f.shop().methods().unpause().call().await.unwrap();
f.buy(&f.buyer, 1, ASSET, 90).await.unwrap();
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 1);
f.shop()
.methods()
.revoke_role(
DISCOUNT_MANAGER_ROLE,
Identity::Address(f.stranger.address()),
)
.call()
.await
.unwrap();
assert!(
manager
.methods()
.increase_user_discount_purchases(f.buyer.address(), None, 1, 1_000)
.call()
.await
.is_err()
);
assert!(
manager
.methods()
.decrease_user_discount_purchases(f.buyer.address(), None, 1)
.call()
.await
.is_err()
);
}
#[tokio::test]
async fn replenishing_and_decreasing_exact_grants_preserves_rates_and_fallback() {
let f = Fixture::ready().await;
f.allow_discounts().await;
f.increase_discount(None, 2, 1_000).await;
f.increase_discount(Some(1), 1, 2_500).await;
f.buy(&f.buyer, 1, ASSET, 75).await.unwrap();
assert_eq!(f.discount(Some(1)).await.unwrap().remaining_purchases, 0);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 2);
assert_eq!(f.quote(1, ASSET).await, Some(90));
let receipt = f.increase_discount(Some(1), 2, 5_000).await;
let replenished = EvalShopUserDiscount {
discount_bps: 2_500,
remaining_purchases: 2,
};
assert_eq!(f.discount(Some(1)).await, Some(replenished.clone()));
assert_eq!(
receipt
.decode_logs_with_type::<EvalShopUserDiscountChanged>()
.unwrap()[0]
.discount,
Some(replenished)
);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 2);
assert_eq!(f.quote(1, ASSET).await, Some(75));
f.decrease_discount(Some(1), 1).await;
f.buy(&f.buyer, 1, ASSET, 75).await.unwrap();
assert_eq!(f.quote(1, ASSET).await, Some(90));
f.increase_discount(None, 1, 0).await;
assert_eq!(
f.discount(None).await,
Some(EvalShopUserDiscount {
discount_bps: 1_000,
remaining_purchases: 3,
})
);
f.decrease_discount(Some(2), 3).await;
assert_eq!(f.discount(Some(2)).await, None);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 3);
f.decrease_discount(Some(1), u64::MAX).await;
assert_eq!(
f.discount(Some(1)).await,
Some(EvalShopUserDiscount {
discount_bps: 2_500,
remaining_purchases: 0,
})
);
f.decrease_discount(None, 2).await;
f.buy(&f.buyer, 1, ASSET, 90).await.unwrap();
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 0);
assert_eq!(f.quote(1, ASSET).await, Some(100));
f.increase_discount(None, 1, 10_000).await;
assert_eq!(f.discount(None).await.unwrap().discount_bps, 1_000);
assert_eq!(f.quote(1, ASSET).await, Some(90));
f.buy(&f.buyer, 1, ASSET, 90).await.unwrap();
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 0);
}
#[tokio::test]
async fn counter_adjustments_handle_zero_overflow_and_invalid_initial_rates() {
let f = Fixture::ready().await;
f.allow_discounts().await;
let receipt = f.increase_discount(None, 0, 0).await;
assert_eq!(
f.discount(None).await,
Some(EvalShopUserDiscount {
discount_bps: 0,
remaining_purchases: 0,
})
);
assert_eq!(
receipt
.decode_logs_with_type::<EvalShopUserDiscountChanged>()
.unwrap()[0]
.discount,
f.discount(None).await
);
for tier in [None, Some(1)] {
f.assert_error(
f.shop()
.methods()
.increase_user_discount_purchases(f.buyer.address(), tier, 0, 10_001)
.call()
.await
.unwrap_err(),
"InvalidDiscount",
);
}
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 0);
assert_eq!(f.discount(Some(1)).await, None);
f.increase_discount(Some(1), u64::MAX, 2_000).await;
let max_grant = EvalShopUserDiscount {
discount_bps: 2_000,
remaining_purchases: u64::MAX,
};
f.assert_error(
f.shop()
.methods()
.increase_user_discount_purchases(f.buyer.address(), Some(1), 1, 3_000)
.call()
.await
.unwrap_err(),
"DiscountCountOverflow",
);
assert_eq!(f.discount(Some(1)).await, Some(max_grant.clone()));
f.increase_discount(Some(1), 0, 10_000).await;
f.decrease_discount(Some(1), 0).await;
assert_eq!(f.discount(Some(1)).await, Some(max_grant));
f.decrease_discount(Some(1), 1).await;
assert_eq!(
f.discount(Some(1)).await.unwrap().remaining_purchases,
u64::MAX - 1
);
f.increase_discount(Some(1), 1, 0).await;
assert_eq!(
f.discount(Some(1)).await.unwrap().remaining_purchases,
u64::MAX
);
let receipt = f.decrease_discount(Some(1), u64::MAX).await;
let exhausted = EvalShopUserDiscount {
discount_bps: 2_000,
remaining_purchases: 0,
};
assert_eq!(f.discount(Some(1)).await, Some(exhausted.clone()));
assert_eq!(
receipt
.decode_logs_with_type::<EvalShopUserDiscountChanged>()
.unwrap()[0]
.discount,
Some(exhausted.clone())
);
f.decrease_discount(Some(1), 1).await;
f.increase_discount(Some(1), 0, 0).await;
assert_eq!(f.discount(Some(1)).await, Some(exhausted.clone()));
f.increase_discount(Some(1), 2, 0).await;
f.decrease_discount(Some(1), 3).await;
assert_eq!(f.discount(Some(1)).await, Some(exhausted));
assert_eq!(f.next(&f.buyer).await, 1);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 0);
}
#[tokio::test]
async fn tier_discount_exhaustion_falls_back_to_global_then_full_price() {
let f = Fixture::ready().await;
f.allow_discounts().await;
f.set_price(1, ASSET, Some(101)).await;
f.set_price(2, SECOND_ASSET, Some(17)).await;
f.set_discount(None, 1_000, 2).await;
f.set_discount(Some(1), 2_500, 1).await;
let before = f.buyer.get_asset_balance(&ASSET).await.unwrap();
assert_eq!(f.quote(1, ASSET).await, Some(75));
assert_eq!(f.quote(1, ASSET).await, Some(75));
assert_eq!(f.discount(Some(1)).await.unwrap().remaining_purchases, 1);
assert_eq!(
f.shop()
.methods()
.quote_price(f.stranger.address(), 1, ASSET)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(101)
);
let receipt = f.buy(&f.buyer, 1, ASSET, 100).await.unwrap();
let event = &receipt
.decode_logs_with_type::<EvalAccountPurchasedV2>()
.unwrap()[0];
assert_eq!((event.price, event.paid_price, event.refund), (101, 75, 25));
assert_eq!(event.discount_bps, 2_500);
assert_eq!(
f.buyer.get_asset_balance(&ASSET).await.unwrap(),
before - 75
);
assert_eq!(f.discount(Some(1)).await.unwrap().remaining_purchases, 0);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 2);
assert_eq!(f.quote(1, ASSET).await, Some(90));
f.buy(&f.buyer, 1, ASSET, 90).await.unwrap();
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 1);
assert_eq!(f.quote(2, SECOND_ASSET).await, Some(15));
f.buy(&f.buyer, 2, SECOND_ASSET, 15).await.unwrap();
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 0);
assert_eq!(f.quote(1, ASSET).await, Some(101));
assert_eq!(f.quote(2, SECOND_ASSET).await, Some(17));
assert_eq!(
f.shop()
.methods()
.resolve_user_discount(f.buyer.address(), 1)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
None
);
let receipt = f.buy(&f.buyer, 1, ASSET, 101).await.unwrap();
let event = &receipt
.decode_logs_with_type::<EvalAccountPurchasedV2>()
.unwrap()[0];
assert_eq!((event.paid_price, event.discount_bps), (101, 0));
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 266);
}
#[tokio::test]
async fn rejected_discounted_purchases_preserve_funds_nonce_and_uses() {
let f = Fixture::ready().await;
f.allow_discounts().await;
f.set_discount(None, 5_000, 1).await;
let before = f.buyer.get_asset_balance(&ASSET).await.unwrap();
assert_eq!(f.quote(1, ASSET).await, Some(50));
assert_eq!(f.quote(7, ASSET).await, None);
for (tier, amount, error) in [(1, 49, "InsufficientPayment"), (7, 50, "PriceNotSet")]
{
f.assert_error(
f.buy(&f.buyer, tier, ASSET, amount).await.unwrap_err(),
error,
);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 1);
assert_eq!(f.next(&f.buyer).await, 1);
assert_eq!(f.buyer.get_asset_balance(&ASSET).await.unwrap(), before);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 0);
}
f.shop().methods().pause().call().await.unwrap();
assert!(f.buy(&f.buyer, 1, ASSET, 50).await.is_err());
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 1);
f.shop().methods().unpause().call().await.unwrap();
f.buy(&f.buyer, 1, ASSET, 50).await.unwrap();
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 0);
f.assert_error(
f.buy(&f.buyer, 1, ASSET, 50).await.unwrap_err(),
"InsufficientPayment",
);
assert_eq!(f.next(&f.buyer).await, 2);
assert_eq!(
f.buyer.get_asset_balance(&ASSET).await.unwrap(),
before - 50
);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 50);
}
#[tokio::test]
async fn discount_rounding_free_purchases_and_zero_rate_grants() {
let f = Fixture::ready().await;
f.allow_discounts().await;
f.set_discount(None, 10_000, 1).await;
let before = f.buyer.get_asset_balance(&ASSET).await.unwrap();
assert_eq!(f.quote(1, ASSET).await, Some(0));
let receipt = f.buy(&f.buyer, 1, ASSET, 10).await.unwrap();
let event = &receipt
.decode_logs_with_type::<EvalAccountPurchasedV2>()
.unwrap()[0];
assert_eq!(
(event.paid_price, event.refund, event.discount_bps),
(0, 10, 10_000)
);
assert_eq!(f.buyer.get_asset_balance(&ASSET).await.unwrap(), before);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 0);
f.set_discount(None, 0, 2).await;
f.buy(&f.buyer, 1, ASSET, 100).await.unwrap();
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 1);
f.set_price(1, ASSET, Some(0)).await;
f.buy(&f.buyer, 1, ASSET, 0).await.unwrap();
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 0);
f.set_discount(None, 3_333, 1).await;
f.set_price(1, ASSET, Some(101)).await;
assert_eq!(f.quote(1, ASSET).await, Some(67));
let receipt = f.buy(&f.buyer, 1, ASSET, 67).await.unwrap();
assert_eq!(
receipt
.decode_logs_with_type::<EvalAccountPurchasedV2>()
.unwrap()[0]
.paid_price,
67
);
f.set_discount(None, 3_333, 1).await;
f.set_price(1, ASSET, Some(u64::MAX)).await;
assert_eq!(
f.quote(1, ASSET).await,
Some((u128::from(u64::MAX) * 6_667 / 10_000) as u64)
);
f.set_discount(Some(1), 10_000, 0).await;
assert_eq!(f.discount(Some(1)).await.unwrap().remaining_purchases, 0);
assert_eq!(
f.quote(1, ASSET).await,
Some((u128::from(u64::MAX) * 6_667 / 10_000) as u64)
);
}
#[tokio::test]
async fn legacy_purchase_event_method_always_reverts() {
let f = Fixture::ready().await;
let event = EvalAccountPurchased {
owner: f.buyer.address(),
sender: Identity::Address(f.buyer.address()),
trade_account: None,
nonce: 1,
tier_id: 1,
asset_id: ASSET,
price: 100,
amount_received: 100,
refund: 0,
timestamp: super::Time { unix: 1 },
};
for caller in [&f.admin, &f.buyer] {
let error = f
.shop()
.clone()
.with_account(caller.clone())
.methods()
.legacy_eval_account_purchased(event.clone())
.call()
.await
.unwrap_err();
assert!(error.to_string().contains("Disabled"), "{error:#}");
}
assert_eq!(f.next(&f.buyer).await, 1);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 0);
}
#[tokio::test]
async fn unregistered_and_contract_owned_accounts_cannot_purchase() {
let f = Fixture::ready().await;
let outer = f.account(Identity::Address(f.buyer.address()), false).await;
let error = f.account_buy(outer, 100).await.unwrap_err();
assert!(
error.to_string().contains("UnregisteredAccount"),
"{error:#}"
);
assert_eq!(f.balance(outer, ASSET).await, 1_000);
let inner = f.account(Identity::ContractId(outer), true).await;
let nested = CallContractArg {
contract_id: inner,
function_selector: Bytes(fn_selector!(
call_contract(Option<Signature>, CallContractArg)
)),
call_params: CallParams {
coins: 0,
asset_id: ASSET,
gas: 20_000_000,
},
call_data: Some(Bytes(call_data!(
Option::<crate::trade_account_deploy::Signature>::None,
f.buy_call(100)
))),
};
let error = TradingAccount::new(outer, f.buyer.clone())
.methods()
.call_contract(None, nested)
.with_contracts(&[f.shop(), &f.registry.registry])
.with_contract_ids(&[inner, f.deployment.contract_id, f.registry.contract_id])
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap_err();
assert!(
error.to_string().contains("InvalidAccountOwner"),
"{error:#}"
);
assert_eq!(f.balance(inner, ASSET).await, 1_000);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 0);
assert_eq!(f.next(&f.buyer).await, 1);
}
#[tokio::test]
async fn initialization_uses_proxy_owner_and_roles_are_explicit() {
let f = Fixture::bare().await;
let mut slots: Vec<StorageSlot> =
serde_json::from_slice(GENERIC_PROXY_STORAGE).unwrap();
slots.extend(serde_json::from_slice::<Vec<StorageSlot>>(EVAL_SHOP_STORAGE).unwrap());
let contract =
Contract::regular(GENERIC_PROXY_BYTECODE.to_vec(), Salt::new([9; 32]), slots)
.with_configurables(
GenericProxyContractConfigurables::default()
.with_INITIAL_OWNER(State::Initialized(Identity::Address(
f.buyer.address(),
)))
.unwrap()
.with_INITIAL_TARGET(f.deployment.blob_id)
.unwrap(),
);
let id = contract.contract_id();
contract
.deploy(&f.admin, TxPolicies::default())
.await
.unwrap();
let proxy = GenericProxyContract::new(id, f.admin.clone());
let shop = EvalShopContract::new(id, f.admin.clone());
assert!(shop.methods().initialize().call().await.is_err());
proxy.methods().initialize_proxy().call().await.unwrap();
let error = shop.methods().initialize().call().await.unwrap_err();
assert!(error.to_string().contains("NotOwner"), "{error:#}");
let owner_shop = shop.clone().with_account(f.buyer.clone());
owner_shop.methods().initialize().call().await.unwrap();
for (who, role, expected) in [
(f.buyer.address(), 0, true),
(f.admin.address(), 0, false),
(f.buyer.address(), 1, false),
(f.buyer.address(), 2, false),
] {
assert_eq!(
shop.methods()
.has_role(role, Identity::Address(who))
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
expected
);
}
assert!(
owner_shop
.methods()
.initialize()
.call()
.await
.unwrap_err()
.to_string()
.contains("AlreadyInitialized")
);
proxy
.with_account(f.buyer.clone())
.methods()
.set_owner(Identity::Address(f.stranger.address()))
.call()
.await
.unwrap();
assert!(
!shop
.methods()
.has_role(0, Identity::Address(f.stranger.address()))
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
assert!(
shop.with_account(f.stranger.clone())
.methods()
.set_registry(f.registry.contract_id)
.call()
.await
.is_err()
);
owner_shop
.methods()
.set_registry(f.registry.contract_id)
.call()
.await
.unwrap();
}
#[tokio::test]
async fn configuration_and_operational_roles_are_required() {
let f = Fixture::bare().await;
let buyer_balance = f.buyer.get_asset_balance(&ASSET).await.unwrap();
f.assert_error(
f.buy(&f.buyer, 1, ASSET, 100).await.unwrap_err(),
"RegistryNotSet",
);
assert!(
f.shop()
.methods()
.set_registry(ContractId::zeroed())
.call()
.await
.unwrap_err()
.to_string()
.contains("InvalidRegistry")
);
f.shop()
.methods()
.set_registry(f.registry.contract_id)
.call()
.await
.unwrap();
f.assert_error(
f.buy(&f.buyer, 1, ASSET, 100).await.unwrap_err(),
"TreasuryNotSet",
);
for zero in [
Identity::Address(Address::zeroed()),
Identity::ContractId(ContractId::zeroed()),
] {
assert!(
f.shop()
.methods()
.set_treasury(zero)
.call()
.await
.unwrap_err()
.to_string()
.contains("InvalidTreasury")
);
}
let outsider = f.shop().clone().with_account(f.stranger.clone());
assert!(
outsider
.methods()
.set_registry(f.registry.contract_id)
.call()
.await
.is_err()
);
assert!(
outsider
.methods()
.set_treasury(Identity::Address(f.stranger.address()))
.call()
.await
.is_err()
);
assert!(
outsider
.methods()
.grant_role(0, Identity::Address(f.stranger.address()))
.call()
.await
.is_err()
);
assert!(
f.shop()
.methods()
.set_price(1, ASSET, Some(100))
.call()
.await
.is_err()
);
assert!(
f.shop()
.methods()
.withdraw_fees(ASSET, 0)
.call()
.await
.is_err()
);
f.shop()
.methods()
.grant_role(PRICE_MANAGER_ROLE, Identity::Address(f.stranger.address()))
.call()
.await
.unwrap();
let response = outsider
.methods()
.set_price(1, ASSET, Some(0))
.call()
.await
.unwrap();
let event = &response
.decode_logs_with_type::<EvalShopPriceChanged>()
.unwrap()[0];
assert_eq!((event.old_price, event.new_price), (None, Some(0)));
f.shop()
.methods()
.revoke_role(PRICE_MANAGER_ROLE, Identity::Address(f.stranger.address()))
.call()
.await
.unwrap();
assert!(
outsider
.methods()
.set_price(1, ASSET, Some(10))
.call()
.await
.is_err()
);
f.shop()
.methods()
.grant_role(FEE_WITHDRAWER_ROLE, Identity::Address(f.stranger.address()))
.call()
.await
.unwrap();
f.assert_error(
outsider
.methods()
.withdraw_fees(ASSET, 0)
.call()
.await
.unwrap_err(),
"TreasuryNotSet",
);
assert_eq!(
f.buyer.get_asset_balance(&ASSET).await.unwrap(),
buyer_balance
);
assert_eq!(f.next(&f.buyer).await, 1);
}
#[tokio::test]
async fn pause_stops_purchases_but_allows_configuration_and_treasury_withdrawals() {
let f = Fixture::ready().await;
f.buy(&f.buyer, 1, ASSET, 100).await.unwrap();
f.admin
.force_transfer_to_contract(
f.deployment.contract_id,
37,
ASSET,
TxPolicies::default(),
)
.await
.unwrap();
let outsider = f.shop().clone().with_account(f.stranger.clone());
assert!(outsider.methods().pause().call().await.is_err());
f.shop()
.methods()
.grant_role(PAUSER_ROLE, Identity::Address(f.stranger.address()))
.call()
.await
.unwrap();
outsider.methods().pause().call().await.unwrap();
assert!(outsider.methods().unpause().call().await.is_err());
f.shop()
.methods()
.revoke_role(PAUSER_ROLE, Identity::Address(f.stranger.address()))
.call()
.await
.unwrap();
assert!(
f.shop()
.methods()
.is_paused()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
f.shop().methods().unpause().call().await.unwrap();
assert!(outsider.methods().pause().call().await.is_err());
f.shop().methods().pause().call().await.unwrap();
let before = f.buyer.get_asset_balance(&ASSET).await.unwrap();
assert!(
f.buy(&f.buyer, 1, ASSET, 100)
.await
.unwrap_err()
.to_string()
.contains("Paused")
);
assert_eq!(f.next(&f.buyer).await, 2);
assert_eq!(f.buyer.get_asset_balance(&ASSET).await.unwrap(), before);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 137);
f.shop()
.methods()
.grant_role(FEE_WITHDRAWER_ROLE, Identity::Address(f.stranger.address()))
.call()
.await
.unwrap();
let before_treasury = f.treasury.get_asset_balance(&ASSET).await.unwrap();
let before_caller = f.stranger.get_asset_balance(&ASSET).await.unwrap();
let receipt = outsider
.methods()
.withdraw_fees(ASSET, 10)
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap();
let event = &receipt
.decode_logs_with_type::<EvalShopFeesWithdrawn>()
.unwrap()[0];
assert_eq!(event.caller, Identity::Address(f.stranger.address()));
assert_eq!(event.treasury, Identity::Address(f.treasury.address()));
assert_eq!((event.asset_id, event.amount), (ASSET, 10));
assert_eq!(
f.treasury.get_asset_balance(&ASSET).await.unwrap(),
before_treasury + 10
);
assert_eq!(
f.stranger.get_asset_balance(&ASSET).await.unwrap(),
before_caller
);
assert!(
outsider
.methods()
.withdraw_fees(ASSET, 128)
.with_variable_output_policy(VariableOutputPolicy::EstimateMinimum)
.call()
.await
.unwrap_err()
.to_string()
.contains("InsufficientBalance")
);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 127);
let recipient = f
.account(Identity::Address(f.treasury.address()), false)
.await;
f.shop()
.methods()
.set_treasury(Identity::ContractId(recipient))
.call()
.await
.unwrap();
outsider
.methods()
.withdraw_fees(ASSET, 127)
.with_contract_ids(&[recipient])
.call()
.await
.unwrap();
assert_eq!(f.balance(recipient, ASSET).await, 1_127);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 0);
f.shop()
.methods()
.revoke_role(FEE_WITHDRAWER_ROLE, Identity::Address(f.stranger.address()))
.call()
.await
.unwrap();
assert!(
outsider
.methods()
.withdraw_fees(ASSET, 0)
.call()
.await
.is_err()
);
f.set_price(1, ASSET, Some(5)).await;
f.shop().methods().unpause().call().await.unwrap();
assert_eq!(f.buy(&f.buyer, 1, ASSET, 5).await.unwrap().value, 2);
}
#[tokio::test]
async fn proxy_upgrade_preserves_state_and_redeployment_respects_upgrade_flag() {
let f = Fixture::ready().await;
f.allow_discounts().await;
f.set_discount(None, 0, 3).await;
f.buy(&f.buyer, 1, ASSET, 100).await.unwrap();
f.shop().methods().pause().call().await.unwrap();
let config = EvalShopDeployConfig {
contract_id: Some(f.deployment.contract_id),
max_words_per_blob: 500,
..Default::default()
};
let no_upgrade = EvalShopDeployment::deploy(&f.admin, &config).await.unwrap();
assert_eq!(no_upgrade.blob_id, f.deployment.blob_id);
let (data, loader) = crate::blob_loader::build_loader_blobs(
EVAL_SHOP_BYTECODE.to_vec(),
config.salt,
serde_json::from_slice(EVAL_SHOP_STORAGE).unwrap(),
Configurables::default(),
500,
)
.unwrap();
let other = crate::blob_loader::upload_loader_blobs(&f.admin, data, loader)
.await
.unwrap();
assert_ne!(ContractId::from(other), f.deployment.blob_id);
assert!(
f.deployment
.proxy
.clone()
.with_account(f.stranger.clone())
.methods()
.set_proxy_target(other.into())
.call()
.await
.is_err()
);
assert!(
f.deployment
.proxy
.methods()
.set_proxy_target(ContractId::new([0x55; 32]))
.call()
.await
.is_err()
);
let upgraded = EvalShopDeployment::deploy(
&f.admin,
&EvalShopDeployConfig {
upgrade_bytecode: true,
..config.clone()
},
)
.await
.unwrap();
assert_eq!(upgraded.contract_id, f.deployment.contract_id);
assert_eq!(upgraded.blob_id, ContractId::from(other));
assert_eq!(
upgraded
.shop
.methods()
.registry()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(f.registry.contract_id)
);
assert_eq!(
upgraded
.shop
.methods()
.treasury()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(Identity::Address(f.treasury.address()))
);
assert!(
upgraded
.shop
.methods()
.is_paused()
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
assert_eq!(
upgraded
.shop
.methods()
.price(1, ASSET)
.simulate(Execution::state_read_only())
.await
.unwrap()
.value,
Some(100)
);
assert!(
upgraded
.shop
.methods()
.has_role(PRICE_MANAGER_ROLE, Identity::Address(f.admin.address()))
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
assert_eq!(f.next(&f.buyer).await, 2);
assert_eq!(f.balance(f.deployment.contract_id, ASSET).await, 100);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 2);
assert!(
upgraded
.shop
.methods()
.has_role(DISCOUNT_MANAGER_ROLE, Identity::Address(f.admin.address()))
.simulate(Execution::state_read_only())
.await
.unwrap()
.value
);
upgraded.shop.methods().unpause().call().await.unwrap();
assert_eq!(f.buy(&f.buyer, 1, ASSET, 100).await.unwrap().value, 2);
assert_eq!(f.discount(None).await.unwrap().remaining_purchases, 1);
let height = f.admin.provider().latest_block_height().await.unwrap();
EvalShopDeployment::deploy(
&f.admin,
&EvalShopDeployConfig {
upgrade_bytecode: true,
..config
},
)
.await
.unwrap();
assert_eq!(
f.admin.provider().latest_block_height().await.unwrap(),
height
);
}
#[test]
fn implementation_has_no_configurables() {
let abi: serde_json::Value =
serde_json::from_str(include_str!("../artifacts/eval-shop/eval-shop-abi.json"))
.unwrap();
assert!(abi["configurables"].as_array().unwrap().is_empty());
}
#[test]
fn legacy_purchase_event_retains_its_log_id_and_payload() {
let abi: serde_json::Value =
serde_json::from_str(include_str!("../artifacts/eval-shop/eval-shop-abi.json"))
.unwrap();
let event = abi["concreteTypes"]
.as_array()
.unwrap()
.iter()
.find(|ty| {
ty["type"] == "struct contract_schema::eval_shop::EvalAccountPurchased"
})
.unwrap();
assert!(abi["loggedTypes"].as_array().unwrap().iter().any(|log| {
log["logId"] == "1633163362121529043"
&& log["concreteTypeId"] == event["concreteTypeId"]
}));
let fields = abi["metadataTypes"]
.as_array()
.unwrap()
.iter()
.find(|ty| ty["metadataTypeId"] == event["metadataTypeId"])
.unwrap()["components"]
.as_array()
.unwrap()
.iter()
.map(|field| field["name"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(
fields,
[
"owner",
"sender",
"trade_account",
"nonce",
"tier_id",
"asset_id",
"price",
"amount_received",
"refund",
"timestamp",
]
);
}
#[test]
fn purchase_v2_metadata_round_trips_without_a_twenty_word_limit() {
use fuels::core::{
codec::{
ABIDecoder,
ABIEncoder,
},
traits::{
Parameterize,
Tokenizable,
},
};
for metadata in [None, Some(vec![]), Some(vec![Bits256([0x42; 32]); 25])] {
let event = EvalAccountPurchasedV2 {
owner: Address::new([1; 32]),
sender: Identity::Address(Address::new([1; 32])),
trade_account: Some(ContractId::new([2; 32])),
nonce: 3,
tier_id: 4,
asset_id: ASSET,
price: 100,
amount_received: 100,
refund: 25,
timestamp: super::Time { unix: 5 },
paid_price: 75,
discount_bps: 2_500,
metadata,
};
let bytes = ABIEncoder::default()
.encode(&[event.clone().into_token()])
.unwrap();
let token = ABIDecoder::default()
.decode(&EvalAccountPurchasedV2::param_type(), bytes.as_slice())
.unwrap();
assert_eq!(EvalAccountPurchasedV2::from_token(token).unwrap(), event);
}
}