From 94e11e088cb85a34f081646f19e778755075edf7 Mon Sep 17 00:00:00 2001 From: Sameh Abouel-saad Date: Tue, 2 Jun 2026 13:55:54 +0300 Subject: [PATCH 1/3] fix(tfchain-client-js): stop getTwin/listTwins crashing on stale ip field The Twin struct no longer has an `ip` field (migrated to `relay`/`pk` long ago), but getTwin/listTwins still did `res.ip = hex2a(res.ip)`. hex2a(undefined) dereferences `undefined.length`, so every getTwinByID/listTwins call threw `Cannot read properties of undefined (reading 'length')` on the current runtime. - Remove the stale `ip` decode from getTwin and listTwins; return the metadata-decoded object as-is. - Harden hex2a to return '' for undefined/null. This is the root of the same crash class: hex2a is called on ~16 fields across twin/entity/farms/node/ tfkvstore, several of which are Option<...> (null) on the current runtime (e.g. node serialNumber, publicConfig ipv4/ipv6/gw4/gw6). Null-safe hex2a immunizes all of them. - Add unit tests for hex2a (built-in node:test, no new deps) and wire up the `test` script. Closes #1090 Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/tfchain-client-js/lib/twin.js | 12 ++++----- clients/tfchain-client-js/lib/util.js | 5 ++++ clients/tfchain-client-js/package.json | 2 +- clients/tfchain-client-js/test/util.test.js | 27 +++++++++++++++++++++ 4 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 clients/tfchain-client-js/test/util.test.js diff --git a/clients/tfchain-client-js/lib/twin.js b/clients/tfchain-client-js/lib/twin.js index 9bd2299fe..f6043afcd 100644 --- a/clients/tfchain-client-js/lib/twin.js +++ b/clients/tfchain-client-js/lib/twin.js @@ -1,5 +1,3 @@ -const { hex2a } = require('./util') - // createTwin creates an entity with given name async function createTwin (self, relay, pk, callback) { const create = self.api.tx.tfgridModule.createTwin(relay, pk) @@ -49,7 +47,9 @@ async function getTwin (self, id) { const twin = await self.api.query.tfgridModule.twins(id) const res = twin.toJSON() - res.ip = hex2a(res.ip) + // The Twin struct no longer has an `ip` field (migrated to `relay`/`pk` long + // ago). Return the metadata-decoded object as-is; decoding a non-existent + // field threw `Cannot read properties of undefined` on the current runtime (#1090). if (res.id !== id) { throw Error('No such twin') } @@ -69,10 +69,8 @@ async function listTwins (self) { const twins = await self.api.query.tfgridModule.twins.entries() const parsedTwins = twins.map(twin => { - const parsedTwin = twin[1].toJSON() - parsedTwin.ip = hex2a(parsedTwin.ip) - - return parsedTwin + // See getTwin: no `ip` field on the current runtime — return as-is (#1090). + return twin[1].toJSON() }) return parsedTwins diff --git a/clients/tfchain-client-js/lib/util.js b/clients/tfchain-client-js/lib/util.js index 92941b4c9..5579674fa 100644 --- a/clients/tfchain-client-js/lib/util.js +++ b/clients/tfchain-client-js/lib/util.js @@ -1,4 +1,9 @@ function hex2a (hex) { + // Guard against absent/null fields. On the current runtime several previously + // byte-encoded fields are Option<...> (decoded as null) or were removed + // entirely, so callers may pass undefined/null. Returning '' keeps the read + // wrappers from throwing `Cannot read properties of undefined`. + if (hex === undefined || hex === null) return '' let str = '' for (let i = 0; i < hex.length; i += 2) { const v = parseInt(hex.substr(i, 2), 16) diff --git a/clients/tfchain-client-js/package.json b/clients/tfchain-client-js/package.json index 176a09e74..4bad72dae 100644 --- a/clients/tfchain-client-js/package.json +++ b/clients/tfchain-client-js/package.json @@ -4,7 +4,7 @@ "description": "API client for the TF Grid", "main": "index.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "node --test test/" }, "author": "", "license": "ISC", diff --git a/clients/tfchain-client-js/test/util.test.js b/clients/tfchain-client-js/test/util.test.js new file mode 100644 index 000000000..ad5778be0 --- /dev/null +++ b/clients/tfchain-client-js/test/util.test.js @@ -0,0 +1,27 @@ +const { test } = require('node:test') +const assert = require('node:assert') +const { hex2a } = require('../lib/util') + +test('hex2a decodes a 0x-prefixed hex string to ASCII', () => { + // "test" = 0x74657374 + assert.strictEqual(hex2a('0x74657374'), 'test') +}) + +test('hex2a decodes a bare (no-0x) hex string to ASCII', () => { + assert.strictEqual(hex2a('74657374'), 'test') +}) + +test('hex2a returns empty string for undefined (absent field on current runtime)', () => { + // Regression for #1090: twin/node/farm read wrappers call hex2a on fields + // that no longer exist on the current runtime; this must not throw. + assert.strictEqual(hex2a(undefined), '') +}) + +test('hex2a returns empty string for null (Option field decoded as null)', () => { + assert.strictEqual(hex2a(null), '') +}) + +test('hex2a returns empty string for empty input', () => { + assert.strictEqual(hex2a(''), '') + assert.strictEqual(hex2a('0x'), '') +}) From da51fe596a6bb7a54c647fcfb48414eb8b35b2b9 Mon Sep 17 00:00:00 2001 From: Sameh Abouel-saad Date: Tue, 2 Jun 2026 14:10:35 +0300 Subject: [PATCH 2/3] fix(tfchain-client-js): correct stale entity storage names and null-guard reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live devnet testing (spec 157) surfaced more stale-runtime-assumption bugs in the read wrappers, beyond the twin `ip` field: - getEntityIDByName queried `entitiesByNameID` and getEntityIDByPubkey queried `entitiesByPubkeyID` — neither exists on the current runtime (renamed to `entityIdByName` / `entityIdByAccountID`). Both threw "is not a function". These are called by activation-service's createEntity, so createEntity was broken against the current runtime. Also normalize not-found to 0 (the new storage decodes to null) to preserve the "0 means absent" contract callers rely on. - getEntity / getNode / getTwin did `res.id` on a null result when the id does not exist, throwing "Cannot read properties of null". Guard with `!res` so they throw a clean "No such " instead. All verified live on devnet. Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/tfchain-client-js/lib/entity.js | 17 ++++++++++++----- clients/tfchain-client-js/lib/node.js | 4 +++- clients/tfchain-client-js/lib/twin.js | 3 ++- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/clients/tfchain-client-js/lib/entity.js b/clients/tfchain-client-js/lib/entity.js index 2e415384b..a214a1780 100644 --- a/clients/tfchain-client-js/lib/entity.js +++ b/clients/tfchain-client-js/lib/entity.js @@ -30,7 +30,9 @@ async function getEntity (self, id) { const entity = await self.api.query.tfgridModule.entities(id) const res = entity.toJSON() - if (res.id !== id) { + // A non-existent entity decodes to null; guard before reading fields so we + // throw a clean "No such entity" instead of a TypeError on null. + if (!res || res.id !== id) { throw Error('No such entity') } @@ -39,15 +41,20 @@ async function getEntity (self, id) { } async function getEntityIDByName (self, name) { - const entity = await self.api.query.tfgridModule.entitiesByNameID(name) + // Storage was renamed on the current runtime: entitiesByNameID -> entityIdByName. + const entity = await self.api.query.tfgridModule.entityIdByName(name) - return entity.toJSON() + // Normalize not-found to 0 (the storage may decode to null), preserving the + // "0 means absent" contract that callers (e.g. activation-service) rely on. + return entity.toJSON() || 0 } async function getEntityIDByPubkey (self, pubkey) { - const entity = await self.api.query.tfgridModule.entitiesByPubkeyID(pubkey) + // Storage was renamed on the current runtime: entitiesByPubkeyID -> entityIdByAccountID. + const entity = await self.api.query.tfgridModule.entityIdByAccountID(pubkey) - return entity.toJSON() + // Normalize not-found to 0 (decodes to null), preserving the "0 means absent" contract. + return entity.toJSON() || 0 } async function listEntities (self) { diff --git a/clients/tfchain-client-js/lib/node.js b/clients/tfchain-client-js/lib/node.js index 9caea31df..f2134bcab 100644 --- a/clients/tfchain-client-js/lib/node.js +++ b/clients/tfchain-client-js/lib/node.js @@ -63,7 +63,9 @@ async function getNode (self, id) { const node = await self.api.query.tfgridModule.nodes(id) const res = node.toJSON() - if (res.id !== id) { + // A non-existent node decodes to null; guard before reading fields so we throw + // a clean "No such node" instead of a TypeError on null. + if (!res || res.id !== id) { throw Error('No such node') } diff --git a/clients/tfchain-client-js/lib/twin.js b/clients/tfchain-client-js/lib/twin.js index f6043afcd..6a60e3030 100644 --- a/clients/tfchain-client-js/lib/twin.js +++ b/clients/tfchain-client-js/lib/twin.js @@ -50,7 +50,8 @@ async function getTwin (self, id) { // The Twin struct no longer has an `ip` field (migrated to `relay`/`pk` long // ago). Return the metadata-decoded object as-is; decoding a non-existent // field threw `Cannot read properties of undefined` on the current runtime (#1090). - if (res.id !== id) { + // A non-existent twin decodes to null — guard before reading `id`. + if (!res || res.id !== id) { throw Error('No such twin') } return res From 454969c59f5af86029088b694c097cc108417b54 Mon Sep 17 00:00:00 2001 From: Sameh Abouel-saad Date: Tue, 2 Jun 2026 15:22:44 +0300 Subject: [PATCH 3/3] fix(tfchain-client-js): decode node public config/location and pricing policy name More stale-structure bugs found while live-verifying getNode/getPricingPolicyById on devnet (all returned un-decoded hex): - getNode public config: read the field as `publicConfig` (camelCase, was the non-existent `public_config`) and decode its actual nested shape { ip4: { ip, gw }, ip6: { ip, gw }, domain } (was a flat { ipv4, ipv6, gw4, gw6 } that matched nothing). - getNode location: city/country are nested under `location` on the current runtime (not top-level), so they were never decoded. Decode city/country/ latitude/longitude inside `location`; drop the dead top-level country/city reads. - getPricingPolicyById: decode the byte-encoded `name` (was returned as raw hex). Verified live on devnet: node 50 now returns city "Bartlesville", country "United States", public IPs/domain readable; policy 1 name "threefold_default_pricing_policy". Co-Authored-By: Claude Opus 4.8 (1M context) --- clients/tfchain-client-js/lib/node.js | 42 +++++++++++-------- .../tfchain-client-js/lib/pricing_policy.js | 10 ++++- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/clients/tfchain-client-js/lib/node.js b/clients/tfchain-client-js/lib/node.js index f2134bcab..e2177472b 100644 --- a/clients/tfchain-client-js/lib/node.js +++ b/clients/tfchain-client-js/lib/node.js @@ -69,27 +69,35 @@ async function getNode (self, id) { throw Error('No such node') } - // Decode location + // Decode location. On the current runtime city/country/latitude/longitude are + // all nested under `location` and byte-encoded. The previous code decoded only + // lat/long and read `res.country`/`res.city` at the top level — where they do + // not exist — so city/country were left hex-encoded. const { location } = res - const { longitude = '', latitude = '' } = location - location.longitude = hex2a(longitude) - location.latitude = hex2a(latitude) - - if (res.country) { - res.country = hex2a(res.country) - } - - if (res.city) { - res.city = hex2a(res.city) + if (location) { + location.longitude = hex2a(location.longitude) + location.latitude = hex2a(location.latitude) + location.city = hex2a(location.city) + location.country = hex2a(location.country) } - const { public_config: publicConfig } = res + // On the current runtime the field decodes as `publicConfig` (camelCase) with a + // nested shape: { ip4: { ip, gw }, ip6: { ip, gw }, domain }. The previous code + // read `public_config` (snake) with a flat { ipv4, ipv6, gw4, gw6 } shape, so it + // matched nothing and left everything hex-encoded. + const { publicConfig } = res if (publicConfig) { - const { ipv4, ipv6, gw4, gw6 } = publicConfig - publicConfig.ipv4 = hex2a(ipv4) - publicConfig.ipv6 = hex2a(ipv6) - publicConfig.gw4 = hex2a(gw4) - publicConfig.gw6 = hex2a(gw6) + if (publicConfig.ip4) { + publicConfig.ip4.ip = hex2a(publicConfig.ip4.ip) + publicConfig.ip4.gw = hex2a(publicConfig.ip4.gw) + } + if (publicConfig.ip6) { + publicConfig.ip6.ip = hex2a(publicConfig.ip6.ip) + publicConfig.ip6.gw = hex2a(publicConfig.ip6.gw) + } + if (publicConfig.domain) { + publicConfig.domain = hex2a(publicConfig.domain) + } } if (res.serialNumber) { diff --git a/clients/tfchain-client-js/lib/pricing_policy.js b/clients/tfchain-client-js/lib/pricing_policy.js index 39f04a3d6..df63ebf99 100644 --- a/clients/tfchain-client-js/lib/pricing_policy.js +++ b/clients/tfchain-client-js/lib/pricing_policy.js @@ -1,7 +1,15 @@ +const { hex2a } = require('./util') + async function getPricingPolicyById (self, policyId) { const value = await self.api.query.tfgridModule.pricingPolicies(policyId) - return value.toJSON() + const res = value.toJSON() + // The policy `name` is byte-encoded; decode it to a string. A non-existent + // policy decodes to null, so guard before touching fields. + if (res && res.name) { + res.name = hex2a(res.name) + } + return res } module.exports = { getPricingPolicyById