Skip to content

Commit 1c75b57

Browse files
authored
Merge pull request #1067 from threefoldtech/development-feat-v3-billing-opt-out
Add V3 billing opt-out functionality with admin-only deployment restrictions
2 parents c29e0b0 + 25f900e commit 1c75b57

File tree

27 files changed

+1599
-390
lines changed

27 files changed

+1599
-390
lines changed

clients/tfchain-client-go/events.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,25 @@ type ZosVersionUpdated struct {
331331
Topics []types.Hash
332332
}
333333

334+
type NodeV3BillingOptedOut struct {
335+
Phase types.Phase
336+
NodeID types.U32 `json:"node_id"`
337+
OptedOutAt types.U64 `json:"opted_out_at"`
338+
Topics []types.Hash
339+
}
340+
341+
type TwinAdminAdded struct {
342+
Phase types.Phase
343+
Account AccountID `json:"account_id"`
344+
Topics []types.Hash
345+
}
346+
347+
type TwinAdminRemoved struct {
348+
Phase types.Phase
349+
Account AccountID `json:"account_id"`
350+
Topics []types.Hash
351+
}
352+
334353
type EventSchedulerCallUnavailable struct {
335354
Phase types.Phase
336355
Task types.TaskAddress
@@ -467,6 +486,9 @@ type EventRecords struct {
467486
TfgridModule_FarmingPolicySet []FarmingPolicySet //nolint:stylecheck,golint
468487
TfgridModule_FarmCertificationSet []FarmCertificationSet //nolint:stylecheck,golint
469488
TfgridModule_ZosVersionUpdated []ZosVersionUpdated //nolint:stylecheck,golint
489+
TfgridModule_NodeV3BillingOptedOut []NodeV3BillingOptedOut //nolint:stylecheck,golint
490+
TfgridModule_TwinAdminAdded []TwinAdminAdded //nolint:stylecheck,golint
491+
TfgridModule_TwinAdminRemoved []TwinAdminRemoved //nolint:stylecheck,golint
470492

471493
// burn module events
472494
BurningModule_BurnTransactionCreated []BurnTransactionCreated //nolint:stylecheck,golint

clients/tfchain-client-go/node.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -805,6 +805,124 @@ func (s *Substrate) GetDedicatedNodePrice(nodeID uint32) (uint64, error) {
805805
return uint64(price), nil
806806
}
807807

808+
// OptOutOfV3Billing opts a node out of the v3 billing system, opening a free migration window.
809+
// Only the farmer who owns the node can call this.
810+
func (s *Substrate) OptOutOfV3Billing(identity Identity, nodeID uint32) (hash types.Hash, err error) {
811+
cl, meta, err := s.GetClient()
812+
if err != nil {
813+
return hash, err
814+
}
815+
816+
c, err := types.NewCall(meta, "TfgridModule.opt_out_of_v3_billing", nodeID)
817+
if err != nil {
818+
return hash, errors.Wrap(err, "failed to create call")
819+
}
820+
821+
callResponse, err := s.Call(cl, meta, identity, c)
822+
if err != nil {
823+
return hash, errors.Wrap(err, "failed to opt out of v3 billing")
824+
}
825+
826+
return callResponse.Hash, nil
827+
}
828+
829+
// AddTwinAdmin adds an account to the list of twin admins allowed to deploy on opted-out nodes.
830+
// Requires council (restricted) origin.
831+
func (s *Substrate) AddTwinAdmin(identity Identity, account AccountID) (hash types.Hash, err error) {
832+
cl, meta, err := s.GetClient()
833+
if err != nil {
834+
return hash, err
835+
}
836+
837+
c, err := types.NewCall(meta, "TfgridModule.add_twin_admin", account)
838+
if err != nil {
839+
return hash, errors.Wrap(err, "failed to create call")
840+
}
841+
842+
callResponse, err := s.Call(cl, meta, identity, c)
843+
if err != nil {
844+
return hash, errors.Wrap(err, "failed to add twin admin")
845+
}
846+
847+
return callResponse.Hash, nil
848+
}
849+
850+
// RemoveTwinAdmin removes an account from the list of twin admins allowed to deploy on opted-out nodes.
851+
// Requires council (restricted) origin.
852+
func (s *Substrate) RemoveTwinAdmin(identity Identity, account AccountID) (hash types.Hash, err error) {
853+
cl, meta, err := s.GetClient()
854+
if err != nil {
855+
return hash, err
856+
}
857+
858+
c, err := types.NewCall(meta, "TfgridModule.remove_twin_admin", account)
859+
if err != nil {
860+
return hash, errors.Wrap(err, "failed to create call")
861+
}
862+
863+
callResponse, err := s.Call(cl, meta, identity, c)
864+
if err != nil {
865+
return hash, errors.Wrap(err, "failed to remove twin admin")
866+
}
867+
868+
return callResponse.Hash, nil
869+
}
870+
871+
// GetAllowedTwinAdmins returns the list of accounts allowed to deploy on opted-out nodes.
872+
// Returns an empty slice if no admins have been configured.
873+
func (s *Substrate) GetAllowedTwinAdmins() ([]AccountID, error) {
874+
cl, meta, err := s.GetClient()
875+
if err != nil {
876+
return nil, err
877+
}
878+
879+
key, err := types.CreateStorageKey(meta, "TfgridModule", "AllowedTwinAdmins")
880+
if err != nil {
881+
return nil, errors.Wrap(err, "failed to create substrate query key")
882+
}
883+
884+
raw, err := cl.RPC.State.GetStorageRawLatest(key)
885+
if err != nil {
886+
return nil, errors.Wrap(err, "failed to lookup allowed twin admins")
887+
}
888+
889+
if len(*raw) == 0 {
890+
return []AccountID{}, nil
891+
}
892+
893+
var admins []AccountID
894+
if err := Decode(*raw, &admins); err != nil {
895+
return nil, errors.Wrap(err, "failed to decode allowed twin admins")
896+
}
897+
898+
return admins, nil
899+
}
900+
901+
// IsNodeOptedOutOfV3Billing returns true if the node has opted out of v3 billing.
902+
func (s *Substrate) IsNodeOptedOutOfV3Billing(nodeID uint32) (bool, error) {
903+
cl, meta, err := s.GetClient()
904+
if err != nil {
905+
return false, err
906+
}
907+
908+
bytes, err := Encode(nodeID)
909+
if err != nil {
910+
return false, errors.Wrap(err, "substrate: encoding error building query arguments")
911+
}
912+
913+
key, err := types.CreateStorageKey(meta, "TfgridModule", "NodeV3BillingOptOut", bytes)
914+
if err != nil {
915+
return false, errors.Wrap(err, "failed to create substrate query key")
916+
}
917+
918+
raw, err := cl.RPC.State.GetStorageRawLatest(key)
919+
if err != nil {
920+
return false, errors.Wrap(err, "failed to lookup node v3 billing opt-out")
921+
}
922+
923+
return len(*raw) > 0, nil
924+
}
925+
808926
// SetNodeCertificate sets the node certificate type
809927
func (s *Substrate) SetNodeCertificate(identity Identity, id uint32, cert NodeCertification) error {
810928
cl, meta, err := s.GetClient()

clients/tfchain-client-go/node_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,54 @@ func TestSetDedicatedNodePrice(t *testing.T) {
6767
require.Equal(t, uint64(price), priceSet)
6868
}
6969

70+
func TestOptOutOfV3Billing(t *testing.T) {
71+
cl := startLocalConnection(t)
72+
defer cl.Close()
73+
74+
// Bob is the farmer
75+
identity, err := NewIdentityFromSr25519Phrase(BobMnemonics)
76+
require.NoError(t, err)
77+
78+
farmID, twinID := assertCreateFarm(t, cl)
79+
nodeID := assertCreateNode(t, cl, farmID, twinID, identity)
80+
81+
_, err = cl.OptOutOfV3Billing(identity, nodeID)
82+
// NodeV3BillingOptOutAlreadyEnabled is acceptable on re-runs (opt-out is permanent)
83+
if err != nil {
84+
require.EqualError(t, err, "NodeV3BillingOptOutAlreadyEnabled")
85+
}
86+
87+
optedOut, err := cl.IsNodeOptedOutOfV3Billing(nodeID)
88+
require.NoError(t, err)
89+
require.True(t, optedOut)
90+
}
91+
92+
func TestGetAllowedTwinAdmins(t *testing.T) {
93+
cl := startLocalConnection(t)
94+
defer cl.Close()
95+
96+
rootIdentity, err := NewIdentityFromSr25519Phrase(AliceMnemonics)
97+
require.NoError(t, err)
98+
99+
bobAccount, err := FromAddress(BobAddress)
100+
require.NoError(t, err)
101+
102+
// Ensure Bob is in the list
103+
_, err = cl.AddTwinAdmin(rootIdentity, bobAccount)
104+
require.NoError(t, err)
105+
admins, err := cl.GetAllowedTwinAdmins()
106+
require.NoError(t, err)
107+
require.Contains(t, admins, bobAccount)
108+
109+
// Clean up
110+
_, err = cl.RemoveTwinAdmin(rootIdentity, bobAccount)
111+
require.NoError(t, err)
112+
113+
admins, err = cl.GetAllowedTwinAdmins()
114+
require.NoError(t, err)
115+
require.NotContains(t, admins, bobAccount)
116+
}
117+
70118
func TestUptimeReport(t *testing.T) {
71119
cl := startLocalConnection(t)
72120
defer cl.Close()

clients/tfchain-client-go/utils.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ var smartContractModuleErrors = []string{
129129
"UnauthorizedToSetExtraFee",
130130
"RewardDistributionError",
131131
"ContractPaymentStateNotExists",
132+
"OnlyTwinAdminCanDeployOnThisNode",
132133
}
133134

134135
// https://github.com/threefoldtech/tfchain/blob/development/substrate-node/pallets/pallet-tfgrid/src/lib.rs#L442
@@ -249,6 +250,13 @@ var tfgridModuleErrors = []string{
249250
"InvalidRelayAddress",
250251
"InvalidTimestampHint",
251252
"InvalidStorageInput",
253+
"TwinTransferRequestNotFound",
254+
"TwinTransferNewAccountHasTwin",
255+
"TwinTransferPendingExists",
256+
"NodeV3BillingOptOutAlreadyEnabled",
257+
"AlreadyTwinAdmin",
258+
"NotTwinAdmin",
259+
"TwinAdminListFull",
252260
}
253261

254262
// https://github.com/threefoldtech/tfchain/blob/development/substrate-node/pallets/pallet-tft-bridge/src/lib.rs#L152

clients/tfchain-client-js/lib/node.js

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,42 @@ async function deleteNode (self, id, callback) {
126126
.signAndSend(self.key, { nonce }, callback)
127127
}
128128

129+
// optOutOfV3Billing opts a node out of v3 billing (farmer only, one-way)
130+
async function optOutOfV3Billing (self, nodeID, callback) {
131+
const nonce = await self.api.rpc.system.accountNextIndex(self.address)
132+
return self.api.tx.tfgridModule
133+
.optOutOfV3Billing(nodeID)
134+
.signAndSend(self.key, { nonce }, callback)
135+
}
136+
137+
// addTwinAdmin adds an account to the list of twin admins (council origin)
138+
async function addTwinAdmin (self, account, callback) {
139+
const nonce = await self.api.rpc.system.accountNextIndex(self.address)
140+
return self.api.tx.tfgridModule
141+
.addTwinAdmin(account)
142+
.signAndSend(self.key, { nonce }, callback)
143+
}
144+
145+
// removeTwinAdmin removes an account from the list of twin admins (council origin)
146+
async function removeTwinAdmin (self, account, callback) {
147+
const nonce = await self.api.rpc.system.accountNextIndex(self.address)
148+
return self.api.tx.tfgridModule
149+
.removeTwinAdmin(account)
150+
.signAndSend(self.key, { nonce }, callback)
151+
}
152+
153+
// getAllowedTwinAdmins returns the list of accounts allowed to deploy on opted-out nodes
154+
async function getAllowedTwinAdmins (self) {
155+
const result = await self.api.query.tfgridModule.allowedTwinAdmins()
156+
return result.toJSON() || []
157+
}
158+
159+
// isNodeOptedOutOfV3Billing returns true if the node has opted out of v3 billing
160+
async function isNodeOptedOutOfV3Billing (self, nodeID) {
161+
const result = await self.api.query.tfgridModule.nodeV3BillingOptOut(nodeID)
162+
return !result.isNone
163+
}
164+
129165
async function validateNode (self, farmID) {
130166
const farm = await getFarm(self, farmID)
131167
if (farm.id !== farmID) {
@@ -139,5 +175,10 @@ module.exports = {
139175
getNode,
140176
getNodeIDByPubkey,
141177
deleteNode,
142-
listNodes
178+
listNodes,
179+
optOutOfV3Billing,
180+
addTwinAdmin,
181+
removeTwinAdmin,
182+
getAllowedTwinAdmins,
183+
isNodeOptedOutOfV3Billing
143184
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# 25. V3 Billing Opt-Out for Node Migration
2+
3+
Date: 2026-02-18
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
During the migration from v3 to Mycelium, farmers need a way to signal that their nodes are entering a migration window. Existing workloads on these nodes should not be billed during this period, as the node is transitioning infrastructure.
12+
13+
## Decision
14+
15+
### New Storage Items (pallet-tfgrid)
16+
17+
- **`NodeV3BillingOptOut`**: A `StorageMap<node_id → opted_out_at (Unix seconds)>` tracking which nodes have opted out. Presence in this map is the sole indicator of opt-out status. No storage migration is required as this is an additive change.
18+
- **`AllowedTwinAdmins`**: A `StorageValue<BoundedVec<AccountId, MaxTwinAdmins>>` listing accounts authorized to deploy on opted-out nodes. Bounded to `MaxTwinAdmins` (default: 100) to prevent unbounded storage growth.
19+
20+
### New Extrinsics (pallet-tfgrid)
21+
22+
| Extrinsic | Origin | Call Index |
23+
| --- | --- | --- |
24+
| `opt_out_of_v3_billing(node_id)` | Farmer (signed) | 43 |
25+
| `add_twin_admin(account)` | Council (`RestrictedOrigin`) | 44 |
26+
| `remove_twin_admin(account)` | Council (`RestrictedOrigin`) | 45 |
27+
28+
Opt-out is one-way and permanent — there is no opt-back-in extrinsic. The `NodeV3BillingOptOut` entry is cleaned up automatically when the node is deleted.
29+
30+
### Deployment Guards (pallet-smart-contract)
31+
32+
`_create_node_contract` and `_create_rent_contract` both check `NodeV3BillingOptOut` before allowing deployment. If the target node has opted out, the caller's `AccountId` must be present in `AllowedTwinAdmins`, otherwise the call fails with `OnlyTwinAdminCanDeployOnThisNode`.
33+
34+
### Billing Suppression (pallet-smart-contract)
35+
36+
In `bill_contract`, a `should_waive_migration_billing` flag is computed by checking `NodeV3BillingOptOut` for the contract's node. When true:
37+
38+
- **`Created` state**: early return, no billing work performed.
39+
- **`GracePeriod` state**: cost is zeroed; `manage_contract_state` runs normally, allowing the contract to be restored to `Created` once the user tops up to cover pre-opt-out overdraft.
40+
- **`Deleted` state**: cost is zeroed; cleanup proceeds normally.
41+
42+
This is distinct from `should_waive_standby_rent` (standby power state, rent contracts only, emits `RentWaived`). The migration billing waiver is silent — no event is emitted because no billing is expected after opt-out.
43+
44+
### New Events (pallet-tfgrid)
45+
46+
- `NodeV3BillingOptedOut { node_id, opted_out_at }` — emitted on successful opt-out.
47+
- `TwinAdminAdded(AccountId)` — emitted when an admin is added.
48+
- `TwinAdminRemoved(AccountId)` — emitted when an admin is removed.
49+
50+
### New Errors
51+
52+
### pallet-smart-contract
53+
54+
- `OnlyTwinAdminCanDeployOnThisNode` — returned when a non-admin attempts to deploy on an opted-out node.
55+
56+
### pallet-tfgrid
57+
58+
- `NodeV3BillingOptOutAlreadyEnabled` — returned when `opt_out_of_v3_billing` is called on a node that has already opted out.
59+
- `AlreadyTwinAdmin` — returned when `add_twin_admin` is called for an account already in the admin list.
60+
- `NotTwinAdmin` — returned when `remove_twin_admin` is called for an account not in the admin list, or when the list is empty.
61+
- `TwinAdminListFull` — returned when `add_twin_admin` is called but the list has reached `MaxTwinAdmins` capacity.
62+
63+
## Consequences
64+
65+
- **No storage migration**: all new storage items are additive.
66+
- **Free migration window**: existing workloads on opted-out nodes accumulate no new charges. Users with pre-existing overdraft (contracts in `GracePeriod`) can still top up to restore their workloads, after which subsequent billing cycles are free.
67+
- **Access control**: only council-approved twin admins can deploy new workloads on opted-out nodes.

substrate-node/pallets/pallet-burning/src/weights.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@
22
//! Autogenerated weights for pallet_burning
33
//!
44
//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev
5-
//! DATE: 2025-09-15, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]`
5+
//! DATE: 2026-02-19, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]`
66
//! WORST CASE MAP SIZE: `1000000`
7-
//! HOSTNAME: `ebea9d7a9918`, CPU: `AMD Ryzen 7 5800X 8-Core Processor`
7+
//! HOSTNAME: `19750001b071`, CPU: `DO-Regular`
88
//! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024
99
1010
// Executed Command:
@@ -45,8 +45,8 @@ impl<T: frame_system::Config> WeightInfo for SubstrateWeight<T> {
4545
// Proof Size summary in bytes:
4646
// Measured: `109`
4747
// Estimated: `1594`
48-
// Minimum execution time: 26_060_000 picoseconds.
49-
Weight::from_parts(26_561_000, 1594)
48+
// Minimum execution time: 54_457_000 picoseconds.
49+
Weight::from_parts(93_148_000, 1594)
5050
.saturating_add(T::DbWeight::get().reads(1_u64))
5151
.saturating_add(T::DbWeight::get().writes(1_u64))
5252
}
@@ -60,8 +60,8 @@ impl WeightInfo for () {
6060
// Proof Size summary in bytes:
6161
// Measured: `109`
6262
// Estimated: `1594`
63-
// Minimum execution time: 26_060_000 picoseconds.
64-
Weight::from_parts(26_561_000, 1594)
63+
// Minimum execution time: 54_457_000 picoseconds.
64+
Weight::from_parts(93_148_000, 1594)
6565
.saturating_add(RocksDbWeight::get().reads(1_u64))
6666
.saturating_add(RocksDbWeight::get().writes(1_u64))
6767
}

0 commit comments

Comments
 (0)