Skip to content

Commit d9cdd96

Browse files
authored
feat: Add node V3 opt-out metadata storage and management (#1074)
* feat: Add node V3 opt-out metadata storage and management Implement storage and extrinsic for setting optional metadata on opted-out nodes (e.g. v4 account addresses). Add NodeV3OptOutMetadata storage map with 256-byte limit, set_node_v3_opt_out_metadata extrinsic callable by farm owner, and corresponding events NodeV3OptOutMetadataSet/Cleared. Include client methods GetNodeV3OptOutMetadata and SetNodeV3OptOutMetadata in Go and JS clients with comprehensive unit tests covering set, clear, authorization ---------
1 parent b65dc46 commit d9cdd96

19 files changed

Lines changed: 1031 additions & 421 deletions

File tree

clients/tfchain-client-go/events.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,13 @@ type TwinAdminRemoved struct {
350350
Topics []types.Hash
351351
}
352352

353+
type NodeV3OptOutMetadataUpdated struct {
354+
Phase types.Phase
355+
NodeID types.U32 `json:"node_id"`
356+
Metadata types.OptionBytes `json:"metadata"`
357+
Topics []types.Hash
358+
}
359+
353360
type EventSchedulerCallUnavailable struct {
354361
Phase types.Phase
355362
Task types.TaskAddress
@@ -489,6 +496,7 @@ type EventRecords struct {
489496
TfgridModule_NodeV3BillingOptedOut []NodeV3BillingOptedOut //nolint:stylecheck,golint
490497
TfgridModule_TwinAdminAdded []TwinAdminAdded //nolint:stylecheck,golint
491498
TfgridModule_TwinAdminRemoved []TwinAdminRemoved //nolint:stylecheck,golint
499+
TfgridModule_NodeV3OptOutMetadataUpdated []NodeV3OptOutMetadataUpdated //nolint:stylecheck,golint
492500

493501
// burn module events
494502
BurningModule_BurnTransactionCreated []BurnTransactionCreated //nolint:stylecheck,golint

clients/tfchain-client-go/node.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,62 @@ func (s *Substrate) GetNodeV3BillingOptOutTimestamp(nodeID uint32) (*uint64, err
901901
return &ts, nil
902902
}
903903

904+
// GetNodeV3OptOutMetadata returns the metadata stored for an opted-out node, or nil if not set.
905+
func (s *Substrate) GetNodeV3OptOutMetadata(nodeID uint32) ([]byte, error) {
906+
cl, meta, err := s.GetClient()
907+
if err != nil {
908+
return nil, err
909+
}
910+
911+
bytes, err := Encode(nodeID)
912+
if err != nil {
913+
return nil, errors.Wrap(err, "substrate: encoding error building query arguments")
914+
}
915+
916+
key, err := types.CreateStorageKey(meta, "TfgridModule", "NodeV3OptOutMetadata", bytes)
917+
if err != nil {
918+
return nil, errors.Wrap(err, "failed to create substrate query key")
919+
}
920+
921+
raw, err := cl.RPC.State.GetStorageRawLatest(key)
922+
if err != nil {
923+
return nil, errors.Wrap(err, "failed to lookup node v3 opt-out metadata")
924+
}
925+
926+
if len(*raw) == 0 {
927+
return nil, nil
928+
}
929+
930+
var metadata []byte
931+
if err := Decode(*raw, &metadata); err != nil {
932+
return nil, errors.Wrap(err, "failed to decode node v3 opt-out metadata")
933+
}
934+
935+
return metadata, nil
936+
}
937+
938+
// SetNodeV3OptOutMetadata sets metadata for an opted-out node (e.g. a v4 account address).
939+
// The node must already be opted out of v3 billing. Pass empty bytes to clear.
940+
// Only the farm owner can call this.
941+
func (s *Substrate) SetNodeV3OptOutMetadata(identity Identity, nodeID uint32, metadata []byte) (hash types.Hash, err error) {
942+
cl, meta, err := s.GetClient()
943+
if err != nil {
944+
return hash, err
945+
}
946+
947+
c, err := types.NewCall(meta, "TfgridModule.set_node_v3_opt_out_metadata", nodeID, metadata)
948+
if err != nil {
949+
return hash, errors.Wrap(err, "failed to create call")
950+
}
951+
952+
callResponse, err := s.Call(cl, meta, identity, c)
953+
if err != nil {
954+
return hash, errors.Wrap(err, "failed to set node v3 opt-out metadata")
955+
}
956+
957+
return callResponse.Hash, nil
958+
}
959+
904960
// SetNodeCertificate sets the node certificate type
905961
func (s *Substrate) SetNodeCertificate(identity Identity, id uint32, cert NodeCertification) error {
906962
cl, meta, err := s.GetClient()

clients/tfchain-client-go/node_test.go

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,74 @@ func TestOptOutOfV3Billing(t *testing.T) {
8181
_, err = cl.OptOutOfV3Billing(identity, nodeID)
8282
// NodeV3BillingOptOutAlreadyEnabled is acceptable on re-runs (opt-out is permanent)
8383
if err != nil {
84-
require.EqualError(t, err, "NodeV3BillingOptOutAlreadyEnabled")
84+
// If node is already opted out, that's fine
85+
require.Contains(t, err.Error(), "NodeV3BillingOptOutAlreadyEnabled")
8586
}
8687

8788
optedOut, err := cl.IsNodeOptedOutOfV3Billing(nodeID)
8889
require.NoError(t, err)
8990
require.True(t, optedOut)
9091
}
9192

93+
func TestSetNodeV3OptOutMetadata(t *testing.T) {
94+
cl := startLocalConnection(t)
95+
defer cl.Close()
96+
97+
identity, err := NewIdentityFromSr25519Phrase(BobMnemonics)
98+
require.NoError(t, err)
99+
100+
farmID, twinID := assertCreateFarm(t, cl)
101+
nodeID := assertCreateNode(t, cl, farmID, twinID, identity)
102+
103+
// Node must be opted out first
104+
_, err = cl.OptOutOfV3Billing(identity, nodeID)
105+
if err != nil {
106+
// If node is already opted out, that's fine
107+
require.Contains(t, err.Error(), "NodeV3BillingOptOutAlreadyEnabled")
108+
}
109+
110+
metadata := []byte(`{"v4_account":"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"}`)
111+
112+
_, err = cl.SetNodeV3OptOutMetadata(identity, nodeID, metadata)
113+
require.NoError(t, err)
114+
115+
got, err := cl.GetNodeV3OptOutMetadata(nodeID)
116+
require.NoError(t, err)
117+
require.NotNil(t, got)
118+
require.Equal(t, metadata, got)
119+
}
120+
121+
func TestClearNodeV3OptOutMetadata(t *testing.T) {
122+
cl := startLocalConnection(t)
123+
defer cl.Close()
124+
125+
identity, err := NewIdentityFromSr25519Phrase(BobMnemonics)
126+
require.NoError(t, err)
127+
128+
farmID, twinID := assertCreateFarm(t, cl)
129+
nodeID := assertCreateNode(t, cl, farmID, twinID, identity)
130+
131+
// Node must be opted out first
132+
_, err = cl.OptOutOfV3Billing(identity, nodeID)
133+
if err != nil {
134+
// If node is already opted out, that's fine
135+
require.Contains(t, err.Error(), "NodeV3BillingOptOutAlreadyEnabled")
136+
}
137+
138+
// Set some metadata first
139+
metadata := []byte(`{"v4_account":"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"}`)
140+
_, err = cl.SetNodeV3OptOutMetadata(identity, nodeID, metadata)
141+
require.NoError(t, err)
142+
143+
// Clear by passing empty bytes
144+
_, err = cl.SetNodeV3OptOutMetadata(identity, nodeID, []byte{})
145+
require.NoError(t, err)
146+
147+
got, err := cl.GetNodeV3OptOutMetadata(nodeID)
148+
require.NoError(t, err)
149+
require.Nil(t, got)
150+
}
151+
92152
func TestUptimeReport(t *testing.T) {
93153
cl := startLocalConnection(t)
94154
defer cl.Close()

clients/tfchain-client-go/utils.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ var tfgridModuleErrors = []string{
257257
"AlreadyTwinAdmin",
258258
"NotTwinAdmin",
259259
"TwinAdminListFull",
260+
"NodeNotOptedOutOfV3Billing",
261+
"NodeV3OptOutMetadataTooLong",
260262
}
261263

262264
// 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: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,25 @@ async function getNodeV3BillingOptOutTimestamp (self, nodeID) {
154154
return result.unwrap().toNumber()
155155
}
156156

157+
// getNodeV3OptOutMetadata returns the metadata stored for an opted-out node as a string,
158+
// or null if no metadata has been set.
159+
async function getNodeV3OptOutMetadata (self, nodeID) {
160+
const result = await self.api.query.tfgridModule.nodeV3OptOutMetadata(nodeID)
161+
if (result.isNone) return null
162+
return Buffer.from(result.unwrap().toU8a(true)).toString('utf8')
163+
}
164+
165+
// setNodeV3OptOutMetadata sets metadata for an opted-out node (e.g. a v4 account address).
166+
// The node must already be opted out of v3 billing. Pass empty string to clear.
167+
// Only the farm owner can call this.
168+
async function setNodeV3OptOutMetadata (self, nodeID, metadata, callback) {
169+
const nonce = await self.api.rpc.system.accountNextIndex(self.address)
170+
const bytes = Buffer.from(metadata, 'utf8')
171+
return self.api.tx.tfgridModule
172+
.setNodeV3OptOutMetadata(nodeID, bytes)
173+
.signAndSend(self.key, { nonce }, callback)
174+
}
175+
157176
async function validateNode (self, farmID) {
158177
const farm = await getFarm(self, farmID)
159178
if (farm.id !== farmID) {
@@ -171,5 +190,7 @@ module.exports = {
171190
optOutOfV3Billing,
172191
getAllowedTwinAdmins,
173192
isNodeOptedOutOfV3Billing,
174-
getNodeV3BillingOptOutTimestamp
193+
getNodeV3BillingOptOutTimestamp,
194+
getNodeV3OptOutMetadata,
195+
setNodeV3OptOutMetadata
175196
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# 26. V3 Node Opt-Out Metadata for V4 Account Linkage
2+
3+
Date: 2026-02-26
4+
5+
## Status
6+
7+
Accepted
8+
9+
## Context
10+
11+
ADR-0025 introduced the `NodeV3BillingOptOut` mechanism allowing farmers to signal that their nodes
12+
are entering a migration window to Mycelium (v4). Once a node is opted out, the marketplace needs
13+
a way to associate that node with a v4 account so that:
14+
15+
1. The v4 marketplace can verify the node is legitimately transitioning and not being double-billed.
16+
2. The v4 marketplace can attribute the node's resources and uptime to the correct farmer identity on v4.
17+
3. The linking is farmer-controlled, on-chain, and auditable — no off-chain oracle or manual mapping is required.
18+
19+
### Options Considered
20+
21+
**Option A: Hero Ledger — mutual authentication via cross-chain signed proof**
22+
The proposal was a **mutual authentication** scheme: a v4 account that wants to claim ownership of a v3 node
23+
would sign a proof using the v3 farmer private key (proving control of the v3 account), then submit
24+
a transaction from the v4 account to store that signed proof in Hero Ledger. Any verifier could then
25+
fetch the proof from KVS, retrieve the v3 farmer's public key from TFChain, and verify the signature —
26+
establishing that the v3 and v4 accounts are controlled by the same party without requiring a
27+
transaction on TFChain.
28+
29+
Rejected because:
30+
31+
- The chosen approach (Option D) achieves the same ownership guarantee more simply: the farmer signs a TFChain extrinsic from their v3 account, which is already the authoritative proof of ownership.
32+
33+
**Option B: TFChain `kvstore` pallet**
34+
Use the existing generic key-value store pallet already deployed on TFChain. Farmers could write their v4
35+
account under a well-known key (e.g. `node:<node_id>:v4_account`). Rejected because the `kvstore` pallet
36+
is scoped per twin — any twin can write to its own namespace but there is no way to enforce that the
37+
writer is the farm owner of a specific node, or to enforce that the node must be opted out before the
38+
key can be set. The linkage would be self-asserted with no on-chain validation of the ownership
39+
relationship, making it unsuitable as a trust anchor for the marketplace verifier.
40+
41+
**Option C: Extend `NodeV3BillingOptOut` map (inline struct)**
42+
Change the existing `NodeV3BillingOptOut` storage value type from a bare `u64` timestamp to a struct
43+
containing both the timestamp and optional metadata. Rejected because it requires a storage migration
44+
for all existing opted-out nodes, couples two distinct concerns (immutable billing state and mutable
45+
v4 linkage) into one storage item, and makes future independent evolution of either field harder.
46+
47+
**Option D: Separate opt-out-gated storage map in pallet-tfgrid (chosen)**
48+
Add a new `NodeV3OptOutMetadata` storage map keyed by `node_id`, only writable when the node has already
49+
opted out. This is additive (no migration), co-located with node data, farmer-controlled, and enforces
50+
the invariant that metadata is only meaningful for opted-out nodes.
51+
52+
### Per-Node vs Per-Farm Storage
53+
54+
An alternative keying was discussed: storing one metadata entry per farm rather than per node. This would
55+
allow a single `set` call to link all nodes on a farm to one v4 account. Rejected in favour of per-node
56+
keying because:
57+
58+
- Nodes on the same farm may migrate at different times and could legitimately map to different v4 accounts.
59+
- The opt-out itself (`NodeV3BillingOptOut`) is per-node, so the metadata key should match to keep the relationship unambiguous.
60+
61+
Per-node keying is more granular, consistent with the existing opt-out model, and keeps the linkage lookup O(1) by node ID.
62+
63+
## Decision
64+
65+
### New Storage Item (pallet-tfgrid)
66+
67+
```rust
68+
NodeV3OptOutMetadata: StorageMap<node_id (u32) → BoundedVec<u8, 256>>
69+
```
70+
71+
- Keyed by node ID; presence is independent of `NodeV3BillingOptOut` at the storage level but enforced at the extrinsic level.
72+
- Max 256 bytes — sufficient to hold any account address format (SS58, hex, bech32) plus a small JSON envelope if needed.
73+
- No storage migration required; purely additive.
74+
75+
### New Extrinsic (pallet-tfgrid)
76+
77+
| Extrinsic | Origin | Call Index |
78+
| --- | --- | --- |
79+
| `set_node_v3_opt_out_metadata(node_id, metadata)` | Farmer (signed) | 46 |
80+
81+
**Preconditions enforced on-chain:**
82+
83+
1. Caller's `AccountId` maps to a twin (`TwinNotExists` otherwise).
84+
2. The node exists (`NodeNotExists` otherwise).
85+
3. The caller's twin is the farm owner twin for the node's farm (`NodeUpdateNotAuthorized` otherwise).
86+
4. The node is already opted out of v3 billing (`NodeNotOptedOutOfV3Billing` otherwise).
87+
5. `metadata.len() ≤ 256` (`NodeV3OptOutMetadataTooLong` otherwise).
88+
89+
**Behaviour:**
90+
91+
- If `metadata` is non-empty: upsert `NodeV3OptOutMetadata[node_id]`, emit `NodeV3OptOutMetadataUpdated { node_id, metadata: Some(metadata) }`.
92+
- If `metadata` is empty: remove `NodeV3OptOutMetadata[node_id]`, emit `NodeV3OptOutMetadataUpdated { node_id, metadata: None }`.
93+
94+
The extrinsic is idempotent and can be called repeatedly to update or clear the metadata. Only the farm owner can call it, matching the ownership model of `opt_out_of_v3_billing`.
95+
96+
### New Events (pallet-tfgrid)
97+
98+
- `NodeV3OptOutMetadataUpdated { node_id: u32, metadata: Option<Vec<u8>> }` — emitted when metadata is set, updated, or cleared. `Some(bytes)` indicates set/update, `None` indicates clear.
99+
100+
### New Errors (pallet-tfgrid)
101+
102+
- `NodeNotOptedOutOfV3Billing` — returned when `set_node_v3_opt_out_metadata` is called for a node that has not yet opted out.
103+
- `NodeV3OptOutMetadataTooLong` — returned when the supplied metadata exceeds 256 bytes.
104+
105+
## Flow
106+
107+
### Full opt-out and linkage sequence
108+
109+
```mermaid
110+
graph TD
111+
A["Farmer"] -->|1| B["opt_out_of_v3_billing(node_id)"]
112+
B --> C["Guard: caller twin == farm owner twin"]
113+
C --> D["Guard: node not already opted out"]
114+
D --> E["Insert: NodeV3BillingOptOut[node_id] = now()"]
115+
E --> F["Emit: NodeV3BillingOptedOut { node_id, opted_out_at }"]
116+
117+
A -->|2| G["set_node_v3_opt_out_metadata(node_id, v4_account_bytes)"]
118+
G --> H["Guard: caller twin == farm owner twin"]
119+
H --> I["Guard: NodeV3BillingOptOut[node_id] exists"]
120+
I --> J["Guard: len(metadata) ≤ 256"]
121+
J --> K["Insert: NodeV3OptOutMetadata[node_id] = v4_account_bytes"]
122+
K --> L["Emit: NodeV3OptOutMetadataUpdated { node_id, metadata: Some( ) }"]
123+
```
124+
125+
After step 2, `NodeV3OptOutMetadata[node_id]` holds the farmer's v4 account address (or any agreed-upon linking payload).
126+
127+
### V4 Marketplace Verification Flow
128+
129+
When a node registers or reports uptime on the v4 marketplace, the marketplace verifier must:
130+
131+
```mermaid
132+
graph TD
133+
A["V4 Marketplace Verifier"] -->|1| B["Query TFChain: NodeV3BillingOptOut[node_id]"]
134+
B --> C{"None?"}
135+
C -->|Yes| D["Node is NOT in migration window, reject"]
136+
C -->|Some opted_out_at| E["Node is opted out, continue"]
137+
138+
E -->|2| F["Query TFChain: NodeV3OptOutMetadata[node_id]"]
139+
F --> G{"None?"}
140+
G -->|Yes| H["Treat as unlinked"]
141+
G -->|Some metadata| I["Decode as v4 account address"]
142+
143+
I -->|3| J["Verify v4 account matches node's reported account"]
144+
J --> K{"Match?"}
145+
K -->|Mismatch| L["Reject; farmer must update metadata"]
146+
K -->|Match| M["Node verified as legitimately transitioned"]
147+
148+
M -->|4| N["Attribute node resources and uptime to verified v4 account"]
149+
```
150+
151+
### Metadata Content Convention
152+
153+
The metadata field is opaque bytes at the pallet level. A UTF-8 JSON object can be used for richer payloads, provided it stays within 256 bytes. For example:
154+
155+
```json
156+
{"schema":"v1","account":"5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY"}
157+
```
158+
159+
The marketplace should document and enforce its expected format. The pallet enforces only the length bound.
160+
161+
## Consequences
162+
163+
- **No storage migration**: additive change only; existing nodes and storage layouts are unaffected.
164+
- **Farmer-controlled linkage**: the farm owner has sole authority to set or update the v4 account link, matching the existing ownership model.
165+
- **Invariant enforced on-chain**: metadata can only exist for opted-out nodes, preventing invalid or premature linking.
166+
- **Auditable**: all set and clear operations emit events, providing a full on-chain history of linkage changes.
167+
- **Marketplace trust model**: the v4 marketplace must perform two chain queries (opt-out status + metadata) to verify a node. This is a read-only operation and adds no write overhead to the critical paths.
168+
- **Clearing supported**: farmers can clear the metadata by passing empty bytes, which removes the storage entry entirely.

0 commit comments

Comments
 (0)