Skip to content
Merged
22 changes: 22 additions & 0 deletions clients/tfchain-client-go/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,25 @@ type ZosVersionUpdated struct {
Topics []types.Hash
}

type NodeV3BillingOptedOut struct {
Phase types.Phase
NodeID types.U32 `json:"node_id"`
OptedOutAt types.U64 `json:"opted_out_at"`
Topics []types.Hash
}

type TwinAdminAdded struct {
Phase types.Phase
Account AccountID `json:"account_id"`
Topics []types.Hash
}

type TwinAdminRemoved struct {
Phase types.Phase
Account AccountID `json:"account_id"`
Topics []types.Hash
}

type EventSchedulerCallUnavailable struct {
Phase types.Phase
Task types.TaskAddress
Expand Down Expand Up @@ -467,6 +486,9 @@ type EventRecords struct {
TfgridModule_FarmingPolicySet []FarmingPolicySet //nolint:stylecheck,golint
TfgridModule_FarmCertificationSet []FarmCertificationSet //nolint:stylecheck,golint
TfgridModule_ZosVersionUpdated []ZosVersionUpdated //nolint:stylecheck,golint
TfgridModule_NodeV3BillingOptedOut []NodeV3BillingOptedOut //nolint:stylecheck,golint
TfgridModule_TwinAdminAdded []TwinAdminAdded //nolint:stylecheck,golint
TfgridModule_TwinAdminRemoved []TwinAdminRemoved //nolint:stylecheck,golint

// burn module events
BurningModule_BurnTransactionCreated []BurnTransactionCreated //nolint:stylecheck,golint
Expand Down
118 changes: 118 additions & 0 deletions clients/tfchain-client-go/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -805,6 +805,124 @@ func (s *Substrate) GetDedicatedNodePrice(nodeID uint32) (uint64, error) {
return uint64(price), nil
}

// OptOutOfV3Billing opts a node out of the v3 billing system, opening a free migration window.
// Only the farmer who owns the node can call this.
func (s *Substrate) OptOutOfV3Billing(identity Identity, nodeID uint32) (hash types.Hash, err error) {
cl, meta, err := s.GetClient()
if err != nil {
return hash, err
}

c, err := types.NewCall(meta, "TfgridModule.opt_out_of_v3_billing", nodeID)
if err != nil {
return hash, errors.Wrap(err, "failed to create call")
}

callResponse, err := s.Call(cl, meta, identity, c)
if err != nil {
return hash, errors.Wrap(err, "failed to opt out of v3 billing")
}

return callResponse.Hash, nil
}

// AddTwinAdmin adds an account to the list of twin admins allowed to deploy on opted-out nodes.
// Requires council (restricted) origin.
func (s *Substrate) AddTwinAdmin(identity Identity, account AccountID) (hash types.Hash, err error) {
cl, meta, err := s.GetClient()
if err != nil {
return hash, err
}

c, err := types.NewCall(meta, "TfgridModule.add_twin_admin", account)
if err != nil {
return hash, errors.Wrap(err, "failed to create call")
}

callResponse, err := s.Call(cl, meta, identity, c)
if err != nil {
return hash, errors.Wrap(err, "failed to add twin admin")
}

return callResponse.Hash, nil
}

// RemoveTwinAdmin removes an account from the list of twin admins allowed to deploy on opted-out nodes.
// Requires council (restricted) origin.
func (s *Substrate) RemoveTwinAdmin(identity Identity, account AccountID) (hash types.Hash, err error) {
cl, meta, err := s.GetClient()
if err != nil {
return hash, err
}

c, err := types.NewCall(meta, "TfgridModule.remove_twin_admin", account)
if err != nil {
return hash, errors.Wrap(err, "failed to create call")
}

callResponse, err := s.Call(cl, meta, identity, c)
if err != nil {
return hash, errors.Wrap(err, "failed to remove twin admin")
}

return callResponse.Hash, nil
}

// GetAllowedTwinAdmins returns the list of accounts allowed to deploy on opted-out nodes.
// Returns an empty slice if no admins have been configured.
func (s *Substrate) GetAllowedTwinAdmins() ([]AccountID, error) {
cl, meta, err := s.GetClient()
if err != nil {
return nil, err
}

key, err := types.CreateStorageKey(meta, "TfgridModule", "AllowedTwinAdmins")
if err != nil {
return nil, errors.Wrap(err, "failed to create substrate query key")
}

raw, err := cl.RPC.State.GetStorageRawLatest(key)
if err != nil {
return nil, errors.Wrap(err, "failed to lookup allowed twin admins")
}

if len(*raw) == 0 {
return []AccountID{}, nil
}

var admins []AccountID
if err := Decode(*raw, &admins); err != nil {
return nil, errors.Wrap(err, "failed to decode allowed twin admins")
}

return admins, nil
}

// IsNodeOptedOutOfV3Billing returns true if the node has opted out of v3 billing.
func (s *Substrate) IsNodeOptedOutOfV3Billing(nodeID uint32) (bool, error) {
cl, meta, err := s.GetClient()
if err != nil {
return false, err
}

bytes, err := Encode(nodeID)
if err != nil {
return false, errors.Wrap(err, "substrate: encoding error building query arguments")
}

key, err := types.CreateStorageKey(meta, "TfgridModule", "NodeV3BillingOptOut", bytes)
if err != nil {
return false, errors.Wrap(err, "failed to create substrate query key")
}

raw, err := cl.RPC.State.GetStorageRawLatest(key)
if err != nil {
return false, errors.Wrap(err, "failed to lookup node v3 billing opt-out")
}

return len(*raw) > 0, nil
}

// SetNodeCertificate sets the node certificate type
func (s *Substrate) SetNodeCertificate(identity Identity, id uint32, cert NodeCertification) error {
cl, meta, err := s.GetClient()
Expand Down
48 changes: 48 additions & 0 deletions clients/tfchain-client-go/node_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,54 @@ func TestSetDedicatedNodePrice(t *testing.T) {
require.Equal(t, uint64(price), priceSet)
}

func TestOptOutOfV3Billing(t *testing.T) {
cl := startLocalConnection(t)
defer cl.Close()

// Bob is the farmer
identity, err := NewIdentityFromSr25519Phrase(BobMnemonics)
require.NoError(t, err)

farmID, twinID := assertCreateFarm(t, cl)
nodeID := assertCreateNode(t, cl, farmID, twinID, identity)

_, err = cl.OptOutOfV3Billing(identity, nodeID)
// NodeV3BillingOptOutAlreadyEnabled is acceptable on re-runs (opt-out is permanent)
if err != nil {
require.EqualError(t, err, "NodeV3BillingOptOutAlreadyEnabled")
}

optedOut, err := cl.IsNodeOptedOutOfV3Billing(nodeID)
require.NoError(t, err)
require.True(t, optedOut)
}

func TestGetAllowedTwinAdmins(t *testing.T) {
cl := startLocalConnection(t)
defer cl.Close()

rootIdentity, err := NewIdentityFromSr25519Phrase(AliceMnemonics)
require.NoError(t, err)

bobAccount, err := FromAddress(BobAddress)
require.NoError(t, err)

// Ensure Bob is in the list
_, err = cl.AddTwinAdmin(rootIdentity, bobAccount)
require.NoError(t, err)
admins, err := cl.GetAllowedTwinAdmins()
require.NoError(t, err)
require.Contains(t, admins, bobAccount)

// Clean up
_, err = cl.RemoveTwinAdmin(rootIdentity, bobAccount)
require.NoError(t, err)

admins, err = cl.GetAllowedTwinAdmins()
require.NoError(t, err)
require.NotContains(t, admins, bobAccount)
}

func TestUptimeReport(t *testing.T) {
cl := startLocalConnection(t)
defer cl.Close()
Expand Down
8 changes: 8 additions & 0 deletions clients/tfchain-client-go/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ var smartContractModuleErrors = []string{
"UnauthorizedToSetExtraFee",
"RewardDistributionError",
"ContractPaymentStateNotExists",
"OnlyTwinAdminCanDeployOnThisNode",
}

// https://github.com/threefoldtech/tfchain/blob/development/substrate-node/pallets/pallet-tfgrid/src/lib.rs#L442
Expand Down Expand Up @@ -249,6 +250,13 @@ var tfgridModuleErrors = []string{
"InvalidRelayAddress",
"InvalidTimestampHint",
"InvalidStorageInput",
"TwinTransferRequestNotFound",
"TwinTransferNewAccountHasTwin",
"TwinTransferPendingExists",
"NodeV3BillingOptOutAlreadyEnabled",
"AlreadyTwinAdmin",
"NotTwinAdmin",
"TwinAdminListFull",
}

// https://github.com/threefoldtech/tfchain/blob/development/substrate-node/pallets/pallet-tft-bridge/src/lib.rs#L152
Expand Down
43 changes: 42 additions & 1 deletion clients/tfchain-client-js/lib/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,42 @@ async function deleteNode (self, id, callback) {
.signAndSend(self.key, { nonce }, callback)
}

// optOutOfV3Billing opts a node out of v3 billing (farmer only, one-way)
async function optOutOfV3Billing (self, nodeID, callback) {
const nonce = await self.api.rpc.system.accountNextIndex(self.address)
return self.api.tx.tfgridModule
.optOutOfV3Billing(nodeID)
.signAndSend(self.key, { nonce }, callback)
}

// addTwinAdmin adds an account to the list of twin admins (council origin)
async function addTwinAdmin (self, account, callback) {
const nonce = await self.api.rpc.system.accountNextIndex(self.address)
return self.api.tx.tfgridModule
.addTwinAdmin(account)
.signAndSend(self.key, { nonce }, callback)
}

// removeTwinAdmin removes an account from the list of twin admins (council origin)
async function removeTwinAdmin (self, account, callback) {
const nonce = await self.api.rpc.system.accountNextIndex(self.address)
return self.api.tx.tfgridModule
.removeTwinAdmin(account)
.signAndSend(self.key, { nonce }, callback)
}

// getAllowedTwinAdmins returns the list of accounts allowed to deploy on opted-out nodes
async function getAllowedTwinAdmins (self) {
const result = await self.api.query.tfgridModule.allowedTwinAdmins()
return result.toJSON() || []
}

// isNodeOptedOutOfV3Billing returns true if the node has opted out of v3 billing
async function isNodeOptedOutOfV3Billing (self, nodeID) {
const result = await self.api.query.tfgridModule.nodeV3BillingOptOut(nodeID)
return !result.isNone
}

async function validateNode (self, farmID) {
const farm = await getFarm(self, farmID)
if (farm.id !== farmID) {
Expand All @@ -139,5 +175,10 @@ module.exports = {
getNode,
getNodeIDByPubkey,
deleteNode,
listNodes
listNodes,
optOutOfV3Billing,
addTwinAdmin,
removeTwinAdmin,
getAllowedTwinAdmins,
isNodeOptedOutOfV3Billing
}
67 changes: 67 additions & 0 deletions docs/architecture/0025-v3-billing-opt-out.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 25. V3 Billing Opt-Out for Node Migration

Date: 2026-02-18

## Status

Accepted

## Context

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.

## Decision

### New Storage Items (pallet-tfgrid)

- **`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.
- **`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.

### New Extrinsics (pallet-tfgrid)

| Extrinsic | Origin | Call Index |
| --- | --- | --- |
| `opt_out_of_v3_billing(node_id)` | Farmer (signed) | 43 |
| `add_twin_admin(account)` | Council (`RestrictedOrigin`) | 44 |
| `remove_twin_admin(account)` | Council (`RestrictedOrigin`) | 45 |

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.

### Deployment Guards (pallet-smart-contract)

`_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`.

### Billing Suppression (pallet-smart-contract)

In `bill_contract`, a `should_waive_migration_billing` flag is computed by checking `NodeV3BillingOptOut` for the contract's node. When true:

- **`Created` state**: early return, no billing work performed.
- **`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.
- **`Deleted` state**: cost is zeroed; cleanup proceeds normally.

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.

### New Events (pallet-tfgrid)

- `NodeV3BillingOptedOut { node_id, opted_out_at }` — emitted on successful opt-out.
- `TwinAdminAdded(AccountId)` — emitted when an admin is added.
- `TwinAdminRemoved(AccountId)` — emitted when an admin is removed.

### New Errors

### pallet-smart-contract

- `OnlyTwinAdminCanDeployOnThisNode` — returned when a non-admin attempts to deploy on an opted-out node.

### pallet-tfgrid

- `NodeV3BillingOptOutAlreadyEnabled` — returned when `opt_out_of_v3_billing` is called on a node that has already opted out.
- `AlreadyTwinAdmin` — returned when `add_twin_admin` is called for an account already in the admin list.
- `NotTwinAdmin` — returned when `remove_twin_admin` is called for an account not in the admin list, or when the list is empty.
- `TwinAdminListFull` — returned when `add_twin_admin` is called but the list has reached `MaxTwinAdmins` capacity.

## Consequences

- **No storage migration**: all new storage items are additive.
- **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.
- **Access control**: only council-approved twin admins can deploy new workloads on opted-out nodes.
12 changes: 6 additions & 6 deletions substrate-node/pallets/pallet-burning/src/weights.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
//! Autogenerated weights for pallet_burning
//!
//! THIS FILE WAS AUTO-GENERATED USING THE SUBSTRATE BENCHMARK CLI VERSION 4.0.0-dev
//! DATE: 2025-09-15, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]`
//! DATE: 2026-02-19, STEPS: `50`, REPEAT: `20`, LOW RANGE: `[]`, HIGH RANGE: `[]`
//! WORST CASE MAP SIZE: `1000000`
//! HOSTNAME: `ebea9d7a9918`, CPU: `AMD Ryzen 7 5800X 8-Core Processor`
//! HOSTNAME: `19750001b071`, CPU: `DO-Regular`
//! EXECUTION: , WASM-EXECUTION: Compiled, CHAIN: Some("dev"), DB CACHE: 1024

// Executed Command:
Expand Down Expand Up @@ -45,8 +45,8 @@ impl<T: frame_system::Config> WeightInfo for SubstrateWeight<T> {
// Proof Size summary in bytes:
// Measured: `109`
// Estimated: `1594`
// Minimum execution time: 26_060_000 picoseconds.
Weight::from_parts(26_561_000, 1594)
// Minimum execution time: 54_457_000 picoseconds.
Weight::from_parts(93_148_000, 1594)
.saturating_add(T::DbWeight::get().reads(1_u64))
.saturating_add(T::DbWeight::get().writes(1_u64))
}
Expand All @@ -60,8 +60,8 @@ impl WeightInfo for () {
// Proof Size summary in bytes:
// Measured: `109`
// Estimated: `1594`
// Minimum execution time: 26_060_000 picoseconds.
Weight::from_parts(26_561_000, 1594)
// Minimum execution time: 54_457_000 picoseconds.
Weight::from_parts(93_148_000, 1594)
.saturating_add(RocksDbWeight::get().reads(1_u64))
.saturating_add(RocksDbWeight::get().writes(1_u64))
}
Expand Down
Loading
Loading