use crate::{
Code, Config, DeletionQueue, FreezeReason, HoldReason, NativeDepositOf,
deposit_payment::{Deposit, Funds},
test_utils::{
ALICE, BOB, CHARLIE, DJANGO_ADDR,
builder::{BareCallBuilder, Contract},
},
tests::{
Assets, AssetsFreezer, AssetsHolder, Balances, Contracts, ExtBuilder, PGAS_ASSET_ID,
RuntimeOrigin, System, Test, builder, test_utils::get_contract_checked,
},
};
use alloy_core::sol_types::SolCall;
use frame_support::{
assert_ok,
traits::{
OnIdle,
fungible::{Inspect as _, InspectHold, Mutate as _},
tokens::{
Fortitude, Precision, Preservation,
fungibles::{
Inspect as FungiblesInspect, InspectFreeze as FungiblesInspectFreeze,
InspectHold as _, Mutate as FungiblesMutate,
},
},
},
weights::Weight,
};
use pallet_revive_fixtures::{
FixtureType, MultiContributorStorage, compile_module, compile_module_with_type,
};
use pretty_assertions::assert_eq;
use sp_runtime::{AccountId32, DispatchResult};
use test_case::test_case;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct State {
payer_native: u128,
payer_pgas: u128,
contract_native_held: u128,
contract_pgas_held: u128,
native_entitlement: u128,
}
fn snapshot(payer: &AccountId32, contract: &AccountId32) -> State {
let hold = HoldReason::StorageDepositReserve.into();
State {
payer_native: Balances::free_balance(payer),
payer_pgas: Assets::balance(PGAS_ASSET_ID, payer),
contract_native_held: Balances::balance_on_hold(&hold, contract),
contract_pgas_held: AssetsHolder::balance_on_hold(PGAS_ASSET_ID, &hold, contract),
native_entitlement: NativeDepositOf::<Test>::get(contract, payer),
}
}
struct Charge {
payer: AccountId32,
amount: u128,
expected: State,
}
struct AccountSetup {
account: AccountId32,
native: u128,
pgas: u128,
}
struct TestCase {
accounts: Vec<AccountSetup>,
charges: Vec<Charge>,
refund: (AccountId32, u128),
expected_after_refund: Vec<(AccountId32, State)>,
}
fn charge_and_hold(from: &AccountId32, to: &AccountId32, amount: u128) -> DispatchResult {
<<Test as Config>::Deposit as Deposit<Test>>::charge_and_hold(
HoldReason::StorageDepositReserve,
Funds::Balance(from),
to,
amount,
)
}
fn refund_on_hold(from: &AccountId32, to: &AccountId32, amount: u128) -> DispatchResult {
<<Test as Config>::Deposit as Deposit<Test>>::refund_on_hold(
HoldReason::StorageDepositReserve,
from,
Funds::Balance(to),
amount,
)
}
fn run(TestCase { accounts, charges, refund, expected_after_refund }: TestCase) {
let pgas_balances = accounts
.iter()
.filter(|account| account.pgas > 0)
.map(|account| (account.account.clone(), account.pgas))
.collect();
ExtBuilder::default()
.with_pgas_balances(pgas_balances)
.build()
.execute_with(|| {
for AccountSetup { account, native, .. } in accounts {
Balances::set_balance(&account, native);
}
assert_ok!(<<Test as Config>::Deposit as Deposit<Test>>::init_contract(&BOB));
for (i, charge) in charges.iter().enumerate() {
assert_ok!(charge_and_hold(&charge.payer, &BOB, charge.amount));
assert_eq!(snapshot(&charge.payer, &BOB), charge.expected, "after charge {i}");
}
assert_ok!(refund_on_hold(&BOB, &refund.0, refund.1));
for (payer, expected) in expected_after_refund {
assert_eq!(snapshot(&payer, &BOB), expected, "after refund for {payer:?}");
}
});
}
#[test]
fn pay_native_refund_native() {
run(TestCase {
accounts: vec![AccountSetup { account: ALICE, native: 1_000, pgas: 0 }],
charges: vec![Charge {
payer: ALICE,
amount: 100,
expected: State {
payer_native: 900,
contract_native_held: 100,
native_entitlement: 100,
..State::default()
},
}],
refund: (ALICE, 100),
expected_after_refund: vec![(ALICE, State { payer_native: 1_000, ..State::default() })],
});
}
#[test]
fn pay_pgas_refund_pgas() {
run(TestCase {
accounts: vec![AccountSetup { account: ALICE, native: 1_000, pgas: 1_000 }],
charges: vec![Charge {
payer: ALICE,
amount: 100,
expected: State {
payer_native: 1_000,
payer_pgas: 900,
contract_pgas_held: 100,
..State::default()
},
}],
refund: (ALICE, 100),
expected_after_refund: vec![(
ALICE,
State { payer_native: 1_000, payer_pgas: 910, ..State::default() },
)],
});
}
#[test]
fn pay_mixed_refund_mixed() {
run(TestCase {
accounts: vec![AccountSetup { account: ALICE, native: 1_000, pgas: 100 }],
charges: vec![
Charge {
payer: ALICE,
amount: 40,
expected: State {
payer_native: 1_000,
payer_pgas: 60,
contract_pgas_held: 40,
..State::default()
},
},
Charge {
payer: ALICE,
amount: 80,
expected: State {
payer_native: 920,
payer_pgas: 60,
contract_native_held: 80,
contract_pgas_held: 40,
native_entitlement: 80,
},
},
],
refund: (ALICE, 120),
expected_after_refund: vec![(
ALICE,
State { payer_native: 1_000, payer_pgas: 64, ..State::default() },
)],
});
}
#[test]
fn burn_held_on_sub_ed_hold_works() {
ExtBuilder::default()
.with_pgas_min_balance(100)
.with_pgas_balances(vec![(ALICE, 1_000)])
.build()
.execute_with(|| {
Balances::set_balance(&ALICE, 1_000);
assert_ok!(<<Test as Config>::Deposit as Deposit<Test>>::init_contract(&BOB));
assert_ok!(charge_and_hold(&ALICE, &BOB, 50));
assert_eq!(
snapshot(&ALICE, &BOB),
State {
payer_native: 1_000,
payer_pgas: 950,
contract_pgas_held: 50,
..State::default()
},
"after sub-ED charge",
);
assert_ok!(refund_on_hold(&BOB, &ALICE, 50));
assert_eq!(
snapshot(&ALICE, &BOB),
State { payer_native: 1_000, payer_pgas: 955, ..State::default() },
"after refund (5 refunded, 45 burned)",
);
});
}
#[test]
fn burn_held_on_sub_ed_hold_partial_refund() {
ExtBuilder::default()
.with_pgas_min_balance(100)
.with_pgas_balances(vec![(ALICE, 1_000)])
.build()
.execute_with(|| {
Balances::set_balance(&ALICE, 1_000);
assert_ok!(<<Test as Config>::Deposit as Deposit<Test>>::init_contract(&BOB));
assert_ok!(charge_and_hold(&ALICE, &BOB, 50));
assert_ok!(refund_on_hold(&BOB, &ALICE, 20));
assert_eq!(
snapshot(&ALICE, &BOB),
State {
payer_native: 1_000,
payer_pgas: 952,
contract_pgas_held: 30,
..State::default()
},
"after partial refund (2 refunded, 18 burned, 30 still held)",
);
});
}
#[test]
fn init_and_destroy_contract_round_trip() {
ExtBuilder::default().existential_deposit(50).build().execute_with(|| {
let native_total_before = Balances::total_issuance();
let native_inactive_before = Balances::inactive_issuance();
let native_active_before = Balances::active_issuance();
let pgas_total_before = Assets::total_issuance(PGAS_ASSET_ID);
assert_ok!(<<Test as Config>::Deposit as Deposit<Test>>::init_contract(&BOB));
assert_eq!(Balances::balance(&BOB), 50, "BOB should have native ED minted");
assert_eq!(Balances::total_issuance(), native_total_before + 50);
assert_eq!(Balances::inactive_issuance(), native_inactive_before + 50);
assert_eq!(
Balances::active_issuance(),
native_active_before,
"deactivate keeps active issuance pinned"
);
let pgas_ed = Assets::minimum_balance(PGAS_ASSET_ID);
assert_eq!(Assets::balance(PGAS_ASSET_ID, &BOB), pgas_ed);
assert_eq!(Assets::total_issuance(PGAS_ASSET_ID), pgas_total_before + pgas_ed);
assert_ok!(<<Test as Config>::Deposit as Deposit<Test>>::destroy_contract(&BOB));
assert_eq!(Balances::balance(&BOB), 0, "native ED has been burned out of BOB");
assert_eq!(Assets::balance(PGAS_ASSET_ID, &BOB), 0);
assert_eq!(Balances::total_issuance(), native_total_before);
assert_eq!(Balances::inactive_issuance(), native_inactive_before);
assert_eq!(Balances::active_issuance(), native_active_before);
assert_eq!(Assets::total_issuance(PGAS_ASSET_ID), pgas_total_before);
});
}
#[test]
fn minted_contract_can_receive_sub_ed_pgas() {
ExtBuilder::default()
.with_pgas_min_balance(100)
.with_pgas_balances(vec![(ALICE, 1_000)])
.build()
.execute_with(|| {
Balances::set_balance(&ALICE, 1_000);
assert_ok!(<<Test as Config>::Deposit as Deposit<Test>>::init_contract(&BOB));
assert_eq!(Assets::balance(PGAS_ASSET_ID, &BOB), 100);
assert_ok!(<Assets as FungiblesMutate<_>>::transfer(
PGAS_ASSET_ID,
&ALICE,
&BOB,
30,
Preservation::Preserve,
));
assert_eq!(Assets::balance(PGAS_ASSET_ID, &BOB), 130);
assert_eq!(Assets::balance(PGAS_ASSET_ID, &ALICE), 970);
});
}
#[test]
fn minted_contract_can_receive_sub_ed_native() {
ExtBuilder::default().existential_deposit(50).build().execute_with(|| {
Balances::set_balance(&ALICE, 1_000);
assert_ok!(<<Test as Config>::Deposit as Deposit<Test>>::init_contract(&BOB));
assert_eq!(Balances::balance(&BOB), 50);
assert_ok!(Balances::transfer(&ALICE, &BOB, 10, Preservation::Preserve));
assert_eq!(Balances::balance(&BOB), 60);
assert_eq!(Balances::balance(&ALICE), 990);
});
}
#[test]
fn minted_contract_native_ed_not_extractable_with_consumer() {
let (binary, _) = compile_module("dummy").unwrap();
ExtBuilder::default().existential_deposit(50).build().execute_with(|| {
Balances::set_balance(&ALICE, 1_000_000);
let Contract { account_id, .. } =
builder::bare_instantiate(Code::Upload(binary)).build_and_unwrap_contract();
let before = Balances::balance(&account_id);
let result = Balances::burn_from(
&account_id,
50,
Preservation::Expendable,
Precision::Exact,
Fortitude::Force,
);
assert!(
result.is_err(),
"the consumer pin must keep the native ED non-extractable; got {result:?}"
);
assert_eq!(Balances::balance(&account_id), before, "balance unchanged");
});
}
#[test]
fn minted_contract_pgas_ed_not_extractable_due_to_freeze() {
let pgas_ed = 100u128;
ExtBuilder::default().with_pgas_min_balance(pgas_ed).build().execute_with(|| {
assert_ok!(<<Test as Config>::Deposit as Deposit<Test>>::init_contract(&BOB));
assert_eq!(Assets::balance(PGAS_ASSET_ID, &BOB), pgas_ed);
assert_eq!(
<AssetsFreezer as FungiblesInspectFreeze<_>>::balance_frozen(
PGAS_ASSET_ID,
&FreezeReason::PGasMinBalance.into(),
&BOB,
),
pgas_ed,
);
assert_eq!(
<Assets as FungiblesInspect<_>>::reducible_balance(
PGAS_ASSET_ID,
&BOB,
Preservation::Expendable,
Fortitude::Force,
),
0,
"the freeze pins the ED — Expendable/Force don't override it",
);
assert!(
<Assets as FungiblesMutate<_>>::transfer(
PGAS_ASSET_ID,
&BOB,
&ALICE,
1,
Preservation::Expendable,
)
.is_err(),
"transfer of 1 unit must fail while the ED is frozen",
);
});
}
#[test_case(FixtureType::Solc)]
#[test_case(FixtureType::Resolc)]
fn refund_all_drains_multi_contributor_native_hold(fixture_type: FixtureType) {
let (code, _) = compile_module_with_type("MultiContributorStorage", fixture_type).unwrap();
ExtBuilder::default().build().execute_with(|| {
Balances::set_balance(&ALICE, 100_000_000_000);
Balances::set_balance(&CHARLIE, 100_000_000_000);
let Contract { addr, account_id } =
builder::bare_instantiate(Code::Upload(code)).build_and_unwrap_contract();
assert_ok!(
builder::bare_call(addr)
.data(MultiContributorStorage::growStorageCall {}.abi_encode())
.build()
.result,
);
assert_ok!(
BareCallBuilder::<Test>::bare_call(RuntimeOrigin::signed(CHARLIE), addr)
.data(MultiContributorStorage::growStorageCall {}.abi_encode())
.build()
.result,
);
let alice_entry = NativeDepositOf::<Test>::get(&account_id, &ALICE);
let charlie_entry = NativeDepositOf::<Test>::get(&account_id, &CHARLIE);
assert!(alice_entry > 0);
assert!(charlie_entry > 0);
let hold: <Test as Config>::RuntimeHoldReason = HoldReason::StorageDepositReserve.into();
let native_held = Balances::balance_on_hold(&hold, &account_id);
let pgas_held = AssetsHolder::balance_on_hold(PGAS_ASSET_ID, &hold, &account_id);
assert_eq!(pgas_held, 0, "every charge fell back to native");
assert_eq!(native_held, alice_entry + charlie_entry);
let alice_before = Balances::balance(&ALICE);
assert_ok!(
builder::bare_call(addr)
.data(
MultiContributorStorage::terminateCall { beneficiary: DJANGO_ADDR.0.into() }
.abi_encode(),
)
.build()
.result,
);
let alice_after = Balances::balance(&ALICE);
assert!(get_contract_checked(&addr).is_none(), "contract should be gone");
assert_eq!(
Balances::balance_on_hold(&hold, &account_id),
0,
"the full multi-contributor native hold has been released",
);
assert!(
alice_after.saturating_sub(alice_before) >= native_held,
"expected ALICE balance delta >= {}, got {}",
native_held,
alice_after.saturating_sub(alice_before),
);
});
}
#[test_case(FixtureType::Solc)]
#[test_case(FixtureType::Resolc)]
fn destroy_contract_reaps_account_and_clears_native_deposit_map(fixture_type: FixtureType) {
let (code, _) = compile_module_with_type("MultiContributorStorage", fixture_type).unwrap();
ExtBuilder::default().build().execute_with(|| {
Balances::set_balance(&ALICE, 100_000_000_000);
Balances::set_balance(&CHARLIE, 100_000_000_000);
let Contract { addr, account_id } =
builder::bare_instantiate(Code::Upload(code)).build_and_unwrap_contract();
assert_ok!(
builder::bare_call(addr)
.data(MultiContributorStorage::growStorageCall {}.abi_encode())
.build()
.result,
);
assert_ok!(
BareCallBuilder::<Test>::bare_call(RuntimeOrigin::signed(CHARLIE), addr)
.data(MultiContributorStorage::growStorageCall {}.abi_encode())
.build()
.result,
);
assert!(NativeDepositOf::<Test>::get(&account_id, &ALICE) > 0);
assert!(NativeDepositOf::<Test>::get(&account_id, &CHARLIE) > 0);
assert!(System::account_exists(&account_id), "contract account is alive pre-terminate");
assert_ok!(
builder::bare_call(addr)
.data(
MultiContributorStorage::terminateCall { beneficiary: DJANGO_ADDR.0.into() }
.abi_encode(),
)
.build()
.result,
);
assert!(get_contract_checked(&addr).is_none(), "contract info should be gone");
assert!(
!System::account_exists(&account_id),
"system account should be reaped once destroy_contract burns the EDs",
);
assert_eq!(Balances::balance(&account_id), 0);
assert_eq!(Assets::balance(PGAS_ASSET_ID, &account_id), 0);
assert!(NativeDepositOf::<Test>::get(&account_id, &ALICE) > 0);
assert!(NativeDepositOf::<Test>::get(&account_id, &CHARLIE) > 0);
assert_eq!(DeletionQueue::<Test>::iter().count(), 1, "contract is queued for deletion");
Contracts::on_idle(System::block_number(), Weight::MAX);
assert_eq!(
DeletionQueue::<Test>::iter().count(),
0,
"deletion queue drained to completion",
);
assert_eq!(NativeDepositOf::<Test>::iter_prefix(&account_id).count(), 0);
});
}
#[test]
fn refund_to_user_without_entitlement_does_not_revert() {
let after_charge = State {
payer_native: 900,
contract_native_held: 100,
native_entitlement: 100,
..State::default()
};
run(TestCase {
accounts: vec![AccountSetup { account: ALICE, native: 1_000, pgas: 0 }],
charges: vec![Charge { payer: ALICE, amount: 100, expected: after_charge }],
refund: (CHARLIE, 80),
expected_after_refund: vec![(ALICE, after_charge)],
});
}
#[test]
fn mixed_native_pgas_refund_caps_pgas_without_reverting() {
run(TestCase {
accounts: vec![
AccountSetup { account: ALICE, native: 1_000, pgas: 0 },
AccountSetup { account: CHARLIE, native: 1_000, pgas: 1_000 },
],
charges: vec![
Charge {
payer: ALICE,
amount: 100,
expected: State {
payer_native: 900,
contract_native_held: 100,
native_entitlement: 100,
..State::default()
},
},
Charge {
payer: CHARLIE,
amount: 40,
expected: State {
payer_native: 1_000,
payer_pgas: 960,
contract_native_held: 100,
contract_pgas_held: 40,
..State::default()
},
},
],
refund: (CHARLIE, 80),
expected_after_refund: vec![
(
ALICE,
State {
payer_native: 900,
contract_native_held: 100,
native_entitlement: 100,
..State::default()
},
),
(
CHARLIE,
State {
payer_native: 1_000,
payer_pgas: 964,
contract_native_held: 100,
..State::default()
},
),
],
});
}
#[test]
fn code_upload_and_remove_with_pgas() {
let (binary, code_hash) = compile_module("dummy").unwrap();
ExtBuilder::default()
.with_pgas_balances(vec![(ALICE, 10_000_000)])
.build()
.execute_with(|| {
Balances::set_balance(&ALICE, 0);
let pallet_account = crate::Pallet::<Test>::account_id();
assert_ok!(Contracts::upload_code(
RuntimeOrigin::signed(ALICE),
binary,
crate::test_utils::deposit_limit::<Test>(),
));
let info = crate::CodeInfoOf::<Test>::get(&code_hash).unwrap();
let deposit = info.deposit();
assert_eq!(
AssetsHolder::balance_on_hold(
PGAS_ASSET_ID,
&HoldReason::CodeUploadDepositReserve.into(),
&pallet_account,
),
deposit,
"deposit held in PGAS on the pallet account",
);
assert_eq!(
NativeDepositOf::<Test>::get(&pallet_account, &ALICE),
0,
"PGAS path does not record a native entitlement",
);
let pgas_before_remove = Assets::balance(PGAS_ASSET_ID, &ALICE);
assert_ok!(Contracts::remove_code(RuntimeOrigin::signed(ALICE), code_hash));
let pgas_after_remove = Assets::balance(PGAS_ASSET_ID, &ALICE);
let refund_pct = crate::tests::PGasRefundPercent::get();
let expected_refund = refund_pct.mul_floor(deposit);
assert_eq!(
pgas_after_remove - pgas_before_remove,
expected_refund,
"PGAS partial refund credited to uploader",
);
assert_eq!(
AssetsHolder::balance_on_hold(
PGAS_ASSET_ID,
&HoldReason::CodeUploadDepositReserve.into(),
&pallet_account,
),
0,
"hold released",
);
});
}