/* This is free and unencumbered software released into the public domain. */ import { AccountID, Address } from './account.js'; import { BlockHash, BlockHeight, BlockID, BlockOptions, BlockProxy, parseBlockID, } from './block.js'; import { NETWORKS } from './config.js'; import { KeyStore } from './key_store.js'; import { Err, Ok, Quantity, Result, U256 } from './prelude.js'; import { SubmitResult, FunctionCallArgs, GetStorageAtArgs, InitCallArgs, NewCallArgs, ViewCallArgs, FungibleTokenMetadata, TransactionStatus, OutOfGas, GasBurned, WrappedSubmitResult, } from './schema.js'; import { TransactionID } from './transaction.js'; import { base58ToBytes } from './utils.js'; import { defaultAbiCoder } from '@ethersproject/abi'; import { arrayify as parseHexString } from '@ethersproject/bytes'; import { parse as parseRawTransaction } from '@ethersproject/transactions'; import { toBigIntBE, toBufferBE } from 'bigint-buffer'; import { Buffer } from 'buffer'; import BN from 'bn.js'; import * as NEAR from 'near-api-js'; import { ResErr } from '@hqoss/monads/dist/lib/result/result'; export { getAddress as parseAddress } from '@ethersproject/address'; export { arrayify as parseHexString } from '@ethersproject/bytes'; export type Bytecode = Uint8Array; export type Bytecodeish = Bytecode | string; export type ChainID = bigint; export type Error = string; export interface TransactionOutcome { id: TransactionID; output: Uint8Array; gasBurned?: GasBurned; tx?: string; } export interface BlockInfo { hash: BlockHash; coinbase: Address; timestamp: number; number: BlockHeight; difficulty: number; gasLimit: Quantity; } export interface ViewOptions { block?: BlockID; } export interface ConnectOptions { network?: string; // network ID endpoint?: string; // endpoint URL contract?: string; // engine ID signer?: string; // signer ID } export interface ConnectEnv { AURORA_ENGINE?: string; // engine ID HOME?: string; // home directory NEAR_ENV?: string; // network ID NEAR_MASTER_ACCOUNT?: string; // signer ID NEAR_URL?: string; // endpoint URL } export type AddressStorage = Map<U256, U256>; export class AddressState { constructor( public address: Address, public nonce: U256 = BigInt(0), public balance: Quantity = BigInt(0), public code?: Bytecode, public storage: AddressStorage = new Map() ) {} } export const enum EngineStorageKeyPrefix { Config = 0x0, Nonce = 0x1, Balance = 0x2, Code = 0x3, Storage = 0x4, } export type EngineStorage = Map<Address, AddressState>; export class EngineState { constructor(public storage: EngineStorage = new Map()) {} } export interface TransactionErrorDetails { tx?: string; gasBurned?: GasBurned; } const DEFAULT_NETWORK_ID = 'local'; export class Engine { protected constructor( public readonly near: NEAR.Near, public readonly keyStore: KeyStore, public readonly signer: NEAR.Account, public readonly networkID: string, public readonly contractID: AccountID ) {} static async connect( options: ConnectOptions, env?: ConnectEnv ): Promise<Engine> { const networkID = options.network || (env && env.NEAR_ENV) || DEFAULT_NETWORK_ID; const network = NETWORKS.get(networkID)!; // TODO: error handling const contractID = AccountID.parse( options.contract || (env && env.AURORA_ENGINE) || network.contractID ).unwrap(); const signerID = AccountID.parse( options.signer || (env && env.NEAR_MASTER_ACCOUNT) ).unwrap(); // TODO: error handling const keyStore = KeyStore.load(networkID, env); const near = new NEAR.Near({ deps: { keyStore }, networkId: networkID, nodeUrl: options.endpoint || (env && env.NEAR_URL) || network.nearEndpoint, }); const signer = await near.account(signerID.toString()); return new Engine(near, keyStore, signer, networkID, contractID); } async install(contractCode: Bytecode): Promise<Result<TransactionID, Error>> { const contractAccount = (await this.getAccount()).unwrap(); const result = await contractAccount.deployContract(contractCode); return Ok(TransactionID.fromHex(result.transaction.hash)); } async upgrade(contractCode: Bytecode): Promise<Result<TransactionID, Error>> { return await this.install(contractCode); } async initialize(options: any): Promise<Result<TransactionID, Error>> { const newArgs = new NewCallArgs( parseHexString(defaultAbiCoder.encode(['uint256'], [options.chain || 0])), options.owner || '', options.bridgeProver || '', new BN(options.upgradeDelay || 0) ); const default_ft_metadata = FungibleTokenMetadata.default(); const given_ft_metadata = options.metadata || default_ft_metadata; const ft_metadata = new FungibleTokenMetadata( given_ft_metadata.spec || default_ft_metadata.spec, given_ft_metadata.name || default_ft_metadata.name, given_ft_metadata.symbol || default_ft_metadata.symbol, given_ft_metadata.icon || default_ft_metadata.icon, given_ft_metadata.reference || default_ft_metadata.reference, given_ft_metadata.reference_hash || default_ft_metadata.reference_hash, given_ft_metadata.decimals || default_ft_metadata.decimals ); // default values are the testnet values const connectorArgs = new InitCallArgs( options.prover || 'prover.ropsten.testnet', options.ethCustodian || '9006a6D7d08A388Eeea0112cc1b6b6B15a4289AF', ft_metadata ); // TODO: this should be able to be a single transaction with multiple actions, // but there doesn't seem to be a good way to do that in `near-api-js` presently. const tx = await this.promiseAndThen( this.callMutativeFunction('new', newArgs.encode()), (_) => this.callMutativeFunction('new_eth_connector', connectorArgs.encode()) ); return tx.map(({ id }) => id); } // Like Result.andThen, but wrapped up in Promises private async promiseAndThen<T, U, E>( p: Promise<Result<T, E>>, f: (x: T) => Promise<Result<U, E>> ): Promise<Result<U, E>> { const r = await p; if (r.isOk()) { const t = r.unwrap(); return await f(t); } else { return Err(r.unwrapErr()); } } async getAccount(): Promise<Result<NEAR.Account, Error>> { return Ok(await this.near.account(this.contractID.toString())); } async getBlockHash(): Promise<Result<BlockHash, Error>> { const contractAccount = (await this.getAccount()).unwrap(); const state = (await contractAccount.state()) as any; return Ok(state.block_hash); } async getBlockHeight(): Promise<Result<BlockHeight, Error>> { const contractAccount = (await this.getAccount()).unwrap(); const state = (await contractAccount.state()) as any; return Ok(state.block_height); } async getBlockInfo(): Promise<Result<BlockInfo, Error>> { return Ok({ hash: '', // TODO coinbase: Address.zero(), // TODO timestamp: 0, number: 0, difficulty: 0, gasLimit: 0, }); } async getBlockTransactionCount( blockID: BlockID ): Promise<Result<number, Error>> { try { const provider = this.near.connection.provider; // eslint-disable-next-line @typescript-eslint/no-explicit-any const block = (await provider.block(parseBlockID(blockID))) as any; const chunk_mask: boolean[] = block.header.chunk_mask; const requests = (block.chunks as any[]) .filter((_: any, index) => chunk_mask[index]) .map(async (chunkHeader: any) => { if (chunkHeader.tx_root == '11111111111111111111111111111111') { return 0; // no transactions in this chunk } else { const chunk = await provider.chunk(chunkHeader.chunk_hash); return chunk.transactions.length; } }); const counts = (await Promise.all(requests)) as number[]; return Ok(counts.reduce((a, b) => a + b, 0)); } catch (error: any) { //console.error('Engine#getBlockTransactionCount', error); return Err(error.message); } } async getBlock( blockID: BlockID, options?: BlockOptions ): Promise<Result<BlockProxy, Error>> { const provider = this.near.connection.provider; return await BlockProxy.fetch(provider, blockID, options); } async hasBlock(blockID: BlockID): Promise<Result<boolean, Error>> { const provider = this.near.connection.provider; return await BlockProxy.lookup(provider, blockID); } async getCoinbase(): Promise<Result<Address, Error>> { return Ok(Address.zero()); // TODO } async getVersion(options?: ViewOptions): Promise<Result<string, Error>> { return ( await this.callFunction('get_version', undefined, options) ).map((output) => output.toString()); } async getOwner(options?: ViewOptions): Promise<Result<AccountID, Error>> { return ( await this.callFunction('get_owner', undefined, options) ).andThen((output) => AccountID.parse(output.toString())); } async getBridgeProvider( options?: ViewOptions ): Promise<Result<AccountID, Error>> { return ( await this.callFunction('get_bridge_provider', undefined, options) ).andThen((output) => AccountID.parse(output.toString())); } async getChainID(options?: ViewOptions): Promise<Result<ChainID, Error>> { const result = await this.callFunction('get_chain_id', undefined, options); return result.map(toBigIntBE); } // TODO: getUpgradeIndex() // TODO: stageUpgrade() // TODO: deployUpgrade() async deployCode(bytecode: Bytecodeish): Promise<Result<Address, Error>> { const args = parseHexString(bytecode); const outcome = await this.callMutativeFunction('deploy_code', args); return outcome.map(({ output }) => { const result = SubmitResult.decode(Buffer.from(output)); return Address.parse( Buffer.from(result.output().unwrap()).toString('hex') ).unwrap(); }); } async call( contract: Address, input: Uint8Array | string ): Promise<Result<Uint8Array, Error>> { const args = new FunctionCallArgs( contract.toBytes(), this.prepareInput(input) ); return (await this.callMutativeFunction('call', args.encode())).map( ({ output }) => output ); } async submit( input: Uint8Array | string ): Promise<Result<WrappedSubmitResult, Error>> { try { const inputBytes = this.prepareInput(input); try { const rawTransaction = parseRawTransaction(inputBytes); // throws Error if (rawTransaction.gasLimit.toBigInt() < 21000n) { // See: https://github.com/aurora-is-near/aurora-relayer/issues/17 return Err('ERR_INTRINSIC_GAS'); } } catch (error) { //console.error(error); // DEBUG return Err('ERR_INVALID_TX'); } return (await this.callMutativeFunction('submit', inputBytes)).map( ({ output, gasBurned, tx }) => { return new WrappedSubmitResult( SubmitResult.decode(Buffer.from(output)), gasBurned, tx ); } ); } catch (error) { //console.error(error); // DEBUG return Err(error.message); } } // TODO: metaCall() async view( sender: Address, address: Address, amount: Quantity, input: Uint8Array | string, options?: ViewOptions ): Promise<Result<Uint8Array | ResErr<unknown, OutOfGas>, Error>> { const args = new ViewCallArgs( sender.toBytes(), address.toBytes(), toBufferBE(BigInt(amount), 32), this.prepareInput(input) ); const result = await this.callFunction('view', args.encode(), options); return result.map((output) => { const status = TransactionStatus.decode(output); if (status.success !== undefined) return status.success.output as Uint8Array; else if (status.revert !== undefined) return status.revert.output as Uint8Array; else if (status.outOfGas !== undefined) return Err(status.outOfGas); else if (status.outOfFund !== undefined) return Err(status.outOfFund); else if (status.outOfOffset !== undefined) return Err(status.outOfOffset); else if (status.callTooDeep !== undefined) return Err(status.callTooDeep); else return Err('Failed to retrieve data from the contract'); }); } async getCode( address: Address, options?: ViewOptions ): Promise<Result<Bytecode, Error>> { const args = address.toBytes(); if (typeof options === 'object' && options.block) { options.block = ((options.block as number) + 1) as BlockID; } return await this.callFunction('get_code', args, options); } async getBalance( address: Address, options?: ViewOptions ): Promise<Result<U256, Error>> { const args = address.toBytes(); const result = await this.callFunction('get_balance', args, options); return result.map(toBigIntBE); } async getNonce( address: Address, options?: ViewOptions ): Promise<Result<U256, Error>> { const args = address.toBytes(); const result = await this.callFunction('get_nonce', args, options); return result.map(toBigIntBE); } async getStorageAt( address: Address, key: U256 | number | string, options?: ViewOptions ): Promise<Result<U256, Error>> { const args = new GetStorageAtArgs( address.toBytes(), parseHexString(defaultAbiCoder.encode(['uint256'], [key])) ); const result = await this.callFunction( 'get_storage_at', args.encode(), options ); return result.map(toBigIntBE); } async getAuroraErc20Address( nep141: AccountID, options?: ViewOptions ): Promise<Result<Address, Error>> { const args = Buffer.from(nep141.id, 'utf-8'); const result = await this.callFunction( 'get_erc20_from_nep141', args, options ); return result.map((output) => { return Address.parse(output.toString('hex')).unwrap(); }); } async getNEP141Account( erc20: Address, options?: ViewOptions ): Promise<Result<AccountID, Error>> { const args = erc20.toBytes(); const result = await this.callFunction( 'get_nep141_from_erc20', args, options ); return result.map((output) => { return AccountID.parse(output.toString('utf-8')).unwrap(); }); } // TODO: beginChain() // TODO: beginBlock() async getStorage(): Promise<Result<EngineStorage, Error>> { const result = new Map(); const contractAccount = (await this.getAccount()).unwrap(); const records = await contractAccount.viewState('', { finality: 'final' }); for (const record of records) { const record_type = record.key[0]; if (record_type == EngineStorageKeyPrefix.Config) continue; // skip EVM metadata const key = record_type == EngineStorageKeyPrefix.Storage ? record.key.subarray(1, 21) : record.key.subarray(1); const address = Buffer.from(key).toString('hex'); if (!result.has(address)) { result.set(address, new AddressState(Address.parse(address).unwrap())); } const state = result.get(address)!; switch (record_type) { case EngineStorageKeyPrefix.Config: break; // unreachable case EngineStorageKeyPrefix.Nonce: state.nonce = toBigIntBE(record.value); break; case EngineStorageKeyPrefix.Balance: state.balance = toBigIntBE(record.value); break; case EngineStorageKeyPrefix.Code: state.code = record.value; break; case EngineStorageKeyPrefix.Storage: { state.storage.set( toBigIntBE(record.key.subarray(21)), toBigIntBE(record.value) ); break; } } } return Ok(result); } protected async callFunction( methodName: string, args?: Uint8Array, options?: ViewOptions ): Promise<Result<Buffer, Error>> { let err; for (let i = 0, retries = 3; i < retries; i++) { try { const result = await this.signer.connection.provider.query({ request_type: 'call_function', account_id: this.contractID.toString(), method_name: methodName, args_base64: this.prepareInput(args).toString('base64'), finality: options?.block === undefined || options?.block === null ? 'final' : undefined, block_id: options?.block !== undefined && options?.block !== null ? options.block : undefined, }); if (result.logs && result.logs.length > 0) console.debug(result.logs); // TODO return Ok(Buffer.from(result.result)); } catch (error: any) { if ( typeof options === 'object' && options.block && error.message.startsWith( '[-32000] Server error: DB Not Found Error: BLOCK HEIGHT' ) ) { options.block = ((options.block as number) + 1) as BlockID; err = error.message; } else { throw error; } } } throw err; } protected async callMutativeFunction( methodName: string, args?: Uint8Array ): Promise<Result<TransactionOutcome, Error>> { this.keyStore.reKey(); const gas = new BN('300000000000000'); // TODO? try { const result = await this.signer.functionCall( this.contractID.toString(), methodName, this.prepareInput(args), gas ); if ( typeof result.status === 'object' && typeof result.status.SuccessValue === 'string' ) { const transactionId = result?.transaction_outcome?.id; return Ok({ id: TransactionID.fromHex(result.transaction.hash), output: Buffer.from(result.status.SuccessValue, 'base64'), tx: transactionId, gasBurned: await this.transactionGasBurned(transactionId), }); } return Err(result.toString()); // FIXME: unreachable? } catch (error) { //assert(error instanceof ServerTransactionError); switch (error?.type) { case 'FunctionCallError': { const transactionId = error?.transaction_outcome?.id; const details: TransactionErrorDetails = { tx: transactionId, gasBurned: await this.transactionGasBurned(transactionId), }; const errorKind = error?.kind?.ExecutionError; if (errorKind) { const errorCode = errorKind.replace( 'Smart contract panicked: ', '' ); return Err(this.errorWithDetails(errorCode, details)); } return Err(this.errorWithDetails(error.message, details)); } case 'MethodNotFound': return Err(error.message); default: console.debug(error); return Err(error.toString()); } } } private prepareInput(args?: Uint8Array | string): Buffer { if (typeof args === 'undefined') return Buffer.alloc(0); if (typeof args === 'string') return Buffer.from(parseHexString(args as string)); return Buffer.from(args); } private errorWithDetails( message: string, details: TransactionErrorDetails ): string { return `${message}|${JSON.stringify(details)}`; } private async transactionGasBurned(id: string): Promise<GasBurned> { try { const transactionStatus = await this.near.connection.provider.txStatus( base58ToBytes(id), this.contractID.toString() ); const receiptsGasBurned = transactionStatus.receipts_outcome.reduce( (sum, value) => sum + value.outcome.gas_burnt, 0 ); const transactionGasBurned = transactionStatus.transaction_outcome.outcome.gas_burnt || 0; return receiptsGasBurned + transactionGasBurned; } catch (error) { return 0; } } }