From ae640f56395bdb57939079ac8eb60dd8eb7fee01 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 10 Apr 2026 12:48:47 -0500 Subject: [PATCH 1/5] sdk-core: adopt wire-sysio table_id namespace isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire-sysio PR Wire-Network/wire-sysio#288 widened table_def.name from sysio::name (uint64) to a free-form std::string and appended two new fields — table_id (uint16, DJB2 hash of the table name % 65536) and secondary_indexes (vector) — for KV-table per-table namespace isolation. The wire format break means any sdk-core caller that loads a contract ABI from on-chain via get_raw_abi (or any other binary-encoded source) parses misaligned starting at the first table field — name reads the wrong bytes, every subsequent field is shifted, and ricardian_clauses ends up reading garbage. PR Wire-Network/wire-sysio#290 separately changed the get_table_rows HTTP response shape for KV-backed tables. Each row used to be the decoded struct directly (or {data, payer} when show_payer was set); the unified endpoint now returns {key, value, payer?} per row. The ChainAPI wrapper used to destructure {data, payer} on show_payer and then map rows through Serializer.decode for typed callers — both paths break against the new shape. This commit takes a hard break on the binary format (no fallback to the legacy EOSIO 8-byte name encoding) and a backward-compatible unwrap on the HTTP response shape. packages/sdk-core/src/chain/Abi.ts: - new ABI.Index interface (name, key_type, table_id) mirroring sysio::chain::index_def in wire-sysio's libraries/chain/include/sysio/chain/abi_def.hpp. - ABI.Table gains optional table_id (uint16) and secondary_indexes (Index[]) fields. Optional so hand-built test fixtures and ABIs constructed programmatically don't need to specify them; the encoder defaults to 0 / [] when omitted. - fromABI() reads name as a length-prefixed string instead of an 8-byte sysio::name uint64, then consumes table_id (uint16 LE) and secondary_indexes (vector) after the existing type field. Also reads a trailing protobuf_types extension after enums so that any subsequent extension appended to abi_def parses cleanly to EOF. - toABI() mirrors the parser: writes table_def.name via encoder.writeString(String(table.name)) (the String() coercion keeps NameType-typed callers compiling), then table_id and secondary_indexes, and finally the empty protobuf_types string. packages/sdk-core/src/api/v1/Chain.ts: - get_table_rows() now detects the unified KV row shape — each row has both `key` and `value` keys — and unwraps to the inner value before downstream processing. The old EOSIO shapes (decoded struct directly, or {data, payer} on show_payer) are left untouched, so sdk-core remains usable against EOSIO chains. - When show_payer is set on a KV row, payer is captured into ram_payers in the same way as the legacy {data, payer} path. Missing payer fields coerce to an empty Name rather than throwing. Tests (15 new cases, 304/304 sdk-core suite green): packages/sdk-core/tests/chain/Abi.test.ts (8 cases) - Round-trips a table with table_id + empty secondary_indexes - Round-trips a long table name (>12 chars) — the whole reason name was widened - Round-trips secondary_indexes with checksum256 key_type (the case the wire-cdt #49 abigen fix unblocked end-to-end) - table_id 0 and 65535 boundary values - Missing table_id defaults to 0 on encode + decode - protobuf_types extension is consumed without affecting enums - Encoder always emits the empty protobuf_types trailer - Multi-table ABI end-to-end round-trip (structs + actions + secondary_indexes) packages/sdk-core/tests/api/v1/Chain.test.ts (7 cases) - Unwraps the new {key, value} shape into plain rows - Unwraps the new shape with show_payer + captures ram_payers - Preserves legacy plain-row shape from EOSIO chains - Preserves legacy {data, payer} show_payer shape from EOSIO - Empty rows array works for both shapes - Missing payer in new shape coerces to empty name (no throw) - Uses an in-memory MockProvider so no network access required Out of scope: - packages/sdk-core/src/resources/{Ram,Rex,Powerup}.ts call get_table_rows for tables (rammarket, rex_pool, powup_state) that do not exist in wire-sysio and have not for some time. These resources were already dead on Wire chains regardless of any of the in-flight wire-sysio PRs and remain dead after this commit. - packages/sdk-core/src/types/SystemContractTypes.ts is auto- generated and would benefit from a regen pass after sysio.token's migration to kv::scoped_table merges (Wire-Network/wire-sysio#291), but the field schemas of the structs it generates do not depend on the binary table_def layout. Companion PRs: - Wire-Network/wire-sysio#288 — chain-side table_id namespace isolation - Wire-Network/wire-sysio#290 — unified get_table_rows API - Wire-Network/wire-sysio#291 — sysio.token migrated to kv::scoped_table - Wire-Network/wire-cdt#49 — kv::scoped_table + abigen secondary_indexes - Wire-Network/wirejs-native#4 — TS schema mirror for abi_def - Wire-Network/abieos#12 — abieos C API + binary format update - Wire-Network/node-abieos#8 — bumps abieos + drops the lossy uint64 round-trip - Wire-Network/hyperion-history-api#9 — KV delta handler + AbiDefinitions --- packages/sdk-core/src/api/v1/Chain.ts | 34 ++- packages/sdk-core/src/chain/Abi.ts | 88 +++++- packages/sdk-core/tests/api/v1/Chain.test.ts | 207 ++++++++++++++ packages/sdk-core/tests/chain/Abi.test.ts | 286 +++++++++++++++++++ 4 files changed, 611 insertions(+), 4 deletions(-) create mode 100644 packages/sdk-core/tests/api/v1/Chain.test.ts create mode 100644 packages/sdk-core/tests/chain/Abi.test.ts diff --git a/packages/sdk-core/src/api/v1/Chain.ts b/packages/sdk-core/src/api/v1/Chain.ts index 0ed8e01..b359334 100644 --- a/packages/sdk-core/src/api/v1/Chain.ts +++ b/packages/sdk-core/src/api/v1/Chain.ts @@ -347,7 +347,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/packages/sdk-core/src/chain/Abi.ts b/packages/sdk-core/src/chain/Abi.ts index ee1c8e0..f191c3d 100644 --- a/packages/sdk-core/src/chain/Abi.ts +++ b/packages/sdk-core/src/chain/Abi.ts @@ -103,7 +103,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() @@ -120,7 +125,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[] = [] @@ -214,6 +247,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 that any subsequent extension + // appended to abi_def can also be parsed cleanly. + if (decoder.canRead()) { + decoder.readString() // discard + } + return new ABI({ version, types, @@ -260,7 +301,10 @@ 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 #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) @@ -275,6 +319,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) @@ -329,6 +389,12 @@ export class ABI implements ABISerializableObject { encoder.writeArray(buf) } } + + // 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 via + // might_not_exist semantics. + encoder.writeString("") } resolveType(name: string): ABI.ResolvedType { @@ -492,12 +558,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/packages/sdk-core/tests/api/v1/Chain.test.ts b/packages/sdk-core/tests/api/v1/Chain.test.ts new file mode 100644 index 0000000..ef96133 --- /dev/null +++ b/packages/sdk-core/tests/api/v1/Chain.test.ts @@ -0,0 +1,207 @@ +import { APIClient } from "@wireio/sdk-core/api/Client" +import type { APIProvider } from "@wireio/sdk-core/api/Provider" +import type { APIResponse } from "@wireio/sdk-core/api/Client" + +// Tests for the wire-sysio PR #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. + +// Minimal in-memory APIProvider for unit tests. Returns the JSON body +// passed via the `responses` map keyed by request path. +class MockProvider implements APIProvider { + constructor(private responses: Record) {} + async call(args: { path: string; params?: unknown }): Promise { + const json = this.responses[args.path] + if (json === undefined) { + throw new Error(`MockProvider: no response registered for ${args.path}`) + } + return { + status: 200, + headers: {}, + json, + text: JSON.stringify(json) + } as unknown as APIResponse + } +} + +function makeClient(responses: Record): APIClient { + return new APIClient({ + provider: new MockProvider(responses) + }) +} + +describe("ChainAPI.get_table_rows — wire-sysio KV row shape", () => { + test("unwraps the new {key, value, payer?} shape into plain rows", async () => { + 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" + }) + + expect(result.rows).toHaveLength(2) + // Each row is the unwrapped `value` (the decoded struct), not the + // outer `{key, value}` wrapper. + expect(result.rows[0]).toEqual({ balance: "100.0000 SYS" }) + expect(result.rows[1]).toEqual({ balance: "200.0000 AAA" }) + expect(result.more).toBe(false) + expect(result.ram_payers).toBeUndefined() + }) + + test("unwraps the new shape with show_payer and captures payers", async () => { + 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 + }) + + expect(result.rows).toHaveLength(2) + expect(result.rows[0]).toEqual({ balance: "100.0000 SYS" }) + expect(result.rows[1]).toEqual({ balance: "200.0000 AAA" }) + expect(result.ram_payers).toBeDefined() + expect(result.ram_payers).toHaveLength(2) + expect(String(result.ram_payers![0])).toBe("alice") + expect(String(result.ram_payers![1])).toBe("sysio") + }) + + test("preserves legacy plain-row shape from EOSIO chains", async () => { + // 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" + }) + + expect(result.rows).toHaveLength(2) + expect(result.rows[0]).toEqual({ owner: "alice", balance: "100.0000 SYS" }) + expect(result.rows[1]).toEqual({ owner: "bob", balance: "200.0000 SYS" }) + }) + + test("preserves legacy {data, payer} show_payer shape from EOSIO chains", async () => { + 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 + }) + + expect(result.rows).toHaveLength(2) + expect(result.rows[0]).toEqual({ owner: "alice", balance: "100.0000 SYS" }) + expect(result.rows[1]).toEqual({ owner: "bob", balance: "200.0000 SYS" }) + expect(result.ram_payers).toHaveLength(2) + expect(String(result.ram_payers![0])).toBe("alice") + expect(String(result.ram_payers![1])).toBe("sysio") + }) + + test("empty rows array works for both shapes", async () => { + 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" + }) + + expect(result.rows).toEqual([]) + expect(result.more).toBe(false) + }) + + test("missing payer in new shape coerces to empty name", async () => { + // 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 + }) + + expect(result.rows).toHaveLength(1) + expect(result.rows[0]).toEqual({ balance: "100.0000 SYS" }) + expect(result.ram_payers).toHaveLength(1) + expect(String(result.ram_payers![0])).toBe("") + }) +}) diff --git a/packages/sdk-core/tests/chain/Abi.test.ts b/packages/sdk-core/tests/chain/Abi.test.ts new file mode 100644 index 0000000..7eedb11 --- /dev/null +++ b/packages/sdk-core/tests/chain/Abi.test.ts @@ -0,0 +1,286 @@ +import { ABI } from "@wireio/sdk-core/chain/Abi" +import { ABIDecoder } from "@wireio/sdk-core/serializer/Decoder" +import { ABIEncoder } from "@wireio/sdk-core/serializer/Encoder" +import { Bytes } from "@wireio/sdk-core/chain/Bytes" + +// Tests for the wire-sysio binary format adopted in PR #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. + +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) +} + +describe("ABI", () => { + describe("table_def binary format (wire-sysio PR #288)", () => { + test("round-trips a table with table_id and empty secondary_indexes", () => { + 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 bytes = encodeAbi(original) + const decoded = decodeAbi(bytes) + + expect(decoded.tables).toHaveLength(1) + const decodedTable = decoded.tables[0] + expect(decodedTable.name).toBe("accounts") + expect(decodedTable.index_type).toBe("i64") + expect(decodedTable.key_names).toEqual(["scope", "primary_key"]) + expect(decodedTable.key_types).toEqual(["name", "uint64"]) + expect(decodedTable.type).toBe("account") + expect(decodedTable.table_id).toBe(12345) + expect(decodedTable.secondary_indexes).toEqual([]) + }) + + test("round-trips a long table name (>12 chars) — the whole reason name was widened", () => { + 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)) + expect(decoded.tables[0].name).toBe("a-very-long-table-name") + expect(decoded.tables[0].table_id).toBe(7) + }) + + test("round-trips secondary_indexes with checksum256 key_type", () => { + 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] + expect(t.secondary_indexes).toHaveLength(3) + expect(t.secondary_indexes![0]).toEqual({ + name: "byowner", + key_type: "name", + table_id: 200 + }) + expect(t.secondary_indexes![1]).toEqual({ + name: "bybalance", + key_type: "uint64", + table_id: 201 + }) + expect(t.secondary_indexes![2]).toEqual({ + name: "byhash", + key_type: "checksum256", + table_id: 202 + }) + }) + + test("table_id 0 is preserved (the default for hand-built tables)", () => { + 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)) + expect(decoded.tables[0].table_id).toBe(0) + }) + + test("table_id 65535 (max uint16) round-trips correctly", () => { + 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)) + expect(decoded.tables[0].table_id).toBe(65535) + }) + + test("missing table_id defaults to 0 on encode", () => { + // 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)) + expect(decoded.tables[0].table_id).toBe(0) + expect(decoded.tables[0].secondary_indexes).toEqual([]) + }) + }) + + describe("abi_def trailing protobuf_types extension (wire-sysio)", () => { + test("decoder skips a protobuf_types field appended after enums", () => { + // Build an encoded ABI manually that includes a protobuf_types + // string after the enums section. The parser should consume it + // without error and not affect the rest of the decoded ABI. + const original = new ABI({ + enums: [ + { + name: "color", + type: "uint8", + values: [ + { name: "red", value: 0 }, + { name: "green", value: 1 } + ] + } + ] + }) + const bytes = encodeAbi(original) + // The encoder writes an empty protobuf_types string at the end — + // verify the decoder consumes it cleanly and produces the same ABI. + const decoded = decodeAbi(bytes) + expect(decoded.enums).toHaveLength(1) + expect(decoded.enums[0].name).toBe("color") + expect(decoded.enums[0].values).toHaveLength(2) + }) + + test("encoder always emits protobuf_types (empty string by default)", () => { + // 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) + expect(bytes[bytes.length - 1]).toBe(0x00) + }) + }) + + describe("end-to-end ABI round-trip", () => { + test("multi-table ABI with structs, actions, secondary indexes", () => { + 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)) + expect(decoded.version).toBe("sysio::abi/1.2") + expect(decoded.types).toHaveLength(1) + expect(decoded.structs).toHaveLength(3) + expect(decoded.actions).toHaveLength(1) + expect(decoded.tables).toHaveLength(2) + expect(decoded.tables[0].name).toBe("accounts") + expect(decoded.tables[0].table_id).toBe(1) + expect(decoded.tables[1].name).toBe("users") + expect(decoded.tables[1].table_id).toBe(2) + expect(decoded.tables[1].secondary_indexes).toHaveLength(1) + expect(decoded.tables[1].secondary_indexes![0].name).toBe("byowner") + }) + }) +}) From 2f367629a3c7030059b4c1969bf78c0f79e89201 Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 17 Apr 2026 07:22:35 -0500 Subject: [PATCH 2/5] Address PR #5 review feedback Align with the approved sdk-core#10 follow-up so the two mirrored packages stay consistent. - Tighten isWireKvShape: require `key` to be a non-null object (wire- sysio KV keys are always composite scope+primary_key) so user tables with scalar `key`/`value` fields are not misinterpreted. - Report missing payer as `undefined` on both the KV and legacy show_payer paths; widen GetTableRowsResponse.ram_payers to (Name | undefined)[] so the absent-payer case is explicit. - Widen ABI.Table.name from NameType to string (drop the String() coercion); names > 12 chars and arbitrary characters are valid. - Guard table_id / index table_id as uint16 on encode via ABI.assertUint16 so out-of-range values throw rather than silently truncating. - Drain all remaining string-typed trailing extensions (while-loop) so a later-appended extension doesn't break decoding. - Align optionality: ABI.Index.table_id is optional (matches ABI.Table.table_id), encoder falls back to 0. - Trim verbose inline wire-sysio PR references from comments; keep the "why" (format changed). Tests: - table_id and index table_id out-of-range throw. - decoder drains multiple trailing string-typed extensions. - golden-bytes fixture pinning the table_def binary layout. - user table with scalar key+value is not unwrapped. - missing payer in KV shape is reported as undefined, not "". All 309 sdk-core tests pass. --- packages/sdk-core/src/api/v1/Chain.ts | 33 +++-- packages/sdk-core/src/api/v1/Types.ts | 7 +- packages/sdk-core/src/chain/Abi.ts | 70 ++++++----- packages/sdk-core/tests/api/v1/Chain.test.ts | 36 +++++- packages/sdk-core/tests/chain/Abi.test.ts | 119 +++++++++++++++++++ 5 files changed, 206 insertions(+), 59 deletions(-) diff --git a/packages/sdk-core/src/api/v1/Chain.ts b/packages/sdk-core/src/api/v1/Chain.ts index b359334..becd3aa 100644 --- a/packages/sdk-core/src/api/v1/Chain.ts +++ b/packages/sdk-core/src/api/v1/Chain.ts @@ -345,34 +345,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 { @@ -382,7 +379,7 @@ export class ChainAPI { // Legacy show_payer wrapper: {data, payer} ram_payers = [] rows = rows.map(({ data, payer }) => { - ram_payers!.push(Name.from(payer)) + ram_payers!.push(payer ? Name.from(payer) : undefined) return data }) } diff --git a/packages/sdk-core/src/api/v1/Types.ts b/packages/sdk-core/src/api/v1/Types.ts index a23b466..c0d08da 100644 --- a/packages/sdk-core/src/api/v1/Types.ts +++ b/packages/sdk-core/src/api/v1/Types.ts @@ -605,7 +605,12 @@ export interface GetTableRowsParamsTyped< export interface GetTableRowsResponse { 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/packages/sdk-core/src/chain/Abi.ts b/packages/sdk-core/src/chain/Abi.ts index f191c3d..ea1c38a 100644 --- a/packages/sdk-core/src/chain/Abi.ts +++ b/packages/sdk-core/src/chain/Abi.ts @@ -103,11 +103,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[] = [] @@ -125,11 +123,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() @@ -247,12 +243,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 that 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({ @@ -301,10 +297,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 #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) @@ -319,21 +312,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) } } @@ -390,13 +384,19 @@ export class ABI implements ABISerializableObject { } } - // 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 via - // might_not_exist semantics. + // 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 }) @@ -559,21 +559,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/packages/sdk-core/tests/api/v1/Chain.test.ts b/packages/sdk-core/tests/api/v1/Chain.test.ts index ef96133..4d7ef36 100644 --- a/packages/sdk-core/tests/api/v1/Chain.test.ts +++ b/packages/sdk-core/tests/api/v1/Chain.test.ts @@ -174,10 +174,11 @@ describe("ChainAPI.get_table_rows — wire-sysio KV row shape", () => { expect(result.more).toBe(false) }) - test("missing payer in new shape coerces to empty name", async () => { + test("missing payer in new shape is reported as undefined", async () => { // 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: [ @@ -202,6 +203,33 @@ describe("ChainAPI.get_table_rows — wire-sysio KV row shape", () => { expect(result.rows).toHaveLength(1) expect(result.rows[0]).toEqual({ balance: "100.0000 SYS" }) expect(result.ram_payers).toHaveLength(1) - expect(String(result.ram_payers![0])).toBe("") + expect(result.ram_payers![0]).toBeUndefined() + }) + + test("does not unwrap user table that happens to have scalar key+value fields", async () => { + // 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" + }) + + expect(result.rows).toHaveLength(2) + expect(result.rows[0]).toEqual({ key: "some_setting", value: 42 }) + expect(result.rows[1]).toEqual({ key: "other_setting", value: 7 }) }) }) diff --git a/packages/sdk-core/tests/chain/Abi.test.ts b/packages/sdk-core/tests/chain/Abi.test.ts index 7eedb11..372bafa 100644 --- a/packages/sdk-core/tests/chain/Abi.test.ts +++ b/packages/sdk-core/tests/chain/Abi.test.ts @@ -210,6 +210,125 @@ describe("ABI", () => { const bytes = encodeAbi(abi) expect(bytes[bytes.length - 1]).toBe(0x00) }) + + test("encoder rejects table_id outside uint16 range", () => { + const tooBig = new ABI({ + tables: [ + { + name: "t", + index_type: "i64", + key_names: [], + key_types: [], + type: "row", + table_id: 65536, + secondary_indexes: [] + } + ] + }) + expect(() => encodeAbi(tooBig)).toThrow(/uint16/) + + const negative = new ABI({ + tables: [ + { + name: "t", + index_type: "i64", + key_names: [], + key_types: [], + type: "row", + table_id: -1, + secondary_indexes: [] + } + ] + }) + expect(() => encodeAbi(negative)).toThrow(/uint16/) + }) + + test("encoder rejects secondary_index table_id outside uint16 range", () => { + 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 } + ] + } + ] + }) + expect(() => encodeAbi(abi)).toThrow(/uint16/) + }) + + test("decoder drains multiple trailing string-typed extensions", () => { + // 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) + expect(decoded.version).toBe("sysio::abi/1.2") + expect(decoded.tables).toHaveLength(0) + }) + + test("golden bytes: minimal single-table ABI matches expected layout", () => { + // 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, // enums + 0x00 // protobuf_types = "" + ]) + expect(Array.from(bytes)).toEqual(Array.from(expected)) + }) }) describe("end-to-end ABI round-trip", () => { From f87666aa99df7f74b3b292e6a99a3f1e1d4a7d6f Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 17 Apr 2026 13:23:24 -0500 Subject: [PATCH 3/5] Address PR #5 follow-up review feedback - Abi.ts: version-gate trailing-string drain to sysio::abi/1.x so a future non-string extension fails safely instead of being mis-parsed as a string length - Abi.ts: extract readUint16LE/writeUint16LE helpers to collapse the three duplicated table_id encode/decode sites - Abi.ts: mirror the "Binary order matches sysio::chain::table_def" comment in toABI so both sides are equally self-documenting - Abi.ts: clarify Index.table_id JSDoc - required on-chain, defaults to 0 on encode when omitted from a hand-built ABI - Chain.ts: document the residual object-key false-positive in isWireKvShape and add a pinned regression test - Abi.test.ts: replace brittle bytes[length-1] === 0x00 check with a populated-ABI round-trip; add test for the version-gated drain --- packages/sdk-core/src/api/v1/Chain.ts | 10 +++ packages/sdk-core/src/chain/Abi.ts | 70 ++++++++++++++------ packages/sdk-core/tests/api/v1/Chain.test.ts | 42 ++++++++++++ packages/sdk-core/tests/chain/Abi.test.ts | 60 +++++++++++++++-- 4 files changed, 155 insertions(+), 27 deletions(-) diff --git a/packages/sdk-core/src/api/v1/Chain.ts b/packages/sdk-core/src/api/v1/Chain.ts index becd3aa..1a0e00f 100644 --- a/packages/sdk-core/src/api/v1/Chain.ts +++ b/packages/sdk-core/src/api/v1/Chain.ts @@ -355,6 +355,16 @@ export class ChainAPI { // 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`. + // + // Residual ambiguity: a user-defined row whose top-level struct + // contains a nested struct named `key` AND a field named `value` + // will still trigger the KV unwrap and be silently stripped to + // `value`. Row shapes like `{key: {...}, value: ...}` are rare in + // practice, but cannot be fully distinguished from the wire KV + // shape by heuristic alone — the ABI would need to be consulted. + // See the test `does not unwrap user table whose key is itself an + // object` for a pinned repro of this known false-positive so the + // behavior is caught if the discriminant is ever tightened. const isWireKvShape = Array.isArray(rows) && rows.length > 0 && diff --git a/packages/sdk-core/src/chain/Abi.ts b/packages/sdk-core/src/chain/Abi.ts index ea1c38a..9b27171 100644 --- a/packages/sdk-core/src/chain/Abi.ts +++ b/packages/sdk-core/src/chain/Abi.ts @@ -123,21 +123,17 @@ export class ABI implements ABISerializableObject { } const type = decoder.readString() - const tidLo = decoder.readByte() - const tidHi = decoder.readByte() - const table_id = tidLo | (tidHi << 8) + const table_id = ABI.readUint16LE(decoder) 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) + table_id: ABI.readUint16LE(decoder) }) } @@ -243,12 +239,23 @@ export class ABI implements ABISerializableObject { } } - // 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() + // After enums, wire-sysio sysio::abi/1.x writes protobuf_types as the + // sole trailing extension (length-prefixed string). We drain any + // further string-typed trailers for forward-compat within the 1.x + // line, but only when `version` is a recognized 1.x tag — if a + // future release bumps the major or introduces a non-string trailing + // extension, a blind drain would mis-parse the length prefix as a + // string length and silently produce a corrupt ABI. Scoping the + // drain to known-safe versions makes that failure mode explicit: + // unrecognized versions stop here with any trailing bytes ignored, + // and extending this parser is required when the wire format grows + // a new non-string field. The SDK does not consume the string + // trailers; they are read purely to advance past them. + const knownStringOnlyTrailers = version.startsWith("sysio::abi/1.") + if (knownStringOnlyTrailers) { + while (decoder.canRead()) { + decoder.readString() + } } return new ABI({ @@ -297,6 +304,10 @@ export class ABI implements ABISerializableObject { encoder.writeVaruint32(this.tables.length) for (const table of this.tables) { + // Binary order matches sysio::chain::table_def: name (string), + // index_type, key_names, key_types, type, table_id (uint16 LE), + // secondary_indexes (vector). Mirror of the layout + // consumed in fromABI — keep both sides in sync. encoder.writeString(table.name) encoder.writeString(table.index_type) encoder.writeVaruint32(table.key_names.length) @@ -312,22 +323,16 @@ export class ABI implements ABISerializableObject { } encoder.writeString(table.type) - // 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) + // table_id defaults to 0 for hand-built tables that omit it; + // on-chain ABIs always carry a DJB2(name) % 65536 value. + ABI.writeUint16LE(encoder, table.table_id ?? 0, `table ${table.name} table_id`) const secIdx = table.secondary_indexes ?? [] encoder.writeVaruint32(secIdx.length) for (const idx of secIdx) { encoder.writeString(idx.name) encoder.writeString(idx.key_type) - const idxTid = idx.table_id ?? 0 - ABI.assertUint16(idxTid, `index ${idx.name} table_id`) - encoder.writeByte(idxTid & 0xff) - encoder.writeByte((idxTid >> 8) & 0xff) + ABI.writeUint16LE(encoder, idx.table_id ?? 0, `index ${idx.name} table_id`) } } @@ -397,6 +402,22 @@ export class ABI implements ABISerializableObject { } } + private static readUint16LE(decoder: ABIDecoder): number { + const lo = decoder.readByte() + const hi = decoder.readByte() + return lo | (hi << 8) + } + + private static writeUint16LE( + encoder: ABIEncoder, + value: number, + label: string + ) { + ABI.assertUint16(value, label) + encoder.writeByte(value & 0xff) + encoder.writeByte((value >> 8) & 0xff) + } + resolveType(name: string): ABI.ResolvedType { const types: { [name: string]: ABI.ResolvedType } = {} return this.resolve({ name, types }, { id: 0 }) @@ -560,9 +581,14 @@ export namespace ABI { } // Per-secondary-index metadata embedded in Table. Mirrors // sysio::chain::index_def. table_id is a uint16 DJB2(name) % 65536. + // On-chain ABIs always carry a non-zero table_id (CDT computes it at + // compile time); the field is optional here to allow programmatic + // construction of ABI objects, in which case the encoder writes 0. export interface Index { name: string key_type: string + /** uint16 DJB2(name) % 65536; always present in on-chain ABIs, + * defaults to 0 on encode when omitted from a hand-built ABI. */ table_id?: number } // table_def.name is a free-form string (was sysio::name uint64 pre-wire), diff --git a/packages/sdk-core/tests/api/v1/Chain.test.ts b/packages/sdk-core/tests/api/v1/Chain.test.ts index 4d7ef36..9635680 100644 --- a/packages/sdk-core/tests/api/v1/Chain.test.ts +++ b/packages/sdk-core/tests/api/v1/Chain.test.ts @@ -232,4 +232,46 @@ describe("ChainAPI.get_table_rows — wire-sysio KV row shape", () => { expect(result.rows[0]).toEqual({ key: "some_setting", value: 42 }) expect(result.rows[1]).toEqual({ key: "other_setting", value: 7 }) }) + + test("does not unwrap user table whose key is itself an object", async () => { + // Pinned repro of the residual false-positive in isWireKvShape: a + // user-defined row whose top-level struct contains a nested struct + // named `key` AND a field named `value` is indistinguishable from + // the wire-sysio KV shape by heuristic alone. The current behavior + // is that these rows ARE silently unwrapped to `value`. This test + // documents that known limitation so future tightening of the + // heuristic (or an ABI-aware detection path) has a regression + // target to flip from xfail → pass. + const client = makeClient({ + "/v1/chain/get_table_rows": { + rows: [ + { + key: { name: "alice", perm: "active" }, + value: 42 + }, + { + key: { name: "bob", perm: "active" }, + value: 7 + } + ], + more: false, + next_key: "" + } + }) + + const result = await client.v1.chain.get_table_rows({ + code: "user.contract", + scope: "user.contract", + table: "nested_table" + }) + + // KNOWN FALSE-POSITIVE: the heuristic cannot distinguish this shape + // from the wire-sysio KV shape, so the rows are unwrapped to the + // `value` field. When the discriminant is strengthened (e.g. with + // ABI awareness) this expectation should flip to assert the + // original `{key, value}` objects are preserved. + expect(result.rows).toHaveLength(2) + expect(result.rows[0]).toBe(42) + expect(result.rows[1]).toBe(7) + }) }) diff --git a/packages/sdk-core/tests/chain/Abi.test.ts b/packages/sdk-core/tests/chain/Abi.test.ts index 372bafa..a939b9f 100644 --- a/packages/sdk-core/tests/chain/Abi.test.ts +++ b/packages/sdk-core/tests/chain/Abi.test.ts @@ -204,11 +204,33 @@ describe("ABI", () => { }) test("encoder always emits protobuf_types (empty string by default)", () => { - // 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({}) + // Verify that encoding an ABI with populated enums still ends + // with a length-prefixed empty protobuf_types string. Using an + // ABI with real content means the final two bytes are forced to + // be the varuint(0) length prefix + the empty string (both + // 0x00), rather than coincidentally 0x00 from another field. The + // golden-bytes test below pins the exact byte layout for a + // minimal ABI; this test exists as a sanity check that the + // protobuf_types trailer is present regardless of content, and + // that the round-trip preserves the rest of the ABI. + const abi = new ABI({ + enums: [ + { + name: "color", + type: "uint8", + values: [{ name: "red", value: 0 }] + } + ] + }) const bytes = encodeAbi(abi) + expect(bytes.length).toBeGreaterThan(2) expect(bytes[bytes.length - 1]).toBe(0x00) + // Round-trip must preserve the enums so we know the trailing + // 0x00 wasn't consumed as part of an earlier field. + const decoded = decodeAbi(bytes) + expect(decoded.enums).toHaveLength(1) + expect(decoded.enums[0].name).toBe("color") + expect(decoded.enums[0].values).toHaveLength(1) }) test("encoder rejects table_id outside uint16 range", () => { @@ -262,10 +284,11 @@ describe("ABI", () => { expect(() => encodeAbi(abi)).toThrow(/uint16/) }) - test("decoder drains multiple trailing string-typed extensions", () => { + test("decoder drains multiple trailing string-typed extensions for sysio::abi/1.x", () => { // 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). + // must not choke on the extra data (forward-compat within the + // sysio::abi/1.x line). const abi = new ABI({ version: "sysio::abi/1.2", tables: [] @@ -286,6 +309,33 @@ describe("ABI", () => { expect(decoded.tables).toHaveLength(0) }) + test("decoder does NOT drain trailing bytes for unknown ABI versions", () => { + // Version-gated safety: when the decoded ABI version is outside + // the sysio::abi/1.x line (e.g. a future sysio::abi/2.0), the + // drain loop is skipped so a non-string trailing extension can + // not be mis-parsed as a string. Previously-required fields + // parsed earlier are still populated correctly; trailing bytes + // are simply ignored. This test guarantees no throw and no + // silent corruption of the populated fields. + const abi = new ABI({ + version: "sysio::abi/2.0", + tables: [] + }) + const baseBytes = encodeAbi(abi) + // Append bytes that would mis-parse as a giant string if drained + // (0xff is varuint continuation, leading to a multi-byte length + // prefix). A blind drain would hang or throw on underflow; with + // the version gate, these bytes are left untouched. + const extra = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0x01]) + const combined = new Uint8Array(baseBytes.length + extra.length) + combined.set(baseBytes, 0) + combined.set(extra, baseBytes.length) + + const decoded = decodeAbi(combined) + expect(decoded.version).toBe("sysio::abi/2.0") + expect(decoded.tables).toHaveLength(0) + }) + test("golden bytes: minimal single-table ABI matches expected layout", () => { // Fixture asserting byte-for-byte layout matches the C++ wire-sysio // table_def struct in libraries/chain/include/sysio/chain/abi_def.hpp. From 0544ed338faf0e6970ba6e340ce0f325bd6d352b Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 17 Apr 2026 14:16:30 -0500 Subject: [PATCH 4/5] Address PR #5 third-pass review feedback - Chain.ts: hoist inline row-shape casts to named WireKvRow type; tighten isWireKvShape to reject array-valued `key` fields - Abi.ts: version-gate the encoder's protobuf_types trailer on sysio::abi/1. to match the decoder's gate, so non-1.x ABIs round-trip byte-exact without a trailer - Abi.test.ts: update golden-bytes fixture to sysio::abi/1.2; add companion golden test for non-1.x (no trailer); rename the protobuf_types emission test to reflect version gating - Chain.test.ts: add regression test for user tables whose `key` field is an array (previously matched the heuristic, now rejected) --- packages/sdk-core/src/api/v1/Chain.ts | 22 +++-- packages/sdk-core/src/chain/Abi.ts | 23 ++++- packages/sdk-core/tests/api/v1/Chain.test.ts | 27 ++++++ packages/sdk-core/tests/chain/Abi.test.ts | 98 ++++++++++++++++---- 4 files changed, 139 insertions(+), 31 deletions(-) diff --git a/packages/sdk-core/src/api/v1/Chain.ts b/packages/sdk-core/src/api/v1/Chain.ts index 1a0e00f..382f1bb 100644 --- a/packages/sdk-core/src/api/v1/Chain.ts +++ b/packages/sdk-core/src/api/v1/Chain.ts @@ -351,10 +351,11 @@ export class ChainAPI { // {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`. + // must be an object whose `key` is itself a plain object (the + // wire-sysio key is always a composite scope+primary_key struct) + // and also has a `value` field. Requiring `key` to be a non-array + // object avoids misinterpreting user tables that happen to have + // scalar or array fields named `key` and `value`. // // Residual ambiguity: a user-defined row whose top-level struct // contains a nested struct named `key` AND a field named `value` @@ -373,17 +374,24 @@ export class ChainAPI { "key" in rows[0] && "value" in rows[0] && typeof rows[0].key === "object" && - rows[0].key !== null + rows[0].key !== null && + !Array.isArray(rows[0].key) + + type WireKvRow = { + key: Record + value: unknown + payer?: string + } if (isWireKvShape) { if (params.show_payer) { ram_payers = [] - rows = rows.map((row: { value: any; payer?: string }) => { + rows = rows.map((row: WireKvRow) => { ram_payers!.push(row.payer ? Name.from(row.payer) : undefined) return row.value }) } else { - rows = rows.map((row: { value: any }) => row.value) + rows = rows.map((row: WireKvRow) => row.value) } } else if (params.show_payer) { // Legacy show_payer wrapper: {data, payer} diff --git a/packages/sdk-core/src/chain/Abi.ts b/packages/sdk-core/src/chain/Abi.ts index 9b27171..7d50da5 100644 --- a/packages/sdk-core/src/chain/Abi.ts +++ b/packages/sdk-core/src/chain/Abi.ts @@ -325,14 +325,22 @@ export class ABI implements ABISerializableObject { encoder.writeString(table.type) // table_id defaults to 0 for hand-built tables that omit it; // on-chain ABIs always carry a DJB2(name) % 65536 value. - ABI.writeUint16LE(encoder, table.table_id ?? 0, `table ${table.name} table_id`) + ABI.writeUint16LE( + encoder, + table.table_id ?? 0, + `table ${table.name} table_id` + ) const secIdx = table.secondary_indexes ?? [] encoder.writeVaruint32(secIdx.length) for (const idx of secIdx) { encoder.writeString(idx.name) encoder.writeString(idx.key_type) - ABI.writeUint16LE(encoder, idx.table_id ?? 0, `index ${idx.name} table_id`) + ABI.writeUint16LE( + encoder, + idx.table_id ?? 0, + `index ${idx.name} table_id` + ) } } @@ -389,9 +397,14 @@ export class ABI implements ABISerializableObject { } } - // protobuf_types: forward-compat extension; always written as an empty - // string for symmetry with the parser. - encoder.writeString("") + // protobuf_types trailer: only emitted for sysio::abi/1.x, matching + // the decoder's version gate in fromABI. For non-1.x versions (e.g. + // a future sysio::abi/2.0 whose trailer shape is unknown here), no + // trailer is written so round-tripping through this SDK stays + // byte-exact for whatever version the ABI claims. + if (this.version.startsWith("sysio::abi/1.")) { + encoder.writeString("") + } } private static assertUint16(value: number, label: string) { diff --git a/packages/sdk-core/tests/api/v1/Chain.test.ts b/packages/sdk-core/tests/api/v1/Chain.test.ts index 9635680..79556fe 100644 --- a/packages/sdk-core/tests/api/v1/Chain.test.ts +++ b/packages/sdk-core/tests/api/v1/Chain.test.ts @@ -206,6 +206,33 @@ describe("ChainAPI.get_table_rows — wire-sysio KV row shape", () => { expect(result.ram_payers![0]).toBeUndefined() }) + test("does not unwrap user table whose key field is an array", async () => { + // `typeof [] === "object"` in JS, so an array-valued `key` would + // pass an object-only check. The wire-sysio KV key is always a + // struct (Record), never an array, so rejecting + // arrays tightens the discriminant at no cost to the real case. + const client = makeClient({ + "/v1/chain/get_table_rows": { + rows: [ + { key: ["a", 1], value: "first" }, + { key: ["b", 2], value: "second" } + ], + more: false, + next_key: "" + } + }) + + const result = await client.v1.chain.get_table_rows({ + code: "user.contract", + scope: "user.contract", + table: "tuple_keyed" + }) + + expect(result.rows).toHaveLength(2) + expect(result.rows[0]).toEqual({ key: ["a", 1], value: "first" }) + expect(result.rows[1]).toEqual({ key: ["b", 2], value: "second" }) + }) + test("does not unwrap user table that happens to have scalar key+value fields", async () => { // A user-defined table with struct {key: string, value: uint64} would // collide with the wire-sysio KV shape on field-name alone. Requiring diff --git a/packages/sdk-core/tests/chain/Abi.test.ts b/packages/sdk-core/tests/chain/Abi.test.ts index a939b9f..eabaf9d 100644 --- a/packages/sdk-core/tests/chain/Abi.test.ts +++ b/packages/sdk-core/tests/chain/Abi.test.ts @@ -203,16 +203,13 @@ describe("ABI", () => { expect(decoded.enums[0].values).toHaveLength(2) }) - test("encoder always emits protobuf_types (empty string by default)", () => { - // Verify that encoding an ABI with populated enums still ends - // with a length-prefixed empty protobuf_types string. Using an - // ABI with real content means the final two bytes are forced to - // be the varuint(0) length prefix + the empty string (both - // 0x00), rather than coincidentally 0x00 from another field. The - // golden-bytes test below pins the exact byte layout for a - // minimal ABI; this test exists as a sanity check that the - // protobuf_types trailer is present regardless of content, and - // that the round-trip preserves the rest of the ABI. + test("encoder emits protobuf_types for sysio::abi/1.x (default version)", () => { + // Default version is sysio::abi/1.1, so the encoder writes the + // length-prefixed empty protobuf_types trailer. The final byte + // is the varuint(0) length prefix for that empty string. Using + // an ABI with real content ensures the round-trip preserves + // earlier fields; the two golden-bytes tests below pin the + // exact layout for 1.x and non-1.x versions. const abi = new ABI({ enums: [ { @@ -296,8 +293,14 @@ describe("ABI", () => { 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" + 0x03, + 0x66, + 0x6f, + 0x6f, // "foo" + 0x03, + 0x62, + 0x61, + 0x72 // "bar" ]) const combined = new Uint8Array(baseBytes.length + extra.length) combined.set(baseBytes, 0) @@ -342,7 +345,7 @@ describe("ABI", () => { // Keeping this as a pinned vector catches silent drift in field // order / widths without running an integration build. const abi = new ABI({ - version: "v", + version: "sysio::abi/1.2", tables: [ { name: "T", @@ -357,17 +360,35 @@ describe("ABI", () => { }) const bytes = encodeAbi(abi) const expected = new Uint8Array([ - 0x01, 0x76, // version "v" + 0x0e, + 0x73, + 0x79, + 0x73, + 0x69, + 0x6f, + 0x3a, + 0x3a, + 0x61, + 0x62, + 0x69, + 0x2f, + 0x31, + 0x2e, + 0x32, // version "sysio::abi/1.2" 0x00, // types 0x00, // structs 0x00, // actions 0x01, // tables.length = 1 - 0x01, 0x54, // table[0].name = "T" - 0x01, 0x69, // table[0].index_type = "i" + 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 + 0x01, + 0x72, // type = "r" + 0x34, + 0x12, // table_id = 0x1234 LE 0x00, // secondary_indexes.length = 0 0x00, // ricardian_clauses 0x00, // error_messages @@ -375,7 +396,46 @@ describe("ABI", () => { 0x00, // variants 0x00, // action_results 0x00, // enums - 0x00 // protobuf_types = "" + 0x00 // protobuf_types = "" (1.x trailer) + ]) + expect(Array.from(bytes)).toEqual(Array.from(expected)) + }) + + test("golden bytes: non-1.x ABI omits the protobuf_types trailer", () => { + // Encoder mirrors the decoder's version gate: only sysio::abi/1.x + // gets the empty protobuf_types trailer. A hypothetical 2.0 ABI + // encoded here carries no trailer, so round-tripping stays + // byte-exact for the version it claims. + const abi = new ABI({ + version: "sysio::abi/2.0" + }) + const bytes = encodeAbi(abi) + const expected = new Uint8Array([ + 0x0e, + 0x73, + 0x79, + 0x73, + 0x69, + 0x6f, + 0x3a, + 0x3a, + 0x61, + 0x62, + 0x69, + 0x2f, + 0x32, + 0x2e, + 0x30, // version "sysio::abi/2.0" + 0x00, // types + 0x00, // structs + 0x00, // actions + 0x00, // tables + 0x00, // ricardian_clauses + 0x00, // error_messages + 0x00, // extensions + 0x00, // variants + 0x00, // action_results + 0x00 // enums (no trailer) ]) expect(Array.from(bytes)).toEqual(Array.from(expected)) }) From 436de48d963f562b24b0e106ed1d24c1a88112eb Mon Sep 17 00:00:00 2001 From: kevin Heifner Date: Fri, 17 Apr 2026 15:36:22 -0500 Subject: [PATCH 5/5] Address PR #5 fourth-pass review nits - Hoist WireKvRow type alias above isWireKvShape so the function reads top-to-bottom instead of relying on TS hoisting. - Pin version: "sysio::abi/1.2" in the "decoder skips protobuf_types" test so it no longer leans on the implicit default version. --- packages/sdk-core/src/api/v1/Chain.ts | 12 ++++++------ packages/sdk-core/tests/chain/Abi.test.ts | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/sdk-core/src/api/v1/Chain.ts b/packages/sdk-core/src/api/v1/Chain.ts index 382f1bb..d467d4f 100644 --- a/packages/sdk-core/src/api/v1/Chain.ts +++ b/packages/sdk-core/src/api/v1/Chain.ts @@ -366,6 +366,12 @@ export class ChainAPI { // See the test `does not unwrap user table whose key is itself an // object` for a pinned repro of this known false-positive so the // behavior is caught if the discriminant is ever tightened. + type WireKvRow = { + key: Record + value: unknown + payer?: string + } + const isWireKvShape = Array.isArray(rows) && rows.length > 0 && @@ -377,12 +383,6 @@ export class ChainAPI { rows[0].key !== null && !Array.isArray(rows[0].key) - type WireKvRow = { - key: Record - value: unknown - payer?: string - } - if (isWireKvShape) { if (params.show_payer) { ram_payers = [] diff --git a/packages/sdk-core/tests/chain/Abi.test.ts b/packages/sdk-core/tests/chain/Abi.test.ts index eabaf9d..e662dfb 100644 --- a/packages/sdk-core/tests/chain/Abi.test.ts +++ b/packages/sdk-core/tests/chain/Abi.test.ts @@ -182,7 +182,10 @@ describe("ABI", () => { // Build an encoded ABI manually that includes a protobuf_types // string after the enums section. The parser should consume it // without error and not affect the rest of the decoded ABI. + // Version is pinned to 1.2 so the trailer-emission path in the + // encoder is exercised regardless of what the default version is. const original = new ABI({ + version: "sysio::abi/1.2", enums: [ { name: "color",