Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions activation-service/helm/tfchainactivationservice/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ apiVersion: v2
name: tfchainactivationservice
description: TFchain account activation funding service
type: application
version: 2.10.1
appVersion: '2.10.1'
version: 2.11.0
appVersion: '2.11.0'
2 changes: 1 addition & 1 deletion activation-service/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "substrate-funding-service",
"version": "2.10.1",
"version": "2.11.0",
"description": "Substrate funding service",
"main": "index.js",
"scripts": {
Expand Down
4 changes: 2 additions & 2 deletions bridge/tfchain_bridge/chart/tfchainbridge/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ apiVersion: v2
name: tfchainbridge
description: Bridge for TFT between Tfchain Stellar
type: application
version: 2.10.1
appVersion: '2.10.1'
version: 2.11.0
appVersion: '2.11.0'
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
}
2 changes: 1 addition & 1 deletion clients/tfchain-client-js/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "tfgrid-api-client",
"version": "2.10.1",
"version": "2.11.0",
"description": "API client for the TF Grid",
"main": "index.js",
"scripts": {
Expand Down
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.
Loading
Loading