|
| 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