diff --git a/packages/sdk-core/src/api/v1/Chain.ts b/packages/sdk-core/src/api/v1/Chain.ts index 0ed8e01..d467d4f 100644 --- a/packages/sdk-core/src/api/v1/Chain.ts +++ b/packages/sdk-core/src/api/v1/Chain.ts @@ -345,12 +345,59 @@ export class ChainAPI { lower_bound } }) - let ram_payers: Name[] | undefined + 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 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` + // 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. + type WireKvRow = { + key: Record + value: unknown + payer?: string + } - if (params.show_payer) { + const isWireKvShape = + Array.isArray(rows) && + rows.length > 0 && + typeof rows[0] === "object" && + rows[0] !== null && + "key" in rows[0] && + "value" in rows[0] && + typeof rows[0].key === "object" && + rows[0].key !== null && + !Array.isArray(rows[0].key) + + if (isWireKvShape) { + if (params.show_payer) { + ram_payers = [] + rows = rows.map((row: WireKvRow) => { + ram_payers!.push(row.payer ? Name.from(row.payer) : undefined) + return row.value + }) + } else { + rows = rows.map((row: WireKvRow) => 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)) + 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 ee1c8e0..7d50da5 100644 --- a/packages/sdk-core/src/chain/Abi.ts +++ b/packages/sdk-core/src/chain/Abi.ts @@ -103,7 +103,10 @@ export class ABI implements ABISerializableObject { const numTables = decoder.readVaruint32() for (let i = 0; i < numTables; i++) { - const name = Name.fromABI(decoder) + // 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[] = [] const numKeyNames = decoder.readVaruint32() @@ -120,7 +123,29 @@ export class ABI implements ABISerializableObject { } const type = decoder.readString() - tables.push({ name, index_type, key_names, key_types, type }) + 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() + secondary_indexes.push({ + name: idxName, + key_type: idxKeyType, + table_id: ABI.readUint16LE(decoder) + }) + } + + tables.push({ + name, + index_type, + key_names, + key_types, + type, + table_id, + secondary_indexes + }) } const ricardian_clauses: ABI.Clause[] = [] @@ -214,6 +239,25 @@ export class ABI implements ABISerializableObject { } } + // 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({ version, types, @@ -260,7 +304,11 @@ export class ABI implements ABISerializableObject { encoder.writeVaruint32(this.tables.length) for (const table of this.tables) { - Name.from(table.name).toABI(encoder) + // 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) @@ -275,6 +323,25 @@ 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` + ) + 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` + ) + } } encoder.writeVaruint32(this.ricardian_clauses.length) @@ -329,6 +396,39 @@ export class ABI implements ABISerializableObject { encoder.writeArray(buf) } } + + // 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) { + if (!Number.isInteger(value) || value < 0 || value > 0xffff) { + throw new Error( + `ABI ${label} must be a uint16 in [0, 65535], got ${value}` + ) + } + } + + 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 { @@ -492,12 +592,31 @@ export namespace ABI { type: string ricardian_contract: string } + // 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), + // 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[] 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..79556fe --- /dev/null +++ b/packages/sdk-core/tests/api/v1/Chain.test.ts @@ -0,0 +1,304 @@ +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 is reported as undefined", async () => { + // The unified API makes `payer` optional. When show_payer is true + // 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: [ + { + 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(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 + // `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 }) + }) + + 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 new file mode 100644 index 0000000..e662dfb --- /dev/null +++ b/packages/sdk-core/tests/chain/Abi.test.ts @@ -0,0 +1,518 @@ +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. + // 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", + 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 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: [ + { + 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", () => { + 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 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 within the + // sysio::abi/1.x line). + 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("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. + // Keeping this as a pinned vector catches silent drift in field + // order / widths without running an integration build. + const abi = new ABI({ + version: "sysio::abi/1.2", + 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([ + 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" + 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 = "" (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)) + }) + }) + + 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") + }) + }) +})