diff --git a/src/api/v1/chain.ts b/src/api/v1/chain.ts index e0b422c..03ec96c 100644 --- a/src/api/v1/chain.ts +++ b/src/api/v1/chain.ts @@ -328,9 +328,38 @@ export class ChainAPI { lower_bound, }, }); - let ram_payers: Name[] | undefined; - - if (params.show_payer) { + 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] && + 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(row.payer ? Name.from(row.payer) : undefined); + return row.value; + }); + } else { + rows = rows.map((row: {value: any}) => row.value); + } + } else if (params.show_payer) { + // Legacy show_payer wrapper: {data, payer} ram_payers = []; rows = rows.map(({data, payer}) => { ram_payers!.push(Name.from(payer)); diff --git a/src/api/v1/types.ts b/src/api/v1/types.ts index 3d0cf97..95d0301 100644 --- a/src/api/v1/types.ts +++ b/src/api/v1/types.ts @@ -591,7 +591,12 @@ export interface GetTableRowsParamsTyped { rows: Row[]; more: boolean; - ram_payers?: Name[]; + /** + * Populated when `show_payer` is set. Each entry aligns with `rows[i]`. + * The entry is `undefined` when the chain returned a row without a payer + * (wire-sysio KV shape makes `payer` optional). + */ + ram_payers?: (Name | undefined)[]; next_key?: Index; } diff --git a/src/chain/abi.ts b/src/chain/abi.ts index 59733bd..5e97aa9 100644 --- a/src/chain/abi.ts +++ b/src/chain/abi.ts @@ -99,7 +99,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(); @@ -116,7 +119,33 @@ export class ABI implements ABISerializableObject { } const type = decoder.readString(); - tables.push({name, index_type, key_names, key_types, type}); + const tidLo = decoder.readByte(); + const tidHi = decoder.readByte(); + const table_id = tidLo | (tidHi << 8); + const secondary_indexes: ABI.Index[] = []; + const numIndexes = decoder.readVaruint32(); + + for (let j = 0; j < numIndexes; j++) { + const idxName = decoder.readString(); + const idxKeyType = decoder.readString(); + const idxLo = decoder.readByte(); + const idxHi = decoder.readByte(); + secondary_indexes.push({ + name: idxName, + key_type: idxKeyType, + table_id: idxLo | (idxHi << 8), + }); + } + + tables.push({ + name, + index_type, + key_names, + key_types, + type, + table_id, + secondary_indexes, + }); } const ricardian_clauses: ABI.Clause[] = []; @@ -175,6 +204,14 @@ 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(); + } + return new ABI({ version, types, @@ -220,7 +257,7 @@ export class ABI implements ABISerializableObject { encoder.writeVaruint32(this.tables.length); for (const table of this.tables) { - Name.from(table.name).toABI(encoder); + encoder.writeString(table.name); encoder.writeString(table.index_type); encoder.writeVaruint32(table.key_names.length); @@ -235,6 +272,23 @@ 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); + 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); + } } encoder.writeVaruint32(this.ricardian_clauses.length); @@ -263,6 +317,18 @@ export class ABI implements ABISerializableObject { Name.from(result.name).toABI(encoder); encoder.writeString(result.result_type); } + + // 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 { @@ -398,12 +464,26 @@ 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. + export interface Index { + name: string; + key_type: string; + 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/test/abi.ts b/test/abi.ts new file mode 100644 index 0000000..37518f8 --- /dev/null +++ b/test/abi.ts @@ -0,0 +1,365 @@ +import {assert} from 'chai'; + +// Tests for the wire-sysio binary format adopted in PR +// Wire-Network/wire-sysio#288 (table_id namespace isolation). The on-wire +// shape of table_def changed: `name` widened from sysio::name (uint64) to +// a length-prefixed string, and table_id (uint16) + secondary_indexes +// (vector) were appended. abi_def also gained a trailing +// protobuf_types extension. + +import {ABI, ABIDecoder, ABIEncoder} from '$lib'; + +function encodeAbi(abi: ABI): Uint8Array { + const encoder = new ABIEncoder(); + abi.toABI(encoder); + return encoder.getData(); +} + +function decodeAbi(bytes: Uint8Array): ABI { + const decoder = new ABIDecoder(bytes); + return ABI.fromABI(decoder); +} + +suite('abi binary format (wire-sysio table_id namespace isolation)', function () { + test('round-trips a table with table_id and empty secondary_indexes', function () { + const original = new ABI({ + version: 'sysio::abi/1.2', + structs: [ + { + name: 'account', + base: '', + fields: [{name: 'balance', type: 'asset'}], + }, + ], + tables: [ + { + name: 'accounts', + index_type: 'i64', + key_names: ['scope', 'primary_key'], + key_types: ['name', 'uint64'], + type: 'account', + table_id: 12345, + secondary_indexes: [], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables.length, 1); + const t = decoded.tables[0]; + assert.equal(t.name, 'accounts'); + assert.equal(t.index_type, 'i64'); + assert.deepEqual(t.key_names, ['scope', 'primary_key']); + assert.deepEqual(t.key_types, ['name', 'uint64']); + assert.equal(t.type, 'account'); + assert.equal(t.table_id, 12345); + assert.deepEqual(t.secondary_indexes, []); + }); + + test('round-trips a long table name (>12 chars) - the whole reason name was widened', function () { + const original = new ABI({ + tables: [ + { + name: 'a-very-long-table-name', + index_type: 'i64', + key_names: ['pk'], + key_types: ['uint64'], + type: 'row', + table_id: 7, + secondary_indexes: [], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables[0].name, 'a-very-long-table-name'); + assert.equal(decoded.tables[0].table_id, 7); + }); + + test('round-trips secondary_indexes with checksum256 key_type', function () { + const original = new ABI({ + tables: [ + { + name: 'users', + index_type: 'i64', + key_names: ['scope', 'id'], + key_types: ['name', 'uint64'], + type: 'user', + table_id: 100, + secondary_indexes: [ + {name: 'byowner', key_type: 'name', table_id: 200}, + {name: 'bybalance', key_type: 'uint64', table_id: 201}, + {name: 'byhash', key_type: 'checksum256', table_id: 202}, + ], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + const t = decoded.tables[0]; + assert.equal(t.secondary_indexes!.length, 3); + assert.deepEqual(t.secondary_indexes![0], { + name: 'byowner', + key_type: 'name', + table_id: 200, + }); + assert.deepEqual(t.secondary_indexes![1], { + name: 'bybalance', + key_type: 'uint64', + table_id: 201, + }); + assert.deepEqual(t.secondary_indexes![2], { + name: 'byhash', + key_type: 'checksum256', + table_id: 202, + }); + }); + + test('table_id 0 is preserved (the default for hand-built tables)', function () { + const original = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: 0, + secondary_indexes: [], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables[0].table_id, 0); + }); + + test('table_id 65535 (max uint16) round-trips correctly', function () { + const original = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: 65535, + secondary_indexes: [], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables[0].table_id, 65535); + }); + + test('missing table_id defaults to 0 on encode', function () { + // Hand-built ABIs may omit table_id; the encoder defaults to 0 and + // the decoder reads back 0. + const original = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.tables[0].table_id, 0); + assert.deepEqual(decoded.tables[0].secondary_indexes, []); + }); + + test('encoder always emits protobuf_types (empty string trailer)', function () { + // The encoded form should end with the varint-prefixed empty string + // for protobuf_types: 0x00 (length 0). Verify the last byte is 0x00. + const abi = new ABI({}); + const bytes = encodeAbi(abi); + assert.equal(bytes[bytes.length - 1], 0x00); + }); + + test('encoder rejects table_id outside uint16 range', function () { + const tooBig = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: 65536, + secondary_indexes: [], + }, + ], + }); + assert.throws(() => encodeAbi(tooBig), /uint16/); + + const negative = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: -1, + secondary_indexes: [], + }, + ], + }); + assert.throws(() => encodeAbi(negative), /uint16/); + }); + + test('encoder rejects secondary_index table_id outside uint16 range', function () { + const abi = new ABI({ + tables: [ + { + name: 't', + index_type: 'i64', + key_names: [], + key_types: [], + type: 'row', + table_id: 0, + secondary_indexes: [{name: 'bad', key_type: 'name', table_id: 70000}], + }, + ], + }); + assert.throws(() => encodeAbi(abi), /uint16/); + }); + + test('decoder drains multiple trailing string-typed extensions', function () { + // Simulate a future wire-sysio release that appends additional + // string-typed extension fields after protobuf_types. The decoder + // must not choke on the extra data (forward-compat). + const abi = new ABI({ + version: 'sysio::abi/1.2', + tables: [], + }); + const baseBytes = encodeAbi(abi); + // Append two extra length-prefixed strings: "foo" and "bar". + const extra = new Uint8Array([ + 0x03, 0x66, 0x6f, 0x6f, // "foo" + 0x03, 0x62, 0x61, 0x72, // "bar" + ]); + const combined = new Uint8Array(baseBytes.length + extra.length); + combined.set(baseBytes, 0); + combined.set(extra, baseBytes.length); + + // Should decode cleanly without throwing. + const decoded = decodeAbi(combined); + assert.equal(decoded.version, 'sysio::abi/1.2'); + assert.equal(decoded.tables.length, 0); + }); + + test('golden bytes: minimal single-table ABI matches expected layout', function () { + // Fixture asserting byte-for-byte layout matches the C++ wire-sysio + // table_def struct in libraries/chain/include/sysio/chain/abi_def.hpp. + // Keeping this as a pinned vector catches silent drift in field + // order / widths without running an integration build. + const abi = new ABI({ + version: 'v', + tables: [ + { + name: 'T', + index_type: 'i', + key_names: [], + key_types: [], + type: 'r', + table_id: 0x1234, + secondary_indexes: [], + }, + ], + }); + const bytes = encodeAbi(abi); + const expected = new Uint8Array([ + 0x01, 0x76, // version "v" + 0x00, // types + 0x00, // structs + 0x00, // actions + 0x01, // tables.length = 1 + 0x01, 0x54, // table[0].name = "T" + 0x01, 0x69, // table[0].index_type = "i" + 0x00, // key_names + 0x00, // key_types + 0x01, 0x72, // type = "r" + 0x34, 0x12, // table_id = 0x1234 LE + 0x00, // secondary_indexes.length = 0 + 0x00, // ricardian_clauses + 0x00, // error_messages + 0x00, // extensions + 0x00, // variants + 0x00, // action_results + 0x00, // protobuf_types = "" + ]); + assert.deepEqual(Array.from(bytes), Array.from(expected)); + }); + + test('multi-table ABI with structs, actions, secondary indexes round-trips', function () { + const original = new ABI({ + version: 'sysio::abi/1.2', + types: [{new_type_name: 'account_name', type: 'name'}], + structs: [ + { + name: 'transfer', + base: '', + fields: [ + {name: 'from', type: 'account_name'}, + {name: 'to', type: 'account_name'}, + {name: 'quantity', type: 'asset'}, + {name: 'memo', type: 'string'}, + ], + }, + { + name: 'account', + base: '', + fields: [{name: 'balance', type: 'asset'}], + }, + { + name: 'user', + base: '', + fields: [ + {name: 'id', type: 'uint64'}, + {name: 'owner', type: 'name'}, + ], + }, + ], + actions: [{name: 'transfer', type: 'transfer', ricardian_contract: ''}], + tables: [ + { + name: 'accounts', + index_type: 'i64', + key_names: ['scope', 'sym_code'], + key_types: ['name', 'uint64'], + type: 'account', + table_id: 1, + secondary_indexes: [], + }, + { + name: 'users', + index_type: 'i64', + key_names: ['scope', 'id'], + key_types: ['name', 'uint64'], + type: 'user', + table_id: 2, + secondary_indexes: [{name: 'byowner', key_type: 'name', table_id: 100}], + }, + ], + }); + + const decoded = decodeAbi(encodeAbi(original)); + assert.equal(decoded.version, 'sysio::abi/1.2'); + assert.equal(decoded.types.length, 1); + assert.equal(decoded.structs.length, 3); + assert.equal(decoded.actions.length, 1); + assert.equal(decoded.tables.length, 2); + assert.equal(decoded.tables[0].name, 'accounts'); + assert.equal(decoded.tables[0].table_id, 1); + assert.equal(decoded.tables[1].name, 'users'); + assert.equal(decoded.tables[1].table_id, 2); + assert.equal(decoded.tables[1].secondary_indexes!.length, 1); + assert.equal(decoded.tables[1].secondary_indexes![0].name, 'byowner'); + }); +}); diff --git a/test/chain-api.ts b/test/chain-api.ts new file mode 100644 index 0000000..ac1c2aa --- /dev/null +++ b/test/chain-api.ts @@ -0,0 +1,227 @@ +import {assert} from 'chai'; + +// Tests for the wire-sysio PR Wire-Network/wire-sysio#290 unified +// get_table_rows response shape. KV-backed tables now return rows as +// `{key, value, payer?}` objects instead of the legacy form (decoded +// struct directly, or `{data, payer}` when show_payer is set). The +// Chain.get_table_rows wrapper detects the new shape and unwraps it +// so downstream callers keep seeing the same row layout they always have. + +import {APIClient, APIProvider, APIResponse} from '$lib'; + +// Minimal in-memory APIProvider for unit tests. Returns the JSON body +// passed via the `responses` map keyed by request path. +class StubProvider implements APIProvider { + constructor(private responses: Record) {} + async call(args: {path: string}): Promise { + const json = this.responses[args.path]; + if (json === undefined) { + throw new Error(`StubProvider: no response registered for ${args.path}`); + } + return { + status: 200, + headers: {}, + json, + text: JSON.stringify(json), + }; + } +} + +function makeClient(responses: Record): APIClient { + return new APIClient({provider: new StubProvider(responses)}); +} + +suite('ChainAPI.get_table_rows (wire-sysio KV row shape)', function () { + test('unwraps the new {key, value} shape into plain rows', async function () { + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + { + key: {scope: 'alice', sym_code: '1397703940'}, + value: {balance: '100.0000 SYS'}, + }, + { + key: {scope: 'alice', sym_code: '1145521988'}, + value: {balance: '200.0000 AAA'}, + }, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'alice', + table: 'accounts', + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {balance: '100.0000 SYS'}); + assert.deepEqual(result.rows[1], {balance: '200.0000 AAA'}); + assert.equal(result.more, false); + assert.isUndefined(result.ram_payers); + }); + + test('unwraps the new shape with show_payer and captures payers', async function () { + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + { + key: {scope: 'alice', sym_code: '1397703940'}, + value: {balance: '100.0000 SYS'}, + payer: 'alice', + }, + { + key: {scope: 'alice', sym_code: '1145521988'}, + value: {balance: '200.0000 AAA'}, + payer: 'sysio', + }, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'alice', + table: 'accounts', + show_payer: true, + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {balance: '100.0000 SYS'}); + assert.deepEqual(result.rows[1], {balance: '200.0000 AAA'}); + assert.isDefined(result.ram_payers); + assert.equal(result.ram_payers!.length, 2); + assert.equal(String(result.ram_payers![0]), 'alice'); + assert.equal(String(result.ram_payers![1]), 'sysio'); + }); + + test('preserves legacy plain-row shape from EOSIO chains', async function () { + // EOSIO chains still return rows as the decoded struct directly (no + // {key, value} wrapper). The wrapper must not touch these. + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + {owner: 'alice', balance: '100.0000 SYS'}, + {owner: 'bob', balance: '200.0000 SYS'}, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'sysio.token', + table: 'accounts', + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {owner: 'alice', balance: '100.0000 SYS'}); + assert.deepEqual(result.rows[1], {owner: 'bob', balance: '200.0000 SYS'}); + }); + + test('preserves legacy {data, payer} show_payer shape from EOSIO chains', async function () { + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + {data: {owner: 'alice', balance: '100.0000 SYS'}, payer: 'alice'}, + {data: {owner: 'bob', balance: '200.0000 SYS'}, payer: 'sysio'}, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'sysio.token', + table: 'accounts', + show_payer: true, + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {owner: 'alice', balance: '100.0000 SYS'}); + assert.deepEqual(result.rows[1], {owner: 'bob', balance: '200.0000 SYS'}); + assert.equal(result.ram_payers!.length, 2); + assert.equal(String(result.ram_payers![0]), 'alice'); + assert.equal(String(result.ram_payers![1]), 'sysio'); + }); + + test('empty rows array works for both shapes', async function () { + const client = makeClient({ + '/v1/chain/get_table_rows': {rows: [], more: false, next_key: ''}, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'sysio.token', + scope: 'alice', + table: 'accounts', + }); + + assert.deepEqual(result.rows, []); + assert.equal(result.more, false); + }); + + test('missing payer in new shape is reported as undefined', async function () { + // 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, + }); + + assert.equal(result.rows.length, 1); + assert.deepEqual(result.rows[0], {balance: '100.0000 SYS'}); + assert.equal(result.ram_payers!.length, 1); + assert.isUndefined(result.ram_payers![0]); + }); + + test('does not unwrap user table that happens to have scalar key+value fields', async function () { + // A user-defined table with struct {key: string, value: uint64} would + // collide with the wire-sysio KV shape on field-name alone. Requiring + // `key` to be an object (wire KV keys are always composite) avoids + // misinterpreting these rows. + const client = makeClient({ + '/v1/chain/get_table_rows': { + rows: [ + {key: 'some_setting', value: 42}, + {key: 'other_setting', value: 7}, + ], + more: false, + next_key: '', + }, + }); + + const result = await client.v1.chain.get_table_rows({ + code: 'user.contract', + scope: 'user.contract', + table: 'settings', + }); + + assert.equal(result.rows.length, 2); + assert.deepEqual(result.rows[0], {key: 'some_setting', value: 42}); + assert.deepEqual(result.rows[1], {key: 'other_setting', value: 7}); + }); +}); diff --git a/test/serializer.ts b/test/serializer.ts index 2e762ce..0c1e117 100644 --- a/test/serializer.ts +++ b/test/serializer.ts @@ -1081,7 +1081,15 @@ suite('serializer', function () { types: [{new_type_name: 'b', type: 'a'}], structs: [{base: '', name: 'a', fields: [{name: 'f', type: 'a'}]}], tables: [ - {name: 't', type: 'a', index_type: 'i64', key_names: ['k'], key_types: ['i64']}, + { + name: 't', + type: 'a', + index_type: 'i64', + key_names: ['k'], + key_types: ['i64'], + table_id: 0, + secondary_indexes: [], + }, ], ricardian_clauses: [{id: 'foo', body: 'bar'}], variants: [{name: 'v', types: ['a', 'b']}],