diff --git a/.changeset/bright-nails-glow.md b/.changeset/bright-nails-glow.md new file mode 100644 index 0000000000..a1866ca1e0 --- /dev/null +++ b/.changeset/bright-nails-glow.md @@ -0,0 +1,5 @@ +--- +"@near-js/accounts": patch +--- + +Implement local execution of contract view methods diff --git a/packages/accounts/package.json b/packages/accounts/package.json index 239fd836fc..2a4969d92a 100644 --- a/packages/accounts/package.json +++ b/packages/accounts/package.json @@ -27,6 +27,7 @@ "bn.js": "5.2.1", "borsh": "1.0.0", "depd": "^2.0.0", + "lru_map": "^0.4.1", "near-abi": "0.1.1" }, "devDependencies": { diff --git a/packages/accounts/src/contract.ts b/packages/accounts/src/contract.ts index a73e9a5a08..17dd1b0385 100644 --- a/packages/accounts/src/contract.ts +++ b/packages/accounts/src/contract.ts @@ -1,5 +1,6 @@ import { getTransactionLastResult } from '@near-js/utils'; import { ArgumentTypeError, PositionalArgsError } from '@near-js/types'; +import { LocalViewExecution } from './local-view-execution'; import Ajv from 'ajv'; import addFormats from 'ajv-formats'; import BN from 'bn.js'; @@ -99,6 +100,11 @@ export interface ContractMethods { * ABI defining this contract's interface. */ abi?: AbiRoot; + + /** + * Executes view methods locally. This flag is useful when multiple view calls will be made for the same blockId + */ + useLocalViewExecution: boolean; } /** @@ -138,6 +144,7 @@ export interface ContractMethods { export class Contract { readonly account: Account; readonly contractId: string; + readonly lve: LocalViewExecution; /** * @param account NEAR account to sign change method transactions @@ -147,7 +154,8 @@ export class Contract { constructor(account: Account, contractId: string, options: ContractMethods) { this.account = account; this.contractId = contractId; - const { viewMethods = [], changeMethods = [], abi: abiRoot } = options; + this.lve = new LocalViewExecution(account); + const { viewMethods = [], changeMethods = [], abi: abiRoot, useLocalViewExecution } = options; let viewMethodsWithAbi = viewMethods.map((name) => ({ name, abi: null as AbiFunction })); let changeMethodsWithAbi = changeMethods.map((name) => ({ name, abi: null as AbiFunction })); @@ -177,6 +185,20 @@ export class Contract { validateArguments(args, abi, ajv, abiRoot); } + if (useLocalViewExecution) { + try { + return await this.lve.viewFunction({ + contractId: this.contractId, + methodName: name, + args, + ...options, + }); + } catch (error) { + console.warn(`Local view execution failed with: "${error.message}"`); + console.warn(`Fallback to normal RPC call`); + } + } + return this.account.viewFunction({ contractId: this.contractId, methodName: name, diff --git a/packages/accounts/src/local-view-execution/index.ts b/packages/accounts/src/local-view-execution/index.ts new file mode 100644 index 0000000000..d231c9a408 --- /dev/null +++ b/packages/accounts/src/local-view-execution/index.ts @@ -0,0 +1,84 @@ +import { BlockReference, ContractCodeView } from '@near-js/types'; +import { printTxOutcomeLogs } from '@near-js/utils'; +import { Account, FunctionCallOptions } from '../account'; +import { Storage } from './storage'; +import { Runtime } from './runtime'; +import { ContractState } from './types'; + +interface ViewFunctionCallOptions extends FunctionCallOptions { + blockQuery?: BlockReference +} + +export class LocalViewExecution { + private readonly account: Account; + private readonly storage: Storage; + + constructor(account: Account) { + this.account = account; + this.storage = new Storage(); + } + + private async fetchContractCode(contractId: string, blockQuery: BlockReference) { + const result = await this.account.connection.provider.query({ + request_type: 'view_code', + account_id: contractId, + ...blockQuery, + }); + + return result.code_base64; + } + + private async fetchContractState(blockQuery: BlockReference): Promise { + return this.account.viewState('', blockQuery); + } + + private async fetch(contractId: string, blockQuery: BlockReference) { + const block = await this.account.connection.provider.block(blockQuery); + const blockHash = block.header.hash; + const blockHeight = block.header.height; + const blockTimestamp = block.header.timestamp; + + const contractCode = await this.fetchContractCode(contractId, blockQuery); + const contractState = await this.fetchContractState(blockQuery); + + return { + blockHash, + blockHeight, + blockTimestamp, + contractCode, + contractState, + }; + } + + private async loadOrFetch(contractId: string, blockQuery: BlockReference) { + const stored = this.storage.load(blockQuery); + + if (stored) { + return stored; + } + + const { blockHash, ...fetched } = await this.fetch(contractId, blockQuery); + + this.storage.save(blockHash, fetched); + + return fetched; + } + + public async viewFunction({ contractId, methodName, args = {}, blockQuery = { finality: 'optimistic' }, ...ignored }: ViewFunctionCallOptions) { + const methodArgs = JSON.stringify(args); + + const { contractCode, contractState, blockHeight, blockTimestamp } = await this.loadOrFetch( + contractId, + blockQuery + ); + const runtime = new Runtime({ contractId, contractCode, contractState, blockHeight, blockTimestamp, methodArgs }); + + const { result, logs } = await runtime.execute(methodName); + + if (logs) { + printTxOutcomeLogs({ contractId, logs }); + } + + return JSON.parse(Buffer.from(result).toString()); + } +} diff --git a/packages/accounts/src/local-view-execution/runtime.ts b/packages/accounts/src/local-view-execution/runtime.ts new file mode 100644 index 0000000000..d684348052 --- /dev/null +++ b/packages/accounts/src/local-view-execution/runtime.ts @@ -0,0 +1,397 @@ +import { createHash } from 'crypto'; +import { ContractState } from './types'; + +const notImplemented = + (name: string) => + () => { + throw new Error('method not implemented: ' + name); + }; + +const prohibitedInView = + (name: string) => + () => { + throw new Error('method not available for view calls: ' + name); + }; + +interface RuntimeCtx { + contractId: string, contractState: ContractState, blockHeight: number, blockTimestamp: number, methodArgs: string +} +interface RuntimeConstructorArgs extends RuntimeCtx { + // base64 encoded contract code + contractCode: string, +} +export class Runtime { + context: RuntimeCtx; + wasm: Buffer; + memory: WebAssembly.Memory; + registers: Record; + logs: any[]; + result: Buffer; + + constructor({ contractCode, ...context }: RuntimeConstructorArgs) { + this.context = context; + this.wasm = this.prepareWASM(Buffer.from(contractCode, 'base64')); + this.memory = new WebAssembly.Memory({ initial: 1024, maximum: 2048 }); + this.registers = {}; + this.logs = []; + this.result = Buffer.from([]); + } + + private readUTF16CStr(ptr: bigint) { + const arr: number[] = []; + const mem = new Uint16Array(this.memory.buffer); + let key = Number(ptr) / 2; + while (mem[key] != 0) { + arr.push(mem[key]); + key++; + } + return Buffer.from(Uint16Array.from(arr).buffer).toString('ucs2'); + } + + private readUTF8CStr(len: bigint, ptr: bigint) { + const arr: number[] = []; + const mem = new Uint8Array(this.memory.buffer); + let key = Number(ptr); + for (let i = 0; i < len && mem[key] != 0; i++) { + arr.push(mem[key]); + key++; + } + return Buffer.from(arr).toString('utf8'); + } + + private storageRead(keyLen: bigint, keyPtr: bigint) { + const storageKey = Buffer.from(new Uint8Array(this.memory.buffer, Number(keyPtr), Number(keyLen))); + + const stateVal = this.context.contractState.filter((obj) => Buffer.compare(obj.key, storageKey) === 0).map((obj) => obj.value); + + if (stateVal.length === 0) return null; + + return stateVal.length > 1 ? stateVal : stateVal[0]; + } + + private prepareWASM(input: Buffer) { + const parts = [] as Buffer[]; + + const magic = input.subarray(0, 4); + + if (magic.toString('utf8') !== '\0asm') { + throw new Error('Invalid magic number'); + } + + const version = input.readUInt32LE(4); + if (version != 1) { + throw new Error('Invalid version: ' + version); + } + + let offset = 8; + parts.push(input.subarray(0, offset)); + + function decodeLEB128() { + let result = 0; + let shift = 0; + let byte: number; + do { + byte = input[offset++]; + result |= (byte & 0x7f) << shift; + shift += 7; + } while (byte & 0x80); + return result; + } + + function decodeLimits() { + const flags = input[offset++]; + const hasMax = flags & 0x1; + const initial = decodeLEB128(); + const max = hasMax ? decodeLEB128() : null; + return { initial, max }; + } + + function decodeString() { + const length = decodeLEB128(); + const result = input.subarray(offset, offset + length); + offset += length; + return result.toString('utf8'); + } + + function encodeLEB128(value: number) { + const result: number[] = []; + do { + let byte = value & 0x7f; + value >>= 7; + if (value !== 0) { + byte |= 0x80; + } + result.push(byte); + } while (value !== 0); + return Buffer.from(result); + } + + function encodeString(value: string) { + const result = Buffer.from(value, 'utf8'); + return Buffer.concat([encodeLEB128(result.length), result]); + } + + do { + const sectionStart = offset; + const sectionId = input.readUInt8(offset); + offset++; + const sectionSize = decodeLEB128(); + const sectionEnd = offset + sectionSize; + + if (sectionId == 5) { + // Memory section + // Make sure it's empty and only imported memory is used + parts.push(Buffer.from([5, 1, 0])); + } else if (sectionId == 2) { + // Import section + const sectionParts: Buffer[] = []; + const numImports = decodeLEB128(); + for (let i = 0; i < numImports; i++) { + const importStart = offset; + decodeString(); + decodeString(); + const kind = input.readUInt8(offset); + offset++; + + let skipImport = false; + switch (kind) { + case 0: + // Function import + decodeLEB128(); // index + break; + case 1: + // Table import + offset++; // type + decodeLimits(); + break; + case 2: + // Memory import + decodeLimits(); + // NOTE: existing memory import is removed (so no need to add it to sectionParts) + skipImport = true; + break; + case 3: + // Global import + offset++; // type + offset++; // mutability + break; + default: + throw new Error('Invalid import kind: ' + kind); + } + + if (!skipImport) { + sectionParts.push(input.subarray(importStart, offset)); + } + } + + const importMemory = Buffer.concat([ + encodeString('env'), + encodeString('memory'), + Buffer.from([2]), // Memory import + Buffer.from([0]), + encodeLEB128(1), + ]); + + sectionParts.push(importMemory); + + const sectionData = Buffer.concat([ + encodeLEB128(sectionParts.length), + ...sectionParts, + ]); + + parts.push(Buffer.concat([ + Buffer.from([2]), // Import section + encodeLEB128(sectionData.length), + sectionData + ])); + } else if (sectionId == 7) { + // Export section + const sectionParts: Buffer[] = []; + const numExports = decodeLEB128(); + for (let i = 0; i < numExports; i++) { + const exportStart = offset; + decodeString(); + const kind = input.readUInt8(offset); + offset++; + decodeLEB128(); + + if (kind !== 2) { + // Pass through all exports except memory + sectionParts.push(input.subarray(exportStart, offset)); + } + } + + const sectionData = Buffer.concat([ + encodeLEB128(sectionParts.length), + ...sectionParts, + ]); + + parts.push(Buffer.concat([ + Buffer.from([7]), // Export section + encodeLEB128(sectionData.length), + sectionData + ])); + } else { + parts.push(input.subarray(sectionStart, sectionEnd)); + } + + offset = sectionEnd; + } while (offset < input.length); + + return Buffer.concat(parts); + } + + // Host functions + private getRegisterLength (registerId: bigint) { + return BigInt(this.registers[registerId.toString()] ? this.registers[registerId.toString()].length : Number.MAX_SAFE_INTEGER); + } + + private readFromRegister (registerId: bigint, ptr: bigint) { + const mem = new Uint8Array(this.memory.buffer); + mem.set(this.registers[registerId.toString()] || Buffer.from([]), Number(ptr)); + } + + private getCurrentAccountId (registerId: bigint) { + this.registers[registerId.toString()] = Buffer.from(this.context.contractId); + } + + private inputMethodArgs (registerId: bigint) { + this.registers[registerId.toString()] = Buffer.from(this.context.methodArgs); + } + + private getBlockHeight () { + return BigInt(this.context.blockHeight); + } + + private getBlockTimestamp () { + return BigInt(this.context.blockTimestamp); + } + + private sha256 (valueLen: bigint, valuePtr: bigint, registerId: bigint) { + const value = new Uint8Array(this.memory.buffer, Number(valuePtr), Number(valueLen)); + const hash = createHash('sha256'); + hash.update(value); + this.registers[registerId.toString()] = hash.digest(); + } + + private returnValue (valueLen: bigint, valuePtr: bigint) { + this.result = Buffer.from(new Uint8Array(this.memory.buffer, Number(valuePtr), Number(valueLen))); + } + + private panic (message: string) { + throw new Error('panic: ' + message); + } + + private abort (msg_ptr: bigint, filename_ptr: bigint, line: number, col: number) { + const msg = this.readUTF16CStr(msg_ptr); + const filename = this.readUTF16CStr(filename_ptr); + const message = `${msg} ${filename}:${line}:${col}`; + if (!msg || !filename) { + throw new Error('abort: ' + 'String encoding is bad UTF-16 sequence.'); + } + throw new Error('abort: ' + message); + } + + private appendToLog (len: bigint, ptr: bigint) { + this.logs.push(this.readUTF8CStr(len, ptr)); + } + + private readStorage (key_len: bigint, key_ptr: bigint, register_id: number): bigint { + const result = this.storageRead(key_len, key_ptr); + + if (result == null) { + return BigInt(0); + } + + this.registers[register_id] = result; + return BigInt(1); + } + + private hasStorageKey (key_len: bigint, key_ptr: bigint): bigint { + const result = this.storageRead(key_len, key_ptr); + + if (result == null) { + return BigInt(0); + } + + return BigInt(1); + } + + private getHostImports() { + return { + register_len: this.getRegisterLength.bind(this), + read_register: this.readFromRegister.bind(this), + current_account_id: this.getCurrentAccountId.bind(this), + input: this.inputMethodArgs.bind(this), + block_index: this.getBlockHeight.bind(this), + block_timestamp: this.getBlockTimestamp.bind(this), + sha256: this.sha256.bind(this), + value_return: this.returnValue.bind(this), + abort: this.abort.bind(this), + log_utf8: this.appendToLog.bind(this), + log_utf16: this.appendToLog.bind(this), + storage_read: this.readStorage.bind(this), + storage_has_key: this.hasStorageKey.bind(this), + panic: () => this.panic('explicit guest panic'), + panic_utf8: (len: bigint, ptr: bigint) => this.panic(this.readUTF8CStr(len, ptr)), + // Not implemented + epoch_height: notImplemented('epoch_height'), + storage_usage: notImplemented('storage_usage'), + account_balance: notImplemented('account_balance'), + account_locked_balance: notImplemented('account_locked_balance'), + random_seed: notImplemented('random_seed'), + ripemd160: notImplemented('ripemd160'), + keccak256: notImplemented('keccak256'), + keccak512: notImplemented('keccak512'), + ecrecover: notImplemented('ecrecover'), + validator_stake: notImplemented('validator_stake'), + validator_total_stake: notImplemented('validator_total_stake'), + // Prohibited + write_register: prohibitedInView('write_register'), + signer_account_id: prohibitedInView('signer_account_id'), + signer_account_pk: prohibitedInView('signer_account_pk'), + predecessor_account_id: prohibitedInView('predecessor_account_id'), + attached_deposit: prohibitedInView('attached_deposit'), + prepaid_gas: prohibitedInView('prepaid_gas'), + used_gas: prohibitedInView('used_gas'), + promise_create: prohibitedInView('promise_create'), + promise_then: prohibitedInView('promise_then'), + promise_and: prohibitedInView('promise_and'), + promise_batch_create: prohibitedInView('promise_batch_create'), + promise_batch_then: prohibitedInView('promise_batch_then'), + promise_batch_action_create_account: prohibitedInView('promise_batch_action_create_account'), + promise_batch_action_deploy_contract: prohibitedInView('promise_batch_action_deploy_contract'), + promise_batch_action_function_call: prohibitedInView('promise_batch_action_function_call'), + promise_batch_action_function_call_weight: prohibitedInView('promise_batch_action_function_call_weight'), + promise_batch_action_transfer: prohibitedInView('promise_batch_action_transfer'), + promise_batch_action_stake: prohibitedInView('promise_batch_action_stake'), + promise_batch_action_add_key_with_full_access: prohibitedInView('promise_batch_action_add_key_with_full_access'), + promise_batch_action_add_key_with_function_call: prohibitedInView('promise_batch_action_add_key_with_function_call'), + promise_batch_action_delete_key: prohibitedInView('promise_batch_action_delete_key'), + promise_batch_action_delete_account: prohibitedInView('promise_batch_action_delete_account'), + promise_results_count: prohibitedInView('promise_results_count'), + promise_result: prohibitedInView('promise_result'), + promise_return: prohibitedInView('promise_return'), + storage_write: prohibitedInView('storage_write'), + storage_remove: prohibitedInView('storage_remove'), + }; + } + + async execute(methodName: string) { + const module = await WebAssembly.compile(this.wasm); + const instance = await WebAssembly.instantiate(module, { env: { ...this.getHostImports(), memory: this.memory } }); + + const callMethod = instance.exports[methodName] as CallableFunction | undefined; + + if (callMethod == undefined) { + throw new Error(`Contract method '${methodName}' does not exists in contract ${this.context.contractId} for block id ${this.context.blockHeight}`); + } + + callMethod(); + + return { + result: this.result, + logs: this.logs + }; + } +} diff --git a/packages/accounts/src/local-view-execution/storage.ts b/packages/accounts/src/local-view-execution/storage.ts new file mode 100644 index 0000000000..bb6adbcbe6 --- /dev/null +++ b/packages/accounts/src/local-view-execution/storage.ts @@ -0,0 +1,48 @@ +import { LRUMap } from 'lru_map'; +import { BlockHash, BlockReference } from '@near-js/types'; +import { ContractState } from './types'; + +export interface StorageData { + blockHeight: number, + blockTimestamp: number, + contractCode: string, + contractState: ContractState +} + +export interface StorageOptions { + max: number +} + +export class Storage { + private readonly cache: LRUMap; + + private static MAX_ELEMENTS = 100; + + // map block hash to block height + private blockHeights: Map; + + constructor(options: StorageOptions = { max: Storage.MAX_ELEMENTS }) { + this.cache = new LRUMap(options.max); + this.blockHeights = new Map(); + } + + public load(blockRef: BlockReference): StorageData | undefined { + const noBlockId = !('blockId' in blockRef); + + if (noBlockId) return undefined; + + let blockId = blockRef.blockId; + + // block hash is passed, so get its corresponding block height + if (blockId.toString().length == 44) { + blockId = this.blockHeights.get(blockId.toString()); + } + // get cached values for the given block height + return this.cache.get(blockId); + } + + public save(blockHash: BlockHash, { blockHeight, blockTimestamp, contractCode, contractState }: StorageData) { + this.blockHeights.set(blockHash, blockHeight); + this.cache.set(blockHeight, { blockHeight, blockTimestamp, contractCode, contractState }); + } +} diff --git a/packages/accounts/src/local-view-execution/types.ts b/packages/accounts/src/local-view-execution/types.ts new file mode 100644 index 0000000000..4f7965be6f --- /dev/null +++ b/packages/accounts/src/local-view-execution/types.ts @@ -0,0 +1,5 @@ +/* eslint-disable @typescript-eslint/no-empty-interface */ +export interface ContractState extends Array<{ + key: Buffer; + value: Buffer; +}> { } \ No newline at end of file diff --git a/packages/accounts/test/contract.test.js b/packages/accounts/test/contract.test.js index 21fd61f6b9..43b49121c3 100644 --- a/packages/accounts/test/contract.test.js +++ b/packages/accounts/test/contract.test.js @@ -1,6 +1,7 @@ const { PositionalArgsError } = require('@near-js/types'); const { Contract } = require('../lib'); +const testUtils = require('./test-utils'); const account = { viewFunction({ contractId, methodName, args, parse, stringify, jsContract, blockQuery }) { @@ -100,3 +101,74 @@ describe('changeMethod', () => { }); }); }); + + +describe('local view execution', () => { + let nearjs; + let workingAccount; + let contract; + let blockQuery; + + jest.setTimeout(60000); + + beforeAll(async () => { + nearjs = await testUtils.setUpTestConnection(); + workingAccount = await testUtils.createAccount(nearjs); + contract = await testUtils.deployContractGuestBook(workingAccount, testUtils.generateUniqueString('guestbook')); + + await contract.add_message({ text: 'first message' }); + await contract.add_message({ text: 'second message' }); + + const block = await contract.account.connection.provider.block({ finality: 'optimistic' }); + + contract.account.connection.provider.query = jest.fn(contract.account.connection.provider.query); + blockQuery = { blockId: block.header.height }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('calls total_messages() function using RPC provider', async () => { + const totalMessages = await contract.total_messages({}, { blockQuery }); + + expect(contract.account.connection.provider.query).toHaveBeenCalledWith({ + request_type: 'view_code', + account_id: contract.contractId, + ...blockQuery, + }); + expect(totalMessages).toBe(2); + }); + + test('calls total_messages() function using cache data', async () => { + const totalMessages = await contract.total_messages({}, { blockQuery }); + + expect(contract.account.connection.provider.query).not.toHaveBeenCalled(); + expect(totalMessages).toBe(2); + }); + + test('calls get_messages() function using cache data', async () => { + const messages = await contract.get_messages({}, { blockQuery }); + + expect(contract.account.connection.provider.query).not.toHaveBeenCalled(); + expect(messages.length).toBe(2); + expect(messages[0].text).toEqual('first message'); + expect(messages[1].text).toEqual('second message'); + }); + + test('local execution fails and fallbacks to normal RPC call', async () => { + const _contract = new Contract(contract.account, contract.contractId, { viewMethods: ['get_msg'], useLocalViewExecution: true }); + _contract.account.viewFunction = jest.fn(_contract.account.viewFunction); + + try { + await _contract.get_msg({}, { blockQuery }); + } catch (error) { + expect(_contract.account.viewFunction).toHaveBeenCalledWith({ + contractId: _contract.contractId, + methodName: 'get_msg', + args: {}, + blockQuery, + }); + } + }); +}); \ No newline at end of file diff --git a/packages/accounts/test/lve_runtime.test.js b/packages/accounts/test/lve_runtime.test.js new file mode 100644 index 0000000000..c36aee28fd --- /dev/null +++ b/packages/accounts/test/lve_runtime.test.js @@ -0,0 +1,103 @@ +const { + GUESTBOOK_CONTRACT_ID, + GUESTBOOK_CONTRACT_STATE, + loadGuestBookContractCode, +} = require('./test-utils'); +const { Runtime } = require('../lib/local-view-execution/runtime'); + +let contractCode; +const blockHeight = 1; +const blockTimestamp = Math.floor(Date.now() * 1000000); +const contractState = GUESTBOOK_CONTRACT_STATE; + +const parse = (result) => JSON.parse(Buffer.from(result).toString()); + +const newRuntime = (methodArgs = {}) => { + methodArgs = JSON.stringify(methodArgs); + + return new Runtime({ + contractId: GUESTBOOK_CONTRACT_ID, + contractCode, + contractState, + blockHeight, + blockTimestamp, + methodArgs, + }); +}; + +describe('Local View Execution - Runtime', () => { + beforeAll(async () => { + contractCode = await loadGuestBookContractCode(); + }); + + test('execute total_messages function in WASM runtime', async () => { + const methodName = 'total_messages'; + const methodArgs = {}; + + const runtime = newRuntime(methodArgs); + + const { result } = await runtime.execute(methodName); + + expect(parse(result)).toBe(2); + }); + + test('execute get_messages(0, 1) function in WASM runtime', async () => { + const methodName = 'get_messages'; + const methodArgs = { from_index: 0, limit: 1 }; + + const runtime = newRuntime(methodArgs); + + const { result } = await runtime.execute(methodName); + const expected = contractState[1].value; // '{\'premium\':true,\'sender\':\'dev-1688987398360-47112266275867\',\'text\':\'a message\'}' + + expect(parse(result)).toEqual([parse(expected)]); + }); + + test('executes get_messages(0, 10) function in WASM runtime', async () => { + const methodName = 'get_messages'; + const methodArgs = { from_index: 0, limit: 10 }; + + const runtime = newRuntime(methodArgs); + + const { result } = await runtime.execute(methodName); + + expect(parse(result)).toEqual([ + parse(contractState[1].value), + parse(contractState[2].value), + ]); + }); + + test('executes get_messages(1, 1) function in WASM runtime', async () => { + const methodName = 'get_messages'; + const methodArgs = { from_index: 1, limit: 1 }; + + const runtime = newRuntime(methodArgs); + + const { result } = await runtime.execute(methodName); + + expect(parse(result)).toEqual([parse(contractState[2].value)]); + }); + + test('executes get_messages({}) function with ignored args in WASM runtime', async () => { + const methodName = 'get_messages'; + const methodArgs = { fromInde: 0, Limit: 1 }; + + const runtime = newRuntime(methodArgs); + + const { result } = await runtime.execute(methodName); + + expect(parse(result)).toEqual([ + parse(contractState[1].value), + parse(contractState[2].value), + ]); + }); + + test('throws UnknownContractMethodError on non-existing function from WASM runtime', async () => { + const methodName = 'unknown_method'; + const methodArgs = { from_index: 1, limit: 1 }; + + const runtime = newRuntime(methodArgs); + + await expect(runtime.execute(methodName)).rejects.toThrow(); + }); +}); diff --git a/packages/accounts/test/lve_storage.test.js b/packages/accounts/test/lve_storage.test.js new file mode 100644 index 0000000000..bd455bc41d --- /dev/null +++ b/packages/accounts/test/lve_storage.test.js @@ -0,0 +1,106 @@ +const crypto = require('crypto'); +const { Storage } = require('../lib/local-view-execution/storage'); +const { + GUESTBOOK_CONTRACT_STATE, + loadGuestBookContractCode, +} = require('./test-utils'); + +let contractCode; +const contractState = GUESTBOOK_CONTRACT_STATE; +const blockHash = 'G2DF9Pe4KegQK7PkcxDu5cxakvcy99zgrFZEadRCxrwF'; + +const blockHeight = 1; +const blockTimestamp = Math.floor(Date.now() * 1000000); + +const createBlockHash = (data) => + crypto.createHash('sha256').update(JSON.stringify(data)).digest('base64'); + +describe('Local View Execution - Storage', () => { + beforeAll(async () => { + contractCode = await loadGuestBookContractCode(); + }); + + test('load empty cached data', async () => { + const storage = new Storage(); + + const data = storage.load({}); + + expect(data).toBe(undefined); + }); + + test('load empty cached data by block hash', async () => { + const storage = new Storage(); + + const data = storage.load({ blockId: blockHash }); + + expect(data).toBe(undefined); + }); + + test('load empty cached data by block height', async () => { + const storage = new Storage(); + + const data = storage.load({ blockId: blockHeight }); + + expect(data).toBe(undefined); + }); + + test('save & load cached data by block height', async () => { + const storage = new Storage(); + const data = { + blockHeight, + blockTimestamp, + contractCode, + contractState, + }; + + storage.save(blockHash, data); + + const stored = storage.load({ blockId: blockHeight }); + + expect(stored).toEqual(data); + }); + + test('save & load cached data by block hash', async () => { + const storage = new Storage(); + const data = { + blockHeight, + blockTimestamp, + contractCode, + contractState, + }; + + storage.save(blockHash, data); + + const stored = storage.load({ blockId: blockHash }); + + expect(stored).toEqual(data); + }); + + test('overwrite the less-recently used value', async () => { + const storage = new Storage({ max: 2 }); + + const data = { + blockHeight, + blockTimestamp, + contractCode, + contractState, + }; + + const firstData = { ...data, blockHeight: 0 }; + const secondData = { ...data, blockHeight: 1 }; + const newData = { ...data, blockHeight: 2 }; + + storage.save(createBlockHash(firstData), firstData); + storage.save(createBlockHash(secondData), secondData); + + // the less-recently used value + expect(storage.load({ blockId: 0 })).toEqual(firstData); + expect(storage.load({ blockId: 1 })).toEqual(secondData); + + storage.save(createBlockHash(newData), newData); + + expect(storage.load({ blockId: 0 })).toBe(undefined); + expect(storage.load({ blockId: 1 })).toEqual(secondData); + expect(storage.load({ blockId: 2 })).toEqual(newData); + }); +}); diff --git a/packages/accounts/test/test-utils.js b/packages/accounts/test/test-utils.js index 7875a4b17a..14bbf577d4 100644 --- a/packages/accounts/test/test-utils.js +++ b/packages/accounts/test/test-utils.js @@ -2,6 +2,7 @@ const { KeyPair } = require('@near-js/crypto'); const { InMemoryKeyStore } = require('@near-js/keystores'); const BN = require('bn.js'); const fs = require('fs').promises; +const path = require('path'); const { Account, AccountMultisig, Contract, Connection, LocalAccountCreator } = require('../lib'); @@ -18,6 +19,36 @@ const MULTISIG_WASM_PATH = process.env.MULTISIG_WASM_PATH || './test/wasm/multis // least 32. const RANDOM_ACCOUNT_LENGTH = 40; +const GUESTBOOK_CONTRACT_ID = 'guestbook-1690363526419-7138950000000000'; +const GUESTBOOK_WASM_PATH = path.resolve(__dirname, './wasm/guestbook.wasm'); +const GUESTBOOK_CONTRACT_STATE = [ + { + key: Buffer.from('U1RBVEU=', 'base64'), + value: Buffer.from( + 'eyJtZXNzYWdlcyI6eyJwcmVmaXgiOiJ2LXVpZCIsImxlbmd0aCI6Mn19', + 'base64' + ), + }, + { + key: Buffer.from('di11aWQAAAAA', 'base64'), + value: Buffer.from( + 'eyJwcmVtaXVtIjp0cnVlLCJzZW5kZXIiOiJkZXYtMTY4ODk4NzM5ODM2MC00NzExMjI2NjI3NTg2NyIsInRleHQiOiJhIG1lc3NhZ2UifQ==', + 'base64' + ), + }, + { + key: Buffer.from('di11aWQBAAAA', 'base64'), + value: Buffer.from( + 'eyJwcmVtaXVtIjp0cnVlLCJzZW5kZXIiOiJkZXYtMTY4ODk4NzM5ODM2MC00NzExMjI2NjI3NTg2NyIsInRleHQiOiJzZWNvbmQgbWVzc2FnZSJ9', + 'base64' + ), + }, +]; + +async function loadGuestBookContractCode() { + const contractCode = await fs.readFile(GUESTBOOK_WASM_PATH); + return contractCode.toString('base64'); +} async function setUpTestConnection() { const keyStore = new InMemoryKeyStore(); const config = Object.assign(require('./config')(process.env.NODE_ENV || 'test'), { @@ -90,6 +121,13 @@ async function deployContract(workingAccount, contractId) { return new Contract(workingAccount, contractId, HELLO_WASM_METHODS); } +async function deployContractGuestBook(workingAccount, contractId) { + const newPublicKey = await workingAccount.connection.signer.createKey(contractId, networkId); + const data = [...(await fs.readFile(GUESTBOOK_WASM_PATH))]; + const account = await workingAccount.createAndDeployContract(contractId, newPublicKey, data, HELLO_WASM_BALANCE); + return new Contract(account, contractId, { viewMethods: ['total_messages', 'get_messages'], changeMethods: ['add_message'], useLocalViewExecution: true }); +} + function sleep(time) { return new Promise(function (resolve) { setTimeout(resolve, time); @@ -121,6 +159,11 @@ module.exports = { deployContract, HELLO_WASM_PATH, HELLO_WASM_BALANCE, + loadGuestBookContractCode, + deployContractGuestBook, + GUESTBOOK_CONTRACT_ID, + GUESTBOOK_CONTRACT_STATE, + GUESTBOOK_WASM_PATH, sleep, waitFor, }; diff --git a/packages/accounts/test/wasm/guestbook.wasm b/packages/accounts/test/wasm/guestbook.wasm new file mode 100755 index 0000000000..5bfbdbb7b8 Binary files /dev/null and b/packages/accounts/test/wasm/guestbook.wasm differ diff --git a/packages/accounts/tsconfig.json b/packages/accounts/tsconfig.json index fdc99be318..2081c160d8 100644 --- a/packages/accounts/tsconfig.json +++ b/packages/accounts/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "preserveSymlinks": false, "outDir": "./lib", + "lib": ["es2021", "DOM"] }, "files": [ "src/index.ts" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cf0c8ed04..9f0204836b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,5 @@ lockfileVersion: '6.0' -settings: - autoInstallPeers: false - excludeLinksFromLockfile: false - importers: .: @@ -80,6 +76,9 @@ importers: depd: specifier: ^2.0.0 version: 2.0.0 + lru_map: + specifier: ^0.4.1 + version: 0.4.1 near-abi: specifier: 0.1.1 version: 0.1.1 @@ -95,7 +94,7 @@ importers: version: 4.0.0 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) near-hello: specifier: ^0.5.1 version: 0.5.1 @@ -199,7 +198,7 @@ importers: version: 18.16.1 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) ts-jest: specifier: ^26.5.6 version: 26.5.6(jest@26.0.1)(typescript@4.9.4) @@ -231,7 +230,7 @@ importers: version: 18.16.1 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) ts-jest: specifier: ^26.5.6 version: 26.5.6(jest@26.0.1)(typescript@4.9.4) @@ -250,7 +249,7 @@ importers: devDependencies: jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) ts-jest: specifier: ^26.5.6 version: 26.5.6(jest@26.0.1)(typescript@4.9.4) @@ -272,7 +271,7 @@ importers: version: 18.16.1 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) ts-jest: specifier: ^26.5.6 version: 26.5.6(jest@26.0.1)(typescript@4.9.4) @@ -378,7 +377,7 @@ importers: version: 2.0.0 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) localstorage-memory: specifier: ^1.0.3 version: 1.0.3 @@ -428,7 +427,7 @@ importers: version: 18.16.1 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) ts-jest: specifier: ^26.5.6 version: 26.5.6(jest@26.0.1)(typescript@4.9.4) @@ -453,7 +452,7 @@ importers: version: 18.16.1 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) ts-jest: specifier: ^26.5.6 version: 26.5.6(jest@26.0.1)(typescript@4.9.4) @@ -493,7 +492,7 @@ importers: version: 18.16.1 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) ts-jest: specifier: ^26.5.6 version: 26.5.6(jest@26.0.1)(typescript@4.9.4) @@ -512,7 +511,7 @@ importers: version: 18.16.1 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) ts-jest: specifier: ^26.5.6 version: 26.5.6(jest@26.0.1)(typescript@4.9.4) @@ -543,7 +542,7 @@ importers: version: 18.16.1 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) ts-jest: specifier: ^26.5.6 version: 26.5.6(jest@26.0.1)(typescript@4.9.4) @@ -586,7 +585,7 @@ importers: version: 18.16.1 jest: specifier: ^26.0.1 - version: 26.0.1 + version: 26.0.1(ts-node@10.9.1) localstorage-memory: specifier: ^1.0.3 version: 1.0.3 @@ -1468,7 +1467,7 @@ packages: slash: 3.0.0 dev: true - /@jest/core@26.6.3: + /@jest/core@26.6.3(ts-node@10.9.1): resolution: {integrity: sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw==} engines: {node: '>= 10.14.2'} dependencies: @@ -1483,14 +1482,14 @@ packages: exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 26.6.2 - jest-config: 26.6.3 + jest-config: 26.6.3(ts-node@10.9.1) jest-haste-map: 26.6.2 jest-message-util: 26.6.2 jest-regex-util: 26.0.0 jest-resolve: 26.6.2 jest-resolve-dependencies: 26.6.3 - jest-runner: 26.6.3 - jest-runtime: 26.6.3 + jest-runner: 26.6.3(ts-node@10.9.1) + jest-runtime: 26.6.3(ts-node@10.9.1) jest-snapshot: 26.6.2 jest-util: 26.6.2 jest-validate: 26.6.2 @@ -1592,15 +1591,15 @@ packages: collect-v8-coverage: 1.0.2 dev: true - /@jest/test-sequencer@26.6.3: + /@jest/test-sequencer@26.6.3(ts-node@10.9.1): resolution: {integrity: sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw==} engines: {node: '>= 10.14.2'} dependencies: '@jest/test-result': 26.6.2 graceful-fs: 4.2.11 jest-haste-map: 26.6.2 - jest-runner: 26.6.3 - jest-runtime: 26.6.3 + jest-runner: 26.6.3(ts-node@10.9.1) + jest-runtime: 26.6.3(ts-node@10.9.1) transitivePeerDependencies: - bufferutil - canvas @@ -5238,12 +5237,12 @@ packages: throat: 5.0.0 dev: true - /jest-cli@26.6.3: + /jest-cli@26.6.3(ts-node@10.9.1): resolution: {integrity: sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg==} engines: {node: '>= 10.14.2'} hasBin: true dependencies: - '@jest/core': 26.6.3 + '@jest/core': 26.6.3(ts-node@10.9.1) '@jest/test-result': 26.6.2 '@jest/types': 26.6.2 chalk: 4.1.2 @@ -5251,7 +5250,7 @@ packages: graceful-fs: 4.2.11 import-local: 3.1.0 is-ci: 2.0.0 - jest-config: 26.6.3 + jest-config: 26.6.3(ts-node@10.9.1) jest-util: 26.6.2 jest-validate: 26.6.2 prompts: 2.4.2 @@ -5264,7 +5263,7 @@ packages: - utf-8-validate dev: true - /jest-config@26.6.3: + /jest-config@26.6.3(ts-node@10.9.1): resolution: {integrity: sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg==} engines: {node: '>= 10.14.2'} peerDependencies: @@ -5274,7 +5273,7 @@ packages: optional: true dependencies: '@babel/core': 7.22.9 - '@jest/test-sequencer': 26.6.3 + '@jest/test-sequencer': 26.6.3(ts-node@10.9.1) '@jest/types': 26.6.2 babel-jest: 26.6.3(@babel/core@7.22.9) chalk: 4.1.2 @@ -5284,13 +5283,14 @@ packages: jest-environment-jsdom: 26.6.2 jest-environment-node: 26.6.2 jest-get-type: 26.3.0 - jest-jasmine2: 26.6.3 + jest-jasmine2: 26.6.3(ts-node@10.9.1) jest-regex-util: 26.0.0 jest-resolve: 26.6.2 jest-util: 26.6.2 jest-validate: 26.6.2 micromatch: 4.0.5 pretty-format: 26.6.2 + ts-node: 10.9.1(@types/node@18.17.2)(typescript@4.9.4) transitivePeerDependencies: - bufferutil - canvas @@ -5384,7 +5384,7 @@ packages: - supports-color dev: true - /jest-jasmine2@26.6.3: + /jest-jasmine2@26.6.3(ts-node@10.9.1): resolution: {integrity: sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg==} engines: {node: '>= 10.14.2'} dependencies: @@ -5401,7 +5401,7 @@ packages: jest-each: 26.6.2 jest-matcher-utils: 26.6.2 jest-message-util: 26.6.2 - jest-runtime: 26.6.3 + jest-runtime: 26.6.3(ts-node@10.9.1) jest-snapshot: 26.6.2 jest-util: 26.6.2 pretty-format: 26.6.2 @@ -5497,7 +5497,7 @@ packages: slash: 3.0.0 dev: true - /jest-runner@26.6.3: + /jest-runner@26.6.3(ts-node@10.9.1): resolution: {integrity: sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ==} engines: {node: '>= 10.14.2'} dependencies: @@ -5510,13 +5510,13 @@ packages: emittery: 0.7.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 26.6.3 + jest-config: 26.6.3(ts-node@10.9.1) jest-docblock: 26.0.0 jest-haste-map: 26.6.2 jest-leak-detector: 26.6.2 jest-message-util: 26.6.2 jest-resolve: 26.6.2 - jest-runtime: 26.6.3 + jest-runtime: 26.6.3(ts-node@10.9.1) jest-util: 26.6.2 jest-worker: 26.6.2 source-map-support: 0.5.21 @@ -5529,7 +5529,7 @@ packages: - utf-8-validate dev: true - /jest-runtime@26.6.3: + /jest-runtime@26.6.3(ts-node@10.9.1): resolution: {integrity: sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw==} engines: {node: '>= 10.14.2'} hasBin: true @@ -5549,7 +5549,7 @@ packages: exit: 0.1.2 glob: 7.2.3 graceful-fs: 4.2.11 - jest-config: 26.6.3 + jest-config: 26.6.3(ts-node@10.9.1) jest-haste-map: 26.6.2 jest-message-util: 26.6.2 jest-mock: 26.6.2 @@ -5647,14 +5647,14 @@ packages: supports-color: 7.2.0 dev: true - /jest@26.0.1: + /jest@26.0.1(ts-node@10.9.1): resolution: {integrity: sha512-29Q54kn5Bm7ZGKIuH2JRmnKl85YRigp0o0asTc6Sb6l2ch1DCXIeZTLLFy9ultJvhkTqbswF5DEx4+RlkmCxWg==} engines: {node: '>= 10.14.2'} hasBin: true dependencies: - '@jest/core': 26.6.3 + '@jest/core': 26.6.3(ts-node@10.9.1) import-local: 3.1.0 - jest-cli: 26.6.3 + jest-cli: 26.6.3(ts-node@10.9.1) transitivePeerDependencies: - bufferutil - canvas @@ -6050,6 +6050,10 @@ packages: yallist: 4.0.0 dev: true + /lru_map@0.4.1: + resolution: {integrity: sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==} + dev: false + /make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -7857,7 +7861,7 @@ packages: bs-logger: 0.2.6 buffer-from: 1.1.2 fast-json-stable-stringify: 2.1.0 - jest: 26.0.1 + jest: 26.0.1(ts-node@10.9.1) jest-util: 26.6.2 json5: 2.2.3 lodash: 4.17.21 @@ -8611,3 +8615,7 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false