From 66f1a305c4324ffe951babf484d48975a49207a0 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Mon, 13 Apr 2026 10:52:53 -0500 Subject: [PATCH 1/2] Adopt wire-sysio table_id namespace isolation Mirrors Wire-Network/wire-libraries-ts#5 (packages/sdk-core). Binary ABI (hard break): table_def.name is now a length-prefixed string (was an 8-byte sysio::name uint64). table_id (uint16 LE, DJB2 hash of the table name % 65536) and secondary_indexes (vector) are appended to each table entry. A protobuf_types trailing extension field is also read/written for forward compatibility. The ABI.Table interface gains optional table_id and secondary_indexes fields and a new ABI.Index interface. This mirrors wire-sysio Wire-Network/wire-sysio#288. get_table_rows response shape (back-compat): KV-backed tables on wire-sysio Wire-Network/wire-sysio#290+ return each row as {key, value, payer?} instead of the decoded struct directly. ChainAPI detects this shape and unwraps to expose just the value (and captures payer into ram_payers when show_payer is set), preserving the existing API contract for callers. EOSIO chains emitting the legacy flat or {data,payer} shapes are unaffected. Tests: test/abi.ts covers table_id round-trip, long names, secondary indexes, boundary values (0/65535), missing-field defaults, the protobuf_types trailer, and a multi-table end-to-end encode/decode. test/chain-api.ts covers the new KV shape unwrap, show_payer capture, and backward compatibility with both legacy row shapes. --- src/api/v1/chain.ts | 34 +++++- src/chain/abi.ts | 88 +++++++++++++++- test/abi.ts | 249 ++++++++++++++++++++++++++++++++++++++++++++ test/chain-api.ts | 199 +++++++++++++++++++++++++++++++++++ test/serializer.ts | 10 +- 5 files changed, 575 insertions(+), 5 deletions(-) create mode 100644 test/abi.ts create mode 100644 test/chain-api.ts diff --git a/src/api/v1/chain.ts b/src/api/v1/chain.ts index e0b422ce..e4852b0a 100644 --- a/src/api/v1/chain.ts +++ b/src/api/v1/chain.ts @@ -330,7 +330,39 @@ export class ChainAPI { }); let ram_payers: Name[] | undefined; - if (params.show_payer) { + // Wire-sysio PR Wire-Network/wire-sysio#290 unified the get_table_rows + // endpoint and changed the per-row shape for KV-backed tables from + // {} (legacy) + // {data: , payer: } (legacy + show_payer) + // to + // {key, value, payer?} (unified, all KV tables) + // The new shape is detectable: each row is an object with both `key` + // and `value` keys. Unwrap to expose the decoded value (or hex string + // when json=false) directly to downstream code, and capture `payer` + // into ram_payers when show_payer was requested. This preserves the + // legacy shape (plain decoded object, optionally wrapped in + // {data, payer}) for callers and stays backward compatible with + // EOSIO chains that still emit the old format. + const isWireKvShape = + Array.isArray(rows) && + rows.length > 0 && + typeof rows[0] === 'object' && + rows[0] !== null && + 'key' in rows[0] && + 'value' in rows[0]; + + if (isWireKvShape) { + if (params.show_payer) { + ram_payers = []; + rows = rows.map((row: {value: any; payer?: string}) => { + ram_payers!.push(Name.from(row.payer ?? '')); + return row.value; + }); + } else { + rows = rows.map((row: {value: any}) => row.value); + } + } else if (params.show_payer) { + // Legacy show_payer wrapper: {data, payer} ram_payers = []; rows = rows.map(({data, payer}) => { ram_payers!.push(Name.from(payer)); diff --git a/src/chain/abi.ts b/src/chain/abi.ts index 59733bdb..7bf0a670 100644 --- a/src/chain/abi.ts +++ b/src/chain/abi.ts @@ -99,7 +99,12 @@ export class ABI implements ABISerializableObject { const numTables = decoder.readVaruint32(); for (let i = 0; i < numTables; i++) { - const name = Name.fromABI(decoder); + // Wire-sysio PR Wire-Network/wire-sysio#288: name is now a length- + // prefixed string (was an 8-byte sysio::name uint64), and the table + // gains table_id (uint16) and secondary_indexes (vector). + // The struct binary order matches sysio::chain::table_def in + // libraries/chain/include/sysio/chain/abi_def.hpp. + const name = decoder.readString(); const index_type = decoder.readString(); const key_names: string[] = []; const numKeyNames = decoder.readVaruint32(); @@ -116,7 +121,35 @@ export class ABI implements ABISerializableObject { } const type = decoder.readString(); - tables.push({name, index_type, key_names, key_types, type}); + // table_id: uint16 LE + const tidLo = decoder.readByte(); + const tidHi = decoder.readByte(); + const table_id = tidLo | (tidHi << 8); + // secondary_indexes: vector + const secondary_indexes: ABI.Index[] = []; + const numIndexes = decoder.readVaruint32(); + + for (let j = 0; j < numIndexes; j++) { + const idxName = decoder.readString(); + const idxKeyType = decoder.readString(); + const idxLo = decoder.readByte(); + const idxHi = decoder.readByte(); + secondary_indexes.push({ + name: idxName, + key_type: idxKeyType, + table_id: idxLo | (idxHi << 8), + }); + } + + tables.push({ + name, + index_type, + key_names, + key_types, + type, + table_id, + secondary_indexes, + }); } const ricardian_clauses: ABI.Clause[] = []; @@ -175,6 +208,14 @@ export class ABI implements ABISerializableObject { } } + // protobuf_types: forward-compat extension added by wire-sysio in + // libraries/chain/include/sysio/chain/abi_def.hpp. The SDK does not + // consume this field but reads it so any subsequent extension + // appended to abi_def can also be parsed cleanly. + if (decoder.canRead()) { + decoder.readString(); // discard + } + return new ABI({ version, types, @@ -220,7 +261,11 @@ export class ABI implements ABISerializableObject { encoder.writeVaruint32(this.tables.length); for (const table of this.tables) { - Name.from(table.name).toABI(encoder); + // table_def.name is now a free-form string (was a sysio::name uint64 + // before wire-sysio PR Wire-Network/wire-sysio#288). Coerce via + // String() so callers that historically passed a Name object via + // NameType still work. + encoder.writeString(String(table.name)); encoder.writeString(table.index_type); encoder.writeVaruint32(table.key_names.length); @@ -235,6 +280,22 @@ export class ABI implements ABISerializableObject { } encoder.writeString(table.type); + // table_id: uint16 LE; default 0 for tables built without one + // (e.g. hand-constructed test fixtures). The chain side computes + // the same value via DJB2(name) % 65536 at compile time in CDT. + const tid = table.table_id ?? 0; + encoder.writeByte(tid & 0xff); + encoder.writeByte((tid >> 8) & 0xff); + // secondary_indexes: vector + const secIdx = table.secondary_indexes ?? []; + encoder.writeVaruint32(secIdx.length); + + for (const idx of secIdx) { + encoder.writeString(idx.name); + encoder.writeString(idx.key_type); + encoder.writeByte(idx.table_id & 0xff); + encoder.writeByte((idx.table_id >> 8) & 0xff); + } } encoder.writeVaruint32(this.ricardian_clauses.length); @@ -263,6 +324,11 @@ export class ABI implements ABISerializableObject { Name.from(result.name).toABI(encoder); encoder.writeString(result.result_type); } + + // protobuf_types: forward-compat extension. Always written as empty + // string for symmetry with the parser; wire-sysio's + // sysio::chain::abi_def writes this field unconditionally. + encoder.writeString(''); } resolveType(name: string): ABI.ResolvedType { @@ -398,12 +464,28 @@ export namespace ABI { type: string; ricardian_contract: string; } + // Per-secondary-index metadata embedded in Table. Mirrors + // sysio::chain::index_def in wire-sysio's + // libraries/chain/include/sysio/chain/abi_def.hpp. + export interface Index { + name: string; + key_type: string; + table_id: number; + } + // Wire-sysio PR Wire-Network/wire-sysio#288 widened table_def.name from + // sysio::name (uint64) to a free-form string and added table_id (uint16, + // DJB2 hash of the table name % 65536) and secondary_indexes for KV-table + // per-table namespace isolation. Older EOSIO chains still emit the legacy + // 8-byte name and have no table_id/secondary_indexes; this SDK only + // supports the wire-sysio binary format. export interface Table { name: NameType; index_type: string; key_names: string[]; key_types: string[]; type: string; + table_id?: number; + secondary_indexes?: Index[]; } export interface Clause { id: string; diff --git a/test/abi.ts b/test/abi.ts new file mode 100644 index 00000000..ce045be9 --- /dev/null +++ b/test/abi.ts @@ -0,0 +1,249 @@ +import {assert} from 'chai'; + +// Tests for the wire-sysio binary format adopted in PR +// Wire-Network/wire-sysio#288 (table_id namespace isolation). The on-wire +// shape of table_def changed: `name` widened from sysio::name (uint64) to +// a length-prefixed string, and table_id (uint16) + secondary_indexes +// (vector) were appended. abi_def also gained a trailing +// protobuf_types extension. + +import {ABI, ABIDecoder, ABIEncoder} from '$lib'; + +function encodeAbi(abi: ABI): Uint8Array { + const encoder = new ABIEncoder(); + abi.toABI(encoder); + return encoder.getData(); +} + +function decodeAbi(bytes: Uint8Array): ABI { + const decoder = new ABIDecoder(bytes); + return ABI.fromABI(decoder); +} + +suite('abi binary format (wire-sysio table_id namespace isolation)', function () { + test('round-trips a table with table_id and empty secondary_indexes', function () { + const original = new ABI({ + version: 'sysio::abi/1.2', + structs: [ + { + name: 'account', + base: '', + fields: [{name: 'balance', type: 'asset'}], + }, + ], + tables: [ + { + name: 'accounts', + index_type: 'i64', + key_names: ['scope', 'primary_key'], + key_types: ['name', 'uint64'], + type: 'account', + table_id: 12345, + secondary_indexes: [], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables.length, 1); + const t = decoded.tables[0]; + assert.equal(t.name, 'accounts'); + assert.equal(t.index_type, 'i64'); + assert.deepEqual(t.key_names, ['scope', 'primary_key']); + assert.deepEqual(t.key_types, ['name', 'uint64']); + assert.equal(t.type, 'account'); + assert.equal(t.table_id, 12345); + assert.deepEqual(t.secondary_indexes, []); + }); + + test('round-trips a long table name (>12 chars) - the whole reason name was widened', function () { + const original = new ABI({ + tables: [ + { + name: 'a-very-long-table-name', + index_type: 'i64', + key_names: ['pk'], + key_types: ['uint64'], + type: 'row', + table_id: 7, + secondary_indexes: [], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables[0].name, 'a-very-long-table-name'); + assert.equal(decoded.tables[0].table_id, 7); + }); + + test('round-trips secondary_indexes with checksum256 key_type', function () { + const original = new ABI({ + tables: [ + { + name: 'users', + index_type: 'i64', + key_names: ['scope', 'id'], + key_types: ['name', 'uint64'], + type: 'user', + table_id: 100, + secondary_indexes: [ + {name: 'byowner', key_type: 'name', table_id: 200}, + {name: 'bybalance', key_type: 'uint64', table_id: 201}, + {name: 'byhash', key_type: 'checksum256', table_id: 202}, + ], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + const t = decoded.tables[0]; + assert.equal(t.secondary_indexes!.length, 3); + assert.deepEqual(t.secondary_indexes![0], { + name: 'byowner', + key_type: 'name', + table_id: 200, + }); + assert.deepEqual(t.secondary_indexes![1], { + name: 'bybalance', + key_type: 'uint64', + table_id: 201, + }); + assert.deepEqual(t.secondary_indexes![2], { + name: 'byhash', + key_type: 'checksum256', + table_id: 202, + }); + }); + + test('table_id 0 is preserved (the default for hand-built tables)', function () { + const original = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: 0, + secondary_indexes: [], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables[0].table_id, 0); + }); + + test('table_id 65535 (max uint16) round-trips correctly', function () { + const original = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: 65535, + secondary_indexes: [], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables[0].table_id, 65535); + }); + + test('missing table_id defaults to 0 on encode', function () { + // Hand-built ABIs may omit table_id; the encoder defaults to 0 and + // the decoder reads back 0. + const original = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables[0].table_id, 0); + assert.deepEqual(decoded.tables[0].secondary_indexes, []); + }); + + test('encoder always emits protobuf_types (empty string trailer)', function () { + // The encoded form should end with the varint-prefixed empty string + // for protobuf_types: 0x00 (length 0). Verify the last byte is 0x00. + const abi = new ABI({}); + const bytes = encodeAbi(abi); + assert.equal(bytes[bytes.length - 1], 0x00); + }); + + test('multi-table ABI with structs, actions, secondary indexes round-trips', function () { + const original = new ABI({ + version: 'sysio::abi/1.2', + types: [{new_type_name: 'account_name', type: 'name'}], + structs: [ + { + name: 'transfer', + base: '', + fields: [ + {name: 'from', type: 'account_name'}, + {name: 'to', type: 'account_name'}, + {name: 'quantity', type: 'asset'}, + {name: 'memo', type: 'string'}, + ], + }, + { + name: 'account', + base: '', + fields: [{name: 'balance', type: 'asset'}], + }, + { + name: 'user', + base: '', + fields: [ + {name: 'id', type: 'uint64'}, + {name: 'owner', type: 'name'}, + ], + }, + ], + actions: [{name: 'transfer', type: 'transfer', ricardian_contract: ''}], + tables: [ + { + name: 'accounts', + index_type: 'i64', + key_names: ['scope', 'sym_code'], + key_types: ['name', 'uint64'], + type: 'account', + table_id: 1, + secondary_indexes: [], + }, + { + name: 'users', + index_type: 'i64', + key_names: ['scope', 'id'], + key_types: ['name', 'uint64'], + type: 'user', + table_id: 2, + secondary_indexes: [{name: 'byowner', key_type: 'name', table_id: 100}], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.version, 'sysio::abi/1.2'); + assert.equal(decoded.types.length, 1); + assert.equal(decoded.structs.length, 3); + assert.equal(decoded.actions.length, 1); + assert.equal(decoded.tables.length, 2); + assert.equal(decoded.tables[0].name, 'accounts'); + assert.equal(decoded.tables[0].table_id, 1); + assert.equal(decoded.tables[1].name, 'users'); + assert.equal(decoded.tables[1].table_id, 2); + assert.equal(decoded.tables[1].secondary_indexes!.length, 1); + assert.equal(decoded.tables[1].secondary_indexes![0].name, 'byowner'); + }); +}); diff --git a/test/chain-api.ts b/test/chain-api.ts new file mode 100644 index 00000000..868fe1c5 --- /dev/null +++ b/test/chain-api.ts @@ -0,0 +1,199 @@ +import {assert} from 'chai'; + +// Tests for the wire-sysio PR Wire-Network/wire-sysio#290 unified +// get_table_rows response shape. KV-backed tables now return rows as +// `{key, value, payer?}` objects instead of the legacy form (decoded +// struct directly, or `{data, payer}` when show_payer is set). The +// Chain.get_table_rows wrapper detects the new shape and unwraps it +// so downstream callers keep seeing the same row layout they always have. + +import {APIClient, APIProvider, APIResponse} from '$lib'; + +// Minimal in-memory APIProvider for unit tests. Returns the JSON body +// passed via the `responses` map keyed by request path. +class StubProvider implements APIProvider { + constructor(private responses: Record) {} + async call(args: {path: string}): Promise { + const json = this.responses[args.path]; + if (json === undefined) { + throw new Error(`StubProvider: no response registered for ${args.path}`); + } + return { + status: 200, + headers: {}, + json, + text: JSON.stringify(json), + }; + } +} + +function makeClient(responses: Record): APIClient { + return new APIClient({provider: new StubProvider(responses)}); +} + +suite('ChainAPI.get_table_rows (wire-sysio KV row shape)', function () { + test('unwraps the new {key, value} shape into plain rows', async function () { + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + { + key: {scope: 'alice', sym_code: '1397703940'}, + value: {balance: '100.0000 SYS'}, + }, + { + key: {scope: 'alice', sym_code: '1145521988'}, + value: {balance: '200.0000 AAA'}, + }, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'alice', + table: 'accounts', + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {balance: '100.0000 SYS'}); + assert.deepEqual(result.rows[1], {balance: '200.0000 AAA'}); + assert.equal(result.more, false); + assert.isUndefined(result.ram_payers); + }); + + test('unwraps the new shape with show_payer and captures payers', async function () { + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + { + key: {scope: 'alice', sym_code: '1397703940'}, + value: {balance: '100.0000 SYS'}, + payer: 'alice', + }, + { + key: {scope: 'alice', sym_code: '1145521988'}, + value: {balance: '200.0000 AAA'}, + payer: 'sysio', + }, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'alice', + table: 'accounts', + show_payer: true, + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {balance: '100.0000 SYS'}); + assert.deepEqual(result.rows[1], {balance: '200.0000 AAA'}); + assert.isDefined(result.ram_payers); + assert.equal(result.ram_payers!.length, 2); + assert.equal(String(result.ram_payers![0]), 'alice'); + assert.equal(String(result.ram_payers![1]), 'sysio'); + }); + + test('preserves legacy plain-row shape from EOSIO chains', async function () { + // EOSIO chains still return rows as the decoded struct directly (no + // {key, value} wrapper). The wrapper must not touch these. + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + {owner: 'alice', balance: '100.0000 SYS'}, + {owner: 'bob', balance: '200.0000 SYS'}, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'sysio.token', + table: 'accounts', + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {owner: 'alice', balance: '100.0000 SYS'}); + assert.deepEqual(result.rows[1], {owner: 'bob', balance: '200.0000 SYS'}); + }); + + test('preserves legacy {data, payer} show_payer shape from EOSIO chains', async function () { + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + {data: {owner: 'alice', balance: '100.0000 SYS'}, payer: 'alice'}, + {data: {owner: 'bob', balance: '200.0000 SYS'}, payer: 'sysio'}, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'sysio.token', + table: 'accounts', + show_payer: true, + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {owner: 'alice', balance: '100.0000 SYS'}); + assert.deepEqual(result.rows[1], {owner: 'bob', balance: '200.0000 SYS'}); + assert.equal(result.ram_payers!.length, 2); + assert.equal(String(result.ram_payers![0]), 'alice'); + assert.equal(String(result.ram_payers![1]), 'sysio'); + }); + + test('empty rows array works for both shapes', async function () { + const client = makeClient({ + '/v1/chain/get_table_rows': {rows: [], more: false, next_key: ''}, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'alice', + table: 'accounts', + }); + + assert.deepEqual(result.rows, []); + assert.equal(result.more, false); + }); + + test('missing payer in new shape coerces to empty name', async function () { + // The unified API makes `payer` optional. When show_payer is true + // but a row was synthesized without a payer (edge case), the + // wrapper should not throw. + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + { + key: {scope: 'alice', sym_code: '1397703940'}, + value: {balance: '100.0000 SYS'}, + // payer intentionally omitted + }, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'alice', + table: 'accounts', + show_payer: true, + }); + + assert.equal(result.rows.length, 1); + assert.deepEqual(result.rows[0], {balance: '100.0000 SYS'}); + assert.equal(result.ram_payers!.length, 1); + assert.equal(String(result.ram_payers![0]), ''); + }); +}); diff --git a/test/serializer.ts b/test/serializer.ts index 2e762ced..0c1e117e 100644 --- a/test/serializer.ts +++ b/test/serializer.ts @@ -1081,7 +1081,15 @@ suite('serializer', function () { types: [{new_type_name: 'b', type: 'a'}], structs: [{base: '', name: 'a', fields: [{name: 'f', type: 'a'}]}], tables: [ - {name: 't', type: 'a', index_type: 'i64', key_names: ['k'], key_types: ['i64']}, + { + name: 't', + type: 'a', + index_type: 'i64', + key_names: ['k'], + key_types: ['i64'], + table_id: 0, + secondary_indexes: [], + }, ], ricardian_clauses: [{id: 'foo', body: 'bar'}], variants: [{name: 'v', types: ['a', 'b']}], From b971036ffe95ad784d1f22008e3e2079a8bd3dc3 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Thu, 16 Apr 2026 15:50:38 -0500 Subject: [PATCH 2/2] Address PR #10 review feedback Fix issues surfaced in review of PR #10: - Tighten isWireKvShape detection: require `key` to be a non-null object so that user tables with scalar `key`/`value` fields are not misinterpreted as the wire-sysio KV shape. - Report missing payer as `undefined` (not Name.from('')) in the new KV shape; widen ram_payers type to (Name | undefined)[] so the absent-payer case is explicit. - Widen ABI.Table.name type from NameType to string to match the widened table_def.name semantics. - Validate table_id and index table_id as uint16 on encode so out-of-range values throw instead of silently truncating. - Drain remaining string-typed trailing extensions in the decoder for forward-compat with future abi_def extensions (not just one). - Align optionality: Index.table_id is optional (matches Table.table_id) with encoder falling back to 0. - Trim verbose inline GitHub PR references from comments; keep the "why" (format changed). Tests: - Two new guards: out-of-range table_id and index table_id throw. - Forward-compat: decoder drains extra string-typed trailing fields. - Golden-bytes layout fixture catches silent byte-layout drift vs the C++ sysio::chain::table_def struct. - ChainAPI: missing payer reported as undefined; user table with scalar key+value is not unwrapped. --- src/api/v1/chain.ts | 31 ++++++------ src/api/v1/types.ts | 7 ++- src/chain/abi.ts | 70 +++++++++++++------------- test/abi.ts | 116 ++++++++++++++++++++++++++++++++++++++++++++ test/chain-api.ts | 36 ++++++++++++-- 5 files changed, 202 insertions(+), 58 deletions(-) diff --git a/src/api/v1/chain.ts b/src/api/v1/chain.ts index e4852b0a..03ec96c0 100644 --- a/src/api/v1/chain.ts +++ b/src/api/v1/chain.ts @@ -328,34 +328,31 @@ export class ChainAPI { lower_bound, }, }); - let ram_payers: Name[] | undefined; - - // Wire-sysio PR Wire-Network/wire-sysio#290 unified the get_table_rows - // endpoint and changed the per-row shape for KV-backed tables from - // {} (legacy) - // {data: , payer: } (legacy + show_payer) - // to - // {key, value, payer?} (unified, all KV tables) - // The new shape is detectable: each row is an object with both `key` - // and `value` keys. Unwrap to expose the decoded value (or hex string - // when json=false) directly to downstream code, and capture `payer` - // into ram_payers when show_payer was requested. This preserves the - // legacy shape (plain decoded object, optionally wrapped in - // {data, payer}) for callers and stays backward compatible with - // EOSIO chains that still emit the old format. + let ram_payers: (Name | undefined)[] | undefined; + + // Wire-sysio unified get_table_rows returns KV-backed rows as + // {key: {scope, primary_key, ...}, value: , payer?: } + // instead of the legacy shape (decoded struct directly, or + // {data, payer} when show_payer is set). Detect by shape: each row + // must be an object whose `key` is itself an object (the wire-sysio + // key is always composite scope+primary_key) and also has a `value` + // field. Requiring `key` to be an object avoids misinterpreting user + // tables that happen to have scalar fields named `key` and `value`. const isWireKvShape = Array.isArray(rows) && rows.length > 0 && typeof rows[0] === 'object' && rows[0] !== null && 'key' in rows[0] && - 'value' in rows[0]; + 'value' in rows[0] && + typeof rows[0].key === 'object' && + rows[0].key !== null; if (isWireKvShape) { if (params.show_payer) { ram_payers = []; rows = rows.map((row: {value: any; payer?: string}) => { - ram_payers!.push(Name.from(row.payer ?? '')); + ram_payers!.push(row.payer ? Name.from(row.payer) : undefined); return row.value; }); } else { diff --git a/src/api/v1/types.ts b/src/api/v1/types.ts index 3d0cf975..95d03013 100644 --- a/src/api/v1/types.ts +++ b/src/api/v1/types.ts @@ -591,7 +591,12 @@ export interface GetTableRowsParamsTyped { rows: Row[]; more: boolean; - ram_payers?: Name[]; + /** + * Populated when `show_payer` is set. Each entry aligns with `rows[i]`. + * The entry is `undefined` when the chain returned a row without a payer + * (wire-sysio KV shape makes `payer` optional). + */ + ram_payers?: (Name | undefined)[]; next_key?: Index; } diff --git a/src/chain/abi.ts b/src/chain/abi.ts index 7bf0a670..5e97aa92 100644 --- a/src/chain/abi.ts +++ b/src/chain/abi.ts @@ -99,11 +99,9 @@ export class ABI implements ABISerializableObject { const numTables = decoder.readVaruint32(); for (let i = 0; i < numTables; i++) { - // Wire-sysio PR Wire-Network/wire-sysio#288: name is now a length- - // prefixed string (was an 8-byte sysio::name uint64), and the table - // gains table_id (uint16) and secondary_indexes (vector). - // The struct binary order matches sysio::chain::table_def in - // libraries/chain/include/sysio/chain/abi_def.hpp. + // name is a length-prefixed string (widened from sysio::name uint64); + // table_id (uint16) and secondary_indexes (vector) follow. + // Binary order matches sysio::chain::table_def. const name = decoder.readString(); const index_type = decoder.readString(); const key_names: string[] = []; @@ -121,11 +119,9 @@ export class ABI implements ABISerializableObject { } const type = decoder.readString(); - // table_id: uint16 LE const tidLo = decoder.readByte(); const tidHi = decoder.readByte(); const table_id = tidLo | (tidHi << 8); - // secondary_indexes: vector const secondary_indexes: ABI.Index[] = []; const numIndexes = decoder.readVaruint32(); @@ -208,12 +204,12 @@ export class ABI implements ABISerializableObject { } } - // protobuf_types: forward-compat extension added by wire-sysio in - // libraries/chain/include/sysio/chain/abi_def.hpp. The SDK does not - // consume this field but reads it so any subsequent extension - // appended to abi_def can also be parsed cleanly. - if (decoder.canRead()) { - decoder.readString(); // discard + // protobuf_types and any further string-typed trailing extensions: + // drain them so a later-appended extension doesn't cause a decode + // error. The SDK does not consume these fields. Non-string extensions + // added in the future will need explicit handling here. + while (decoder.canRead()) { + decoder.readString(); } return new ABI({ @@ -261,11 +257,7 @@ export class ABI implements ABISerializableObject { encoder.writeVaruint32(this.tables.length); for (const table of this.tables) { - // table_def.name is now a free-form string (was a sysio::name uint64 - // before wire-sysio PR Wire-Network/wire-sysio#288). Coerce via - // String() so callers that historically passed a Name object via - // NameType still work. - encoder.writeString(String(table.name)); + encoder.writeString(table.name); encoder.writeString(table.index_type); encoder.writeVaruint32(table.key_names.length); @@ -280,21 +272,22 @@ export class ABI implements ABISerializableObject { } encoder.writeString(table.type); - // table_id: uint16 LE; default 0 for tables built without one - // (e.g. hand-constructed test fixtures). The chain side computes - // the same value via DJB2(name) % 65536 at compile time in CDT. + // table_id is uint16 LE; chain side computes DJB2(name) % 65536. + // Default to 0 for hand-built tables that omit it. const tid = table.table_id ?? 0; + ABI.assertUint16(tid, `table ${table.name} table_id`); encoder.writeByte(tid & 0xff); encoder.writeByte((tid >> 8) & 0xff); - // secondary_indexes: vector const secIdx = table.secondary_indexes ?? []; encoder.writeVaruint32(secIdx.length); for (const idx of secIdx) { encoder.writeString(idx.name); encoder.writeString(idx.key_type); - encoder.writeByte(idx.table_id & 0xff); - encoder.writeByte((idx.table_id >> 8) & 0xff); + const idxTid = idx.table_id ?? 0; + ABI.assertUint16(idxTid, `index ${idx.name} table_id`); + encoder.writeByte(idxTid & 0xff); + encoder.writeByte((idxTid >> 8) & 0xff); } } @@ -325,12 +318,19 @@ export class ABI implements ABISerializableObject { encoder.writeString(result.result_type); } - // protobuf_types: forward-compat extension. Always written as empty - // string for symmetry with the parser; wire-sysio's - // sysio::chain::abi_def writes this field unconditionally. + // protobuf_types: forward-compat extension; always written as an empty + // string for symmetry with the parser. encoder.writeString(''); } + private static assertUint16(value: number, label: string) { + if (!Number.isInteger(value) || value < 0 || value > 0xffff) { + throw new Error( + `ABI ${label} must be a uint16 in [0, 65535], got ${value}` + ); + } + } + resolveType(name: string): ABI.ResolvedType { const types: {[name: string]: ABI.ResolvedType} = {}; return this.resolve({name, types}, {id: 0}); @@ -465,21 +465,19 @@ export namespace ABI { ricardian_contract: string; } // Per-secondary-index metadata embedded in Table. Mirrors - // sysio::chain::index_def in wire-sysio's - // libraries/chain/include/sysio/chain/abi_def.hpp. + // sysio::chain::index_def. table_id is a uint16 DJB2(name) % 65536. export interface Index { name: string; key_type: string; - table_id: number; + table_id?: number; } - // Wire-sysio PR Wire-Network/wire-sysio#288 widened table_def.name from - // sysio::name (uint64) to a free-form string and added table_id (uint16, - // DJB2 hash of the table name % 65536) and secondary_indexes for KV-table - // per-table namespace isolation. Older EOSIO chains still emit the legacy - // 8-byte name and have no table_id/secondary_indexes; this SDK only + // table_def.name is a free-form string (was sysio::name uint64 pre-wire), + // so names > 12 chars / containing arbitrary characters are valid. + // table_id (uint16) = DJB2(name) % 65536 and secondary_indexes provide + // per-table namespace isolation for KV-backed tables. This SDK only // supports the wire-sysio binary format. export interface Table { - name: NameType; + name: string; index_type: string; key_names: string[]; key_types: string[]; diff --git a/test/abi.ts b/test/abi.ts index ce045be9..37518f8a 100644 --- a/test/abi.ts +++ b/test/abi.ts @@ -181,6 +181,122 @@ suite('abi binary format (wire-sysio table_id namespace isolation)', function () assert.equal(bytes[bytes.length - 1], 0x00); }); + test('encoder rejects table_id outside uint16 range', function () { + const tooBig = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: 65536, + secondary_indexes: [], + }, + ], + }); + assert.throws(() => encodeAbi(tooBig), /uint16/); + + const negative = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: -1, + secondary_indexes: [], + }, + ], + }); + assert.throws(() => encodeAbi(negative), /uint16/); + }); + + test('encoder rejects secondary_index table_id outside uint16 range', function () { + const abi = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: 0, + secondary_indexes: [{name: 'bad', key_type: 'name', table_id: 70000}], + }, + ], + }); + assert.throws(() => encodeAbi(abi), /uint16/); + }); + + test('decoder drains multiple trailing string-typed extensions', function () { + // Simulate a future wire-sysio release that appends additional + // string-typed extension fields after protobuf_types. The decoder + // must not choke on the extra data (forward-compat). + const abi = new ABI({ + version: 'sysio::abi/1.2', + tables: [], + }); + const baseBytes = encodeAbi(abi); + // Append two extra length-prefixed strings: "foo" and "bar". + const extra = new Uint8Array([ + 0x03, 0x66, 0x6f, 0x6f, // "foo" + 0x03, 0x62, 0x61, 0x72, // "bar" + ]); + const combined = new Uint8Array(baseBytes.length + extra.length); + combined.set(baseBytes, 0); + combined.set(extra, baseBytes.length); + + // Should decode cleanly without throwing. + const decoded = decodeAbi(combined); + assert.equal(decoded.version, 'sysio::abi/1.2'); + assert.equal(decoded.tables.length, 0); + }); + + test('golden bytes: minimal single-table ABI matches expected layout', function () { + // Fixture asserting byte-for-byte layout matches the C++ wire-sysio + // table_def struct in libraries/chain/include/sysio/chain/abi_def.hpp. + // Keeping this as a pinned vector catches silent drift in field + // order / widths without running an integration build. + const abi = new ABI({ + version: 'v', + tables: [ + { + name: 'T', + index_type: 'i', + key_names: [], + key_types: [], + type: 'r', + table_id: 0x1234, + secondary_indexes: [], + }, + ], + }); + const bytes = encodeAbi(abi); + const expected = new Uint8Array([ + 0x01, 0x76, // version "v" + 0x00, // types + 0x00, // structs + 0x00, // actions + 0x01, // tables.length = 1 + 0x01, 0x54, // table[0].name = "T" + 0x01, 0x69, // table[0].index_type = "i" + 0x00, // key_names + 0x00, // key_types + 0x01, 0x72, // type = "r" + 0x34, 0x12, // table_id = 0x1234 LE + 0x00, // secondary_indexes.length = 0 + 0x00, // ricardian_clauses + 0x00, // error_messages + 0x00, // extensions + 0x00, // variants + 0x00, // action_results + 0x00, // protobuf_types = "" + ]); + assert.deepEqual(Array.from(bytes), Array.from(expected)); + }); + test('multi-table ABI with structs, actions, secondary indexes round-trips', function () { const original = new ABI({ version: 'sysio::abi/1.2', diff --git a/test/chain-api.ts b/test/chain-api.ts index 868fe1c5..ac1c2aa5 100644 --- a/test/chain-api.ts +++ b/test/chain-api.ts @@ -166,10 +166,11 @@ suite('ChainAPI.get_table_rows (wire-sysio KV row shape)', function () { assert.equal(result.more, false); }); - test('missing payer in new shape coerces to empty name', async function () { + test('missing payer in new shape is reported as undefined', async function () { // The unified API makes `payer` optional. When show_payer is true - // but a row was synthesized without a payer (edge case), the - // wrapper should not throw. + // but a row was returned without a payer, the wrapper pushes + // `undefined` so the absent-payer case is explicit to callers rather + // than silently coerced to an empty Name. const client = makeClient({ '/v1/chain/get_table_rows': { rows: [ @@ -194,6 +195,33 @@ suite('ChainAPI.get_table_rows (wire-sysio KV row shape)', function () { assert.equal(result.rows.length, 1); assert.deepEqual(result.rows[0], {balance: '100.0000 SYS'}); assert.equal(result.ram_payers!.length, 1); - assert.equal(String(result.ram_payers![0]), ''); + assert.isUndefined(result.ram_payers![0]); + }); + + test('does not unwrap user table that happens to have scalar key+value fields', async function () { + // A user-defined table with struct {key: string, value: uint64} would + // collide with the wire-sysio KV shape on field-name alone. Requiring + // `key` to be an object (wire KV keys are always composite) avoids + // misinterpreting these rows. + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + {key: 'some_setting', value: 42}, + {key: 'other_setting', value: 7}, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'user.contract', + scope: 'user.contract', + table: 'settings', + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {key: 'some_setting', value: 42}); + assert.deepEqual(result.rows[1], {key: 'other_setting', value: 7}); }); });