diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aa00471..5598378 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,8 +6,12 @@ jobs: strategy: fail-fast: false matrix: - node-version: [14, 16, 18] + node-version: [18, 20, 22] name: Node.js v${{ matrix.node-version }} + env: + # Node 22.18+ strips types itself, which leaves the decorators in the test + # suite for V8 to choke on before ts-node is ever reached + NODE_OPTIONS: ${{ matrix.node-version >= 22 && '--no-experimental-strip-types' || '' }} steps: - name: Setup Node.js uses: actions/setup-node@v2 diff --git a/package.json b/package.json index 2d51da6..2e16d41 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@wharfkit/contract", "description": "ContractKit for Wharf", - "version": "1.1.5", + "version": "1.3.0", "homepage": "https://github.com/wharfkit/contract", "license": "BSD-3-Clause", "main": "lib/contract.js", @@ -62,5 +62,6 @@ "typedoc": "^0.24.6", "typescript": "^4.9.5", "yarn-deduplicate": "^6.0.2" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/contract.ts b/src/contract.ts index 3768add..45bd26a 100644 --- a/src/contract.ts +++ b/src/contract.ts @@ -15,6 +15,7 @@ import { import {PlaceholderAuth} from '@wharfkit/signing-request' import {Table} from './contract/table' +import {formatExceptionMessage, TableScopeType} from './utils' export interface ContractArgs { abi: ABIDef @@ -22,6 +23,10 @@ export interface ContractArgs { client: APIClient } +export interface ContractOptions { + debug?: boolean +} + export interface ActionOptions { authorization?: PermissionLevelType[] } @@ -45,13 +50,14 @@ export class Contract { readonly abi: ABI readonly account: Name readonly client: APIClient + readonly debug: boolean = false /** * Constructs a new `Contract` instance. * * @param {ContractArgs} args - The required arguments for a contract. */ - constructor(args: ContractArgs) { + constructor(args: ContractArgs, options: ContractOptions = {}) { if (!args.abi) { throw new Error('Contract requires an ABI') } @@ -64,6 +70,9 @@ export class Contract { throw new Error('Contract requires an APIClient') } this.client = args.client + if (options.debug) { + this.debug = options.debug + } } public get tableNames(): string[] { @@ -74,7 +83,7 @@ export class Contract { return this.tableNames.includes(String(name)) } - public table(name: NameType, scope?: NameType, rowType?): Table { + public table(name: NameType, scope?: TableScopeType, rowType?): Table { if (!this.hasTable(name)) { throw new Error(`Contract (${this.account}) does not have a table named (${name})`) } @@ -82,6 +91,7 @@ export class Contract { abi: this.abi, account: this.account, client: this.client, + debug: this.debug, defaultScope: scope, name, rowType, @@ -133,6 +143,9 @@ export class Contract { }) // Execute and retrieve response const response = await this.client.v1.chain.send_read_only_transaction(transaction) + if (response.processed.except) { + throw new Error(formatExceptionMessage(response.processed.except)) + } // Decode and return results const hexData = response.processed.action_traces[0].return_value_hex_data const returnType = this.abi.action_results.find((a) => Name.from(a.name).equals(name)) diff --git a/src/contract/row-cursor.ts b/src/contract/row-cursor.ts index 3eef73f..2e0206c 100644 --- a/src/contract/row-cursor.ts +++ b/src/contract/row-cursor.ts @@ -1,5 +1,4 @@ import {API, Serializer} from '@wharfkit/antelope' -import {wrapIndexValue} from '../utils' import {TableCursor} from './table-cursor' export class TableRowCursor extends TableCursor { @@ -15,26 +14,10 @@ export class TableRowCursor extends TableCursor { return [] } - // Set the lower_bound, and override if the cursor has a next_key value - let lower_bound = this.params.lower_bound - if (this.next_key) { - lower_bound = this.next_key - } - - // Determine the maximum number of remaining rows for the cursor - const rowsRemaining = this.maxRows - this.rowsCount - - // Find the lowest amount between rows remaining, rows per request, or the provided query params limit - const limit = Math.min(rowsRemaining, rowsPerAPIRequest, this.params.limit) - - // Assemble and perform the v1/chain/get_table_rows query - const query = { - ...this.params, - limit, - lower_bound: wrapIndexValue(lower_bound), - upper_bound: wrapIndexValue(this.params.upper_bound), - } + // Assemble the query params + const query = this.getTableRowsParams(rowsPerAPIRequest) + // Execute the query const result = await this.client!.v1.chain.get_table_rows(query) // Determine if we need to decode the rows, based on if: diff --git a/src/contract/table-cursor.ts b/src/contract/table-cursor.ts index 75417e3..2c4abab 100644 --- a/src/contract/table-cursor.ts +++ b/src/contract/table-cursor.ts @@ -1,4 +1,5 @@ import {ABI, ABIDef, API, APIClient, Name} from '@wharfkit/antelope' +import {wrapIndexValue} from '../utils' /** Mashup of valid types for an APIClient call to v1.chain.get_table_rows */ export type TableRowParamsTypes = @@ -68,6 +69,13 @@ export abstract class TableCursor { this.type = table.type } + /** + * Get the current next key value to use for the lower_bounds parameter in the next API request. + */ + get nextkey() { + return this.next_key + } + /** * Implements the async iterator protocol for the cursor. * @@ -116,4 +124,30 @@ export abstract class TableCursor { } return rows } + + /** + * Build the query for the get_table_rows API endpoint. + */ + getTableRowsParams(rowsPerAPIRequest: number = Number.MAX_SAFE_INTEGER): any { + // Set the lower_bound, and override if the cursor has a next_key value + let lower_bound = this.params.lower_bound + if (this.next_key) { + lower_bound = this.next_key + } + + // Determine the maximum number of remaining rows for the cursor + const rowsRemaining = this.maxRows - this.rowsCount + + // Find the lowest amount between rows remaining, rows per request, or the provided query params limit + const limit = Math.min(rowsRemaining, rowsPerAPIRequest, this.params.limit) + + // Assemble and perform the v1/chain/get_table_rows query + const query = { + ...this.params, + limit, + lower_bound: wrapIndexValue(lower_bound), + upper_bound: wrapIndexValue(this.params.upper_bound), + } + return query + } } diff --git a/src/contract/table.ts b/src/contract/table.ts index 9d675a5..c6dc6dd 100644 --- a/src/contract/table.ts +++ b/src/contract/table.ts @@ -1,5 +1,11 @@ -import {ABI, ABIDef, API, APIClient, Name, NameType, Serializer} from '@wharfkit/antelope' -import {indexPositionInWords, wrapIndexValue} from '../utils' +import {ABI, ABIDef, API, APIClient, Name, NameType, Serializer, UInt64} from '@wharfkit/antelope' +import { + indexPositionInWords, + isAbsentScope, + TableScopeType, + wrapIndexValue, + wrapScopeValue, +} from '../utils' import {TableRowCursor} from './row-cursor' import {TableScopeCursor} from './scope-cursor' import {TableCursor} from './table-cursor' @@ -7,7 +13,7 @@ import {TableCursor} from './table-cursor' export interface QueryParams { index?: string index_position?: string - scope?: NameType | number + scope?: TableScopeType key_type?: keyof API.v1.TableIndexTypes json?: boolean from?: API.v1.TableIndexType | string | number @@ -30,13 +36,14 @@ interface TableParams { name: NameType rowType?: TableRow fieldToIndex?: FieldToIndex + debug?: boolean defaultRowLimit?: number - defaultScope?: NameType + defaultScope?: TableScopeType } export interface GetTableRowsOptions { limit?: number - scope?: NameType + scope?: TableScopeType } /** @@ -52,10 +59,11 @@ export class Table { readonly name: Name readonly rowType?: RowType readonly tableABI: ABI.Table + readonly debug: boolean = false private fieldToIndex?: any - public defaultScope?: NameType + public defaultScope?: TableScopeType public defaultRowLimit = 1000 /** @@ -82,6 +90,9 @@ export class Table { } this.tableABI = tableABI this.defaultScope = args.defaultScope + if (args.debug) { + this.debug = true + } } /** @@ -112,12 +123,10 @@ export class Table { // Table query table: this.name, code: this.account, - scope: - params.scope !== undefined - ? String(params.scope) - : this.defaultScope || this.account, + scope: this.resolveScope(params.scope), // Response typing type: this.rowType, + json: this.debug, // Filtering index_position: params.index_position, key_type: params.key_type, @@ -164,17 +173,14 @@ export class Table { const tableRowsParams: any = { table: this.name, code: this.account, - scope: - params.scope !== undefined - ? String(params.scope) - : this.defaultScope || this.account, + scope: this.resolveScope(params.scope), type: this.rowType!, limit: 1, lower_bound: wrapIndexValue(value), upper_bound: wrapIndexValue(value), index_position: params.index_position, key_type: params.key_type, - json: false, + json: this.debug, reverse: params.reverse, } @@ -199,6 +205,11 @@ export class Table { } let [row] = rows + // Debug mode will return a JSON result, so just return it + if (this.debug) { + return row + } + if (!this.rowType) { row = Serializer.decode({ data: row, @@ -236,6 +247,15 @@ export class Table { return this.query(params).all() } + /** Resolve the scope of a query, falling back to the table default and then the contract account. */ + private resolveScope(scope?: TableScopeType): Name | UInt64 | string { + const value = isAbsentScope(scope) ? this.defaultScope : scope + if (isAbsentScope(value)) { + return this.account + } + return wrapScopeValue(value) + } + getFieldToIndex() { if (this.fieldToIndex) { return this.fieldToIndex diff --git a/src/kit.ts b/src/kit.ts index 21c347e..b381d0c 100644 --- a/src/kit.ts +++ b/src/kit.ts @@ -15,6 +15,7 @@ export interface ABIDefinition { export interface ContractKitOptions { abiCache?: ABICacheInterface abis?: ABIDefinition[] + debug?: boolean } const defaultContractKitOptions: ContractKitOptions = {} @@ -22,6 +23,7 @@ const defaultContractKitOptions: ContractKitOptions = {} export class ContractKit { readonly abiCache: ABICacheInterface readonly client: APIClient + readonly debug: boolean = false constructor(args: ContractKitArgs, options: ContractKitOptions = defaultContractKitOptions) { if (args.client) { @@ -43,6 +45,10 @@ export class ContractKit { this.abiCache.setAbi(Name.from(name), ABI.from(abi)) ) } + + if (options.debug) { + this.debug = options.debug + } } /** @@ -54,10 +60,15 @@ export class ContractKit { async load(contract: NameType): Promise { const account = Name.from(contract) const abiDef = await this.abiCache.getAbi(account) - return new Contract({ - abi: ABI.from(abiDef), - account, - client: this.client, - }) + return new Contract( + { + abi: ABI.from(abiDef), + account, + client: this.client, + }, + { + debug: this.debug, + } + ) } } diff --git a/src/utils.ts b/src/utils.ts index aa2dbf8..040d4cb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,13 +7,18 @@ import { Float64, isInstanceOf, Name, + NameType, Serializer, UInt128, UInt64, + UInt64Type, } from '@wharfkit/antelope' export type PartialBy = Omit & Partial> +/** A table scope: a name, or any `uint64` value. Numeric scopes above 2^53 must be passed as a `UInt64`, since a `number` cannot hold them. */ +export type TableScopeType = NameType | UInt64Type + export function pascalCase(value: string): string { return value .split(/_| /) @@ -80,6 +85,35 @@ export function wrapIndexValue(value): API.v1.TableIndexType | undefined { return Name.from(value) } +/** Whether a scope is absent, meaning a query should fall back to its default. A `0` scope is present. */ +export function isAbsentScope(value?: TableScopeType | null): value is undefined | null | '' { + return value === undefined || value === null || value === '' +} + +/** Resolve a {@link TableScopeType} to the value sent as the `scope` of a table query. */ +export function wrapScopeValue(value: TableScopeType): Name | UInt64 | string { + if (value === undefined || value === null) { + throw new Error('Scope is required') + } + + if (isInstanceOf(value, Name)) { + return value + } + + // Strings reach the chain untouched, which reads an all-digit scope as a number and the rest as a name + if (typeof value === 'string') { + return value + } + + if (typeof value === 'number' && !Number.isSafeInteger(value)) { + throw new Error( + `Scope ${value} is not an integer a number can hold, use UInt64.from() to pass it instead` + ) + } + + return UInt64.from(value) +} + export function abiToBlob(abi: ABI): Blob { const serializedABI = Serializer.encode({object: abi, type: ABI}) return new Blob(serializedABI.array) @@ -89,3 +123,18 @@ export function blobStringToAbi(blobString: string): ABI { const blob = Blob.from(blobString) return ABI.from(blob) } + +export function formatExceptionMessage(except: API.v1.SendTransactionResponseException): string { + const top = except.stack?.[0] + if (top?.format) { + const data = top.data ?? {} + const substituted = top.format.replace(/\$\{(\w+)\}/g, (_, key) => + key in data ? String(data[key]) : `\${${key}}` + ) + if (substituted) return substituted + } + if (typeof top?.data?.s === 'string') { + return top.data.s + } + return except.message +} diff --git a/test/data/requests/1c7e42cd1ab2b6b573c3b13c24d5a057c979572f.json b/test/data/requests/1c7e42cd1ab2b6b573c3b13c24d5a057c979572f.json new file mode 100644 index 0000000..4e62188 --- /dev/null +++ b/test/data/requests/1c7e42cd1ab2b6b573c3b13c24d5a057c979572f.json @@ -0,0 +1,23 @@ +{ + "request": { + "path": "https://eos.greymass.com/v1/chain/get_table_rows", + "params": { + "method": "POST", + "body": "{\"table\":\"namebids\",\"code\":\"eosio\",\"scope\":\"eosio\",\"limit\":1,\"key_type\":\"name\",\"json\":true}" + } + }, + "status": 200, + "json": { + "rows": [ + { + "newname": "", + "high_bidder": "guydgnjygige", + "high_bid": 3000, + "last_bid_time": "2018-06-21T03:51:50.500" + } + ], + "more": true, + "next_key": "594475150812905472" + }, + "text": "{\"rows\":[{\"newname\":\"\",\"high_bidder\":\"guydgnjygige\",\"high_bid\":3000,\"last_bid_time\":\"2018-06-21T03:51:50.500\"}],\"more\":true,\"next_key\":\"594475150812905472\"}" +} \ No newline at end of file diff --git a/test/data/requests/366fa742787295d5854874aeb6ef78c442b50abb.json b/test/data/requests/366fa742787295d5854874aeb6ef78c442b50abb.json new file mode 100644 index 0000000..f51407a --- /dev/null +++ b/test/data/requests/366fa742787295d5854874aeb6ef78c442b50abb.json @@ -0,0 +1,49 @@ +{ + "request": { + "path": "https://eos.greymass.com/v1/chain/get_table_rows", + "params": { + "method": "POST", + "body": "{\"json\":true,\"limit\":1000,\"table\":\"global\",\"code\":\"eosio\",\"scope\":\"eosio\",\"key_type\":\"name\"}" + } + }, + "status": 200, + "json": { + "rows": [ + { + "max_block_net_usage": 1048576, + "target_block_net_usage_pct": 10000, + "max_transaction_net_usage": 524288, + "base_per_transaction_net_usage": 12, + "net_usage_leeway": 500, + "context_free_discount_net_usage_num": 20, + "context_free_discount_net_usage_den": 100, + "max_block_cpu_usage": 200000, + "target_block_cpu_usage_pct": 10, + "max_transaction_cpu_usage": 150000, + "min_transaction_cpu_usage": 100, + "max_transaction_lifetime": 3600, + "deferred_trx_expiration_window": 600, + "max_transaction_delay": 3888000, + "max_inline_action_size": 524287, + "max_inline_action_depth": 10, + "max_authority_depth": 10, + "max_ram_size": "418945440768", + "total_ram_bytes_reserved": "333551148743", + "total_ram_stake": "213336801716", + "last_producer_schedule_update": "2024-12-21T19:15:00.500", + "last_pervote_bucket_fill": "2024-12-21T18:53:42.000", + "pervote_bucket": 257850978, + "perblock_bucket": 39779454, + "total_unpaid_blocks": 82609, + "total_activated_stake": "6092900115741", + "thresh_activated_stake_time": "2018-06-20T14:44:52.000", + "last_producer_schedule_size": 21, + "total_producer_vote_weight": "1410584016862351458304.00000000000000000", + "last_name_close": "2024-12-21T02:42:47.500" + } + ], + "more": false, + "next_key": "" + }, + "text": "{\"rows\":[{\"max_block_net_usage\":1048576,\"target_block_net_usage_pct\":10000,\"max_transaction_net_usage\":524288,\"base_per_transaction_net_usage\":12,\"net_usage_leeway\":500,\"context_free_discount_net_usage_num\":20,\"context_free_discount_net_usage_den\":100,\"max_block_cpu_usage\":200000,\"target_block_cpu_usage_pct\":10,\"max_transaction_cpu_usage\":150000,\"min_transaction_cpu_usage\":100,\"max_transaction_lifetime\":3600,\"deferred_trx_expiration_window\":600,\"max_transaction_delay\":3888000,\"max_inline_action_size\":524287,\"max_inline_action_depth\":10,\"max_authority_depth\":10,\"max_ram_size\":\"418945440768\",\"total_ram_bytes_reserved\":\"333551148743\",\"total_ram_stake\":\"213336801716\",\"last_producer_schedule_update\":\"2024-12-21T19:15:00.500\",\"last_pervote_bucket_fill\":\"2024-12-21T18:53:42.000\",\"pervote_bucket\":257850978,\"perblock_bucket\":39779454,\"total_unpaid_blocks\":82609,\"total_activated_stake\":\"6092900115741\",\"thresh_activated_stake_time\":\"2018-06-20T14:44:52.000\",\"last_producer_schedule_size\":21,\"total_producer_vote_weight\":\"1410584016862351458304.00000000000000000\",\"last_name_close\":\"2024-12-21T02:42:47.500\"}],\"more\":false,\"next_key\":\"\"}" +} \ No newline at end of file diff --git a/test/tests/contract.ts b/test/tests/contract.ts index 8298be7..42a90fe 100644 --- a/test/tests/contract.ts +++ b/test/tests/contract.ts @@ -3,7 +3,16 @@ import {assert} from 'chai' import ContractKit, {Contract, ContractArgs, Table} from '$lib' import {DelegatedBandwidth, ProducerInfo} from '$test/data/structs/eosio' -import {ABI, Action, Asset, Name, PrivateKey, Serializer, UInt64} from '@wharfkit/antelope' +import { + ABI, + Action, + APIClient, + Asset, + Name, + PrivateKey, + Serializer, + UInt64, +} from '@wharfkit/antelope' import {PlaceholderAuth} from '@wharfkit/signing-request' import {runGenericContractTests} from './helpers/generic' @@ -104,8 +113,8 @@ suite('Contract', async function () { suite('specific contract', function () { suite('system contract', function () { - suite('generic tests', async () => { - runGenericContractTests(systemContract) + suite('generic tests', function () { + runGenericContractTests(() => systemContract) }) suite('tableNames', function () { test('validate for contract', function () { @@ -255,8 +264,8 @@ suite('Contract', async function () { }) }) suite('token contract', function () { - suite('generic tests', async () => { - runGenericContractTests(tokenContract) + suite('generic tests', function () { + runGenericContractTests(() => tokenContract) }) suite('action', function () { suite('load', function () { @@ -375,6 +384,91 @@ suite('Contract', async function () { const result = await contract.readonly('callapi') assert.instanceOf(result.foo, UInt64) }) + + suite('exceptions', () => { + function failingContract(except: unknown): Promise { + const response = { + transaction_id: 'ff'.repeat(32), + processed: { + id: 'ff'.repeat(32), + block_num: 1, + block_time: '2026-01-01T00:00:00.000', + receipt: null, + elapsed: 1, + except, + net_usage: 0, + scheduled: false, + action_traces: [], + account_ram_delta: null, + }, + } + return mockKit.load('testing.gm').then( + (loaded) => + new Contract({ + abi: loaded.abi, + account: loaded.account, + client: new APIClient({ + provider: { + call: async () => ({ + status: 200, + headers: {}, + text: JSON.stringify(response), + json: response, + }), + }, + }), + }) + ) + } + + test('throws the substituted assertion message', async () => { + const contract = await failingContract({ + code: 3050003, + name: 'eosio_assert_message_exception', + message: 'eosio_assert_message assertion failure', + stack: [ + { + context: {level: 'error'}, + format: 'assertion failure with message: ${s}', + data: {s: 'insufficient balance'}, + }, + ], + }) + + let error + try { + await contract.readonly('callapi') + } catch (err) { + error = err + } + assert.instanceOf(error, Error) + assert.equal( + (error as Error).message, + 'assertion failure with message: insufficient balance' + ) + }) + + test('falls back to the exception message with an empty stack', async () => { + const contract = await failingContract({ + code: 3080004, + name: 'tx_cpu_usage_exceeded', + message: 'transaction exceeded the current CPU usage limit', + stack: [], + }) + + let error + try { + await contract.readonly('callapi') + } catch (err) { + error = err + } + assert.instanceOf(error, Error) + assert.equal( + (error as Error).message, + 'transaction exceeded the current CPU usage limit' + ) + }) + }) }) }) }) diff --git a/test/tests/helpers/generic.ts b/test/tests/helpers/generic.ts index e90e02f..37ce289 100644 --- a/test/tests/helpers/generic.ts +++ b/test/tests/helpers/generic.ts @@ -39,48 +39,66 @@ export function getMockParams(contract: Contract): ActionDataType { } } -export function runGenericContractTests(contract: Contract) { - // suite: tableNames - // contains tables - assert.isArray(contract.tableNames) - assert.isTrue(contract.tableNames.length > 0) +export function runGenericContractTests(getContract: () => Contract) { + suite('tableNames', function () { + test('contains tables', function () { + const contract = getContract() + assert.isArray(contract.tableNames) + assert.isTrue(contract.tableNames.length > 0) + }) + }) - // suite: table - // load table using Name - const tableName = Name.from(contract.tableNames[0]) - const table = contract.table(tableName) - assert.instanceOf(table, Table) - assert.isTrue(table.name.equals(tableName)) + suite('table', function () { + test('loads table using Name', function () { + const contract = getContract() + const tableName = Name.from(contract.tableNames[0]) + const table = contract.table(tableName) + assert.instanceOf(table, Table) + assert.isTrue(table.name.equals(tableName)) + }) - // load table using string - const tableName2 = contract.tableNames[0] - const table2 = contract.table(tableName2) - assert.instanceOf(table2, Table) - assert.isTrue(table2.name.equals(tableName2)) + test('loads table using string', function () { + const contract = getContract() + const tableName = contract.tableNames[0] + const table = contract.table(tableName) + assert.instanceOf(table, Table) + assert.isTrue(table.name.equals(tableName)) + }) - // throws on invalid name - assert.throws(() => contract.table('foo')) + test('throws on invalid name', function () { + const contract = getContract() + assert.throws(() => contract.table('foo')) + }) + }) - // suite: actionNames - // contains actions - assert.isArray(contract.actionNames) - assert.isTrue(contract.actionNames.length > 0) + suite('actionNames', function () { + test('contains actions', function () { + const contract = getContract() + assert.isArray(contract.actionNames) + assert.isTrue(contract.actionNames.length > 0) + }) + }) - // suite: action - // load action using Name - const actionName = Name.from(contract.actionNames[0]) - const params = getMockParams(contract) - const action = contract.action(actionName, params) - assert.instanceOf(action, Action) - assert.isTrue(action.name.equals(actionName)) + suite('action', function () { + test('loads action using Name', function () { + const contract = getContract() + const actionName = Name.from(contract.actionNames[0]) + const action = contract.action(actionName, getMockParams(contract)) + assert.instanceOf(action, Action) + assert.isTrue(action.name.equals(actionName)) + }) - // load action using string - const actionName2 = contract.actionNames[0] - const params2 = getMockParams(contract) - const action2 = contract.action(actionName2, params2) - assert.instanceOf(action2, Action) - assert.isTrue(action2.name.equals(actionName2)) + test('loads action using string', function () { + const contract = getContract() + const actionName = contract.actionNames[0] + const action = contract.action(actionName, getMockParams(contract)) + assert.instanceOf(action, Action) + assert.isTrue(action.name.equals(actionName)) + }) - // throws on invalid name - assert.throws(() => contract.action('foo', {})) + test('throws on invalid name', function () { + const contract = getContract() + assert.throws(() => contract.action('foo', {})) + }) + }) } diff --git a/test/tests/kit.ts b/test/tests/kit.ts index c08c723..1d4ec2d 100644 --- a/test/tests/kit.ts +++ b/test/tests/kit.ts @@ -29,6 +29,18 @@ suite('Kit', function () { assert.instanceOf(kit, ContractKit) assert.instanceOf(kit.abiCache, ABICache) }) + test('options: debug', async function () { + const kit = new ContractKit(mockContractKitArgs, { + debug: true, + }) + assert.instanceOf(kit, ContractKit) + assert.isTrue(kit.debug) + const contract = await kit.load('eosio.token') + assert.instanceOf(contract, Contract) + assert.isTrue(contract.debug) + const table = contract.table('accounts') + assert.isTrue(table.debug) + }) suite('options: abis', function () { test('untyped', async function () { const kit = new ContractKit(mockContractKitArgs, { diff --git a/test/tests/table.ts b/test/tests/table.ts index a949a2c..b57ba2c 100644 --- a/test/tests/table.ts +++ b/test/tests/table.ts @@ -3,7 +3,17 @@ import {assert} from 'chai' import ContractKit, {Contract, Table, TableRowCursor, TableScopeCursor} from '$lib' import {EosioGlobalState} from '$test/data/structs/eosio' -import {Asset, Int64, Name, Serializer, Struct, TimePoint, UInt32} from '@wharfkit/antelope' +import { + APIClient, + Asset, + Int64, + Name, + Serializer, + Struct, + TimePoint, + UInt32, + UInt64, +} from '@wharfkit/antelope' import {makeClient} from '@wharfkit/mock-data' const mockClient = makeClient('https://eos.greymass.com') @@ -315,6 +325,93 @@ suite('Table', () => { assert.equal(rowsBuyTxtScope.length, 144) assert.equal(rowsSellTxtScope.length, 348) }) + + test('should work with uint64 scopes', async function () { + const testKit = new ContractKit({ + client: makeClient('https://wax.greymass.com'), + }) + + const contract = await testKit.load('alcordexmain') + + const rowsBuy = await contract + .table('buyorder') + .query({scope: UInt64.from(0)}) + .all() + const rowsSell = await contract + .table('sellorder') + .query({scope: UInt64.from(0)}) + .all() + assert.equal(rowsBuy.length, 144) + assert.equal(rowsSell.length, 348) + }) + + test('should keep a uint64 scope beyond the range of a number', function () { + const cursor = producersTable.query({scope: UInt64.from('9223372036854775808')}) + assert.equal(String(cursor.params.scope), '9223372036854775808') + }) + + test('should accept a uint64 scope from the table call', function () { + const cursor = eosio.table('producers', UInt64.from('9223372036854775808')).query() + assert.equal(String(cursor.params.scope), '9223372036854775808') + }) + + test('should reject a number scope that cannot hold the value', function () { + assert.throws( + () => producersTable.query({scope: 9223372036854775808}), + /is not an integer a number can hold/ + ) + }) + + test('should still default to the contract account', function () { + const cursor = producersTable.query() + assert.equal(String(cursor.params.scope), 'eosio') + }) + + test('should keep a scope of zero from the table call', function () { + const cursor = eosio.table('producers', 0).query() + assert.equal(String(cursor.params.scope), '0') + }) + + test('should fall back to the default scope for an absent query scope', function () { + const table = eosio.table('producers', UInt64.from(42)) + assert.equal(String(table.query().params.scope), '42') + assert.equal(String(table.query({scope: null as any}).params.scope), '42') + assert.equal(String(table.query({scope: ''}).params.scope), '42') + }) + + test('should fall back to the contract account for an empty default scope', function () { + const cursor = eosio.table('producers', '').query() + assert.equal(String(cursor.params.scope), 'eosio') + }) + + test('should send a uint64 scope on a get request', async function () { + const scopes: unknown[] = [] + const client = new APIClient({ + provider: { + call: async ({params}: any) => { + // Assert against the encoded body, where a number scope would lose precision + scopes.push(JSON.parse(JSON.stringify(params)).scope) + return { + status: 200, + headers: {}, + text: '{"rows":[],"more":false}', + json: {rows: [], more: false}, + } + }, + }, + }) + const table = new Table({ + abi: eosio.abi, + account: 'eosio', + client, + name: 'producers', + }) + + await table.get(undefined, {scope: UInt64.from('9223372036854775808')}) + await table.get() + + assert.deepEqual(scopes, ['9223372036854775808', 'eosio']) + }) }) test('reverse', async function () { @@ -326,6 +423,18 @@ suite('Table', () => { [6, 5] ) }) + + test('should return deserialized data in debug', async function () { + const table = new Table({ + abi: eosio.abi, + account: 'eosio', + client: mockClient, + name: 'global', + debug: true, + }) + const row = await table.all() + assert.deepEqual(row, JSON.parse(JSON.stringify(row))) + }) }) suite('get', () => { @@ -418,6 +527,18 @@ suite('Table', () => { assert.isUndefined(row) }) + + test('should return deserialized data in debug', async function () { + const table = new Table({ + abi: eosio.abi, + account: 'eosio', + client: mockClient, + name: 'namebids', + debug: true, + }) + const row = await table.get() + assert.deepEqual(row, JSON.parse(JSON.stringify(row))) + }) }) suite('first', () => { @@ -485,6 +606,19 @@ suite('Table', () => { assert.instanceOf(batch[0].ref.category, Name) }) }) + + test('should return deserialized data in debug', async function () { + const table = new Table({ + abi: eosio.abi, + account: 'eosio', + client: mockClient, + name: 'global', + debug: true, + }) + const cursor = await table.query() + const row = await cursor.all() + assert.deepEqual(row, JSON.parse(JSON.stringify(row))) + }) }) suite('all', () => { @@ -496,6 +630,17 @@ suite('Table', () => { const tableRows = await nameBidTable.all() assert.instanceOf(tableRows[0].high_bidder, Name) }) + test('should return deserialized data in debug', async function () { + const table = new Table({ + abi: eosio.abi, + account: 'eosio', + client: mockClient, + name: 'global', + debug: true, + }) + const row = await table.all() + assert.deepEqual(row, JSON.parse(JSON.stringify(row))) + }) }) suite('scopes', () => { diff --git a/test/tests/utils.ts b/test/tests/utils.ts index 3062187..65d3595 100644 --- a/test/tests/utils.ts +++ b/test/tests/utils.ts @@ -1,15 +1,18 @@ import {assert} from 'chai' import fs from 'fs' -import {ABI, Blob, Name, Serializer, UInt128, UInt64} from '@wharfkit/antelope' +import {ABI, Blob, Int64, Name, Serializer, UInt128, UInt32, UInt64} from '@wharfkit/antelope' import { abiToBlob, blobStringToAbi, capitalize, + formatExceptionMessage, indexPositionInWords, + isAbsentScope, pascalCase, singularize, wrapIndexValue, + wrapScopeValue, } from '../../src/utils' suite('Utility functions', function () { @@ -42,6 +45,66 @@ suite('Utility functions', function () { assert.deepEqual(wrapIndexValue('name'), Name.from('name')) }) + suite('Wraps scope value', function () { + test('names', function () { + assert.deepEqual(wrapScopeValue(Name.from('teamgreymass')), Name.from('teamgreymass')) + }) + + test('strings pass through untouched', function () { + assert.equal(wrapScopeValue('teamgreymass'), 'teamgreymass') + assert.equal(wrapScopeValue('0'), '0') + assert.equal(wrapScopeValue('18446744073709551615'), '18446744073709551615') + }) + + test('numbers', function () { + assert.deepEqual(wrapScopeValue(0), UInt64.from(0)) + assert.deepEqual(wrapScopeValue(10), UInt64.from(10)) + }) + + test('uint64 values beyond the range of a number', function () { + const scope = UInt64.from('9223372036854775808') + assert.deepEqual(wrapScopeValue(scope), scope) + assert.equal(String(wrapScopeValue(scope)), '9223372036854775808') + }) + + test('rejects numbers that cannot hold the scope', function () { + assert.throws( + () => wrapScopeValue(9223372036854775808), + /is not an integer a number can hold/ + ) + assert.throws(() => wrapScopeValue(1.5), /is not an integer a number can hold/) + }) + + test('rejects negative values', function () { + assert.throws(() => wrapScopeValue(-1), /underflows uint64/) + assert.throws(() => wrapScopeValue(Int64.from(-5)), /underflows uint64/) + }) + + test('accepts the full uint64 range', function () { + const max = UInt64.from('18446744073709551615') + assert.equal(String(wrapScopeValue(max)), '18446744073709551615') + }) + + test('accepts other integer types', function () { + assert.deepEqual(wrapScopeValue(Int64.from(5)), UInt64.from(5)) + assert.deepEqual(wrapScopeValue(UInt32.from(7)), UInt64.from(7)) + }) + + test('rejects an absent scope rather than exhausting memory', function () { + assert.throws(() => wrapScopeValue(null as any), /Scope is required/) + assert.throws(() => wrapScopeValue(undefined as any), /Scope is required/) + }) + + test('reports which scopes are absent', function () { + assert.isTrue(isAbsentScope(undefined)) + assert.isTrue(isAbsentScope(null)) + assert.isTrue(isAbsentScope('')) + assert.isFalse(isAbsentScope(0)) + assert.isFalse(isAbsentScope('teamgreymass')) + assert.isFalse(isAbsentScope(UInt64.from(0))) + }) + }) + const testABI = ABI.from(fs.readFileSync(`test/data/abis/rewards.gm.json`, {encoding: 'utf8'})) // Blob created from the testABI @@ -64,4 +127,63 @@ suite('Utility functions', function () { const result = blobStringToAbi(blobString) assert(result.equals(testABI)) }) + + suite('formatExceptionMessage', function () { + test('substitutes ${key} placeholders from stack[0]', function () { + const except: any = { + code: 3050003, + name: 'eosio_assert_message_exception', + message: 'eosio_assert_message assertion failure', + stack: [ + { + context: { + level: 'error', + file: 'cf_system.cpp', + line: 14, + method: 'eosio_assert', + }, + format: 'assertion failure with message: ${s}', + data: {s: 'container not found'}, + }, + ], + } + assert.equal( + formatExceptionMessage(except), + 'assertion failure with message: container not found' + ) + }) + + test('falls back to except.message when stack is empty', function () { + const except: any = { + code: 3080004, + name: 'tx_cpu_usage_exceeded', + message: 'transaction exceeded the current CPU usage limit', + stack: [], + } + assert.equal( + formatExceptionMessage(except), + 'transaction exceeded the current CPU usage limit' + ) + }) + + test('uses data.s when format is empty', function () { + const except: any = { + code: 3050003, + name: 'eosio_assert_message_exception', + message: 'eosio_assert_message assertion failure', + stack: [{context: {}, format: '', data: {s: 'leftover string'}}], + } + assert.equal(formatExceptionMessage(except), 'leftover string') + }) + + test('leaves unmatched placeholders intact', function () { + const except: any = { + code: 1, + name: 'whatever', + message: 'fallback', + stack: [{context: {}, format: 'oops ${missing}', data: {}}], + } + assert.equal(formatExceptionMessage(except), 'oops ${missing}') + }) + }) })