Skip to content

Commit 048de90

Browse files
committed
Add V3 billing opt-out functionality with admin-only deployment restrictions
Implement node-level V3 billing opt-out mechanism allowing farmers to exempt nodes from billing while restricting deployments to authorized twin admins. Add storage maps for opt-out tracking and admin list management. Include billing suppression logic for opted-out nodes in Created state, with zero-cost calculations for both standard and additional fees.
1 parent c29e0b0 commit 048de90

10 files changed

Lines changed: 700 additions & 2 deletions

File tree

substrate-node/pallets/pallet-smart-contract/src/billing.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,23 @@ impl<T: Config> Pallet<T> {
206206
_ => false,
207207
};
208208

209+
let should_waive_v3_billing = match &contract.contract_type {
210+
types::ContractData::NodeContract(nc) => {
211+
pallet_tfgrid::NodeV3BillingOptOut::<T>::contains_key(nc.node_id)
212+
}
213+
types::ContractData::RentContract(rc) => {
214+
pallet_tfgrid::NodeV3BillingOptOut::<T>::contains_key(rc.node_id)
215+
}
216+
_ => false,
217+
};
218+
219+
// For Created contracts on opted-out nodes: early return (mirrors should_waive_payment).
220+
// early return is cleaner and consistent with the should_waive_payment pattern.
221+
// GracePeriod and Deleted fall through: zero new cost + manage_contract_state runs naturally.
222+
if should_waive_v3_billing && matches!(contract.state, types::ContractState::Created) {
223+
return Ok(().into());
224+
}
225+
209226
if should_waive_payment {
210227
log::info!("Waiving rent for contract_id: {:?}", contract.contract_id);
211228
Self::deposit_event(Event::RentWaived {
@@ -219,7 +236,7 @@ impl<T: Config> Pallet<T> {
219236
}
220237

221238
// Calculate the due amount
222-
let (standard_amount_due, discount_received) = if should_waive_payment {
239+
let (standard_amount_due, discount_received) = if should_waive_payment || should_waive_v3_billing {
223240
(BalanceOf::<T>::zero(), types::DiscountLevel::None)
224241
} else {
225242
contract
@@ -236,7 +253,7 @@ impl<T: Config> Pallet<T> {
236253

237254
let additional_amount_due =
238255
if let types::ContractData::RentContract(rc) = &contract.contract_type {
239-
if should_waive_payment {
256+
if should_waive_payment || should_waive_v3_billing {
240257
BalanceOf::<T>::zero()
241258
} else {
242259
contract.calculate_extra_fee_cost_tft(rc.node_id, seconds_elapsed)

substrate-node/pallets/pallet-smart-contract/src/grid_contract.rs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ impl<T: Config> Pallet<T> {
3434
Error::<T>::NodeNotAvailableToDeploy
3535
);
3636

37+
// V3 billing opt-out guard: only twin admins can deploy on opted-out nodes
38+
if pallet_tfgrid::NodeV3BillingOptOut::<T>::contains_key(node_id) {
39+
let caller_is_admin = pallet_tfgrid::AllowedTwinAdmins::<T>::get()
40+
.unwrap_or_default()
41+
.contains(&account_id);
42+
ensure!(caller_is_admin, Error::<T>::OnlyTwinAdminCanDeployOnThisNode);
43+
}
44+
3745
let farm = pallet_tfgrid::Farms::<T>::get(node.farm_id).ok_or(Error::<T>::FarmNotExists)?;
3846

3947
// A node is dedicated (can only be used under a rent contract)
@@ -129,6 +137,14 @@ impl<T: Config> Pallet<T> {
129137
Error::<T>::FarmNotExists
130138
);
131139

140+
// V3 billing opt-out guard: only twin admins can deploy on opted-out nodes
141+
if pallet_tfgrid::NodeV3BillingOptOut::<T>::contains_key(node_id) {
142+
let caller_is_admin = pallet_tfgrid::AllowedTwinAdmins::<T>::get()
143+
.unwrap_or_default()
144+
.contains(&account_id);
145+
ensure!(caller_is_admin, Error::<T>::OnlyTwinAdminCanDeployOnThisNode);
146+
}
147+
132148
let active_node_contracts = ActiveNodeContracts::<T>::get(node_id);
133149
let farm = pallet_tfgrid::Farms::<T>::get(node.farm_id).ok_or(Error::<T>::FarmNotExists)?;
134150
ensure!(

substrate-node/pallets/pallet-smart-contract/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,7 @@ pub mod pallet {
418418
UnauthorizedToSetExtraFee,
419419
RewardDistributionError,
420420
ContractPaymentStateNotExists,
421+
OnlyTwinAdminCanDeployOnThisNode,
421422
}
422423

423424
#[pallet::genesis_config]

substrate-node/pallets/pallet-smart-contract/src/tests.rs

Lines changed: 269 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5312,6 +5312,275 @@ fn prepare_solution_provider(origin: AccountId) {
53125312
));
53135313
}
53145314

5315+
// ------------------------------------------ //
5316+
// V3 BILLING OPT-OUT TESTS //
5317+
// ------------------------------------------ //
5318+
5319+
#[test]
5320+
fn test_create_node_contract_on_opted_out_node_non_admin_fails() {
5321+
new_test_ext().execute_with(|| {
5322+
run_to_block(1, None);
5323+
prepare_farm_and_node();
5324+
let node_id = 1;
5325+
5326+
assert_ok!(TfgridModule::opt_out_of_v3_billing(
5327+
RuntimeOrigin::signed(alice()),
5328+
node_id,
5329+
));
5330+
5331+
assert_noop!(
5332+
SmartContractModule::create_node_contract(
5333+
RuntimeOrigin::signed(bob()),
5334+
node_id,
5335+
generate_deployment_hash(),
5336+
get_deployment_data(),
5337+
0,
5338+
None,
5339+
),
5340+
Error::<TestRuntime>::OnlyTwinAdminCanDeployOnThisNode
5341+
);
5342+
});
5343+
}
5344+
5345+
#[test]
5346+
fn test_create_node_contract_on_opted_out_node_admin_succeeds() {
5347+
new_test_ext().execute_with(|| {
5348+
run_to_block(1, None);
5349+
prepare_farm_and_node();
5350+
let node_id = 1;
5351+
5352+
assert_ok!(TfgridModule::opt_out_of_v3_billing(
5353+
RuntimeOrigin::signed(alice()),
5354+
node_id,
5355+
));
5356+
5357+
assert_ok!(TfgridModule::add_twin_admin(
5358+
RawOrigin::Root.into(),
5359+
bob(),
5360+
));
5361+
5362+
assert_ok!(SmartContractModule::create_node_contract(
5363+
RuntimeOrigin::signed(bob()),
5364+
node_id,
5365+
generate_deployment_hash(),
5366+
get_deployment_data(),
5367+
0,
5368+
None,
5369+
));
5370+
});
5371+
}
5372+
5373+
#[test]
5374+
fn test_create_node_contract_on_normal_node_unaffected() {
5375+
new_test_ext().execute_with(|| {
5376+
run_to_block(1, None);
5377+
prepare_farm_and_node();
5378+
let node_id = 1;
5379+
5380+
assert_ok!(SmartContractModule::create_node_contract(
5381+
RuntimeOrigin::signed(bob()),
5382+
node_id,
5383+
generate_deployment_hash(),
5384+
get_deployment_data(),
5385+
0,
5386+
None,
5387+
));
5388+
});
5389+
}
5390+
5391+
#[test]
5392+
fn test_create_node_contract_opted_out_empty_admin_list_fails() {
5393+
new_test_ext().execute_with(|| {
5394+
run_to_block(1, None);
5395+
prepare_farm_and_node();
5396+
let node_id = 1;
5397+
5398+
assert_ok!(TfgridModule::opt_out_of_v3_billing(
5399+
RuntimeOrigin::signed(alice()),
5400+
node_id,
5401+
));
5402+
5403+
assert_noop!(
5404+
SmartContractModule::create_node_contract(
5405+
RuntimeOrigin::signed(alice()),
5406+
node_id,
5407+
generate_deployment_hash(),
5408+
get_deployment_data(),
5409+
0,
5410+
None,
5411+
),
5412+
Error::<TestRuntime>::OnlyTwinAdminCanDeployOnThisNode
5413+
);
5414+
});
5415+
}
5416+
5417+
#[test]
5418+
fn test_admin_removed_cannot_deploy_on_opted_out_node() {
5419+
new_test_ext().execute_with(|| {
5420+
run_to_block(1, None);
5421+
prepare_farm_and_node();
5422+
let node_id = 1;
5423+
5424+
assert_ok!(TfgridModule::opt_out_of_v3_billing(
5425+
RuntimeOrigin::signed(alice()),
5426+
node_id,
5427+
));
5428+
5429+
assert_ok!(TfgridModule::add_twin_admin(RawOrigin::Root.into(), bob()));
5430+
assert_ok!(TfgridModule::remove_twin_admin(RawOrigin::Root.into(), bob()));
5431+
5432+
assert_noop!(
5433+
SmartContractModule::create_node_contract(
5434+
RuntimeOrigin::signed(bob()),
5435+
node_id,
5436+
generate_deployment_hash(),
5437+
get_deployment_data(),
5438+
0,
5439+
None,
5440+
),
5441+
Error::<TestRuntime>::OnlyTwinAdminCanDeployOnThisNode
5442+
);
5443+
});
5444+
}
5445+
5446+
#[test]
5447+
fn test_create_rent_contract_on_opted_out_node_non_admin_fails() {
5448+
new_test_ext().execute_with(|| {
5449+
run_to_block(1, None);
5450+
prepare_dedicated_farm_and_node();
5451+
let node_id = 1;
5452+
5453+
assert_ok!(TfgridModule::opt_out_of_v3_billing(
5454+
RuntimeOrigin::signed(alice()),
5455+
node_id,
5456+
));
5457+
5458+
assert_noop!(
5459+
SmartContractModule::create_rent_contract(
5460+
RuntimeOrigin::signed(charlie()),
5461+
node_id,
5462+
None,
5463+
),
5464+
Error::<TestRuntime>::OnlyTwinAdminCanDeployOnThisNode
5465+
);
5466+
});
5467+
}
5468+
5469+
#[test]
5470+
fn test_create_rent_contract_on_opted_out_node_admin_succeeds() {
5471+
new_test_ext().execute_with(|| {
5472+
run_to_block(1, None);
5473+
prepare_dedicated_farm_and_node();
5474+
let node_id = 1;
5475+
5476+
assert_ok!(TfgridModule::opt_out_of_v3_billing(
5477+
RuntimeOrigin::signed(alice()),
5478+
node_id,
5479+
));
5480+
5481+
assert_ok!(TfgridModule::add_twin_admin(
5482+
RawOrigin::Root.into(),
5483+
charlie(),
5484+
));
5485+
5486+
assert_ok!(SmartContractModule::create_rent_contract(
5487+
RuntimeOrigin::signed(charlie()),
5488+
node_id,
5489+
None,
5490+
));
5491+
});
5492+
}
5493+
5494+
#[test]
5495+
fn test_billing_suppressed_for_opted_out_node_contract() {
5496+
// Note: should_bill_contract returns false for a node contract with no resources/IPs/NU/overdraft,
5497+
// so the OCW never submits bill_contract_for_block. We verify billing suppression by directly
5498+
// calling bill_contract and checking that balance is unchanged and state stays Created.
5499+
new_test_ext().execute_with(|| {
5500+
run_to_block(1, None);
5501+
prepare_farm_and_node();
5502+
let node_id = 1;
5503+
5504+
assert_ok!(TfgridModule::add_twin_admin(RawOrigin::Root.into(), bob()));
5505+
5506+
assert_ok!(TfgridModule::opt_out_of_v3_billing(
5507+
RuntimeOrigin::signed(alice()),
5508+
node_id,
5509+
));
5510+
5511+
assert_ok!(SmartContractModule::create_node_contract(
5512+
RuntimeOrigin::signed(bob()),
5513+
node_id,
5514+
generate_deployment_hash(),
5515+
get_deployment_data(),
5516+
0,
5517+
None,
5518+
));
5519+
let contract_id = 1;
5520+
5521+
let balance_before = Balances::free_balance(&bob());
5522+
5523+
// Directly invoke bill_contract (the on-chain extrinsic path)
5524+
assert_ok!(SmartContractModule::bill_contract_for_block(
5525+
RuntimeOrigin::signed(alice()),
5526+
contract_id,
5527+
));
5528+
5529+
let balance_after = Balances::free_balance(&bob());
5530+
5531+
// No charge — billing suppressed for opted-out node
5532+
assert_eq!(balance_before, balance_after);
5533+
5534+
// Contract remains in Created state (not pushed to GracePeriod)
5535+
let contract = SmartContractModule::contracts(contract_id).unwrap();
5536+
assert_eq!(contract.state, types::ContractState::Created);
5537+
});
5538+
}
5539+
5540+
#[test]
5541+
fn test_cancel_contract_on_opted_out_node_zero_final_bill() {
5542+
let (mut ext, mut pool_state) = new_test_ext_with_pool_state(0);
5543+
ext.execute_with(|| {
5544+
run_to_block(1, None);
5545+
prepare_farm_and_node();
5546+
let node_id = 1;
5547+
5548+
assert_ok!(TfgridModule::add_twin_admin(RawOrigin::Root.into(), bob()));
5549+
5550+
assert_ok!(TfgridModule::opt_out_of_v3_billing(
5551+
RuntimeOrigin::signed(alice()),
5552+
node_id,
5553+
));
5554+
5555+
assert_ok!(SmartContractModule::create_node_contract(
5556+
RuntimeOrigin::signed(bob()),
5557+
node_id,
5558+
generate_deployment_hash(),
5559+
get_deployment_data(),
5560+
0,
5561+
None,
5562+
));
5563+
let contract_id = 1;
5564+
5565+
let balance_before = Balances::free_balance(&bob());
5566+
5567+
// Cancel the contract — triggers a final bill_contract call
5568+
assert_ok!(SmartContractModule::cancel_contract(
5569+
RuntimeOrigin::signed(bob()),
5570+
contract_id,
5571+
));
5572+
5573+
let balance_after = Balances::free_balance(&bob());
5574+
5575+
// No charge at cancellation either
5576+
assert_eq!(balance_before, balance_after);
5577+
5578+
// Contract is cleaned up
5579+
assert!(SmartContractModule::contracts(contract_id).is_none());
5580+
let _ = pool_state;
5581+
});
5582+
}
5583+
53155584
fn record(event: RuntimeEvent) -> EventRecord<RuntimeEvent, H256> {
53165585
EventRecord {
53175586
phase: Phase::Initialization,

0 commit comments

Comments
 (0)