diff --git a/.changeset/slimy-baboons-itch.md b/.changeset/slimy-baboons-itch.md new file mode 100644 index 0000000000..af284ca8ec --- /dev/null +++ b/.changeset/slimy-baboons-itch.md @@ -0,0 +1,19 @@ +--- +"near-api-js": major +"@near-js/accounts": patch +"@near-js/crypto": patch +"@near-js/keystores": patch +"@near-js/keystores-browser": patch +"@near-js/keystores-node": patch +"@near-js/providers": patch +"@near-js/signers": patch +"@near-js/transactions": patch +"@near-js/types": patch +"@near-js/utils": patch +"@near-js/wallet-account": patch +--- + +Major functionality in near-api-js has now been broken up into packages under @near-js + +Breaking changes: + - `KeyPairEd25519` no longer supports the `fromString` static method. This method is now only available on the `KeyPair` class. diff --git a/.eslintrc.yml b/.eslintrc.base.yml similarity index 89% rename from .eslintrc.yml rename to .eslintrc.base.yml index e59578dd9f..e1b41fd64b 100644 --- a/.eslintrc.yml +++ b/.eslintrc.base.yml @@ -5,10 +5,12 @@ extends: - 'eslint:recommended' parserOptions: ecmaVersion: 2018 + sourceType: module rules: indent: - error - 4 + - SwitchCase: 1 linebreak-style: - error - unix diff --git a/.eslintrc.js.yml b/.eslintrc.js.yml new file mode 100644 index 0000000000..54abdec6e0 --- /dev/null +++ b/.eslintrc.js.yml @@ -0,0 +1,7 @@ +extends: './.eslintrc.base.yml' +env: + jest: true +globals: + jasmine: true + window: false + fail: true diff --git a/.eslintrc.ts.yml b/.eslintrc.ts.yml new file mode 100644 index 0000000000..d68c7ff9ac --- /dev/null +++ b/.eslintrc.ts.yml @@ -0,0 +1,5 @@ +extends: + - './.eslintrc.base.yml' + - 'plugin:@typescript-eslint/eslint-recommended' + - 'plugin:@typescript-eslint/recommended' +parser: '@typescript-eslint/parser' diff --git a/README.md b/README.md index a49eec7f67..4ab50a2eac 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,20 @@ Follow next steps: 2. Fetch new schema: `node fetch_error_schema.js` 3. `pnpm build` to update `lib/**.js` files + +## Packages + +- [accounts](https://github.com/near/near-api-js/tree/master/packages/accounts) account creation & management +- [keypairs](https://github.com/near/near-api-js/tree/master/packages/keypairs) cryptographic key pairs & signing +- [keystores](https://github.com/near/near-api-js/tree/master/packages/keystores) general-purpose key persistence & management +- [keystores-browser](https://github.com/near/near-api-js/tree/master/packages/keystores-browser) browser keystores +- [keystores-node](https://github.com/near/near-api-js/tree/master/packages/keystores-node) NodeJS keystores +- [providers](https://github.com/near/near-api-js/tree/master/packages/providers) RPC interaction +- [transactions](https://github.com/near/near-api-js/tree/master/packages/transactions) transaction composition & signing +- [types](https://github.com/near/near-api-js/tree/master/packages/types) common types +- [utils](https://github.com/near/near-api-js/tree/master/packages/types) common methods +- [wallet-account](https://github.com/near/near-api-js/tree/master/packages/wallet-account) accounts in browser-based wallets + ## License This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). diff --git a/package.json b/package.json index 10dc0e3b21..6b168a4845 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preinstall": "npx only-allow pnpm", "build": "turbo run build", "clean": "turbo run clean", - "lint": "turbo run lint", + "lint": "concurrently \"turbo run lint:ts\" \"turbo run lint:js\"", + "lint:fix": "concurrently \"turbo run lint:ts:fix\" \"turbo run lint:js:fix\"", + "autoclave": "concurrently \"rimraf packages/**/dist\" \"rimraf packages/**/lib\" \"rimraf packages/**/node_modules\" \"rimraf packages/**/coverage\" \"rimraf packages/**/.turbo\" && rm -rf node_modules", "test": "turbo run test", "release": "changeset publish", "prepare": "husky install" @@ -19,13 +21,14 @@ "@changesets/cli": "^2.24.4", "@commitlint/cli": "^17.0.3", "@commitlint/config-conventional": "^17.0.3", - "typescript": "^4.7.4", "@typescript-eslint/eslint-plugin": "^5.31.0", "@typescript-eslint/parser": "^5.31.0", "commitlint": "^17.0.3", - "eslint": "^8.20.0", + "concurrently": "^7.6.0", + "eslint": "^8.32.0", "husky": "^7.0.4", "rimraf": "^3.0.2", - "turbo": "^1.4.5" + "turbo": "^1.4.5", + "typescript": "^4.9.4" } } diff --git a/packages/accounts/README.md b/packages/accounts/README.md new file mode 100644 index 0000000000..45153e405b --- /dev/null +++ b/packages/accounts/README.md @@ -0,0 +1,19 @@ +# @near-js/accounts + +A collection of classes, functions, and types for interacting with accounts and contracts. + +## Modules + +- [Account](src/account.ts) a class with methods to transfer NEAR, manage account keys, sign transactions, etc. +- [AccountMultisig](src/account_multisig.ts) a [multisig](https://github.com/near/core-contracts/tree/master/multisig) deployed `Account` requiring multiple keys to sign transactions +- [Account2FA](src/account_2fa.ts) extension of `AccountMultisig` used in conjunction with 2FA provided by [near-contract-helper](https://github.com/near/near-contract-helper) +- [AccountCreator](src/account_creator.ts) classes for creating NEAR accounts +- [Contract](src/contract.ts) represents a deployed smart contract with view and/or change methods +- [Connection](src/connection.ts) a record containing the information required to connect to NEAR RPC +- [Constants](src/constants.ts) account-specific constants +- [Types](src/types.ts) account-specific types + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/accounts/jest.config.js b/packages/accounts/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/accounts/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/accounts/package.json b/packages/accounts/package.json new file mode 100644 index 0000000000..5866e9c741 --- /dev/null +++ b/packages/accounts/package.json @@ -0,0 +1,51 @@ +{ + "name": "@near-js/accounts", + "version": "0.0.1", + "description": "Classes encapsulating account-specific functionality", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc", + "lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix", + "lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/crypto": "workspace:*", + "@near-js/providers": "workspace:*", + "@near-js/signers": "workspace:*", + "@near-js/transactions": "workspace:*", + "@near-js/types": "workspace:*", + "@near-js/utils": "workspace:*", + "ajv": "^8.11.2", + "ajv-formats": "^2.1.1", + "bn.js": "5.2.1", + "borsh": "^0.7.0", + "depd": "^2.0.0", + "near-abi": "0.1.1" + }, + "devDependencies": { + "@near-js/keystores": "workspace:*", + "@types/node": "^18.11.18", + "bs58": "^4.0.0", + "jest": "^26.0.1", + "near-hello": "^0.5.1", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/accounts/src/account.ts b/packages/accounts/src/account.ts new file mode 100644 index 0000000000..8ceab3f471 --- /dev/null +++ b/packages/accounts/src/account.ts @@ -0,0 +1,662 @@ +import BN from 'bn.js'; + +import { + logWarning, + parseResultError, + DEFAULT_FUNCTION_CALL_GAS, + printTxOutcomeLogs, + printTxOutcomeLogsAndFailures, +} from '@near-js/utils'; +import { exponentialBackoff } from '@near-js/providers'; +import { + actionCreators, + Action, + signTransaction, + SignedTransaction, + stringifyJsonOrBytes +} from '@near-js/transactions'; +import { PublicKey } from '@near-js/crypto'; +import { + PositionalArgsError, + FinalExecutionOutcome, + TypedError, + ErrorContext, + ViewStateResult, + AccountView, + AccessKeyView, + AccessKeyViewRaw, + CodeResult, + AccessKeyList, + AccessKeyInfoView, + FunctionCallPermissionView, + BlockReference, +} from '@near-js/types'; +import { baseDecode, baseEncode } from 'borsh'; + +import { Connection } from './connection.js'; + +const { + addKey, + createAccount, + deleteAccount, + deleteKey, + deployContract, + fullAccessKey, + functionCall, + functionCallAccessKey, + stake, + transfer, +} = actionCreators; + +// Default number of retries with different nonce before giving up on a transaction. +const TX_NONCE_RETRY_NUMBER = 12; + +// Default wait until next retry in millis. +const TX_NONCE_RETRY_WAIT = 500; + +// Exponential back off for waiting to retry. +const TX_NONCE_RETRY_WAIT_BACKOFF = 1.5; + +export interface AccountBalance { + total: string; + stateStaked: string; + staked: string; + available: string; +} + +export interface AccountAuthorizedApp { + contractId: string; + amount: string; + publicKey: string; +} + +/** + * Options used to initiate sining and sending transactions + */ +export interface SignAndSendTransactionOptions { + receiverId: string; + actions: Action[]; + /** + * Metadata to send the NEAR Wallet if using it to sign transactions. + * @see {@link RequestSignTransactionsOptions} + */ + walletMeta?: string; + /** + * Callback url to send the NEAR Wallet if using it to sign transactions. + * @see {@link RequestSignTransactionsOptions} + */ + walletCallbackUrl?: string; + returnError?: boolean; +} + +/** + * Options used to initiate a function call (especially a change function call) + * @see {@link account!Account#viewFunction} to initiate a view function call + */ +export interface FunctionCallOptions { + /** The NEAR account id where the contract is deployed */ + contractId: string; + /** The name of the method to invoke */ + methodName: string; + /** + * named arguments to pass the method `{ messageText: 'my message' }` + */ + args?: object; + /** max amount of gas that method call can use */ + gas?: BN; + /** amount of NEAR (in yoctoNEAR) to send together with the call */ + attachedDeposit?: BN; + /** + * Convert input arguments into bytes array. + */ + stringify?: (input: any) => Buffer; + /** + * Is contract from JS SDK, automatically encodes args from JS SDK to binary. + */ + jsContract?: boolean; +} + +export interface ChangeFunctionCallOptions extends FunctionCallOptions { + /** + * Metadata to send the NEAR Wallet if using it to sign transactions. + * @see {@link RequestSignTransactionsOptions} + */ + walletMeta?: string; + /** + * Callback url to send the NEAR Wallet if using it to sign transactions. + * @see {@link RequestSignTransactionsOptions} + */ + walletCallbackUrl?: string; +} +export interface ViewFunctionCallOptions extends FunctionCallOptions { + parse?: (response: Uint8Array) => any; + blockQuery?: BlockReference; +} + +interface StakedBalance { + validatorId: string; + amount?: string; + error?: string; +} + +interface ActiveDelegatedStakeBalance { + stakedValidators: StakedBalance[]; + failedValidators: StakedBalance[]; + total: BN | string; +} + +function parseJsonFromRawResponse(response: Uint8Array): any { + return JSON.parse(Buffer.from(response).toString()); +} + +function bytesJsonStringify(input: any): Buffer { + return Buffer.from(JSON.stringify(input)); +} + +/** + * This class provides common account related RPC calls including signing transactions with a {@link utils/key_pair!KeyPair}. + * + * @hint Use {@link walletAccount!WalletConnection} in the browser to redirect to [NEAR Wallet](https://wallet.near.org/) for Account/key management using the {@link key_stores/browser_local_storage_key_store!BrowserLocalStorageKeyStore}. + * @see [https://docs.near.org/docs/develop/front-end/naj-quick-reference#account](https://docs.near.org/tools/near-api-js/quick-reference#account) + * @see [Account Spec](https://nomicon.io/DataStructures/Account.html) + */ +export class Account { + readonly connection: Connection; + readonly accountId: string; + + constructor(connection: Connection, accountId: string) { + this.connection = connection; + this.accountId = accountId; + } + + /** + * Returns basic NEAR account information via the `view_account` RPC query method + * @see [https://docs.near.org/api/rpc/contracts#view-account](https://docs.near.org/api/rpc/contracts#view-account) + */ + async state(): Promise { + return this.connection.provider.query({ + request_type: 'view_account', + account_id: this.accountId, + finality: 'optimistic' + }); + } + + /** + * Create a signed transaction which can be broadcast to the network + * @param receiverId NEAR account receiving the transaction + * @param actions list of actions to perform as part of the transaction + * @see {@link providers/json-rpc-provider!JsonRpcProvider#sendTransaction | JsonRpcProvider.sendTransaction} + */ + protected async signTransaction(receiverId: string, actions: Action[]): Promise<[Uint8Array, SignedTransaction]> { + const accessKeyInfo = await this.findAccessKey(receiverId, actions); + if (!accessKeyInfo) { + throw new TypedError(`Can not sign transactions for account ${this.accountId} on network ${this.connection.networkId}, no matching key pair exists for this account`, 'KeyNotFound'); + } + const { accessKey } = accessKeyInfo; + + const block = await this.connection.provider.block({ finality: 'final' }); + const blockHash = block.header.hash; + + const nonce = accessKey.nonce.add(new BN(1)); + return await signTransaction( + receiverId, nonce, actions, baseDecode(blockHash), this.connection.signer, this.accountId, this.connection.networkId + ); + } + + /** + * Sign a transaction to preform a list of actions and broadcast it using the RPC API. + * @see {@link providers/json-rpc-provider!JsonRpcProvider#sendTransaction | JsonRpcProvider.sendTransaction} + */ + async signAndSendTransaction({ receiverId, actions, returnError }: SignAndSendTransactionOptions): Promise { + let txHash, signedTx; + // TODO: TX_NONCE (different constants for different uses of exponentialBackoff?) + const result = await exponentialBackoff(TX_NONCE_RETRY_WAIT, TX_NONCE_RETRY_NUMBER, TX_NONCE_RETRY_WAIT_BACKOFF, async () => { + [txHash, signedTx] = await this.signTransaction(receiverId, actions); + const publicKey = signedTx.transaction.publicKey; + + try { + return await this.connection.provider.sendTransaction(signedTx); + } catch (error) { + if (error.type === 'InvalidNonce') { + logWarning(`Retrying transaction ${receiverId}:${baseEncode(txHash)} with new nonce.`); + delete this.accessKeyByPublicKeyCache[publicKey.toString()]; + return null; + } + if (error.type === 'Expired') { + logWarning(`Retrying transaction ${receiverId}:${baseEncode(txHash)} due to expired block hash`); + return null; + } + + error.context = new ErrorContext(baseEncode(txHash)); + throw error; + } + }); + if (!result) { + // TODO: This should have different code actually, as means "transaction not submitted for sure" + throw new TypedError('nonce retries exceeded for transaction. This usually means there are too many parallel requests with the same access key.', 'RetriesExceeded'); + } + + printTxOutcomeLogsAndFailures({ contractId: signedTx.transaction.receiverId, outcome: result }); + + // Should be falsy if result.status.Failure is null + if (!returnError && typeof result.status === 'object' && typeof result.status.Failure === 'object' && result.status.Failure !== null) { + // if error data has error_message and error_type properties, we consider that node returned an error in the old format + if (result.status.Failure.error_message && result.status.Failure.error_type) { + throw new TypedError( + `Transaction ${result.transaction_outcome.id} failed. ${result.status.Failure.error_message}`, + result.status.Failure.error_type); + } else { + throw parseResultError(result); + } + } + // TODO: if Tx is Unknown or Started. + return result; + } + + /** @hidden */ + accessKeyByPublicKeyCache: { [key: string]: AccessKeyView } = {}; + + /** + * Finds the {@link providers/provider!AccessKeyView} associated with the accounts {@link utils/key_pair!PublicKey} stored in the {@link key_stores/keystore!KeyStore}. + * + * @todo Find matching access key based on transaction (i.e. receiverId and actions) + * + * @param receiverId currently unused (see todo) + * @param actions currently unused (see todo) + * @returns `{ publicKey PublicKey; accessKey: AccessKeyView }` + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async findAccessKey(receiverId: string, actions: Action[]): Promise<{ publicKey: PublicKey; accessKey: AccessKeyView }> { + // TODO: Find matching access key based on transaction (i.e. receiverId and actions) + const publicKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId); + if (!publicKey) { + throw new TypedError(`no matching key pair found in ${this.connection.signer}`, 'PublicKeyNotFound'); + } + + const cachedAccessKey = this.accessKeyByPublicKeyCache[publicKey.toString()]; + if (cachedAccessKey !== undefined) { + return { publicKey, accessKey: cachedAccessKey }; + } + + try { + const rawAccessKey = await this.connection.provider.query({ + request_type: 'view_access_key', + account_id: this.accountId, + public_key: publicKey.toString(), + finality: 'optimistic' + }); + + // store nonce as BN to preserve precision on big number + const accessKey = { + ...rawAccessKey, + nonce: new BN(rawAccessKey.nonce), + }; + // this function can be called multiple times and retrieve the same access key + // this checks to see if the access key was already retrieved and cached while + // the above network call was in flight. To keep nonce values in line, we return + // the cached access key. + if (this.accessKeyByPublicKeyCache[publicKey.toString()]) { + return { publicKey, accessKey: this.accessKeyByPublicKeyCache[publicKey.toString()] }; + } + + this.accessKeyByPublicKeyCache[publicKey.toString()] = accessKey; + return { publicKey, accessKey }; + } catch (e) { + if (e.type == 'AccessKeyDoesNotExist') { + return null; + } + + throw e; + } + } + + /** + * Create a new account and deploy a contract to it + * + * @param contractId NEAR account where the contract is deployed + * @param publicKey The public key to add to the created contract account + * @param data The compiled contract code + * @param amount of NEAR to transfer to the created contract account. Transfer enough to pay for storage https://docs.near.org/docs/concepts/storage-staking + */ + async createAndDeployContract(contractId: string, publicKey: string | PublicKey, data: Uint8Array, amount: BN): Promise { + const accessKey = fullAccessKey(); + await this.signAndSendTransaction({ + receiverId: contractId, + actions: [createAccount(), transfer(amount), addKey(PublicKey.from(publicKey), accessKey), deployContract(data)] + }); + const contractAccount = new Account(this.connection, contractId); + return contractAccount; + } + + /** + * @param receiverId NEAR account receiving Ⓝ + * @param amount Amount to send in yoctoⓃ + */ + async sendMoney(receiverId: string, amount: BN): Promise { + return this.signAndSendTransaction({ + receiverId, + actions: [transfer(amount)] + }); + } + + /** + * @param newAccountId NEAR account name to be created + * @param publicKey A public key created from the masterAccount + */ + async createAccount(newAccountId: string, publicKey: string | PublicKey, amount: BN): Promise { + const accessKey = fullAccessKey(); + return this.signAndSendTransaction({ + receiverId: newAccountId, + actions: [createAccount(), transfer(amount), addKey(PublicKey.from(publicKey), accessKey)] + }); + } + + /** + * @param beneficiaryId The NEAR account that will receive the remaining Ⓝ balance from the account being deleted + */ + async deleteAccount(beneficiaryId: string) { + if (typeof process !== 'undefined' && !process.env['NEAR_NO_LOGS']) { + console.log('Deleting an account does not automatically transfer NFTs and FTs to the beneficiary address. Ensure to transfer assets before deleting.'); + } + return this.signAndSendTransaction({ + receiverId: this.accountId, + actions: [deleteAccount(beneficiaryId)] + }); + } + + /** + * @param data The compiled contract code + */ + async deployContract(data: Uint8Array): Promise { + return this.signAndSendTransaction({ + receiverId: this.accountId, + actions: [deployContract(data)] + }); + } + + /** @hidden */ + private encodeJSContractArgs(contractId: string, method: string, args) { + return Buffer.concat([Buffer.from(contractId), Buffer.from([0]), Buffer.from(method), Buffer.from([0]), Buffer.from(args)]); + } + + /** + * Execute function call + * @returns {Promise} + */ + async functionCall({ contractId, methodName, args = {}, gas = DEFAULT_FUNCTION_CALL_GAS, attachedDeposit, walletMeta, walletCallbackUrl, stringify, jsContract }: ChangeFunctionCallOptions): Promise { + this.validateArgs(args); + let functionCallArgs; + + if(jsContract){ + const encodedArgs = this.encodeJSContractArgs( contractId, methodName, JSON.stringify(args) ); + functionCallArgs = ['call_js_contract', encodedArgs, gas, attachedDeposit, null, true ]; + } else{ + const stringifyArg = stringify === undefined ? stringifyJsonOrBytes : stringify; + functionCallArgs = [methodName, args, gas, attachedDeposit, stringifyArg, false]; + } + + return this.signAndSendTransaction({ + receiverId: jsContract ? this.connection.jsvmAccountId: contractId, + // eslint-disable-next-line prefer-spread + actions: [functionCall.apply(void 0, functionCallArgs)], + walletMeta, + walletCallbackUrl + }); + } + + /** + * @see [https://docs.near.org/concepts/basics/accounts/access-keys](https://docs.near.org/concepts/basics/accounts/access-keys) + * @todo expand this API to support more options. + * @param publicKey A public key to be associated with the contract + * @param contractId NEAR account where the contract is deployed + * @param methodNames The method names on the contract that should be allowed to be called. Pass null for no method names and '' or [] for any method names. + * @param amount Payment in yoctoⓃ that is sent to the contract during this function call + */ + async addKey(publicKey: string | PublicKey, contractId?: string, methodNames?: string | string[], amount?: BN): Promise { + if (!methodNames) { + methodNames = []; + } + if (!Array.isArray(methodNames)) { + methodNames = [methodNames]; + } + let accessKey; + if (!contractId) { + accessKey = fullAccessKey(); + } else { + accessKey = functionCallAccessKey(contractId, methodNames, amount); + } + return this.signAndSendTransaction({ + receiverId: this.accountId, + actions: [addKey(PublicKey.from(publicKey), accessKey)] + }); + } + + /** + * @param publicKey The public key to be deleted + * @returns {Promise} + */ + async deleteKey(publicKey: string | PublicKey): Promise { + return this.signAndSendTransaction({ + receiverId: this.accountId, + actions: [deleteKey(PublicKey.from(publicKey))] + }); + } + + /** + * @see [https://near-nodes.io/validator/staking-and-delegation](https://near-nodes.io/validator/staking-and-delegation) + * + * @param publicKey The public key for the account that's staking + * @param amount The account to stake in yoctoⓃ + */ + async stake(publicKey: string | PublicKey, amount: BN): Promise { + return this.signAndSendTransaction({ + receiverId: this.accountId, + actions: [stake(amount, PublicKey.from(publicKey))] + }); + } + + /** @hidden */ + private validateArgs(args: any) { + const isUint8Array = args.byteLength !== undefined && args.byteLength === args.length; + if (isUint8Array) { + return; + } + + if (Array.isArray(args) || typeof args !== 'object') { + throw new PositionalArgsError(); + } + } + + /** + * Invoke a contract view function using the RPC API. + * @see [https://docs.near.org/api/rpc/contracts#call-a-contract-function](https://docs.near.org/api/rpc/contracts#call-a-contract-function) + * + * @param viewFunctionCallOptions.contractId NEAR account where the contract is deployed + * @param viewFunctionCallOptions.methodName The view-only method (no state mutations) name on the contract as it is written in the contract code + * @param viewFunctionCallOptions.args Any arguments to the view contract method, wrapped in JSON + * @param viewFunctionCallOptions.parse Parse the result of the call. Receives a Buffer (bytes array) and converts it to any object. By default result will be treated as json. + * @param viewFunctionCallOptions.stringify Convert input arguments into a bytes array. By default the input is treated as a JSON. + * @param viewFunctionCallOptions.jsContract Is contract from JS SDK, automatically encodes args from JS SDK to binary. + * @param viewFunctionCallOptions.blockQuery specifies which block to query state at. By default returns last "optimistic" block (i.e. not necessarily finalized). + * @returns {Promise} + */ + + async viewFunction({ + contractId, + methodName, + args = {}, + parse = parseJsonFromRawResponse, + stringify = bytesJsonStringify, + jsContract = false, + blockQuery = { finality: 'optimistic' } + }: ViewFunctionCallOptions): Promise { + let encodedArgs; + + this.validateArgs(args); + + if(jsContract){ + encodedArgs = this.encodeJSContractArgs(contractId, methodName, Object.keys(args).length > 0 ? JSON.stringify(args): ''); + } else{ + encodedArgs = stringify(args); + } + + const result = await this.connection.provider.query({ + request_type: 'call_function', + ...blockQuery, + account_id: jsContract ? this.connection.jsvmAccountId : contractId, + method_name: jsContract ? 'view_js_contract' : methodName, + args_base64: encodedArgs.toString('base64') + }); + + if (result.logs) { + printTxOutcomeLogs({ contractId, logs: result.logs }); + } + + return result.result && result.result.length > 0 && parse(Buffer.from(result.result)); + } + + /** + * Returns the state (key value pairs) of this account's contract based on the key prefix. + * Pass an empty string for prefix if you would like to return the entire state. + * @see [https://docs.near.org/api/rpc/contracts#view-contract-state](https://docs.near.org/api/rpc/contracts#view-contract-state) + * + * @param prefix allows to filter which keys should be returned. Empty prefix means all keys. String prefix is utf-8 encoded. + * @param blockQuery specifies which block to query state at. By default returns last "optimistic" block (i.e. not necessarily finalized). + */ + async viewState(prefix: string | Uint8Array, blockQuery: BlockReference = { finality: 'optimistic' } ): Promise> { + const { values } = await this.connection.provider.query({ + request_type: 'view_state', + ...blockQuery, + account_id: this.accountId, + prefix_base64: Buffer.from(prefix).toString('base64') + }); + + return values.map(({ key, value }) => ({ + key: Buffer.from(key, 'base64'), + value: Buffer.from(value, 'base64') + })); + } + + /** + * Get all access keys for the account + * @see [https://docs.near.org/api/rpc/access-keys#view-access-key-list](https://docs.near.org/api/rpc/access-keys#view-access-key-list) + */ + async getAccessKeys(): Promise { + const response = await this.connection.provider.query({ + request_type: 'view_access_key_list', + account_id: this.accountId, + finality: 'optimistic' + }); + // Replace raw nonce into a new BN + return response?.keys?.map((key) => ({ ...key, access_key: { ...key.access_key, nonce: new BN(key.access_key.nonce) } })); + } + + /** + * Returns a list of authorized apps + * @todo update the response value to return all the different keys, not just app keys. + */ + async getAccountDetails(): Promise<{ authorizedApps: AccountAuthorizedApp[] }> { + // TODO: update the response value to return all the different keys, not just app keys. + // Also if we need this function, or getAccessKeys is good enough. + const accessKeys = await this.getAccessKeys(); + const authorizedApps = accessKeys + .filter(item => item.access_key.permission !== 'FullAccess') + .map(item => { + const perm = (item.access_key.permission as FunctionCallPermissionView); + return { + contractId: perm.FunctionCall.receiver_id, + amount: perm.FunctionCall.allowance, + publicKey: item.public_key, + }; + }); + return { authorizedApps }; + } + + /** + * Returns calculated account balance + */ + async getAccountBalance(): Promise { + const protocolConfig = await this.connection.provider.experimental_protocolConfig({ finality: 'final' }); + const state = await this.state(); + + const costPerByte = new BN(protocolConfig.runtime_config.storage_amount_per_byte); + const stateStaked = new BN(state.storage_usage).mul(costPerByte); + const staked = new BN(state.locked); + const totalBalance = new BN(state.amount).add(staked); + const availableBalance = totalBalance.sub(BN.max(staked, stateStaked)); + + return { + total: totalBalance.toString(), + stateStaked: stateStaked.toString(), + staked: staked.toString(), + available: availableBalance.toString() + }; + } + + /** + * Returns the NEAR tokens balance and validators of a given account that is delegated to the staking pools that are part of the validators set in the current epoch. + * + * NOTE: If the tokens are delegated to a staking pool that is currently on pause or does not have enough tokens to participate in validation, they won't be accounted for. + * @returns {Promise} + */ + async getActiveDelegatedStakeBalance(): Promise { + const block = await this.connection.provider.block({ finality: 'final' }); + const blockHash = block.header.hash; + const epochId = block.header.epoch_id; + const { current_validators, next_validators, current_proposals } = await this.connection.provider.validators(epochId); + const pools:Set = new Set(); + [...current_validators, ...next_validators, ...current_proposals] + .forEach((validator) => pools.add(validator.account_id)); + + const uniquePools = [...pools]; + const promises = uniquePools + .map((validator) => ( + this.viewFunction({ + contractId: validator, + methodName: 'get_account_total_balance', + args: { account_id: this.accountId }, + blockQuery: { blockId: blockHash } + }) + )); + + const results = await Promise.allSettled(promises); + + const hasTimeoutError = results.some((result) => { + if (result.status === 'rejected' && result.reason.type === 'TimeoutError') { + return true; + } + return false; + }); + + // When RPC is down and return timeout error, throw error + if (hasTimeoutError) { + throw new Error('Failed to get delegated stake balance'); + } + const summary = results.reduce((result, state, index) => { + const validatorId = uniquePools[index]; + if (state.status === 'fulfilled') { + const currentBN = new BN(state.value); + if (!currentBN.isZero()) { + return { + ...result, + stakedValidators: [...result.stakedValidators, { validatorId, amount: currentBN.toString() }], + total: result.total.add(currentBN), + }; + } + } + if (state.status === 'rejected') { + return { + ...result, + failedValidators: [...result.failedValidators, { validatorId, error: state.reason }], + }; + } + return result; + }, + { stakedValidators: [], failedValidators: [], total: new BN(0) }); + + return { + ...summary, + total: summary.total.toString(), + }; + } +} diff --git a/packages/accounts/src/account_2fa.ts b/packages/accounts/src/account_2fa.ts new file mode 100644 index 0000000000..8b1749dfd2 --- /dev/null +++ b/packages/accounts/src/account_2fa.ts @@ -0,0 +1,281 @@ +'use strict'; + +import { PublicKey } from '@near-js/crypto'; +import { FinalExecutionOutcome, TypedError, FunctionCallPermissionView } from '@near-js/types'; +import { fetchJson } from '@near-js/providers'; +import { actionCreators } from '@near-js/transactions'; +import BN from 'bn.js'; + +import { SignAndSendTransactionOptions } from './account.js'; +import { AccountMultisig } from './account_multisig.js'; +import { Connection } from './connection.js'; +import { + MULTISIG_CHANGE_METHODS, + MULTISIG_CONFIRM_METHODS, + MULTISIG_DEPOSIT, + MULTISIG_GAS, +} from './constants.js'; +import { MultisigStateStatus } from './types.js'; + +const { addKey, deleteKey, deployContract, fullAccessKey, functionCall, functionCallAccessKey } = actionCreators; + +type sendCodeFunction = () => Promise; +type getCodeFunction = (method: any) => Promise; +type verifyCodeFunction = (securityCode: any) => Promise; + +export class Account2FA extends AccountMultisig { + /******************************** + Account2FA has options object where you can provide callbacks for: + - sendCode: how to send the 2FA code in case you don't use NEAR Contract Helper + - getCode: how to get code from user (use this to provide custom UI/UX for prompt of 2FA code) + - onResult: the tx result after it's been confirmed by NEAR Contract Helper + ********************************/ + public sendCode: sendCodeFunction; + public getCode: getCodeFunction; + public verifyCode: verifyCodeFunction; + public onConfirmResult: (any) => any; + public helperUrl = 'https://helper.testnet.near.org'; + + constructor(connection: Connection, accountId: string, options: any) { + super(connection, accountId, options); + this.helperUrl = options.helperUrl || this.helperUrl; + this.storage = options.storage; + this.sendCode = options.sendCode || this.sendCodeDefault; + this.getCode = options.getCode || this.getCodeDefault; + this.verifyCode = options.verifyCode || this.verifyCodeDefault; + this.onConfirmResult = options.onConfirmResult; + } + + /** + * Sign a transaction to preform a list of actions and broadcast it using the RPC API. + * @see {@link providers/json-rpc-provider!JsonRpcProvider#sendTransaction | JsonRpcProvider.sendTransaction} + */ + async signAndSendTransaction({ receiverId, actions }: SignAndSendTransactionOptions): Promise { + await super.signAndSendTransaction({ receiverId, actions }); + // TODO: Should following override onRequestResult in superclass instead of doing custom signAndSendTransaction? + await this.sendCode(); + const result = await this.promptAndVerify(); + if (this.onConfirmResult) { + await this.onConfirmResult(result); + } + return result; + } + + // default helpers for CH deployments of multisig + + async deployMultisig(contractBytes: Uint8Array) { + const { accountId } = this; + + const seedOrLedgerKey = (await this.getRecoveryMethods()).data + .filter(({ kind, publicKey }) => (kind === 'phrase' || kind === 'ledger') && publicKey !== null) + .map((rm) => rm.publicKey); + + const fak2lak = (await this.getAccessKeys()) + .filter(({ public_key, access_key: { permission } }) => permission === 'FullAccess' && !seedOrLedgerKey.includes(public_key)) + .map((ak) => ak.public_key) + .map(toPK); + + const confirmOnlyKey = toPK((await this.postSignedJson('/2fa/getAccessKey', { accountId })).publicKey); + + const newArgs = Buffer.from(JSON.stringify({ 'num_confirmations': 2 })); + + const actions = [ + ...fak2lak.map((pk) => deleteKey(pk)), + ...fak2lak.map((pk) => addKey(pk, functionCallAccessKey(accountId, MULTISIG_CHANGE_METHODS, null))), + addKey(confirmOnlyKey, functionCallAccessKey(accountId, MULTISIG_CONFIRM_METHODS, null)), + deployContract(contractBytes), + ]; + const newFunctionCallActionBatch = actions.concat(functionCall('new', newArgs, MULTISIG_GAS, MULTISIG_DEPOSIT)); + console.log('deploying multisig contract for', accountId); + + const { stateStatus: multisigStateStatus } = await this.checkMultisigCodeAndStateStatus(contractBytes); + switch (multisigStateStatus) { + case MultisigStateStatus.STATE_NOT_INITIALIZED: + return await super.signAndSendTransactionWithAccount(accountId, newFunctionCallActionBatch); + case MultisigStateStatus.VALID_STATE: + return await super.signAndSendTransactionWithAccount(accountId, actions); + case MultisigStateStatus.INVALID_STATE: + throw new TypedError(`Can not deploy a contract to account ${this.accountId} on network ${this.connection.networkId}, the account has existing state.`, 'ContractHasExistingState'); + default: + throw new TypedError(`Can not deploy a contract to account ${this.accountId} on network ${this.connection.networkId}, the account state could not be verified.`, 'ContractStateUnknown'); + } + } + + async disableWithFAK({ contractBytes, cleanupContractBytes }: { contractBytes: Uint8Array; cleanupContractBytes?: Uint8Array }) { + let cleanupActions = []; + if(cleanupContractBytes) { + await this.deleteAllRequests().catch(e => e); + cleanupActions = await this.get2faDisableCleanupActions(cleanupContractBytes); + } + const keyConversionActions = await this.get2faDisableKeyConversionActions(); + + const actions = [ + ...cleanupActions, + ...keyConversionActions, + deployContract(contractBytes) + ]; + + const accessKeyInfo = await this.findAccessKey(this.accountId, actions); + + if(accessKeyInfo && accessKeyInfo.accessKey && accessKeyInfo.accessKey.permission !== 'FullAccess') { + throw new TypedError('No full access key found in keystore. Unable to bypass multisig', 'NoFAKFound'); + } + + return this.signAndSendTransactionWithAccount(this.accountId, actions); + } + + async get2faDisableCleanupActions(cleanupContractBytes: Uint8Array) { + const currentAccountState: { key: Buffer; value: Buffer }[] = await this.viewState('').catch(error => { + const cause = error.cause && error.cause.name; + if (cause == 'NO_CONTRACT_CODE') { + return []; + } + throw cause == 'TOO_LARGE_CONTRACT_STATE' + ? new TypedError(`Can not deploy a contract to account ${this.accountId} on network ${this.connection.networkId}, the account has existing state.`, 'ContractHasExistingState') + : error; + }); + + const currentAccountStateKeys = currentAccountState.map(({ key }) => key.toString('base64')); + return currentAccountState.length ? [ + deployContract(cleanupContractBytes), + functionCall('clean', { keys: currentAccountStateKeys }, MULTISIG_GAS, new BN('0')) + ] : []; + } + + async get2faDisableKeyConversionActions() { + const { accountId } = this; + const accessKeys = await this.getAccessKeys(); + const lak2fak = accessKeys + .filter(({ access_key }) => access_key.permission !== 'FullAccess') + .filter(({ access_key }) => { + const perm = (access_key.permission as FunctionCallPermissionView).FunctionCall; + return perm.receiver_id === accountId && + perm.method_names.length === 4 && + perm.method_names.includes('add_request_and_confirm'); + }); + const confirmOnlyKey = PublicKey.from((await this.postSignedJson('/2fa/getAccessKey', { accountId })).publicKey); + return [ + deleteKey(confirmOnlyKey), + ...lak2fak.map(({ public_key }) => deleteKey(PublicKey.from(public_key))), + ...lak2fak.map(({ public_key }) => addKey(PublicKey.from(public_key), fullAccessKey())) + ]; + } + + /** + * This method converts LAKs back to FAKs, clears state and deploys an 'empty' contract (contractBytes param) + * @param [contractBytes]{@link https://github.com/near/near-wallet/blob/master/packages/frontend/src/wasm/main.wasm?raw=true} + * @param [cleanupContractBytes]{@link https://github.com/near/core-contracts/blob/master/state-cleanup/res/state_cleanup.wasm?raw=true} + */ + async disable(contractBytes: Uint8Array, cleanupContractBytes: Uint8Array) { + const { stateStatus } = await this.checkMultisigCodeAndStateStatus(); + if(stateStatus !== MultisigStateStatus.VALID_STATE && stateStatus !== MultisigStateStatus.STATE_NOT_INITIALIZED) { + throw new TypedError(`Can not deploy a contract to account ${this.accountId} on network ${this.connection.networkId}, the account state could not be verified.`, 'ContractStateUnknown'); + } + + let deleteAllRequestsError; + await this.deleteAllRequests().catch(e => deleteAllRequestsError = e); + + const cleanupActions = await this.get2faDisableCleanupActions(cleanupContractBytes).catch(e => { + if(e.type === 'ContractHasExistingState') { + throw deleteAllRequestsError || e; + } + throw e; + }); + + const actions = [ + ...cleanupActions, + ...(await this.get2faDisableKeyConversionActions()), + deployContract(contractBytes), + ]; + console.log('disabling 2fa for', this.accountId); + return await this.signAndSendTransaction({ + receiverId: this.accountId, + actions + }); + } + + async sendCodeDefault() { + const { accountId } = this; + const { requestId } = this.getRequest(); + const method = await this.get2faMethod(); + await this.postSignedJson('/2fa/send', { + accountId, + method, + requestId, + }); + return requestId; + } + + async getCodeDefault(): Promise { + throw new Error('There is no getCode callback provided. Please provide your own in AccountMultisig constructor options. It has a parameter method where method.kind is "email" or "phone".'); + } + + async promptAndVerify() { + const method = await this.get2faMethod(); + const securityCode = await this.getCode(method); + try { + const result = await this.verifyCode(securityCode); + + // TODO: Parse error from result for real (like in normal account.signAndSendTransaction) + return result; + } catch (e) { + console.warn('Error validating security code:', e); + if (e.toString().includes('invalid 2fa code provided') || e.toString().includes('2fa code not valid')) { + return await this.promptAndVerify(); + } + + throw e; + } + } + + async verifyCodeDefault(securityCode: string) { + const { accountId } = this; + const request = this.getRequest(); + if (!request) { + throw new Error('no request pending'); + } + const { requestId } = request; + return await this.postSignedJson('/2fa/verify', { + accountId, + securityCode, + requestId + }); + } + + async getRecoveryMethods() { + const { accountId } = this; + return { + accountId, + data: await this.postSignedJson('/account/recoveryMethods', { accountId }) + }; + } + + async get2faMethod() { + let { data } = await this.getRecoveryMethods(); + if (data && data.length) { + data = data.find((m) => m.kind.indexOf('2fa-') === 0); + } + if (!data) return null; + const { kind, detail } = data; + return { kind, detail }; + } + + async signatureFor() { + const { accountId } = this; + const block = await this.connection.provider.block({ finality: 'final' }); + const blockNumber = block.header.height.toString(); + const signed = await this.connection.signer.signMessage(Buffer.from(blockNumber), accountId, this.connection.networkId); + const blockNumberSignature = Buffer.from(signed.signature).toString('base64'); + return { blockNumber, blockNumberSignature }; + } + + async postSignedJson(path, body) { + return await fetchJson(this.helperUrl + path, JSON.stringify({ + ...body, + ...(await this.signatureFor()) + })); + } +} + +// helpers +const toPK = (pk) => PublicKey.from(pk); diff --git a/packages/accounts/src/account_creator.ts b/packages/accounts/src/account_creator.ts new file mode 100644 index 0000000000..0ba2135eab --- /dev/null +++ b/packages/accounts/src/account_creator.ts @@ -0,0 +1,56 @@ +import { PublicKey } from '@near-js/crypto'; +import { fetchJson } from '@near-js/providers'; +import BN from 'bn.js'; + +import { Connection } from './connection.js'; +import { Account } from './account.js'; + +/** + * Account creator provides an interface for implementations to actually create accounts + */ +export abstract class AccountCreator { + abstract createAccount(newAccountId: string, publicKey: PublicKey): Promise; +} + +export class LocalAccountCreator extends AccountCreator { + readonly masterAccount: Account; + readonly initialBalance: BN; + + constructor(masterAccount: Account, initialBalance: BN) { + super(); + this.masterAccount = masterAccount; + this.initialBalance = initialBalance; + } + + /** + * Creates an account using a masterAccount, meaning the new account is created from an existing account + * @param newAccountId The name of the NEAR account to be created + * @param publicKey The public key from the masterAccount used to create this account + * @returns {Promise} + */ + async createAccount(newAccountId: string, publicKey: PublicKey): Promise { + await this.masterAccount.createAccount(newAccountId, publicKey, this.initialBalance); + } +} + +export class UrlAccountCreator extends AccountCreator { + readonly connection: Connection; + readonly helperUrl: string; + + constructor(connection: Connection, helperUrl: string) { + super(); + this.connection = connection; + this.helperUrl = helperUrl; + } + + /** + * Creates an account using a helperUrl + * This is [hosted here](https://helper.nearprotocol.com) or set up locally with the [near-contract-helper](https://github.com/nearprotocol/near-contract-helper) repository + * @param newAccountId The name of the NEAR account to be created + * @param publicKey The public key from the masterAccount used to create this account + * @returns {Promise} + */ + async createAccount(newAccountId: string, publicKey: PublicKey): Promise { + await fetchJson(`${this.helperUrl}/account`, JSON.stringify({ newAccountId, newAccountPublicKey: publicKey.toString() })); + } +} diff --git a/packages/accounts/src/account_multisig.ts b/packages/accounts/src/account_multisig.ts new file mode 100644 index 0000000000..b9c937175b --- /dev/null +++ b/packages/accounts/src/account_multisig.ts @@ -0,0 +1,226 @@ +'use strict'; + +import { Action, actionCreators } from '@near-js/transactions'; +import { FinalExecutionOutcome } from '@near-js/types'; + +import { Account, SignAndSendTransactionOptions } from './account.js'; +import { Connection } from './connection.js'; +import { + MULTISIG_ALLOWANCE, + MULTISIG_CHANGE_METHODS, + MULTISIG_DEPOSIT, + MULTISIG_GAS, + MULTISIG_STORAGE_KEY, +} from './constants.js'; +import { MultisigDeleteRequestRejectionError, MultisigStateStatus } from './types.js'; + +const { deployContract, functionCall } = actionCreators; + +enum MultisigCodeStatus { + INVALID_CODE, + VALID_CODE, + UNKNOWN_CODE +} + +// in memory request cache for node w/o localStorage +const storageFallback = { + [MULTISIG_STORAGE_KEY]: null +}; + +export class AccountMultisig extends Account { + public storage: any; + public onAddRequestResult: (any) => any; + + constructor(connection: Connection, accountId: string, options: any) { + super(connection, accountId); + this.storage = options.storage; + this.onAddRequestResult = options.onAddRequestResult; + } + + async signAndSendTransactionWithAccount(receiverId: string, actions: Action[]): Promise { + return super.signAndSendTransaction({ receiverId, actions }); + } + + async signAndSendTransaction({ receiverId, actions }: SignAndSendTransactionOptions): Promise { + const { accountId } = this; + + const args = Buffer.from(JSON.stringify({ + request: { + receiver_id: receiverId, + actions: convertActions(actions, accountId, receiverId) + } + })); + + let result; + try { + result = await super.signAndSendTransaction({ + receiverId: accountId, + actions: [ + functionCall('add_request_and_confirm', args, MULTISIG_GAS, MULTISIG_DEPOSIT) + ] + }); + } catch (e) { + if (e.toString().includes('Account has too many active requests. Confirm or delete some')) { + await this.deleteUnconfirmedRequests(); + return await this.signAndSendTransaction({ receiverId, actions }); + } + throw e; + } + + // TODO: Are following even needed? Seems like it throws on error already + if (!result.status) { + throw new Error('Request failed'); + } + const status: any = { ...result.status }; + if (!status.SuccessValue || typeof status.SuccessValue !== 'string') { + throw new Error('Request failed'); + } + + this.setRequest({ + accountId, + actions, + requestId: parseInt(Buffer.from(status.SuccessValue, 'base64').toString('ascii'), 10) + }); + + if (this.onAddRequestResult) { + await this.onAddRequestResult(result); + } + + // NOTE there is no await on purpose to avoid blocking for 2fa + this.deleteUnconfirmedRequests(); + + return result; + } + + /* + * This method submits a canary transaction that is expected to always fail in order to determine whether the contract currently has valid multisig state + * and whether it is initialized. The canary transaction attempts to delete a request at index u32_max and will go through if a request exists at that index. + * a u32_max + 1 and -1 value cannot be used for the canary due to expected u32 error thrown before deserialization attempt. + */ + async checkMultisigCodeAndStateStatus(contractBytes?: Uint8Array): Promise<{ codeStatus: MultisigCodeStatus; stateStatus: MultisigStateStatus }> { + const u32_max = 4_294_967_295; + const validCodeStatusIfNoDeploy = contractBytes ? MultisigCodeStatus.UNKNOWN_CODE : MultisigCodeStatus.VALID_CODE; + + try { + if(contractBytes) { + await super.signAndSendTransaction({ + receiverId: this.accountId, actions: [ + deployContract(contractBytes), + functionCall('delete_request', { request_id: u32_max }, MULTISIG_GAS, MULTISIG_DEPOSIT) + ] + }); + } else { + await this.deleteRequest(u32_max); + } + + return { codeStatus: MultisigCodeStatus.VALID_CODE, stateStatus: MultisigStateStatus.VALID_STATE }; + } catch (e) { + if (new RegExp(MultisigDeleteRequestRejectionError.CANNOT_DESERIALIZE_STATE).test(e && e.kind && e.kind.ExecutionError)) { + return { codeStatus: validCodeStatusIfNoDeploy, stateStatus: MultisigStateStatus.INVALID_STATE }; + } else if (new RegExp(MultisigDeleteRequestRejectionError.MULTISIG_NOT_INITIALIZED).test(e && e.kind && e.kind.ExecutionError)) { + return { codeStatus: validCodeStatusIfNoDeploy, stateStatus: MultisigStateStatus.STATE_NOT_INITIALIZED }; + } else if (new RegExp(MultisigDeleteRequestRejectionError.NO_SUCH_REQUEST).test(e && e.kind && e.kind.ExecutionError)) { + return { codeStatus: validCodeStatusIfNoDeploy, stateStatus: MultisigStateStatus.VALID_STATE }; + } else if (new RegExp(MultisigDeleteRequestRejectionError.METHOD_NOT_FOUND).test(e && e.message)) { + // not reachable if transaction included a deploy + return { codeStatus: MultisigCodeStatus.INVALID_CODE, stateStatus: MultisigStateStatus.UNKNOWN_STATE }; + } + throw e; + } + } + + deleteRequest(request_id) { + return super.signAndSendTransaction({ + receiverId: this.accountId, + actions: [functionCall('delete_request', { request_id }, MULTISIG_GAS, MULTISIG_DEPOSIT)] + }); + } + + async deleteAllRequests() { + const request_ids = await this.getRequestIds(); + if(request_ids.length) { + await Promise.all(request_ids.map((id) => this.deleteRequest(id))); + } + } + + async deleteUnconfirmedRequests () { + // TODO: Delete in batch, don't delete unexpired + // TODO: Delete in batch, don't delete unexpired (can reduce gas usage dramatically) + const request_ids = await this.getRequestIds(); + const { requestId } = this.getRequest(); + for (const requestIdToDelete of request_ids) { + if (requestIdToDelete == requestId) { + continue; + } + try { + await super.signAndSendTransaction({ + receiverId: this.accountId, + actions: [functionCall('delete_request', { request_id: requestIdToDelete }, MULTISIG_GAS, MULTISIG_DEPOSIT)] + }); + } catch (e) { + console.warn('Attempt to delete an earlier request before 15 minutes failed. Will try again.'); + } + } + } + + // helpers + + async getRequestIds(): Promise { + // TODO: Read requests from state to allow filtering by expiration time + // TODO: https://github.com/near/core-contracts/blob/305d1db4f4f2cf5ce4c1ef3479f7544957381f11/multisig/src/lib.rs#L84 + return this.viewFunction({ + contractId: this.accountId, + methodName: 'list_request_ids', + }); + } + + getRequest() { + if (this.storage) { + return JSON.parse(this.storage.getItem(MULTISIG_STORAGE_KEY) || '{}'); + } + return storageFallback[MULTISIG_STORAGE_KEY]; + } + + setRequest(data) { + if (this.storage) { + return this.storage.setItem(MULTISIG_STORAGE_KEY, JSON.stringify(data)); + } + storageFallback[MULTISIG_STORAGE_KEY] = data; + } +} + +const convertPKForContract = (pk) => pk.toString().replace('ed25519:', ''); + +const convertActions = (actions, accountId, receiverId) => actions.map((a) => { + const type = a.enum; + const { gas, publicKey, methodName, args, deposit, accessKey, code } = a[type]; + const action = { + type: type[0].toUpperCase() + type.substr(1), + gas: (gas && gas.toString()) || undefined, + public_key: (publicKey && convertPKForContract(publicKey)) || undefined, + method_name: methodName, + args: (args && Buffer.from(args).toString('base64')) || undefined, + code: (code && Buffer.from(code).toString('base64')) || undefined, + amount: (deposit && deposit.toString()) || undefined, + deposit: (deposit && deposit.toString()) || '0', + permission: undefined, + }; + if (accessKey) { + if (receiverId === accountId && accessKey.permission.enum !== 'fullAccess') { + action.permission = { + receiver_id: accountId, + allowance: MULTISIG_ALLOWANCE.toString(), + method_names: MULTISIG_CHANGE_METHODS, + }; + } + if (accessKey.permission.enum === 'functionCall') { + const { receiverId: receiver_id, methodNames: method_names, allowance } = accessKey.permission.functionCall; + action.permission = { + receiver_id, + allowance: (allowance && allowance.toString()) || undefined, + method_names + }; + } + } + return action; +}); diff --git a/packages/accounts/src/connection.ts b/packages/accounts/src/connection.ts new file mode 100644 index 0000000000..3c27a3a45f --- /dev/null +++ b/packages/accounts/src/connection.ts @@ -0,0 +1,56 @@ +import { Signer, InMemorySigner } from '@near-js/signers'; +import { Provider, JsonRpcProvider } from '@near-js/providers'; + +/** + * @param config Contains connection info details + * @returns {Provider} + */ +function getProvider(config: any): Provider { + switch (config.type) { + case undefined: + return config; + case 'JsonRpcProvider': return new JsonRpcProvider({ ...config.args }); + default: throw new Error(`Unknown provider type ${config.type}`); + } +} + +/** + * @param config Contains connection info details + * @returns {Signer} + */ +function getSigner(config: any): Signer { + switch (config.type) { + case undefined: + return config; + case 'InMemorySigner': { + return new InMemorySigner(config.keyStore); + } + default: throw new Error(`Unknown signer type ${config.type}`); + } +} + +/** + * Connects an account to a given network via a given provider + */ +export class Connection { + readonly networkId: string; + readonly provider: Provider; + readonly signer: Signer; + readonly jsvmAccountId: string; + + constructor(networkId: string, provider: Provider, signer: Signer, jsvmAccountId: string) { + this.networkId = networkId; + this.provider = provider; + this.signer = signer; + this.jsvmAccountId = jsvmAccountId; + } + + /** + * @param config Contains connection info details + */ + static fromConfig(config: any): Connection { + const provider = getProvider(config.provider); + const signer = getSigner(config.signer); + return new Connection(config.networkId, provider, signer, config.jsvmAccountId); + } +} diff --git a/packages/accounts/src/constants.ts b/packages/accounts/src/constants.ts new file mode 100644 index 0000000000..464afa7da0 --- /dev/null +++ b/packages/accounts/src/constants.ts @@ -0,0 +1,10 @@ +import { parseNearAmount } from '@near-js/utils'; +import BN from 'bn.js'; + +export const MULTISIG_STORAGE_KEY = '__multisigRequest'; +export const MULTISIG_ALLOWANCE = new BN(parseNearAmount('1')); +// TODO: Different gas value for different requests (can reduce gas usage dramatically) +export const MULTISIG_GAS = new BN('100000000000000'); +export const MULTISIG_DEPOSIT = new BN('0'); +export const MULTISIG_CHANGE_METHODS = ['add_request', 'add_request_and_confirm', 'delete_request', 'confirm']; +export const MULTISIG_CONFIRM_METHODS = ['confirm']; diff --git a/packages/accounts/src/contract.ts b/packages/accounts/src/contract.ts new file mode 100644 index 0000000000..d7db3236b3 --- /dev/null +++ b/packages/accounts/src/contract.ts @@ -0,0 +1,247 @@ +import { getTransactionLastResult } from '@near-js/utils'; +import { ArgumentTypeError, PositionalArgsError } from '@near-js/types'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import BN from 'bn.js'; +import depd from 'depd'; +import { AbiFunction, AbiFunctionKind, AbiRoot, AbiSerializationType } from 'near-abi'; + +import { Account } from './account.js'; +import { UnsupportedSerializationError, UnknownArgumentError, ArgumentSchemaError, ConflictingOptions } from './errors.js'; + +// Makes `function.name` return given name +function nameFunction(name: string, body: (args?: any[]) => any) { + return { + [name](...args: any[]) { + return body(...args); + } + }[name]; +} + +function validateArguments(args: object, abiFunction: AbiFunction, ajv: Ajv, abiRoot: AbiRoot) { + if (!isObject(args)) return; + + if (abiFunction.params && abiFunction.params.serialization_type !== AbiSerializationType.Json) { + throw new UnsupportedSerializationError(abiFunction.name, abiFunction.params.serialization_type); + } + + if (abiFunction.result && abiFunction.result.serialization_type !== AbiSerializationType.Json) { + throw new UnsupportedSerializationError(abiFunction.name, abiFunction.result.serialization_type); + } + + const params = abiFunction.params?.args || []; + for (const p of params) { + const arg = args[p.name]; + const typeSchema = p.type_schema; + typeSchema.definitions = abiRoot.body.root_schema.definitions; + const validate = ajv.compile(typeSchema); + if (!validate(arg)) { + throw new ArgumentSchemaError(p.name, validate.errors); + } + } + // Check there are no extra unknown arguments passed + for (const argName of Object.keys(args)) { + const param = params.find((p) => p.name === argName); + if (!param) { + throw new UnknownArgumentError(argName, params.map((p) => p.name)); + } + } +} + +function createAjv() { + // Strict mode is disabled for now as it complains about unknown formats. We need to + // figure out if we want to support a fixed set of formats. `uint32` and `uint64` + // are added explicitly just to reduce the amount of warnings as these are very popular + // types. + const ajv = new Ajv({ + strictSchema: false, + formats: { + uint32: true, + uint64: true + } + }); + addFormats(ajv); + return ajv; +} + +const isUint8Array = (x: any) => + x && x.byteLength !== undefined && x.byteLength === x.length; + +const isObject = (x: any) => + Object.prototype.toString.call(x) === '[object Object]'; + +interface ChangeMethodOptions { + args: object; + methodName: string; + gas?: BN; + amount?: BN; + meta?: string; + callbackUrl?: string; +} + +export interface ContractMethods { + /** + * Methods that change state. These methods cost gas and require a signed transaction. + * + * @see {@link account!Account.functionCall} + */ + changeMethods: string[]; + + /** + * View methods do not require a signed transaction. + * + * @see {@link account!Account#viewFunction} + */ + viewMethods: string[]; + + /** + * ABI defining this contract's interface. + */ + abi: AbiRoot; +} + +/** + * Defines a smart contract on NEAR including the change (mutable) and view (non-mutable) methods + * + * @see [https://docs.near.org/tools/near-api-js/quick-reference#contract](https://docs.near.org/tools/near-api-js/quick-reference#contract) + * @example + * ```js + * import { Contract } from 'near-api-js'; + * + * async function contractExample() { + * const methodOptions = { + * viewMethods: ['getMessageByAccountId'], + * changeMethods: ['addMessage'] + * }; + * const contract = new Contract( + * wallet.account(), + * 'contract-id.testnet', + * methodOptions + * ); + * + * // use a contract view method + * const messages = await contract.getMessages({ + * accountId: 'example-account.testnet' + * }); + * + * // use a contract change method + * await contract.addMessage({ + * meta: 'some info', + * callbackUrl: 'https://example.com/callback', + * args: { text: 'my message' }, + * amount: 1 + * }) + * } + * ``` + */ +export class Contract { + readonly account: Account; + readonly contractId: string; + + /** + * @param account NEAR account to sign change method transactions + * @param contractId NEAR account id where the contract is deployed + * @param options NEAR smart contract methods that your application will use. These will be available as `contract.methodName` + */ + constructor(account: Account, contractId: string, options: ContractMethods) { + this.account = account; + this.contractId = contractId; + const { viewMethods = [], changeMethods = [], abi: abiRoot } = options; + + let viewMethodsWithAbi = viewMethods.map((name) => ({ name, abi: null as AbiFunction })); + let changeMethodsWithAbi = changeMethods.map((name) => ({ name, abi: null as AbiFunction })); + if (abiRoot) { + if (viewMethodsWithAbi.length > 0 || changeMethodsWithAbi.length > 0) { + throw new ConflictingOptions(); + } + viewMethodsWithAbi = abiRoot.body.functions + .filter((m) => m.kind === AbiFunctionKind.View) + .map((m) => ({ name: m.name, abi: m })); + changeMethodsWithAbi = abiRoot.body.functions + .filter((methodAbi) => methodAbi.kind === AbiFunctionKind.Call) + .map((methodAbi) => ({ name: methodAbi.name, abi: methodAbi })); + } + + const ajv = createAjv(); + viewMethodsWithAbi.forEach(({ name, abi }) => { + Object.defineProperty(this, name, { + writable: false, + enumerable: true, + value: nameFunction(name, async (args: object = {}, options = {}, ...ignored) => { + if (ignored.length || !(isObject(args) || isUint8Array(args)) || !isObject(options)) { + throw new PositionalArgsError(); + } + + if (abi) { + validateArguments(args, abi, ajv, abiRoot); + } + + return this.account.viewFunction({ + contractId: this.contractId, + methodName: name, + args, + ...options, + }); + }) + }); + }); + changeMethodsWithAbi.forEach(({ name, abi }) => { + Object.defineProperty(this, name, { + writable: false, + enumerable: true, + value: nameFunction(name, async (...args: any[]) => { + if (args.length && (args.length > 3 || !(isObject(args[0]) || isUint8Array(args[0])))) { + throw new PositionalArgsError(); + } + + if (args.length > 1 || !(args[0] && args[0].args)) { + const deprecate = depd('contract.methodName(args, gas, amount)'); + deprecate('use `contract.methodName({ args, gas?, amount?, callbackUrl?, meta? })` instead'); + args[0] = { + args: args[0], + gas: args[1], + amount: args[2] + }; + } + + if (abi) { + validateArguments(args[0].args, abi, ajv, abiRoot); + } + + return this._changeMethod({ methodName: name, ...args[0] }); + }) + }); + }); + } + + private async _changeMethod({ args, methodName, gas, amount, meta, callbackUrl }: ChangeMethodOptions) { + validateBNLike({ gas, amount }); + + const rawResult = await this.account.functionCall({ + contractId: this.contractId, + methodName, + args, + gas, + attachedDeposit: amount, + walletMeta: meta, + walletCallbackUrl: callbackUrl + }); + + return getTransactionLastResult(rawResult); + } +} + +/** + * Validation on arguments being a big number from bn.js + * Throws if an argument is not in BN format or otherwise invalid + * @param argMap + */ +function validateBNLike(argMap: { [name: string]: any }) { + const bnLike = 'number, decimal string or BN'; + for (const argName of Object.keys(argMap)) { + const argValue = argMap[argName]; + if (argValue && !BN.isBN(argValue) && isNaN(argValue)) { + throw new ArgumentTypeError(argName, bnLike, argValue); + } + } +} \ No newline at end of file diff --git a/packages/accounts/src/errors.ts b/packages/accounts/src/errors.ts new file mode 100644 index 0000000000..b37051625a --- /dev/null +++ b/packages/accounts/src/errors.ts @@ -0,0 +1,25 @@ +import { ErrorObject } from 'ajv'; + +export class UnsupportedSerializationError extends Error { + constructor(methodName: string, serializationType: string) { + super(`Contract method '${methodName}' is using an unsupported serialization type ${serializationType}`); + } +} + +export class UnknownArgumentError extends Error { + constructor(actualArgName: string, expectedArgNames: string[]) { + super(`Unrecognized argument '${actualArgName}', expected '${JSON.stringify(expectedArgNames)}'`); + } +} + +export class ArgumentSchemaError extends Error { + constructor(argName: string, errors: ErrorObject[]) { + super(`Argument '${argName}' does not conform to the specified ABI schema: '${JSON.stringify(errors)}'`); + } +} + +export class ConflictingOptions extends Error { + constructor() { + super('Conflicting contract method options have been passed. You can either specify ABI or a list of view/call methods.'); + } +} diff --git a/packages/accounts/src/index.ts b/packages/accounts/src/index.ts new file mode 100644 index 0000000000..2e048dc935 --- /dev/null +++ b/packages/accounts/src/index.ts @@ -0,0 +1,39 @@ +export { + Account, + AccountBalance, + AccountAuthorizedApp, + SignAndSendTransactionOptions, + FunctionCallOptions, + ChangeFunctionCallOptions, + ViewFunctionCallOptions, +} from './account.js'; +export { Account2FA } from './account_2fa.js'; +export { + AccountCreator, + LocalAccountCreator, + UrlAccountCreator, +} from './account_creator.js'; +export { AccountMultisig } from './account_multisig.js'; +export { Connection } from './connection.js'; +export { + MULTISIG_STORAGE_KEY, + MULTISIG_ALLOWANCE, + MULTISIG_GAS, + MULTISIG_DEPOSIT, + MULTISIG_CHANGE_METHODS, + MULTISIG_CONFIRM_METHODS, +} from './constants.js'; +export { + Contract, + ContractMethods, +} from './contract.js'; +export { + ArgumentSchemaError, + ConflictingOptions, + UnknownArgumentError, + UnsupportedSerializationError, +} from './errors.js'; +export { + MultisigDeleteRequestRejectionError, + MultisigStateStatus, +} from './types.js'; diff --git a/packages/accounts/src/types.ts b/packages/accounts/src/types.ts new file mode 100644 index 0000000000..9e016e5d6f --- /dev/null +++ b/packages/accounts/src/types.ts @@ -0,0 +1,14 @@ +export enum MultisigDeleteRequestRejectionError { + CANNOT_DESERIALIZE_STATE = 'Cannot deserialize the contract state', + MULTISIG_NOT_INITIALIZED = 'Smart contract panicked: Multisig contract should be initialized before usage', + NO_SUCH_REQUEST = 'Smart contract panicked: panicked at \'No such request: either wrong number or already confirmed\'', + REQUEST_COOLDOWN_ERROR = 'Request cannot be deleted immediately after creation.', + METHOD_NOT_FOUND = 'Contract method is not found' +} + +export enum MultisigStateStatus { + INVALID_STATE, + STATE_NOT_INITIALIZED, + VALID_STATE, + UNKNOWN_STATE +} diff --git a/packages/accounts/test/.eslintrc.yml b/packages/accounts/test/.eslintrc.yml new file mode 100644 index 0000000000..0fae1d994f --- /dev/null +++ b/packages/accounts/test/.eslintrc.yml @@ -0,0 +1,7 @@ +extends: '../../../.eslintrc.js.yml' +env: + jest: true +globals: + jasmine: true + window: false + fail: true diff --git a/packages/accounts/test/account.access_key.test.js b/packages/accounts/test/account.access_key.test.js new file mode 100644 index 0000000000..3275173afb --- /dev/null +++ b/packages/accounts/test/account.access_key.test.js @@ -0,0 +1,97 @@ +import { KeyPair } from '@near-js/crypto'; + +import testUtils from './test-utils.js'; + +let nearjs; +let workingAccount; +let contractId; +let contract; + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 50000; + +beforeAll(async () => { + nearjs = await testUtils.setUpTestConnection(); +}); + +beforeEach(async () => { + contractId = testUtils.generateUniqueString('test'); + workingAccount = await testUtils.createAccount(nearjs); + contract = await testUtils.deployContract(workingAccount, contractId); +}); + +test('make function call using access key', async() => { + const keyPair = KeyPair.fromRandom('ed25519'); + await workingAccount.addKey(keyPair.getPublicKey(), contractId, '', '2000000000000000000000000'); + + // Override in the key store the workingAccount key to the given access key. + await nearjs.connection.signer.keyStore.setKey(testUtils.networkId, workingAccount.accountId, keyPair); + const setCallValue = testUtils.generateUniqueString('setCallPrefix'); + await contract.setValue({ args: { value: setCallValue } }); + expect(await contract.getValue()).toEqual(setCallValue); +}); + +test('remove access key no longer works', async() => { + const keyPair = KeyPair.fromRandom('ed25519'); + let publicKey = keyPair.getPublicKey(); + await workingAccount.addKey(publicKey, contractId, '', 400000); + await workingAccount.deleteKey(publicKey); + // Override in the key store the workingAccount key to the given access key. + await nearjs.connection.signer.keyStore.setKey(testUtils.networkId, workingAccount.accountId, keyPair); + try { + await contract.setValue({ args: { value: 'test' } }); + fail('should throw an error'); + } catch (e) { + expect(e.message).toEqual(`Can not sign transactions for account ${workingAccount.accountId} on network ${testUtils.networkId}, no matching key pair exists for this account`); + expect(e.type).toEqual('KeyNotFound'); + } +}); + +test('view account details after adding access keys', async() => { + const keyPair = KeyPair.fromRandom('ed25519'); + await workingAccount.addKey(keyPair.getPublicKey(), contractId, '', 1000000000); + + const contract2 = await testUtils.deployContract(workingAccount, testUtils.generateUniqueString('test_contract2')); + const keyPair2 = KeyPair.fromRandom('ed25519'); + await workingAccount.addKey(keyPair2.getPublicKey(), contract2.contractId, '', 2000000000); + + const details = await workingAccount.getAccountDetails(); + const expectedResult = { + authorizedApps: [{ + contractId: contractId, + amount: '1000000000', + publicKey: keyPair.getPublicKey().toString(), + }, + { + contractId: contract2.contractId, + amount: '2000000000', + publicKey: keyPair2.getPublicKey().toString(), + }], + transactions: [] + }; + expect(details.authorizedApps).toEqual(jasmine.arrayContaining(expectedResult.authorizedApps)); +}); + +test('loading account after adding a full key', async() => { + const keyPair = KeyPair.fromRandom('ed25519'); + // wallet calls this with an empty string for contract id and method + await workingAccount.addKey(keyPair.getPublicKey(), '', ''); + + let accessKeys = await workingAccount.getAccessKeys(); + + expect(accessKeys.length).toBe(2); + const addedKey = accessKeys.find(item => item.public_key == keyPair.getPublicKey().toString()); + expect(addedKey).toBeTruthy(); + expect(addedKey.access_key.permission).toEqual('FullAccess'); +}); + +test('load invalid key pair', async() => { + // Override in the key store with invalid key pair + await nearjs.connection.signer.keyStore.setKey(testUtils.networkId, workingAccount.accountId, ''); + try { + await contract.setValue({ args: { value: 'test' } }); + fail('should throw an error'); + } catch (e) { + expect(e.message).toEqual(`no matching key pair found in ${nearjs.connection.signer}`); + expect(e.type).toEqual('PublicKeyNotFound'); + } +}); \ No newline at end of file diff --git a/packages/accounts/test/account.test.js b/packages/accounts/test/account.test.js new file mode 100644 index 0000000000..9007f5d7f2 --- /dev/null +++ b/packages/accounts/test/account.test.js @@ -0,0 +1,473 @@ +import { getTransactionLastResult } from '@near-js/utils'; +import { actionCreators } from '@near-js/transactions'; +import { TypedError } from '@near-js/types'; +import BN from 'bn.js'; +import fs from 'fs'; + +import { Account, Contract } from '../lib/esm'; +import testUtils from './test-utils.js'; + +let nearjs; +let workingAccount; + +const { HELLO_WASM_PATH, HELLO_WASM_BALANCE } = testUtils; + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 50000; + +beforeAll(async () => { + nearjs = await testUtils.setUpTestConnection(); + workingAccount = await testUtils.createAccount(nearjs); +}); + +afterAll(async () => { + await workingAccount.deleteAccount(workingAccount.accountId); +}); + +test('view pre-defined account works and returns correct name', async () => { + let status = await workingAccount.state(); + expect(status.code_hash).toEqual('11111111111111111111111111111111'); +}); + +test('create account and then view account returns the created account', async () => { + const newAccountName = testUtils.generateUniqueString('test'); + const newAccountPublicKey = '9AhWenZ3JddamBoyMqnTbp7yVbRuvqAv3zwfrWgfVRJE'; + const { amount } = await workingAccount.state(); + const newAmount = new BN(amount).div(new BN(10)); + await workingAccount.createAccount(newAccountName, newAccountPublicKey, newAmount); + const newAccount = new Account(nearjs.connection, newAccountName); + const state = await newAccount.state(); + expect(state.amount).toEqual(newAmount.toString()); +}); + +test('send money', async() => { + const sender = await testUtils.createAccount(nearjs); + const receiver = await testUtils.createAccount(nearjs); + const { amount: receiverAmount } = await receiver.state(); + await sender.sendMoney(receiver.accountId, new BN(10000)); + const state = await receiver.state(); + expect(state.amount).toEqual(new BN(receiverAmount).add(new BN(10000)).toString()); +}); + +test('send money through signAndSendTransaction', async() => { + const sender = await testUtils.createAccount(nearjs); + const receiver = await testUtils.createAccount(nearjs); + const { amount: receiverAmount } = await receiver.state(); + await sender.signAndSendTransaction({ + receiverId: receiver.accountId, + actions: [actionCreators.transfer(new BN(10000))], + }); + const state = await receiver.state(); + expect(state.amount).toEqual(new BN(receiverAmount).add(new BN(10000)).toString()); +}); + +test('delete account', async() => { + const sender = await testUtils.createAccount(nearjs); + const receiver = await testUtils.createAccount(nearjs); + await sender.deleteAccount(receiver.accountId); + const reloaded = new Account(sender.connection, sender); + await expect(reloaded.state()).rejects.toThrow(); +}); + +test('multiple parallel transactions', async () => { + const PARALLEL_NUMBER = 5; + await Promise.all([...Array(PARALLEL_NUMBER).keys()].map(async (_, i) => { + const account = new Account(workingAccount.connection, workingAccount.accountId); + // NOTE: Need to have different transactions outside of nonce, or they all succeed by being identical + // TODO: Check if randomization of exponential back off helps to do more transactions without exceeding retries + await account.sendMoney(account.accountId, new BN(i)); + })); +}); + +test('findAccessKey returns the same access key when fetched simultaneously', async() => { + const account = await testUtils.createAccount(nearjs); + + const [key1, key2] = await Promise.all([ + account.findAccessKey(), + account.findAccessKey() + ]); + + expect(key1.accessKey).toBe(key2.accessKey); +}); + +describe('errors', () => { + let oldLog; + let logs; + + beforeEach(async () => { + oldLog = console.log; + logs = []; + console.log = function () { + logs.push(Array.from(arguments).join(' ')); + }; + }); + + afterEach(async () => { + console.log = oldLog; + }); + + test('create existing account', async() => { + await expect(workingAccount.createAccount(workingAccount.accountId, '9AhWenZ3JddamBoyMqnTbp7yVbRuvqAv3zwfrWgfVRJE', 100)) + .rejects.toThrow(/Can't create a new account .+, because it already exists/); + }); +}); + +describe('with deploy contract', () => { + let oldLog; + let logs; + let contractId = testUtils.generateUniqueString('test_contract'); + let contract; + + beforeAll(async () => { + const newPublicKey = await nearjs.connection.signer.createKey(contractId, testUtils.networkId); + const data = [...fs.readFileSync(HELLO_WASM_PATH)]; + await workingAccount.createAndDeployContract(contractId, newPublicKey, data, HELLO_WASM_BALANCE); + contract = new Contract(workingAccount, contractId, { + viewMethods: ['hello', 'getValue', 'returnHiWithLogs'], + changeMethods: ['setValue', 'generateLogs', 'triggerAssert', 'testSetRemove', 'crossContract'] + }); + }); + + beforeEach(async () => { + oldLog = console.log; + logs = []; + console.log = function () { + logs.push(Array.from(arguments).join(' ')); + }; + }); + + afterEach(async () => { + console.log = oldLog; + }); + + test('cross-contact assertion and panic', async () => { + await expect(contract.crossContract({ + args: {}, + gas: 300000000000000 + })).rejects.toThrow(/Smart contract panicked: expected to fail./); + expect(logs.length).toEqual(7); + expect(logs[0]).toMatch(new RegExp('^Receipts: \\w+, \\w+, \\w+$')); + // Log [test_contract1591458385248117]: test_contract1591458385248117 + expect(logs[1]).toMatch(new RegExp(`^\\s+Log \\[${contractId}\\]: ${contractId}$`)); + expect(logs[2]).toMatch(new RegExp('^Receipt: \\w+$')); + // Log [test_contract1591459677449181]: log before planned panic + expect(logs[3]).toMatch(new RegExp(`^\\s+Log \\[${contractId}\\]: log before planned panic$`)); + expect(logs[4]).toMatch(new RegExp('^Receipt: \\w+$')); + expect(logs[5]).toMatch(new RegExp(`^\\s+Log \\[${contractId}\\]: log before assert$`)); + expect(logs[6]).toMatch(new RegExp(`^\\s+Log \\[${contractId}\\]: ABORT: expected to fail, filename: \\"assembly/index.ts" line: \\d+ col: \\d+$`)); + }); + + test('make function calls via account', async() => { + const result = await workingAccount.viewFunction({ + contractId, + methodName: 'hello', // this is the function defined in hello.wasm file that we are calling + args: {name: 'trex'} + }); + expect(result).toEqual('hello trex'); + + const setCallValue = testUtils.generateUniqueString('setCallPrefix'); + const result2 = await workingAccount.functionCall({ + contractId, + methodName: 'setValue', + args: { value: setCallValue } + }); + expect(getTransactionLastResult(result2)).toEqual(setCallValue); + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue' + })).toEqual(setCallValue); + }); + + test('view contract state', async() => { + const setCallValue = testUtils.generateUniqueString('setCallPrefix'); + await workingAccount.functionCall({ + contractId, + methodName: 'setValue', + args: { value: setCallValue } + }); + + const contractAccount = new Account(nearjs.connection, contractId); + const state = (await contractAccount.viewState('')).map(({ key, value }) => [key.toString('utf-8'), value.toString('utf-8')]); + expect(state).toEqual([['name', setCallValue]]); + }); + + test('make function calls via account with custom parser', async() => { + const result = await workingAccount.viewFunction({ + contractId, + methodName:'hello', // this is the function defined in hello.wasm file that we are calling + args: {name: 'trex'}, + parse: x => JSON.parse(x.toString()).replace('trex', 'friend') + }); + expect(result).toEqual('hello friend'); + }); + + test('make function calls via contract', async() => { + const result = await contract.hello({ name: 'trex' }); + expect(result).toEqual('hello trex'); + + const setCallValue = testUtils.generateUniqueString('setCallPrefix'); + const result2 = await contract.setValue({ args: { value: setCallValue } }); + expect(result2).toEqual(setCallValue); + expect(await contract.getValue()).toEqual(setCallValue); + }); + + test('view function calls by block Id and finality', async() => { + const setCallValue1 = testUtils.generateUniqueString('setCallPrefix'); + const result1 = await contract.setValue({ args: { value: setCallValue1 } }); + expect(result1).toEqual(setCallValue1); + expect(await contract.getValue()).toEqual(setCallValue1); + + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue', + blockQuery: { finality: 'optimistic' }, + })).toEqual(setCallValue1); + + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue' + })).toEqual(setCallValue1); + + const block1 = await workingAccount.connection.provider.block({ finality: 'optimistic' }); + const blockHash1 = block1.header.hash; + const blockIndex1 = block1.header.height; + + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue', + blockQuery: { blockId: blockHash1 }, + })).toEqual(setCallValue1); + + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue', + blockQuery: { blockId: blockIndex1 }, + })).toEqual(setCallValue1); + + const setCallValue2 = testUtils.generateUniqueString('setCallPrefix'); + const result2 = await contract.setValue({ args: { value: setCallValue2 } }); + expect(result2).toEqual(setCallValue2); + expect(await contract.getValue()).toEqual(setCallValue2); + + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue', + blockQuery: { finality: 'optimistic' }, + })).toEqual(setCallValue2); + + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue' + })).toEqual(setCallValue2); + + // Old blockHash should still be value #1 + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue', + blockQuery: { blockId: blockHash1 }, + })).toEqual(setCallValue1); + + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue', + blockQuery: { blockId: blockIndex1 }, + })).toEqual(setCallValue1); + + const block2 = await workingAccount.connection.provider.block({ finality: 'optimistic' }); + const blockHash2 = block2.header.hash; + const blockIndex2 = block2.header.height; + + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue', + blockQuery: { blockId: blockHash2 }, + })).toEqual(setCallValue2); + + expect(await workingAccount.viewFunction({ + contractId, + methodName: 'getValue', + blockQuery: { blockId: blockIndex2 }, + })).toEqual(setCallValue2); + }); + + test('make function calls via contract with gas', async() => { + const setCallValue = testUtils.generateUniqueString('setCallPrefix'); + const result2 = await contract.setValue({ + args: { value: setCallValue }, + gas: 1000000 * 1000000 + }); + expect(result2).toEqual(setCallValue); + expect(await contract.getValue()).toEqual(setCallValue); + }); + + test('can get logs from method result', async () => { + await contract.generateLogs(); + expect(logs.length).toEqual(3); + expect(logs[0].substr(0, 8)).toEqual('Receipt:'); + expect(logs.slice(1)).toEqual([`\tLog [${contractId}]: log1`, `\tLog [${contractId}]: log2`]); + }); + + test('can get logs from view call', async () => { + let result = await contract.returnHiWithLogs(); + expect(result).toEqual('Hi'); + expect(logs).toEqual([`Log [${contractId}]: loooog1`, `Log [${contractId}]: loooog2`]); + }); + + test('can get assert message from method result', async () => { + await expect(contract.triggerAssert()).rejects.toThrow(/Smart contract panicked: expected to fail.+/); + expect(logs[1]).toEqual(`\tLog [${contractId}]: log before assert`); + expect(logs[2]).toMatch(new RegExp(`^\\s+Log \\[${contractId}\\]: ABORT: expected to fail, filename: \\"assembly/index.ts" line: \\d+ col: \\d+$`)); + }); + + test('test set/remove', async () => { + await contract.testSetRemove({ + args: { value: '123' } + }); + }); + + test('can have view methods only', async () => { + const contract = new Contract(workingAccount, contractId, { + viewMethods: ['hello'], + }); + expect(await contract.hello({ name: 'world' })).toEqual('hello world'); + }); + + test('can have change methods only', async () => { + const contract = new Contract(workingAccount, contractId, { + changeMethods: ['hello'], + }); + expect(await contract.hello({ + args: { name: 'world' } + })).toEqual('hello world'); + }); + + test('make viewFunction call with object format', async() => { + const result = await workingAccount.viewFunction({ + contractId, + methodName: 'hello', // this is the function defined in hello.wasm file that we are calling + args: { name: 'trex' }, + }); + expect(result).toEqual('hello trex'); + }); + + test('get total stake balance and validator responses', async() => { + const CUSTOM_ERROR = new TypedError('Querying failed: wasm execution failed with error: FunctionCallError(CompilationError(CodeDoesNotExist { account_id: AccountId("invalid_account_id") })).', 'UntypedError'); + const mockConnection = { + ...nearjs.connection, + provider: { + ...nearjs.connection.provider, + validators: () => ({ + current_validators: [ + { + account_id: 'testing1.pool.f863973.m0', + is_slashed: false, + num_expected_blocks: 7, + num_expected_chunks: 19, + num_produced_blocks: 7, + num_produced_chunks: 18, + public_key: 'ed25519:5QzHuNZ4stznMwf3xbDfYGUbjVt8w48q8hinDRmVx41z', + shards: [ 1 ], + stake: '73527610191458905577047103204' + }, + { + account_id: 'testing2.pool.f863973.m0', + is_slashed: false, + num_expected_blocks: 4, + num_expected_chunks: 22, + num_produced_blocks: 4, + num_produced_chunks: 20, + public_key: 'ed25519:9SYKubUbsGVfxrMGaJ9tLMEfPdjD55FLqGoqy3cTnRm6', + shards: [ 2 ], + stake: '74531922534760985104659653178' + }, + { + account_id: 'invalid_account_id', + is_slashed: false, + num_expected_blocks: 4, + num_expected_chunks: 22, + num_produced_blocks: 4, + num_produced_chunks: 20, + public_key: 'ed25519:9SYKubUbsGVfxrMGaJ9tLMEfPdjD55FLqGoqy3cTnRm6', + shards: [ 2 ], + stake: '0' + }, + ], + next_validators: [], + current_proposals: [], + }), + }, + }; + + const account = new Account(mockConnection, 'test.near'); + // mock internal functions that are being used on getActiveDelegatedStakeBalance + account.viewFunction = async ({ methodName, ...args}) => { + if (methodName === 'get_account_total_balance') { + // getActiveDelegatedStakeBalance sums stake from active validators and ignores throws + if (args.contractId === 'invalid_account_id') { + throw CUSTOM_ERROR; + } + return Promise.resolve('10000'); + } else { + return await account.viewFunction({ methodName, ...args }); + } + }; + account.connection.provider.block = async () => { + return Promise.resolve({ header: { hash: 'dontcare' } }); + }; + const result = await account.getActiveDelegatedStakeBalance(); + expect(result).toEqual({ + stakedValidators: [{ validatorId: 'testing1.pool.f863973.m0', amount: '10000'}, { validatorId: 'testing2.pool.f863973.m0', amount: '10000'}], + failedValidators: [{ validatorId: 'invalid_account_id', error: CUSTOM_ERROR}], + total: '20000' + }); + }); + test('Fail to get total stake balance upon timeout error', async () => { + const ERROR_MESSAGE = 'Failed to get delegated stake balance'; + const CUSTOM_ERROR = new TypedError('RPC DOWN', 'TimeoutError'); + const mockConnection = { + ...nearjs.connection, + provider: { + ...nearjs.connection.provider, + validators: () => ({ + current_validators: [ + { + account_id: 'timeout_account_id', + is_slashed: false, + num_expected_blocks: 4, + num_expected_chunks: 22, + num_produced_blocks: 4, + num_produced_chunks: 20, + public_key: 'ed25519:9SYKubUbsGVfxrMGaJ9tLMEfPdjD55FLqGoqy3cTnRm6', + shards: [ 2 ], + stake: '0' + }, + ], + next_validators: [], + current_proposals: [], + }), + }, + }; + + const account = new Account(mockConnection, 'test.near'); + // mock internal functions that are being used on getActiveDelegatedStakeBalance + account.viewFunction = async ({ methodName, ...args}) => { + if (methodName === 'get_account_total_balance') { + // getActiveDelegatedStakeBalance sums stake from active validators and ignores throws + if (args.contractId === 'timeout_account_id') { + throw CUSTOM_ERROR; + } + return Promise.resolve('10000'); + } else { + return await account.viewFunction({ methodName, ...args }); + } + }; + account.connection.provider.block = async () => { + return Promise.resolve({ header: { hash: 'dontcare' } }); + }; + + try { + await account.getActiveDelegatedStakeBalance(); + } catch(e) { + expect(e).toEqual(new Error(ERROR_MESSAGE)); + } + }); +}); diff --git a/packages/accounts/test/account_multisig.test.js b/packages/accounts/test/account_multisig.test.js new file mode 100644 index 0000000000..6eaeda6974 --- /dev/null +++ b/packages/accounts/test/account_multisig.test.js @@ -0,0 +1,133 @@ +/* global BigInt */ +import { parseNearAmount } from '@near-js/utils'; +import { KeyPair } from '@near-js/crypto'; +import { InMemorySigner } from '@near-js/signers'; +import { actionCreators } from '@near-js/transactions'; +import BN from 'bn.js'; +import fs from 'fs'; +import semver from 'semver'; + +import { Account2FA, MULTISIG_DEPOSIT, MULTISIG_GAS } from '../lib/esm'; +import testUtils from './test-utils.js'; + +const { functionCall, transfer } = actionCreators; + +let nearjs; +let startFromVersion; + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 50000; + +const getAccount2FA = async (account, keyMapping = ({ public_key: publicKey }) => ({ publicKey, kind: 'phone' })) => { + // modifiers to functions replaces contract helper (CH) + const { accountId } = account; + const keys = await account.getAccessKeys(); + const account2fa = new Account2FA(nearjs.connection, accountId, { + // skip this (not using CH) + getCode: () => {}, + sendCode: () => {}, + // auto accept "code" + verifyCode: () => ({ }), // TODO: Is there any content needed in result? + onAddRequestResult: async () => { + const { requestId } = account2fa.getRequest(); + // set confirmKey as signer + const originalSigner = nearjs.connection.signer; + nearjs.connection.signer = await InMemorySigner.fromKeyPair(nearjs.connection.networkId, accountId, account2fa.confirmKey); + // 2nd confirmation signing with confirmKey from Account instance + await account.signAndSendTransaction({ + receiverId: accountId, + actions: [ + functionCall('confirm', { request_id: requestId }, MULTISIG_GAS, MULTISIG_DEPOSIT) + ] + }); + nearjs.connection.signer = originalSigner; + } + }); + account2fa.confirmKey = KeyPair.fromRandom('ed25519'); + account2fa.postSignedJson = () => ({ publicKey: account2fa.confirmKey.getPublicKey() }); + account2fa.getRecoveryMethods = () => ({ + data: keys.map(keyMapping) + }); + await account2fa.deployMultisig([...fs.readFileSync('./test/wasm/multisig.wasm')]); + return account2fa; +}; + +beforeAll(async () => { + nearjs = await testUtils.setUpTestConnection(); + let nodeStatus = await nearjs.connection.provider.status(); + startFromVersion = (version) => semver.gte(nodeStatus.version.version, version); + console.log(startFromVersion); +}); + +describe('deployMultisig key rotations', () => { + + test('full access key if recovery method is "ledger" or "phrase", limited access key if "phone"', async () => { + const account = await testUtils.createAccount(nearjs); + await account.addKey(KeyPair.fromRandom('ed25519').getPublicKey()); + await account.addKey(KeyPair.fromRandom('ed25519').getPublicKey()); + const keys = await account.getAccessKeys(); + const kinds = ['ledger', 'phrase', 'phone']; + const account2fa = await getAccount2FA( + account, + ({ public_key: publicKey }, i) => ({ publicKey, kind: kinds[i] }) + ); + const currentKeys = await account2fa.getAccessKeys(); + expect(currentKeys.find(({ public_key }) => keys[0].public_key === public_key).access_key.permission).toEqual('FullAccess'); + expect(currentKeys.find(({ public_key }) => keys[1].public_key === public_key).access_key.permission).toEqual('FullAccess'); + expect(currentKeys.find(({ public_key }) => keys[2].public_key === public_key).access_key.permission).not.toEqual('FullAccess'); + }); + +}); + +describe('account2fa transactions', () => { + + test('add app key before deployMultisig', async() => { + let account = await testUtils.createAccount(nearjs); + const appPublicKey = KeyPair.fromRandom('ed25519').getPublicKey(); + const appAccountId = 'foobar'; + const appMethodNames = ['some_app_stuff','some_more_app_stuff']; + await account.addKey(appPublicKey.toString(), appAccountId, appMethodNames, new BN(parseNearAmount('0.25'))); + account = await getAccount2FA(account); + const keys = await account.getAccessKeys(); + expect(keys.find(({ public_key }) => appPublicKey.toString() === public_key) + .access_key.permission.FunctionCall.method_names).toEqual(appMethodNames); + expect(keys.find(({ public_key }) => appPublicKey.toString() === public_key) + .access_key.permission.FunctionCall.receiver_id).toEqual(appAccountId); + }); + + test('add app key', async() => { + let account = await testUtils.createAccount(nearjs); + account = await getAccount2FA(account); + const appPublicKey = KeyPair.fromRandom('ed25519').getPublicKey(); + const appAccountId = 'foobar'; + const appMethodNames = ['some_app_stuff', 'some_more_app_stuff']; + await account.addKey(appPublicKey.toString(), appAccountId, appMethodNames, new BN(parseNearAmount('0.25'))); + const keys = await account.getAccessKeys(); + expect(keys.find(({ public_key }) => appPublicKey.toString() === public_key) + .access_key.permission.FunctionCall.method_names).toEqual(appMethodNames); + expect(keys.find(({ public_key }) => appPublicKey.toString() === public_key) + .access_key.permission.FunctionCall.receiver_id).toEqual(appAccountId); + }); + + test('send money', async() => { + let sender = await testUtils.createAccount(nearjs); + let receiver = await testUtils.createAccount(nearjs); + sender = await getAccount2FA(sender); + receiver = await getAccount2FA(receiver); + const { amount: receiverAmount } = await receiver.state(); + await sender.sendMoney(receiver.accountId, new BN(parseNearAmount('1'))); + const state = await receiver.state(); + expect(BigInt(state.amount)).toBeGreaterThanOrEqual(BigInt(new BN(receiverAmount).add(new BN(parseNearAmount('0.9'))).toString())); + }); + + test('send money through signAndSendTransaction', async() => { + let sender = await testUtils.createAccount(nearjs); + let receiver = await testUtils.createAccount(nearjs); + sender = await getAccount2FA(sender); + receiver = await getAccount2FA(receiver); + const { amount: receiverAmount } = await receiver.state(); + await sender.signAndSendTransaction({receiverId: receiver.accountId, actions: [transfer(new BN(parseNearAmount('1')))]}); + const state = await receiver.state(); + expect(BigInt(state.amount)).toBeGreaterThanOrEqual(BigInt(new BN(receiverAmount).add(new BN(parseNearAmount('0.9'))).toString())); + }); + +}); diff --git a/packages/accounts/test/config.js b/packages/accounts/test/config.js new file mode 100644 index 0000000000..434f94c156 --- /dev/null +++ b/packages/accounts/test/config.js @@ -0,0 +1,44 @@ +export default function getConfig(env) { + switch (env) { + case 'production': + case 'mainnet': + return { + networkId: 'mainnet', + nodeUrl: 'https://rpc.mainnet.near.org', + walletUrl: 'https://wallet.near.org', + helperUrl: 'https://helper.mainnet.near.org', + }; + case 'development': + case 'testnet': + return { + networkId: 'default', + nodeUrl: 'https://rpc.testnet.near.org', + walletUrl: 'https://wallet.testnet.near.org', + helperUrl: 'https://helper.testnet.near.org', + masterAccount: 'test.near', + }; + case 'betanet': + return { + networkId: 'betanet', + nodeUrl: 'https://rpc.betanet.near.org', + walletUrl: 'https://wallet.betanet.near.org', + helperUrl: 'https://helper.betanet.near.org', + }; + case 'local': + return { + networkId: 'local', + nodeUrl: 'http://localhost:3030', + keyPath: `${process.env.HOME}/.near/validator_key.json`, + walletUrl: 'http://localhost:4000/wallet', + }; + case 'test': + case 'ci': + return { + networkId: 'shared-test', + nodeUrl: 'https://rpc.ci-testnet.near.org', + masterAccount: 'test.near', + }; + default: + throw Error(`Unconfigured environment '${env}'. Can be configured in src/config.js.`); + } +} diff --git a/packages/accounts/test/contract.test.js b/packages/accounts/test/contract.test.js new file mode 100644 index 0000000000..eb49f94f2b --- /dev/null +++ b/packages/accounts/test/contract.test.js @@ -0,0 +1,103 @@ +import { jest } from '@jest/globals'; +import { PositionalArgsError } from '@near-js/types'; + +import { Contract } from '../lib/esm'; + +const account = { + viewFunction({ contractId, methodName, args, parse, stringify, jsContract, blockQuery}) { + return { this: this, contractId, methodName, args, parse, stringify, jsContract, blockQuery }; + }, + functionCall() { + return this; + } +}; + +const contract = new Contract(account, 'contractId', { + viewMethods: ['viewMethod'], + changeMethods: ['changeMethod'], +}); + +['viewMethod', 'changeMethod'].forEach(method => { + describe(method, () => { + test('returns what you expect for .name', () => { + expect(contract[method].name).toBe(method); + }); + + test('maintains correct reference to `this` when passed around an application', async () => { + function callFuncInNewContext(fn) { + return fn(); + } + expect(await callFuncInNewContext(contract[method])); + }); + + test('throws PositionalArgsError if first argument is not an object', async() => { + await expect(contract[method](1)).rejects.toBeInstanceOf(PositionalArgsError); + await expect(contract[method]('lol')).rejects.toBeInstanceOf(PositionalArgsError); + await expect(contract[method]([])).rejects.toBeInstanceOf(PositionalArgsError); + await expect(contract[method](new Date())).rejects.toBeInstanceOf(PositionalArgsError); + await expect(contract[method](null)).rejects.toBeInstanceOf(PositionalArgsError); + await expect(contract[method](new Set())).rejects.toBeInstanceOf(PositionalArgsError); + }); + + test('throws PositionalArgsError if given too many arguments', () => { + return expect(contract[method]({}, 1, 0, 'oops')).rejects.toBeInstanceOf(PositionalArgsError); + }); + + test('allows args encoded as Uint8Array (for borsh)', async () => { + expect(await contract[method](new Uint8Array())); + }); + }); +}); + +describe('viewMethod', () => { + test('passes options through to account viewFunction', async () => { + function customParser () {} + const stubbedReturnValue = await account.viewFunction({ parse: customParser }); + expect(stubbedReturnValue.parse).toBe(customParser); + }); + + describe.each([ + 1, + 'lol', + [], + new Date(), + null, + new Set(), + ])('throws PositionalArgsError if 2nd arg is not an object', badArg => { + test(String(badArg), async () => { + try { + await contract.viewMethod({ a: 1 }, badArg); + throw new Error(`Calling \`contract.viewMethod({ a: 1 }, ${badArg})\` worked. It shouldn't have worked.`); + } catch (e) { + if (!(e instanceof PositionalArgsError)) throw e; + } + }); + }); +}); + +describe('changeMethod', () => { + test('throws error message for invalid gas argument', () => { + return expect(contract.changeMethod({ a: 1}, 'whatever')).rejects.toThrow(/Expected number, decimal string or BN for 'gas' argument, but got.+/); + }); + + test('gives error message for invalid amount argument', () => { + return expect(contract.changeMethod({ a: 1}, 1000, 'whatever')).rejects.toThrow(/Expected number, decimal string or BN for 'amount' argument, but got.+/); + }); + + test('makes a functionCall and passes along walletCallbackUrl and walletMeta', async() => { + account.functionCall = jest.fn(() => Promise.resolve(account)); + await contract.changeMethod({ + args: {}, + meta: 'someMeta', + callbackUrl: 'http://neartest.test/somepath?and=query', + }); + + expect(account.functionCall).toHaveBeenCalledWith({ + args: {}, + contractId: 'contractId', + methodName: 'changeMethod', + walletMeta: 'someMeta', + walletCallbackUrl: 'http://neartest.test/somepath?and=query' + }); + }); +}); diff --git a/packages/accounts/test/contract_abi.test.js b/packages/accounts/test/contract_abi.test.js new file mode 100644 index 0000000000..e56378d50e --- /dev/null +++ b/packages/accounts/test/contract_abi.test.js @@ -0,0 +1,185 @@ +import { ArgumentSchemaError, Contract, UnknownArgumentError, UnsupportedSerializationError } from '../lib/esm'; + +let rawAbi = `{ + "schema_version": "0.3.0", + "body": { + "functions": [ + { + "name": "add", + "doc": " Adds two pairs point-wise.", + "kind": "view", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "a", + "type_schema": { + "$ref": "#/definitions/Pair" + } + }, + { + "name": "b", + "type_schema": { + "$ref": "#/definitions/Pair" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Pair" + } + } + }, + { + "name": "add_call", + "doc": " Adds two pairs point-wise.", + "kind": "call", + "params": { + "serialization_type": "json", + "args": [ + { + "name": "a", + "type_schema": { + "$ref": "#/definitions/Pair" + } + }, + { + "name": "b", + "type_schema": { + "$ref": "#/definitions/Pair" + } + } + ] + }, + "result": { + "serialization_type": "json", + "type_schema": { + "$ref": "#/definitions/Pair" + } + } + }, + { + "name": "empty_call", + "kind": "call" + } + ], + "root_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "String", + "type": "string", + "definitions": { + "Pair": { + "type": "array", + "items": [ + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + }, + { + "type": "integer", + "format": "uint32", + "minimum": 0.0 + } + ], + "maxItems": 2, + "minItems": 2 + } + } + } + } +}`; + +const account = { + viewFunction({ contractId, methodName, args, parse, stringify, jsContract, blockQuery }) { + return { this: this, contractId, methodName, args, parse, stringify, jsContract, blockQuery }; + }, + functionCall() { + return this; + } +}; + +const abi = JSON.parse(rawAbi); + +const contract = new Contract(account, 'contractId', { + abi +}); + +describe('add', () => { + test('can be called successfully', async () => { + await contract.add({ a: [1, 2], b: [3, 4] }); + }); + + test('throws ArgumentSchemaError if required argument was not supplied', async () => { + await expect(contract.add({})).rejects.toBeInstanceOf(ArgumentSchemaError); + }); + + test('throws ArgumentSchemaError if argument has unexpected type', async () => { + await expect(contract.add({ a: 1, b: [3, 4] })).rejects.toBeInstanceOf(ArgumentSchemaError); + }); + + test('throws UnknownArgumentError if unknown argument was supplied', async () => { + await expect(contract.add({ a: [1, 2], b: [3, 4], c: 5 })).rejects.toBeInstanceOf(UnknownArgumentError); + }); +}); + + +describe('add_call', () => { + test('can be called successfully', async () => { + await contract.add_call({ args: { a: [1, 2], b: [3, 4] } }); + }); + + test('throws ArgumentSchemaError if required argument was not supplied', async () => { + await expect(contract.add_call({ args: {} })).rejects.toBeInstanceOf(ArgumentSchemaError); + }); + + test('throws ArgumentSchemaError if argument has unexpected type', async () => { + await expect(contract.add_call({ args: { a: 1, b: [3, 4] } })).rejects.toBeInstanceOf(ArgumentSchemaError); + }); + + test('throws UnknownArgumentError if unknown argument was supplied', async () => { + await expect(contract.add_call({ args: { a: [1, 2], b: [3, 4], c: 5 } })).rejects.toBeInstanceOf(UnknownArgumentError); + }); +}); + +describe('empty_call', () => { + test('can be called successfully', async () => { + await contract.empty_call({}); + }); +}); + +describe('Contract constructor', () => { + test('throws UnsupportedSerializationError when ABI has borsh serialization', async () => { + let rawAbi = `{ + "schema_version": "0.3.0", + "body": { + "functions": [ + { + "name": "add", + "kind": "view", + "params": { + "serialization_type": "borsh", + "args": [ + { + "name": "b", + "type_schema": { + "type": "integer" + } + } + ] + } + } + ], + "root_schema": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "String", + "type": "string" + } + } + }`; + const contract = new Contract(account, 'contractId', { abi: JSON.parse(rawAbi) }); + await expect(contract.add({ a: 1 })).rejects.toBeInstanceOf(UnsupportedSerializationError); + }); +}); diff --git a/packages/accounts/test/promise.test.js b/packages/accounts/test/promise.test.js new file mode 100644 index 0000000000..1b69e1c692 --- /dev/null +++ b/packages/accounts/test/promise.test.js @@ -0,0 +1,325 @@ +import BN from 'bn.js'; + +import testUtils from './test-utils.js'; + +let nearjs; +let workingAccount; + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 120000; + +const CONTRACT_CALL_GAS = new BN(300000000000000); + +beforeAll(async () => { + nearjs = await testUtils.setUpTestConnection(); + workingAccount = await testUtils.createAccount(nearjs); +}); + +describe('with promises', () => { + let contract, contract1, contract2; + let oldLog; + let logs; + let contractName = testUtils.generateUniqueString('cnt'); + let contractName1 = testUtils.generateUniqueString('cnt'); + let contractName2 = testUtils.generateUniqueString('cnt'); + + beforeAll(async () => { + contract = await testUtils.deployContract(workingAccount, contractName); + contract1 = await testUtils.deployContract(workingAccount, contractName1); + contract2 = await testUtils.deployContract(workingAccount, contractName2); + }); + + beforeEach(async () => { + oldLog = console.log; + logs = []; + console.log = function() { + logs.push(Array.from(arguments).join(' ')); + }; + }); + + afterEach(async () => { + console.log = oldLog; + }); + + // -> means async call + // => means callback + + test('single promise, no callback (A->B)', async () => { + const realResult = await contract.callPromise({ + args: { + args: { + receiver: contractName1, + methodName: 'callbackWithName', + args: null, + gas: '3000000000000', + balance: '0', + callback: null, + callbackArgs: null, + callbackBalance: '0', + callbackGas: '0', + } + }, + gas: CONTRACT_CALL_GAS + }); + const lastResult = await contract1.getLastResult(); + expect(lastResult).toEqual({ + rs: [], + n: contractName1, + }); + expect(realResult).toEqual(lastResult); + }); + + test('single promise with callback (A->B=>A)', async () => { + const realResult = await contract.callPromise({ + args: { + args: { + receiver: contractName1, + methodName: 'callbackWithName', + args: null, + gas: '3000000000000', + balance: '0', + callback: 'callbackWithName', + callbackArgs: null, + callbackBalance: '0', + callbackGas: '2000000000000', + } + }, + gas: CONTRACT_CALL_GAS + }); + const lastResult1 = await contract1.getLastResult(); + expect(lastResult1).toEqual({ + rs: [], + n: contractName1, + }); + const lastResult = await contract.getLastResult(); + expect(lastResult).toEqual({ + rs: [{ + ok: true, + r: lastResult1, + }], + n: contractName, + }); + expect(realResult).toEqual(lastResult); + }); + + test('two promises, no callbacks (A->B->C)', async () => { + const realResult = await contract.callPromise({ + args: { + args: { + receiver: contractName1, + methodName: 'callPromise', + args: { + receiver: contractName2, + methodName: 'callbackWithName', + args: null, + gas: '40000000000000', + balance: '0', + callback: null, + callbackArgs: null, + callbackBalance: '0', + callbackGas: '20000000000000', + }, + gas: '60000000000000', + balance: '0', + callback: null, + callbackArgs: null, + callbackBalance: '0', + callbackGas: '60000000000000', + } + }, + gas: CONTRACT_CALL_GAS + }); + const lastResult2 = await contract2.getLastResult(); + expect(lastResult2).toEqual({ + rs: [], + n: contractName2, + }); + expect(realResult).toEqual(lastResult2); + }); + + test('two promises, with two callbacks (A->B->C=>B=>A)', async () => { + const realResult = await contract.callPromise({ + args: { + args: { + receiver: contractName1, + methodName: 'callPromise', + args: { + receiver: contractName2, + methodName: 'callbackWithName', + args: null, + gas: '40000000000000', + balance: '0', + callback: 'callbackWithName', + callbackArgs: null, + callbackBalance: '0', + callbackGas: '20000000000000', + }, + gas: '100000000000000', + balance: '0', + callback: 'callbackWithName', + callbackArgs: null, + callbackBalance: '0', + callbackGas: '30000000000000', + } + }, + gas: CONTRACT_CALL_GAS + }); + const lastResult2 = await contract2.getLastResult(); + expect(lastResult2).toEqual({ + rs: [], + n: contractName2, + }); + const lastResult1 = await contract1.getLastResult(); + expect(lastResult1).toEqual({ + rs: [{ + ok: true, + r: lastResult2, + }], + n: contractName1, + }); + const lastResult = await contract.getLastResult(); + expect(lastResult).toEqual({ + rs: [{ + ok: true, + r: lastResult1, + }], + n: contractName, + }); + expect(realResult).toEqual(lastResult); + }); + + test('cross contract call with callbacks (A->B->A=>B=>A)', async () => { + const realResult = await contract.callPromise({ + args: { + args: { + receiver: contractName1, + methodName: 'callPromise', + args: { + receiver: contractName, + methodName: 'callbackWithName', + args: null, + gas: '40000000000000', + balance: '0', + callback: 'callbackWithName', + callbackArgs: null, + callbackBalance: '0', + callbackGas: '40000000000000', + }, + gas: '100000000000000', + balance: '0', + callback: 'callbackWithName', + callbackArgs: null, + callbackBalance: '0', + callbackGas: '30000000000000', + } + }, + gas: CONTRACT_CALL_GAS + }); + const lastResult1 = await contract1.getLastResult(); + expect(lastResult1).toEqual({ + rs: [{ + ok: true, + r: { + rs: [], + n: contractName, + }, + }], + n: contractName1, + }); + const lastResult = await contract.getLastResult(); + expect(lastResult).toEqual({ + rs: [{ + ok: true, + r: lastResult1, + }], + n: contractName, + }); + expect(realResult).toEqual(lastResult); + }); + + test('2 promises with 1 skipped callbacks (A->B->C=>A)', async () => { + const realResult = await contract.callPromise({ + args: { + args: { + receiver: contractName1, + methodName: 'callPromise', + args: { + receiver: contractName2, + methodName: 'callbackWithName', + args: null, + gas: '20000000000000', + balance: '0', + callback: null, + callbackArgs: null, + callbackBalance: '0', + callbackGas: '20000000000000', + }, + gas: '50000000000000', + balance: '0', + callback: 'callbackWithName', + callbackArgs: null, + callbackBalance: '0', + callbackGas: '30000000000000' + } + }, + gas: CONTRACT_CALL_GAS + }); + const lastResult2 = await contract2.getLastResult(); + expect(lastResult2).toEqual({ + rs: [], + n: contractName2, + }); + const lastResult = await contract.getLastResult(); + expect(lastResult).toEqual({ + rs: [{ + ok: true, + r: lastResult2, + }], + n: contractName, + }); + expect(realResult).toEqual(lastResult); + }); + + test('two promises, with one callbacks to B only (A->B->C=>B)', async () => { + const realResult = await contract.callPromise({ + args: { + args: { + receiver: contractName1, + methodName: 'callPromise', + args: { + receiver: contractName2, + methodName: 'callbackWithName', + args: null, + gas: '40000000000000', + balance: '0', + callback: 'callbackWithName', + callbackArgs: null, + callbackBalance: '0', + callbackGas: '40000000000000', + }, + gas: '100000000000000', + balance: '0', + callback: null, + callbackArgs: null, + callbackBalance: '0', + callbackGas: '0', + } + }, + gas: CONTRACT_CALL_GAS + }); + const lastResult2 = await contract2.getLastResult(); + expect(lastResult2).toEqual({ + rs: [], + n: contractName2, + }); + const lastResult1 = await contract1.getLastResult(); + expect(lastResult1).toEqual({ + rs: [{ + ok: true, + r: lastResult2, + }], + n: contractName1, + }); + expect(realResult).toEqual(lastResult1); + }); + +}); diff --git a/packages/accounts/test/providers.test.js b/packages/accounts/test/providers.test.js new file mode 100644 index 0000000000..0b87c550b0 --- /dev/null +++ b/packages/accounts/test/providers.test.js @@ -0,0 +1,186 @@ +import { jest } from '@jest/globals'; +import { JsonRpcProvider } from '@near-js/providers'; +import BN from 'bn.js'; +import base58 from 'bs58'; + +import getConfig from './config.js'; +import testUtils from './test-utils.js'; + +jest.setTimeout(20000); + +const withProvider = (fn) => { + const config = Object.assign(getConfig(process.env.NODE_ENV || 'test')); + const provider = new JsonRpcProvider(config.nodeUrl); + return () => fn(provider); +}; + +test('txStatus with string hash and buffer hash', withProvider(async(provider) => { + const near = await testUtils.setUpTestConnection(); + const sender = await testUtils.createAccount(near); + const receiver = await testUtils.createAccount(near); + const outcome = await sender.sendMoney(receiver.accountId, new BN('1')); + + const responseWithString = await provider.txStatus(outcome.transaction.hash, sender.accountId); + const responseWithUint8Array = await provider.txStatus(base58.decode(outcome.transaction.hash), sender.accountId); + expect(responseWithString).toMatchObject(outcome); + expect(responseWithUint8Array).toMatchObject(outcome); +})); + +test('txStatusReciept with string hash and buffer hash', withProvider(async(provider) => { + const near = await testUtils.setUpTestConnection(); + const sender = await testUtils.createAccount(near); + const receiver = await testUtils.createAccount(near); + const outcome = await sender.sendMoney(receiver.accountId, new BN('1')); + const reciepts = await provider.sendJsonRpc('EXPERIMENTAL_tx_status', [outcome.transaction.hash, sender.accountId]); + + const responseWithString = await provider.txStatusReceipts(outcome.transaction.hash, sender.accountId); + const responseWithUint8Array = await provider.txStatusReceipts(base58.decode(outcome.transaction.hash), sender.accountId); + expect(responseWithString).toMatchObject(reciepts); + expect(responseWithUint8Array).toMatchObject(reciepts); +})); + +test('json rpc query account', withProvider(async (provider) => { + const near = await testUtils.setUpTestConnection(); + const account = await testUtils.createAccount(near); + let response = await provider.query(`account/${account.accountId}`, ''); + expect(response.code_hash).toEqual('11111111111111111111111111111111'); +})); + +test('json rpc query view_state', withProvider(async (provider) => { + const near = await testUtils.setUpTestConnection(); + const account = await testUtils.createAccount(near); + const contract = await testUtils.deployContract(account, testUtils.generateUniqueString('test')); + + await contract.setValue({ args: { value: 'hello' }}); + + return testUtils.waitFor(async() => { + const response = await provider.query({ + request_type: 'view_state', + finality: 'final', + account_id: contract.contractId, + prefix_base64: '' + }); + expect(response).toEqual({ + block_height: expect.any(Number), + block_hash: expect.any(String), + values: [ + { key: 'bmFtZQ==', value: 'aGVsbG8=', proof: [] } + ], + proof: [] + }); + }); +})); + +test('json rpc query view_code', withProvider(async (provider) => { + const near = await testUtils.setUpTestConnection(); + const account = await testUtils.createAccount(near); + const contract = await testUtils.deployContract(account, testUtils.generateUniqueString('test')); + + return testUtils.waitFor(async() => { + const response = await provider.query({ + request_type: 'view_code', + finality: 'final', + account_id: contract.contractId + }); + + expect(response).toEqual({ + block_height: expect.any(Number), + block_hash: expect.any(String), + code_base64: expect.any(String), + hash: expect.any(String) + }); + }); +})); + +test('json rpc query call_function', withProvider(async (provider) => { + const near = await testUtils.setUpTestConnection(); + const account = await testUtils.createAccount(near); + const contract = await testUtils.deployContract(account, testUtils.generateUniqueString('test')); + + await contract.setValue({ args: { value: 'hello' }}); + + return testUtils.waitFor(async() => { + const response = await provider.query({ + request_type: 'call_function', + finality: 'final', + account_id: contract.contractId, + method_name: 'getValue', + args_base64: '' + }); + expect(response).toEqual({ + block_height: expect.any(Number), + block_hash: expect.any(String), + logs: [], + result: [ + 34, + 104, + 101, + 108, + 108, + 111, + 34 + ] + }); + }); +})); + +test('json rpc light client proof', async() => { + const near = await testUtils.setUpTestConnection(); + const workingAccount = await testUtils.createAccount(near); + const executionOutcome = await workingAccount.sendMoney(workingAccount.accountId, new BN(10000)); + const provider = near.connection.provider; + + async function waitForStatusMatching(isMatching) { + const MAX_ATTEMPTS = 10; + for (let i = 0; i < MAX_ATTEMPTS; i++) { + await testUtils.sleep(500); + const nodeStatus = await provider.status(); + if (isMatching(nodeStatus)) { + return nodeStatus; + } + } + throw new Error(`Exceeded ${MAX_ATTEMPTS} attempts waiting for matching node status.`); + } + + const comittedStatus = await waitForStatusMatching(status => + status.sync_info.latest_block_hash !== executionOutcome.transaction_outcome.block_hash); + const BLOCKS_UNTIL_FINAL = 2; + const finalizedStatus = await waitForStatusMatching(status => + status.sync_info.latest_block_height > comittedStatus.sync_info.latest_block_height + BLOCKS_UNTIL_FINAL); + + const block = await provider.block({ blockId: finalizedStatus.sync_info.latest_block_hash }); + const lightClientHead = block.header.last_final_block; + let lightClientRequest = { + type: 'transaction', + light_client_head: lightClientHead, + transaction_hash: executionOutcome.transaction.hash, + sender_id: workingAccount.accountId, + }; + const lightClientProof = await provider.lightClientProof(lightClientRequest); + expect('prev_block_hash' in lightClientProof.block_header_lite).toBe(true); + expect('inner_rest_hash' in lightClientProof.block_header_lite).toBe(true); + expect('inner_lite' in lightClientProof.block_header_lite).toBe(true); + expect(lightClientProof.outcome_proof.id).toEqual(executionOutcome.transaction_outcome.id); + expect('block_hash' in lightClientProof.outcome_proof).toBe(true); + expect(lightClientProof.outcome_root_proof).toEqual([]); + expect(lightClientProof.block_proof.length).toBeGreaterThan(0); + + // pass nonexistent hash for light client head will fail + lightClientRequest = { + type: 'transaction', + light_client_head: '11111111111111111111111111111111', + transaction_hash: executionOutcome.transaction.hash, + sender_id: workingAccount.accountId, + }; + await expect(provider.lightClientProof(lightClientRequest)).rejects.toThrow('DB Not Found Error'); + + // Use old block hash as light client head should fail + lightClientRequest = { + type: 'transaction', + light_client_head: executionOutcome.transaction_outcome.block_hash, + transaction_hash: executionOutcome.transaction.hash, + sender_id: workingAccount.accountId, + }; + + await expect(provider.lightClientProof(lightClientRequest)).rejects.toThrow(/.+ block .+ is ahead of head block .+/); +}); diff --git a/packages/accounts/test/test-utils.js b/packages/accounts/test/test-utils.js new file mode 100644 index 0000000000..f0d281103e --- /dev/null +++ b/packages/accounts/test/test-utils.js @@ -0,0 +1,127 @@ +import { KeyPair } from '@near-js/crypto'; +import { InMemoryKeyStore } from '@near-js/keystores'; +import BN from 'bn.js'; +import { promises as fs } from 'fs'; + +import { Account, AccountMultisig, Contract, Connection, LocalAccountCreator } from '../lib/esm'; +import getConfig from './config.js'; + +const networkId = 'unittest'; + +const HELLO_WASM_PATH = process.env.HELLO_WASM_PATH || 'node_modules/near-hello/dist/main.wasm'; +const HELLO_WASM_BALANCE = new BN('10000000000000000000000000'); +const HELLO_WASM_METHODS = { + viewMethods: ['getValue', 'getLastResult'], + changeMethods: ['setValue', 'callPromise'] +}; +const MULTISIG_WASM_PATH = process.env.MULTISIG_WASM_PATH || './test/wasm/multisig.wasm'; +// Length of a random account. Set to 40 because in the protocol minimal allowed top-level account length should be at +// least 32. +const RANDOM_ACCOUNT_LENGTH = 40; + +async function setUpTestConnection() { + const keyStore = new InMemoryKeyStore(); + const config = Object.assign(getConfig(process.env.NODE_ENV || 'test'), { + networkId, + keyStore + }); + + if (config.masterAccount) { + // full accessKey on ci-testnet, dedicated rpc for tests. + await keyStore.setKey(networkId, config.masterAccount, KeyPair.fromString('ed25519:2wyRcSwSuHtRVmkMCGjPwnzZmQLeXLzLLyED1NDMt4BjnKgQL6tF85yBx6Jr26D2dUNeC716RBoTxntVHsegogYw')); + } + + const connection = Connection.fromConfig({ + networkId: config.networkId, + provider: { type: 'JsonRpcProvider', args: { url: config.nodeUrl, headers: config.headers } }, + signer: { type: 'InMemorySigner', keyStore: config.keyStore }, + }); + + return { + accountCreator: new LocalAccountCreator(new Account(connection, config.masterAccount), new BN('500000000000000000000000000')), + connection, + }; +} + +// Generate some unique string of length at least RANDOM_ACCOUNT_LENGTH with a given prefix using the alice nonce. +function generateUniqueString(prefix) { + let result = `${prefix}-${Date.now()}-${Math.round(Math.random() * 1000000)}`; + let add_symbols = Math.max(RANDOM_ACCOUNT_LENGTH - result.length, 1); + for (let i = add_symbols; i > 0; --i) result += '0'; + return result; +} + +async function createAccount({ accountCreator, connection }) { + const newAccountName = generateUniqueString('test'); + const newPublicKey = await connection.signer.createKey(newAccountName, networkId); + await accountCreator.createAccount(newAccountName, newPublicKey); + return new Account(connection, newAccountName); +} + +async function createAccountMultisig({ accountCreator, connection }, options) { + const newAccountName = generateUniqueString('test'); + const newPublicKey = await connection.signer.createKey(newAccountName, networkId); + await accountCreator.createAccount(newAccountName, newPublicKey); + // add a confirm key for multisig (contract helper sim) + + try { + const confirmKeyPair = KeyPair.fromRandom('ed25519'); + const { publicKey } = confirmKeyPair; + const accountMultisig = new AccountMultisig(connection, newAccountName, options); + accountMultisig.useConfirmKey = async () => { + await connection.signer.setKey(networkId, options.masterAccount, confirmKeyPair); + }; + accountMultisig.getRecoveryMethods = () => ({ data: [] }); + accountMultisig.postSignedJson = async (path) => { + switch (path) { + case '/2fa/getAccessKey': return { publicKey }; + } + }; + await accountMultisig.deployMultisig(new Uint8Array([...(await fs.readFile(MULTISIG_WASM_PATH))])); + return accountMultisig; + } catch (e) { + console.log(e); + } +} + +async function deployContract(workingAccount, contractId) { + const newPublicKey = await workingAccount.connection.signer.createKey(contractId, networkId); + const data = [...(await fs.readFile(HELLO_WASM_PATH))]; + await workingAccount.createAndDeployContract(contractId, newPublicKey, data, HELLO_WASM_BALANCE); + return new Contract(workingAccount, contractId, HELLO_WASM_METHODS); +} + +function sleep(time) { + return new Promise(function (resolve) { + setTimeout(resolve, time); + }); +} + +function waitFor(fn) { + const _waitFor = async (count = 10) => { + try { + return await fn(); + } catch (e) { + if (count > 0) { + await sleep(500); + return _waitFor(count - 1); + } + else throw e; + } + }; + + return _waitFor(); +} + +export default { + setUpTestConnection, + networkId, + generateUniqueString, + createAccount, + createAccountMultisig, + deployContract, + HELLO_WASM_PATH, + HELLO_WASM_BALANCE, + sleep, + waitFor, +}; diff --git a/packages/accounts/test/wasm/multisig.wasm b/packages/accounts/test/wasm/multisig.wasm new file mode 100755 index 0000000000..c904c5b784 Binary files /dev/null and b/packages/accounts/test/wasm/multisig.wasm differ diff --git a/packages/accounts/tsconfig.cjs.json b/packages/accounts/tsconfig.cjs.json new file mode 100644 index 0000000000..78ee67801b --- /dev/null +++ b/packages/accounts/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "tsconfig/node.json", + "compilerOptions": { + "preserveSymlinks": false, + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/accounts/tsconfig.esm.json b/packages/accounts/tsconfig.esm.json new file mode 100644 index 0000000000..90660683cf --- /dev/null +++ b/packages/accounts/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "tsconfig/esm.json", + "compilerOptions": { + "preserveSymlinks": false, + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/crypto/README.md b/packages/crypto/README.md new file mode 100644 index 0000000000..2b21f6c893 --- /dev/null +++ b/packages/crypto/README.md @@ -0,0 +1,16 @@ +# @near-js/crypto + +A collection of classes and types for working with cryptographic key pairs. + +## Modules + +- [PublicKey](src/public_key.ts) representation of a public key capable of verifying signatures +- [KeyPairBase](src/key_pair_base.ts) abstract class representing a key pair +- [KeyPair](src/key_pair.ts) abstract extension of `KeyPairBase` with static methods for parsing and generating key pairs +- [KeyPairEd25519](src/key_pair_ed25519.ts) implementation of `KeyPairBase` using [Ed25519](https://en.wikipedia.org/wiki/EdDSA#Ed25519) +- [Constants](src/constants.ts) keypair-specific constants + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/crypto/jest.config.js b/packages/crypto/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/crypto/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/crypto/package.json b/packages/crypto/package.json new file mode 100644 index 0000000000..fc1c2fde21 --- /dev/null +++ b/packages/crypto/package.json @@ -0,0 +1,40 @@ +{ + "name": "@near-js/crypto", + "version": "0.0.1", + "description": "Abstractions around NEAR-compatible elliptical curves and cryptographic keys", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc", + "lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix", + "lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/types": "workspace:*", + "bn.js": "5.2.1", + "borsh": "^0.7.0", + "tweetnacl": "^1.0.1" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/crypto/src/constants.ts b/packages/crypto/src/constants.ts new file mode 100644 index 0000000000..d61ef34fa0 --- /dev/null +++ b/packages/crypto/src/constants.ts @@ -0,0 +1,4 @@ +/** All supported key types */ +export enum KeyType { + ED25519 = 0, +} diff --git a/packages/crypto/src/index.ts b/packages/crypto/src/index.ts new file mode 100644 index 0000000000..bd1487ed75 --- /dev/null +++ b/packages/crypto/src/index.ts @@ -0,0 +1,5 @@ +export { KeyType } from './constants.js'; +export { KeyPair } from './key_pair.js'; +export { Signature } from './key_pair_base.js'; +export { KeyPairEd25519 } from './key_pair_ed25519.js'; +export { PublicKey } from './public_key.js'; diff --git a/packages/crypto/src/key_pair.ts b/packages/crypto/src/key_pair.ts new file mode 100644 index 0000000000..b41650414b --- /dev/null +++ b/packages/crypto/src/key_pair.ts @@ -0,0 +1,29 @@ +import { KeyPairBase } from './key_pair_base.js'; +import { KeyPairEd25519 } from './key_pair_ed25519.js'; + +export abstract class KeyPair extends KeyPairBase { + /** + * @param curve Name of elliptical curve, case-insensitive + * @returns Random KeyPair based on the curve + */ + static fromRandom(curve: string): KeyPair { + switch (curve.toUpperCase()) { + case 'ED25519': return KeyPairEd25519.fromRandom(); + default: throw new Error(`Unknown curve ${curve}`); + } + } + + static fromString(encodedKey: string): KeyPair { + const parts = encodedKey.split(':'); + if (parts.length === 1) { + return new KeyPairEd25519(parts[0]); + } else if (parts.length === 2) { + switch (parts[0].toUpperCase()) { + case 'ED25519': return new KeyPairEd25519(parts[1]); + default: throw new Error(`Unknown curve: ${parts[0]}`); + } + } else { + throw new Error('Invalid encoded key format, must be :'); + } + } +} diff --git a/packages/crypto/src/key_pair_base.ts b/packages/crypto/src/key_pair_base.ts new file mode 100644 index 0000000000..619eb596b2 --- /dev/null +++ b/packages/crypto/src/key_pair_base.ts @@ -0,0 +1,13 @@ +import { PublicKey } from './public_key.js'; + +export interface Signature { + signature: Uint8Array; + publicKey: PublicKey; +} + +export abstract class KeyPairBase { + abstract sign(message: Uint8Array): Signature; + abstract verify(message: Uint8Array, signature: Uint8Array): boolean; + abstract toString(): string; + abstract getPublicKey(): PublicKey; +} diff --git a/packages/crypto/src/key_pair_ed25519.ts b/packages/crypto/src/key_pair_ed25519.ts new file mode 100644 index 0000000000..a91e29bf15 --- /dev/null +++ b/packages/crypto/src/key_pair_ed25519.ts @@ -0,0 +1,59 @@ +import { baseEncode, baseDecode } from 'borsh'; +import nacl from 'tweetnacl'; + +import { KeyType } from './constants.js'; +import { KeyPairBase, Signature} from './key_pair_base.js'; +import { PublicKey } from './public_key.js'; + +/** + * This class provides key pair functionality for Ed25519 curve: + * generating key pairs, encoding key pairs, signing and verifying. + */ +export class KeyPairEd25519 extends KeyPairBase { + readonly publicKey: PublicKey; + readonly secretKey: string; + + /** + * Construct an instance of key pair given a secret key. + * It's generally assumed that these are encoded in base58. + * @param {string} secretKey + */ + constructor(secretKey: string) { + super(); + const keyPair = nacl.sign.keyPair.fromSecretKey(baseDecode(secretKey)); + this.publicKey = new PublicKey({ keyType: KeyType.ED25519, data: keyPair.publicKey }); + this.secretKey = secretKey; + } + + /** + * Generate a new random keypair. + * @example + * const keyRandom = KeyPair.fromRandom(); + * keyRandom.publicKey + * // returns [PUBLIC_KEY] + * + * keyRandom.secretKey + * // returns [SECRET_KEY] + */ + static fromRandom() { + const newKeyPair = nacl.sign.keyPair(); + return new KeyPairEd25519(baseEncode(newKeyPair.secretKey)); + } + + sign(message: Uint8Array): Signature { + const signature = nacl.sign.detached(message, baseDecode(this.secretKey)); + return { signature, publicKey: this.publicKey }; + } + + verify(message: Uint8Array, signature: Uint8Array): boolean { + return this.publicKey.verify(message, signature); + } + + toString(): string { + return `ed25519:${this.secretKey}`; + } + + getPublicKey(): PublicKey { + return this.publicKey; + } +} diff --git a/packages/crypto/src/public_key.ts b/packages/crypto/src/public_key.ts new file mode 100644 index 0000000000..86fd0f3081 --- /dev/null +++ b/packages/crypto/src/public_key.ts @@ -0,0 +1,56 @@ +import { Assignable } from '@near-js/types'; +import { baseEncode, baseDecode } from 'borsh'; +import nacl from 'tweetnacl'; + +import { KeyType } from './constants.js'; + +function key_type_to_str(keyType: KeyType): string { + switch (keyType) { + case KeyType.ED25519: return 'ed25519'; + default: throw new Error(`Unknown key type ${keyType}`); + } +} + +function str_to_key_type(keyType: string): KeyType { + switch (keyType.toLowerCase()) { + case 'ed25519': return KeyType.ED25519; + default: throw new Error(`Unknown key type ${keyType}`); + } +} + +/** + * PublicKey representation that has type and bytes of the key. + */ +export class PublicKey extends Assignable { + keyType: KeyType; + data: Uint8Array; + + static from(value: string | PublicKey): PublicKey { + if (typeof value === 'string') { + return PublicKey.fromString(value); + } + return value; + } + + static fromString(encodedKey: string): PublicKey { + const parts = encodedKey.split(':'); + if (parts.length === 1) { + return new PublicKey({ keyType: KeyType.ED25519, data: baseDecode(parts[0]) }); + } else if (parts.length === 2) { + return new PublicKey({ keyType: str_to_key_type(parts[0]), data: baseDecode(parts[1]) }); + } else { + throw new Error('Invalid encoded key format, must be :'); + } + } + + toString(): string { + return `${key_type_to_str(this.keyType)}:${baseEncode(this.data)}`; + } + + verify(message: Uint8Array, signature: Uint8Array): boolean { + switch (this.keyType) { + case KeyType.ED25519: return nacl.sign.detached.verify(message, signature, this.data); + default: throw new Error(`Unknown key type ${this.keyType}`); + } + } +} diff --git a/packages/crypto/test/key_pair.test.js b/packages/crypto/test/key_pair.test.js new file mode 100644 index 0000000000..8aa7b611b8 --- /dev/null +++ b/packages/crypto/test/key_pair.test.js @@ -0,0 +1,42 @@ +import { baseEncode } from 'borsh'; +import { sha256 } from 'js-sha256'; + +import { KeyPair, KeyPairEd25519, PublicKey } from '../lib/esm'; + +test('test sign and verify', async () => { + const keyPair = new KeyPairEd25519('26x56YPzPDro5t2smQfGcYAPy3j7R2jB2NUb7xKbAGK23B6x4WNQPh3twb6oDksFov5X8ts5CtntUNbpQpAKFdbR'); + expect(keyPair.publicKey.toString()).toEqual('ed25519:AYWv9RAN1hpSQA4p1DLhCNnpnNXwxhfH9qeHN8B4nJ59'); + const message = new Uint8Array(sha256.array('message')); + const signature = keyPair.sign(message); + expect(baseEncode(signature.signature)).toEqual('26gFr4xth7W9K7HPWAxq3BLsua8oTy378mC1MYFiEXHBBpeBjP8WmJEJo8XTBowetvqbRshcQEtBUdwQcAqDyP8T'); +}); + +test('test sign and verify with random', async () => { + const keyPair = KeyPairEd25519.fromRandom(); + const message = new Uint8Array(sha256.array('message')); + const signature = keyPair.sign(message); + expect(keyPair.verify(message, signature.signature)).toBeTruthy(); +}); + +test('test sign and verify with public key', async () => { + const keyPair = new KeyPairEd25519('5JueXZhEEVqGVT5powZ5twyPP8wrap2K7RdAYGGdjBwiBdd7Hh6aQxMP1u3Ma9Yanq1nEv32EW7u8kUJsZ6f315C'); + const message = new Uint8Array(sha256.array('message')); + const signature = keyPair.sign(message); + const publicKey = PublicKey.from('ed25519:EWrekY1deMND7N3Q7Dixxj12wD7AVjFRt2H9q21QHUSW'); + expect(publicKey.verify(message, signature.signature)).toBeTruthy(); +}); + +test('test from secret', async () => { + const keyPair = new KeyPairEd25519('5JueXZhEEVqGVT5powZ5twyPP8wrap2K7RdAYGGdjBwiBdd7Hh6aQxMP1u3Ma9Yanq1nEv32EW7u8kUJsZ6f315C'); + expect(keyPair.publicKey.toString()).toEqual('ed25519:EWrekY1deMND7N3Q7Dixxj12wD7AVjFRt2H9q21QHUSW'); +}); + +test('convert to string', async () => { + const keyPair = KeyPairEd25519.fromRandom(); + const newKeyPair = KeyPair.fromString(keyPair.toString()); + expect(newKeyPair.secretKey).toEqual(keyPair.secretKey); + + const keyString = 'ed25519:2wyRcSwSuHtRVmkMCGjPwnzZmQLeXLzLLyED1NDMt4BjnKgQL6tF85yBx6Jr26D2dUNeC716RBoTxntVHsegogYw'; + const keyPair2 = KeyPair.fromString(keyString); + expect(keyPair2.toString()).toEqual(keyString); +}); diff --git a/packages/crypto/tsconfig.cjs.json b/packages/crypto/tsconfig.cjs.json new file mode 100644 index 0000000000..b055ecfb97 --- /dev/null +++ b/packages/crypto/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/node.json", + "compilerOptions": { + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/crypto/tsconfig.esm.json b/packages/crypto/tsconfig.esm.json new file mode 100644 index 0000000000..5f3d7bd793 --- /dev/null +++ b/packages/crypto/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/example-esm/index.js b/packages/example-esm/index.js new file mode 100644 index 0000000000..ee68c00626 --- /dev/null +++ b/packages/example-esm/index.js @@ -0,0 +1,11 @@ +import { Account, Connection } from '@near-js/accounts'; + +const account = new Account(Connection.fromConfig({ + networkId: 'mainnet', + provider: { type: 'JsonRpcProvider', args: { url: 'https://rpc.testnet.near.org' } }, + signer: { type: 'InMemorySigner', keyStore: {} }, +}), 'gornt.testnet'); + +(async function () { + console.log(await account.getAccessKeys()); +}()); diff --git a/packages/example-esm/package.json b/packages/example-esm/package.json new file mode 100644 index 0000000000..d63ca32adb --- /dev/null +++ b/packages/example-esm/package.json @@ -0,0 +1,17 @@ +{ + "name": "example-esm", + "private": true, + "version": "0.0.0", + "description": "", + "main": "index.js", + "type": "module", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "@near-js/accounts": "workspace:*" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/packages/example-vite/.gitignore b/packages/example-vite/.gitignore new file mode 100644 index 0000000000..a547bf36d8 --- /dev/null +++ b/packages/example-vite/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/packages/example-vite/README.md b/packages/example-vite/README.md new file mode 100644 index 0000000000..ef72fd5242 --- /dev/null +++ b/packages/example-vite/README.md @@ -0,0 +1,18 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/packages/example-vite/package.json b/packages/example-vite/package.json new file mode 100644 index 0000000000..83fce60f83 --- /dev/null +++ b/packages/example-vite/package.json @@ -0,0 +1,22 @@ +{ + "name": "example-vite", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@near-js/accounts": "workspace:*", + "dayjs": "^1.11.7", + "typescript": "^4.9.4", + "vue": "^3.2.45" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^4.0.0", + "vite": "^4.0.0", + "vue-tsc": "^1.0.11" + } +} \ No newline at end of file diff --git a/packages/example-vite/public/vite.svg b/packages/example-vite/public/vite.svg new file mode 100644 index 0000000000..e7b8dfb1b2 --- /dev/null +++ b/packages/example-vite/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/example-vite/src/App.vue b/packages/example-vite/src/App.vue new file mode 100644 index 0000000000..fb679f1d51 --- /dev/null +++ b/packages/example-vite/src/App.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/packages/example-vite/src/assets/vue.svg b/packages/example-vite/src/assets/vue.svg new file mode 100644 index 0000000000..770e9d333e --- /dev/null +++ b/packages/example-vite/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/example-vite/src/components/HelloWorld.vue b/packages/example-vite/src/components/HelloWorld.vue new file mode 100644 index 0000000000..dccc69c4ab --- /dev/null +++ b/packages/example-vite/src/components/HelloWorld.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/packages/example-vite/src/main.ts b/packages/example-vite/src/main.ts new file mode 100644 index 0000000000..2425c0f745 --- /dev/null +++ b/packages/example-vite/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/packages/example-vite/src/style.css b/packages/example-vite/src/style.css new file mode 100644 index 0000000000..0192f9aac9 --- /dev/null +++ b/packages/example-vite/src/style.css @@ -0,0 +1,81 @@ +:root { + font-family: Inter, Avenir, Helvetica, Arial, sans-serif; + font-size: 16px; + line-height: 24px; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + -webkit-text-size-adjust: 100%; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/packages/example-vite/src/vite-env.d.ts b/packages/example-vite/src/vite-env.d.ts new file mode 100644 index 0000000000..11f02fe2a0 --- /dev/null +++ b/packages/example-vite/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/example-vite/tsconfig.json b/packages/example-vite/tsconfig.json new file mode 100644 index 0000000000..b557c4047c --- /dev/null +++ b/packages/example-vite/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "module": "ESNext", + "moduleResolution": "Node", + "strict": true, + "jsx": "preserve", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "lib": ["ESNext", "DOM"], + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/packages/example-vite/tsconfig.node.json b/packages/example-vite/tsconfig.node.json new file mode 100644 index 0000000000..9d31e2aed9 --- /dev/null +++ b/packages/example-vite/tsconfig.node.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "composite": true, + "module": "ESNext", + "moduleResolution": "Node", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/packages/example-vite/vite.config.ts b/packages/example-vite/vite.config.ts new file mode 100644 index 0000000000..411e108c07 --- /dev/null +++ b/packages/example-vite/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [vue()], + define: { + global: {}, + } +}) diff --git a/packages/keystores-browser/README.md b/packages/keystores-browser/README.md new file mode 100644 index 0000000000..8d47cf76a7 --- /dev/null +++ b/packages/keystores-browser/README.md @@ -0,0 +1,12 @@ +# @near-js/keystores-browser + +A collection of classes for managing keys in a web browser execution context. + +## Modules + +- [BrowserLocalStorageKeyStore](src/browser_local_storage_key_store.ts) implementation of [KeyStore](../keystores/src/keystore.ts) storing unencrypted keys in browser LocalStorage + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/keystores-browser/jest.config.js b/packages/keystores-browser/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/keystores-browser/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/keystores-browser/package.json b/packages/keystores-browser/package.json new file mode 100644 index 0000000000..40df10721d --- /dev/null +++ b/packages/keystores-browser/package.json @@ -0,0 +1,39 @@ +{ + "name": "@near-js/keystores-browser", + "version": "0.0.1", + "description": "KeyStore implementation for working with keys in browser LocalStorage", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc", + "lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix", + "lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/crypto": "workspace:*", + "@near-js/keystores": "workspace:*" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "localstorage-memory": "^1.0.3", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/keystores-browser/src/browser_local_storage_key_store.ts b/packages/keystores-browser/src/browser_local_storage_key_store.ts new file mode 100644 index 0000000000..0fc42b5221 --- /dev/null +++ b/packages/keystores-browser/src/browser_local_storage_key_store.ts @@ -0,0 +1,137 @@ +import { KeyPair } from '@near-js/crypto'; +import { KeyStore } from '@near-js/keystores'; + +const LOCAL_STORAGE_KEY_PREFIX = 'near-api-js:keystore:'; + +/** + * This class is used to store keys in the browsers local storage. + * + * @see [https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store](https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store) + * @example + * ```js + * import { connect, keyStores } from 'near-api-js'; + * + * const keyStore = new keyStores.BrowserLocalStorageKeyStore(); + * const config = { + * keyStore, // instance of BrowserLocalStorageKeyStore + * networkId: 'testnet', + * nodeUrl: 'https://rpc.testnet.near.org', + * walletUrl: 'https://wallet.testnet.near.org', + * helperUrl: 'https://helper.testnet.near.org', + * explorerUrl: 'https://explorer.testnet.near.org' + * }; + * + * // inside an async function + * const near = await connect(config) + * ``` + */ +export class BrowserLocalStorageKeyStore extends KeyStore { + /** @hidden */ + private localStorage: any; + /** @hidden */ + private prefix: string; + + /** + * @param localStorage defaults to window.localStorage + * @param prefix defaults to `near-api-js:keystore:` + */ + constructor(localStorage: any = window.localStorage, prefix = LOCAL_STORAGE_KEY_PREFIX) { + super(); + this.localStorage = localStorage; + this.prefix = prefix; + } + + /** + * Stores a {@link utils/key_pair!KeyPair} in local storage. + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @param keyPair The key pair to store in local storage + */ + async setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise { + this.localStorage.setItem(this.storageKeyForSecretKey(networkId, accountId), keyPair.toString()); + } + + /** + * Gets a {@link utils/key_pair!KeyPair} from local storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @returns {Promise} + */ + async getKey(networkId: string, accountId: string): Promise { + const value = this.localStorage.getItem(this.storageKeyForSecretKey(networkId, accountId)); + if (!value) { + return null; + } + return KeyPair.fromString(value); + } + + /** + * Removes a {@link utils/key_pair!KeyPair} from local storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + */ + async removeKey(networkId: string, accountId: string): Promise { + this.localStorage.removeItem(this.storageKeyForSecretKey(networkId, accountId)); + } + + /** + * Removes all items that start with `prefix` from local storage + */ + async clear(): Promise { + for (const key of this.storageKeys()) { + if (key.startsWith(this.prefix)) { + this.localStorage.removeItem(key); + } + } + } + + /** + * Get the network(s) from local storage + * @returns {Promise} + */ + async getNetworks(): Promise { + const result = new Set(); + for (const key of this.storageKeys()) { + if (key.startsWith(this.prefix)) { + const parts = key.substring(this.prefix.length).split(':'); + result.add(parts[1]); + } + } + return Array.from(result.values()); + } + + /** + * Gets the account(s) from local storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + */ + async getAccounts(networkId: string): Promise { + const result = new Array(); + for (const key of this.storageKeys()) { + if (key.startsWith(this.prefix)) { + const parts = key.substring(this.prefix.length).split(':'); + if (parts[1] === networkId) { + result.push(parts[0]); + } + } + } + return result; + } + + /** + * @hidden + * Helper function to retrieve a local storage key + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the storage keythat's sought + * @returns {string} An example might be: `near-api-js:keystore:near-friend:default` + */ + private storageKeyForSecretKey(networkId: string, accountId: string): string { + return `${this.prefix}${accountId}:${networkId}`; + } + + /** @hidden */ + private *storageKeys(): IterableIterator { + for (let i = 0; i < this.localStorage.length; i++) { + yield this.localStorage.key(i); + } + } +} diff --git a/packages/keystores-browser/src/index.ts b/packages/keystores-browser/src/index.ts new file mode 100644 index 0000000000..2efe7c05b7 --- /dev/null +++ b/packages/keystores-browser/src/index.ts @@ -0,0 +1 @@ +export { BrowserLocalStorageKeyStore } from './browser_local_storage_key_store'; diff --git a/packages/keystores-browser/test/browser_keystore.test.js b/packages/keystores-browser/test/browser_keystore.test.js new file mode 100644 index 0000000000..9d6609a9e1 --- /dev/null +++ b/packages/keystores-browser/test/browser_keystore.test.js @@ -0,0 +1,14 @@ +import localStorageMemory from 'localstorage-memory'; + +import { BrowserLocalStorageKeyStore } from '../lib/esm'; +import { shouldStoreAndRetrieveKeys } from './keystore_common.js'; + +describe('Browser keystore', () => { + let ctx = {}; + + beforeAll(async () => { + ctx.keyStore = new BrowserLocalStorageKeyStore(localStorageMemory); + }); + + shouldStoreAndRetrieveKeys(ctx); +}); diff --git a/packages/keystores-browser/test/keystore_common.js b/packages/keystores-browser/test/keystore_common.js new file mode 100644 index 0000000000..ded6130740 --- /dev/null +++ b/packages/keystores-browser/test/keystore_common.js @@ -0,0 +1,54 @@ +import { KeyPairEd25519 } from '@near-js/crypto'; + +const NETWORK_ID_SINGLE_KEY = 'singlekeynetworkid'; +const ACCOUNT_ID_SINGLE_KEY = 'singlekey_accountid'; +const KEYPAIR_SINGLE_KEY = new KeyPairEd25519('2wyRcSwSuHtRVmkMCGjPwnzZmQLeXLzLLyED1NDMt4BjnKgQL6tF85yBx6Jr26D2dUNeC716RBoTxntVHsegogYw'); + +export const shouldStoreAndRetrieveKeys = ctx => { + beforeEach(async () => { + await ctx.keyStore.clear(); + await ctx.keyStore.setKey(NETWORK_ID_SINGLE_KEY, ACCOUNT_ID_SINGLE_KEY, KEYPAIR_SINGLE_KEY); + }); + + test('Get all keys with empty network returns empty list', async () => { + const emptyList = await ctx.keyStore.getAccounts('emptynetwork'); + expect(emptyList).toEqual([]); + }); + + test('Get all keys with single key in keystore', async () => { + const accountIds = await ctx.keyStore.getAccounts(NETWORK_ID_SINGLE_KEY); + expect(accountIds).toEqual([ACCOUNT_ID_SINGLE_KEY]); + }); + + test('Get not-existing account', async () => { + expect(await ctx.keyStore.getKey('somenetwork', 'someaccount')).toBeNull(); + }); + + test('Get account id from a network with single key', async () => { + const key = await ctx.keyStore.getKey(NETWORK_ID_SINGLE_KEY, ACCOUNT_ID_SINGLE_KEY); + expect(key).toEqual(KEYPAIR_SINGLE_KEY); + }); + + test('Get networks', async () => { + const networks = await ctx.keyStore.getNetworks(); + expect(networks).toEqual([NETWORK_ID_SINGLE_KEY]); + }); + + test('Add two keys to network and retrieve them', async () => { + const networkId = 'twoKeyNetwork'; + const accountId1 = 'acc1'; + const accountId2 = 'acc2'; + const key1Expected = KeyPairEd25519.fromRandom(); + const key2Expected = KeyPairEd25519.fromRandom(); + await ctx.keyStore.setKey(networkId, accountId1, key1Expected); + await ctx.keyStore.setKey(networkId, accountId2, key2Expected); + const key1 = await ctx.keyStore.getKey(networkId, accountId1); + const key2 = await ctx.keyStore.getKey(networkId, accountId2); + expect(key1).toEqual(key1Expected); + expect(key2).toEqual(key2Expected); + const accountIds = await ctx.keyStore.getAccounts(networkId); + expect(accountIds).toEqual([accountId1, accountId2]); + const networks = await ctx.keyStore.getNetworks(); + expect(networks).toEqual([NETWORK_ID_SINGLE_KEY, networkId]); + }); +}; diff --git a/packages/keystores-browser/tsconfig.cjs.json b/packages/keystores-browser/tsconfig.cjs.json new file mode 100644 index 0000000000..d3048afeee --- /dev/null +++ b/packages/keystores-browser/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/browser.json", + "compilerOptions": { + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/keystores-browser/tsconfig.esm.json b/packages/keystores-browser/tsconfig.esm.json new file mode 100644 index 0000000000..5a929300cb --- /dev/null +++ b/packages/keystores-browser/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/browser.esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/keystores-node/README.md b/packages/keystores-node/README.md new file mode 100644 index 0000000000..f0a935d29a --- /dev/null +++ b/packages/keystores-node/README.md @@ -0,0 +1,12 @@ +# @near-js/keystores-node + +A collection of classes and functions for managing keys in NodeJS execution context. + +## Modules + +- [UnencryptedFileSystemKeyStore](src/unencrypted_file_system_keystore.ts) implementation of [KeyStore](../keystores/src/keystore.ts) storing unencrypted keys on the local filesystem + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/keystores-node/jest.config.js b/packages/keystores-node/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/keystores-node/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/keystores-node/package.json b/packages/keystores-node/package.json new file mode 100644 index 0000000000..f030f063ee --- /dev/null +++ b/packages/keystores-node/package.json @@ -0,0 +1,38 @@ +{ + "name": "@near-js/keystores-node", + "version": "0.0.1", + "description": "KeyStore implementation for working with keys in the local filesystem", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc", + "lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix", + "lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/crypto": "workspace:*", + "@near-js/keystores": "workspace:*" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/keystores-node/src/index.ts b/packages/keystores-node/src/index.ts new file mode 100644 index 0000000000..92da05d1fe --- /dev/null +++ b/packages/keystores-node/src/index.ts @@ -0,0 +1 @@ +export { readKeyFile, UnencryptedFileSystemKeyStore } from './unencrypted_file_system_keystore'; diff --git a/packages/keystores-node/src/unencrypted_file_system_keystore.ts b/packages/keystores-node/src/unencrypted_file_system_keystore.ts new file mode 100644 index 0000000000..c47febb23d --- /dev/null +++ b/packages/keystores-node/src/unencrypted_file_system_keystore.ts @@ -0,0 +1,178 @@ +import { KeyPair } from '@near-js/crypto'; +import { KeyStore } from '@near-js/keystores'; +import fs from 'fs'; +import path from 'path'; +import { promisify as _promisify } from 'util'; + +/* remove for versions not referenced by near-api-js */ +const promisify = (fn: any) => { + if (!fn) { + return () => { + throw new Error('Trying to use unimplemented function. `fs` module not available in web build?'); + }; + } + return _promisify(fn); +}; + +const exists = promisify(fs.exists); +const readFile = promisify(fs.readFile); +const writeFile = promisify(fs.writeFile); +const unlink = promisify(fs.unlink); +const readdir = promisify(fs.readdir); +const mkdir = promisify(fs.mkdir); + +/** + * Format of the account stored on disk. + */ +interface AccountInfo { + account_id: string; + public_key: string; + private_key: string; +} + +/** @hidden */ +async function loadJsonFile(filename: string): Promise { + const content = await readFile(filename); + return JSON.parse(content.toString()); +} + +async function ensureDir(dir: string): Promise { + try { + await mkdir(dir, { recursive: true }); + } catch (err) { + if (err.code !== 'EEXIST') { throw err; } + } +} + +/** @hidden */ +export async function readKeyFile(filename: string): Promise<[string, KeyPair]> { + const accountInfo = await loadJsonFile(filename); + // The private key might be in private_key or secret_key field. + let privateKey = accountInfo.private_key; + if (!privateKey && accountInfo.secret_key) { + privateKey = accountInfo.secret_key; + } + return [accountInfo.account_id, KeyPair.fromString(privateKey)]; +} + +/** + * This class is used to store keys on the file system. + * + * @see [https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store](https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store) + * @example + * ```js + * const { homedir } = require('os'); + * const { connect, keyStores } = require('near-api-js'); + * + * const keyStore = new keyStores.UnencryptedFileSystemKeyStore(`${homedir()}/.near-credentials`); + * const config = { + * keyStore, // instance of UnencryptedFileSystemKeyStore + * networkId: 'testnet', + * nodeUrl: 'https://rpc.testnet.near.org', + * walletUrl: 'https://wallet.testnet.near.org', + * helperUrl: 'https://helper.testnet.near.org', + * explorerUrl: 'https://explorer.testnet.near.org' + * }; + * + * // inside an async function + * const near = await connect(config) + * ``` + */ +export class UnencryptedFileSystemKeyStore extends KeyStore { + /** @hidden */ + readonly keyDir: string; + + /** + * @param keyDir base directory for key storage. Keys will be stored in `keyDir/networkId/accountId.json` + */ + constructor(keyDir: string) { + super(); + this.keyDir = path.resolve(keyDir); + } + + /** + * Store a {@link utils/key_pair!KeyPair} in an unencrypted file + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @param keyPair The key pair to store in local storage + */ + async setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise { + await ensureDir(`${this.keyDir}/${networkId}`); + const content: AccountInfo = { account_id: accountId, public_key: keyPair.getPublicKey().toString(), private_key: keyPair.toString() }; + await writeFile(this.getKeyFilePath(networkId, accountId), JSON.stringify(content), { mode: 0o600 }); + } + + /** + * Gets a {@link utils/key_pair!KeyPair} from an unencrypted file + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @returns {Promise} + */ + async getKey(networkId: string, accountId: string): Promise { + // Find key / account id. + if (!await exists(this.getKeyFilePath(networkId, accountId))) { + return null; + } + const accountKeyPair = await readKeyFile(this.getKeyFilePath(networkId, accountId)); + return accountKeyPair[1]; + } + + /** + * Deletes an unencrypted file holding a {@link utils/key_pair!KeyPair} + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + */ + async removeKey(networkId: string, accountId: string): Promise { + if (await exists(this.getKeyFilePath(networkId, accountId))) { + await unlink(this.getKeyFilePath(networkId, accountId)); + } + } + + /** + * Deletes all unencrypted files from the `keyDir` path. + */ + async clear(): Promise { + for (const network of await this.getNetworks()) { + for (const account of await this.getAccounts(network)) { + await this.removeKey(network, account); + } + } + } + + /** @hidden */ + private getKeyFilePath(networkId: string, accountId: string): string { + return `${this.keyDir}/${networkId}/${accountId}.json`; + } + + /** + * Get the network(s) from files in `keyDir` + * @returns {Promise} + */ + async getNetworks(): Promise { + const files: string[] = await readdir(this.keyDir); + const result = new Array(); + files.forEach((item) => { + result.push(item); + }); + return result; + } + + /** + * Gets the account(s) files in `keyDir/networkId` + * @param networkId The targeted network. (ex. default, betanet, etc…) + */ + async getAccounts(networkId: string): Promise { + if (!await exists(`${this.keyDir}/${networkId}`)) { + return []; + } + const files: string[] = await readdir(`${this.keyDir}/${networkId}`); + return files + .filter(file => file.endsWith('.json')) + .map(file => file.replace(/.json$/, '')); + } + + /** @hidden */ + toString(): string { + return `UnencryptedFileSystemKeyStore(${this.keyDir})`; + } +} diff --git a/packages/keystores-node/test/keystore_common.js b/packages/keystores-node/test/keystore_common.js new file mode 100644 index 0000000000..ded6130740 --- /dev/null +++ b/packages/keystores-node/test/keystore_common.js @@ -0,0 +1,54 @@ +import { KeyPairEd25519 } from '@near-js/crypto'; + +const NETWORK_ID_SINGLE_KEY = 'singlekeynetworkid'; +const ACCOUNT_ID_SINGLE_KEY = 'singlekey_accountid'; +const KEYPAIR_SINGLE_KEY = new KeyPairEd25519('2wyRcSwSuHtRVmkMCGjPwnzZmQLeXLzLLyED1NDMt4BjnKgQL6tF85yBx6Jr26D2dUNeC716RBoTxntVHsegogYw'); + +export const shouldStoreAndRetrieveKeys = ctx => { + beforeEach(async () => { + await ctx.keyStore.clear(); + await ctx.keyStore.setKey(NETWORK_ID_SINGLE_KEY, ACCOUNT_ID_SINGLE_KEY, KEYPAIR_SINGLE_KEY); + }); + + test('Get all keys with empty network returns empty list', async () => { + const emptyList = await ctx.keyStore.getAccounts('emptynetwork'); + expect(emptyList).toEqual([]); + }); + + test('Get all keys with single key in keystore', async () => { + const accountIds = await ctx.keyStore.getAccounts(NETWORK_ID_SINGLE_KEY); + expect(accountIds).toEqual([ACCOUNT_ID_SINGLE_KEY]); + }); + + test('Get not-existing account', async () => { + expect(await ctx.keyStore.getKey('somenetwork', 'someaccount')).toBeNull(); + }); + + test('Get account id from a network with single key', async () => { + const key = await ctx.keyStore.getKey(NETWORK_ID_SINGLE_KEY, ACCOUNT_ID_SINGLE_KEY); + expect(key).toEqual(KEYPAIR_SINGLE_KEY); + }); + + test('Get networks', async () => { + const networks = await ctx.keyStore.getNetworks(); + expect(networks).toEqual([NETWORK_ID_SINGLE_KEY]); + }); + + test('Add two keys to network and retrieve them', async () => { + const networkId = 'twoKeyNetwork'; + const accountId1 = 'acc1'; + const accountId2 = 'acc2'; + const key1Expected = KeyPairEd25519.fromRandom(); + const key2Expected = KeyPairEd25519.fromRandom(); + await ctx.keyStore.setKey(networkId, accountId1, key1Expected); + await ctx.keyStore.setKey(networkId, accountId2, key2Expected); + const key1 = await ctx.keyStore.getKey(networkId, accountId1); + const key2 = await ctx.keyStore.getKey(networkId, accountId2); + expect(key1).toEqual(key1Expected); + expect(key2).toEqual(key2Expected); + const accountIds = await ctx.keyStore.getAccounts(networkId); + expect(accountIds).toEqual([accountId1, accountId2]); + const networks = await ctx.keyStore.getNetworks(); + expect(networks).toEqual([NETWORK_ID_SINGLE_KEY, networkId]); + }); +}; diff --git a/packages/keystores-node/test/unencrypted_file_system_keystore.test.js b/packages/keystores-node/test/unencrypted_file_system_keystore.test.js new file mode 100644 index 0000000000..166a265d3d --- /dev/null +++ b/packages/keystores-node/test/unencrypted_file_system_keystore.test.js @@ -0,0 +1,40 @@ +import { KeyPairEd25519 } from '@near-js/crypto'; +import { promises as fs } from 'fs'; +import path from 'path'; +import rimrafPkg from 'rimraf'; +import { promisify } from 'util'; + +import { UnencryptedFileSystemKeyStore } from '../lib/esm'; +import { shouldStoreAndRetrieveKeys } from './keystore_common.js'; + +const rimraf = promisify(rimrafPkg); +const KEYSTORE_PATH = '../../test-keys'; + +describe('Unencrypted file system keystore', () => { + let ctx = {}; + + beforeAll(async () => { + await rimraf(KEYSTORE_PATH); + try { + await fs.mkdir(KEYSTORE_PATH, { recursive: true }); + } catch (err) { + if (err.code !== 'EEXIST') throw err; + } + ctx.keyStore = new UnencryptedFileSystemKeyStore(KEYSTORE_PATH); + }); + + shouldStoreAndRetrieveKeys(ctx); + + it('test path resolve', async() => { + expect(ctx.keyStore.keyDir).toEqual(path.join(process.cwd(), KEYSTORE_PATH)); + }); + + it('test public key exists', async () => { + const key1 = KeyPairEd25519.fromRandom(); + await ctx.keyStore.setKey('network', 'account', key1); + const keyFilePath = ctx.keyStore.getKeyFilePath('network', 'account'); + const content = await fs.readFile(keyFilePath); + const accountInfo = JSON.parse(content.toString()); + expect(accountInfo.public_key).toEqual(key1.getPublicKey().toString()); + }); +}); diff --git a/packages/keystores-node/tsconfig.cjs.json b/packages/keystores-node/tsconfig.cjs.json new file mode 100644 index 0000000000..b055ecfb97 --- /dev/null +++ b/packages/keystores-node/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/node.json", + "compilerOptions": { + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/keystores-node/tsconfig.esm.json b/packages/keystores-node/tsconfig.esm.json new file mode 100644 index 0000000000..5f3d7bd793 --- /dev/null +++ b/packages/keystores-node/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/keystores/README.md b/packages/keystores/README.md new file mode 100644 index 0000000000..e14707454f --- /dev/null +++ b/packages/keystores/README.md @@ -0,0 +1,14 @@ +# @near-js/keystores + +A collection of classes for managing NEAR-compatible cryptographic keys. + +## Modules + +- [KeyStore](src/keystore.ts) abstract class for managing account keys +- [InMemoryKeyStore](src/in_memory_key_store.ts) implementation of `KeyStore` using an in-memory data structure local to the instance +- [MergeKeyStore](src/merge_key_store.ts) implementation of `KeyStore` aggregating multiple `KeyStore` implementations + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/keystores/jest.config.js b/packages/keystores/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/keystores/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/keystores/package.json b/packages/keystores/package.json new file mode 100644 index 0000000000..818e468cc5 --- /dev/null +++ b/packages/keystores/package.json @@ -0,0 +1,38 @@ +{ + "name": "@near-js/keystores", + "version": "0.0.1", + "description": "Key storage and management implementations", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc", + "lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix", + "lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/crypto": "workspace:*", + "@near-js/types": "workspace:*" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/keystores/src/in_memory_key_store.ts b/packages/keystores/src/in_memory_key_store.ts new file mode 100644 index 0000000000..3e831f74a0 --- /dev/null +++ b/packages/keystores/src/in_memory_key_store.ts @@ -0,0 +1,113 @@ +import { KeyPair } from '@near-js/crypto'; + +import { KeyStore } from './keystore.js'; + +/** + * Simple in-memory keystore for mainly for testing purposes. + * + * @see [https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store](https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store) + * @example + * ```js + * import { connect, keyStores, utils } from 'near-api-js'; + * + * const privateKey = '.......'; + * const keyPair = utils.KeyPair.fromString(privateKey); + * + * const keyStore = new keyStores.InMemoryKeyStore(); + * keyStore.setKey('testnet', 'example-account.testnet', keyPair); + * + * const config = { + * keyStore, // instance of InMemoryKeyStore + * networkId: 'testnet', + * nodeUrl: 'https://rpc.testnet.near.org', + * walletUrl: 'https://wallet.testnet.near.org', + * helperUrl: 'https://helper.testnet.near.org', + * explorerUrl: 'https://explorer.testnet.near.org' + * }; + * + * // inside an async function + * const near = await connect(config) + * ``` + */ +export class InMemoryKeyStore extends KeyStore { + /** @hidden */ + private keys: { [key: string]: string }; + + constructor() { + super(); + this.keys = {}; + } + + /** + * Stores a {@link utils/key_pair!KeyPair} in in-memory storage item + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @param keyPair The key pair to store in local storage + */ + async setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise { + this.keys[`${accountId}:${networkId}`] = keyPair.toString(); + } + + /** + * Gets a {@link utils/key_pair!KeyPair} from in-memory storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @returns {Promise} + */ + async getKey(networkId: string, accountId: string): Promise { + const value = this.keys[`${accountId}:${networkId}`]; + if (!value) { + return null; + } + return KeyPair.fromString(value); + } + + /** + * Removes a {@link utils/key_pair!KeyPair} from in-memory storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + */ + async removeKey(networkId: string, accountId: string): Promise { + delete this.keys[`${accountId}:${networkId}`]; + } + + /** + * Removes all {@link utils/key_pair!KeyPair} from in-memory storage + */ + async clear(): Promise { + this.keys = {}; + } + + /** + * Get the network(s) from in-memory storage + * @returns {Promise} + */ + async getNetworks(): Promise { + const result = new Set(); + Object.keys(this.keys).forEach((key) => { + const parts = key.split(':'); + result.add(parts[1]); + }); + return Array.from(result.values()); + } + + /** + * Gets the account(s) from in-memory storage + * @param networkId The targeted network. (ex. default, betanet, etc…) + */ + async getAccounts(networkId: string): Promise { + const result = new Array(); + Object.keys(this.keys).forEach((key) => { + const parts = key.split(':'); + if (parts[parts.length - 1] === networkId) { + result.push(parts.slice(0, parts.length - 1).join(':')); + } + }); + return result; + } + + /** @hidden */ + toString(): string { + return 'InMemoryKeyStore'; + } +} diff --git a/packages/keystores/src/index.ts b/packages/keystores/src/index.ts new file mode 100644 index 0000000000..49569e6327 --- /dev/null +++ b/packages/keystores/src/index.ts @@ -0,0 +1,3 @@ +export { InMemoryKeyStore } from './in_memory_key_store.js'; +export { KeyStore } from './keystore.js'; +export { MergeKeyStore } from './merge_key_store.js'; diff --git a/packages/keystores/src/keystore.ts b/packages/keystores/src/keystore.ts new file mode 100644 index 0000000000..3053e08a89 --- /dev/null +++ b/packages/keystores/src/keystore.ts @@ -0,0 +1,16 @@ +import { KeyPair } from '@near-js/crypto'; + +/** + * KeyStores are passed to {@link near!Near} via {@link near!NearConfig} + * and are used by the {@link signer!InMemorySigner} to sign transactions. + * + * @see {@link connect} + */ +export abstract class KeyStore { + abstract setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise; + abstract getKey(networkId: string, accountId: string): Promise; + abstract removeKey(networkId: string, accountId: string): Promise; + abstract clear(): Promise; + abstract getNetworks(): Promise; + abstract getAccounts(networkId: string): Promise; +} diff --git a/packages/keystores/src/merge_key_store.ts b/packages/keystores/src/merge_key_store.ts new file mode 100644 index 0000000000..24467d4e17 --- /dev/null +++ b/packages/keystores/src/merge_key_store.ts @@ -0,0 +1,135 @@ +import { KeyPair } from '@near-js/crypto'; + +import { KeyStore } from './keystore.js'; + +/** + * Keystore which can be used to merge multiple key stores into one virtual key store. + * + * @example + * ```js + * const { homedir } = require('os'); + * import { connect, keyStores, utils } from 'near-api-js'; + * + * const privateKey = '.......'; + * const keyPair = utils.KeyPair.fromString(privateKey); + * + * const inMemoryKeyStore = new keyStores.InMemoryKeyStore(); + * inMemoryKeyStore.setKey('testnet', 'example-account.testnet', keyPair); + * + * const fileSystemKeyStore = new keyStores.UnencryptedFileSystemKeyStore(`${homedir()}/.near-credentials`); + * + * const keyStore = new MergeKeyStore([ + * inMemoryKeyStore, + * fileSystemKeyStore + * ]); + * const config = { + * keyStore, // instance of MergeKeyStore + * networkId: 'testnet', + * nodeUrl: 'https://rpc.testnet.near.org', + * walletUrl: 'https://wallet.testnet.near.org', + * helperUrl: 'https://helper.testnet.near.org', + * explorerUrl: 'https://explorer.testnet.near.org' + * }; + * + * // inside an async function + * const near = await connect(config) + * ``` + */ + +interface MergeKeyStoreOptions { + writeKeyStoreIndex: number; +} + +export class MergeKeyStore extends KeyStore { + private options: MergeKeyStoreOptions; + keyStores: KeyStore[]; + + /** + * @param keyStores read calls are attempted from start to end of array + * @param options.writeKeyStoreIndex the keystore index that will receive all write calls + */ + constructor(keyStores: KeyStore[], options: MergeKeyStoreOptions = { writeKeyStoreIndex: 0 }) { + super(); + this.options = options; + this.keyStores = keyStores; + } + + /** + * Store a {@link utils/key_pair!KeyPair} to the first index of a key store array + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @param keyPair The key pair to store in local storage + */ + async setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise { + await this.keyStores[this.options.writeKeyStoreIndex].setKey(networkId, accountId, keyPair); + } + + /** + * Gets a {@link utils/key_pair!KeyPair} from the array of key stores + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + * @returns {Promise} + */ + async getKey(networkId: string, accountId: string): Promise { + for (const keyStore of this.keyStores) { + const keyPair = await keyStore.getKey(networkId, accountId); + if (keyPair) { + return keyPair; + } + } + return null; + } + + /** + * Removes a {@link utils/key_pair!KeyPair} from the array of key stores + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account tied to the key pair + */ + async removeKey(networkId: string, accountId: string): Promise { + for (const keyStore of this.keyStores) { + await keyStore.removeKey(networkId, accountId); + } + } + + /** + * Removes all items from each key store + */ + async clear(): Promise { + for (const keyStore of this.keyStores) { + await keyStore.clear(); + } + } + + /** + * Get the network(s) from the array of key stores + * @returns {Promise} + */ + async getNetworks(): Promise { + const result = new Set(); + for (const keyStore of this.keyStores) { + for (const network of await keyStore.getNetworks()) { + result.add(network); + } + } + return Array.from(result); + } + + /** + * Gets the account(s) from the array of key stores + * @param networkId The targeted network. (ex. default, betanet, etc…) + */ + async getAccounts(networkId: string): Promise { + const result = new Set(); + for (const keyStore of this.keyStores) { + for (const account of await keyStore.getAccounts(networkId)) { + result.add(account); + } + } + return Array.from(result); + } + + /** @hidden */ + toString(): string { + return `MergeKeyStore(${this.keyStores.join(', ')})`; + } +} \ No newline at end of file diff --git a/packages/keystores/test/in_memory_keystore.test.js b/packages/keystores/test/in_memory_keystore.test.js new file mode 100644 index 0000000000..d1aea1e613 --- /dev/null +++ b/packages/keystores/test/in_memory_keystore.test.js @@ -0,0 +1,12 @@ +import { InMemoryKeyStore } from '../lib/esm'; +import { shouldStoreAndRetrieveKeys } from './keystore_common.js'; + +describe('In-memory keystore', () => { + let ctx = {}; + + beforeAll(async () => { + ctx.keyStore = new InMemoryKeyStore(); + }); + + shouldStoreAndRetrieveKeys(ctx); +}); diff --git a/packages/keystores/test/keystore_common.js b/packages/keystores/test/keystore_common.js new file mode 100644 index 0000000000..ded6130740 --- /dev/null +++ b/packages/keystores/test/keystore_common.js @@ -0,0 +1,54 @@ +import { KeyPairEd25519 } from '@near-js/crypto'; + +const NETWORK_ID_SINGLE_KEY = 'singlekeynetworkid'; +const ACCOUNT_ID_SINGLE_KEY = 'singlekey_accountid'; +const KEYPAIR_SINGLE_KEY = new KeyPairEd25519('2wyRcSwSuHtRVmkMCGjPwnzZmQLeXLzLLyED1NDMt4BjnKgQL6tF85yBx6Jr26D2dUNeC716RBoTxntVHsegogYw'); + +export const shouldStoreAndRetrieveKeys = ctx => { + beforeEach(async () => { + await ctx.keyStore.clear(); + await ctx.keyStore.setKey(NETWORK_ID_SINGLE_KEY, ACCOUNT_ID_SINGLE_KEY, KEYPAIR_SINGLE_KEY); + }); + + test('Get all keys with empty network returns empty list', async () => { + const emptyList = await ctx.keyStore.getAccounts('emptynetwork'); + expect(emptyList).toEqual([]); + }); + + test('Get all keys with single key in keystore', async () => { + const accountIds = await ctx.keyStore.getAccounts(NETWORK_ID_SINGLE_KEY); + expect(accountIds).toEqual([ACCOUNT_ID_SINGLE_KEY]); + }); + + test('Get not-existing account', async () => { + expect(await ctx.keyStore.getKey('somenetwork', 'someaccount')).toBeNull(); + }); + + test('Get account id from a network with single key', async () => { + const key = await ctx.keyStore.getKey(NETWORK_ID_SINGLE_KEY, ACCOUNT_ID_SINGLE_KEY); + expect(key).toEqual(KEYPAIR_SINGLE_KEY); + }); + + test('Get networks', async () => { + const networks = await ctx.keyStore.getNetworks(); + expect(networks).toEqual([NETWORK_ID_SINGLE_KEY]); + }); + + test('Add two keys to network and retrieve them', async () => { + const networkId = 'twoKeyNetwork'; + const accountId1 = 'acc1'; + const accountId2 = 'acc2'; + const key1Expected = KeyPairEd25519.fromRandom(); + const key2Expected = KeyPairEd25519.fromRandom(); + await ctx.keyStore.setKey(networkId, accountId1, key1Expected); + await ctx.keyStore.setKey(networkId, accountId2, key2Expected); + const key1 = await ctx.keyStore.getKey(networkId, accountId1); + const key2 = await ctx.keyStore.getKey(networkId, accountId2); + expect(key1).toEqual(key1Expected); + expect(key2).toEqual(key2Expected); + const accountIds = await ctx.keyStore.getAccounts(networkId); + expect(accountIds).toEqual([accountId1, accountId2]); + const networks = await ctx.keyStore.getNetworks(); + expect(networks).toEqual([NETWORK_ID_SINGLE_KEY, networkId]); + }); +}; diff --git a/packages/keystores/test/merge_keystore.test.js b/packages/keystores/test/merge_keystore.test.js new file mode 100644 index 0000000000..9c79f7a4b5 --- /dev/null +++ b/packages/keystores/test/merge_keystore.test.js @@ -0,0 +1,36 @@ +import { KeyPairEd25519 } from '@near-js/crypto'; + +import { InMemoryKeyStore, MergeKeyStore } from '../lib/esm'; +import { shouldStoreAndRetrieveKeys } from './keystore_common.js'; + +describe('Merge keystore', () => { + let ctx = {}; + + beforeAll(async () => { + ctx.stores = [new InMemoryKeyStore(), new InMemoryKeyStore()]; + ctx.keyStore = new MergeKeyStore(ctx.stores); + }); + + it('looks up key from fallback key store if needed', async () => { + const key1 = KeyPairEd25519.fromRandom(); + await ctx.stores[1].setKey('network', 'account', key1); + expect(await ctx.keyStore.getKey('network', 'account')).toEqual(key1); + }); + + it('looks up key in proper order', async () => { + const key1 = KeyPairEd25519.fromRandom(); + const key2 = KeyPairEd25519.fromRandom(); + await ctx.stores[0].setKey('network', 'account', key1); + await ctx.stores[1].setKey('network', 'account', key2); + expect(await ctx.keyStore.getKey('network', 'account')).toEqual(key1); + }); + + it('sets keys only in first key store', async () => { + const key1 = KeyPairEd25519.fromRandom(); + await ctx.keyStore.setKey('network', 'account', key1); + expect(await ctx.stores[0].getAccounts('network')).toHaveLength(1); + expect(await ctx.stores[1].getAccounts('network')).toHaveLength(0); + }); + + shouldStoreAndRetrieveKeys(ctx); +}); diff --git a/packages/keystores/tsconfig.cjs.json b/packages/keystores/tsconfig.cjs.json new file mode 100644 index 0000000000..b055ecfb97 --- /dev/null +++ b/packages/keystores/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/node.json", + "compilerOptions": { + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/keystores/tsconfig.esm.json b/packages/keystores/tsconfig.esm.json new file mode 100644 index 0000000000..5f3d7bd793 --- /dev/null +++ b/packages/keystores/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/near-api-js/package.json b/packages/near-api-js/package.json index 095e81c4dc..036afc4d3a 100644 --- a/packages/near-api-js/package.json +++ b/packages/near-api-js/package.json @@ -11,26 +11,34 @@ "browser": "lib/browser-index.js", "types": "lib/index.d.ts", "dependencies": { + "@near-js/accounts": "workspace:*", + "@near-js/crypto": "workspace:*", + "@near-js/keystores": "workspace:*", + "@near-js/keystores-browser": "workspace:*", + "@near-js/keystores-node": "workspace:*", + "@near-js/providers": "workspace:*", + "@near-js/signers": "workspace:*", + "@near-js/transactions": "workspace:*", + "@near-js/types": "workspace:*", + "@near-js/utils": "workspace:*", + "@near-js/wallet-account": "workspace:*", "ajv": "^8.11.2", "ajv-formats": "^2.1.1", "bn.js": "5.2.1", "borsh": "^0.7.0", - "bs58": "^4.0.0", "depd": "^2.0.0", "error-polyfill": "^0.1.3", "http-errors": "^1.7.2", - "js-sha256": "^0.9.0", - "mustache": "^4.0.0", "near-abi": "0.1.1", "node-fetch": "^2.6.1", - "text-encoding-utf-8": "^1.0.2", "tweetnacl": "^1.0.1" }, "devDependencies": { "@types/bn.js": "^5.1.0", "@types/http-errors": "^1.6.1", - "@types/node": "^18.7.14", + "@types/node": "^18.11.18", "browserify": "^16.2.3", + "bs58": "^4.0.0", "bundlewatch": "^0.3.1", "concurrently": "^7.3.0", "danger": "^11.1.1", @@ -53,16 +61,14 @@ "dev": "pnpm compile -w", "build": "pnpm compile && pnpm browserify", "test": "jest test", - "lint": "concurrently \"pnpm:lint:*(!fix)\"", + "lint": "concurrently \"pnpm:lint:*(!fix) --no-error-on-unmatched-pattern\"", "lint:src": "eslint --ext .ts src", - "lint:test": "eslint --ext .js test", "lint:fix": "concurrently \"pnpm:lint:*:fix\"", "lint:src:fix": "eslint --ext .ts --fix src", "lint:test:fix": "eslint --ext .js --fix test", "prefuzz": "pnpm build", "fuzz": "jsfuzz test/fuzz/borsh-roundtrip.js test/fuzz/corpus/", "clean": "pnpm rimraf lib", - "prepare": "pnpm build", "bundlewatch": "bundlewatch" }, "bundlewatch": { @@ -79,4 +85,4 @@ "browser-exports.js" ], "author": "NEAR Inc" -} \ No newline at end of file +} diff --git a/packages/near-api-js/src/account.ts b/packages/near-api-js/src/account.ts index afffd2913c..d1883e140e 100644 --- a/packages/near-api-js/src/account.ts +++ b/packages/near-api-js/src/account.ts @@ -1,652 +1,9 @@ -import BN from 'bn.js'; - -import { - transfer, - createAccount, - signTransaction, - deployContract, - addKey, - functionCall, - fullAccessKey, - functionCallAccessKey, - deleteKey, - stake, - deleteAccount, - Action, - SignedTransaction, - stringifyJsonOrBytes -} from './transaction'; -import { FinalExecutionOutcome, TypedError, ErrorContext } from './providers'; -import { - ViewStateResult, - AccountView, - AccessKeyView, - AccessKeyViewRaw, - CodeResult, - AccessKeyList, - AccessKeyInfoView, - FunctionCallPermissionView, - BlockReference -} from './providers/provider'; -import { Connection } from './connection'; -import { baseDecode, baseEncode } from 'borsh'; -import { PublicKey } from './utils/key_pair'; -import { logWarning, PositionalArgsError } from './utils/errors'; -import { printTxOutcomeLogs, printTxOutcomeLogsAndFailures } from './utils/logging'; -import { parseResultError } from './utils/rpc_errors'; -import { DEFAULT_FUNCTION_CALL_GAS } from './constants'; - -import exponentialBackoff from './utils/exponential-backoff'; - -// Default number of retries with different nonce before giving up on a transaction. -const TX_NONCE_RETRY_NUMBER = 12; - -// Default wait until next retry in millis. -const TX_NONCE_RETRY_WAIT = 500; - -// Exponential back off for waiting to retry. -const TX_NONCE_RETRY_WAIT_BACKOFF = 1.5; - -export interface AccountBalance { - total: string; - stateStaked: string; - staked: string; - available: string; -} - -export interface AccountAuthorizedApp { - contractId: string; - amount: string; - publicKey: string; -} - -/** - * Options used to initiate sining and sending transactions - */ -export interface SignAndSendTransactionOptions { - receiverId: string; - actions: Action[]; - /** - * Metadata to send the NEAR Wallet if using it to sign transactions. - * @see {@link RequestSignTransactionsOptions} - */ - walletMeta?: string; - /** - * Callback url to send the NEAR Wallet if using it to sign transactions. - * @see {@link RequestSignTransactionsOptions} - */ - walletCallbackUrl?: string; - returnError?: boolean; -} - -/** - * Options used to initiate a function call (especially a change function call) - * @see {@link account!Account#viewFunction} to initiate a view function call - */ -export interface FunctionCallOptions { - /** The NEAR account id where the contract is deployed */ - contractId: string; - /** The name of the method to invoke */ - methodName: string; - /** - * named arguments to pass the method `{ messageText: 'my message' }` - */ - args?: object; - /** max amount of gas that method call can use */ - gas?: BN; - /** amount of NEAR (in yoctoNEAR) to send together with the call */ - attachedDeposit?: BN; - /** - * Convert input arguments into bytes array. - */ - stringify?: (input: any) => Buffer; - /** - * Is contract from JS SDK, automatically encodes args from JS SDK to binary. - */ - jsContract?: boolean; -} - -export interface ChangeFunctionCallOptions extends FunctionCallOptions { - /** - * Metadata to send the NEAR Wallet if using it to sign transactions. - * @see {@link RequestSignTransactionsOptions} - */ - walletMeta?: string; - /** - * Callback url to send the NEAR Wallet if using it to sign transactions. - * @see {@link RequestSignTransactionsOptions} - */ - walletCallbackUrl?: string; -} -export interface ViewFunctionCallOptions extends FunctionCallOptions { - parse?: (response: Uint8Array) => any; - blockQuery?: BlockReference; -} - -interface StakedBalance { - validatorId: string; - amount?: string; - error?: string; -} - -interface ActiveDelegatedStakeBalance { - stakedValidators: StakedBalance[]; - failedValidators: StakedBalance[]; - total: BN | string; -} - -function parseJsonFromRawResponse(response: Uint8Array): any { - return JSON.parse(Buffer.from(response).toString()); -} - -function bytesJsonStringify(input: any): Buffer { - return Buffer.from(JSON.stringify(input)); -} - -/** - * This class provides common account related RPC calls including signing transactions with a {@link utils/key_pair!KeyPair}. - * - * @hint Use {@link walletAccount!WalletConnection} in the browser to redirect to [NEAR Wallet](https://wallet.near.org/) for Account/key management using the {@link key_stores/browser_local_storage_key_store!BrowserLocalStorageKeyStore}. - * @see [https://docs.near.org/docs/develop/front-end/naj-quick-reference#account](https://docs.near.org/tools/near-api-js/quick-reference#account) - * @see [Account Spec](https://nomicon.io/DataStructures/Account.html) - */ -export class Account { - readonly connection: Connection; - readonly accountId: string; - - constructor(connection: Connection, accountId: string) { - this.connection = connection; - this.accountId = accountId; - } - - /** - * Returns basic NEAR account information via the `view_account` RPC query method - * @see [https://docs.near.org/api/rpc/contracts#view-account](https://docs.near.org/api/rpc/contracts#view-account) - */ - async state(): Promise { - return this.connection.provider.query({ - request_type: 'view_account', - account_id: this.accountId, - finality: 'optimistic' - }); - } - - /** - * Create a signed transaction which can be broadcast to the network - * @param receiverId NEAR account receiving the transaction - * @param actions list of actions to perform as part of the transaction - * @see {@link providers/json-rpc-provider!JsonRpcProvider#sendTransaction | JsonRpcProvider.sendTransaction} - */ - protected async signTransaction(receiverId: string, actions: Action[]): Promise<[Uint8Array, SignedTransaction]> { - const accessKeyInfo = await this.findAccessKey(receiverId, actions); - if (!accessKeyInfo) { - throw new TypedError(`Can not sign transactions for account ${this.accountId} on network ${this.connection.networkId}, no matching key pair exists for this account`, 'KeyNotFound'); - } - const { accessKey } = accessKeyInfo; - - const block = await this.connection.provider.block({ finality: 'final' }); - const blockHash = block.header.hash; - - const nonce = accessKey.nonce.add(new BN(1)); - return await signTransaction( - receiverId, nonce, actions, baseDecode(blockHash), this.connection.signer, this.accountId, this.connection.networkId - ); - } - - /** - * Sign a transaction to preform a list of actions and broadcast it using the RPC API. - * @see {@link providers/json-rpc-provider!JsonRpcProvider#sendTransaction | JsonRpcProvider.sendTransaction} - */ - async signAndSendTransaction({ receiverId, actions, returnError }: SignAndSendTransactionOptions): Promise { - let txHash, signedTx; - // TODO: TX_NONCE (different constants for different uses of exponentialBackoff?) - const result = await exponentialBackoff(TX_NONCE_RETRY_WAIT, TX_NONCE_RETRY_NUMBER, TX_NONCE_RETRY_WAIT_BACKOFF, async () => { - [txHash, signedTx] = await this.signTransaction(receiverId, actions); - const publicKey = signedTx.transaction.publicKey; - - try { - return await this.connection.provider.sendTransaction(signedTx); - } catch (error) { - if (error.type === 'InvalidNonce') { - logWarning(`Retrying transaction ${receiverId}:${baseEncode(txHash)} with new nonce.`); - delete this.accessKeyByPublicKeyCache[publicKey.toString()]; - return null; - } - if (error.type === 'Expired') { - logWarning(`Retrying transaction ${receiverId}:${baseEncode(txHash)} due to expired block hash`); - return null; - } - - error.context = new ErrorContext(baseEncode(txHash)); - throw error; - } - }); - if (!result) { - // TODO: This should have different code actually, as means "transaction not submitted for sure" - throw new TypedError('nonce retries exceeded for transaction. This usually means there are too many parallel requests with the same access key.', 'RetriesExceeded'); - } - - printTxOutcomeLogsAndFailures({ contractId: signedTx.transaction.receiverId, outcome: result }); - - // Should be falsy if result.status.Failure is null - if (!returnError && typeof result.status === 'object' && typeof result.status.Failure === 'object' && result.status.Failure !== null) { - // if error data has error_message and error_type properties, we consider that node returned an error in the old format - if (result.status.Failure.error_message && result.status.Failure.error_type) { - throw new TypedError( - `Transaction ${result.transaction_outcome.id} failed. ${result.status.Failure.error_message}`, - result.status.Failure.error_type); - } else { - throw parseResultError(result); - } - } - // TODO: if Tx is Unknown or Started. - return result; - } - - /** @hidden */ - accessKeyByPublicKeyCache: { [key: string]: AccessKeyView } = {}; - - /** - * Finds the {@link providers/provider!AccessKeyView} associated with the accounts {@link utils/key_pair!PublicKey} stored in the {@link key_stores/keystore!KeyStore}. - * - * @todo Find matching access key based on transaction (i.e. receiverId and actions) - * - * @param receiverId currently unused (see todo) - * @param actions currently unused (see todo) - * @returns `{ publicKey PublicKey; accessKey: AccessKeyView }` - */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async findAccessKey(receiverId: string, actions: Action[]): Promise<{ publicKey: PublicKey; accessKey: AccessKeyView }> { - // TODO: Find matching access key based on transaction (i.e. receiverId and actions) - const publicKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId); - if (!publicKey) { - throw new TypedError(`no matching key pair found in ${this.connection.signer}`, 'PublicKeyNotFound'); - } - - const cachedAccessKey = this.accessKeyByPublicKeyCache[publicKey.toString()]; - if (cachedAccessKey !== undefined) { - return { publicKey, accessKey: cachedAccessKey }; - } - - try { - const rawAccessKey = await this.connection.provider.query({ - request_type: 'view_access_key', - account_id: this.accountId, - public_key: publicKey.toString(), - finality: 'optimistic' - }); - - // store nonce as BN to preserve precision on big number - const accessKey = { - ...rawAccessKey, - nonce: new BN(rawAccessKey.nonce), - }; - // this function can be called multiple times and retrieve the same access key - // this checks to see if the access key was already retrieved and cached while - // the above network call was in flight. To keep nonce values in line, we return - // the cached access key. - if (this.accessKeyByPublicKeyCache[publicKey.toString()]) { - return { publicKey, accessKey: this.accessKeyByPublicKeyCache[publicKey.toString()] }; - } - - this.accessKeyByPublicKeyCache[publicKey.toString()] = accessKey; - return { publicKey, accessKey }; - } catch (e) { - if (e.type == 'AccessKeyDoesNotExist') { - return null; - } - - throw e; - } - } - - /** - * Create a new account and deploy a contract to it - * - * @param contractId NEAR account where the contract is deployed - * @param publicKey The public key to add to the created contract account - * @param data The compiled contract code - * @param amount of NEAR to transfer to the created contract account. Transfer enough to pay for storage https://docs.near.org/docs/concepts/storage-staking - */ - async createAndDeployContract(contractId: string, publicKey: string | PublicKey, data: Uint8Array, amount: BN): Promise { - const accessKey = fullAccessKey(); - await this.signAndSendTransaction({ - receiverId: contractId, - actions: [createAccount(), transfer(amount), addKey(PublicKey.from(publicKey), accessKey), deployContract(data)] - }); - const contractAccount = new Account(this.connection, contractId); - return contractAccount; - } - - /** - * @param receiverId NEAR account receiving Ⓝ - * @param amount Amount to send in yoctoⓃ - */ - async sendMoney(receiverId: string, amount: BN): Promise { - return this.signAndSendTransaction({ - receiverId, - actions: [transfer(amount)] - }); - } - - /** - * @param newAccountId NEAR account name to be created - * @param publicKey A public key created from the masterAccount - */ - async createAccount(newAccountId: string, publicKey: string | PublicKey, amount: BN): Promise { - const accessKey = fullAccessKey(); - return this.signAndSendTransaction({ - receiverId: newAccountId, - actions: [createAccount(), transfer(amount), addKey(PublicKey.from(publicKey), accessKey)] - }); - } - - /** - * @param beneficiaryId The NEAR account that will receive the remaining Ⓝ balance from the account being deleted - */ - async deleteAccount(beneficiaryId: string) { - if (!process.env['NEAR_NO_LOGS']) { - console.log('Deleting an account does not automatically transfer NFTs and FTs to the beneficiary address. Ensure to transfer assets before deleting.'); - } - return this.signAndSendTransaction({ - receiverId: this.accountId, - actions: [deleteAccount(beneficiaryId)] - }); - } - - /** - * @param data The compiled contract code - */ - async deployContract(data: Uint8Array): Promise { - return this.signAndSendTransaction({ - receiverId: this.accountId, - actions: [deployContract(data)] - }); - } - - /** @hidden */ - private encodeJSContractArgs(contractId: string, method: string, args) { - return Buffer.concat([Buffer.from(contractId), Buffer.from([0]), Buffer.from(method), Buffer.from([0]), Buffer.from(args)]); - } - - /** - * Execute function call - * @returns {Promise} - */ - async functionCall({ contractId, methodName, args = {}, gas = DEFAULT_FUNCTION_CALL_GAS, attachedDeposit, walletMeta, walletCallbackUrl, stringify, jsContract }: ChangeFunctionCallOptions): Promise { - this.validateArgs(args); - let functionCallArgs; - - if(jsContract){ - const encodedArgs = this.encodeJSContractArgs( contractId, methodName, JSON.stringify(args) ); - functionCallArgs = ['call_js_contract', encodedArgs, gas, attachedDeposit, null, true ]; - } else{ - const stringifyArg = stringify === undefined ? stringifyJsonOrBytes : stringify; - functionCallArgs = [methodName, args, gas, attachedDeposit, stringifyArg, false]; - } - - return this.signAndSendTransaction({ - receiverId: jsContract ? this.connection.jsvmAccountId: contractId, - // eslint-disable-next-line prefer-spread - actions: [functionCall.apply(void 0, functionCallArgs)], - walletMeta, - walletCallbackUrl - }); - } - - /** - * @see [https://docs.near.org/concepts/basics/accounts/access-keys](https://docs.near.org/concepts/basics/accounts/access-keys) - * @todo expand this API to support more options. - * @param publicKey A public key to be associated with the contract - * @param contractId NEAR account where the contract is deployed - * @param methodNames The method names on the contract that should be allowed to be called. Pass null for no method names and '' or [] for any method names. - * @param amount Payment in yoctoⓃ that is sent to the contract during this function call - */ - async addKey(publicKey: string | PublicKey, contractId?: string, methodNames?: string | string[], amount?: BN): Promise { - if (!methodNames) { - methodNames = []; - } - if (!Array.isArray(methodNames)) { - methodNames = [methodNames]; - } - let accessKey; - if (!contractId) { - accessKey = fullAccessKey(); - } else { - accessKey = functionCallAccessKey(contractId, methodNames, amount); - } - return this.signAndSendTransaction({ - receiverId: this.accountId, - actions: [addKey(PublicKey.from(publicKey), accessKey)] - }); - } - - /** - * @param publicKey The public key to be deleted - * @returns {Promise} - */ - async deleteKey(publicKey: string | PublicKey): Promise { - return this.signAndSendTransaction({ - receiverId: this.accountId, - actions: [deleteKey(PublicKey.from(publicKey))] - }); - } - - /** - * @see [https://near-nodes.io/validator/staking-and-delegation](https://near-nodes.io/validator/staking-and-delegation) - * - * @param publicKey The public key for the account that's staking - * @param amount The account to stake in yoctoⓃ - */ - async stake(publicKey: string | PublicKey, amount: BN): Promise { - return this.signAndSendTransaction({ - receiverId: this.accountId, - actions: [stake(amount, PublicKey.from(publicKey))] - }); - } - - /** @hidden */ - private validateArgs(args: any) { - const isUint8Array = args.byteLength !== undefined && args.byteLength === args.length; - if (isUint8Array) { - return; - } - - if (Array.isArray(args) || typeof args !== 'object') { - throw new PositionalArgsError(); - } - } - - /** - * Invoke a contract view function using the RPC API. - * @see [https://docs.near.org/api/rpc/contracts#call-a-contract-function](https://docs.near.org/api/rpc/contracts#call-a-contract-function) - * - * @param viewFunctionCallOptions.contractId NEAR account where the contract is deployed - * @param viewFunctionCallOptions.methodName The view-only method (no state mutations) name on the contract as it is written in the contract code - * @param viewFunctionCallOptions.args Any arguments to the view contract method, wrapped in JSON - * @param viewFunctionCallOptions.parse Parse the result of the call. Receives a Buffer (bytes array) and converts it to any object. By default result will be treated as json. - * @param viewFunctionCallOptions.stringify Convert input arguments into a bytes array. By default the input is treated as a JSON. - * @param viewFunctionCallOptions.jsContract Is contract from JS SDK, automatically encodes args from JS SDK to binary. - * @param viewFunctionCallOptions.blockQuery specifies which block to query state at. By default returns last "optimistic" block (i.e. not necessarily finalized). - * @returns {Promise} - */ - - async viewFunction({ - contractId, - methodName, - args = {}, - parse = parseJsonFromRawResponse, - stringify = bytesJsonStringify, - jsContract = false, - blockQuery = { finality: 'optimistic' } - }: ViewFunctionCallOptions): Promise { - let encodedArgs; - - this.validateArgs(args); - - if(jsContract){ - encodedArgs = this.encodeJSContractArgs(contractId, methodName, Object.keys(args).length > 0 ? JSON.stringify(args): ''); - } else{ - encodedArgs = stringify(args); - } - - const result = await this.connection.provider.query({ - request_type: 'call_function', - ...blockQuery, - account_id: jsContract ? this.connection.jsvmAccountId : contractId, - method_name: jsContract ? 'view_js_contract' : methodName, - args_base64: encodedArgs.toString('base64') - }); - - if (result.logs) { - printTxOutcomeLogs({ contractId, logs: result.logs }); - } - - return result.result && result.result.length > 0 && parse(Buffer.from(result.result)); - } - - /** - * Returns the state (key value pairs) of this account's contract based on the key prefix. - * Pass an empty string for prefix if you would like to return the entire state. - * @see [https://docs.near.org/api/rpc/contracts#view-contract-state](https://docs.near.org/api/rpc/contracts#view-contract-state) - * - * @param prefix allows to filter which keys should be returned. Empty prefix means all keys. String prefix is utf-8 encoded. - * @param blockQuery specifies which block to query state at. By default returns last "optimistic" block (i.e. not necessarily finalized). - */ - async viewState(prefix: string | Uint8Array, blockQuery: BlockReference = { finality: 'optimistic' } ): Promise> { - const { values } = await this.connection.provider.query({ - request_type: 'view_state', - ...blockQuery, - account_id: this.accountId, - prefix_base64: Buffer.from(prefix).toString('base64') - }); - - return values.map(({ key, value }) => ({ - key: Buffer.from(key, 'base64'), - value: Buffer.from(value, 'base64') - })); - } - - /** - * Get all access keys for the account - * @see [https://docs.near.org/api/rpc/access-keys#view-access-key-list](https://docs.near.org/api/rpc/access-keys#view-access-key-list) - */ - async getAccessKeys(): Promise { - const response = await this.connection.provider.query({ - request_type: 'view_access_key_list', - account_id: this.accountId, - finality: 'optimistic' - }); - // Replace raw nonce into a new BN - return response?.keys?.map((key) => ({ ...key, access_key: { ...key.access_key, nonce: new BN(key.access_key.nonce) } })); - } - - /** - * Returns a list of authorized apps - * @todo update the response value to return all the different keys, not just app keys. - */ - async getAccountDetails(): Promise<{ authorizedApps: AccountAuthorizedApp[] }> { - // TODO: update the response value to return all the different keys, not just app keys. - // Also if we need this function, or getAccessKeys is good enough. - const accessKeys = await this.getAccessKeys(); - const authorizedApps = accessKeys - .filter(item => item.access_key.permission !== 'FullAccess') - .map(item => { - const perm = (item.access_key.permission as FunctionCallPermissionView); - return { - contractId: perm.FunctionCall.receiver_id, - amount: perm.FunctionCall.allowance, - publicKey: item.public_key, - }; - }); - return { authorizedApps }; - } - - /** - * Returns calculated account balance - */ - async getAccountBalance(): Promise { - const protocolConfig = await this.connection.provider.experimental_protocolConfig({ finality: 'final' }); - const state = await this.state(); - - const costPerByte = new BN(protocolConfig.runtime_config.storage_amount_per_byte); - const stateStaked = new BN(state.storage_usage).mul(costPerByte); - const staked = new BN(state.locked); - const totalBalance = new BN(state.amount).add(staked); - const availableBalance = totalBalance.sub(BN.max(staked, stateStaked)); - - return { - total: totalBalance.toString(), - stateStaked: stateStaked.toString(), - staked: staked.toString(), - available: availableBalance.toString() - }; - } - - /** - * Returns the NEAR tokens balance and validators of a given account that is delegated to the staking pools that are part of the validators set in the current epoch. - * - * NOTE: If the tokens are delegated to a staking pool that is currently on pause or does not have enough tokens to participate in validation, they won't be accounted for. - * @returns {Promise} - */ - async getActiveDelegatedStakeBalance(): Promise { - const block = await this.connection.provider.block({ finality: 'final' }); - const blockHash = block.header.hash; - const epochId = block.header.epoch_id; - const { current_validators, next_validators, current_proposals } = await this.connection.provider.validators(epochId); - const pools:Set = new Set(); - [...current_validators, ...next_validators, ...current_proposals] - .forEach((validator) => pools.add(validator.account_id)); - - const uniquePools = [...pools]; - const promises = uniquePools - .map((validator) => ( - this.viewFunction({ - contractId: validator, - methodName: 'get_account_total_balance', - args: { account_id: this.accountId }, - blockQuery: { blockId: blockHash } - }) - )); - - const results = await Promise.allSettled(promises); - - const hasTimeoutError = results.some((result) => { - if (result.status === 'rejected' && result.reason.type === 'TimeoutError') { - return true; - } - return false; - }); - - // When RPC is down and return timeout error, throw error - if (hasTimeoutError) { - throw new Error('Failed to get delegated stake balance'); - } - const summary = results.reduce((result, state, index) => { - const validatorId = uniquePools[index]; - if (state.status === 'fulfilled') { - const currentBN = new BN(state.value); - if (!currentBN.isZero()) { - return { - ...result, - stakedValidators: [...result.stakedValidators, { validatorId, amount: currentBN.toString() }], - total: result.total.add(currentBN), - }; - } - } - if (state.status === 'rejected') { - return { - ...result, - failedValidators: [...result.failedValidators, { validatorId, error: state.reason }], - }; - } - return result; - }, - { stakedValidators: [], failedValidators: [], total: new BN(0) }); - - return { - ...summary, - total: summary.total.toString(), - }; - } -} +export { + Account, + AccountBalance, + AccountAuthorizedApp, + SignAndSendTransactionOptions, + FunctionCallOptions, + ChangeFunctionCallOptions, + ViewFunctionCallOptions, +} from '@near-js/accounts'; diff --git a/packages/near-api-js/src/account_creator.ts b/packages/near-api-js/src/account_creator.ts index a886d83ba1..fa0db8fa72 100644 --- a/packages/near-api-js/src/account_creator.ts +++ b/packages/near-api-js/src/account_creator.ts @@ -1,55 +1 @@ -import BN from 'bn.js'; -import { Connection } from './connection'; -import { Account } from './account'; -import { fetchJson } from './utils/web'; -import { PublicKey } from './utils/key_pair'; - -/** - * Account creator provides an interface for implementations to actually create accounts - */ -export abstract class AccountCreator { - abstract createAccount(newAccountId: string, publicKey: PublicKey): Promise; -} - -export class LocalAccountCreator extends AccountCreator { - readonly masterAccount: Account; - readonly initialBalance: BN; - - constructor(masterAccount: Account, initialBalance: BN) { - super(); - this.masterAccount = masterAccount; - this.initialBalance = initialBalance; - } - - /** - * Creates an account using a masterAccount, meaning the new account is created from an existing account - * @param newAccountId The name of the NEAR account to be created - * @param publicKey The public key from the masterAccount used to create this account - * @returns {Promise} - */ - async createAccount(newAccountId: string, publicKey: PublicKey): Promise { - await this.masterAccount.createAccount(newAccountId, publicKey, this.initialBalance); - } -} - -export class UrlAccountCreator extends AccountCreator { - readonly connection: Connection; - readonly helperUrl: string; - - constructor(connection: Connection, helperUrl: string) { - super(); - this.connection = connection; - this.helperUrl = helperUrl; - } - - /** - * Creates an account using a helperUrl - * This is [hosted here](https://helper.nearprotocol.com) or set up locally with the [near-contract-helper](https://github.com/nearprotocol/near-contract-helper) repository - * @param newAccountId The name of the NEAR account to be created - * @param publicKey The public key from the masterAccount used to create this account - * @returns {Promise} - */ - async createAccount(newAccountId: string, publicKey: PublicKey): Promise { - await fetchJson(`${this.helperUrl}/account`, JSON.stringify({ newAccountId, newAccountPublicKey: publicKey.toString() })); - } -} +export { AccountCreator, LocalAccountCreator, UrlAccountCreator } from '@near-js/accounts'; diff --git a/packages/near-api-js/src/account_multisig.ts b/packages/near-api-js/src/account_multisig.ts index 621d8dedb3..8671cf5956 100644 --- a/packages/near-api-js/src/account_multisig.ts +++ b/packages/near-api-js/src/account_multisig.ts @@ -1,503 +1,12 @@ -'use strict'; - -import BN from 'bn.js'; -import { Account, SignAndSendTransactionOptions } from './account'; -import { Connection } from './connection'; -import { parseNearAmount } from './utils/format'; -import { PublicKey } from './utils/key_pair'; -import { Action, addKey, deleteKey, deployContract, fullAccessKey, functionCall, functionCallAccessKey } from './transaction'; -import { FinalExecutionOutcome, TypedError } from './providers'; -import { fetchJson } from './utils/web'; -import { FunctionCallPermissionView } from './providers/provider'; - -export const MULTISIG_STORAGE_KEY = '__multisigRequest'; -export const MULTISIG_ALLOWANCE = new BN(parseNearAmount('1')); -// TODO: Different gas value for different requests (can reduce gas usage dramatically) -export const MULTISIG_GAS = new BN('100000000000000'); -export const MULTISIG_DEPOSIT = new BN('0'); -export const MULTISIG_CHANGE_METHODS = ['add_request', 'add_request_and_confirm', 'delete_request', 'confirm']; -export const MULTISIG_CONFIRM_METHODS = ['confirm']; - -type sendCodeFunction = () => Promise; -type getCodeFunction = (method: any) => Promise; -type verifyCodeFunction = (securityCode: any) => Promise; - -export enum MultisigDeleteRequestRejectionError { - CANNOT_DESERIALIZE_STATE = 'Cannot deserialize the contract state', - MULTISIG_NOT_INITIALIZED = 'Smart contract panicked: Multisig contract should be initialized before usage', - NO_SUCH_REQUEST = 'Smart contract panicked: panicked at \'No such request: either wrong number or already confirmed\'', - REQUEST_COOLDOWN_ERROR = 'Request cannot be deleted immediately after creation.', - METHOD_NOT_FOUND = 'Contract method is not found' -} - -export enum MultisigStateStatus { - INVALID_STATE, - STATE_NOT_INITIALIZED, - VALID_STATE, - UNKNOWN_STATE -} - -enum MultisigCodeStatus { - INVALID_CODE, - VALID_CODE, - UNKNOWN_CODE -} - -// in memory request cache for node w/o localStorage -const storageFallback = { - [MULTISIG_STORAGE_KEY]: null -}; - -export class AccountMultisig extends Account { - public storage: any; - public onAddRequestResult: (any) => any; - - constructor(connection: Connection, accountId: string, options: any) { - super(connection, accountId); - this.storage = options.storage; - this.onAddRequestResult = options.onAddRequestResult; - } - - async signAndSendTransactionWithAccount(receiverId: string, actions: Action[]): Promise { - return super.signAndSendTransaction({ receiverId, actions }); - } - - async signAndSendTransaction({ receiverId, actions }: SignAndSendTransactionOptions): Promise { - const { accountId } = this; - - const args = Buffer.from(JSON.stringify({ - request: { - receiver_id: receiverId, - actions: convertActions(actions, accountId, receiverId) - } - })); - - let result; - try { - result = await super.signAndSendTransaction({ - receiverId: accountId, - actions: [ - functionCall('add_request_and_confirm', args, MULTISIG_GAS, MULTISIG_DEPOSIT) - ] - }); - } catch (e) { - if (e.toString().includes('Account has too many active requests. Confirm or delete some')) { - await this.deleteUnconfirmedRequests(); - return await this.signAndSendTransaction({ receiverId, actions }); - } - throw e; - } - - // TODO: Are following even needed? Seems like it throws on error already - if (!result.status) { - throw new Error('Request failed'); - } - const status: any = { ...result.status }; - if (!status.SuccessValue || typeof status.SuccessValue !== 'string') { - throw new Error('Request failed'); - } - - this.setRequest({ - accountId, - actions, - requestId: parseInt(Buffer.from(status.SuccessValue, 'base64').toString('ascii'), 10) - }); - - if (this.onAddRequestResult) { - await this.onAddRequestResult(result); - } - - // NOTE there is no await on purpose to avoid blocking for 2fa - this.deleteUnconfirmedRequests(); - - return result; - } - - /* - * This method submits a canary transaction that is expected to always fail in order to determine whether the contract currently has valid multisig state - * and whether it is initialized. The canary transaction attempts to delete a request at index u32_max and will go through if a request exists at that index. - * a u32_max + 1 and -1 value cannot be used for the canary due to expected u32 error thrown before deserialization attempt. - */ - async checkMultisigCodeAndStateStatus(contractBytes?: Uint8Array): Promise<{ codeStatus: MultisigCodeStatus; stateStatus: MultisigStateStatus }> { - const u32_max = 4_294_967_295; - const validCodeStatusIfNoDeploy = contractBytes ? MultisigCodeStatus.UNKNOWN_CODE : MultisigCodeStatus.VALID_CODE; - - try { - if(contractBytes) { - await super.signAndSendTransaction({ - receiverId: this.accountId, actions: [ - deployContract(contractBytes), - functionCall('delete_request', { request_id: u32_max }, MULTISIG_GAS, MULTISIG_DEPOSIT) - ] - }); - } else { - await this.deleteRequest(u32_max); - } - - return { codeStatus: MultisigCodeStatus.VALID_CODE, stateStatus: MultisigStateStatus.VALID_STATE }; - } catch (e) { - if (new RegExp(MultisigDeleteRequestRejectionError.CANNOT_DESERIALIZE_STATE).test(e && e.kind && e.kind.ExecutionError)) { - return { codeStatus: validCodeStatusIfNoDeploy, stateStatus: MultisigStateStatus.INVALID_STATE }; - } else if (new RegExp(MultisigDeleteRequestRejectionError.MULTISIG_NOT_INITIALIZED).test(e && e.kind && e.kind.ExecutionError)) { - return { codeStatus: validCodeStatusIfNoDeploy, stateStatus: MultisigStateStatus.STATE_NOT_INITIALIZED }; - } else if (new RegExp(MultisigDeleteRequestRejectionError.NO_SUCH_REQUEST).test(e && e.kind && e.kind.ExecutionError)) { - return { codeStatus: validCodeStatusIfNoDeploy, stateStatus: MultisigStateStatus.VALID_STATE }; - } else if (new RegExp(MultisigDeleteRequestRejectionError.METHOD_NOT_FOUND).test(e && e.message)) { - // not reachable if transaction included a deploy - return { codeStatus: MultisigCodeStatus.INVALID_CODE, stateStatus: MultisigStateStatus.UNKNOWN_STATE }; - } - throw e; - } - } - - deleteRequest(request_id) { - return super.signAndSendTransaction({ - receiverId: this.accountId, - actions: [functionCall('delete_request', { request_id }, MULTISIG_GAS, MULTISIG_DEPOSIT)] - }); - } - - async deleteAllRequests() { - const request_ids = await this.getRequestIds(); - if(request_ids.length) { - await Promise.all(request_ids.map((id) => this.deleteRequest(id))); - } - } - - async deleteUnconfirmedRequests () { - // TODO: Delete in batch, don't delete unexpired - // TODO: Delete in batch, don't delete unexpired (can reduce gas usage dramatically) - const request_ids = await this.getRequestIds(); - const { requestId } = this.getRequest(); - for (const requestIdToDelete of request_ids) { - if (requestIdToDelete == requestId) { - continue; - } - try { - await super.signAndSendTransaction({ - receiverId: this.accountId, - actions: [functionCall('delete_request', { request_id: requestIdToDelete }, MULTISIG_GAS, MULTISIG_DEPOSIT)] - }); - } catch (e) { - console.warn('Attempt to delete an earlier request before 15 minutes failed. Will try again.'); - } - } - } - - // helpers - - async getRequestIds(): Promise { - // TODO: Read requests from state to allow filtering by expiration time - // TODO: https://github.com/near/core-contracts/blob/305d1db4f4f2cf5ce4c1ef3479f7544957381f11/multisig/src/lib.rs#L84 - return this.viewFunction({ - contractId: this.accountId, - methodName: 'list_request_ids', - }); - } - - getRequest() { - if (this.storage) { - return JSON.parse(this.storage.getItem(MULTISIG_STORAGE_KEY) || '{}'); - } - return storageFallback[MULTISIG_STORAGE_KEY]; - } - - setRequest(data) { - if (this.storage) { - return this.storage.setItem(MULTISIG_STORAGE_KEY, JSON.stringify(data)); - } - storageFallback[MULTISIG_STORAGE_KEY] = data; - } -} - -export class Account2FA extends AccountMultisig { - /******************************** - Account2FA has options object where you can provide callbacks for: - - sendCode: how to send the 2FA code in case you don't use NEAR Contract Helper - - getCode: how to get code from user (use this to provide custom UI/UX for prompt of 2FA code) - - onResult: the tx result after it's been confirmed by NEAR Contract Helper - ********************************/ - public sendCode: sendCodeFunction; - public getCode: getCodeFunction; - public verifyCode: verifyCodeFunction; - public onConfirmResult: (any) => any; - public helperUrl = 'https://helper.testnet.near.org'; - - constructor(connection: Connection, accountId: string, options: any) { - super(connection, accountId, options); - this.helperUrl = options.helperUrl || this.helperUrl; - this.storage = options.storage; - this.sendCode = options.sendCode || this.sendCodeDefault; - this.getCode = options.getCode || this.getCodeDefault; - this.verifyCode = options.verifyCode || this.verifyCodeDefault; - this.onConfirmResult = options.onConfirmResult; - } - - /** - * Sign a transaction to preform a list of actions and broadcast it using the RPC API. - * @see {@link providers/json-rpc-provider!JsonRpcProvider#sendTransaction | JsonRpcProvider.sendTransaction} - */ - async signAndSendTransaction({ receiverId, actions }: SignAndSendTransactionOptions): Promise { - await super.signAndSendTransaction({ receiverId, actions }); - // TODO: Should following override onRequestResult in superclass instead of doing custom signAndSendTransaction? - await this.sendCode(); - const result = await this.promptAndVerify(); - if (this.onConfirmResult) { - await this.onConfirmResult(result); - } - return result; - } - - // default helpers for CH deployments of multisig - - async deployMultisig(contractBytes: Uint8Array) { - const { accountId } = this; - - const seedOrLedgerKey = (await this.getRecoveryMethods()).data - .filter(({ kind, publicKey }) => (kind === 'phrase' || kind === 'ledger') && publicKey !== null) - .map((rm) => rm.publicKey); - - const fak2lak = (await this.getAccessKeys()) - .filter(({ public_key, access_key: { permission } }) => permission === 'FullAccess' && !seedOrLedgerKey.includes(public_key)) - .map((ak) => ak.public_key) - .map(toPK); - - const confirmOnlyKey = toPK((await this.postSignedJson('/2fa/getAccessKey', { accountId })).publicKey); - - const newArgs = Buffer.from(JSON.stringify({ 'num_confirmations': 2 })); - - const actions = [ - ...fak2lak.map((pk) => deleteKey(pk)), - ...fak2lak.map((pk) => addKey(pk, functionCallAccessKey(accountId, MULTISIG_CHANGE_METHODS, null))), - addKey(confirmOnlyKey, functionCallAccessKey(accountId, MULTISIG_CONFIRM_METHODS, null)), - deployContract(contractBytes), - ]; - const newFunctionCallActionBatch = actions.concat(functionCall('new', newArgs, MULTISIG_GAS, MULTISIG_DEPOSIT)); - console.log('deploying multisig contract for', accountId); - - const { stateStatus: multisigStateStatus } = await this.checkMultisigCodeAndStateStatus(contractBytes); - switch (multisigStateStatus) { - case MultisigStateStatus.STATE_NOT_INITIALIZED: - return await super.signAndSendTransactionWithAccount(accountId, newFunctionCallActionBatch); - case MultisigStateStatus.VALID_STATE: - return await super.signAndSendTransactionWithAccount(accountId, actions); - case MultisigStateStatus.INVALID_STATE: - throw new TypedError(`Can not deploy a contract to account ${this.accountId} on network ${this.connection.networkId}, the account has existing state.`, 'ContractHasExistingState'); - default: - throw new TypedError(`Can not deploy a contract to account ${this.accountId} on network ${this.connection.networkId}, the account state could not be verified.`, 'ContractStateUnknown'); - } - } - - async disableWithFAK({ contractBytes, cleanupContractBytes }: { contractBytes: Uint8Array; cleanupContractBytes?: Uint8Array }) { - let cleanupActions = []; - if(cleanupContractBytes) { - await this.deleteAllRequests().catch(e => e); - cleanupActions = await this.get2faDisableCleanupActions(cleanupContractBytes); - } - const keyConversionActions = await this.get2faDisableKeyConversionActions(); - - const actions = [ - ...cleanupActions, - ...keyConversionActions, - deployContract(contractBytes) - ]; - - const accessKeyInfo = await this.findAccessKey(this.accountId, actions); - - if(accessKeyInfo && accessKeyInfo.accessKey && accessKeyInfo.accessKey.permission !== 'FullAccess') { - throw new TypedError('No full access key found in keystore. Unable to bypass multisig', 'NoFAKFound'); - } - - return this.signAndSendTransactionWithAccount(this.accountId, actions); - } - - async get2faDisableCleanupActions(cleanupContractBytes: Uint8Array) { - const currentAccountState: { key: Buffer; value: Buffer }[] = await this.viewState('').catch(error => { - const cause = error.cause && error.cause.name; - if (cause == 'NO_CONTRACT_CODE') { - return []; - } - throw cause == 'TOO_LARGE_CONTRACT_STATE' - ? new TypedError(`Can not deploy a contract to account ${this.accountId} on network ${this.connection.networkId}, the account has existing state.`, 'ContractHasExistingState') - : error; - }); - - const currentAccountStateKeys = currentAccountState.map(({ key }) => key.toString('base64')); - return currentAccountState.length ? [ - deployContract(cleanupContractBytes), - functionCall('clean', { keys: currentAccountStateKeys }, MULTISIG_GAS, new BN('0')) - ] : []; - } - - async get2faDisableKeyConversionActions() { - const { accountId } = this; - const accessKeys = await this.getAccessKeys(); - const lak2fak = accessKeys - .filter(({ access_key }) => access_key.permission !== 'FullAccess') - .filter(({ access_key }) => { - const perm = (access_key.permission as FunctionCallPermissionView).FunctionCall; - return perm.receiver_id === accountId && - perm.method_names.length === 4 && - perm.method_names.includes('add_request_and_confirm'); - }); - const confirmOnlyKey = PublicKey.from((await this.postSignedJson('/2fa/getAccessKey', { accountId })).publicKey); - return [ - deleteKey(confirmOnlyKey), - ...lak2fak.map(({ public_key }) => deleteKey(PublicKey.from(public_key))), - ...lak2fak.map(({ public_key }) => addKey(PublicKey.from(public_key), fullAccessKey())) - ]; - } - - /** - * This method converts LAKs back to FAKs, clears state and deploys an 'empty' contract (contractBytes param) - * @param [contractBytes]{@link https://github.com/near/near-wallet/blob/master/packages/frontend/src/wasm/main.wasm?raw=true} - * @param [cleanupContractBytes]{@link https://github.com/near/core-contracts/blob/master/state-cleanup/res/state_cleanup.wasm?raw=true} - */ - async disable(contractBytes: Uint8Array, cleanupContractBytes: Uint8Array) { - const { stateStatus } = await this.checkMultisigCodeAndStateStatus(); - if(stateStatus !== MultisigStateStatus.VALID_STATE && stateStatus !== MultisigStateStatus.STATE_NOT_INITIALIZED) { - throw new TypedError(`Can not deploy a contract to account ${this.accountId} on network ${this.connection.networkId}, the account state could not be verified.`, 'ContractStateUnknown'); - } - - let deleteAllRequestsError; - await this.deleteAllRequests().catch(e => deleteAllRequestsError = e); - - const cleanupActions = await this.get2faDisableCleanupActions(cleanupContractBytes).catch(e => { - if(e.type === 'ContractHasExistingState') { - throw deleteAllRequestsError || e; - } - throw e; - }); - - const actions = [ - ...cleanupActions, - ...(await this.get2faDisableKeyConversionActions()), - deployContract(contractBytes), - ]; - console.log('disabling 2fa for', this.accountId); - return await this.signAndSendTransaction({ - receiverId: this.accountId, - actions - }); - } - - async sendCodeDefault() { - const { accountId } = this; - const { requestId } = this.getRequest(); - const method = await this.get2faMethod(); - await this.postSignedJson('/2fa/send', { - accountId, - method, - requestId, - }); - return requestId; - } - - async getCodeDefault(): Promise { - throw new Error('There is no getCode callback provided. Please provide your own in AccountMultisig constructor options. It has a parameter method where method.kind is "email" or "phone".'); - } - - async promptAndVerify() { - const method = await this.get2faMethod(); - const securityCode = await this.getCode(method); - try { - const result = await this.verifyCode(securityCode); - - // TODO: Parse error from result for real (like in normal account.signAndSendTransaction) - return result; - } catch (e) { - console.warn('Error validating security code:', e); - if (e.toString().includes('invalid 2fa code provided') || e.toString().includes('2fa code not valid')) { - return await this.promptAndVerify(); - } - - throw e; - } - } - - async verifyCodeDefault(securityCode: string) { - const { accountId } = this; - const request = this.getRequest(); - if (!request) { - throw new Error('no request pending'); - } - const { requestId } = request; - return await this.postSignedJson('/2fa/verify', { - accountId, - securityCode, - requestId - }); - } - - async getRecoveryMethods() { - const { accountId } = this; - return { - accountId, - data: await this.postSignedJson('/account/recoveryMethods', { accountId }) - }; - } - - async get2faMethod() { - let { data } = await this.getRecoveryMethods(); - if (data && data.length) { - data = data.find((m) => m.kind.indexOf('2fa-') === 0); - } - if (!data) return null; - const { kind, detail } = data; - return { kind, detail }; - } - - async signatureFor() { - const { accountId } = this; - const block = await this.connection.provider.block({ finality: 'final' }); - const blockNumber = block.header.height.toString(); - const signed = await this.connection.signer.signMessage(Buffer.from(blockNumber), accountId, this.connection.networkId); - const blockNumberSignature = Buffer.from(signed.signature).toString('base64'); - return { blockNumber, blockNumberSignature }; - } - - async postSignedJson(path, body) { - return await fetchJson(this.helperUrl + path, JSON.stringify({ - ...body, - ...(await this.signatureFor()) - })); - } -} - -// helpers -const toPK = (pk) => PublicKey.from(pk); -const convertPKForContract = (pk) => pk.toString().replace('ed25519:', ''); - -const convertActions = (actions, accountId, receiverId) => actions.map((a) => { - const type = a.enum; - const { gas, publicKey, methodName, args, deposit, accessKey, code } = a[type]; - const action = { - type: type[0].toUpperCase() + type.substr(1), - gas: (gas && gas.toString()) || undefined, - public_key: (publicKey && convertPKForContract(publicKey)) || undefined, - method_name: methodName, - args: (args && Buffer.from(args).toString('base64')) || undefined, - code: (code && Buffer.from(code).toString('base64')) || undefined, - amount: (deposit && deposit.toString()) || undefined, - deposit: (deposit && deposit.toString()) || '0', - permission: undefined, - }; - if (accessKey) { - if (receiverId === accountId && accessKey.permission.enum !== 'fullAccess') { - action.permission = { - receiver_id: accountId, - allowance: MULTISIG_ALLOWANCE.toString(), - method_names: MULTISIG_CHANGE_METHODS, - }; - } - if (accessKey.permission.enum === 'functionCall') { - const { receiverId: receiver_id, methodNames: method_names, allowance } = accessKey.permission.functionCall; - action.permission = { - receiver_id, - allowance: (allowance && allowance.toString()) || undefined, - method_names - }; - } - } - return action; -}); +export { + Account2FA, + AccountMultisig, + MULTISIG_STORAGE_KEY, + MULTISIG_ALLOWANCE, + MULTISIG_GAS, + MULTISIG_DEPOSIT, + MULTISIG_CHANGE_METHODS, + MULTISIG_CONFIRM_METHODS, + MultisigDeleteRequestRejectionError, + MultisigStateStatus, +} from '@near-js/accounts'; diff --git a/packages/near-api-js/src/connect.ts b/packages/near-api-js/src/connect.ts index 8b95c54d86..aaeeed20c0 100644 --- a/packages/near-api-js/src/connect.ts +++ b/packages/near-api-js/src/connect.ts @@ -56,7 +56,7 @@ export async function connect(config: ConnectConfig): Promise { keyPathStore, config.keyStore ], { writeKeyStoreIndex: 1 }); - if (!process.env['NEAR_NO_LOGS']) { + if (typeof process !== 'undefined' && !process.env['NEAR_NO_LOGS']) { console.log(`Loaded master account ${accountKeyFile[0]} key from ${config.keyPath} with public key = ${keyPair.getPublicKey()}`); } } diff --git a/packages/near-api-js/src/connection.ts b/packages/near-api-js/src/connection.ts index 80e7d9a984..ab8ff55899 100644 --- a/packages/near-api-js/src/connection.ts +++ b/packages/near-api-js/src/connection.ts @@ -1,56 +1 @@ -import { Provider, JsonRpcProvider } from './providers'; -import { Signer, InMemorySigner } from './signer'; - -/** - * @param config Contains connection info details - * @returns {Provider} - */ -function getProvider(config: any): Provider { - switch (config.type) { - case undefined: - return config; - case 'JsonRpcProvider': return new JsonRpcProvider({ ...config.args }); - default: throw new Error(`Unknown provider type ${config.type}`); - } -} - -/** - * @param config Contains connection info details - * @returns {Signer} - */ -function getSigner(config: any): Signer { - switch (config.type) { - case undefined: - return config; - case 'InMemorySigner': { - return new InMemorySigner(config.keyStore); - } - default: throw new Error(`Unknown signer type ${config.type}`); - } -} - -/** - * Connects an account to a given network via a given provider - */ -export class Connection { - readonly networkId: string; - readonly provider: Provider; - readonly signer: Signer; - readonly jsvmAccountId: string; - - constructor(networkId: string, provider: Provider, signer: Signer, jsvmAccountId: string) { - this.networkId = networkId; - this.provider = provider; - this.signer = signer; - this.jsvmAccountId = jsvmAccountId; - } - - /** - * @param config Contains connection info details - */ - static fromConfig(config: any): Connection { - const provider = getProvider(config.provider); - const signer = getSigner(config.signer); - return new Connection(config.networkId, provider, signer, config.jsvmAccountId); - } -} +export { Connection } from '@near-js/accounts'; \ No newline at end of file diff --git a/packages/near-api-js/src/constants.ts b/packages/near-api-js/src/constants.ts index 63a7c421ce..bca466d866 100644 --- a/packages/near-api-js/src/constants.ts +++ b/packages/near-api-js/src/constants.ts @@ -1,9 +1 @@ -import BN from 'bn.js'; - -// Default amount of gas to be sent with the function calls. Used to pay for the fees -// incurred while running the contract execution. The unused amount will be refunded back to -// the originator. -// Due to protocol changes that charge upfront for the maximum possible gas price inflation due to -// full blocks, the price of max_prepaid_gas is decreased to `300 * 10**12`. -// For discussion see https://github.com/nearprotocol/NEPs/issues/67 -export const DEFAULT_FUNCTION_CALL_GAS = new BN('30000000000000'); \ No newline at end of file +export { DEFAULT_FUNCTION_CALL_GAS } from '@near-js/utils'; diff --git a/packages/near-api-js/src/contract.ts b/packages/near-api-js/src/contract.ts index 01c4b4fa2c..926d261436 100644 --- a/packages/near-api-js/src/contract.ts +++ b/packages/near-api-js/src/contract.ts @@ -1,245 +1 @@ -import Ajv from 'ajv'; -import addFormats from 'ajv-formats'; -import BN from 'bn.js'; -import depd from 'depd'; -import { AbiFunction, AbiFunctionKind, AbiRoot, AbiSerializationType } from 'near-abi'; -import { Account } from './account'; -import { getTransactionLastResult } from './providers'; -import { PositionalArgsError, ArgumentTypeError, UnsupportedSerializationError, UnknownArgumentError, ArgumentSchemaError, ConflictingOptions } from './utils/errors'; - -// Makes `function.name` return given name -function nameFunction(name: string, body: (args?: any[]) => any) { - return { - [name](...args: any[]) { - return body(...args); - } - }[name]; -} - -function validateArguments(args: object, abiFunction: AbiFunction, ajv: Ajv, abiRoot: AbiRoot) { - if (!isObject(args)) return; - - if (abiFunction.params && abiFunction.params.serialization_type !== AbiSerializationType.Json) { - throw new UnsupportedSerializationError(abiFunction.name, abiFunction.params.serialization_type); - } - - if (abiFunction.result && abiFunction.result.serialization_type !== AbiSerializationType.Json) { - throw new UnsupportedSerializationError(abiFunction.name, abiFunction.result.serialization_type); - } - - const params = abiFunction.params?.args || []; - for (const p of params) { - const arg = args[p.name]; - const typeSchema = p.type_schema; - typeSchema.definitions = abiRoot.body.root_schema.definitions; - const validate = ajv.compile(typeSchema); - if (!validate(arg)) { - throw new ArgumentSchemaError(p.name, validate.errors); - } - } - // Check there are no extra unknown arguments passed - for (const argName of Object.keys(args)) { - const param = params.find((p) => p.name === argName); - if (!param) { - throw new UnknownArgumentError(argName, params.map((p) => p.name)); - } - } -} - -function createAjv() { - // Strict mode is disabled for now as it complains about unknown formats. We need to - // figure out if we want to support a fixed set of formats. `uint32` and `uint64` - // are added explicitly just to reduce the amount of warnings as these are very popular - // types. - const ajv = new Ajv({ - strictSchema: false, - formats: { - uint32: true, - uint64: true - } - }); - addFormats(ajv); - return ajv; -} - -const isUint8Array = (x: any) => - x && x.byteLength !== undefined && x.byteLength === x.length; - -const isObject = (x: any) => - Object.prototype.toString.call(x) === '[object Object]'; - -interface ChangeMethodOptions { - args: object; - methodName: string; - gas?: BN; - amount?: BN; - meta?: string; - callbackUrl?: string; -} - -export interface ContractMethods { - /** - * Methods that change state. These methods cost gas and require a signed transaction. - * - * @see {@link account!Account.functionCall} - */ - changeMethods: string[]; - - /** - * View methods do not require a signed transaction. - * - * @see {@link account!Account#viewFunction} - */ - viewMethods: string[]; - - /** - * ABI defining this contract's interface. - */ - abi: AbiRoot; -} - -/** - * Defines a smart contract on NEAR including the change (mutable) and view (non-mutable) methods - * - * @see [https://docs.near.org/tools/near-api-js/quick-reference#contract](https://docs.near.org/tools/near-api-js/quick-reference#contract) - * @example - * ```js - * import { Contract } from 'near-api-js'; - * - * async function contractExample() { - * const methodOptions = { - * viewMethods: ['getMessageByAccountId'], - * changeMethods: ['addMessage'] - * }; - * const contract = new Contract( - * wallet.account(), - * 'contract-id.testnet', - * methodOptions - * ); - * - * // use a contract view method - * const messages = await contract.getMessages({ - * accountId: 'example-account.testnet' - * }); - * - * // use a contract change method - * await contract.addMessage({ - * meta: 'some info', - * callbackUrl: 'https://example.com/callback', - * args: { text: 'my message' }, - * amount: 1 - * }) - * } - * ``` - */ -export class Contract { - readonly account: Account; - readonly contractId: string; - - /** - * @param account NEAR account to sign change method transactions - * @param contractId NEAR account id where the contract is deployed - * @param options NEAR smart contract methods that your application will use. These will be available as `contract.methodName` - */ - constructor(account: Account, contractId: string, options: ContractMethods) { - this.account = account; - this.contractId = contractId; - const { viewMethods = [], changeMethods = [], abi: abiRoot } = options; - - let viewMethodsWithAbi = viewMethods.map((name) => ({ name, abi: null as AbiFunction })); - let changeMethodsWithAbi = changeMethods.map((name) => ({ name, abi: null as AbiFunction })); - if (abiRoot) { - if (viewMethodsWithAbi.length > 0 || changeMethodsWithAbi.length > 0) { - throw new ConflictingOptions(); - } - viewMethodsWithAbi = abiRoot.body.functions - .filter((m) => m.kind === AbiFunctionKind.View) - .map((m) => ({ name: m.name, abi: m })); - changeMethodsWithAbi = abiRoot.body.functions - .filter((methodAbi) => methodAbi.kind === AbiFunctionKind.Call) - .map((methodAbi) => ({ name: methodAbi.name, abi: methodAbi })); - } - - const ajv = createAjv(); - viewMethodsWithAbi.forEach(({ name, abi }) => { - Object.defineProperty(this, name, { - writable: false, - enumerable: true, - value: nameFunction(name, async (args: object = {}, options = {}, ...ignored) => { - if (ignored.length || !(isObject(args) || isUint8Array(args)) || !isObject(options)) { - throw new PositionalArgsError(); - } - - if (abi) { - validateArguments(args, abi, ajv, abiRoot); - } - - return this.account.viewFunction({ - contractId: this.contractId, - methodName: name, - args, - ...options, - }); - }) - }); - }); - changeMethodsWithAbi.forEach(({ name, abi }) => { - Object.defineProperty(this, name, { - writable: false, - enumerable: true, - value: nameFunction(name, async (...args: any[]) => { - if (args.length && (args.length > 3 || !(isObject(args[0]) || isUint8Array(args[0])))) { - throw new PositionalArgsError(); - } - - if (args.length > 1 || !(args[0] && args[0].args)) { - const deprecate = depd('contract.methodName(args, gas, amount)'); - deprecate('use `contract.methodName({ args, gas?, amount?, callbackUrl?, meta? })` instead'); - args[0] = { - args: args[0], - gas: args[1], - amount: args[2] - }; - } - - if (abi) { - validateArguments(args[0].args, abi, ajv, abiRoot); - } - - return this._changeMethod({ methodName: name, ...args[0] }); - }) - }); - }); - } - - private async _changeMethod({ args, methodName, gas, amount, meta, callbackUrl }: ChangeMethodOptions) { - validateBNLike({ gas, amount }); - - const rawResult = await this.account.functionCall({ - contractId: this.contractId, - methodName, - args, - gas, - attachedDeposit: amount, - walletMeta: meta, - walletCallbackUrl: callbackUrl - }); - - return getTransactionLastResult(rawResult); - } -} - -/** - * Validation on arguments being a big number from bn.js - * Throws if an argument is not in BN format or otherwise invalid - * @param argMap - */ -function validateBNLike(argMap: { [name: string]: any }) { - const bnLike = 'number, decimal string or BN'; - for (const argName of Object.keys(argMap)) { - const argValue = argMap[argName]; - if (argValue && !BN.isBN(argValue) && isNaN(argValue)) { - throw new ArgumentTypeError(argName, bnLike, argValue); - } - } -} +export { Contract, ContractMethods } from '@near-js/accounts'; diff --git a/packages/near-api-js/src/key_stores/browser_local_storage_key_store.ts b/packages/near-api-js/src/key_stores/browser_local_storage_key_store.ts index 03ba676f6a..fe4cef8c64 100644 --- a/packages/near-api-js/src/key_stores/browser_local_storage_key_store.ts +++ b/packages/near-api-js/src/key_stores/browser_local_storage_key_store.ts @@ -1,137 +1 @@ -import { KeyStore } from './keystore'; -import { KeyPair } from '../utils/key_pair'; - -const LOCAL_STORAGE_KEY_PREFIX = 'near-api-js:keystore:'; - -/** - * This class is used to store keys in the browsers local storage. - * - * @see [https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store](https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store) - * @example - * ```js - * import { connect, keyStores } from 'near-api-js'; - * - * const keyStore = new keyStores.BrowserLocalStorageKeyStore(); - * const config = { - * keyStore, // instance of BrowserLocalStorageKeyStore - * networkId: 'testnet', - * nodeUrl: 'https://rpc.testnet.near.org', - * walletUrl: 'https://wallet.testnet.near.org', - * helperUrl: 'https://helper.testnet.near.org', - * explorerUrl: 'https://explorer.testnet.near.org' - * }; - * - * // inside an async function - * const near = await connect(config) - * ``` - */ -export class BrowserLocalStorageKeyStore extends KeyStore { - /** @hidden */ - private localStorage: any; - /** @hidden */ - private prefix: string; - - /** - * @param localStorage defaults to window.localStorage - * @param prefix defaults to `near-api-js:keystore:` - */ - constructor(localStorage: any = window.localStorage, prefix = LOCAL_STORAGE_KEY_PREFIX) { - super(); - this.localStorage = localStorage; - this.prefix = prefix; - } - - /** - * Stores a {@link utils/key_pair!KeyPair} in local storage. - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - * @param keyPair The key pair to store in local storage - */ - async setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise { - this.localStorage.setItem(this.storageKeyForSecretKey(networkId, accountId), keyPair.toString()); - } - - /** - * Gets a {@link utils/key_pair!KeyPair} from local storage - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - * @returns {Promise} - */ - async getKey(networkId: string, accountId: string): Promise { - const value = this.localStorage.getItem(this.storageKeyForSecretKey(networkId, accountId)); - if (!value) { - return null; - } - return KeyPair.fromString(value); - } - - /** - * Removes a {@link utils/key_pair!KeyPair} from local storage - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - */ - async removeKey(networkId: string, accountId: string): Promise { - this.localStorage.removeItem(this.storageKeyForSecretKey(networkId, accountId)); - } - - /** - * Removes all items that start with `prefix` from local storage - */ - async clear(): Promise { - for (const key of this.storageKeys()) { - if (key.startsWith(this.prefix)) { - this.localStorage.removeItem(key); - } - } - } - - /** - * Get the network(s) from local storage - * @returns {Promise} - */ - async getNetworks(): Promise { - const result = new Set(); - for (const key of this.storageKeys()) { - if (key.startsWith(this.prefix)) { - const parts = key.substring(this.prefix.length).split(':'); - result.add(parts[1]); - } - } - return Array.from(result.values()); - } - - /** - * Gets the account(s) from local storage - * @param networkId The targeted network. (ex. default, betanet, etc…) - */ - async getAccounts(networkId: string): Promise { - const result = new Array(); - for (const key of this.storageKeys()) { - if (key.startsWith(this.prefix)) { - const parts = key.substring(this.prefix.length).split(':'); - if (parts[1] === networkId) { - result.push(parts[0]); - } - } - } - return result; - } - - /** - * @hidden - * Helper function to retrieve a local storage key - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the storage keythat's sought - * @returns {string} An example might be: `near-api-js:keystore:near-friend:default` - */ - private storageKeyForSecretKey(networkId: string, accountId: string): string { - return `${this.prefix}${accountId}:${networkId}`; - } - - /** @hidden */ - private *storageKeys(): IterableIterator { - for (let i = 0; i < this.localStorage.length; i++) { - yield this.localStorage.key(i); - } - } -} +export { BrowserLocalStorageKeyStore } from '@near-js/keystores-browser'; \ No newline at end of file diff --git a/packages/near-api-js/src/key_stores/in_memory_key_store.ts b/packages/near-api-js/src/key_stores/in_memory_key_store.ts index 2660e3bfee..e89ffaa0cd 100644 --- a/packages/near-api-js/src/key_stores/in_memory_key_store.ts +++ b/packages/near-api-js/src/key_stores/in_memory_key_store.ts @@ -1,112 +1 @@ -import { KeyStore } from './keystore'; -import { KeyPair } from '../utils/key_pair'; - -/** - * Simple in-memory keystore for mainly for testing purposes. - * - * @see [https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store](https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store) - * @example - * ```js - * import { connect, keyStores, utils } from 'near-api-js'; - * - * const privateKey = '.......'; - * const keyPair = utils.KeyPair.fromString(privateKey); - * - * const keyStore = new keyStores.InMemoryKeyStore(); - * keyStore.setKey('testnet', 'example-account.testnet', keyPair); - * - * const config = { - * keyStore, // instance of InMemoryKeyStore - * networkId: 'testnet', - * nodeUrl: 'https://rpc.testnet.near.org', - * walletUrl: 'https://wallet.testnet.near.org', - * helperUrl: 'https://helper.testnet.near.org', - * explorerUrl: 'https://explorer.testnet.near.org' - * }; - * - * // inside an async function - * const near = await connect(config) - * ``` - */ -export class InMemoryKeyStore extends KeyStore { - /** @hidden */ - private keys: { [key: string]: string }; - - constructor() { - super(); - this.keys = {}; - } - - /** - * Stores a {@link utils/key_pair!KeyPair} in in-memory storage item - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - * @param keyPair The key pair to store in local storage - */ - async setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise { - this.keys[`${accountId}:${networkId}`] = keyPair.toString(); - } - - /** - * Gets a {@link utils/key_pair!KeyPair} from in-memory storage - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - * @returns {Promise} - */ - async getKey(networkId: string, accountId: string): Promise { - const value = this.keys[`${accountId}:${networkId}`]; - if (!value) { - return null; - } - return KeyPair.fromString(value); - } - - /** - * Removes a {@link utils/key_pair!KeyPair} from in-memory storage - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - */ - async removeKey(networkId: string, accountId: string): Promise { - delete this.keys[`${accountId}:${networkId}`]; - } - - /** - * Removes all {@link utils/key_pair!KeyPair} from in-memory storage - */ - async clear(): Promise { - this.keys = {}; - } - - /** - * Get the network(s) from in-memory storage - * @returns {Promise} - */ - async getNetworks(): Promise { - const result = new Set(); - Object.keys(this.keys).forEach((key) => { - const parts = key.split(':'); - result.add(parts[1]); - }); - return Array.from(result.values()); - } - - /** - * Gets the account(s) from in-memory storage - * @param networkId The targeted network. (ex. default, betanet, etc…) - */ - async getAccounts(networkId: string): Promise { - const result = new Array(); - Object.keys(this.keys).forEach((key) => { - const parts = key.split(':'); - if (parts[parts.length - 1] === networkId) { - result.push(parts.slice(0, parts.length - 1).join(':')); - } - }); - return result; - } - - /** @hidden */ - toString(): string { - return 'InMemoryKeyStore'; - } -} +export { InMemoryKeyStore } from '@near-js/keystores'; diff --git a/packages/near-api-js/src/key_stores/keystore.ts b/packages/near-api-js/src/key_stores/keystore.ts index 289ef0508f..1d891ccb69 100644 --- a/packages/near-api-js/src/key_stores/keystore.ts +++ b/packages/near-api-js/src/key_stores/keystore.ts @@ -1,16 +1 @@ -import { KeyPair } from '../utils/key_pair'; - -/** - * KeyStores are passed to {@link near!Near} via {@link near!NearConfig} - * and are used by the {@link signer!InMemorySigner} to sign transactions. - * - * @see {@link connect} - */ -export abstract class KeyStore { - abstract setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise; - abstract getKey(networkId: string, accountId: string): Promise; - abstract removeKey(networkId: string, accountId: string): Promise; - abstract clear(): Promise; - abstract getNetworks(): Promise; - abstract getAccounts(networkId: string): Promise; -} +export { KeyStore } from '@near-js/keystores'; diff --git a/packages/near-api-js/src/key_stores/merge_key_store.ts b/packages/near-api-js/src/key_stores/merge_key_store.ts index 603f7ed747..c0f9e25f11 100644 --- a/packages/near-api-js/src/key_stores/merge_key_store.ts +++ b/packages/near-api-js/src/key_stores/merge_key_store.ts @@ -1,134 +1 @@ -import { KeyStore } from './keystore'; -import { KeyPair } from '../utils/key_pair'; - -/** - * Keystore which can be used to merge multiple key stores into one virtual key store. - * - * @example - * ```js - * const { homedir } = require('os'); - * import { connect, keyStores, utils } from 'near-api-js'; - * - * const privateKey = '.......'; - * const keyPair = utils.KeyPair.fromString(privateKey); - * - * const inMemoryKeyStore = new keyStores.InMemoryKeyStore(); - * inMemoryKeyStore.setKey('testnet', 'example-account.testnet', keyPair); - * - * const fileSystemKeyStore = new keyStores.UnencryptedFileSystemKeyStore(`${homedir()}/.near-credentials`); - * - * const keyStore = new MergeKeyStore([ - * inMemoryKeyStore, - * fileSystemKeyStore - * ]); - * const config = { - * keyStore, // instance of MergeKeyStore - * networkId: 'testnet', - * nodeUrl: 'https://rpc.testnet.near.org', - * walletUrl: 'https://wallet.testnet.near.org', - * helperUrl: 'https://helper.testnet.near.org', - * explorerUrl: 'https://explorer.testnet.near.org' - * }; - * - * // inside an async function - * const near = await connect(config) - * ``` - */ - -interface MergeKeyStoreOptions { - writeKeyStoreIndex: number; -} - -export class MergeKeyStore extends KeyStore { - private options: MergeKeyStoreOptions; - keyStores: KeyStore[]; - - /** - * @param keyStores read calls are attempted from start to end of array - * @param options.writeKeyStoreIndex the keystore index that will receive all write calls - */ - constructor(keyStores: KeyStore[], options: MergeKeyStoreOptions = { writeKeyStoreIndex: 0 }) { - super(); - this.options = options; - this.keyStores = keyStores; - } - - /** - * Store a {@link utils/key_pair!KeyPair} to the first index of a key store array - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - * @param keyPair The key pair to store in local storage - */ - async setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise { - await this.keyStores[this.options.writeKeyStoreIndex].setKey(networkId, accountId, keyPair); - } - - /** - * Gets a {@link utils/key_pair!KeyPair} from the array of key stores - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - * @returns {Promise} - */ - async getKey(networkId: string, accountId: string): Promise { - for (const keyStore of this.keyStores) { - const keyPair = await keyStore.getKey(networkId, accountId); - if (keyPair) { - return keyPair; - } - } - return null; - } - - /** - * Removes a {@link utils/key_pair!KeyPair} from the array of key stores - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - */ - async removeKey(networkId: string, accountId: string): Promise { - for (const keyStore of this.keyStores) { - await keyStore.removeKey(networkId, accountId); - } - } - - /** - * Removes all items from each key store - */ - async clear(): Promise { - for (const keyStore of this.keyStores) { - await keyStore.clear(); - } - } - - /** - * Get the network(s) from the array of key stores - * @returns {Promise} - */ - async getNetworks(): Promise { - const result = new Set(); - for (const keyStore of this.keyStores) { - for (const network of await keyStore.getNetworks()) { - result.add(network); - } - } - return Array.from(result); - } - - /** - * Gets the account(s) from the array of key stores - * @param networkId The targeted network. (ex. default, betanet, etc…) - */ - async getAccounts(networkId: string): Promise { - const result = new Set(); - for (const keyStore of this.keyStores) { - for (const account of await keyStore.getAccounts(networkId)) { - result.add(account); - } - } - return Array.from(result); - } - - /** @hidden */ - toString(): string { - return `MergeKeyStore(${this.keyStores.join(', ')})`; - } -} \ No newline at end of file +export { MergeKeyStore } from '@near-js/keystores'; diff --git a/packages/near-api-js/src/key_stores/unencrypted_file_system_keystore.ts b/packages/near-api-js/src/key_stores/unencrypted_file_system_keystore.ts index 2ac80cd0b0..c36e134a37 100644 --- a/packages/near-api-js/src/key_stores/unencrypted_file_system_keystore.ts +++ b/packages/near-api-js/src/key_stores/unencrypted_file_system_keystore.ts @@ -1,178 +1 @@ -import fs from 'fs'; -import path from 'path'; -import { promisify as _promisify } from 'util'; - -import { KeyPair } from '../utils/key_pair'; -import { KeyStore } from './keystore'; - -const promisify = (fn: any) => { - if (!fn) { - return () => { - throw new Error('Trying to use unimplemented function. `fs` module not available in web build?'); - }; - } - return _promisify(fn); -}; - -const exists = promisify(fs.exists); -const readFile = promisify(fs.readFile); -const writeFile = promisify(fs.writeFile); -const unlink = promisify(fs.unlink); -const readdir = promisify(fs.readdir); -const mkdir = promisify(fs.mkdir); - -/** - * Format of the account stored on disk. - */ -interface AccountInfo { - account_id: string; - public_key: string; - private_key: string; -} - -/** @hidden */ -export async function loadJsonFile(filename: string): Promise { - const content = await readFile(filename); - return JSON.parse(content.toString()); -} - -async function ensureDir(dir: string): Promise { - try { - await mkdir(dir, { recursive: true }); - } catch (err) { - if (err.code !== 'EEXIST') { throw err; } - } -} - -/** @hidden */ -export async function readKeyFile(filename: string): Promise<[string, KeyPair]> { - const accountInfo = await loadJsonFile(filename); - // The private key might be in private_key or secret_key field. - let privateKey = accountInfo.private_key; - if (!privateKey && accountInfo.secret_key) { - privateKey = accountInfo.secret_key; - } - return [accountInfo.account_id, KeyPair.fromString(privateKey)]; -} - -/** - * This class is used to store keys on the file system. - * - * @see [https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store](https://docs.near.org/docs/develop/front-end/naj-quick-reference#key-store) - * @example - * ```js - * const { homedir } = require('os'); - * const { connect, keyStores } = require('near-api-js'); - * - * const keyStore = new keyStores.UnencryptedFileSystemKeyStore(`${homedir()}/.near-credentials`); - * const config = { - * keyStore, // instance of UnencryptedFileSystemKeyStore - * networkId: 'testnet', - * nodeUrl: 'https://rpc.testnet.near.org', - * walletUrl: 'https://wallet.testnet.near.org', - * helperUrl: 'https://helper.testnet.near.org', - * explorerUrl: 'https://explorer.testnet.near.org' - * }; - * - * // inside an async function - * const near = await connect(config) - * ``` - */ -export class UnencryptedFileSystemKeyStore extends KeyStore { - /** @hidden */ - readonly keyDir: string; - - /** - * @param keyDir base directory for key storage. Keys will be stored in `keyDir/networkId/accountId.json` - */ - constructor(keyDir: string) { - super(); - this.keyDir = path.resolve(keyDir); - } - - /** - * Store a {@link utils/key_pair!KeyPair} in an unencrypted file - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - * @param keyPair The key pair to store in local storage - */ - async setKey(networkId: string, accountId: string, keyPair: KeyPair): Promise { - await ensureDir(`${this.keyDir}/${networkId}`); - const content: AccountInfo = { account_id: accountId, public_key: keyPair.getPublicKey().toString(), private_key: keyPair.toString() }; - await writeFile(this.getKeyFilePath(networkId, accountId), JSON.stringify(content), { mode: 0o600 }); - } - - /** - * Gets a {@link utils/key_pair!KeyPair} from an unencrypted file - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - * @returns {Promise} - */ - async getKey(networkId: string, accountId: string): Promise { - // Find key / account id. - if (!await exists(this.getKeyFilePath(networkId, accountId))) { - return null; - } - const accountKeyPair = await readKeyFile(this.getKeyFilePath(networkId, accountId)); - return accountKeyPair[1]; - } - - /** - * Deletes an unencrypted file holding a {@link utils/key_pair!KeyPair} - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account tied to the key pair - */ - async removeKey(networkId: string, accountId: string): Promise { - if (await exists(this.getKeyFilePath(networkId, accountId))) { - await unlink(this.getKeyFilePath(networkId, accountId)); - } - } - - /** - * Deletes all unencrypted files from the `keyDir` path. - */ - async clear(): Promise { - for (const network of await this.getNetworks()) { - for (const account of await this.getAccounts(network)) { - await this.removeKey(network, account); - } - } - } - - /** @hidden */ - private getKeyFilePath(networkId: string, accountId: string): string { - return `${this.keyDir}/${networkId}/${accountId}.json`; - } - - /** - * Get the network(s) from files in `keyDir` - * @returns {Promise} - */ - async getNetworks(): Promise { - const files: string[] = await readdir(this.keyDir); - const result = new Array(); - files.forEach((item) => { - result.push(item); - }); - return result; - } - - /** - * Gets the account(s) files in `keyDir/networkId` - * @param networkId The targeted network. (ex. default, betanet, etc…) - */ - async getAccounts(networkId: string): Promise { - if (!await exists(`${this.keyDir}/${networkId}`)) { - return []; - } - const files: string[] = await readdir(`${this.keyDir}/${networkId}`); - return files - .filter(file => file.endsWith('.json')) - .map(file => file.replace(/.json$/, '')); - } - - /** @hidden */ - toString(): string { - return `UnencryptedFileSystemKeyStore(${this.keyDir})`; - } -} +export { readKeyFile, UnencryptedFileSystemKeyStore } from '@near-js/keystores-node'; diff --git a/packages/near-api-js/src/near.ts b/packages/near-api-js/src/near.ts index 7ebd5cb8ad..c75aa3c7de 100644 --- a/packages/near-api-js/src/near.ts +++ b/packages/near-api-js/src/near.ts @@ -1,129 +1 @@ -/** - * This module contains the main class developers will use to interact with NEAR. - * The {@link Near} class is used to interact with {@link account!Account | Accounts} through the {@link providers/json-rpc-provider!JsonRpcProvider}. - * It is configured via the {@link NearConfig}. - * - * @see [https://docs.near.org/tools/near-api-js/quick-reference#account](https://docs.near.org/tools/near-api-js/quick-reference#account) - * - * @module near - */ -import BN from 'bn.js'; -import { Account } from './account'; -import { Connection } from './connection'; -import { Signer } from './signer'; -import { PublicKey } from './utils/key_pair'; -import { AccountCreator, LocalAccountCreator, UrlAccountCreator } from './account_creator'; -import { KeyStore } from './key_stores'; - -export interface NearConfig { - /** Holds {@link utils/key_pair!KeyPair | KeyPairs} for signing transactions */ - keyStore?: KeyStore; - - /** @hidden */ - signer?: Signer; - - /** - * [NEAR Contract Helper](https://github.com/near/near-contract-helper) url used to create accounts if no master account is provided - * @see {@link account_creator!UrlAccountCreator} - */ - helperUrl?: string; - - /** - * The balance transferred from the {@link masterAccount} to a created account - * @see {@link account_creator!LocalAccountCreator} - */ - initialBalance?: string; - - /** - * The account to use when creating new accounts - * @see {@link account_creator!LocalAccountCreator} - */ - masterAccount?: string; - - /** - * {@link utils/key_pair!KeyPair | KeyPairs} are stored in a {@link key_stores/keystore!KeyStore} under the `networkId` namespace. - */ - networkId: string; - - /** - * NEAR RPC API url. used to make JSON RPC calls to interact with NEAR. - * @see {@link providers/json-rpc-provider!JsonRpcProvider} - */ - nodeUrl: string; - - /** - * NEAR RPC API headers. Can be used to pass API KEY and other parameters. - * @see {@link providers/json-rpc-provider!JsonRpcProvider} - */ - headers?: { [key: string]: string | number }; - - /** - * NEAR wallet url used to redirect users to their wallet in browser applications. - * @see [https://wallet.near.org/](https://wallet.near.org/) - */ - walletUrl?: string; - - /** - * JVSM account ID for NEAR JS SDK - */ - jsvmAccountId?: string; -} - -/** - * This is the main class developers should use to interact with NEAR. - * @example - * ```js - * const near = new Near(config); - * ``` - */ -export class Near { - readonly config: any; - readonly connection: Connection; - readonly accountCreator: AccountCreator; - - constructor(config: NearConfig) { - this.config = config; - this.connection = Connection.fromConfig({ - networkId: config.networkId, - provider: { type: 'JsonRpcProvider', args: { url: config.nodeUrl, headers: config.headers } }, - signer: config.signer || { type: 'InMemorySigner', keyStore: config.keyStore }, - jsvmAccountId: config.jsvmAccountId || `jsvm.${config.networkId}` - }); - - if (config.masterAccount) { - // TODO: figure out better way of specifiying initial balance. - // Hardcoded number below must be enough to pay the gas cost to dev-deploy with near-shell for multiple times - const initialBalance = config.initialBalance ? new BN(config.initialBalance) : new BN('500000000000000000000000000'); - this.accountCreator = new LocalAccountCreator(new Account(this.connection, config.masterAccount), initialBalance); - } else if (config.helperUrl) { - this.accountCreator = new UrlAccountCreator(this.connection, config.helperUrl); - } else { - this.accountCreator = null; - } - } - - /** - * @param accountId near accountId used to interact with the network. - */ - async account(accountId: string): Promise { - const account = new Account(this.connection, accountId); - return account; - } - - /** - * Create an account using the {@link account_creator!AccountCreator}. Either: - * * using a masterAccount with {@link account_creator!LocalAccountCreator} - * * using the helperUrl with {@link account_creator!UrlAccountCreator} - * @see {@link NearConfig.masterAccount} and {@link NearConfig.helperUrl} - * - * @param accountId - * @param publicKey - */ - async createAccount(accountId: string, publicKey: PublicKey): Promise { - if (!this.accountCreator) { - throw new Error('Must specify account creator, either via masterAccount or helperUrl configuration settings.'); - } - await this.accountCreator.createAccount(accountId, publicKey); - return new Account(this.connection, accountId); - } -} +export { Near, NearConfig } from '@near-js/wallet-account'; diff --git a/packages/near-api-js/src/providers/json-rpc-provider.ts b/packages/near-api-js/src/providers/json-rpc-provider.ts index 960543e6ef..d18c08ebdd 100644 --- a/packages/near-api-js/src/providers/json-rpc-provider.ts +++ b/packages/near-api-js/src/providers/json-rpc-provider.ts @@ -1,377 +1,2 @@ -/** - * @module - * @description - * This module contains the {@link JsonRpcProvider} client class - * which can be used to interact with the [NEAR RPC API](https://docs.near.org/api/rpc/introduction). - * @see {@link providers/provider | providers} for a list of request and response types - */ -import { - AccessKeyWithPublicKey, - Provider, - FinalExecutionOutcome, - NodeStatusResult, - BlockId, - BlockReference, - BlockResult, - BlockChangeResult, - ChangeResult, - ChunkId, - ChunkResult, - EpochValidatorInfo, - NearProtocolConfig, - LightClientProof, - LightClientProofRequest, - GasPrice, - QueryResponseKind -} from './provider'; -import { ConnectionInfo, fetchJson } from '../utils/web'; -import { TypedError, ErrorContext } from '../utils/errors'; -import { baseEncode } from 'borsh'; -import exponentialBackoff from '../utils/exponential-backoff'; -import { parseRpcError, getErrorTypeFromErrorMessage } from '../utils/rpc_errors'; -import { SignedTransaction } from '../transaction'; - -/** @hidden */ -export { TypedError, ErrorContext }; - -// Default number of retries before giving up on a request. -const REQUEST_RETRY_NUMBER = 12; - -// Default wait until next retry in millis. -const REQUEST_RETRY_WAIT = 500; - -// Exponential back off for waiting to retry. -const REQUEST_RETRY_WAIT_BACKOFF = 1.5; - -/// Keep ids unique across all connections. -let _nextId = 123; - -/** - * Client class to interact with the [NEAR RPC API](https://docs.near.org/api/rpc/introduction). - * @see [https://github.com/near/nearcore/tree/master/chain/jsonrpc](https://github.com/near/nearcore/tree/master/chain/jsonrpc) - */ -export class JsonRpcProvider extends Provider { - /** @hidden */ - readonly connection: ConnectionInfo; - - /** - * @param connectionInfo Connection info - */ - constructor(connectionInfo: ConnectionInfo) { - super(); - this.connection = connectionInfo || { url: '' }; - } - - /** - * Gets the RPC's status - * @see [https://docs.near.org/docs/develop/front-end/rpc#general-validator-status](https://docs.near.org/docs/develop/front-end/rpc#general-validator-status) - */ - async status(): Promise { - return this.sendJsonRpc('status', []); - } - - /** - * Sends a signed transaction to the RPC and waits until transaction is fully complete - * @see [https://docs.near.org/docs/develop/front-end/rpc#send-transaction-await](https://docs.near.org/docs/develop/front-end/rpc#general-validator-status) - * - * @param signedTransaction The signed transaction being sent - */ - async sendTransaction(signedTransaction: SignedTransaction): Promise { - const bytes = signedTransaction.encode(); - return this.sendJsonRpc('broadcast_tx_commit', [Buffer.from(bytes).toString('base64')]); - } - - /** - * Sends a signed transaction to the RPC and immediately returns transaction hash - * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#send-transaction-async) - * @param signedTransaction The signed transaction being sent - * @returns {Promise} - */ - async sendTransactionAsync(signedTransaction: SignedTransaction): Promise { - const bytes = signedTransaction.encode(); - return this.sendJsonRpc('broadcast_tx_async', [Buffer.from(bytes).toString('base64')]); - } - - /** - * Gets a transaction's status from the RPC - * @see [https://docs.near.org/docs/develop/front-end/rpc#transaction-status](https://docs.near.org/docs/develop/front-end/rpc#general-validator-status) - * - * @param txHash A transaction hash as either a Uint8Array or a base58 encoded string - * @param accountId The NEAR account that signed the transaction - */ - async txStatus(txHash: Uint8Array | string, accountId: string): Promise { - if (typeof txHash === 'string') { - return this.txStatusString(txHash, accountId); - } else { - return this.txStatusUint8Array(txHash, accountId); - } - } - - private async txStatusUint8Array(txHash: Uint8Array, accountId: string): Promise { - return this.sendJsonRpc('tx', [baseEncode(txHash), accountId]); - } - - private async txStatusString(txHash: string, accountId: string): Promise { - return this.sendJsonRpc('tx', [txHash, accountId]); - } - - /** - * Gets a transaction's status from the RPC with receipts - * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#transaction-status-with-receipts) - * @param txHash The hash of the transaction - * @param accountId The NEAR account that signed the transaction - * @returns {Promise} - */ - async txStatusReceipts(txHash: Uint8Array | string, accountId: string): Promise { - if (typeof txHash === 'string') { - return this.sendJsonRpc('EXPERIMENTAL_tx_status', [txHash, accountId]); - } - else { - return this.sendJsonRpc('EXPERIMENTAL_tx_status', [baseEncode(txHash), accountId]); - } - } - - /** - * Query the RPC by passing an {@link providers/provider!RpcQueryRequest} - * @see [https://docs.near.org/api/rpc/contracts](https://docs.near.org/api/rpc/contracts) - * - * @typeParam T the shape of the returned query response - */ - async query(...args: any[]): Promise { - let result; - if (args.length === 1) { - const { block_id, blockId, ...otherParams } = args[0]; - result = await this.sendJsonRpc('query', { ...otherParams, block_id: block_id || blockId }); - } else { - const [path, data] = args; - result = await this.sendJsonRpc('query', [path, data]); - } - if (result && result.error) { - throw new TypedError( - `Querying failed: ${result.error}.\n${JSON.stringify(result, null, 2)}`, - getErrorTypeFromErrorMessage(result.error, result.error.name) - ); - } - return result; - } - - /** - * Query for block info from the RPC - * pass block_id OR finality as blockQuery, not both - * @see [https://docs.near.org/api/rpc/block-chunk](https://docs.near.org/api/rpc/block-chunk) - * - * @param blockQuery {@link providers/provider!BlockReference} (passing a {@link providers/provider!BlockId} is deprecated) - */ - async block(blockQuery: BlockId | BlockReference): Promise { - const { finality } = blockQuery as any; - const { blockId } = blockQuery as any; - return this.sendJsonRpc('block', { block_id: blockId, finality }); - } - - /** - * Query changes in block from the RPC - * pass block_id OR finality as blockQuery, not both - * @see [https://docs.near.org/api/rpc/block-chunk](https://docs.near.org/api/rpc/block-chunk) - */ - async blockChanges(blockQuery: BlockReference): Promise { - const { finality } = blockQuery as any; - const { blockId } = blockQuery as any; - return this.sendJsonRpc('EXPERIMENTAL_changes_in_block', { block_id: blockId, finality }); - } - - /** - * Queries for details about a specific chunk appending details of receipts and transactions to the same chunk data provided by a block - * @see [https://docs.near.org/api/rpc/block-chunk](https://docs.near.org/api/rpc/block-chunk) - * - * @param chunkId Hash of a chunk ID or shard ID - */ - async chunk(chunkId: ChunkId): Promise { - return this.sendJsonRpc('chunk', [chunkId]); - } - - /** - * Query validators of the epoch defined by the given block id. - * @see [https://docs.near.org/api/rpc/network#validation-status](https://docs.near.org/api/rpc/network#validation-status) - * - * @param blockId Block hash or height, or null for latest. - */ - async validators(blockId: BlockId | null): Promise { - return this.sendJsonRpc('validators', [blockId]); - } - - /** - * Gets the protocol config at a block from RPC - * - * @param blockReference specifies the block to get the protocol config for - */ - async experimental_protocolConfig(blockReference: BlockReference | { sync_checkpoint: 'genesis' }): Promise { - const { blockId, ...otherParams } = blockReference as any; - return await this.sendJsonRpc('EXPERIMENTAL_protocol_config', {...otherParams, block_id: blockId}); - } - - /** - * Gets a light client execution proof for verifying execution outcomes - * @see [https://github.com/nearprotocol/NEPs/blob/master/specs/ChainSpec/LightClient.md#light-client-proof](https://github.com/nearprotocol/NEPs/blob/master/specs/ChainSpec/LightClient.md#light-client-proof) - */ - async lightClientProof(request: LightClientProofRequest): Promise { - return await this.sendJsonRpc('EXPERIMENTAL_light_client_proof', request); - } - - /** - * Gets access key changes for a given array of accountIds - * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-access-key-changes-all) - * @returns {Promise} - */ - async accessKeyChanges(accountIdArray: string[], blockQuery: BlockReference): Promise { - const { finality } = blockQuery as any; - const { blockId } = blockQuery as any; - return this.sendJsonRpc('EXPERIMENTAL_changes', { - changes_type: 'all_access_key_changes', - account_ids: accountIdArray, - block_id: blockId, - finality - }); - } - - /** - * Gets single access key changes for a given array of access keys - * pass block_id OR finality as blockQuery, not both - * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-access-key-changes-single) - * @returns {Promise} - */ - async singleAccessKeyChanges(accessKeyArray: AccessKeyWithPublicKey[], blockQuery: BlockReference): Promise { - const { finality } = blockQuery as any; - const { blockId } = blockQuery as any; - return this.sendJsonRpc('EXPERIMENTAL_changes', { - changes_type: 'single_access_key_changes', - keys: accessKeyArray, - block_id: blockId, - finality - }); - } - - /** - * Gets account changes for a given array of accountIds - * pass block_id OR finality as blockQuery, not both - * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-account-changes) - * @returns {Promise} - */ - async accountChanges(accountIdArray: string[], blockQuery: BlockReference): Promise { - const { finality } = blockQuery as any; - const { blockId } = blockQuery as any; - return this.sendJsonRpc('EXPERIMENTAL_changes', { - changes_type: 'account_changes', - account_ids: accountIdArray, - block_id: blockId, - finality - }); - } - - /** - * Gets contract state changes for a given array of accountIds - * pass block_id OR finality as blockQuery, not both - * Note: If you pass a keyPrefix it must be base64 encoded - * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-contract-state-changes) - * @returns {Promise} - */ - async contractStateChanges(accountIdArray: string[], blockQuery: BlockReference, keyPrefix = ''): Promise { - const { finality } = blockQuery as any; - const { blockId } = blockQuery as any; - return this.sendJsonRpc('EXPERIMENTAL_changes', { - changes_type: 'data_changes', - account_ids: accountIdArray, - key_prefix_base64: keyPrefix, - block_id: blockId, - finality - }); - } - - /** - * Gets contract code changes for a given array of accountIds - * pass block_id OR finality as blockQuery, not both - * Note: Change is returned in a base64 encoded WASM file - * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-contract-code-changes) - * @returns {Promise} - */ - async contractCodeChanges(accountIdArray: string[], blockQuery: BlockReference): Promise { - const { finality } = blockQuery as any; - const { blockId } = blockQuery as any; - return this.sendJsonRpc('EXPERIMENTAL_changes', { - changes_type: 'contract_code_changes', - account_ids: accountIdArray, - block_id: blockId, - finality - }); - } - - /** - * Returns gas price for a specific block_height or block_hash. - * @see [https://docs.near.org/api/rpc/gas](https://docs.near.org/api/rpc/gas) - * - * @param blockId Block hash or height, or null for latest. - */ - async gasPrice(blockId: BlockId | null): Promise { - return await this.sendJsonRpc('gas_price', [blockId]); - } - - /** - * Directly call the RPC specifying the method and params - * - * @param method RPC method - * @param params Parameters to the method - */ - async sendJsonRpc(method: string, params: object): Promise { - const response = await exponentialBackoff(REQUEST_RETRY_WAIT, REQUEST_RETRY_NUMBER, REQUEST_RETRY_WAIT_BACKOFF, async () => { - try { - const request = { - method, - params, - id: (_nextId++), - jsonrpc: '2.0' - }; - const response = await fetchJson(this.connection, JSON.stringify(request)); - if (response.error) { - if (typeof response.error.data === 'object') { - if (typeof response.error.data.error_message === 'string' && typeof response.error.data.error_type === 'string') { - // if error data has error_message and error_type properties, we consider that node returned an error in the old format - throw new TypedError(response.error.data.error_message, response.error.data.error_type); - } - - throw parseRpcError(response.error.data); - } else { - const errorMessage = `[${response.error.code}] ${response.error.message}: ${response.error.data}`; - // NOTE: All this hackery is happening because structured errors not implemented - // TODO: Fix when https://github.com/nearprotocol/nearcore/issues/1839 gets resolved - if (response.error.data === 'Timeout' || errorMessage.includes('Timeout error') - || errorMessage.includes('query has timed out')) { - throw new TypedError(errorMessage, 'TimeoutError'); - } - - throw new TypedError(errorMessage, getErrorTypeFromErrorMessage(response.error.data, response.error.name)); - } - } - // Success when response.error is not exist - return response; - } catch (error) { - if (error.type === 'TimeoutError') { - if (!process.env['NEAR_NO_LOGS']) { - console.warn(`Retrying request to ${method} as it has timed out`, params); - } - return null; - } - - throw error; - } - }); - const { result } = response; - // From jsonrpc spec: - // result - // This member is REQUIRED on success. - // This member MUST NOT exist if there was an error invoking the method. - if (typeof result === 'undefined') { - throw new TypedError( - `Exceeded ${REQUEST_RETRY_NUMBER} attempts for request to ${method}.`, 'RetriesExceeded'); - } - return result; - } -} +export { ErrorContext, TypedError } from '@near-js/types'; +export { JsonRpcProvider } from '@near-js/providers'; diff --git a/packages/near-api-js/src/providers/provider.ts b/packages/near-api-js/src/providers/provider.ts index 9ac7b0ffc7..282825d47a 100644 --- a/packages/near-api-js/src/providers/provider.ts +++ b/packages/near-api-js/src/providers/provider.ts @@ -1,454 +1,71 @@ -/** - * NEAR RPC API request types and responses - * @module - */ -import { SignedTransaction } from '../transaction'; -import BN from 'bn.js'; - -export interface SyncInfo { - latest_block_hash: string; - latest_block_height: number; - latest_block_time: string; - latest_state_root: string; - syncing: boolean; -} - -interface Version { - version: string; - build: string; -} - -export interface NodeStatusResult { - chain_id: string; - rpc_addr: string; - sync_info: SyncInfo; - validators: string[]; - version: Version; -} - -type BlockHash = string; -type BlockHeight = number; -export type BlockId = BlockHash | BlockHeight; - -export type Finality = 'optimistic' | 'near-final' | 'final' - -export type BlockReference = { blockId: BlockId } | { finality: Finality } | { sync_checkpoint: 'genesis' | 'earliest_available' } - -export enum ExecutionStatusBasic { - Unknown = 'Unknown', - Pending = 'Pending', - Failure = 'Failure', -} - -export interface ExecutionStatus { - SuccessValue?: string; - SuccessReceiptId?: string; - Failure?: ExecutionError; -} - -export enum FinalExecutionStatusBasic { - NotStarted = 'NotStarted', - Started = 'Started', - Failure = 'Failure', -} - -export interface ExecutionError { - error_message: string; - error_type: string; -} - -export interface FinalExecutionStatus { - SuccessValue?: string; - Failure?: ExecutionError; -} - -export interface ExecutionOutcomeWithId { - id: string; - outcome: ExecutionOutcome; -} - -export interface ExecutionOutcome { - logs: string[]; - receipt_ids: string[]; - gas_burnt: number; - status: ExecutionStatus | ExecutionStatusBasic; -} - -export interface ExecutionOutcomeWithIdView { - proof: MerklePath; - block_hash: string; - id: string; - outcome: ExecutionOutcome; -} - -export interface FinalExecutionOutcome { - status: FinalExecutionStatus | FinalExecutionStatusBasic; - transaction: any; - transaction_outcome: ExecutionOutcomeWithId; - receipts_outcome: ExecutionOutcomeWithId[]; -} - -export interface TotalWeight { - num: number; -} - -export interface BlockHeader { - height: number; - epoch_id: string; - next_epoch_id: string; - hash: string; - prev_hash: string; - prev_state_root: string; - chunk_receipts_root: string; - chunk_headers_root: string; - chunk_tx_root: string; - outcome_root: string; - chunks_included: number; - challenges_root: string; - timestamp: number; - timestamp_nanosec: string; - random_value: string; - validator_proposals: any[]; - chunk_mask: boolean[]; - gas_price: string; - rent_paid: string; - validator_reward: string; - total_supply: string; - challenges_result: any[]; - last_final_block: string; - last_ds_final_block: string; - next_bp_hash: string; - block_merkle_root: string; - approvals: string[]; - signature: string; - latest_protocol_version: number; -} - -export type ChunkHash = string; -export type ShardId = number; -export type BlockShardId = [BlockId, ShardId]; -export type ChunkId = ChunkHash | BlockShardId; - -export interface ChunkHeader { - balance_burnt: string; - chunk_hash: ChunkHash; - encoded_length: number; - encoded_merkle_root: string; - gas_limit: number; - gas_used: number; - height_created: number; - height_included: number; - outcome_root: string; - outgoing_receipts_root: string; - prev_block_hash: string; - prev_state_root: string; - rent_paid: string; - shard_id: number; - signature: string; - tx_root: string; - validator_proposals: any[]; - validator_reward: string; -} - -export interface ChunkResult { - author: string; - header: ChunkHeader; - receipts: any[]; - transactions: Transaction[]; -} - -export interface Chunk { - chunk_hash: string; - prev_block_hash: string; - outcome_root: string; - prev_state_root: string; - encoded_merkle_root: string; - encoded_length: number; - height_created: number; - height_included: number; - shard_id: number; - gas_used: number; - gas_limit: number; - rent_paid: string; - validator_reward: string; - balance_burnt: string; - outgoing_receipts_root: string; - tx_root: string; - validator_proposals: any[]; - signature: string; -} - -export interface Transaction { - actions: Array; - hash: string; - nonce: BN; - public_key: string; - receiver_id: string; - signature: string; - signer_id: string; -} - -export interface BlockResult { - author: string; - header: BlockHeader; - chunks: Chunk[]; -} - -export interface BlockChange { - type: string; - account_id: string; -} - -export interface BlockChangeResult { - block_hash: string; - changes: BlockChange[]; -} - -export interface ChangeResult { - block_hash: string; - changes: any[]; -} - -export interface CurrentEpochValidatorInfo { - account_id: string; - public_key: string; - is_slashed: boolean; - stake: string; - shards: number[]; - num_produced_blocks: number; - num_expected_blocks: number; -} - -export interface NextEpochValidatorInfo { - account_id: string; - public_key: string; - stake: string; - shards: number[]; -} - -export interface ValidatorStakeView { - account_id: string; - public_key: string; - stake: string; -} - -export interface NearProtocolConfig { - runtime_config: NearProtocolRuntimeConfig; -} - -export interface NearProtocolRuntimeConfig { - storage_amount_per_byte: string; -} - -export interface EpochValidatorInfo { - // Validators for the current epoch. - next_validators: NextEpochValidatorInfo[]; - // Validators for the next epoch. - current_validators: CurrentEpochValidatorInfo[]; - // Fishermen for the current epoch. - next_fisherman: ValidatorStakeView[]; - // Fishermen for the next epoch. - current_fisherman: ValidatorStakeView[]; - // Proposals in the current epoch. - current_proposals: ValidatorStakeView[]; - // Kickout in the previous epoch. - prev_epoch_kickout: ValidatorStakeView[]; - // Epoch start height. - epoch_start_height: number; -} - -export interface MerkleNode { - hash: string; - direction: string; -} - -export type MerklePath = MerkleNode[]; - -export interface BlockHeaderInnerLiteView { - height: number; - epoch_id: string; - next_epoch_id: string; - prev_state_root: string; - outcome_root: string; - timestamp: number; - next_bp_hash: string; - block_merkle_root: string; -} - -export interface LightClientBlockLiteView { - prev_block_hash: string; - inner_rest_hash: string; - inner_lite: BlockHeaderInnerLiteView; -} - -export interface LightClientProof { - outcome_proof: ExecutionOutcomeWithIdView; - outcome_root_proof: MerklePath; - block_header_lite: LightClientBlockLiteView; - block_proof: MerklePath; -} - -export enum IdType { - Transaction = 'transaction', - Receipt = 'receipt', -} - -export interface LightClientProofRequest { - type: IdType; - light_client_head: string; - transaction_hash?: string; - sender_id?: string; - receipt_id?: string; - receiver_id?: string; -} - -export interface GasPrice { - gas_price: string; -} - -export interface AccessKeyWithPublicKey { - account_id: string; - public_key: string; -} - -export interface QueryResponseKind { - block_height: BlockHeight; - block_hash: BlockHash; -} - -export interface AccountView extends QueryResponseKind { - amount: string; - locked: string; - code_hash: string; - storage_usage: number; - storage_paid_at: BlockHeight; -} - -interface StateItem { - key: string; - value: string; - proof: string[]; -} - -export interface ViewStateResult extends QueryResponseKind { - values: StateItem[]; - proof: string[]; -} - -export interface CodeResult extends QueryResponseKind { - result: number[]; - logs: string[]; -} - -export interface ContractCodeView extends QueryResponseKind { - code_base64: string; - hash: string; -} - -export interface FunctionCallPermissionView { - FunctionCall: { - allowance: string; - receiver_id: string; - method_names: string[]; - }; -} -export interface AccessKeyViewRaw extends QueryResponseKind { - nonce: number; - permission: 'FullAccess' | FunctionCallPermissionView; -} -export interface AccessKeyView extends QueryResponseKind { - nonce: BN; - permission: 'FullAccess' | FunctionCallPermissionView; -} - -export interface AccessKeyInfoView { - public_key: string; - access_key: AccessKeyView; -} - -export interface AccessKeyList extends QueryResponseKind { - keys: AccessKeyInfoView[]; -} - -export interface ViewAccountRequest { - request_type: 'view_account'; - account_id: string; -} - -export interface ViewCodeRequest { - request_type: 'view_code'; - account_id: string; -} - -export interface ViewStateRequest { - request_type: 'view_state'; - account_id: string; - prefix_base64: string; -} - -export interface ViewAccessKeyRequest { - request_type: 'view_access_key'; - account_id: string; - public_key: string; -} - -export interface ViewAccessKeyListRequest { - request_type: 'view_access_key_list'; - account_id: string; -} - -export interface CallFunctionRequest { - request_type: 'call_function'; - account_id: string; - method_name: string; - args_base64: string; -} - -export type RpcQueryRequest = (ViewAccountRequest | - ViewCodeRequest | - ViewStateRequest | - ViewAccountRequest | - ViewAccessKeyRequest | - ViewAccessKeyListRequest | - CallFunctionRequest) & BlockReference - - -/** @hidden */ -export abstract class Provider { - abstract status(): Promise; - - abstract sendTransaction(signedTransaction: SignedTransaction): Promise; - abstract sendTransactionAsync(signedTransaction: SignedTransaction): Promise; - abstract txStatus(txHash: Uint8Array | string, accountId: string): Promise; - abstract txStatusReceipts(txHash: Uint8Array | string, accountId: string): Promise; - abstract query(params: RpcQueryRequest): Promise; - abstract query(path: string, data: string): Promise; - // TODO: BlockQuery type? - abstract block(blockQuery: BlockId | BlockReference): Promise; - abstract blockChanges(blockQuery: BlockId | BlockReference): Promise; - abstract chunk(chunkId: ChunkId): Promise; - // TODO: Use BlockQuery? - abstract validators(blockId: BlockId): Promise; - abstract experimental_protocolConfig(blockReference: BlockReference): Promise; - abstract lightClientProof(request: LightClientProofRequest): Promise; - abstract gasPrice(blockId: BlockId): Promise; - abstract accessKeyChanges(accountIdArray: string[], BlockQuery: BlockId | BlockReference): Promise; - abstract singleAccessKeyChanges(accessKeyArray: AccessKeyWithPublicKey[], BlockQuery: BlockId | BlockReference): Promise; - abstract accountChanges(accountIdArray: string[], BlockQuery: BlockId | BlockReference): Promise; - abstract contractStateChanges(accountIdArray: string[], BlockQuery: BlockId | BlockReference, keyPrefix: string): Promise; - abstract contractCodeChanges(accountIdArray: string[], BlockQuery: BlockId | BlockReference): Promise; -} - -/** @hidden */ -export function getTransactionLastResult(txResult: FinalExecutionOutcome): any { - if (typeof txResult.status === 'object' && typeof txResult.status.SuccessValue === 'string') { - const value = Buffer.from(txResult.status.SuccessValue, 'base64').toString(); - try { - return JSON.parse(value); - } catch (e) { - return value; - } - } - return null; -} +export { getTransactionLastResult } from '@near-js/utils'; +export { Provider } from '@near-js/providers'; +export { + IdType, + LightClientBlockLiteView, + LightClientProof, + LightClientProofRequest, + + AccessKeyWithPublicKey, + BlockHash, + BlockChange, + BlockChangeResult, + BlockHeader, + BlockHeaderInnerLiteView, + BlockHeight, + BlockId, + BlockReference, + BlockResult, + BlockShardId, + ChangeResult, + Chunk, + ChunkHash, + ChunkHeader, + ChunkId, + ChunkResult, + Finality, + GasPrice, + MerkleNode, + MerklePath, + NearProtocolConfig, + NearProtocolRuntimeConfig, + NodeStatusResult, + ShardId, + SyncInfo, + TotalWeight, + ProviderTransaction as Transaction, + + CallFunctionRequest, + RpcQueryRequest, + ViewAccessKeyListRequest, + ViewAccessKeyRequest, + ViewAccountRequest, + ViewCodeRequest, + ViewStateRequest, + + AccessKeyInfoView, + AccessKeyList, + AccessKeyView, + AccessKeyViewRaw, + AccountView, + CodeResult, + ContractCodeView, + ExecutionError, + ExecutionOutcome, + ExecutionOutcomeWithId, + ExecutionOutcomeWithIdView, + ExecutionStatus, + ExecutionStatusBasic, + FinalExecutionOutcome, + FinalExecutionStatus, + FinalExecutionStatusBasic, + FunctionCallPermissionView, + QueryResponseKind, + ViewStateResult, + + CurrentEpochValidatorInfo, + EpochValidatorInfo, + NextEpochValidatorInfo, + ValidatorStakeView, +} from '@near-js/types'; diff --git a/packages/near-api-js/src/signer.ts b/packages/near-api-js/src/signer.ts index 7b9d3f4d94..0195a07204 100644 --- a/packages/near-api-js/src/signer.ts +++ b/packages/near-api-js/src/signer.ts @@ -1,105 +1 @@ -import sha256 from 'js-sha256'; -import { Signature, KeyPair, PublicKey } from './utils/key_pair'; -import { KeyStore } from './key_stores/keystore'; -import { InMemoryKeyStore } from './key_stores/in_memory_key_store'; - -/** - * General signing interface, can be used for in memory signing, RPC singing, external wallet, HSM, etc. - */ -export abstract class Signer { - - /** - * Creates new key and returns public key. - */ - abstract createKey(accountId: string, networkId?: string): Promise; - - /** - * Returns public key for given account / network. - * @param accountId accountId to retrieve from. - * @param networkId The targeted network. (ex. default, betanet, etc…) - */ - abstract getPublicKey(accountId?: string, networkId?: string): Promise; - - /** - * Signs given message, by first hashing with sha256. - * @param message message to sign. - * @param accountId accountId to use for signing. - * @param networkId The targeted network. (ex. default, betanet, etc…) - */ - abstract signMessage(message: Uint8Array, accountId?: string, networkId?: string): Promise; -} - -/** - * Signs using in memory key store. - */ -export class InMemorySigner extends Signer { - readonly keyStore: KeyStore; - - constructor(keyStore: KeyStore) { - super(); - this.keyStore = keyStore; - } - - /** - * Creates a single account Signer instance with account, network and keyPair provided. - * - * Intended to be useful for temporary keys (e.g. claiming a Linkdrop). - * - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @param accountId The NEAR account to assign the key pair to - * @param keyPair The keyPair to use for signing - */ - static async fromKeyPair(networkId: string, accountId: string, keyPair: KeyPair): Promise { - const keyStore = new InMemoryKeyStore(); - await keyStore.setKey(networkId, accountId, keyPair); - return new InMemorySigner(keyStore); - } - - /** - * Creates a public key for the account given - * @param accountId The NEAR account to assign a public key to - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @returns {Promise} - */ - async createKey(accountId: string, networkId: string): Promise { - const keyPair = KeyPair.fromRandom('ed25519'); - await this.keyStore.setKey(networkId, accountId, keyPair); - return keyPair.getPublicKey(); - } - - /** - * Gets the existing public key for a given account - * @param accountId The NEAR account to assign a public key to - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @returns {Promise} Returns the public key or null if not found - */ - async getPublicKey(accountId?: string, networkId?: string): Promise { - const keyPair = await this.keyStore.getKey(networkId, accountId); - if (keyPair === null) { - return null; - } - return keyPair.getPublicKey(); - } - - /** - * @param message A message to be signed, typically a serialized transaction - * @param accountId the NEAR account signing the message - * @param networkId The targeted network. (ex. default, betanet, etc…) - * @returns {Promise} - */ - async signMessage(message: Uint8Array, accountId?: string, networkId?: string): Promise { - const hash = new Uint8Array(sha256.sha256.array(message)); - if (!accountId) { - throw new Error('InMemorySigner requires provided account id'); - } - const keyPair = await this.keyStore.getKey(networkId, accountId); - if (keyPair === null) { - throw new Error(`Key for ${accountId} not found in ${networkId}`); - } - return keyPair.sign(hash); - } - - toString(): string { - return `InMemorySigner(${this.keyStore})`; - } -} +export { InMemorySigner, Signer } from '@near-js/signers'; diff --git a/packages/near-api-js/src/transaction.ts b/packages/near-api-js/src/transaction.ts index 3d2f72a1f9..e30019e26c 100644 --- a/packages/near-api-js/src/transaction.ts +++ b/packages/near-api-js/src/transaction.ts @@ -1,257 +1,34 @@ -import sha256 from 'js-sha256'; -import BN from 'bn.js'; - -import { Enum, Assignable } from './utils/enums'; -import { serialize, deserialize } from 'borsh'; -import { KeyType, PublicKey } from './utils/key_pair'; -import { Signer } from './signer'; - -export class FunctionCallPermission extends Assignable { - allowance?: BN; - receiverId: string; - methodNames: string[]; -} - -export class FullAccessPermission extends Assignable {} - -export class AccessKeyPermission extends Enum { - functionCall: FunctionCallPermission; - fullAccess: FullAccessPermission; -} - -export class AccessKey extends Assignable { - permission: AccessKeyPermission; -} - -export function fullAccessKey(): AccessKey { - return new AccessKey({ permission: new AccessKeyPermission({fullAccess: new FullAccessPermission({})}) }); -} - -export function functionCallAccessKey(receiverId: string, methodNames: string[], allowance?: BN): AccessKey { - return new AccessKey({ permission: new AccessKeyPermission({functionCall: new FunctionCallPermission({receiverId, allowance, methodNames})})}); -} - -export class IAction extends Assignable {} - -export class CreateAccount extends IAction {} -export class DeployContract extends IAction { code: Uint8Array; } -export class FunctionCall extends IAction { methodName: string; args: Uint8Array; gas: BN; deposit: BN; } -export class Transfer extends IAction { deposit: BN; } -export class Stake extends IAction { stake: BN; publicKey: PublicKey; } -export class AddKey extends IAction { publicKey: PublicKey; accessKey: AccessKey; } -export class DeleteKey extends IAction { publicKey: PublicKey; } -export class DeleteAccount extends IAction { beneficiaryId: string; } - -export function createAccount(): Action { - return new Action({createAccount: new CreateAccount({}) }); -} - -export function deployContract(code: Uint8Array): Action { - return new Action({ deployContract: new DeployContract({code}) }); -} - -export function stringifyJsonOrBytes(args: any): Buffer { - const isUint8Array = args.byteLength !== undefined && args.byteLength === args.length; - const serializedArgs = isUint8Array ? args : Buffer.from(JSON.stringify(args)); - return serializedArgs; -} - -/** - * Constructs {@link Action} instance representing contract method call. - * - * @param methodName the name of the method to call - * @param args arguments to pass to method. Can be either plain JS object which gets serialized as JSON automatically - * or `Uint8Array` instance which represents bytes passed as is. - * @param gas max amount of gas that method call can use - * @param deposit amount of NEAR (in yoctoNEAR) to send together with the call - * @param stringify Convert input arguments into bytes array. - * @param jsContract Is contract from JS SDK, skips stringification of arguments. - */ -export function functionCall(methodName: string, args: Uint8Array | object, gas: BN, deposit: BN, stringify = stringifyJsonOrBytes, jsContract = false): Action { - if(jsContract){ - return new Action({ functionCall: new FunctionCall({ methodName, args, gas, deposit }) }); - } - return new Action({ functionCall: new FunctionCall({ methodName, args: stringify(args), gas, deposit }) }); -} - -export function transfer(deposit: BN): Action { - return new Action({transfer: new Transfer({ deposit }) }); -} - -export function stake(stake: BN, publicKey: PublicKey): Action { - return new Action({stake: new Stake({ stake, publicKey }) }); -} - -export function addKey(publicKey: PublicKey, accessKey: AccessKey): Action { - return new Action({addKey: new AddKey({ publicKey, accessKey}) }); -} - -export function deleteKey(publicKey: PublicKey): Action { - return new Action({deleteKey: new DeleteKey({ publicKey }) }); -} - -export function deleteAccount(beneficiaryId: string): Action { - return new Action({deleteAccount: new DeleteAccount({ beneficiaryId }) }); -} - -export class Signature extends Assignable { - keyType: KeyType; - data: Uint8Array; -} - -export class Transaction extends Assignable { - signerId: string; - publicKey: PublicKey; - nonce: BN; - receiverId: string; - actions: Action[]; - blockHash: Uint8Array; - - encode(): Uint8Array { - return serialize(SCHEMA, this); - } - - static decode(bytes: Buffer): Transaction { - return deserialize(SCHEMA, Transaction, bytes); - } -} - -export class SignedTransaction extends Assignable { - transaction: Transaction; - signature: Signature; - - encode(): Uint8Array { - return serialize(SCHEMA, this); - } - - static decode(bytes: Buffer): SignedTransaction { - return deserialize(SCHEMA, SignedTransaction, bytes); - } -} - -/** - * Contains a list of the valid transaction Actions available with this API - * @see {@link https://nomicon.io/RuntimeSpec/Actions.html | Actions Spec} - */ -export class Action extends Enum { - createAccount: CreateAccount; - deployContract: DeployContract; - functionCall: FunctionCall; - transfer: Transfer; - stake: Stake; - addKey: AddKey; - deleteKey: DeleteKey; - deleteAccount: DeleteAccount; -} - -type Class = new (...args: any[]) => T; - -export const SCHEMA = new Map([ - [Signature, {kind: 'struct', fields: [ - ['keyType', 'u8'], - ['data', [64]] - ]}], - [SignedTransaction, {kind: 'struct', fields: [ - ['transaction', Transaction], - ['signature', Signature] - ]}], - [Transaction, { kind: 'struct', fields: [ - ['signerId', 'string'], - ['publicKey', PublicKey], - ['nonce', 'u64'], - ['receiverId', 'string'], - ['blockHash', [32]], - ['actions', [Action]] - ]}], - [PublicKey, { kind: 'struct', fields: [ - ['keyType', 'u8'], - ['data', [32]] - ]}], - [AccessKey, { kind: 'struct', fields: [ - ['nonce', 'u64'], - ['permission', AccessKeyPermission], - ]}], - [AccessKeyPermission, {kind: 'enum', field: 'enum', values: [ - ['functionCall', FunctionCallPermission], - ['fullAccess', FullAccessPermission], - ]}], - [FunctionCallPermission, {kind: 'struct', fields: [ - ['allowance', {kind: 'option', type: 'u128'}], - ['receiverId', 'string'], - ['methodNames', ['string']], - ]}], - [FullAccessPermission, {kind: 'struct', fields: []}], - [Action, {kind: 'enum', field: 'enum', values: [ - ['createAccount', CreateAccount], - ['deployContract', DeployContract], - ['functionCall', FunctionCall], - ['transfer', Transfer], - ['stake', Stake], - ['addKey', AddKey], - ['deleteKey', DeleteKey], - ['deleteAccount', DeleteAccount], - ]}], - [CreateAccount, { kind: 'struct', fields: [] }], - [DeployContract, { kind: 'struct', fields: [ - ['code', ['u8']] - ]}], - [FunctionCall, { kind: 'struct', fields: [ - ['methodName', 'string'], - ['args', ['u8']], - ['gas', 'u64'], - ['deposit', 'u128'] - ]}], - [Transfer, { kind: 'struct', fields: [ - ['deposit', 'u128'] - ]}], - [Stake, { kind: 'struct', fields: [ - ['stake', 'u128'], - ['publicKey', PublicKey] - ]}], - [AddKey, { kind: 'struct', fields: [ - ['publicKey', PublicKey], - ['accessKey', AccessKey] - ]}], - [DeleteKey, { kind: 'struct', fields: [ - ['publicKey', PublicKey] - ]}], - [DeleteAccount, { kind: 'struct', fields: [ - ['beneficiaryId', 'string'] - ]}], -]); - -export function createTransaction(signerId: string, publicKey: PublicKey, receiverId: string, nonce: BN | string | number, actions: Action[], blockHash: Uint8Array): Transaction { - return new Transaction({ signerId, publicKey, nonce, receiverId, actions, blockHash }); -} - -/** - * Signs a given transaction from an account with given keys, applied to the given network - * @param transaction The Transaction object to sign - * @param signer The {Signer} object that assists with signing keys - * @param accountId The human-readable NEAR account name - * @param networkId The targeted network. (ex. default, betanet, etc…) - */ -async function signTransactionObject(transaction: Transaction, signer: Signer, accountId?: string, networkId?: string): Promise<[Uint8Array, SignedTransaction]> { - const message = serialize(SCHEMA, transaction); - const hash = new Uint8Array(sha256.sha256.array(message)); - const signature = await signer.signMessage(message, accountId, networkId); - const signedTx = new SignedTransaction({ - transaction, - signature: new Signature({ keyType: transaction.publicKey.keyType, data: signature.signature }) - }); - return [hash, signedTx]; -} - -export async function signTransaction(transaction: Transaction, signer: Signer, accountId?: string, networkId?: string): Promise<[Uint8Array, SignedTransaction]>; -export async function signTransaction(receiverId: string, nonce: BN, actions: Action[], blockHash: Uint8Array, signer: Signer, accountId?: string, networkId?: string): Promise<[Uint8Array, SignedTransaction]>; -export async function signTransaction(...args): Promise<[Uint8Array, SignedTransaction]> { - if (args[0].constructor === Transaction) { - const [ transaction, signer, accountId, networkId ] = args; - return signTransactionObject(transaction, signer, accountId, networkId); - } else { - const [ receiverId, nonce, actions, blockHash, signer, accountId, networkId ] = args; - const publicKey = await signer.getPublicKey(accountId, networkId); - const transaction = createTransaction(accountId, publicKey, receiverId, nonce, actions, blockHash); - return signTransactionObject(transaction, signer, accountId, networkId); - } -} +export { + stringifyJsonOrBytes, + Action, + AccessKey, + AccessKeyPermission, + AddKey, + CreateAccount, + DeleteAccount, + DeleteKey, + DeployContract, + FullAccessPermission, + FunctionCall, + FunctionCallPermission, + Stake, + Transfer, + SCHEMA, + createTransaction, + signTransaction, + Signature, + SignedTransaction, + Transaction, +} from '@near-js/transactions'; +import { actionCreators } from '@near-js/transactions'; + +export const addKey = actionCreators.addKey; +export const createAccount = actionCreators.createAccount; +export const deleteAccount = actionCreators.deleteAccount; +export const deleteKey = actionCreators.deleteKey; +export const deployContract = actionCreators.deployContract; +export const fullAccessKey = actionCreators.fullAccessKey; +export const functionCall = actionCreators.functionCall; +export const functionCallAccessKey = actionCreators.functionCallAccessKey; +export const stake = actionCreators.stake; +export const transfer = actionCreators.transfer; diff --git a/packages/near-api-js/src/utils/enums.ts b/packages/near-api-js/src/utils/enums.ts index bb6c98daba..d2000e9296 100644 --- a/packages/near-api-js/src/utils/enums.ts +++ b/packages/near-api-js/src/utils/enums.ts @@ -1,3 +1,5 @@ +export { Assignable } from '@near-js/types'; + /** @hidden @module */ export abstract class Enum { enum: string; @@ -12,11 +14,3 @@ export abstract class Enum { }); } } - -export abstract class Assignable { - constructor(properties: any) { - Object.keys(properties).map((key: any) => { - (this as any)[key] = properties[key]; - }); - } -} diff --git a/packages/near-api-js/src/utils/errors.ts b/packages/near-api-js/src/utils/errors.ts index e0af04521c..522334d15f 100644 --- a/packages/near-api-js/src/utils/errors.ts +++ b/packages/near-api-js/src/utils/errors.ts @@ -1,60 +1,13 @@ -import { ErrorObject } from 'ajv'; - -export class PositionalArgsError extends Error { - constructor() { - super('Contract method calls expect named arguments wrapped in object, e.g. { argName1: argValue1, argName2: argValue2 }'); - } -} - -export class ArgumentTypeError extends Error { - constructor(argName: string, argType: string, argValue: any) { - super(`Expected ${argType} for '${argName}' argument, but got '${JSON.stringify(argValue)}'`); - } -} - -export class TypedError extends Error { - type: string; - context?: ErrorContext; - constructor(message?: string, type?: string, context?: ErrorContext) { - super(message); - this.type = type || 'UntypedError'; - this.context = context; - } -} - -export class ErrorContext { - transactionHash?: string; - constructor(transactionHash?: string) { - this.transactionHash = transactionHash; - } -} - -export function logWarning(...args: any[]): void { - if (!process.env['NEAR_NO_LOGS']) { - console.warn(...args); - } -} - -export class UnsupportedSerializationError extends Error { - constructor(methodName: string, serializationType: string) { - super(`Contract method '${methodName}' is using an unsupported serialization type ${serializationType}`); - } -} - -export class UnknownArgumentError extends Error { - constructor(actualArgName: string, expectedArgNames: string[]) { - super(`Unrecognized argument '${actualArgName}', expected '${JSON.stringify(expectedArgNames)}'`); - } -} - -export class ArgumentSchemaError extends Error { - constructor(argName: string, errors: ErrorObject[]) { - super(`Argument '${argName}' does not conform to the specified ABI schema: '${JSON.stringify(errors)}'`); - } -} - -export class ConflictingOptions extends Error { - constructor() { - super('Conflicting contract method options have been passed. You can either specify ABI or a list of view/call methods.'); - } -} +export { + ArgumentSchemaError, + ConflictingOptions, + UnknownArgumentError, + UnsupportedSerializationError, +} from '@near-js/accounts'; +export { + ArgumentTypeError, + ErrorContext, + PositionalArgsError, + TypedError, +} from '@near-js/types'; +export { logWarning } from '@near-js/utils'; diff --git a/packages/near-api-js/src/utils/exponential-backoff.ts b/packages/near-api-js/src/utils/exponential-backoff.ts index 369311ec24..45565cc44c 100644 --- a/packages/near-api-js/src/utils/exponential-backoff.ts +++ b/packages/near-api-js/src/utils/exponential-backoff.ts @@ -1,23 +1,3 @@ -export default async function exponentialBackoff(startWaitTime, retryNumber, waitBackoff, getResult) { - // TODO: jitter? - - let waitTime = startWaitTime; - for (let i = 0; i < retryNumber; i++) { - const result = await getResult(); - if (result) { - return result; - } - - await sleep(waitTime); - waitTime *= waitBackoff; - } - - return null; -} - -// Sleep given number of millis. -function sleep(millis: number): Promise { - return new Promise(resolve => setTimeout(resolve, millis)); -} - +import { exponentialBackoff } from '@near-js/providers'; +export default exponentialBackoff; diff --git a/packages/near-api-js/src/utils/format.ts b/packages/near-api-js/src/utils/format.ts index 9e68d0b1b8..5495c81e68 100644 --- a/packages/near-api-js/src/utils/format.ts +++ b/packages/near-api-js/src/utils/format.ts @@ -1,107 +1,6 @@ -import BN from 'bn.js'; - -/** - * Exponent for calculating how many indivisible units are there in one NEAR. See {@link NEAR_NOMINATION}. - */ -export const NEAR_NOMINATION_EXP = 24; - -/** - * Number of indivisible units in one NEAR. Derived from {@link NEAR_NOMINATION_EXP}. - */ -export const NEAR_NOMINATION = new BN('10', 10).pow(new BN(NEAR_NOMINATION_EXP, 10)); - -// Pre-calculate offests used for rounding to different number of digits -const ROUNDING_OFFSETS: BN[] = []; -const BN10 = new BN(10); -for (let i = 0, offset = new BN(5); i < NEAR_NOMINATION_EXP; i++, offset = offset.mul(BN10)) { - ROUNDING_OFFSETS[i] = offset; -} - -/** - * Convert account balance value from internal indivisible units to NEAR. 1 NEAR is defined by {@link NEAR_NOMINATION}. - * Effectively this divides given amount by {@link NEAR_NOMINATION}. - * - * @param balance decimal string representing balance in smallest non-divisible NEAR units (as specified by {@link NEAR_NOMINATION}) - * @param fracDigits number of fractional digits to preserve in formatted string. Balance is rounded to match given number of digits. - * @returns Value in Ⓝ - */ -export function formatNearAmount(balance: string, fracDigits: number = NEAR_NOMINATION_EXP): string { - const balanceBN = new BN(balance, 10); - if (fracDigits !== NEAR_NOMINATION_EXP) { - // Adjust balance for rounding at given number of digits - const roundingExp = NEAR_NOMINATION_EXP - fracDigits - 1; - if (roundingExp > 0) { - balanceBN.iadd(ROUNDING_OFFSETS[roundingExp]); - } - } - - balance = balanceBN.toString(); - const wholeStr = balance.substring(0, balance.length - NEAR_NOMINATION_EXP) || '0'; - const fractionStr = balance.substring(balance.length - NEAR_NOMINATION_EXP) - .padStart(NEAR_NOMINATION_EXP, '0').substring(0, fracDigits); - - return trimTrailingZeroes(`${formatWithCommas(wholeStr)}.${fractionStr}`); -} - -/** - * Convert human readable NEAR amount to internal indivisible units. - * Effectively this multiplies given amount by {@link NEAR_NOMINATION}. - * - * @param amt decimal string (potentially fractional) denominated in NEAR. - * @returns The parsed yoctoⓃ amount or null if no amount was passed in - */ -export function parseNearAmount(amt?: string): string | null { - if (!amt) { return null; } - amt = cleanupAmount(amt); - const split = amt.split('.'); - const wholePart = split[0]; - const fracPart = split[1] || ''; - if (split.length > 2 || fracPart.length > NEAR_NOMINATION_EXP) { - throw new Error(`Cannot parse '${amt}' as NEAR amount`); - } - return trimLeadingZeroes(wholePart + fracPart.padEnd(NEAR_NOMINATION_EXP, '0')); -} - -/** - * Removes commas from the input - * @param amount A value or amount that may contain commas - * @returns string The cleaned value - */ -function cleanupAmount(amount: string): string { - return amount.replace(/,/g, '').trim(); -} - -/** - * Removes .000… from an input - * @param value A value that may contain trailing zeroes in the decimals place - * @returns string The value without the trailing zeros - */ -function trimTrailingZeroes(value: string): string { - return value.replace(/\.?0*$/, ''); -} - -/** - * Removes leading zeroes from an input - * @param value A value that may contain leading zeroes - * @returns string The value without the leading zeroes - */ -function trimLeadingZeroes(value: string): string { - value = value.replace(/^0+/, ''); - if (value === '') { - return '0'; - } - return value; -} - -/** - * Returns a human-readable value with commas - * @param value A value that may not contain commas - * @returns string A value with commas - */ -function formatWithCommas(value: string): string { - const pattern = /(-?\d+)(\d{3})/; - while (pattern.test(value)) { - value = value.replace(pattern, '$1,$2'); - } - return value; -} +export { + NEAR_NOMINATION, + NEAR_NOMINATION_EXP, + formatNearAmount, + parseNearAmount, +} from '@near-js/utils'; diff --git a/packages/near-api-js/src/utils/key_pair.ts b/packages/near-api-js/src/utils/key_pair.ts index ee7daa4638..c50a739814 100644 --- a/packages/near-api-js/src/utils/key_pair.ts +++ b/packages/near-api-js/src/utils/key_pair.ts @@ -1,151 +1,9 @@ -import nacl from 'tweetnacl'; -import { base_encode, base_decode } from './serialize'; -import { Assignable } from './enums'; +export { + KeyPair, + KeyPairEd25519, + KeyType, + PublicKey, + Signature, +} from '@near-js/crypto'; export type Arrayish = string | ArrayLike; - -export interface Signature { - signature: Uint8Array; - publicKey: PublicKey; -} - -/** All supported key types */ -export enum KeyType { - ED25519 = 0, -} - -function key_type_to_str(keyType: KeyType): string { - switch (keyType) { - case KeyType.ED25519: return 'ed25519'; - default: throw new Error(`Unknown key type ${keyType}`); - } -} - -function str_to_key_type(keyType: string): KeyType { - switch (keyType.toLowerCase()) { - case 'ed25519': return KeyType.ED25519; - default: throw new Error(`Unknown key type ${keyType}`); - } -} - -/** - * PublicKey representation that has type and bytes of the key. - */ -export class PublicKey extends Assignable { - keyType: KeyType; - data: Uint8Array; - - static from(value: string | PublicKey): PublicKey { - if (typeof value === 'string') { - return PublicKey.fromString(value); - } - return value; - } - - static fromString(encodedKey: string): PublicKey { - const parts = encodedKey.split(':'); - if (parts.length === 1) { - return new PublicKey({ keyType: KeyType.ED25519, data: base_decode(parts[0]) }); - } else if (parts.length === 2) { - return new PublicKey({ keyType: str_to_key_type(parts[0]), data: base_decode(parts[1]) }); - } else { - throw new Error('Invalid encoded key format, must be :'); - } - } - - toString(): string { - return `${key_type_to_str(this.keyType)}:${base_encode(this.data)}`; - } - - verify(message: Uint8Array, signature: Uint8Array): boolean { - switch (this.keyType) { - case KeyType.ED25519: return nacl.sign.detached.verify(message, signature, this.data); - default: throw new Error(`Unknown key type ${this.keyType}`); - } - } -} - -export abstract class KeyPair { - abstract sign(message: Uint8Array): Signature; - abstract verify(message: Uint8Array, signature: Uint8Array): boolean; - abstract toString(): string; - abstract getPublicKey(): PublicKey; - - /** - * @param curve Name of elliptical curve, case-insensitive - * @returns Random KeyPair based on the curve - */ - static fromRandom(curve: string): KeyPair { - switch (curve.toUpperCase()) { - case 'ED25519': return KeyPairEd25519.fromRandom(); - default: throw new Error(`Unknown curve ${curve}`); - } - } - - static fromString(encodedKey: string): KeyPair { - const parts = encodedKey.split(':'); - if (parts.length === 1) { - return new KeyPairEd25519(parts[0]); - } else if (parts.length === 2) { - switch (parts[0].toUpperCase()) { - case 'ED25519': return new KeyPairEd25519(parts[1]); - default: throw new Error(`Unknown curve: ${parts[0]}`); - } - } else { - throw new Error('Invalid encoded key format, must be :'); - } - } -} - -/** - * This class provides key pair functionality for Ed25519 curve: - * generating key pairs, encoding key pairs, signing and verifying. - */ -export class KeyPairEd25519 extends KeyPair { - readonly publicKey: PublicKey; - readonly secretKey: string; - - /** - * Construct an instance of key pair given a secret key. - * It's generally assumed that these are encoded in base58. - * @param {string} secretKey - */ - constructor(secretKey: string) { - super(); - const keyPair = nacl.sign.keyPair.fromSecretKey(base_decode(secretKey)); - this.publicKey = new PublicKey({ keyType: KeyType.ED25519, data: keyPair.publicKey }); - this.secretKey = secretKey; - } - - /** - * Generate a new random keypair. - * @example - * const keyRandom = KeyPair.fromRandom(); - * keyRandom.publicKey - * // returns [PUBLIC_KEY] - * - * keyRandom.secretKey - * // returns [SECRET_KEY] - */ - static fromRandom() { - const newKeyPair = nacl.sign.keyPair(); - return new KeyPairEd25519(base_encode(newKeyPair.secretKey)); - } - - sign(message: Uint8Array): Signature { - const signature = nacl.sign.detached(message, base_decode(this.secretKey)); - return { signature, publicKey: this.publicKey }; - } - - verify(message: Uint8Array, signature: Uint8Array): boolean { - return this.publicKey.verify(message, signature); - } - - toString(): string { - return `ed25519:${this.secretKey}`; - } - - getPublicKey(): PublicKey { - return this.publicKey; - } -} diff --git a/packages/near-api-js/src/utils/logging.ts b/packages/near-api-js/src/utils/logging.ts index 8e1e6c1a86..b92bebd9bd 100644 --- a/packages/near-api-js/src/utils/logging.ts +++ b/packages/near-api-js/src/utils/logging.ts @@ -1,69 +1 @@ -import { FinalExecutionOutcome } from '../providers'; -import { parseRpcError } from './rpc_errors'; - -const SUPPRESS_LOGGING = !!process.env.NEAR_NO_LOGS; - -/** - * Parse and print details from a query execution response - * @param params - * @param params.contractId ID of the account/contract which made the query - * @param params.outcome the query execution response - */ -export function printTxOutcomeLogsAndFailures({ - contractId, - outcome, -}: { contractId: string, outcome: FinalExecutionOutcome }) { - if (SUPPRESS_LOGGING) { - return; - } - - const flatLogs = [outcome.transaction_outcome, ...outcome.receipts_outcome] - .reduce((acc, it) => { - const isFailure = typeof it.outcome.status === 'object' && typeof it.outcome.status.Failure === 'object'; - if (it.outcome.logs.length || isFailure) { - return acc.concat({ - receiptIds: it.outcome.receipt_ids, - logs: it.outcome.logs, - failure: typeof it.outcome.status === 'object' && it.outcome.status.Failure !== undefined - ? parseRpcError(it.outcome.status.Failure) - : null - }); - } else { - return acc; - } - }, []); - - for (const result of flatLogs) { - console.log(`Receipt${result.receiptIds.length > 1 ? 's' : ''}: ${result.receiptIds.join(', ')}`); - printTxOutcomeLogs({ - contractId, - logs: result.logs, - prefix: '\t', - }); - - if (result.failure) { - console.warn(`\tFailure [${contractId}]: ${result.failure}`); - } - } -} - -/** - * Format and print log output from a query execution response - * @param params - * @param params.contractId ID of the account/contract which made the query - * @param params.logs log output from a query execution response - * @param params.prefix string to append to the beginning of each log - */ -export function printTxOutcomeLogs({ - contractId, - logs, - prefix = '', -}: { contractId: string, logs: string[], prefix?: string }) { - if (SUPPRESS_LOGGING) { - return; - } - - for (const log of logs) { - console.log(`${prefix}Log [${contractId}]: ${log}`); - } -} +export { printTxOutcomeLogs, printTxOutcomeLogsAndFailures } from '@near-js/utils'; diff --git a/packages/near-api-js/src/utils/rpc_errors.ts b/packages/near-api-js/src/utils/rpc_errors.ts index 01e5621a5b..fc9c251da0 100644 --- a/packages/near-api-js/src/utils/rpc_errors.ts +++ b/packages/near-api-js/src/utils/rpc_errors.ts @@ -1,120 +1,7 @@ - -import Mustache from 'mustache'; -import schema from '../generated/rpc_error_schema.json'; -import messages from '../res/error_messages.json'; -import { utils } from '../common-index'; -import { TypedError } from '../utils/errors'; - -const mustacheHelpers = { - formatNear: () => (n, render) => utils.format.formatNearAmount(render(n)) -}; - -export class ServerError extends TypedError { -} - -class ServerTransactionError extends ServerError { - public transaction_outcome: any; -} - -export function parseRpcError(errorObj: Record): ServerError { - const result = {}; - const errorClassName = walkSubtype(errorObj, schema.schema, result, ''); - // NOTE: This assumes that all errors extend TypedError - const error = new ServerError(formatError(errorClassName, result), errorClassName); - Object.assign(error, result); - return error; -} - -export function parseResultError(result: any): ServerTransactionError { - const server_error = parseRpcError(result.status.Failure); - const server_tx_error = new ServerTransactionError(); - Object.assign(server_tx_error, server_error); - server_tx_error.type = server_error.type; - server_tx_error.message = server_error.message; - server_tx_error.transaction_outcome = result.transaction_outcome; - return server_tx_error; -} - -export function formatError(errorClassName: string, errorData): string { - if (typeof messages[errorClassName] === 'string') { - return Mustache.render(messages[errorClassName], { - ...errorData, - ...mustacheHelpers - }); - } - return JSON.stringify(errorData); -} - -/** - * Walks through defined schema returning error(s) recursively - * @param errorObj The error to be parsed - * @param schema A defined schema in JSON mapping to the RPC errors - * @param result An object used in recursion or called directly - * @param typeName The human-readable error type name as defined in the JSON mapping - */ -function walkSubtype(errorObj, schema, result, typeName) { - let error; - let type; - let errorTypeName; - for (const errorName in schema) { - if (isString(errorObj[errorName])) { - // Return early if error type is in a schema - return errorObj[errorName]; - } - if (isObject(errorObj[errorName])) { - error = errorObj[errorName]; - type = schema[errorName]; - errorTypeName = errorName; - } else if (isObject(errorObj.kind) && isObject(errorObj.kind[errorName])) { - error = errorObj.kind[errorName]; - type = schema[errorName]; - errorTypeName = errorName; - } else { - continue; - } - } - if (error && type) { - for (const prop of Object.keys(type.props)) { - result[prop] = error[prop]; - } - return walkSubtype(error, schema, result, errorTypeName); - } else { - // TODO: is this the right thing to do? - result.kind = errorObj; - return typeName; - } -} - -export function getErrorTypeFromErrorMessage(errorMessage, errorType) { - // This function should be removed when JSON RPC starts returning typed errors. - switch (true) { - case /^account .*? does not exist while viewing$/.test(errorMessage): - return 'AccountDoesNotExist'; - case /^Account .*? doesn't exist$/.test(errorMessage): - return 'AccountDoesNotExist'; - case /^access key .*? does not exist while viewing$/.test(errorMessage): - return 'AccessKeyDoesNotExist'; - case /wasm execution failed with error: FunctionCallError\(CompilationError\(CodeDoesNotExist/.test(errorMessage): - return 'CodeDoesNotExist'; - case /Transaction nonce \d+ must be larger than nonce of the used access key \d+/.test(errorMessage): - return 'InvalidNonce'; - default: - return errorType; - } -} - -/** - * Helper function determining if the argument is an object - * @param n Value to check - */ -function isObject(n) { - return Object.prototype.toString.call(n) === '[object Object]'; -} - -/** - * Helper function determining if the argument is a string - * @param n Value to check - */ -function isString(n) { - return Object.prototype.toString.call(n) === '[object String]'; -} +export { + parseRpcError, + parseResultError, + formatError, + getErrorTypeFromErrorMessage, + ServerError, +} from '@near-js/utils'; diff --git a/packages/near-api-js/src/utils/web.ts b/packages/near-api-js/src/utils/web.ts index 8e8dfe6a15..ba8d09f15b 100644 --- a/packages/near-api-js/src/utils/web.ts +++ b/packages/near-api-js/src/utils/web.ts @@ -1,55 +1 @@ -import createError from 'http-errors'; - -import exponentialBackoff from './exponential-backoff'; -import { TypedError } from '../providers'; -import { logWarning } from './errors'; - -const START_WAIT_TIME_MS = 1000; -const BACKOFF_MULTIPLIER = 1.5; -const RETRY_NUMBER = 10; - -export interface ConnectionInfo { - url: string; - user?: string; - password?: string; - allowInsecure?: boolean; - timeout?: number; - headers?: { [key: string]: string | number }; -} - -export async function fetchJson(connectionInfoOrUrl: string | ConnectionInfo, json?: string): Promise { - let connectionInfo: ConnectionInfo = { url: null }; - if (typeof (connectionInfoOrUrl) === 'string') { - connectionInfo.url = connectionInfoOrUrl; - } else { - connectionInfo = connectionInfoOrUrl as ConnectionInfo; - } - - const response = await exponentialBackoff(START_WAIT_TIME_MS, RETRY_NUMBER, BACKOFF_MULTIPLIER, async () => { - try { - const response = await fetch(connectionInfo.url, { - method: json ? 'POST' : 'GET', - body: json ? json : undefined, - headers: { ...connectionInfo.headers, 'Content-Type': 'application/json' } - }); - if (!response.ok) { - if (response.status === 503) { - logWarning(`Retrying HTTP request for ${connectionInfo.url} as it's not available now`); - return null; - } - throw createError(response.status, await response.text()); - } - return response; - } catch (error) { - if (error.toString().includes('FetchError') || error.toString().includes('Failed to fetch')) { - logWarning(`Retrying HTTP request for ${connectionInfo.url} because of error: ${error}`); - return null; - } - throw error; - } - }); - if (!response) { - throw new TypedError(`Exceeded ${RETRY_NUMBER} attempts for ${connectionInfo.url}.`, 'RetriesExceeded'); - } - return await response.json(); -} +export { fetchJson } from '@near-js/providers'; diff --git a/packages/near-api-js/src/validators.ts b/packages/near-api-js/src/validators.ts index f725a359bc..d40fb6119b 100644 --- a/packages/near-api-js/src/validators.ts +++ b/packages/near-api-js/src/validators.ts @@ -1,94 +1,6 @@ -'use strict'; - -import BN from 'bn.js'; -import depd from 'depd'; -import { CurrentEpochValidatorInfo, NextEpochValidatorInfo } from './providers/provider'; - -/** Finds seat price given validators stakes and number of seats. - * Calculation follow the spec: https://nomicon.io/Economics/README.html#validator-selection - * @params validators: current or next epoch validators. - * @params maxNumberOfSeats: maximum number of seats in the network. - * @params minimumStakeRatio: minimum stake ratio - * @params protocolVersion: version of the protocol from genesis config - */ -export function findSeatPrice(validators: (CurrentEpochValidatorInfo | NextEpochValidatorInfo)[], maxNumberOfSeats: number, minimumStakeRatio: number[], protocolVersion?: number): BN { - if (protocolVersion && protocolVersion < 49) { - return findSeatPriceForProtocolBefore49(validators, maxNumberOfSeats); - } - if (!minimumStakeRatio) { - const deprecate = depd('findSeatPrice(validators, maxNumberOfSeats)'); - deprecate('`use `findSeatPrice(validators, maxNumberOfSeats, minimumStakeRatio)` instead'); - minimumStakeRatio = [1, 6250]; // harcoded minimumStakeRation from 12/7/21 - } - return findSeatPriceForProtocolAfter49(validators, maxNumberOfSeats, minimumStakeRatio); -} - -function findSeatPriceForProtocolBefore49(validators: (CurrentEpochValidatorInfo | NextEpochValidatorInfo)[], numSeats: number): BN { - const stakes = validators.map(v => new BN(v.stake, 10)).sort((a, b) => a.cmp(b)); - const num = new BN(numSeats); - const stakesSum = stakes.reduce((a, b) => a.add(b)); - if (stakesSum.lt(num)) { - throw new Error('Stakes are below seats'); - } - // assert stakesSum >= numSeats - let left = new BN(1), right = stakesSum.add(new BN(1)); - while (!left.eq(right.sub(new BN(1)))) { - const mid = left.add(right).div(new BN(2)); - let found = false; - let currentSum = new BN(0); - for (let i = 0; i < stakes.length; ++i) { - currentSum = currentSum.add(stakes[i].div(mid)); - if (currentSum.gte(num)) { - left = mid; - found = true; - break; - } - } - if (!found) { - right = mid; - } - } - return left; -} - -// nearcore reference: https://github.com/near/nearcore/blob/5a8ae263ec07930cd34d0dcf5bcee250c67c02aa/chain/epoch_manager/src/validator_selection.rs#L308;L315 -function findSeatPriceForProtocolAfter49(validators: (CurrentEpochValidatorInfo | NextEpochValidatorInfo)[], maxNumberOfSeats: number, minimumStakeRatio: number[]): BN { - if (minimumStakeRatio.length != 2) { - throw Error('minimumStakeRatio should have 2 elements'); - } - const stakes = validators.map(v => new BN(v.stake, 10)).sort((a, b) => a.cmp(b)); - const stakesSum = stakes.reduce((a, b) => a.add(b)); - if (validators.length < maxNumberOfSeats) { - return stakesSum.mul(new BN(minimumStakeRatio[0])).div(new BN(minimumStakeRatio[1])); - } else { - return stakes[0].add(new BN(1)); - } -} - -export interface ChangedValidatorInfo { - current: CurrentEpochValidatorInfo; - next: NextEpochValidatorInfo; -} - -export interface EpochValidatorsDiff { - newValidators: NextEpochValidatorInfo[]; - removedValidators: CurrentEpochValidatorInfo[]; - changedValidators: ChangedValidatorInfo[]; -} - -/** Diff validators between current and next epoch. - * Returns additions, subtractions and changes to validator set. - * @params currentValidators: list of current validators. - * @params nextValidators: list of next validators. - */ -export function diffEpochValidators(currentValidators: CurrentEpochValidatorInfo[], nextValidators: NextEpochValidatorInfo[]): EpochValidatorsDiff { - const validatorsMap = new Map(); - currentValidators.forEach(v => validatorsMap.set(v.account_id, v)); - const nextValidatorsSet = new Set(nextValidators.map(v => v.account_id)); - return { - newValidators: nextValidators.filter(v => !validatorsMap.has(v.account_id)), - removedValidators: currentValidators.filter(v => !nextValidatorsSet.has(v.account_id)), - changedValidators: nextValidators.filter(v => (validatorsMap.has(v.account_id) && validatorsMap.get(v.account_id).stake != v.stake)) - .map(v => ({ current: validatorsMap.get(v.account_id), next: v })) - }; -} +export { + diffEpochValidators, + findSeatPrice, + ChangedValidatorInfo, + EpochValidatorsDiff, +} from '@near-js/utils'; diff --git a/packages/near-api-js/src/wallet-account.ts b/packages/near-api-js/src/wallet-account.ts index 3c6cf4e6b9..172cdfa31f 100644 --- a/packages/near-api-js/src/wallet-account.ts +++ b/packages/near-api-js/src/wallet-account.ts @@ -1,404 +1 @@ -/** - * The classes in this module are used in conjunction with the {@link key_stores/browser_local_storage_key_store!BrowserLocalStorageKeyStore}. - * This module exposes two classes: - * * {@link WalletConnection} which redirects users to [NEAR Wallet](https://wallet.near.org/) for key management. - * * {@link ConnectedWalletAccount} is an {@link account!Account} implementation that uses {@link WalletConnection} to get keys - * - * @module walletAccount - */ -import { Account, SignAndSendTransactionOptions } from './account'; -import { Near } from './near'; -import { KeyStore } from './key_stores'; -import { FinalExecutionOutcome } from './providers'; -import { InMemorySigner } from './signer'; -import { Transaction, Action, SCHEMA, createTransaction } from './transaction'; -import { KeyPair, PublicKey } from './utils'; -import { baseDecode } from 'borsh'; -import { Connection } from './connection'; -import { serialize } from 'borsh'; -import BN from 'bn.js'; - -const LOGIN_WALLET_URL_SUFFIX = '/login/'; -const MULTISIG_HAS_METHOD = 'add_request_and_confirm'; -const LOCAL_STORAGE_KEY_SUFFIX = '_wallet_auth_key'; -const PENDING_ACCESS_KEY_PREFIX = 'pending_key'; // browser storage key for a pending access key (i.e. key has been generated but we are not sure it was added yet) - -interface SignInOptions { - contractId?: string; - methodNames?: string[]; - // TODO: Replace following with single callbackUrl - successUrl?: string; - failureUrl?: string; -} - -/** - * Information to send NEAR wallet for signing transactions and redirecting the browser back to the calling application - */ -interface RequestSignTransactionsOptions { - /** list of transactions to sign */ - transactions: Transaction[]; - /** url NEAR Wallet will redirect to after transaction signing is complete */ - callbackUrl?: string; - /** meta information NEAR Wallet will send back to the application. `meta` will be attached to the `callbackUrl` as a url search param */ - meta?: string; -} - -/** - * This class is used in conjunction with the {@link key_stores/browser_local_storage_key_store!BrowserLocalStorageKeyStore}. - * It redirects users to [NEAR Wallet](https://wallet.near.org) for key management. - * This class is not intended for use outside the browser. Without `window` (i.e. in server contexts), it will instantiate but will throw a clear error when used. - * - * @see [https://docs.near.org/tools/near-api-js/quick-reference#wallet](https://docs.near.org/tools/near-api-js/quick-reference#wallet) - * @example - * ```js - * // create new WalletConnection instance - * const wallet = new WalletConnection(near, 'my-app'); - * - * // If not signed in redirect to the NEAR wallet to sign in - * // keys will be stored in the BrowserLocalStorageKeyStore - * if(!wallet.isSignedIn()) return wallet.requestSignIn() - * ``` - */ -export class WalletConnection { - /** @hidden */ - _walletBaseUrl: string; - - /** @hidden */ - _authDataKey: string; - - /** @hidden */ - _keyStore: KeyStore; - - /** @hidden */ - _authData: { accountId?: string; allKeys?: string[] }; - - /** @hidden */ - _networkId: string; - - /** @hidden */ - _near: Near; - - /** @hidden */ - _connectedAccount: ConnectedWalletAccount; - - /** @hidden */ - _completeSignInPromise: Promise; - - constructor(near: Near, appKeyPrefix: string) { - if(typeof(appKeyPrefix) != 'string') { - throw new Error('Please define a clear appKeyPrefix for this WalletConnection instance as the second argument to the constructor'); - } - if(typeof window === 'undefined') { - return new Proxy(this, { - get(target, property) { - if(property === 'isSignedIn') { - return () => false; - } - if(property === 'getAccountId') { - return () => ''; - } - if(target[property] && typeof target[property] === 'function') { - return () => { - throw new Error('No window found in context, please ensure you are using WalletConnection on the browser'); - }; - } - return target[property]; - } - }); - } - this._near = near; - const authDataKey = appKeyPrefix + LOCAL_STORAGE_KEY_SUFFIX; - const authData = JSON.parse(window.localStorage.getItem(authDataKey)); - this._networkId = near.config.networkId; - this._walletBaseUrl = near.config.walletUrl; - this._keyStore = (near.connection.signer as InMemorySigner).keyStore; - this._authData = authData || { allKeys: [] }; - this._authDataKey = authDataKey; - if (!this.isSignedIn()) { - this._completeSignInPromise = this._completeSignInWithAccessKey(); - } - } - - /** - * Returns true, if this WalletConnection is authorized with the wallet. - * @example - * ```js - * const wallet = new WalletConnection(near, 'my-app'); - * wallet.isSignedIn(); - * ``` - */ - isSignedIn() { - return !!this._authData.accountId; - } - - /** - * Returns promise of completing signing in after redirecting from wallet - * @example - * ```js - * // on login callback page - * const wallet = new WalletConnection(near, 'my-app'); - * wallet.isSignedIn(); // false - * await wallet.isSignedInAsync(); // true - * ``` - */ - async isSignedInAsync() { - if (!this._completeSignInPromise) { - return this.isSignedIn(); - } - - await this._completeSignInPromise; - return this.isSignedIn(); - } - - /** - * Returns authorized Account ID. - * @example - * ```js - * const wallet = new WalletConnection(near, 'my-app'); - * wallet.getAccountId(); - * ``` - */ - getAccountId() { - return this._authData.accountId || ''; - } - - /** - * Redirects current page to the wallet authentication page. - * @param options An optional options object - * @param options.contractId The NEAR account where the contract is deployed - * @param options.successUrl URL to redirect upon success. Default: current url - * @param options.failureUrl URL to redirect upon failure. Default: current url - * - * @example - * ```js - * const wallet = new WalletConnection(near, 'my-app'); - * // redirects to the NEAR Wallet - * wallet.requestSignIn({ contractId: 'account-with-deploy-contract.near' }); - * ``` - */ - async requestSignIn({ contractId, methodNames, successUrl, failureUrl }: SignInOptions) { - const currentUrl = new URL(window.location.href); - const newUrl = new URL(this._walletBaseUrl + LOGIN_WALLET_URL_SUFFIX); - newUrl.searchParams.set('success_url', successUrl || currentUrl.href); - newUrl.searchParams.set('failure_url', failureUrl || currentUrl.href); - if (contractId) { - /* Throws exception if contract account does not exist */ - const contractAccount = await this._near.account(contractId); - await contractAccount.state(); - - newUrl.searchParams.set('contract_id', contractId); - const accessKey = KeyPair.fromRandom('ed25519'); - newUrl.searchParams.set('public_key', accessKey.getPublicKey().toString()); - await this._keyStore.setKey(this._networkId, PENDING_ACCESS_KEY_PREFIX + accessKey.getPublicKey(), accessKey); - } - - if (methodNames) { - methodNames.forEach(methodName => { - newUrl.searchParams.append('methodNames', methodName); - }); - } - - window.location.assign(newUrl.toString()); - } - - /** - * Requests the user to quickly sign for a transaction or batch of transactions by redirecting to the NEAR wallet. - */ - async requestSignTransactions({ transactions, meta, callbackUrl }: RequestSignTransactionsOptions): Promise { - const currentUrl = new URL(window.location.href); - const newUrl = new URL('sign', this._walletBaseUrl); - - newUrl.searchParams.set('transactions', transactions - .map(transaction => serialize(SCHEMA, transaction)) - .map(serialized => Buffer.from(serialized).toString('base64')) - .join(',')); - newUrl.searchParams.set('callbackUrl', callbackUrl || currentUrl.href); - if (meta) newUrl.searchParams.set('meta', meta); - - window.location.assign(newUrl.toString()); - } - - /** - * @hidden - * Complete sign in for a given account id and public key. To be invoked by the app when getting a callback from the wallet. - */ - async _completeSignInWithAccessKey() { - const currentUrl = new URL(window.location.href); - const publicKey = currentUrl.searchParams.get('public_key') || ''; - const allKeys = (currentUrl.searchParams.get('all_keys') || '').split(','); - const accountId = currentUrl.searchParams.get('account_id') || ''; - // TODO: Handle errors during login - if (accountId) { - const authData = { - accountId, - allKeys - }; - window.localStorage.setItem(this._authDataKey, JSON.stringify(authData)); - if (publicKey) { - await this._moveKeyFromTempToPermanent(accountId, publicKey); - } - this._authData = authData; - } - currentUrl.searchParams.delete('public_key'); - currentUrl.searchParams.delete('all_keys'); - currentUrl.searchParams.delete('account_id'); - currentUrl.searchParams.delete('meta'); - currentUrl.searchParams.delete('transactionHashes'); - - window.history.replaceState({}, document.title, currentUrl.toString()); - } - - /** - * @hidden - * @param accountId The NEAR account owning the given public key - * @param publicKey The public key being set to the key store - */ - async _moveKeyFromTempToPermanent(accountId: string, publicKey: string) { - const keyPair = await this._keyStore.getKey(this._networkId, PENDING_ACCESS_KEY_PREFIX + publicKey); - await this._keyStore.setKey(this._networkId, accountId, keyPair); - await this._keyStore.removeKey(this._networkId, PENDING_ACCESS_KEY_PREFIX + publicKey); - } - - /** - * Sign out from the current account - * @example - * walletConnection.signOut(); - */ - signOut() { - this._authData = {}; - window.localStorage.removeItem(this._authDataKey); - } - - /** - * Returns the current connected wallet account - */ - account() { - if (!this._connectedAccount) { - this._connectedAccount = new ConnectedWalletAccount(this, this._near.connection, this._authData.accountId); - } - return this._connectedAccount; - } -} - -/** - * {@link account!Account} implementation which redirects to wallet using {@link WalletConnection} when no local key is available. - */ -export class ConnectedWalletAccount extends Account { - walletConnection: WalletConnection; - - constructor(walletConnection: WalletConnection, connection: Connection, accountId: string) { - super(connection, accountId); - this.walletConnection = walletConnection; - } - - // Overriding Account methods - - /** - * Sign a transaction by redirecting to the NEAR Wallet - * @see {@link WalletConnection.requestSignTransactions} - */ - async signAndSendTransaction({ receiverId, actions, walletMeta, walletCallbackUrl = window.location.href }: SignAndSendTransactionOptions): Promise { - const localKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId); - let accessKey = await this.accessKeyForTransaction(receiverId, actions, localKey); - if (!accessKey) { - throw new Error(`Cannot find matching key for transaction sent to ${receiverId}`); - } - - if (localKey && localKey.toString() === accessKey.public_key) { - try { - return await super.signAndSendTransaction({ receiverId, actions }); - } catch (e) { - if (e.type === 'NotEnoughAllowance') { - accessKey = await this.accessKeyForTransaction(receiverId, actions); - } else { - throw e; - } - } - } - - const block = await this.connection.provider.block({ finality: 'final' }); - const blockHash = baseDecode(block.header.hash); - - const publicKey = PublicKey.from(accessKey.public_key); - // TODO: Cache & listen for nonce updates for given access key - const nonce = accessKey.access_key.nonce.add(new BN(1)); - const transaction = createTransaction(this.accountId, publicKey, receiverId, nonce, actions, blockHash); - await this.walletConnection.requestSignTransactions({ - transactions: [transaction], - meta: walletMeta, - callbackUrl: walletCallbackUrl - }); - - return new Promise((resolve, reject) => { - setTimeout(() => { - reject(new Error('Failed to redirect to sign transaction')); - }, 1000); - }); - - // TODO: Aggregate multiple transaction request with "debounce". - // TODO: Introduce TrasactionQueue which also can be used to watch for status? - } - - /** - * Check if given access key allows the function call or method attempted in transaction - * @param accessKey Array of \{access_key: AccessKey, public_key: PublicKey\} items - * @param receiverId The NEAR account attempting to have access - * @param actions The action(s) needed to be checked for access - */ - async accessKeyMatchesTransaction(accessKey, receiverId: string, actions: Action[]): Promise { - const { access_key: { permission } } = accessKey; - if (permission === 'FullAccess') { - return true; - } - - if (permission.FunctionCall) { - const { receiver_id: allowedReceiverId, method_names: allowedMethods } = permission.FunctionCall; - /******************************** - Accept multisig access keys and let wallets attempt to signAndSendTransaction - If an access key has itself as receiverId and method permission add_request_and_confirm, then it is being used in a wallet with multisig contract: https://github.com/near/core-contracts/blob/671c05f09abecabe7a7e58efe942550a35fc3292/multisig/src/lib.rs#L149-L153 - ********************************/ - if (allowedReceiverId === this.accountId && allowedMethods.includes(MULTISIG_HAS_METHOD)) { - return true; - } - if (allowedReceiverId === receiverId) { - if (actions.length !== 1) { - return false; - } - const [{ functionCall }] = actions; - return functionCall && - (!functionCall.deposit || functionCall.deposit.toString() === '0') && // TODO: Should support charging amount smaller than allowance? - (allowedMethods.length === 0 || allowedMethods.includes(functionCall.methodName)); - // TODO: Handle cases when allowance doesn't have enough to pay for gas - } - } - // TODO: Support other permissions than FunctionCall - - return false; - } - - /** - * Helper function returning the access key (if it exists) to the receiver that grants the designated permission - * @param receiverId The NEAR account seeking the access key for a transaction - * @param actions The action(s) sought to gain access to - * @param localKey A local public key provided to check for access - */ - async accessKeyForTransaction(receiverId: string, actions: Action[], localKey?: PublicKey): Promise { - const accessKeys = await this.getAccessKeys(); - - if (localKey) { - const accessKey = accessKeys.find(key => key.public_key.toString() === localKey.toString()); - if (accessKey && await this.accessKeyMatchesTransaction(accessKey, receiverId, actions)) { - return accessKey; - } - } - - const walletKeys = this.walletConnection._authData.allKeys; - for (const accessKey of accessKeys) { - if (walletKeys.indexOf(accessKey.public_key) !== -1 && await this.accessKeyMatchesTransaction(accessKey, receiverId, actions)) { - return accessKey; - } - } - - return null; - } -} +export { ConnectedWalletAccount, WalletConnection } from '@near-js/wallet-account'; diff --git a/packages/near-api-js/test/.eslintrc.yml b/packages/near-api-js/test/.eslintrc.yml index a74d2e5395..0fae1d994f 100644 --- a/packages/near-api-js/test/.eslintrc.yml +++ b/packages/near-api-js/test/.eslintrc.yml @@ -1,4 +1,4 @@ -extends: '../../../.eslintrc.yml' +extends: '../../../.eslintrc.js.yml' env: jest: true globals: diff --git a/packages/near-api-js/test/key_stores/keystore_common.js b/packages/near-api-js/test/key_stores/keystore_common.js index 7008b950a3..4fbd14a939 100644 --- a/packages/near-api-js/test/key_stores/keystore_common.js +++ b/packages/near-api-js/test/key_stores/keystore_common.js @@ -7,7 +7,7 @@ const NETWORK_ID_SINGLE_KEY = 'singlekeynetworkid'; const ACCOUNT_ID_SINGLE_KEY = 'singlekey_accountid'; const KEYPAIR_SINGLE_KEY = new KeyPair('2wyRcSwSuHtRVmkMCGjPwnzZmQLeXLzLLyED1NDMt4BjnKgQL6tF85yBx6Jr26D2dUNeC716RBoTxntVHsegogYw'); -module.exports.shouldStoreAndRetriveKeys = ctx => { +module.exports.shouldStoreAndRetrieveKeys = ctx => { beforeEach(async () => { await ctx.keyStore.clear(); await ctx.keyStore.setKey(NETWORK_ID_SINGLE_KEY, ACCOUNT_ID_SINGLE_KEY, KEYPAIR_SINGLE_KEY); diff --git a/packages/near-api-js/test/providers.test.js b/packages/near-api-js/test/providers.test.js index 4a60b1b702..2c489807bf 100644 --- a/packages/near-api-js/test/providers.test.js +++ b/packages/near-api-js/test/providers.test.js @@ -3,7 +3,7 @@ const testUtils = require('./test-utils'); const BN = require('bn.js'); const base58 = require('bs58'); -jest.setTimeout(20000); +jest.setTimeout(30000); const withProvider = (fn) => { const config = Object.assign(require('./config')(process.env.NODE_ENV || 'test')); diff --git a/packages/providers/README.md b/packages/providers/README.md new file mode 100644 index 0000000000..9373b95c2b --- /dev/null +++ b/packages/providers/README.md @@ -0,0 +1,15 @@ +# @near-js/providers + +A collection of classes, functions, and types for communicating with the NEAR blockchain directly. For use with both client- and server-side JavaScript execution contexts. + +## Modules + +- [Provider](src/provider.ts) abstract class for interacting with NEAR RPC +- [JsonRpcProvider](src/json-rpc-provider.ts) implementation of `Provider` for [JSON-RPC](https://www.jsonrpc.org/) +- [fetch](src/fetch.ts) NodeJS `fetch` implementation +- [fetchJson](src/fetch_json.ts) low-level function for fetching and parsing RPC data + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/providers/jest.config.js b/packages/providers/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/providers/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/providers/package.json b/packages/providers/package.json new file mode 100644 index 0000000000..34d50e2870 --- /dev/null +++ b/packages/providers/package.json @@ -0,0 +1,45 @@ +{ + "name": "@near-js/providers", + "version": "0.0.1", + "description": "Library of implementations for interfacing with the NEAR blockchain", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc", + "lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix", + "lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/transactions": "workspace:*", + "@near-js/types": "workspace:*", + "@near-js/utils": "workspace:*", + "bn.js": "5.2.1", + "borsh": "^0.7.0", + "http-errors": "^1.7.2" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "optionalDependencies": { + "node-fetch": "^2.6.1" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/providers/src/exponential-backoff.ts b/packages/providers/src/exponential-backoff.ts new file mode 100644 index 0000000000..d9e55e83de --- /dev/null +++ b/packages/providers/src/exponential-backoff.ts @@ -0,0 +1,23 @@ +export async function exponentialBackoff(startWaitTime, retryNumber, waitBackoff, getResult) { + // TODO: jitter? + + let waitTime = startWaitTime; + for (let i = 0; i < retryNumber; i++) { + const result = await getResult(); + if (result) { + return result; + } + + await sleep(waitTime); + waitTime *= waitBackoff; + } + + return null; +} + +// Sleep given number of millis. +function sleep(millis: number): Promise { + return new Promise(resolve => setTimeout(resolve, millis)); +} + + diff --git a/packages/providers/src/fetch.ts b/packages/providers/src/fetch.ts new file mode 100644 index 0000000000..bd9660e068 --- /dev/null +++ b/packages/providers/src/fetch.ts @@ -0,0 +1,21 @@ +import fetch from 'node-fetch'; +import http from 'http'; +import https from 'https'; + +const httpAgent = new http.Agent({ keepAlive: true }); +const httpsAgent = new https.Agent({ keepAlive: true }); + +function agent(_parsedURL) { + if (_parsedURL.protocol === 'http:') { + return httpAgent; + } else { + return httpsAgent; + } +} + +export default function (resource, init) { + return fetch(resource, { + agent: agent(new URL(resource.toString())), + ...init, + }); +} diff --git a/packages/providers/src/fetch_json.ts b/packages/providers/src/fetch_json.ts new file mode 100644 index 0000000000..234fdbf1d9 --- /dev/null +++ b/packages/providers/src/fetch_json.ts @@ -0,0 +1,72 @@ +import { TypedError } from '@near-js/types'; +import { logWarning } from '@near-js/utils'; +import createError from 'http-errors'; + +import { exponentialBackoff } from './exponential-backoff.js'; + +async function resolveFetch() { + if (typeof fetch !== 'undefined') { + return fetch; + } + + if (typeof global !== 'undefined' && global.fetch) { + return global.fetch; + } + + try { + return (await import('./fetch.js')).default; + } catch { + return () => undefined; + } +} + +const START_WAIT_TIME_MS = 1000; +const BACKOFF_MULTIPLIER = 1.5; +const RETRY_NUMBER = 10; + +export interface ConnectionInfo { + url: string; + user?: string; + password?: string; + allowInsecure?: boolean; + timeout?: number; + headers?: { [key: string]: string | number }; +} + +export async function fetchJson(connectionInfoOrUrl: string | ConnectionInfo, json?: string): Promise { + let connectionInfo: ConnectionInfo = { url: null }; + if (typeof (connectionInfoOrUrl) === 'string') { + connectionInfo.url = connectionInfoOrUrl; + } else { + connectionInfo = connectionInfoOrUrl as ConnectionInfo; + } + + const response = await exponentialBackoff(START_WAIT_TIME_MS, RETRY_NUMBER, BACKOFF_MULTIPLIER, async () => { + try { + const fnFetch = await resolveFetch(); + const response = await fnFetch(connectionInfo.url, { + method: json ? 'POST' : 'GET', + body: json ? json : undefined, + headers: { ...connectionInfo.headers, 'Content-Type': 'application/json' } + }); + if (!response.ok) { + if (response.status === 503) { + logWarning(`Retrying HTTP request for ${connectionInfo.url} as it's not available now`); + return null; + } + throw createError(response.status, await response.text()); + } + return response; + } catch (error) { + if (error.toString().includes('FetchError') || error.toString().includes('Failed to fetch')) { + logWarning(`Retrying HTTP request for ${connectionInfo.url} because of error: ${error}`); + return null; + } + throw error; + } + }); + if (!response) { + throw new TypedError(`Exceeded ${RETRY_NUMBER} attempts for ${connectionInfo.url}.`, 'RetriesExceeded'); + } + return await response.json(); +} diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts new file mode 100644 index 0000000000..630c47667a --- /dev/null +++ b/packages/providers/src/index.ts @@ -0,0 +1,4 @@ +export { exponentialBackoff } from './exponential-backoff.js'; +export { JsonRpcProvider } from './json-rpc-provider.js'; +export { Provider } from './provider.js'; +export { fetchJson } from './fetch_json.js'; diff --git a/packages/providers/src/json-rpc-provider.ts b/packages/providers/src/json-rpc-provider.ts new file mode 100644 index 0000000000..6458bded62 --- /dev/null +++ b/packages/providers/src/json-rpc-provider.ts @@ -0,0 +1,381 @@ +/** + * @module + * @description + * This module contains the {@link JsonRpcProvider} client class + * which can be used to interact with the [NEAR RPC API](https://docs.near.org/api/rpc/introduction). + * @see {@link providers/provider | providers} for a list of request and response types + */ +import { + getErrorTypeFromErrorMessage, + parseRpcError, +} from '@near-js/utils'; +import { + AccessKeyWithPublicKey, + BlockId, + BlockReference, + BlockResult, + BlockChangeResult, + ChangeResult, + ChunkId, + ChunkResult, + EpochValidatorInfo, + FinalExecutionOutcome, + GasPrice, + LightClientProof, + LightClientProofRequest, + NearProtocolConfig, + NodeStatusResult, + QueryResponseKind, + TypedError, +} from '@near-js/types'; +import { + SignedTransaction, +} from '@near-js/transactions'; +import { baseEncode } from 'borsh'; + +import { exponentialBackoff } from './exponential-backoff.js'; +import { Provider } from './provider.js'; +import { ConnectionInfo, fetchJson } from './fetch_json.js'; + +/** @hidden */ +// Default number of retries before giving up on a request. +const REQUEST_RETRY_NUMBER = 12; + +// Default wait until next retry in millis. +const REQUEST_RETRY_WAIT = 500; + +// Exponential back off for waiting to retry. +const REQUEST_RETRY_WAIT_BACKOFF = 1.5; + +/// Keep ids unique across all connections. +let _nextId = 123; + +/** + * Client class to interact with the [NEAR RPC API](https://docs.near.org/api/rpc/introduction). + * @see [https://github.com/near/nearcore/tree/master/chain/jsonrpc](https://github.com/near/nearcore/tree/master/chain/jsonrpc) + */ +export class JsonRpcProvider extends Provider { + /** @hidden */ + readonly connection: ConnectionInfo; + + /** + * @param connectionInfo Connection info + */ + constructor(connectionInfo: ConnectionInfo) { + super(); + this.connection = connectionInfo || { url: '' }; + } + + /** + * Gets the RPC's status + * @see [https://docs.near.org/docs/develop/front-end/rpc#general-validator-status](https://docs.near.org/docs/develop/front-end/rpc#general-validator-status) + */ + async status(): Promise { + return this.sendJsonRpc('status', []); + } + + /** + * Sends a signed transaction to the RPC and waits until transaction is fully complete + * @see [https://docs.near.org/docs/develop/front-end/rpc#send-transaction-await](https://docs.near.org/docs/develop/front-end/rpc#general-validator-status) + * + * @param signedTransaction The signed transaction being sent + */ + async sendTransaction(signedTransaction: SignedTransaction): Promise { + const bytes = signedTransaction.encode(); + return this.sendJsonRpc('broadcast_tx_commit', [Buffer.from(bytes).toString('base64')]); + } + + /** + * Sends a signed transaction to the RPC and immediately returns transaction hash + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#send-transaction-async) + * @param signedTransaction The signed transaction being sent + * @returns {Promise} + */ + async sendTransactionAsync(signedTransaction: SignedTransaction): Promise { + const bytes = signedTransaction.encode(); + return this.sendJsonRpc('broadcast_tx_async', [Buffer.from(bytes).toString('base64')]); + } + + /** + * Gets a transaction's status from the RPC + * @see [https://docs.near.org/docs/develop/front-end/rpc#transaction-status](https://docs.near.org/docs/develop/front-end/rpc#general-validator-status) + * + * @param txHash A transaction hash as either a Uint8Array or a base58 encoded string + * @param accountId The NEAR account that signed the transaction + */ + async txStatus(txHash: Uint8Array | string, accountId: string): Promise { + if (typeof txHash === 'string') { + return this.txStatusString(txHash, accountId); + } else { + return this.txStatusUint8Array(txHash, accountId); + } + } + + private async txStatusUint8Array(txHash: Uint8Array, accountId: string): Promise { + return this.sendJsonRpc('tx', [baseEncode(txHash), accountId]); + } + + private async txStatusString(txHash: string, accountId: string): Promise { + return this.sendJsonRpc('tx', [txHash, accountId]); + } + + /** + * Gets a transaction's status from the RPC with receipts + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#transaction-status-with-receipts) + * @param txHash The hash of the transaction + * @param accountId The NEAR account that signed the transaction + * @returns {Promise} + */ + async txStatusReceipts(txHash: Uint8Array | string, accountId: string): Promise { + if (typeof txHash === 'string') { + return this.sendJsonRpc('EXPERIMENTAL_tx_status', [txHash, accountId]); + } + else { + return this.sendJsonRpc('EXPERIMENTAL_tx_status', [baseEncode(txHash), accountId]); + } + } + + /** + * Query the RPC by passing an {@link providers/provider!RpcQueryRequest} + * @see [https://docs.near.org/api/rpc/contracts](https://docs.near.org/api/rpc/contracts) + * + * @typeParam T the shape of the returned query response + */ + async query(...args: any[]): Promise { + let result; + if (args.length === 1) { + const { block_id, blockId, ...otherParams } = args[0]; + result = await this.sendJsonRpc('query', { ...otherParams, block_id: block_id || blockId }); + } else { + const [path, data] = args; + result = await this.sendJsonRpc('query', [path, data]); + } + if (result && result.error) { + throw new TypedError( + `Querying failed: ${result.error}.\n${JSON.stringify(result, null, 2)}`, + getErrorTypeFromErrorMessage(result.error, result.error.name) + ); + } + return result; + } + + /** + * Query for block info from the RPC + * pass block_id OR finality as blockQuery, not both + * @see [https://docs.near.org/api/rpc/block-chunk](https://docs.near.org/api/rpc/block-chunk) + * + * @param blockQuery {@link providers/provider!BlockReference} (passing a {@link providers/provider!BlockId} is deprecated) + */ + async block(blockQuery: BlockId | BlockReference): Promise { + const { finality } = blockQuery as any; + const { blockId } = blockQuery as any; + return this.sendJsonRpc('block', { block_id: blockId, finality }); + } + + /** + * Query changes in block from the RPC + * pass block_id OR finality as blockQuery, not both + * @see [https://docs.near.org/api/rpc/block-chunk](https://docs.near.org/api/rpc/block-chunk) + */ + async blockChanges(blockQuery: BlockReference): Promise { + const { finality } = blockQuery as any; + const { blockId } = blockQuery as any; + return this.sendJsonRpc('EXPERIMENTAL_changes_in_block', { block_id: blockId, finality }); + } + + /** + * Queries for details about a specific chunk appending details of receipts and transactions to the same chunk data provided by a block + * @see [https://docs.near.org/api/rpc/block-chunk](https://docs.near.org/api/rpc/block-chunk) + * + * @param chunkId Hash of a chunk ID or shard ID + */ + async chunk(chunkId: ChunkId): Promise { + return this.sendJsonRpc('chunk', [chunkId]); + } + + /** + * Query validators of the epoch defined by the given block id. + * @see [https://docs.near.org/api/rpc/network#validation-status](https://docs.near.org/api/rpc/network#validation-status) + * + * @param blockId Block hash or height, or null for latest. + */ + async validators(blockId: BlockId | null): Promise { + return this.sendJsonRpc('validators', [blockId]); + } + + /** + * Gets the protocol config at a block from RPC + * + * @param blockReference specifies the block to get the protocol config for + */ + async experimental_protocolConfig(blockReference: BlockReference | { sync_checkpoint: 'genesis' }): Promise { + const { blockId, ...otherParams } = blockReference as any; + return await this.sendJsonRpc('EXPERIMENTAL_protocol_config', {...otherParams, block_id: blockId}); + } + + /** + * Gets a light client execution proof for verifying execution outcomes + * @see [https://github.com/nearprotocol/NEPs/blob/master/specs/ChainSpec/LightClient.md#light-client-proof](https://github.com/nearprotocol/NEPs/blob/master/specs/ChainSpec/LightClient.md#light-client-proof) + */ + async lightClientProof(request: LightClientProofRequest): Promise { + return await this.sendJsonRpc('EXPERIMENTAL_light_client_proof', request); + } + + /** + * Gets access key changes for a given array of accountIds + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-access-key-changes-all) + * @returns {Promise} + */ + async accessKeyChanges(accountIdArray: string[], blockQuery: BlockReference): Promise { + const { finality } = blockQuery as any; + const { blockId } = blockQuery as any; + return this.sendJsonRpc('EXPERIMENTAL_changes', { + changes_type: 'all_access_key_changes', + account_ids: accountIdArray, + block_id: blockId, + finality + }); + } + + /** + * Gets single access key changes for a given array of access keys + * pass block_id OR finality as blockQuery, not both + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-access-key-changes-single) + * @returns {Promise} + */ + async singleAccessKeyChanges(accessKeyArray: AccessKeyWithPublicKey[], blockQuery: BlockReference): Promise { + const { finality } = blockQuery as any; + const { blockId } = blockQuery as any; + return this.sendJsonRpc('EXPERIMENTAL_changes', { + changes_type: 'single_access_key_changes', + keys: accessKeyArray, + block_id: blockId, + finality + }); + } + + /** + * Gets account changes for a given array of accountIds + * pass block_id OR finality as blockQuery, not both + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-account-changes) + * @returns {Promise} + */ + async accountChanges(accountIdArray: string[], blockQuery: BlockReference): Promise { + const { finality } = blockQuery as any; + const { blockId } = blockQuery as any; + return this.sendJsonRpc('EXPERIMENTAL_changes', { + changes_type: 'account_changes', + account_ids: accountIdArray, + block_id: blockId, + finality + }); + } + + /** + * Gets contract state changes for a given array of accountIds + * pass block_id OR finality as blockQuery, not both + * Note: If you pass a keyPrefix it must be base64 encoded + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-contract-state-changes) + * @returns {Promise} + */ + async contractStateChanges(accountIdArray: string[], blockQuery: BlockReference, keyPrefix = ''): Promise { + const { finality } = blockQuery as any; + const { blockId } = blockQuery as any; + return this.sendJsonRpc('EXPERIMENTAL_changes', { + changes_type: 'data_changes', + account_ids: accountIdArray, + key_prefix_base64: keyPrefix, + block_id: blockId, + finality + }); + } + + /** + * Gets contract code changes for a given array of accountIds + * pass block_id OR finality as blockQuery, not both + * Note: Change is returned in a base64 encoded WASM file + * See [docs for more info](https://docs.near.org/docs/develop/front-end/rpc#view-contract-code-changes) + * @returns {Promise} + */ + async contractCodeChanges(accountIdArray: string[], blockQuery: BlockReference): Promise { + const { finality } = blockQuery as any; + const { blockId } = blockQuery as any; + return this.sendJsonRpc('EXPERIMENTAL_changes', { + changes_type: 'contract_code_changes', + account_ids: accountIdArray, + block_id: blockId, + finality + }); + } + + /** + * Returns gas price for a specific block_height or block_hash. + * @see [https://docs.near.org/api/rpc/gas](https://docs.near.org/api/rpc/gas) + * + * @param blockId Block hash or height, or null for latest. + */ + async gasPrice(blockId: BlockId | null): Promise { + return await this.sendJsonRpc('gas_price', [blockId]); + } + + /** + * Directly call the RPC specifying the method and params + * + * @param method RPC method + * @param params Parameters to the method + */ + async sendJsonRpc(method: string, params: object): Promise { + const response = await exponentialBackoff(REQUEST_RETRY_WAIT, REQUEST_RETRY_NUMBER, REQUEST_RETRY_WAIT_BACKOFF, async () => { + try { + const request = { + method, + params, + id: (_nextId++), + jsonrpc: '2.0' + }; + const response = await fetchJson(this.connection, JSON.stringify(request)); + if (response.error) { + if (typeof response.error.data === 'object') { + if (typeof response.error.data.error_message === 'string' && typeof response.error.data.error_type === 'string') { + // if error data has error_message and error_type properties, we consider that node returned an error in the old format + throw new TypedError(response.error.data.error_message, response.error.data.error_type); + } + + throw parseRpcError(response.error.data); + } else { + const errorMessage = `[${response.error.code}] ${response.error.message}: ${response.error.data}`; + // NOTE: All this hackery is happening because structured errors not implemented + // TODO: Fix when https://github.com/nearprotocol/nearcore/issues/1839 gets resolved + if (response.error.data === 'Timeout' || errorMessage.includes('Timeout error') + || errorMessage.includes('query has timed out')) { + throw new TypedError(errorMessage, 'TimeoutError'); + } + + throw new TypedError(errorMessage, getErrorTypeFromErrorMessage(response.error.data, response.error.name)); + } + } + // Success when response.error is not exist + return response; + } catch (error) { + if (error.type === 'TimeoutError') { + if (typeof process !== 'undefined' && !process.env['NEAR_NO_LOGS']) { + console.warn(`Retrying request to ${method} as it has timed out`, params); + } + return null; + } + + throw error; + } + }); + const { result } = response; + // From jsonrpc spec: + // result + // This member is REQUIRED on success. + // This member MUST NOT exist if there was an error invoking the method. + if (typeof result === 'undefined') { + throw new TypedError( + `Exceeded ${REQUEST_RETRY_NUMBER} attempts for request to ${method}.`, 'RetriesExceeded'); + } + return result; + } +} diff --git a/packages/providers/src/provider.ts b/packages/providers/src/provider.ts new file mode 100644 index 0000000000..15e741da7e --- /dev/null +++ b/packages/providers/src/provider.ts @@ -0,0 +1,51 @@ +/** + * NEAR RPC API request types and responses + * @module + */ + +import { SignedTransaction } from '@near-js/transactions'; +import { + AccessKeyWithPublicKey, + BlockChangeResult, + BlockId, + BlockReference, + BlockResult, + ChangeResult, + ChunkId, + ChunkResult, + FinalExecutionOutcome, + GasPrice, + LightClientProof, + LightClientProofRequest, + NearProtocolConfig, + NodeStatusResult, + QueryResponseKind, + RpcQueryRequest, + EpochValidatorInfo, +} from '@near-js/types'; + +/** @hidden */ +export abstract class Provider { + abstract status(): Promise; + + abstract sendTransaction(signedTransaction: SignedTransaction): Promise; + abstract sendTransactionAsync(signedTransaction: SignedTransaction): Promise; + abstract txStatus(txHash: Uint8Array | string, accountId: string): Promise; + abstract txStatusReceipts(txHash: Uint8Array | string, accountId: string): Promise; + abstract query(params: RpcQueryRequest): Promise; + abstract query(path: string, data: string): Promise; + // TODO: BlockQuery type? + abstract block(blockQuery: BlockId | BlockReference): Promise; + abstract blockChanges(blockQuery: BlockId | BlockReference): Promise; + abstract chunk(chunkId: ChunkId): Promise; + // TODO: Use BlockQuery? + abstract validators(blockId: BlockId): Promise; + abstract experimental_protocolConfig(blockReference: BlockReference): Promise; + abstract lightClientProof(request: LightClientProofRequest): Promise; + abstract gasPrice(blockId: BlockId): Promise; + abstract accessKeyChanges(accountIdArray: string[], BlockQuery: BlockId | BlockReference): Promise; + abstract singleAccessKeyChanges(accessKeyArray: AccessKeyWithPublicKey[], BlockQuery: BlockId | BlockReference): Promise; + abstract accountChanges(accountIdArray: string[], BlockQuery: BlockId | BlockReference): Promise; + abstract contractStateChanges(accountIdArray: string[], BlockQuery: BlockId | BlockReference, keyPrefix: string): Promise; + abstract contractCodeChanges(accountIdArray: string[], BlockQuery: BlockId | BlockReference): Promise; +} diff --git a/packages/providers/test/fetch_json.test.js b/packages/providers/test/fetch_json.test.js new file mode 100644 index 0000000000..48d84720d3 --- /dev/null +++ b/packages/providers/test/fetch_json.test.js @@ -0,0 +1,26 @@ +import { fetchJson } from '../lib/esm'; + +describe('fetchJson', () => { + test('string parameter in fetchJson', async () => { + const RPC_URL = 'https://rpc.testnet.near.org'; + const statusRequest = { + 'jsonrpc': '2.0', + 'id': 'dontcare', + 'method': 'status', + 'params': [] + }; + const result = await fetchJson(RPC_URL, JSON.stringify(statusRequest)); + expect(result.result.chain_id).toBe('testnet'); + }); + test('object parameter in fetchJson', async () => { + const connection = { url: 'https://rpc.testnet.near.org' }; + const statusRequest = { + 'jsonrpc': '2.0', + 'id': 'dontcare', + 'method': 'status', + 'params': [] + }; + const result = await fetchJson(connection, JSON.stringify(statusRequest)); + expect(result.result.chain_id).toBe('testnet'); + }); +}); diff --git a/packages/providers/test/providers.test.js b/packages/providers/test/providers.test.js new file mode 100644 index 0000000000..e63f61390c --- /dev/null +++ b/packages/providers/test/providers.test.js @@ -0,0 +1,161 @@ +import { jest } from '@jest/globals'; +import { getTransactionLastResult } from '@near-js/utils'; + +import { JsonRpcProvider } from '../lib/esm'; + +jest.setTimeout(20000); + +const withProvider = (fn) => { + return () => fn(new JsonRpcProvider({ url: 'https://rpc.ci-testnet.near.org' })); +}; + +test('json rpc fetch node status', withProvider(async (provider) => { + let response = await provider.status(); + expect(response.chain_id).toBeTruthy(); +})); + +test('json rpc fetch block info', withProvider(async (provider) => { + let stat = await provider.status(); + let height = stat.sync_info.latest_block_height - 1; + let response = await provider.block({ blockId: height }); + expect(response.header.height).toEqual(height); + + let sameBlock = await provider.block({ blockId: response.header.hash }); + expect(sameBlock.header.height).toEqual(height); + + let optimisticBlock = await provider.block({ finality: 'optimistic' }); + expect(optimisticBlock.header.height - height).toBeLessThan(5); + + let nearFinalBlock = await provider.block({ finality: 'near-final' }); + expect(nearFinalBlock.header.height - height).toBeLessThan(5); + + let finalBlock = await provider.block({ finality: 'final' }); + expect(finalBlock.header.height - height).toBeLessThan(5); +})); + +test('json rpc fetch block changes', withProvider(async (provider) => { + let stat = await provider.status(); + let height = stat.sync_info.latest_block_height - 1; + let response = await provider.blockChanges({ blockId: height }); + + expect(response).toMatchObject({ + block_hash: expect.any(String), + changes: expect.any(Array) + }); +})); + +test('json rpc fetch chunk info', withProvider(async (provider) => { + let stat = await provider.status(); + let height = stat.sync_info.latest_block_height - 1; + let response = await provider.chunk([height, 0]); + expect(response.header.shard_id).toEqual(0); + let sameChunk = await provider.chunk(response.header.chunk_hash); + expect(sameChunk.header.chunk_hash).toEqual(response.header.chunk_hash); + expect(sameChunk.header.shard_id).toEqual(0); +})); + +test('json rpc fetch validators info', withProvider(async (provider) => { + let validators = await provider.validators(null); + expect(validators.current_validators.length).toBeGreaterThanOrEqual(1); +})); + +test('json rpc query with block_id', withProvider(async(provider) => { + const stat = await provider.status(); + let block_id = stat.sync_info.latest_block_height - 1; + + const response = await provider.query({ + block_id, + request_type: 'view_account', + account_id: 'test.near' + }); + + expect(response).toEqual({ + block_height: expect.any(Number), + block_hash: expect.any(String), + amount: expect.any(String), + locked: expect.any(String), + code_hash: '11111111111111111111111111111111', + storage_usage: 182, + storage_paid_at: 0, + }); +})); + +test('json rpc query view_account', withProvider(async (provider) => { + const response = await provider.query({ + request_type: 'view_account', + finality: 'final', + account_id: 'test.near' + }); + + expect(response).toEqual({ + block_height: expect.any(Number), + block_hash: expect.any(String), + amount: expect.any(String), + locked: expect.any(String), + code_hash: '11111111111111111111111111111111', + storage_usage: 182, + storage_paid_at: 0, + }); +})); + +test('final tx result', async() => { + const result = { + status: { SuccessValue: 'e30=' }, + transaction: { id: '11111', outcome: { status: { SuccessReceiptId: '11112' }, logs: [], receipt_ids: ['11112'], gas_burnt: 1 } }, + receipts: [ + { id: '11112', outcome: { status: { SuccessValue: 'e30=' }, logs: [], receipt_ids: ['11112'], gas_burnt: 9001 } }, + { id: '11113', outcome: { status: { SuccessValue: '' }, logs: [], receipt_ids: [], gas_burnt: 0 } } + ] + }; + expect(getTransactionLastResult(result)).toEqual({}); +}); + +test('final tx result with null', async() => { + const result = { + status: 'Failure', + transaction: { id: '11111', outcome: { status: { SuccessReceiptId: '11112' }, logs: [], receipt_ids: ['11112'], gas_burnt: 1 } }, + receipts: [ + { id: '11112', outcome: { status: 'Failure', logs: [], receipt_ids: ['11112'], gas_burnt: 9001 } }, + { id: '11113', outcome: { status: { SuccessValue: '' }, logs: [], receipt_ids: [], gas_burnt: 0 } } + ] + }; + expect(getTransactionLastResult(result)).toEqual(null); +}); + +test('json rpc fetch protocol config', withProvider(async (provider) => { + const status = await provider.status(); + const blockHeight = status.sync_info.latest_block_height; + const blockHash = status.sync_info.latest_block_hash; + for (const blockReference of [{ sync_checkpoint: 'genesis' }, { blockId: blockHeight }, { blockId: blockHash }, { finality: 'final' }, { finality: 'optimistic' }]) { + const response = await provider.experimental_protocolConfig(blockReference); + expect('chain_id' in response).toBe(true); + expect('genesis_height' in response).toBe(true); + expect('runtime_config' in response).toBe(true); + expect('storage_amount_per_byte' in response.runtime_config).toBe(true); + } +})); + +test('json rpc gas price', withProvider(async (provider) => { + let status = await provider.status(); + let positiveIntegerRegex = /^[+]?\d+([.]\d+)?$/; + + let response1 = await provider.gasPrice(status.sync_info.latest_block_height); + expect(response1.gas_price).toMatch(positiveIntegerRegex); + + let response2 = await provider.gasPrice(status.sync_info.latest_block_hash); + expect(response2.gas_price).toMatch(positiveIntegerRegex); + + let response3 = await provider.gasPrice(); + expect(response3.gas_price).toMatch(positiveIntegerRegex); +})); + +test('JsonRpc connection object exist without connectionInfo provided', async () => { + const provider = new JsonRpcProvider(); + expect(provider.connection).toStrictEqual({ url: '' }); +}); + +test('near json rpc fetch node status', async () => { + const provider = new JsonRpcProvider({ url: 'https://rpc.ci-testnet.near.org' }); + let response = await provider.status(); + expect(response.chain_id).toBeTruthy(); +}); diff --git a/packages/providers/tsconfig.cjs.json b/packages/providers/tsconfig.cjs.json new file mode 100644 index 0000000000..d8d8080218 --- /dev/null +++ b/packages/providers/tsconfig.cjs.json @@ -0,0 +1,14 @@ +{ + "extends": "tsconfig/node.json", + "compilerOptions": { + "lib": [ + "es2015", + "esnext", + "dom" + ], + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/providers/tsconfig.esm.json b/packages/providers/tsconfig.esm.json new file mode 100644 index 0000000000..98533488ee --- /dev/null +++ b/packages/providers/tsconfig.esm.json @@ -0,0 +1,13 @@ +{ + "extends": "tsconfig/esm.json", + "compilerOptions": { + "lib": [ + "esnext", + "dom" + ], + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/signers/README.md b/packages/signers/README.md new file mode 100644 index 0000000000..c70a03991b --- /dev/null +++ b/packages/signers/README.md @@ -0,0 +1,13 @@ +# @near-js/signers + +A collection of classes and types to facilitate cryptographic signing. + +## Modules + +- [Signer](src/signer.ts) abstract class for cryptographic signing +- [InMemorySigner](src/in_memory_signer.ts) implementation of `Signer` using [InMemoryKeyStore](../keystores/src/in_memory_key_store.ts) to provide keys + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/signers/jest.config.js b/packages/signers/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/signers/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/signers/package.json b/packages/signers/package.json new file mode 100644 index 0000000000..32965fb88e --- /dev/null +++ b/packages/signers/package.json @@ -0,0 +1,38 @@ +{ + "name": "@near-js/signers", + "version": "0.0.1", + "description": "Core dependencies for the NEAR API JavaScript client", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix", + "lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/crypto": "workspace:*", + "@near-js/keystores": "workspace:*", + "js-sha256": "^0.9.0" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/signers/src/in_memory_signer.ts b/packages/signers/src/in_memory_signer.ts new file mode 100644 index 0000000000..b8d92e0b4d --- /dev/null +++ b/packages/signers/src/in_memory_signer.ts @@ -0,0 +1,80 @@ +import { KeyPair, PublicKey, Signature } from '@near-js/crypto'; +import { InMemoryKeyStore, KeyStore } from '@near-js/keystores'; +import sha256 from 'js-sha256'; + +import { Signer } from './signer.js'; + +/** + * Signs using in memory key store. + */ +export class InMemorySigner extends Signer { + readonly keyStore: KeyStore; + + constructor(keyStore: KeyStore) { + super(); + this.keyStore = keyStore; + } + + /** + * Creates a single account Signer instance with account, network and keyPair provided. + * + * Intended to be useful for temporary keys (e.g. claiming a Linkdrop). + * + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @param accountId The NEAR account to assign the key pair to + * @param keyPair The keyPair to use for signing + */ + static async fromKeyPair(networkId: string, accountId: string, keyPair: KeyPair): Promise { + const keyStore = new InMemoryKeyStore(); + await keyStore.setKey(networkId, accountId, keyPair); + return new InMemorySigner(keyStore); + } + + /** + * Creates a public key for the account given + * @param accountId The NEAR account to assign a public key to + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @returns {Promise} + */ + async createKey(accountId: string, networkId: string): Promise { + const keyPair = KeyPair.fromRandom('ed25519'); + await this.keyStore.setKey(networkId, accountId, keyPair); + return keyPair.getPublicKey(); + } + + /** + * Gets the existing public key for a given account + * @param accountId The NEAR account to assign a public key to + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @returns {Promise} Returns the public key or null if not found + */ + async getPublicKey(accountId?: string, networkId?: string): Promise { + const keyPair = await this.keyStore.getKey(networkId, accountId); + if (keyPair === null) { + return null; + } + return keyPair.getPublicKey(); + } + + /** + * @param message A message to be signed, typically a serialized transaction + * @param accountId the NEAR account signing the message + * @param networkId The targeted network. (ex. default, betanet, etc…) + * @returns {Promise} + */ + async signMessage(message: Uint8Array, accountId?: string, networkId?: string): Promise { + const hash = new Uint8Array(sha256.sha256.array(message)); + if (!accountId) { + throw new Error('InMemorySigner requires provided account id'); + } + const keyPair = await this.keyStore.getKey(networkId, accountId); + if (keyPair === null) { + throw new Error(`Key for ${accountId} not found in ${networkId}`); + } + return keyPair.sign(hash); + } + + toString(): string { + return `InMemorySigner(${this.keyStore})`; + } +} diff --git a/packages/signers/src/index.ts b/packages/signers/src/index.ts new file mode 100644 index 0000000000..01dc2970d2 --- /dev/null +++ b/packages/signers/src/index.ts @@ -0,0 +1,2 @@ +export { InMemorySigner } from './in_memory_signer.js'; +export { Signer } from './signer.js'; diff --git a/packages/signers/src/signer.ts b/packages/signers/src/signer.ts new file mode 100644 index 0000000000..d5d5ae670e --- /dev/null +++ b/packages/signers/src/signer.ts @@ -0,0 +1,27 @@ +import { Signature, PublicKey } from '@near-js/crypto'; + +/** + * General signing interface, can be used for in memory signing, RPC singing, external wallet, HSM, etc. + */ +export abstract class Signer { + + /** + * Creates new key and returns public key. + */ + abstract createKey(accountId: string, networkId?: string): Promise; + + /** + * Returns public key for given account / network. + * @param accountId accountId to retrieve from. + * @param networkId The targeted network. (ex. default, betanet, etc…) + */ + abstract getPublicKey(accountId?: string, networkId?: string): Promise; + + /** + * Signs given message, by first hashing with sha256. + * @param message message to sign. + * @param accountId accountId to use for signing. + * @param networkId The targeted network. (ex. default, betanet, etc…) + */ + abstract signMessage(message: Uint8Array, accountId?: string, networkId?: string): Promise; +} diff --git a/packages/signers/test/signer.test.js b/packages/signers/test/signer.test.js new file mode 100644 index 0000000000..e84abfe548 --- /dev/null +++ b/packages/signers/test/signer.test.js @@ -0,0 +1,9 @@ +import { InMemoryKeyStore } from '@near-js/keystores'; + +import { InMemorySigner } from '../lib/esm'; + +test('test no key', async() => { + const signer = new InMemorySigner(new InMemoryKeyStore()); + await expect(signer.signMessage('message', 'user', 'network')) + .rejects.toThrow(/Key for user not found in network/); +}); diff --git a/packages/signers/tsconfig.cjs.json b/packages/signers/tsconfig.cjs.json new file mode 100644 index 0000000000..b055ecfb97 --- /dev/null +++ b/packages/signers/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/node.json", + "compilerOptions": { + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/signers/tsconfig.esm.json b/packages/signers/tsconfig.esm.json new file mode 100644 index 0000000000..5f3d7bd793 --- /dev/null +++ b/packages/signers/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/transactions/README.md b/packages/transactions/README.md new file mode 100644 index 0000000000..ba88ef08f8 --- /dev/null +++ b/packages/transactions/README.md @@ -0,0 +1,16 @@ +# @near-js/transactions + +A collection of classes, functions, and types for composing, serializing, and signing NEAR transactions. + +## Modules + +- [actionCreators](src/action_creators.ts) functions for composing actions +- [Actions](src/actions.ts) classes for actions +- [Schema](src/schema.ts) classes and types concerned with (de-)serialization of transactions +- [createTransaction](src/create_transaction.ts) function for composing a transaction +- [signTransaction](src/sign.ts) function for signing a transaction + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/transactions/jest.config.js b/packages/transactions/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/transactions/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/transactions/package.json b/packages/transactions/package.json new file mode 100644 index 0000000000..5e858c67f4 --- /dev/null +++ b/packages/transactions/package.json @@ -0,0 +1,44 @@ +{ + "name": "@near-js/transactions", + "version": "0.0.1", + "description": "Functions and data types for transactions on NEAR", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc", + "lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix", + "lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/crypto": "workspace:*", + "@near-js/signers": "workspace:*", + "@near-js/types": "workspace:*", + "@near-js/utils": "workspace:*", + "bn.js": "5.2.1", + "borsh": "^0.7.0", + "js-sha256": "^0.9.0" + }, + "devDependencies": { + "@near-js/keystores": "workspace:*", + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/transactions/src/action_creators.ts b/packages/transactions/src/action_creators.ts new file mode 100644 index 0000000000..9e96d19139 --- /dev/null +++ b/packages/transactions/src/action_creators.ts @@ -0,0 +1,106 @@ +import { PublicKey } from '@near-js/crypto'; +import BN from 'bn.js'; + +import { + AccessKey, + AccessKeyPermission, + Action, + AddKey, + CreateAccount, + DeleteAccount, + DeleteKey, + DeployContract, + FullAccessPermission, + FunctionCall, + FunctionCallPermission, + Stake, + Transfer, +} from './actions.js'; + +function fullAccessKey(): AccessKey { + return new AccessKey({ + permission: new AccessKeyPermission({ + fullAccess: new FullAccessPermission({}), + }) + }); +} + +function functionCallAccessKey(receiverId: string, methodNames: string[], allowance?: BN): AccessKey { + return new AccessKey({ + permission: new AccessKeyPermission({ + functionCall: new FunctionCallPermission({ receiverId, allowance, methodNames }), + }) + }); +} + +function createAccount(): Action { + return new Action({ createAccount: new CreateAccount({}) }); +} + +function deployContract(code: Uint8Array): Action { + return new Action({ deployContract: new DeployContract({ code }) }); +} + +export function stringifyJsonOrBytes(args: any): Buffer { + const isUint8Array = args.byteLength !== undefined && args.byteLength === args.length; + return isUint8Array ? args : Buffer.from(JSON.stringify(args)); +} + +/** + * Constructs {@link Action} instance representing contract method call. + * + * @param methodName the name of the method to call + * @param args arguments to pass to method. Can be either plain JS object which gets serialized as JSON automatically + * or `Uint8Array` instance which represents bytes passed as is. + * @param gas max amount of gas that method call can use + * @param deposit amount of NEAR (in yoctoNEAR) to send together with the call + * @param stringify Convert input arguments into bytes array. + * @param jsContract Is contract from JS SDK, skips stringification of arguments. + */ +function functionCall(methodName: string, args: Uint8Array | object, gas: BN, deposit: BN, stringify = stringifyJsonOrBytes, jsContract = false): Action { + if(jsContract){ + return new Action({ functionCall: new FunctionCall({ methodName, args, gas, deposit }) }); + } + + return new Action({ + functionCall: new FunctionCall({ + methodName, + args: stringify(args), + gas, + deposit, + }), + }); +} + +function transfer(deposit: BN): Action { + return new Action({ transfer: new Transfer({ deposit }) }); +} + +function stake(stake: BN, publicKey: PublicKey): Action { + return new Action({ stake: new Stake({ stake, publicKey }) }); +} + +function addKey(publicKey: PublicKey, accessKey: AccessKey): Action { + return new Action({ addKey: new AddKey({ publicKey, accessKey}) }); +} + +function deleteKey(publicKey: PublicKey): Action { + return new Action({ deleteKey: new DeleteKey({ publicKey }) }); +} + +function deleteAccount(beneficiaryId: string): Action { + return new Action({ deleteAccount: new DeleteAccount({ beneficiaryId }) }); +} + +export const actionCreators = { + addKey, + createAccount, + deleteAccount, + deleteKey, + deployContract, + fullAccessKey, + functionCall, + functionCallAccessKey, + stake, + transfer, +}; diff --git a/packages/transactions/src/actions.ts b/packages/transactions/src/actions.ts new file mode 100644 index 0000000000..0cbfde40cf --- /dev/null +++ b/packages/transactions/src/actions.ts @@ -0,0 +1,60 @@ +import { PublicKey } from '@near-js/crypto'; +import { Assignable } from '@near-js/types'; +import BN from 'bn.js'; + +abstract class Enum { + enum: string; + + constructor(properties: any) { + if (Object.keys(properties).length !== 1) { + throw new Error('Enum can only take single value'); + } + Object.keys(properties).map((key: string) => { + (this as any)[key] = properties[key]; + this.enum = key; + }); + } +} + +export class FunctionCallPermission extends Assignable { + allowance?: BN; + receiverId: string; + methodNames: string[]; +} + +export class FullAccessPermission extends Assignable {} + +export class AccessKeyPermission extends Enum { + functionCall: FunctionCallPermission; + fullAccess: FullAccessPermission; +} + +export class AccessKey extends Assignable { + permission: AccessKeyPermission; +} + +export class IAction extends Assignable {} + +export class CreateAccount extends IAction {} +export class DeployContract extends IAction { code: Uint8Array; } +export class FunctionCall extends IAction { methodName: string; args: Uint8Array; gas: BN; deposit: BN; } +export class Transfer extends IAction { deposit: BN; } +export class Stake extends IAction { stake: BN; publicKey: PublicKey; } +export class AddKey extends IAction { publicKey: PublicKey; accessKey: AccessKey; } +export class DeleteKey extends IAction { publicKey: PublicKey; } +export class DeleteAccount extends IAction { beneficiaryId: string; } + +/** + * Contains a list of the valid transaction Actions available with this API + * @see {@link https://nomicon.io/RuntimeSpec/Actions.html | Actions Spec} + */ +export class Action extends Enum { + createAccount: CreateAccount; + deployContract: DeployContract; + functionCall: FunctionCall; + transfer: Transfer; + stake: Stake; + addKey: AddKey; + deleteKey: DeleteKey; + deleteAccount: DeleteAccount; +} diff --git a/packages/transactions/src/create_transaction.ts b/packages/transactions/src/create_transaction.ts new file mode 100644 index 0000000000..e655273a34 --- /dev/null +++ b/packages/transactions/src/create_transaction.ts @@ -0,0 +1,9 @@ +import { PublicKey } from '@near-js/crypto'; +import BN from 'bn.js'; + +import { Action } from './actions.js'; +import { Transaction } from './schema.js'; + +export function createTransaction(signerId: string, publicKey: PublicKey, receiverId: string, nonce: BN | string | number, actions: Action[], blockHash: Uint8Array): Transaction { + return new Transaction({ signerId, publicKey, nonce, receiverId, actions, blockHash }); +} diff --git a/packages/transactions/src/index.ts b/packages/transactions/src/index.ts new file mode 100644 index 0000000000..91b65b569b --- /dev/null +++ b/packages/transactions/src/index.ts @@ -0,0 +1,5 @@ +export * from './action_creators.js'; +export * from './actions.js'; +export * from './create_transaction.js'; +export * from './schema.js'; +export * from './sign.js'; diff --git a/packages/transactions/src/schema.ts b/packages/transactions/src/schema.ts new file mode 100644 index 0000000000..3e2dd9e19c --- /dev/null +++ b/packages/transactions/src/schema.ts @@ -0,0 +1,131 @@ +import { KeyType, PublicKey } from '@near-js/crypto'; +import { Assignable } from '@near-js/types'; +import BN from 'bn.js'; +import { deserialize, serialize } from 'borsh'; + +import { + Action, + AccessKey, + AccessKeyPermission, + AddKey, + CreateAccount, + DeleteAccount, + DeleteKey, + DeployContract, + FullAccessPermission, + FunctionCall, + FunctionCallPermission, + Stake, + Transfer, +} from './actions.js'; + +export class Signature extends Assignable { + keyType: KeyType; + data: Uint8Array; +} + +export class Transaction extends Assignable { + signerId: string; + publicKey: PublicKey; + nonce: BN; + receiverId: string; + actions: Action[]; + blockHash: Uint8Array; + + encode(): Uint8Array { + return serialize(SCHEMA, this); + } + + static decode(bytes: Buffer): Transaction { + return deserialize(SCHEMA, Transaction, bytes); + } +} + +export class SignedTransaction extends Assignable { + transaction: Transaction; + signature: Signature; + + encode(): Uint8Array { + return serialize(SCHEMA, this); + } + + static decode(bytes: Buffer): SignedTransaction { + return deserialize(SCHEMA, SignedTransaction, bytes); + } +} + +type Class = new (...args: any[]) => T; + +export const SCHEMA = new Map([ + [Signature, {kind: 'struct', fields: [ + ['keyType', 'u8'], + ['data', [64]] + ]}], + [SignedTransaction, {kind: 'struct', fields: [ + ['transaction', Transaction], + ['signature', Signature] + ]}], + [Transaction, { kind: 'struct', fields: [ + ['signerId', 'string'], + ['publicKey', PublicKey], + ['nonce', 'u64'], + ['receiverId', 'string'], + ['blockHash', [32]], + ['actions', [Action]] + ]}], + [PublicKey, { kind: 'struct', fields: [ + ['keyType', 'u8'], + ['data', [32]] + ]}], + [AccessKey, { kind: 'struct', fields: [ + ['nonce', 'u64'], + ['permission', AccessKeyPermission], + ]}], + [AccessKeyPermission, {kind: 'enum', field: 'enum', values: [ + ['functionCall', FunctionCallPermission], + ['fullAccess', FullAccessPermission], + ]}], + [FunctionCallPermission, {kind: 'struct', fields: [ + ['allowance', {kind: 'option', type: 'u128'}], + ['receiverId', 'string'], + ['methodNames', ['string']], + ]}], + [FullAccessPermission, {kind: 'struct', fields: []}], + [Action, {kind: 'enum', field: 'enum', values: [ + ['createAccount', CreateAccount], + ['deployContract', DeployContract], + ['functionCall', FunctionCall], + ['transfer', Transfer], + ['stake', Stake], + ['addKey', AddKey], + ['deleteKey', DeleteKey], + ['deleteAccount', DeleteAccount], + ]}], + [CreateAccount, { kind: 'struct', fields: [] }], + [DeployContract, { kind: 'struct', fields: [ + ['code', ['u8']] + ]}], + [FunctionCall, { kind: 'struct', fields: [ + ['methodName', 'string'], + ['args', ['u8']], + ['gas', 'u64'], + ['deposit', 'u128'] + ]}], + [Transfer, { kind: 'struct', fields: [ + ['deposit', 'u128'] + ]}], + [Stake, { kind: 'struct', fields: [ + ['stake', 'u128'], + ['publicKey', PublicKey] + ]}], + [AddKey, { kind: 'struct', fields: [ + ['publicKey', PublicKey], + ['accessKey', AccessKey] + ]}], + [DeleteKey, { kind: 'struct', fields: [ + ['publicKey', PublicKey] + ]}], + [DeleteAccount, { kind: 'struct', fields: [ + ['beneficiaryId', 'string'] + ]}], +]); diff --git a/packages/transactions/src/sign.ts b/packages/transactions/src/sign.ts new file mode 100644 index 0000000000..d7676e95c3 --- /dev/null +++ b/packages/transactions/src/sign.ts @@ -0,0 +1,40 @@ +import { Signer } from '@near-js/signers'; +import { sha256 } from 'js-sha256'; +import BN from 'bn.js'; +import { serialize } from 'borsh'; + +import { Action } from './actions.js'; +import { createTransaction } from './create_transaction.js'; +import { SCHEMA, Signature, SignedTransaction, Transaction } from './schema.js'; + +/** + * Signs a given transaction from an account with given keys, applied to the given network + * @param transaction The Transaction object to sign + * @param signer The {Signer} object that assists with signing keys + * @param accountId The human-readable NEAR account name + * @param networkId The targeted network. (ex. default, betanet, etc…) + */ +async function signTransactionObject(transaction: Transaction, signer: Signer, accountId?: string, networkId?: string): Promise<[Uint8Array, SignedTransaction]> { + const message = serialize(SCHEMA, transaction); + const hash = new Uint8Array(sha256.array(message)); + const signature = await signer.signMessage(message, accountId, networkId); + const signedTx = new SignedTransaction({ + transaction, + signature: new Signature({ keyType: transaction.publicKey.keyType, data: signature.signature }) + }); + return [hash, signedTx]; +} + +export async function signTransaction(transaction: Transaction, signer: Signer, accountId?: string, networkId?: string): Promise<[Uint8Array, SignedTransaction]>; +export async function signTransaction(receiverId: string, nonce: BN, actions: Action[], blockHash: Uint8Array, signer: Signer, accountId?: string, networkId?: string): Promise<[Uint8Array, SignedTransaction]>; +export async function signTransaction(...args): Promise<[Uint8Array, SignedTransaction]> { + if (args[0].constructor === Transaction) { + const [ transaction, signer, accountId, networkId ] = args; + return signTransactionObject(transaction, signer, accountId, networkId); + } else { + const [ receiverId, nonce, actions, blockHash, signer, accountId, networkId ] = args; + const publicKey = await signer.getPublicKey(accountId, networkId); + const transaction = createTransaction(accountId, publicKey, receiverId, nonce, actions, blockHash); + return signTransactionObject(transaction, signer, accountId, networkId); + } +} diff --git a/packages/transactions/test/data/signed_transaction1.json b/packages/transactions/test/data/signed_transaction1.json new file mode 100644 index 0000000000..76f667ec46 --- /dev/null +++ b/packages/transactions/test/data/signed_transaction1.json @@ -0,0 +1,4 @@ +{ + "type": "SignedTransaction", + "data": "09000000746573742e6e65617200917b3d268d4b58f7fec1b150bd68d69be3ee5d4cc39855e341538465bb77860d01000000000000000d00000077686174657665722e6e6561720fa473fd26901df296be6adc4cc4df34d040efa2435224b6986910e630c2fef601000000030100000000000000000000000000000000969a83332186ee9755e4839325525806e189a3d2d2bb4b4760e94443e97e1c4f22deeef0059a8e9713100eda6e19144da7e8a0ef7e539b20708ba1d8d021bd01" +} diff --git a/packages/transactions/test/data/transaction1.json b/packages/transactions/test/data/transaction1.json new file mode 100644 index 0000000000..aa0334d26b --- /dev/null +++ b/packages/transactions/test/data/transaction1.json @@ -0,0 +1,4 @@ +{ + "type": "Transaction", + "data": "0000000000795cb7b5f57222e742d1759092f0e20071a0cd2bf30e1f681d800e67935e168801000000000000001000000073747564696f2d76776375396534316d4def837b838543990f3380af8e2a3817ddf70fe9960135b2add25a679b2a01ed01000000020a0000006164644d6573736167650b0000007b2274657874223a22227d80841e000000000000000000000000000000000000000000" +} diff --git a/packages/transactions/test/serialize.test.js b/packages/transactions/test/serialize.test.js new file mode 100644 index 0000000000..7c2ed1deaa --- /dev/null +++ b/packages/transactions/test/serialize.test.js @@ -0,0 +1,190 @@ +import { KeyPair, PublicKey } from '@near-js/crypto'; +import { InMemoryKeyStore } from '@near-js/keystores'; +import { InMemorySigner } from '@near-js/signers'; +import { Assignable } from '@near-js/types'; +import fs from 'fs'; +import BN from 'bn.js'; +import { baseDecode, baseEncode, deserialize, serialize } from 'borsh'; + +import { + actionCreators, + createTransaction, + SCHEMA, + signTransaction, + SignedTransaction, + Transaction, +} from '../lib/esm'; + +const { + addKey, + createAccount, + deleteAccount, + deleteKey, + deployContract, + functionCall, + functionCallAccessKey, + stake, + transfer, +} = actionCreators; + +class Test extends Assignable { +} + +test('serialize object', async () => { + const value = new Test({ x: 255, y: 20, z: '123', q: [1, 2, 3]}); + const schema = new Map([[Test, {kind: 'struct', fields: [['x', 'u8'], ['y', 'u64'], ['z', 'string'], ['q', [3]]] }]]); + let buf = serialize(schema, value); + let new_value = deserialize(schema, Test, buf); + expect(new_value.x).toEqual(255); + expect(new_value.y.toString()).toEqual('20'); + expect(new_value.z).toEqual('123'); + expect(new_value.q).toEqual(new Uint8Array([1, 2, 3])); +}); + +test('serialize and sign multi-action tx', async() => { + const keyStore = new InMemoryKeyStore(); + const keyPair = KeyPair.fromString('ed25519:2wyRcSwSuHtRVmkMCGjPwnzZmQLeXLzLLyED1NDMt4BjnKgQL6tF85yBx6Jr26D2dUNeC716RBoTxntVHsegogYw'); + await keyStore.setKey('test', 'test.near', keyPair); + const publicKey = keyPair.publicKey; + const actions = [ + createAccount(), + deployContract(new Uint8Array([1, 2, 3])), + functionCall('qqq', new Uint8Array([1, 2, 3]), 1000, 1000000), + transfer(123), + stake(1000000, publicKey), + addKey(publicKey, functionCallAccessKey('zzz', ['www'], null)), + deleteKey(publicKey), + deleteAccount('123') + ]; + const blockHash = baseDecode('244ZQ9cgj3CQ6bWBdytfrJMuMQ1jdXLFGnr4HhvtCTnM'); + let [hash, { transaction }] = await signTransaction('123', 1, actions, blockHash, new InMemorySigner(keyStore), 'test.near', 'test'); + expect(baseEncode(hash)).toEqual('Fo3MJ9XzKjnKuDuQKhDAC6fra5H2UWawRejFSEpPNk3Y'); + const serialized = serialize(SCHEMA, transaction); + expect(serialized.toString('hex')).toEqual('09000000746573742e6e656172000f56a5f028dfc089ec7c39c1183b321b4d8f89ba5bec9e1762803cc2491f6ef80100000000000000030000003132330fa473fd26901df296be6adc4cc4df34d040efa2435224b6986910e630c2fef608000000000103000000010203020300000071717103000000010203e80300000000000040420f00000000000000000000000000037b0000000000000000000000000000000440420f00000000000000000000000000000f56a5f028dfc089ec7c39c1183b321b4d8f89ba5bec9e1762803cc2491f6ef805000f56a5f028dfc089ec7c39c1183b321b4d8f89ba5bec9e1762803cc2491f6ef800000000000000000000030000007a7a7a010000000300000077777706000f56a5f028dfc089ec7c39c1183b321b4d8f89ba5bec9e1762803cc2491f6ef80703000000313233'); +}); + +function createTransferTx() { + const actions = [ + transfer(1), + ]; + const blockHash = baseDecode('244ZQ9cgj3CQ6bWBdytfrJMuMQ1jdXLFGnr4HhvtCTnM'); + return createTransaction( + 'test.near', + PublicKey.fromString('Anu7LYDfpLtkP7E16LT9imXF694BdQaa9ufVkQiwTQxC'), + 'whatever.near', + 1, + actions, + blockHash); +} + +test('serialize transfer tx', async() => { + const transaction = createTransferTx(); + + const serialized = transaction.encode(); + expect(serialized.toString('hex')).toEqual('09000000746573742e6e65617200917b3d268d4b58f7fec1b150bd68d69be3ee5d4cc39855e341538465bb77860d01000000000000000d00000077686174657665722e6e6561720fa473fd26901df296be6adc4cc4df34d040efa2435224b6986910e630c2fef6010000000301000000000000000000000000000000'); + + const deserialized = Transaction.decode(serialized); + expect(deserialized.encode()).toEqual(serialized); +}); + +async function createKeyStore() { + const keyStore = new InMemoryKeyStore(); + const keyPair = KeyPair.fromString('ed25519:3hoMW1HvnRLSFCLZnvPzWeoGwtdHzke34B2cTHM8rhcbG3TbuLKtShTv3DvyejnXKXKBiV7YPkLeqUHN1ghnqpFv'); + await keyStore.setKey('test', 'test.near', keyPair); + return keyStore; +} + +async function verifySignedTransferTx(signedTx) { + expect(Buffer.from(signedTx.signature.data).toString('base64')).toEqual('lpqDMyGG7pdV5IOTJVJYBuGJo9LSu0tHYOlEQ+l+HE8i3u7wBZqOlxMQDtpuGRRNp+ig735TmyBwi6HY0CG9AQ=='); + const serialized = signedTx.encode(); + expect(serialized.toString('hex')).toEqual('09000000746573742e6e65617200917b3d268d4b58f7fec1b150bd68d69be3ee5d4cc39855e341538465bb77860d01000000000000000d00000077686174657665722e6e6561720fa473fd26901df296be6adc4cc4df34d040efa2435224b6986910e630c2fef601000000030100000000000000000000000000000000969a83332186ee9755e4839325525806e189a3d2d2bb4b4760e94443e97e1c4f22deeef0059a8e9713100eda6e19144da7e8a0ef7e539b20708ba1d8d021bd01'); + + const deserialized = SignedTransaction.decode(serialized); + expect(deserialized.encode()).toEqual(serialized); +} + +test('serialize and sign transfer tx', async() => { + const transaction = createTransferTx(); + const keyStore = await createKeyStore(); + + let [, signedTx] = await signTransaction(transaction.receiverId, transaction.nonce, transaction.actions, transaction.blockHash, new InMemorySigner(keyStore), 'test.near', 'test'); + + verifySignedTransferTx(signedTx); +}); + +test('serialize and sign transfer tx object', async() => { + const transaction = createTransferTx(); + const keyStore = await createKeyStore(); + + let [, signedTx] = await signTransaction(transaction, new InMemorySigner(keyStore), 'test.near', 'test'); + + verifySignedTransferTx(signedTx); +}); + +describe('roundtrip test', () => { + const dataDir = './test/data'; + const testFiles = fs.readdirSync(dataDir); + for (const testFile of testFiles) { + if (/.+\.json$/.test(testFile)) { + const testDefinition = JSON.parse(fs.readFileSync(dataDir + '/' + testFile)); + test(testFile, () => { + const data = Buffer.from(testDefinition.data, 'hex'); + const type = Array.from(SCHEMA.keys()).find(key => key.name === testDefinition.type); + const deserialized = deserialize(SCHEMA, type, data); + const serialized = serialize(SCHEMA, deserialized); + expect(serialized).toEqual(data); + }); + } + } +}); + +describe('serialize and deserialize on different types of nonce', () => { + const actions = [ + transfer(1), + ]; + const blockHash = baseDecode('244ZQ9cgj3CQ6bWBdytfrJMuMQ1jdXLFGnr4HhvtCTnM'); + const targetNonce = new BN(1); + test('number typed nonce', async() => { + const transaction = createTransaction( + 'test.near', + PublicKey.fromString('Anu7LYDfpLtkP7E16LT9imXF694BdQaa9ufVkQiwTQxC'), + 'whatever.near', + 1, + actions, + blockHash); + const serialized = transaction.encode(); + expect(serialized.toString('hex')).toEqual('09000000746573742e6e65617200917b3d268d4b58f7fec1b150bd68d69be3ee5d4cc39855e341538465bb77860d01000000000000000d00000077686174657665722e6e6561720fa473fd26901df296be6adc4cc4df34d040efa2435224b6986910e630c2fef6010000000301000000000000000000000000000000'); + const deserialized = Transaction.decode(serialized); + expect(deserialized.encode()).toEqual(serialized); + expect(deserialized.nonce.toString()).toEqual(targetNonce.toString()); + + }); + test('string typed nonce', async() => { + const transaction = createTransaction( + 'test.near', + PublicKey.fromString('Anu7LYDfpLtkP7E16LT9imXF694BdQaa9ufVkQiwTQxC'), + 'whatever.near', + '1', + actions, + blockHash); + const serialized = transaction.encode(); + expect(serialized.toString('hex')).toEqual('09000000746573742e6e65617200917b3d268d4b58f7fec1b150bd68d69be3ee5d4cc39855e341538465bb77860d01000000000000000d00000077686174657665722e6e6561720fa473fd26901df296be6adc4cc4df34d040efa2435224b6986910e630c2fef6010000000301000000000000000000000000000000'); + const deserialized = Transaction.decode(serialized); + expect(deserialized.encode()).toEqual(serialized); + expect(deserialized.nonce.toString()).toEqual(targetNonce.toString()); + }); + test('BN typed nonce', async() => { + const transaction = createTransaction( + 'test.near', + PublicKey.fromString('Anu7LYDfpLtkP7E16LT9imXF694BdQaa9ufVkQiwTQxC'), + 'whatever.near', + new BN(1), + actions, + blockHash); + const serialized = transaction.encode(); + expect(serialized.toString('hex')).toEqual('09000000746573742e6e65617200917b3d268d4b58f7fec1b150bd68d69be3ee5d4cc39855e341538465bb77860d01000000000000000d00000077686174657665722e6e6561720fa473fd26901df296be6adc4cc4df34d040efa2435224b6986910e630c2fef6010000000301000000000000000000000000000000'); + const deserialized = Transaction.decode(serialized); + expect(deserialized.encode()).toEqual(serialized); + expect(deserialized.nonce.toString()).toEqual(targetNonce.toString()); + }); +}); diff --git a/packages/transactions/test/transaction.test.js b/packages/transactions/test/transaction.test.js new file mode 100644 index 0000000000..23896ebc5a --- /dev/null +++ b/packages/transactions/test/transaction.test.js @@ -0,0 +1,31 @@ +import BN from 'bn.js'; + +import { actionCreators } from '../lib/esm'; + +const { functionCall } = actionCreators; + +test('functionCall with already serialized args', () => { + const serializedArgs = Buffer.from('{}'); + const action = functionCall('methodName', serializedArgs, new BN(1), new BN(2)); + expect(action).toMatchObject({ + functionCall: { + methodName: 'methodName', + args: serializedArgs, + gas: new BN(1), + deposit: new BN(2) + } + }); +}); + +test('functionCall with non-serialized args', () => { + const serializedArgs = Buffer.from('{}'); + const action = functionCall('methodName', {}, new BN(1), new BN(2)); + expect(action).toMatchObject({ + functionCall: { + methodName: 'methodName', + args: serializedArgs, + gas: new BN(1), + deposit: new BN(2) + } + }); +}); diff --git a/packages/transactions/tsconfig.cjs.json b/packages/transactions/tsconfig.cjs.json new file mode 100644 index 0000000000..b055ecfb97 --- /dev/null +++ b/packages/transactions/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/node.json", + "compilerOptions": { + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/transactions/tsconfig.esm.json b/packages/transactions/tsconfig.esm.json new file mode 100644 index 0000000000..5f3d7bd793 --- /dev/null +++ b/packages/transactions/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/tsconfig/base.json b/packages/tsconfig/base.json new file mode 100644 index 0000000000..f357c91191 --- /dev/null +++ b/packages/tsconfig/base.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "module": "commonjs", + "target": "es2015", + "moduleResolution": "node", + "alwaysStrict": true, + "declaration": true, + "preserveSymlinks": true, + "preserveWatchOutput": true, + "pretty": false, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": false, + "noImplicitReturns": true, + "noUnusedLocals": true, + "experimentalDecorators": true, + "resolveJsonModule": true + } +} diff --git a/packages/tsconfig/browser.esm.json b/packages/tsconfig/browser.esm.json new file mode 100644 index 0000000000..57c3b28db0 --- /dev/null +++ b/packages/tsconfig/browser.esm.json @@ -0,0 +1,11 @@ +{ + "extends": "./browser.json", + "compilerOptions": { + "lib": [ + "es2015", + "esnext", + "dom" + ], + "module": "es2022" + } +} diff --git a/packages/tsconfig/browser.json b/packages/tsconfig/browser.json new file mode 100644 index 0000000000..0fd297656a --- /dev/null +++ b/packages/tsconfig/browser.json @@ -0,0 +1,10 @@ +{ + "extends": "./base.json", + "compilerOptions": { + "lib": [ + "es2015", + "esnext", + "dom" + ] + } +} diff --git a/packages/tsconfig/esm.json b/packages/tsconfig/esm.json new file mode 100644 index 0000000000..14a8c0bc41 --- /dev/null +++ b/packages/tsconfig/esm.json @@ -0,0 +1,9 @@ +{ + "extends": "./base.json", + "compilerOptions": { + "lib": [ + "esnext" + ], + "module": "es2022" + } +} diff --git a/packages/tsconfig/node.json b/packages/tsconfig/node.json new file mode 100644 index 0000000000..6da7ec9a18 --- /dev/null +++ b/packages/tsconfig/node.json @@ -0,0 +1,9 @@ +{ + "extends": "./base.json", + "compilerOptions": { + "lib": [ + "es2015", + "esnext" + ] + } +} diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json new file mode 100644 index 0000000000..0d32af3003 --- /dev/null +++ b/packages/tsconfig/package.json @@ -0,0 +1,5 @@ +{ + "name": "tsconfig", + "version": "1.0.0", + "private": true +} diff --git a/packages/types/README.md b/packages/types/README.md new file mode 100644 index 0000000000..33ccb5eb71 --- /dev/null +++ b/packages/types/README.md @@ -0,0 +1,8 @@ +# @near-js/types + +A collection of commonly-used classes and types. + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000000..8fe722d7bc --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,34 @@ +{ + "name": "@near-js/types", + "version": "0.0.1", + "description": "TypeScript types for working with the Near JS API", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "bn.js": "5.2.1" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/types/src/assignable.ts b/packages/types/src/assignable.ts new file mode 100644 index 0000000000..f73132943a --- /dev/null +++ b/packages/types/src/assignable.ts @@ -0,0 +1,7 @@ +export abstract class Assignable { + constructor(properties: any) { + Object.keys(properties).map((key: any) => { + (this as any)[key] = properties[key]; + }); + } +} diff --git a/packages/types/src/errors.ts b/packages/types/src/errors.ts new file mode 100644 index 0000000000..822448c557 --- /dev/null +++ b/packages/types/src/errors.ts @@ -0,0 +1,28 @@ +export class PositionalArgsError extends Error { + constructor() { + super('Contract method calls expect named arguments wrapped in object, e.g. { argName1: argValue1, argName2: argValue2 }'); + } +} + +export class ArgumentTypeError extends Error { + constructor(argName: string, argType: string, argValue: any) { + super(`Expected ${argType} for '${argName}' argument, but got '${JSON.stringify(argValue)}'`); + } +} + +export class TypedError extends Error { + type: string; + context?: ErrorContext; + constructor(message?: string, type?: string, context?: ErrorContext) { + super(message); + this.type = type || 'UntypedError'; + this.context = context; + } +} + +export class ErrorContext { + transactionHash?: string; + constructor(transactionHash?: string) { + this.transactionHash = transactionHash; + } +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000000..c3bcd521f9 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,3 @@ +export * from './assignable.js'; +export * from './errors.js'; +export * from './provider/index.js'; diff --git a/packages/types/src/provider/index.ts b/packages/types/src/provider/index.ts new file mode 100644 index 0000000000..65c322b386 --- /dev/null +++ b/packages/types/src/provider/index.ts @@ -0,0 +1,72 @@ +export { + IdType, + LightClientBlockLiteView, + LightClientProof, + LightClientProofRequest, +} from './light_client.js'; +export { + AccessKeyWithPublicKey, + BlockHash, + BlockChange, + BlockChangeResult, + BlockHeader, + BlockHeaderInnerLiteView, + BlockHeight, + BlockId, + BlockReference, + BlockResult, + BlockShardId, + ChangeResult, + Chunk, + ChunkHash, + ChunkHeader, + ChunkId, + ChunkResult, + Finality, + GasPrice, + MerkleNode, + MerklePath, + NearProtocolConfig, + NearProtocolRuntimeConfig, + NodeStatusResult, + ShardId, + SyncInfo, + TotalWeight, + Transaction as ProviderTransaction, +} from './protocol.js'; +export { + CallFunctionRequest, + RpcQueryRequest, + ViewAccessKeyListRequest, + ViewAccessKeyRequest, + ViewAccountRequest, + ViewCodeRequest, + ViewStateRequest, +} from './request.js'; +export { + AccessKeyInfoView, + AccessKeyList, + AccessKeyView, + AccessKeyViewRaw, + AccountView, + CodeResult, + ContractCodeView, + ExecutionError, + ExecutionOutcome, + ExecutionOutcomeWithId, + ExecutionOutcomeWithIdView, + ExecutionStatus, + ExecutionStatusBasic, + FinalExecutionOutcome, + FinalExecutionStatus, + FinalExecutionStatusBasic, + FunctionCallPermissionView, + QueryResponseKind, + ViewStateResult, +} from './response.js'; +export { + CurrentEpochValidatorInfo, + EpochValidatorInfo, + NextEpochValidatorInfo, + ValidatorStakeView, +} from './validator.js'; diff --git a/packages/types/src/provider/light_client.ts b/packages/types/src/provider/light_client.ts new file mode 100644 index 0000000000..257ffb950d --- /dev/null +++ b/packages/types/src/provider/light_client.ts @@ -0,0 +1,34 @@ +/** + * NEAR RPC API request types and responses + * @module + */ + +import { BlockHeaderInnerLiteView, MerklePath } from './protocol.js'; +import { ExecutionOutcomeWithIdView } from './response.js'; + +export interface LightClientBlockLiteView { + prev_block_hash: string; + inner_rest_hash: string; + inner_lite: BlockHeaderInnerLiteView; +} + +export interface LightClientProof { + outcome_proof: ExecutionOutcomeWithIdView; + outcome_root_proof: MerklePath; + block_header_lite: LightClientBlockLiteView; + block_proof: MerklePath; +} + +export enum IdType { + Transaction = 'transaction', + Receipt = 'receipt', +} + +export interface LightClientProofRequest { + type: IdType; + light_client_head: string; + transaction_hash?: string; + sender_id?: string; + receipt_id?: string; + receiver_id?: string; +} diff --git a/packages/types/src/provider/protocol.ts b/packages/types/src/provider/protocol.ts new file mode 100644 index 0000000000..8be6a6493f --- /dev/null +++ b/packages/types/src/provider/protocol.ts @@ -0,0 +1,191 @@ +/** + * NEAR RPC API request types and responses + * @module + */ + +import BN from 'bn.js'; + +export interface SyncInfo { + latest_block_hash: string; + latest_block_height: number; + latest_block_time: string; + latest_state_root: string; + syncing: boolean; +} + +interface Version { + version: string; + build: string; +} + +export interface NodeStatusResult { + chain_id: string; + rpc_addr: string; + sync_info: SyncInfo; + validators: string[]; + version: Version; +} + +export type BlockHash = string; +export type BlockHeight = number; +export type BlockId = BlockHash | BlockHeight; + +export type Finality = 'optimistic' | 'near-final' | 'final' + +export type BlockReference = { blockId: BlockId } | { finality: Finality } | { sync_checkpoint: 'genesis' | 'earliest_available' } + +export interface TotalWeight { + num: number; +} + +export interface BlockHeader { + height: number; + epoch_id: string; + next_epoch_id: string; + hash: string; + prev_hash: string; + prev_state_root: string; + chunk_receipts_root: string; + chunk_headers_root: string; + chunk_tx_root: string; + outcome_root: string; + chunks_included: number; + challenges_root: string; + timestamp: number; + timestamp_nanosec: string; + random_value: string; + validator_proposals: any[]; + chunk_mask: boolean[]; + gas_price: string; + rent_paid: string; + validator_reward: string; + total_supply: string; + challenges_result: any[]; + last_final_block: string; + last_ds_final_block: string; + next_bp_hash: string; + block_merkle_root: string; + approvals: string[]; + signature: string; + latest_protocol_version: number; +} + +export type ChunkHash = string; +export type ShardId = number; +export type BlockShardId = [BlockId, ShardId]; +export type ChunkId = ChunkHash | BlockShardId; + +export interface ChunkHeader { + balance_burnt: string; + chunk_hash: ChunkHash; + encoded_length: number; + encoded_merkle_root: string; + gas_limit: number; + gas_used: number; + height_created: number; + height_included: number; + outcome_root: string; + outgoing_receipts_root: string; + prev_block_hash: string; + prev_state_root: string; + rent_paid: string; + shard_id: number; + signature: string; + tx_root: string; + validator_proposals: any[]; + validator_reward: string; +} + +export interface ChunkResult { + author: string; + header: ChunkHeader; + receipts: any[]; + transactions: Transaction[]; +} + +export interface Chunk { + chunk_hash: string; + prev_block_hash: string; + outcome_root: string; + prev_state_root: string; + encoded_merkle_root: string; + encoded_length: number; + height_created: number; + height_included: number; + shard_id: number; + gas_used: number; + gas_limit: number; + rent_paid: string; + validator_reward: string; + balance_burnt: string; + outgoing_receipts_root: string; + tx_root: string; + validator_proposals: any[]; + signature: string; +} + +export interface Transaction { + actions: Array; + hash: string; + nonce: BN; + public_key: string; + receiver_id: string; + signature: string; + signer_id: string; +} + +export interface BlockResult { + author: string; + header: BlockHeader; + chunks: Chunk[]; +} + +export interface BlockChange { + type: string; + account_id: string; +} + +export interface BlockChangeResult { + block_hash: string; + changes: BlockChange[]; +} + +export interface ChangeResult { + block_hash: string; + changes: any[]; +} + +export interface NearProtocolConfig { + runtime_config: NearProtocolRuntimeConfig; +} + +export interface NearProtocolRuntimeConfig { + storage_amount_per_byte: string; +} + +export interface MerkleNode { + hash: string; + direction: string; +} + +export type MerklePath = MerkleNode[]; + +export interface BlockHeaderInnerLiteView { + height: number; + epoch_id: string; + next_epoch_id: string; + prev_state_root: string; + outcome_root: string; + timestamp: number; + next_bp_hash: string; + block_merkle_root: string; +} + +export interface GasPrice { + gas_price: string; +} + +export interface AccessKeyWithPublicKey { + account_id: string; + public_key: string; +} diff --git a/packages/types/src/provider/request.ts b/packages/types/src/provider/request.ts new file mode 100644 index 0000000000..5411403144 --- /dev/null +++ b/packages/types/src/provider/request.ts @@ -0,0 +1,48 @@ +/** + * NEAR RPC API request types and responses + * @module + */ + +import { BlockReference } from './protocol.js'; + +export interface ViewAccountRequest { + request_type: 'view_account'; + account_id: string; +} + +export interface ViewCodeRequest { + request_type: 'view_code'; + account_id: string; +} + +export interface ViewStateRequest { + request_type: 'view_state'; + account_id: string; + prefix_base64: string; +} + +export interface ViewAccessKeyRequest { + request_type: 'view_access_key'; + account_id: string; + public_key: string; +} + +export interface ViewAccessKeyListRequest { + request_type: 'view_access_key_list'; + account_id: string; +} + +export interface CallFunctionRequest { + request_type: 'call_function'; + account_id: string; + method_name: string; + args_base64: string; +} + +export type RpcQueryRequest = (ViewAccountRequest | + ViewCodeRequest | + ViewStateRequest | + ViewAccountRequest | + ViewAccessKeyRequest | + ViewAccessKeyListRequest | + CallFunctionRequest) & BlockReference diff --git a/packages/types/src/provider/response.ts b/packages/types/src/provider/response.ts new file mode 100644 index 0000000000..f63a5c418f --- /dev/null +++ b/packages/types/src/provider/response.ts @@ -0,0 +1,127 @@ +/** + * NEAR RPC API request types and responses + * @module + */ + +import BN from 'bn.js'; + +import { BlockHash, BlockHeight, MerklePath } from './protocol.js'; + +export enum ExecutionStatusBasic { + Unknown = 'Unknown', + Pending = 'Pending', + Failure = 'Failure', +} + +export interface ExecutionStatus { + SuccessValue?: string; + SuccessReceiptId?: string; + Failure?: ExecutionError; +} + +export enum FinalExecutionStatusBasic { + NotStarted = 'NotStarted', + Started = 'Started', + Failure = 'Failure', +} + +export interface ExecutionError { + error_message: string; + error_type: string; +} + +export interface FinalExecutionStatus { + SuccessValue?: string; + Failure?: ExecutionError; +} + +export interface ExecutionOutcomeWithId { + id: string; + outcome: ExecutionOutcome; +} + +export interface ExecutionOutcome { + logs: string[]; + receipt_ids: string[]; + gas_burnt: number; + status: ExecutionStatus | ExecutionStatusBasic; +} + +export interface ExecutionOutcomeWithIdView { + proof: MerklePath; + block_hash: string; + id: string; + outcome: ExecutionOutcome; +} + +export interface FinalExecutionOutcome { + status: FinalExecutionStatus | FinalExecutionStatusBasic; + transaction: any; + transaction_outcome: ExecutionOutcomeWithId; + receipts_outcome: ExecutionOutcomeWithId[]; +} + +export interface QueryResponseKind { + block_height: BlockHeight; + block_hash: BlockHash; +} + +export interface AccountView extends QueryResponseKind { + amount: string; + locked: string; + code_hash: string; + storage_usage: number; + storage_paid_at: BlockHeight; +} + +interface StateItem { + key: string; + value: string; + proof: string[]; +} + +export interface ViewStateResult extends QueryResponseKind { + values: StateItem[]; + proof: string[]; +} + +export interface CodeResult extends QueryResponseKind { + result: number[]; + logs: string[]; +} + +export interface ContractCodeView extends QueryResponseKind { + code_base64: string; + hash: string; +} + +export interface FunctionCallPermissionView { + FunctionCall: { + allowance: string; + receiver_id: string; + method_names: string[]; + }; +} + +export interface AccessKeyViewRaw extends QueryResponseKind { + nonce: number; + permission: 'FullAccess' | FunctionCallPermissionView; +} +export interface AccessKeyView extends QueryResponseKind { + nonce: BN; + permission: 'FullAccess' | FunctionCallPermissionView; +} + +export interface AccessKeyInfoView { + public_key: string; + access_key: AccessKeyView; +} + +export interface AccessKeyList extends QueryResponseKind { + keys: AccessKeyInfoView[]; +} + +export interface AccessKeyInfoView { + public_key: string; + access_key: AccessKeyView; +} diff --git a/packages/types/src/provider/validator.ts b/packages/types/src/provider/validator.ts new file mode 100644 index 0000000000..09b269e526 --- /dev/null +++ b/packages/types/src/provider/validator.ts @@ -0,0 +1,44 @@ +/** + * NEAR RPC API request types and responses + * @module + */ + +export interface CurrentEpochValidatorInfo { + account_id: string; + public_key: string; + is_slashed: boolean; + stake: string; + shards: number[]; + num_produced_blocks: number; + num_expected_blocks: number; +} + +export interface NextEpochValidatorInfo { + account_id: string; + public_key: string; + stake: string; + shards: number[]; +} + +export interface ValidatorStakeView { + account_id: string; + public_key: string; + stake: string; +} + +export interface EpochValidatorInfo { + // Validators for the current epoch. + next_validators: NextEpochValidatorInfo[]; + // Validators for the next epoch. + current_validators: CurrentEpochValidatorInfo[]; + // Fishermen for the current epoch. + next_fisherman: ValidatorStakeView[]; + // Fishermen for the next epoch. + current_fisherman: ValidatorStakeView[]; + // Proposals in the current epoch. + current_proposals: ValidatorStakeView[]; + // Kickout in the previous epoch. + prev_epoch_kickout: ValidatorStakeView[]; + // Epoch start height. + epoch_start_height: number; +} diff --git a/packages/types/tsconfig.cjs.json b/packages/types/tsconfig.cjs.json new file mode 100644 index 0000000000..b055ecfb97 --- /dev/null +++ b/packages/types/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/node.json", + "compilerOptions": { + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/types/tsconfig.esm.json b/packages/types/tsconfig.esm.json new file mode 100644 index 0000000000..5f3d7bd793 --- /dev/null +++ b/packages/types/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/utils/README.md b/packages/utils/README.md new file mode 100644 index 0000000000..6da1b4d45c --- /dev/null +++ b/packages/utils/README.md @@ -0,0 +1,15 @@ +# @near-js/utils + +A collection of commonly-used functions and constants. + +## Modules + +- [Format](src/format.ts) NEAR denomination formatting functions +- [Logging](src/logging.ts) functions for printing formatted RPC output +- [Provider](src/provider.ts) functions for parsing RPC output +- [Validators](src/validators.ts) functions for querying blockchain validators + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/near-api-js/fetch_error_schema.js b/packages/utils/fetch_error_schema.js similarity index 80% rename from packages/near-api-js/fetch_error_schema.js rename to packages/utils/fetch_error_schema.js index cdaf29b60d..0882458e90 100644 --- a/packages/near-api-js/fetch_error_schema.js +++ b/packages/utils/fetch_error_schema.js @@ -3,8 +3,7 @@ const fs = require('fs'); const ERROR_SCHEMA_URL = 'https://raw.githubusercontent.com/nearprotocol/nearcore/4c1149974ccf899dbcb2253a3e27cbab86dc47be/chain/jsonrpc/res/rpc_errors_schema.json'; -const TARGET_DIR = process.argv[2] || process.cwd() + '/src/generated'; -const TARGET_SCHEMA_FILE_PATH = TARGET_DIR + '/rpc_error_schema.json'; +const TARGET_SCHEMA_FILE_PATH = `${process.argv[2] || process.cwd()}/src/errors/rpc_error_schema.json'`; https .get(ERROR_SCHEMA_URL, resp => { diff --git a/packages/utils/jest.config.js b/packages/utils/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/utils/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000000..b0e515a683 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,40 @@ +{ + "name": "@near-js/utils", + "version": "0.0.1", + "description": "Common methods and constants for the NEAR API JavaScript client", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "lint:js": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc", + "lint:js:fix": "eslint -c ../../.eslintrc.js.yml test/**/*.js --no-eslintrc --fix", + "lint:ts": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc", + "lint:ts:fix": "eslint -c ../../.eslintrc.ts.yml src/**/*.ts --no-eslintrc --fix", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/types": "workspace:*", + "bn.js": "5.2.1", + "depd": "^2.0.0", + "mustache": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts new file mode 100644 index 0000000000..63a7c421ce --- /dev/null +++ b/packages/utils/src/constants.ts @@ -0,0 +1,9 @@ +import BN from 'bn.js'; + +// Default amount of gas to be sent with the function calls. Used to pay for the fees +// incurred while running the contract execution. The unused amount will be refunded back to +// the originator. +// Due to protocol changes that charge upfront for the maximum possible gas price inflation due to +// full blocks, the price of max_prepaid_gas is decreased to `300 * 10**12`. +// For discussion see https://github.com/nearprotocol/NEPs/issues/67 +export const DEFAULT_FUNCTION_CALL_GAS = new BN('30000000000000'); \ No newline at end of file diff --git a/packages/utils/src/errors/error_messages.ts b/packages/utils/src/errors/error_messages.ts new file mode 100644 index 0000000000..b341eb4ec4 --- /dev/null +++ b/packages/utils/src/errors/error_messages.ts @@ -0,0 +1,66 @@ +export default { + 'GasLimitExceeded': 'Exceeded the maximum amount of gas allowed to burn per contract', + 'MethodEmptyName': 'Method name is empty', + 'WasmerCompileError': 'Wasmer compilation error: {{msg}}', + 'GuestPanic': 'Smart contract panicked: {{panic_msg}}', + 'Memory': 'Error creating Wasm memory', + 'GasExceeded': 'Exceeded the prepaid gas', + 'MethodUTF8Error': 'Method name is not valid UTF8 string', + 'BadUTF16': 'String encoding is bad UTF-16 sequence', + 'WasmTrap': 'WebAssembly trap: {{msg}}', + 'GasInstrumentation': 'Gas instrumentation failed or contract has denied instructions.', + 'InvalidPromiseIndex': '{{promise_idx}} does not correspond to existing promises', + 'InvalidPromiseResultIndex': 'Accessed invalid promise result index: {{result_idx}}', + 'Deserialization': 'Error happened while deserializing the module', + 'MethodNotFound': 'Contract method is not found', + 'InvalidRegisterId': 'Accessed invalid register id: {{register_id}}', + 'InvalidReceiptIndex': 'VM Logic returned an invalid receipt index: {{receipt_index}}', + 'EmptyMethodName': 'Method name is empty in contract call', + 'CannotReturnJointPromise': 'Returning joint promise is currently prohibited', + 'StackHeightInstrumentation': 'Stack instrumentation failed', + 'CodeDoesNotExist': 'Cannot find contract code for account {{account_id}}', + 'MethodInvalidSignature': 'Invalid method signature', + 'IntegerOverflow': 'Integer overflow happened during contract execution', + 'MemoryAccessViolation': 'MemoryAccessViolation', + 'InvalidIteratorIndex': 'Iterator index {{iterator_index}} does not exist', + 'IteratorWasInvalidated': 'Iterator {{iterator_index}} was invalidated after its creation by performing a mutable operation on trie', + 'InvalidAccountId': 'VM Logic returned an invalid account id', + 'Serialization': 'Error happened while serializing the module', + 'CannotAppendActionToJointPromise': 'Actions can only be appended to non-joint promise.', + 'InternalMemoryDeclared': 'Internal memory declaration has been found in the module', + 'Instantiate': 'Error happened during instantiation', + 'ProhibitedInView': '{{method_name}} is not allowed in view calls', + 'InvalidMethodName': 'VM Logic returned an invalid method name', + 'BadUTF8': 'String encoding is bad UTF-8 sequence', + 'BalanceExceeded': 'Exceeded the account balance', + 'LinkError': 'Wasm contract link error: {{msg}}', + 'InvalidPublicKey': 'VM Logic provided an invalid public key', + 'ActorNoPermission': 'Actor {{actor_id}} doesn\'t have permission to account {{account_id}} to complete the action', + 'LackBalanceForState': 'The account {{account_id}} wouldn\'t have enough balance to cover storage, required to have {{amount}} yoctoNEAR more', + 'ReceiverMismatch': 'Wrong AccessKey used for transaction: transaction is sent to receiver_id={{tx_receiver}}, but is signed with function call access key that restricted to only use with receiver_id={{ak_receiver}}. Either change receiver_id in your transaction or switch to use a FullAccessKey.', + 'CostOverflow': 'Transaction gas or balance cost is too high', + 'InvalidSignature': 'Transaction is not signed with the given public key', + 'AccessKeyNotFound': 'Signer "{{account_id}}" doesn\'t have access key with the given public_key {{public_key}}', + 'NotEnoughBalance': 'Sender {{signer_id}} does not have enough balance {{#formatNear}}{{balance}}{{/formatNear}} for operation costing {{#formatNear}}{{cost}}{{/formatNear}}', + 'NotEnoughAllowance': 'Access Key {account_id}:{public_key} does not have enough balance {{#formatNear}}{{allowance}}{{/formatNear}} for transaction costing {{#formatNear}}{{cost}}{{/formatNear}}', + 'Expired': 'Transaction has expired', + 'DeleteAccountStaking': 'Account {{account_id}} is staking and can not be deleted', + 'SignerDoesNotExist': 'Signer {{signer_id}} does not exist', + 'TriesToStake': 'Account {{account_id}} tried to stake {{#formatNear}}{{stake}}{{/formatNear}}, but has staked {{#formatNear}}{{locked}}{{/formatNear}} and only has {{#formatNear}}{{balance}}{{/formatNear}}', + 'AddKeyAlreadyExists': 'The public key {{public_key}} is already used for an existing access key', + 'InvalidSigner': 'Invalid signer account ID {{signer_id}} according to requirements', + 'CreateAccountNotAllowed': 'The new account_id {{account_id}} can\'t be created by {{predecessor_id}}', + 'RequiresFullAccess': 'The transaction contains more then one action, but it was signed with an access key which allows transaction to apply only one specific action. To apply more then one actions TX must be signed with a full access key', + 'TriesToUnstake': 'Account {{account_id}} is not yet staked, but tried to unstake', + 'InvalidNonce': 'Transaction nonce {{tx_nonce}} must be larger than nonce of the used access key {{ak_nonce}}', + 'AccountAlreadyExists': 'Can\'t create a new account {{account_id}}, because it already exists', + 'InvalidChain': 'Transaction parent block hash doesn\'t belong to the current chain', + 'AccountDoesNotExist': 'Can\'t complete the action because account {{account_id}} doesn\'t exist', + 'MethodNameMismatch': 'Transaction method name {{method_name}} isn\'t allowed by the access key', + 'DeleteAccountHasRent': 'Account {{account_id}} can\'t be deleted. It has {{#formatNear}}{{balance}}{{/formatNear}}, which is enough to cover the rent', + 'DeleteAccountHasEnoughBalance': 'Account {{account_id}} can\'t be deleted. It has {{#formatNear}}{{balance}}{{/formatNear}}, which is enough to cover it\'s storage', + 'InvalidReceiver': 'Invalid receiver account ID {{receiver_id}} according to requirements', + 'DeleteKeyDoesNotExist': 'Account {{account_id}} tries to remove an access key that doesn\'t exist', + 'Timeout': 'Timeout exceeded', + 'Closed': 'Connection closed' +}; diff --git a/packages/utils/src/errors/errors.ts b/packages/utils/src/errors/errors.ts new file mode 100644 index 0000000000..27fdd8c194 --- /dev/null +++ b/packages/utils/src/errors/errors.ts @@ -0,0 +1,5 @@ +export function logWarning(...args: any[]): void { + if (typeof process !== 'undefined' && !process.env['NEAR_NO_LOGS']) { + console.warn(...args); + } +} diff --git a/packages/utils/src/errors/index.ts b/packages/utils/src/errors/index.ts new file mode 100644 index 0000000000..2e0253cdf9 --- /dev/null +++ b/packages/utils/src/errors/index.ts @@ -0,0 +1,8 @@ +export { logWarning } from './errors.js'; +export { + ServerError, + formatError, + getErrorTypeFromErrorMessage, + parseResultError, + parseRpcError, +} from './rpc_errors.js'; diff --git a/packages/utils/src/errors/rpc_error_schema.ts b/packages/utils/src/errors/rpc_error_schema.ts new file mode 100644 index 0000000000..05ceff6d7b --- /dev/null +++ b/packages/utils/src/errors/rpc_error_schema.ts @@ -0,0 +1,869 @@ +export default { + 'schema': { + 'BadUTF16': { + 'name': 'BadUTF16', + 'subtypes': [], + 'props': {} + }, + 'BadUTF8': { + 'name': 'BadUTF8', + 'subtypes': [], + 'props': {} + }, + 'BalanceExceeded': { + 'name': 'BalanceExceeded', + 'subtypes': [], + 'props': {} + }, + 'BreakpointTrap': { + 'name': 'BreakpointTrap', + 'subtypes': [], + 'props': {} + }, + 'CacheError': { + 'name': 'CacheError', + 'subtypes': [ + 'ReadError', + 'WriteError', + 'DeserializationError', + 'SerializationError' + ], + 'props': {} + }, + 'CallIndirectOOB': { + 'name': 'CallIndirectOOB', + 'subtypes': [], + 'props': {} + }, + 'CannotAppendActionToJointPromise': { + 'name': 'CannotAppendActionToJointPromise', + 'subtypes': [], + 'props': {} + }, + 'CannotReturnJointPromise': { + 'name': 'CannotReturnJointPromise', + 'subtypes': [], + 'props': {} + }, + 'CodeDoesNotExist': { + 'name': 'CodeDoesNotExist', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'CompilationError': { + 'name': 'CompilationError', + 'subtypes': [ + 'CodeDoesNotExist', + 'PrepareError', + 'WasmerCompileError' + ], + 'props': {} + }, + 'ContractSizeExceeded': { + 'name': 'ContractSizeExceeded', + 'subtypes': [], + 'props': { + 'limit': '', + 'size': '' + } + }, + 'Deprecated': { + 'name': 'Deprecated', + 'subtypes': [], + 'props': { + 'method_name': '' + } + }, + 'Deserialization': { + 'name': 'Deserialization', + 'subtypes': [], + 'props': {} + }, + 'DeserializationError': { + 'name': 'DeserializationError', + 'subtypes': [], + 'props': {} + }, + 'EmptyMethodName': { + 'name': 'EmptyMethodName', + 'subtypes': [], + 'props': {} + }, + 'FunctionCallError': { + 'name': 'FunctionCallError', + 'subtypes': [ + 'CompilationError', + 'LinkError', + 'MethodResolveError', + 'WasmTrap', + 'WasmUnknownError', + 'HostError', + 'EvmError' + ], + 'props': {} + }, + 'GasExceeded': { + 'name': 'GasExceeded', + 'subtypes': [], + 'props': {} + }, + 'GasInstrumentation': { + 'name': 'GasInstrumentation', + 'subtypes': [], + 'props': {} + }, + 'GasLimitExceeded': { + 'name': 'GasLimitExceeded', + 'subtypes': [], + 'props': {} + }, + 'GenericTrap': { + 'name': 'GenericTrap', + 'subtypes': [], + 'props': {} + }, + 'GuestPanic': { + 'name': 'GuestPanic', + 'subtypes': [], + 'props': { + 'panic_msg': '' + } + }, + 'HostError': { + 'name': 'HostError', + 'subtypes': [ + 'BadUTF16', + 'BadUTF8', + 'GasExceeded', + 'GasLimitExceeded', + 'BalanceExceeded', + 'EmptyMethodName', + 'GuestPanic', + 'IntegerOverflow', + 'InvalidPromiseIndex', + 'CannotAppendActionToJointPromise', + 'CannotReturnJointPromise', + 'InvalidPromiseResultIndex', + 'InvalidRegisterId', + 'IteratorWasInvalidated', + 'MemoryAccessViolation', + 'InvalidReceiptIndex', + 'InvalidIteratorIndex', + 'InvalidAccountId', + 'InvalidMethodName', + 'InvalidPublicKey', + 'ProhibitedInView', + 'NumberOfLogsExceeded', + 'KeyLengthExceeded', + 'ValueLengthExceeded', + 'TotalLogLengthExceeded', + 'NumberPromisesExceeded', + 'NumberInputDataDependenciesExceeded', + 'ReturnedValueLengthExceeded', + 'ContractSizeExceeded', + 'Deprecated' + ], + 'props': {} + }, + 'IllegalArithmetic': { + 'name': 'IllegalArithmetic', + 'subtypes': [], + 'props': {} + }, + 'IncorrectCallIndirectSignature': { + 'name': 'IncorrectCallIndirectSignature', + 'subtypes': [], + 'props': {} + }, + 'Instantiate': { + 'name': 'Instantiate', + 'subtypes': [], + 'props': {} + }, + 'IntegerOverflow': { + 'name': 'IntegerOverflow', + 'subtypes': [], + 'props': {} + }, + 'InternalMemoryDeclared': { + 'name': 'InternalMemoryDeclared', + 'subtypes': [], + 'props': {} + }, + 'InvalidAccountId': { + 'name': 'InvalidAccountId', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'InvalidIteratorIndex': { + 'name': 'InvalidIteratorIndex', + 'subtypes': [], + 'props': { + 'iterator_index': '' + } + }, + 'InvalidMethodName': { + 'name': 'InvalidMethodName', + 'subtypes': [], + 'props': {} + }, + 'InvalidPromiseIndex': { + 'name': 'InvalidPromiseIndex', + 'subtypes': [], + 'props': { + 'promise_idx': '' + } + }, + 'InvalidPromiseResultIndex': { + 'name': 'InvalidPromiseResultIndex', + 'subtypes': [], + 'props': { + 'result_idx': '' + } + }, + 'InvalidPublicKey': { + 'name': 'InvalidPublicKey', + 'subtypes': [], + 'props': {} + }, + 'InvalidReceiptIndex': { + 'name': 'InvalidReceiptIndex', + 'subtypes': [], + 'props': { + 'receipt_index': '' + } + }, + 'InvalidRegisterId': { + 'name': 'InvalidRegisterId', + 'subtypes': [], + 'props': { + 'register_id': '' + } + }, + 'IteratorWasInvalidated': { + 'name': 'IteratorWasInvalidated', + 'subtypes': [], + 'props': { + 'iterator_index': '' + } + }, + 'KeyLengthExceeded': { + 'name': 'KeyLengthExceeded', + 'subtypes': [], + 'props': { + 'length': '', + 'limit': '' + } + }, + 'LinkError': { + 'name': 'LinkError', + 'subtypes': [], + 'props': { + 'msg': '' + } + }, + 'Memory': { + 'name': 'Memory', + 'subtypes': [], + 'props': {} + }, + 'MemoryAccessViolation': { + 'name': 'MemoryAccessViolation', + 'subtypes': [], + 'props': {} + }, + 'MemoryOutOfBounds': { + 'name': 'MemoryOutOfBounds', + 'subtypes': [], + 'props': {} + }, + 'MethodEmptyName': { + 'name': 'MethodEmptyName', + 'subtypes': [], + 'props': {} + }, + 'MethodInvalidSignature': { + 'name': 'MethodInvalidSignature', + 'subtypes': [], + 'props': {} + }, + 'MethodNotFound': { + 'name': 'MethodNotFound', + 'subtypes': [], + 'props': {} + }, + 'MethodResolveError': { + 'name': 'MethodResolveError', + 'subtypes': [ + 'MethodEmptyName', + 'MethodUTF8Error', + 'MethodNotFound', + 'MethodInvalidSignature' + ], + 'props': {} + }, + 'MethodUTF8Error': { + 'name': 'MethodUTF8Error', + 'subtypes': [], + 'props': {} + }, + 'MisalignedAtomicAccess': { + 'name': 'MisalignedAtomicAccess', + 'subtypes': [], + 'props': {} + }, + 'NumberInputDataDependenciesExceeded': { + 'name': 'NumberInputDataDependenciesExceeded', + 'subtypes': [], + 'props': { + 'limit': '', + 'number_of_input_data_dependencies': '' + } + }, + 'NumberOfLogsExceeded': { + 'name': 'NumberOfLogsExceeded', + 'subtypes': [], + 'props': { + 'limit': '' + } + }, + 'NumberPromisesExceeded': { + 'name': 'NumberPromisesExceeded', + 'subtypes': [], + 'props': { + 'limit': '', + 'number_of_promises': '' + } + }, + 'PrepareError': { + 'name': 'PrepareError', + 'subtypes': [ + 'Serialization', + 'Deserialization', + 'InternalMemoryDeclared', + 'GasInstrumentation', + 'StackHeightInstrumentation', + 'Instantiate', + 'Memory' + ], + 'props': {} + }, + 'ProhibitedInView': { + 'name': 'ProhibitedInView', + 'subtypes': [], + 'props': { + 'method_name': '' + } + }, + 'ReadError': { + 'name': 'ReadError', + 'subtypes': [], + 'props': {} + }, + 'ReturnedValueLengthExceeded': { + 'name': 'ReturnedValueLengthExceeded', + 'subtypes': [], + 'props': { + 'length': '', + 'limit': '' + } + }, + 'Serialization': { + 'name': 'Serialization', + 'subtypes': [], + 'props': {} + }, + 'SerializationError': { + 'name': 'SerializationError', + 'subtypes': [], + 'props': { + 'hash': '' + } + }, + 'StackHeightInstrumentation': { + 'name': 'StackHeightInstrumentation', + 'subtypes': [], + 'props': {} + }, + 'StackOverflow': { + 'name': 'StackOverflow', + 'subtypes': [], + 'props': {} + }, + 'TotalLogLengthExceeded': { + 'name': 'TotalLogLengthExceeded', + 'subtypes': [], + 'props': { + 'length': '', + 'limit': '' + } + }, + 'Unreachable': { + 'name': 'Unreachable', + 'subtypes': [], + 'props': {} + }, + 'ValueLengthExceeded': { + 'name': 'ValueLengthExceeded', + 'subtypes': [], + 'props': { + 'length': '', + 'limit': '' + } + }, + 'WasmTrap': { + 'name': 'WasmTrap', + 'subtypes': [ + 'Unreachable', + 'IncorrectCallIndirectSignature', + 'MemoryOutOfBounds', + 'CallIndirectOOB', + 'IllegalArithmetic', + 'MisalignedAtomicAccess', + 'BreakpointTrap', + 'StackOverflow', + 'GenericTrap' + ], + 'props': {} + }, + 'WasmUnknownError': { + 'name': 'WasmUnknownError', + 'subtypes': [], + 'props': {} + }, + 'WasmerCompileError': { + 'name': 'WasmerCompileError', + 'subtypes': [], + 'props': { + 'msg': '' + } + }, + 'WriteError': { + 'name': 'WriteError', + 'subtypes': [], + 'props': {} + }, + 'AccessKeyNotFound': { + 'name': 'AccessKeyNotFound', + 'subtypes': [], + 'props': { + 'account_id': '', + 'public_key': '' + } + }, + 'AccountAlreadyExists': { + 'name': 'AccountAlreadyExists', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'AccountDoesNotExist': { + 'name': 'AccountDoesNotExist', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'ActionError': { + 'name': 'ActionError', + 'subtypes': [ + 'AccountAlreadyExists', + 'AccountDoesNotExist', + 'CreateAccountOnlyByRegistrar', + 'CreateAccountNotAllowed', + 'ActorNoPermission', + 'DeleteKeyDoesNotExist', + 'AddKeyAlreadyExists', + 'DeleteAccountStaking', + 'LackBalanceForState', + 'TriesToUnstake', + 'TriesToStake', + 'InsufficientStake', + 'FunctionCallError', + 'NewReceiptValidationError', + 'OnlyImplicitAccountCreationAllowed' + ], + 'props': { + 'index': '' + } + }, + 'ActionsValidationError': { + 'name': 'ActionsValidationError', + 'subtypes': [ + 'DeleteActionMustBeFinal', + 'TotalPrepaidGasExceeded', + 'TotalNumberOfActionsExceeded', + 'AddKeyMethodNamesNumberOfBytesExceeded', + 'AddKeyMethodNameLengthExceeded', + 'IntegerOverflow', + 'InvalidAccountId', + 'ContractSizeExceeded', + 'FunctionCallMethodNameLengthExceeded', + 'FunctionCallArgumentsLengthExceeded', + 'UnsuitableStakingKey', + 'FunctionCallZeroAttachedGas' + ], + 'props': {} + }, + 'ActorNoPermission': { + 'name': 'ActorNoPermission', + 'subtypes': [], + 'props': { + 'account_id': '', + 'actor_id': '' + } + }, + 'AddKeyAlreadyExists': { + 'name': 'AddKeyAlreadyExists', + 'subtypes': [], + 'props': { + 'account_id': '', + 'public_key': '' + } + }, + 'AddKeyMethodNameLengthExceeded': { + 'name': 'AddKeyMethodNameLengthExceeded', + 'subtypes': [], + 'props': { + 'length': '', + 'limit': '' + } + }, + 'AddKeyMethodNamesNumberOfBytesExceeded': { + 'name': 'AddKeyMethodNamesNumberOfBytesExceeded', + 'subtypes': [], + 'props': { + 'limit': '', + 'total_number_of_bytes': '' + } + }, + 'BalanceMismatchError': { + 'name': 'BalanceMismatchError', + 'subtypes': [], + 'props': { + 'final_accounts_balance': '', + 'final_postponed_receipts_balance': '', + 'incoming_receipts_balance': '', + 'incoming_validator_rewards': '', + 'initial_accounts_balance': '', + 'initial_postponed_receipts_balance': '', + 'new_delayed_receipts_balance': '', + 'other_burnt_amount': '', + 'outgoing_receipts_balance': '', + 'processed_delayed_receipts_balance': '', + 'slashed_burnt_amount': '', + 'tx_burnt_amount': '' + } + }, + 'CostOverflow': { + 'name': 'CostOverflow', + 'subtypes': [], + 'props': {} + }, + 'CreateAccountNotAllowed': { + 'name': 'CreateAccountNotAllowed', + 'subtypes': [], + 'props': { + 'account_id': '', + 'predecessor_id': '' + } + }, + 'CreateAccountOnlyByRegistrar': { + 'name': 'CreateAccountOnlyByRegistrar', + 'subtypes': [], + 'props': { + 'account_id': '', + 'predecessor_id': '', + 'registrar_account_id': '' + } + }, + 'DeleteAccountStaking': { + 'name': 'DeleteAccountStaking', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'DeleteActionMustBeFinal': { + 'name': 'DeleteActionMustBeFinal', + 'subtypes': [], + 'props': {} + }, + 'DeleteKeyDoesNotExist': { + 'name': 'DeleteKeyDoesNotExist', + 'subtypes': [], + 'props': { + 'account_id': '', + 'public_key': '' + } + }, + 'DepositWithFunctionCall': { + 'name': 'DepositWithFunctionCall', + 'subtypes': [], + 'props': {} + }, + 'Expired': { + 'name': 'Expired', + 'subtypes': [], + 'props': {} + }, + 'FunctionCallArgumentsLengthExceeded': { + 'name': 'FunctionCallArgumentsLengthExceeded', + 'subtypes': [], + 'props': { + 'length': '', + 'limit': '' + } + }, + 'FunctionCallMethodNameLengthExceeded': { + 'name': 'FunctionCallMethodNameLengthExceeded', + 'subtypes': [], + 'props': { + 'length': '', + 'limit': '' + } + }, + 'FunctionCallZeroAttachedGas': { + 'name': 'FunctionCallZeroAttachedGas', + 'subtypes': [], + 'props': {} + }, + 'InsufficientStake': { + 'name': 'InsufficientStake', + 'subtypes': [], + 'props': { + 'account_id': '', + 'minimum_stake': '', + 'stake': '' + } + }, + 'InvalidAccessKeyError': { + 'name': 'InvalidAccessKeyError', + 'subtypes': [ + 'AccessKeyNotFound', + 'ReceiverMismatch', + 'MethodNameMismatch', + 'RequiresFullAccess', + 'NotEnoughAllowance', + 'DepositWithFunctionCall' + ], + 'props': {} + }, + 'InvalidChain': { + 'name': 'InvalidChain', + 'subtypes': [], + 'props': {} + }, + 'InvalidDataReceiverId': { + 'name': 'InvalidDataReceiverId', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'InvalidNonce': { + 'name': 'InvalidNonce', + 'subtypes': [], + 'props': { + 'ak_nonce': '', + 'tx_nonce': '' + } + }, + 'InvalidPredecessorId': { + 'name': 'InvalidPredecessorId', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'InvalidReceiverId': { + 'name': 'InvalidReceiverId', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'InvalidSignature': { + 'name': 'InvalidSignature', + 'subtypes': [], + 'props': {} + }, + 'InvalidSignerId': { + 'name': 'InvalidSignerId', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'InvalidTxError': { + 'name': 'InvalidTxError', + 'subtypes': [ + 'InvalidAccessKeyError', + 'InvalidSignerId', + 'SignerDoesNotExist', + 'InvalidNonce', + 'InvalidReceiverId', + 'InvalidSignature', + 'NotEnoughBalance', + 'LackBalanceForState', + 'CostOverflow', + 'InvalidChain', + 'Expired', + 'ActionsValidation' + ], + 'props': {} + }, + 'LackBalanceForState': { + 'name': 'LackBalanceForState', + 'subtypes': [], + 'props': { + 'account_id': '', + 'amount': '' + } + }, + 'MethodNameMismatch': { + 'name': 'MethodNameMismatch', + 'subtypes': [], + 'props': { + 'method_name': '' + } + }, + 'NotEnoughAllowance': { + 'name': 'NotEnoughAllowance', + 'subtypes': [], + 'props': { + 'account_id': '', + 'allowance': '', + 'cost': '', + 'public_key': '' + } + }, + 'NotEnoughBalance': { + 'name': 'NotEnoughBalance', + 'subtypes': [], + 'props': { + 'balance': '', + 'cost': '', + 'signer_id': '' + } + }, + 'OnlyImplicitAccountCreationAllowed': { + 'name': 'OnlyImplicitAccountCreationAllowed', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'ReceiptValidationError': { + 'name': 'ReceiptValidationError', + 'subtypes': [ + 'InvalidPredecessorId', + 'InvalidReceiverId', + 'InvalidSignerId', + 'InvalidDataReceiverId', + 'ReturnedValueLengthExceeded', + 'NumberInputDataDependenciesExceeded', + 'ActionsValidation' + ], + 'props': {} + }, + 'ReceiverMismatch': { + 'name': 'ReceiverMismatch', + 'subtypes': [], + 'props': { + 'ak_receiver': '', + 'tx_receiver': '' + } + }, + 'RequiresFullAccess': { + 'name': 'RequiresFullAccess', + 'subtypes': [], + 'props': {} + }, + 'SignerDoesNotExist': { + 'name': 'SignerDoesNotExist', + 'subtypes': [], + 'props': { + 'signer_id': '' + } + }, + 'TotalNumberOfActionsExceeded': { + 'name': 'TotalNumberOfActionsExceeded', + 'subtypes': [], + 'props': { + 'limit': '', + 'total_number_of_actions': '' + } + }, + 'TotalPrepaidGasExceeded': { + 'name': 'TotalPrepaidGasExceeded', + 'subtypes': [], + 'props': { + 'limit': '', + 'total_prepaid_gas': '' + } + }, + 'TriesToStake': { + 'name': 'TriesToStake', + 'subtypes': [], + 'props': { + 'account_id': '', + 'balance': '', + 'locked': '', + 'stake': '' + } + }, + 'TriesToUnstake': { + 'name': 'TriesToUnstake', + 'subtypes': [], + 'props': { + 'account_id': '' + } + }, + 'TxExecutionError': { + 'name': 'TxExecutionError', + 'subtypes': [ + 'ActionError', + 'InvalidTxError' + ], + 'props': {} + }, + 'UnsuitableStakingKey': { + 'name': 'UnsuitableStakingKey', + 'subtypes': [], + 'props': { + 'public_key': '' + } + }, + 'Closed': { + 'name': 'Closed', + 'subtypes': [], + 'props': {} + }, + 'InternalError': { + 'name': 'InternalError', + 'subtypes': [], + 'props': {} + }, + 'ServerError': { + 'name': 'ServerError', + 'subtypes': [ + 'TxExecutionError', + 'Timeout', + 'Closed', + 'InternalError' + ], + 'props': {} + }, + 'Timeout': { + 'name': 'Timeout', + 'subtypes': [], + 'props': {} + } + } +}; \ No newline at end of file diff --git a/packages/utils/src/errors/rpc_errors.ts b/packages/utils/src/errors/rpc_errors.ts new file mode 100644 index 0000000000..127f22fccf --- /dev/null +++ b/packages/utils/src/errors/rpc_errors.ts @@ -0,0 +1,121 @@ +import { TypedError } from '@near-js/types'; +import Mustache from 'mustache'; + +import { formatNearAmount } from '../format.js'; +// TODO get JSON files working with CJS and ESM +import messages from './error_messages.js'; +import schema from './rpc_error_schema.js'; + +const mustacheHelpers = { + formatNear: () => (n, render) => formatNearAmount(render(n)) +}; + +export class ServerError extends TypedError { +} + +class ServerTransactionError extends ServerError { + public transaction_outcome: any; +} + +export function parseRpcError(errorObj: Record): ServerError { + const result = {}; + const errorClassName = walkSubtype(errorObj, schema.schema, result, ''); + // NOTE: This assumes that all errors extend TypedError + const error = new ServerError(formatError(errorClassName, result), errorClassName); + Object.assign(error, result); + return error; +} + +export function parseResultError(result: any): ServerTransactionError { + const server_error = parseRpcError(result.status.Failure); + const server_tx_error = new ServerTransactionError(); + Object.assign(server_tx_error, server_error); + server_tx_error.type = server_error.type; + server_tx_error.message = server_error.message; + server_tx_error.transaction_outcome = result.transaction_outcome; + return server_tx_error; +} + +export function formatError(errorClassName: string, errorData): string { + if (typeof messages[errorClassName] === 'string') { + return Mustache.render(messages[errorClassName], { + ...errorData, + ...mustacheHelpers + }); + } + return JSON.stringify(errorData); +} + +/** + * Walks through defined schema returning error(s) recursively + * @param errorObj The error to be parsed + * @param schema A defined schema in JSON mapping to the RPC errors + * @param result An object used in recursion or called directly + * @param typeName The human-readable error type name as defined in the JSON mapping + */ +function walkSubtype(errorObj, schema, result, typeName) { + let error; + let type; + let errorTypeName; + for (const errorName in schema) { + if (isString(errorObj[errorName])) { + // Return early if error type is in a schema + return errorObj[errorName]; + } + if (isObject(errorObj[errorName])) { + error = errorObj[errorName]; + type = schema[errorName]; + errorTypeName = errorName; + } else if (isObject(errorObj.kind) && isObject(errorObj.kind[errorName])) { + error = errorObj.kind[errorName]; + type = schema[errorName]; + errorTypeName = errorName; + } else { + continue; + } + } + if (error && type) { + for (const prop of Object.keys(type.props)) { + result[prop] = error[prop]; + } + return walkSubtype(error, schema, result, errorTypeName); + } else { + // TODO: is this the right thing to do? + result.kind = errorObj; + return typeName; + } +} + +export function getErrorTypeFromErrorMessage(errorMessage, errorType) { + // This function should be removed when JSON RPC starts returning typed errors. + switch (true) { + case /^account .*? does not exist while viewing$/.test(errorMessage): + return 'AccountDoesNotExist'; + case /^Account .*? doesn't exist$/.test(errorMessage): + return 'AccountDoesNotExist'; + case /^access key .*? does not exist while viewing$/.test(errorMessage): + return 'AccessKeyDoesNotExist'; + case /wasm execution failed with error: FunctionCallError\(CompilationError\(CodeDoesNotExist/.test(errorMessage): + return 'CodeDoesNotExist'; + case /Transaction nonce \d+ must be larger than nonce of the used access key \d+/.test(errorMessage): + return 'InvalidNonce'; + default: + return errorType; + } +} + +/** + * Helper function determining if the argument is an object + * @param n Value to check + */ +function isObject(n) { + return Object.prototype.toString.call(n) === '[object Object]'; +} + +/** + * Helper function determining if the argument is a string + * @param n Value to check + */ +function isString(n) { + return Object.prototype.toString.call(n) === '[object String]'; +} diff --git a/packages/utils/src/format.ts b/packages/utils/src/format.ts new file mode 100644 index 0000000000..9e68d0b1b8 --- /dev/null +++ b/packages/utils/src/format.ts @@ -0,0 +1,107 @@ +import BN from 'bn.js'; + +/** + * Exponent for calculating how many indivisible units are there in one NEAR. See {@link NEAR_NOMINATION}. + */ +export const NEAR_NOMINATION_EXP = 24; + +/** + * Number of indivisible units in one NEAR. Derived from {@link NEAR_NOMINATION_EXP}. + */ +export const NEAR_NOMINATION = new BN('10', 10).pow(new BN(NEAR_NOMINATION_EXP, 10)); + +// Pre-calculate offests used for rounding to different number of digits +const ROUNDING_OFFSETS: BN[] = []; +const BN10 = new BN(10); +for (let i = 0, offset = new BN(5); i < NEAR_NOMINATION_EXP; i++, offset = offset.mul(BN10)) { + ROUNDING_OFFSETS[i] = offset; +} + +/** + * Convert account balance value from internal indivisible units to NEAR. 1 NEAR is defined by {@link NEAR_NOMINATION}. + * Effectively this divides given amount by {@link NEAR_NOMINATION}. + * + * @param balance decimal string representing balance in smallest non-divisible NEAR units (as specified by {@link NEAR_NOMINATION}) + * @param fracDigits number of fractional digits to preserve in formatted string. Balance is rounded to match given number of digits. + * @returns Value in Ⓝ + */ +export function formatNearAmount(balance: string, fracDigits: number = NEAR_NOMINATION_EXP): string { + const balanceBN = new BN(balance, 10); + if (fracDigits !== NEAR_NOMINATION_EXP) { + // Adjust balance for rounding at given number of digits + const roundingExp = NEAR_NOMINATION_EXP - fracDigits - 1; + if (roundingExp > 0) { + balanceBN.iadd(ROUNDING_OFFSETS[roundingExp]); + } + } + + balance = balanceBN.toString(); + const wholeStr = balance.substring(0, balance.length - NEAR_NOMINATION_EXP) || '0'; + const fractionStr = balance.substring(balance.length - NEAR_NOMINATION_EXP) + .padStart(NEAR_NOMINATION_EXP, '0').substring(0, fracDigits); + + return trimTrailingZeroes(`${formatWithCommas(wholeStr)}.${fractionStr}`); +} + +/** + * Convert human readable NEAR amount to internal indivisible units. + * Effectively this multiplies given amount by {@link NEAR_NOMINATION}. + * + * @param amt decimal string (potentially fractional) denominated in NEAR. + * @returns The parsed yoctoⓃ amount or null if no amount was passed in + */ +export function parseNearAmount(amt?: string): string | null { + if (!amt) { return null; } + amt = cleanupAmount(amt); + const split = amt.split('.'); + const wholePart = split[0]; + const fracPart = split[1] || ''; + if (split.length > 2 || fracPart.length > NEAR_NOMINATION_EXP) { + throw new Error(`Cannot parse '${amt}' as NEAR amount`); + } + return trimLeadingZeroes(wholePart + fracPart.padEnd(NEAR_NOMINATION_EXP, '0')); +} + +/** + * Removes commas from the input + * @param amount A value or amount that may contain commas + * @returns string The cleaned value + */ +function cleanupAmount(amount: string): string { + return amount.replace(/,/g, '').trim(); +} + +/** + * Removes .000… from an input + * @param value A value that may contain trailing zeroes in the decimals place + * @returns string The value without the trailing zeros + */ +function trimTrailingZeroes(value: string): string { + return value.replace(/\.?0*$/, ''); +} + +/** + * Removes leading zeroes from an input + * @param value A value that may contain leading zeroes + * @returns string The value without the leading zeroes + */ +function trimLeadingZeroes(value: string): string { + value = value.replace(/^0+/, ''); + if (value === '') { + return '0'; + } + return value; +} + +/** + * Returns a human-readable value with commas + * @param value A value that may not contain commas + * @returns string A value with commas + */ +function formatWithCommas(value: string): string { + const pattern = /(-?\d+)(\d{3})/; + while (pattern.test(value)) { + value = value.replace(pattern, '$1,$2'); + } + return value; +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 0000000000..81ba2660ef --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,6 @@ +export * from './constants.js'; +export * from './errors/index.js'; +export * from './format.js'; +export * from './logging.js'; +export * from './provider.js'; +export * from './validators.js'; diff --git a/packages/utils/src/logging.ts b/packages/utils/src/logging.ts new file mode 100644 index 0000000000..be01566462 --- /dev/null +++ b/packages/utils/src/logging.ts @@ -0,0 +1,70 @@ +import { FinalExecutionOutcome } from '@near-js/types'; + +import { parseRpcError } from './errors/index.js'; + +const SUPPRESS_LOGGING = typeof process !== 'undefined' && !!process.env.NEAR_NO_LOGS; + +/** + * Parse and print details from a query execution response + * @param params + * @param params.contractId ID of the account/contract which made the query + * @param params.outcome the query execution response + */ +export function printTxOutcomeLogsAndFailures({ + contractId, + outcome, +}: { contractId: string, outcome: FinalExecutionOutcome }) { + if (SUPPRESS_LOGGING) { + return; + } + + const flatLogs = [outcome.transaction_outcome, ...outcome.receipts_outcome] + .reduce((acc, it) => { + const isFailure = typeof it.outcome.status === 'object' && typeof it.outcome.status.Failure === 'object'; + if (it.outcome.logs.length || isFailure) { + return acc.concat({ + receiptIds: it.outcome.receipt_ids, + logs: it.outcome.logs, + failure: typeof it.outcome.status === 'object' && it.outcome.status.Failure !== undefined + ? parseRpcError(it.outcome.status.Failure) + : null + }); + } else { + return acc; + } + }, []); + + for (const result of flatLogs) { + console.log(`Receipt${result.receiptIds.length > 1 ? 's' : ''}: ${result.receiptIds.join(', ')}`); + printTxOutcomeLogs({ + contractId, + logs: result.logs, + prefix: '\t', + }); + + if (result.failure) { + console.warn(`\tFailure [${contractId}]: ${result.failure}`); + } + } +} + +/** + * Format and print log output from a query execution response + * @param params + * @param params.contractId ID of the account/contract which made the query + * @param params.logs log output from a query execution response + * @param params.prefix string to append to the beginning of each log + */ +export function printTxOutcomeLogs({ + contractId, + logs, + prefix = '', +}: { contractId: string, logs: string[], prefix?: string }) { + if (SUPPRESS_LOGGING) { + return; + } + + for (const log of logs) { + console.log(`${prefix}Log [${contractId}]: ${log}`); + } +} diff --git a/packages/utils/src/provider.ts b/packages/utils/src/provider.ts new file mode 100644 index 0000000000..24530eda52 --- /dev/null +++ b/packages/utils/src/provider.ts @@ -0,0 +1,14 @@ +import { FinalExecutionOutcome } from '@near-js/types'; + +/** @hidden */ +export function getTransactionLastResult(txResult: FinalExecutionOutcome): any { + if (typeof txResult.status === 'object' && typeof txResult.status.SuccessValue === 'string') { + const value = Buffer.from(txResult.status.SuccessValue, 'base64').toString(); + try { + return JSON.parse(value); + } catch (e) { + return value; + } + } + return null; +} diff --git a/packages/utils/src/validators.ts b/packages/utils/src/validators.ts new file mode 100644 index 0000000000..26ef82edd1 --- /dev/null +++ b/packages/utils/src/validators.ts @@ -0,0 +1,94 @@ +'use strict'; + +import { CurrentEpochValidatorInfo, NextEpochValidatorInfo } from '@near-js/types'; +import BN from 'bn.js'; +import depd from 'depd'; + +/** Finds seat price given validators stakes and number of seats. + * Calculation follow the spec: https://nomicon.io/Economics/README.html#validator-selection + * @params validators: current or next epoch validators. + * @params maxNumberOfSeats: maximum number of seats in the network. + * @params minimumStakeRatio: minimum stake ratio + * @params protocolVersion: version of the protocol from genesis config + */ +export function findSeatPrice(validators: (CurrentEpochValidatorInfo | NextEpochValidatorInfo)[], maxNumberOfSeats: number, minimumStakeRatio: number[], protocolVersion?: number): BN { + if (protocolVersion && protocolVersion < 49) { + return findSeatPriceForProtocolBefore49(validators, maxNumberOfSeats); + } + if (!minimumStakeRatio) { + const deprecate = depd('findSeatPrice(validators, maxNumberOfSeats)'); + deprecate('`use `findSeatPrice(validators, maxNumberOfSeats, minimumStakeRatio)` instead'); + minimumStakeRatio = [1, 6250]; // harcoded minimumStakeRation from 12/7/21 + } + return findSeatPriceForProtocolAfter49(validators, maxNumberOfSeats, minimumStakeRatio); +} + +function findSeatPriceForProtocolBefore49(validators: (CurrentEpochValidatorInfo | NextEpochValidatorInfo)[], numSeats: number): BN { + const stakes = validators.map(v => new BN(v.stake, 10)).sort((a, b) => a.cmp(b)); + const num = new BN(numSeats); + const stakesSum = stakes.reduce((a, b) => a.add(b)); + if (stakesSum.lt(num)) { + throw new Error('Stakes are below seats'); + } + // assert stakesSum >= numSeats + let left = new BN(1), right = stakesSum.add(new BN(1)); + while (!left.eq(right.sub(new BN(1)))) { + const mid = left.add(right).div(new BN(2)); + let found = false; + let currentSum = new BN(0); + for (let i = 0; i < stakes.length; ++i) { + currentSum = currentSum.add(stakes[i].div(mid)); + if (currentSum.gte(num)) { + left = mid; + found = true; + break; + } + } + if (!found) { + right = mid; + } + } + return left; +} + +// nearcore reference: https://github.com/near/nearcore/blob/5a8ae263ec07930cd34d0dcf5bcee250c67c02aa/chain/epoch_manager/src/validator_selection.rs#L308;L315 +function findSeatPriceForProtocolAfter49(validators: (CurrentEpochValidatorInfo | NextEpochValidatorInfo)[], maxNumberOfSeats: number, minimumStakeRatio: number[]): BN { + if (minimumStakeRatio.length != 2) { + throw Error('minimumStakeRatio should have 2 elements'); + } + const stakes = validators.map(v => new BN(v.stake, 10)).sort((a, b) => a.cmp(b)); + const stakesSum = stakes.reduce((a, b) => a.add(b)); + if (validators.length < maxNumberOfSeats) { + return stakesSum.mul(new BN(minimumStakeRatio[0])).div(new BN(minimumStakeRatio[1])); + } else { + return stakes[0].add(new BN(1)); + } +} + +export interface ChangedValidatorInfo { + current: CurrentEpochValidatorInfo; + next: NextEpochValidatorInfo; +} + +export interface EpochValidatorsDiff { + newValidators: NextEpochValidatorInfo[]; + removedValidators: CurrentEpochValidatorInfo[]; + changedValidators: ChangedValidatorInfo[]; +} + +/** Diff validators between current and next epoch. + * Returns additions, subtractions and changes to validator set. + * @params currentValidators: list of current validators. + * @params nextValidators: list of next validators. + */ +export function diffEpochValidators(currentValidators: CurrentEpochValidatorInfo[], nextValidators: NextEpochValidatorInfo[]): EpochValidatorsDiff { + const validatorsMap = new Map(); + currentValidators.forEach(v => validatorsMap.set(v.account_id, v)); + const nextValidatorsSet = new Set(nextValidators.map(v => v.account_id)); + return { + newValidators: nextValidators.filter(v => !validatorsMap.has(v.account_id)), + removedValidators: currentValidators.filter(v => !nextValidatorsSet.has(v.account_id)), + changedValidators: nextValidators.filter(v => (validatorsMap.has(v.account_id) && validatorsMap.get(v.account_id).stake != v.stake)) + .map(v => ({ current: validatorsMap.get(v.account_id), next: v })) + }; +} diff --git a/packages/utils/test/format.test.js b/packages/utils/test/format.test.js new file mode 100644 index 0000000000..ee5a5231ba --- /dev/null +++ b/packages/utils/test/format.test.js @@ -0,0 +1,59 @@ +import { formatNearAmount, parseNearAmount } from '../lib/esm'; + +jasmine.DEFAULT_TIMEOUT_INTERVAL = 50000; + +test.each` + balance | fracDigits | expected + ${'8999999999837087887'} | ${undefined} | ${'0.000008999999999837087887'} + ${'8099099999837087887'} | ${undefined} | ${'0.000008099099999837087887'} + ${'999998999999999837087887000'} | ${undefined} | ${'999.998999999999837087887'} + ${'1'+'0'.repeat(13)} | ${undefined} | ${'0.00000000001'} + ${'9999989999999998370878870000000'} | ${undefined} | ${'9,999,989.99999999837087887'} + ${'000000000000000000000000'} | ${undefined} | ${'0'} + ${'1000000000000000000000000'} | ${undefined} | ${'1'} + ${'999999999999999999000000'} | ${undefined} | ${'0.999999999999999999'} + ${'999999999999999999000000'} | ${10} | ${'1'} + ${'1003000000000000000000000'} | ${3} | ${'1.003'} + ${'3000000000000000000000'} | ${3} | ${'0.003'} + ${'3000000000000000000000'} | ${4} | ${'0.003'} + ${'3500000000000000000000'} | ${3} | ${'0.004'} + ${'03500000000000000000000'} | ${3} | ${'0.004'} + ${'10000000999999997410000000'} | ${undefined} | ${'10.00000099999999741'} + ${'10100000999999997410000000'} | ${undefined} | ${'10.10000099999999741'} + ${'10040000999999997410000000'} | ${2} | ${'10.04'} + ${'10999000999999997410000000'} | ${2} | ${'11'} + ${'1000000100000000000000000000000'} | ${undefined} | ${'1,000,000.1'} + ${'1000100000000000000000000000000'} | ${undefined} | ${'1,000,100'} + ${'910000000000000000000000'} | ${0} | ${'1'} +`('formatNearAmount($balance, $fracDigits) returns $expected', ({ balance, fracDigits, expected }) => { + expect(formatNearAmount(balance, fracDigits)).toEqual(expected); +}); + +test.each` + amt | expected + ${null} | ${null} + ${'5.3'} | ${'5300000000000000000000000'} + ${'5'} | ${'5000000000000000000000000'} + ${'1'} | ${'1000000000000000000000000'} + ${'10'} | ${'10000000000000000000000000'} + ${'0.000008999999999837087887'} | ${'8999999999837087887'} + ${'0.000008099099999837087887'} | ${'8099099999837087887'} + ${'999.998999999999837087887000'} | ${'999998999999999837087887000'} + ${'0.000000000000001'} | ${'1000000000'} + ${'0'} | ${'0'} + ${'0.000'} | ${'0'} + ${'0.000001'} | ${'1000000000000000000'} + ${'.000001'} | ${'1000000000000000000'} + ${'000000.000001'} | ${'1000000000000000000'} + ${'1,000,000.1'} | ${'1000000100000000000000000000000'} +`('parseNearAmount($amt) returns $expected', ({ amt, expected }) => { + expect(parseNearAmount(amt)).toStrictEqual(expected); +}); + +test('parseNearAmount fails when parsing values with ≥25 decimal places', () => { + expect(() => { + parseNearAmount('0.0000080990999998370878871'); + }).toThrowError( + 'Cannot parse \'0.0000080990999998370878871\' as NEAR amount' + ); +}); diff --git a/packages/utils/test/rpc-errors.test.js b/packages/utils/test/rpc-errors.test.js new file mode 100644 index 0000000000..579e7c77c5 --- /dev/null +++ b/packages/utils/test/rpc-errors.test.js @@ -0,0 +1,120 @@ +import { formatError, getErrorTypeFromErrorMessage, parseRpcError, ServerError } from '../lib/esm'; + +describe('rpc-errors', () => { + test('test AccountAlreadyExists error', async () => { + let rpc_error = { + TxExecutionError: { + ActionError: { + index: 1, + kind: {AccountAlreadyExists: {account_id: 'bob.near'}} + } + } + }; + let error = parseRpcError(rpc_error); + expect(error.type === 'AccountAlreadyExists').toBe(true); + expect(error.index).toBe(1); + expect(error.account_id).toBe('bob.near'); + expect(formatError(error.type, error)).toBe('Can\'t create a new account bob.near, because it already exists'); + }); + + test('test ReceiverMismatch error', async () => { + let rpc_error = { + TxExecutionError: { + InvalidTxError: { + InvalidAccessKeyError: { + ReceiverMismatch: { + ak_receiver: 'test.near', + tx_receiver: 'bob.near' + } + } + } + } + }; + let error = parseRpcError(rpc_error); + expect(error.type === 'ReceiverMismatch').toBe(true); + expect(error.ak_receiver).toBe('test.near'); + expect(error.tx_receiver).toBe('bob.near'); + expect(formatError(error.type, error)).toBe( + 'Wrong AccessKey used for transaction: transaction is sent to receiver_id=bob.near, but is signed with function call access key that restricted to only use with receiver_id=test.near. Either change receiver_id in your transaction or switch to use a FullAccessKey.' + ); + }); + + test('test InvalidIteratorIndex error', async () => { + let rpc_error = { + TxExecutionError: { + ActionError: { + FunctionCallError: { + HostError: { + InvalidIteratorIndex: {iterator_index: 42} + } + } + } + } + }; + let error = parseRpcError(rpc_error); + expect(error.type === 'InvalidIteratorIndex').toBe(true); + expect(error.iterator_index).toBe(42); + expect(formatError(error.type, error)).toBe('Iterator index 42 does not exist'); + }); + + test('test ActionError::FunctionCallError::GasLimitExceeded error', async () => { + let rpc_error = { + ActionError: { + 'index': 0, + 'kind': { + FunctionCallError: { + 'HostError': 'GasLimitExceeded' + } + } + } + }; + let error = parseRpcError(rpc_error); + expect(error.type === 'GasLimitExceeded').toBe(true); + + expect(formatError(error.type, error)).toBe('Exceeded the maximum amount of gas allowed to burn per contract'); + }); + + test('test parse error object', async () => { + const errorStr = '{"status":{"Failure":{"ActionError":{"index":0,"kind":{"FunctionCallError":{"EvmError":"ArgumentParseError"}}}}},"transaction":{"signer_id":"test.near","public_key":"ed25519:D5HVgBE8KgXkSirDE4UQ8qwieaLAR4wDDEgrPRtbbNep","nonce":110,"receiver_id":"evm","actions":[{"FunctionCall":{"method_name":"transfer","args":"888ZO7SvECKvfSCJ832LrnFXuF/QKrSGztwAAA==","gas":300000000000000,"deposit":"0"}}],"signature":"ed25519:7JtWQ2Ux63ixaKy7bTDJuRTWnv6XtgE84ejFMMjYGKdv2mLqPiCfkMqbAPt5xwLWwFdKjJniTcxWZe7FdiRWpWv","hash":"E1QorKKEh1WLJwRQSQ1pdzQN3f8yeFsQQ8CbJjnz1ZQe"},"transaction_outcome":{"proof":[],"block_hash":"HXXBPjGp65KaFtam7Xr67B8pZVGujZMZvTmVW6Fy9tXf","id":"E1QorKKEh1WLJwRQSQ1pdzQN3f8yeFsQQ8CbJjnz1ZQe","outcome":{"logs":[],"receipt_ids":["ZsKetkrZQGVTtmXr2jALgNjzcRqpoQQsk9HdLmFafeL"],"gas_burnt":2428001493624,"tokens_burnt":"2428001493624000000000","executor_id":"test.near","status":{"SuccessReceiptId":"ZsKetkrZQGVTtmXr2jALgNjzcRqpoQQsk9HdLmFafeL"}}},"receipts_outcome":[{"proof":[],"block_hash":"H6fQCVpxBDv9y2QtmTVHoxHibJvamVsHau7fDi7AmFa2","id":"ZsKetkrZQGVTtmXr2jALgNjzcRqpoQQsk9HdLmFafeL","outcome":{"logs":[],"receipt_ids":["DgRyf1Wv3ZYLFvM8b67k2yZjdmnyUUJtRkTxAwoFi3qD"],"gas_burnt":2428001493624,"tokens_burnt":"2428001493624000000000","executor_id":"evm","status":{"Failure":{"ActionError":{"index":0,"kind":{"FunctionCallError":{"EvmError":"ArgumentParseError"}}}}}}},{"proof":[],"block_hash":"9qNVA235L9XdZ8rZLBAPRNBbiGPyNnMUfpbi9WxbRdbB","id":"DgRyf1Wv3ZYLFvM8b67k2yZjdmnyUUJtRkTxAwoFi3qD","outcome":{"logs":[],"receipt_ids":[],"gas_burnt":0,"tokens_burnt":"0","executor_id":"test.near","status":{"SuccessValue":""}}}]}'; + const error = parseRpcError(JSON.parse(errorStr).status.Failure); + expect(error).toEqual(new ServerError('{"index":0,"kind":{"EvmError":"ArgumentParseError"}}')); + }); + + test('test getErrorTypeFromErrorMessage', () => { + const err1 = 'account random.near does not exist while viewing'; + const err2 = 'Account random2.testnet doesn\'t exist'; + const err3 = 'access key ed25519:DvXowCpBHKdbD2qutgfhG6jvBMaXyUh7DxrDSjkLxMHp does not exist while viewing'; + const err4 = 'wasm execution failed with error: FunctionCallError(CompilationError(CodeDoesNotExist { account_id: "random.testnet" }))'; + const err5 = '[-32000] Server error: Invalid transaction: Transaction nonce 1 must be larger than nonce of the used access key 1'; + expect(getErrorTypeFromErrorMessage(err1)).toEqual('AccountDoesNotExist'); + expect(getErrorTypeFromErrorMessage(err2)).toEqual('AccountDoesNotExist'); + expect(getErrorTypeFromErrorMessage(err3)).toEqual('AccessKeyDoesNotExist'); + expect(getErrorTypeFromErrorMessage(err4)).toEqual('CodeDoesNotExist'); + expect(getErrorTypeFromErrorMessage(err5)).toEqual('InvalidNonce'); + }); + + test('test NotEnoughBalance message uses human readable values', () => { + const error = parseRpcError({ + NotEnoughBalance: { + balance: '1000000000000000000000000', + cost: '10000000000000000000000000', + signer_id: 'test.near' + } + }); + + expect(error.message).toEqual('Sender test.near does not have enough balance 1 for operation costing 10'); + }); + + test('test TriesToStake message uses human readable values', () => { + const error = parseRpcError({ + TriesToStake: { + account_id: 'test.near', + balance: '9000000000000000000000000', + locked: '1000000000000000000000000', + stake: '10000000000000000000000000', + } + }); + + expect(error.message).toEqual('Account test.near tried to stake 10, but has staked 1 and only has 9'); + }); +}); diff --git a/packages/utils/test/validator.test.js b/packages/utils/test/validator.test.js new file mode 100644 index 0000000000..a955735bb6 --- /dev/null +++ b/packages/utils/test/validator.test.js @@ -0,0 +1,36 @@ +import BN from 'bn.js'; + +import { diffEpochValidators, findSeatPrice } from '../lib/esm'; + +test('find seat price', async () => { + expect(findSeatPrice( + [{stake: '1000000'}, {stake: '1000000'}, {stake: '100'}], 2, [1, 6250], 49 + )).toEqual(new BN('101')); + expect(findSeatPrice( + [{stake: '1000000'}, {stake: '1000000'}, {stake: '100'}], 3, [1, 6250] + )).toEqual(new BN('101')); + expect(findSeatPrice( + [{stake: '1000000'}, {stake: '1000000'}, {stake: '100'}], 4, [1, 6250], 49 + )).toEqual(new BN('320')); + expect(findSeatPrice( + [{stake: '1000'}, {stake: '1000'}, {stake: '200'}], 100, [1, 25] + )).toEqual(new BN('88')); +}); + +test('diff validators', async () => { + expect(diffEpochValidators( + [{account_id: 'x', stake: '10'}], + [{ account_id: 'x', stake: '10' }] + )).toEqual({newValidators: [], removedValidators: [], changedValidators: []}); + expect(diffEpochValidators( + [{ account_id: 'x', stake: '10' }, { account_id: 'y', stake: '10' }], + [{ account_id: 'x', stake: '11' }, { account_id: 'z', stake: '11' }] + )).toEqual({ + newValidators: [{ account_id: 'z', stake: '11' }], + removedValidators: [{ account_id: 'y', stake: '10' }], + changedValidators: [{ + current: { account_id: 'x', stake: '10' }, + next: { account_id: 'x', stake: '11' } + }] + }); +}); diff --git a/packages/utils/tsconfig.cjs.json b/packages/utils/tsconfig.cjs.json new file mode 100644 index 0000000000..b055ecfb97 --- /dev/null +++ b/packages/utils/tsconfig.cjs.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/node.json", + "compilerOptions": { + "outDir": "./lib/cjs", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/utils/tsconfig.esm.json b/packages/utils/tsconfig.esm.json new file mode 100644 index 0000000000..5f3d7bd793 --- /dev/null +++ b/packages/utils/tsconfig.esm.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/wallet-account/README.md b/packages/wallet-account/README.md new file mode 100644 index 0000000000..6650243423 --- /dev/null +++ b/packages/wallet-account/README.md @@ -0,0 +1,13 @@ +# @near-js/wallet-account + +A collection of classes and types for working with accounts within browser-based wallets. + +## Modules + +- [Near](src/near.ts) a general purpose class for configuring and working with the NEAR blockchain +- [WalletAccount](src/wallet_account.ts) an [Account](../accounts/src/account.ts) implementation for use in a browser-based wallet + +# License + +This repository is distributed under the terms of both the MIT license and the Apache License (Version 2.0). +See [LICENSE](https://github.com/near/near-api-js/blob/master/LICENSE) and [LICENSE-APACHE](https://github.com/near/near-api-js/blob/master/LICENSE-APACHE) for details. diff --git a/packages/wallet-account/jest.config.js b/packages/wallet-account/jest.config.js new file mode 100644 index 0000000000..4b2c7ee3d5 --- /dev/null +++ b/packages/wallet-account/jest.config.js @@ -0,0 +1,5 @@ +export default { + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true +}; diff --git a/packages/wallet-account/package.json b/packages/wallet-account/package.json new file mode 100644 index 0000000000..c33c341f0f --- /dev/null +++ b/packages/wallet-account/package.json @@ -0,0 +1,42 @@ +{ + "name": "@near-js/wallet-account", + "version": "0.0.1", + "description": "Dependencies for the NEAR API JavaScript client in the browser", + "main": "lib/esm/index.js", + "type": "module", + "browser": "./lib/cjs/index.js", + "scripts": { + "build": "pnpm compile", + "compile": "concurrently \"tsc -p tsconfig.cjs.json\" \"tsc -p tsconfig.esm.json\"", + "test": "NODE_OPTIONS=--experimental-vm-modules jest test" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@near-js/accounts": "workspace:*", + "@near-js/crypto": "workspace:*", + "@near-js/keystores": "workspace:*", + "@near-js/signers": "workspace:*", + "@near-js/transactions": "workspace:*", + "@near-js/types": "workspace:*", + "@near-js/utils": "workspace:*", + "bn.js": "5.2.1", + "borsh": "^0.7.0" + }, + "devDependencies": { + "@types/node": "^18.11.18", + "jest": "^26.0.1", + "localstorage-memory": "^1.0.3", + "ts-jest": "^26.5.6", + "tsconfig": "workspace:*", + "typescript": "^4.9.4" + }, + "exports": { + "import": "./lib/esm/index.js", + "require": "./lib/cjs/index.js" + }, + "files": [ + "lib" + ] +} diff --git a/packages/wallet-account/src/index.ts b/packages/wallet-account/src/index.ts new file mode 100644 index 0000000000..950edd4f8e --- /dev/null +++ b/packages/wallet-account/src/index.ts @@ -0,0 +1,2 @@ +export { Near, NearConfig } from './near.js'; +export { ConnectedWalletAccount, WalletConnection } from './wallet_account.js'; \ No newline at end of file diff --git a/packages/wallet-account/src/near.ts b/packages/wallet-account/src/near.ts new file mode 100644 index 0000000000..31d310e91c --- /dev/null +++ b/packages/wallet-account/src/near.ts @@ -0,0 +1,133 @@ +/** + * This module contains the main class developers will use to interact with NEAR. + * The {@link Near} class is used to interact with {@link account!Account | Accounts} through the {@link providers/json-rpc-provider!JsonRpcProvider}. + * It is configured via the {@link NearConfig}. + * + * @see [https://docs.near.org/tools/near-api-js/quick-reference#account](https://docs.near.org/tools/near-api-js/quick-reference#account) + * + * @module near + */ +import { + Account, + AccountCreator, + Connection, + LocalAccountCreator, + UrlAccountCreator, +} from '@near-js/accounts'; +import { PublicKey } from '@near-js/crypto'; +import { KeyStore } from '@near-js/keystores'; +import { Signer } from '@near-js/signers'; +import BN from 'bn.js'; + +export interface NearConfig { + /** Holds {@link utils/key_pair!KeyPair | KeyPairs} for signing transactions */ + keyStore?: KeyStore; + + /** @hidden */ + signer?: Signer; + + /** + * [NEAR Contract Helper](https://github.com/near/near-contract-helper) url used to create accounts if no master account is provided + * @see {@link account_creator!UrlAccountCreator} + */ + helperUrl?: string; + + /** + * The balance transferred from the {@link masterAccount} to a created account + * @see {@link account_creator!LocalAccountCreator} + */ + initialBalance?: string; + + /** + * The account to use when creating new accounts + * @see {@link account_creator!LocalAccountCreator} + */ + masterAccount?: string; + + /** + * {@link utils/key_pair!KeyPair | KeyPairs} are stored in a {@link key_stores/keystore!KeyStore} under the `networkId` namespace. + */ + networkId: string; + + /** + * NEAR RPC API url. used to make JSON RPC calls to interact with NEAR. + * @see {@link providers/json-rpc-provider!JsonRpcProvider} + */ + nodeUrl: string; + + /** + * NEAR RPC API headers. Can be used to pass API KEY and other parameters. + * @see {@link providers/json-rpc-provider!JsonRpcProvider} + */ + headers?: { [key: string]: string | number }; + + /** + * NEAR wallet url used to redirect users to their wallet in browser applications. + * @see [https://wallet.near.org/](https://wallet.near.org/) + */ + walletUrl?: string; + + /** + * JVSM account ID for NEAR JS SDK + */ + jsvmAccountId?: string; +} + +/** + * This is the main class developers should use to interact with NEAR. + * @example + * ```js + * const near = new Near(config); + * ``` + */ +export class Near { + readonly config: any; + readonly connection: Connection; + readonly accountCreator: AccountCreator; + + constructor(config: NearConfig) { + this.config = config; + this.connection = Connection.fromConfig({ + networkId: config.networkId, + provider: { type: 'JsonRpcProvider', args: { url: config.nodeUrl, headers: config.headers } }, + signer: config.signer || { type: 'InMemorySigner', keyStore: config.keyStore }, + jsvmAccountId: config.jsvmAccountId || `jsvm.${config.networkId}` + }); + + if (config.masterAccount) { + // TODO: figure out better way of specifiying initial balance. + // Hardcoded number below must be enough to pay the gas cost to dev-deploy with near-shell for multiple times + const initialBalance = config.initialBalance ? new BN(config.initialBalance) : new BN('500000000000000000000000000'); + this.accountCreator = new LocalAccountCreator(new Account(this.connection, config.masterAccount), initialBalance); + } else if (config.helperUrl) { + this.accountCreator = new UrlAccountCreator(this.connection, config.helperUrl); + } else { + this.accountCreator = null; + } + } + + /** + * @param accountId near accountId used to interact with the network. + */ + async account(accountId: string): Promise { + const account = new Account(this.connection, accountId); + return account; + } + + /** + * Create an account using the {@link account_creator!AccountCreator}. Either: + * * using a masterAccount with {@link account_creator!LocalAccountCreator} + * * using the helperUrl with {@link account_creator!UrlAccountCreator} + * @see {@link NearConfig.masterAccount} and {@link NearConfig.helperUrl} + * + * @param accountId + * @param publicKey + */ + async createAccount(accountId: string, publicKey: PublicKey): Promise { + if (!this.accountCreator) { + throw new Error('Must specify account creator, either via masterAccount or helperUrl configuration settings.'); + } + await this.accountCreator.createAccount(accountId, publicKey); + return new Account(this.connection, accountId); + } +} diff --git a/packages/wallet-account/src/wallet_account.ts b/packages/wallet-account/src/wallet_account.ts new file mode 100644 index 0000000000..31e092ba10 --- /dev/null +++ b/packages/wallet-account/src/wallet_account.ts @@ -0,0 +1,410 @@ +/** + * The classes in this module are used in conjunction with the {@link key_stores/browser_local_storage_key_store!BrowserLocalStorageKeyStore}. + * This module exposes two classes: + * * {@link WalletConnection} which redirects users to [NEAR Wallet](https://wallet.near.org/) for key management. + * * {@link ConnectedWalletAccount} is an {@link account!Account} implementation that uses {@link WalletConnection} to get keys + * + * @module walletAccount + */ +import { + Account, + Connection, + SignAndSendTransactionOptions, +} from '@near-js/accounts'; +import { KeyPair, PublicKey } from '@near-js/crypto'; +import { KeyStore } from '@near-js/keystores'; +import { InMemorySigner } from '@near-js/signers'; +import { FinalExecutionOutcome } from '@near-js/types'; +import { Transaction, Action, SCHEMA, createTransaction } from '@near-js/transactions'; +import BN from 'bn.js'; +import { baseDecode, serialize } from 'borsh'; + +import { Near } from './near.js'; + +const LOGIN_WALLET_URL_SUFFIX = '/login/'; +const MULTISIG_HAS_METHOD = 'add_request_and_confirm'; +const LOCAL_STORAGE_KEY_SUFFIX = '_wallet_auth_key'; +const PENDING_ACCESS_KEY_PREFIX = 'pending_key'; // browser storage key for a pending access key (i.e. key has been generated but we are not sure it was added yet) + +interface SignInOptions { + contractId?: string; + methodNames?: string[]; + // TODO: Replace following with single callbackUrl + successUrl?: string; + failureUrl?: string; +} + +/** + * Information to send NEAR wallet for signing transactions and redirecting the browser back to the calling application + */ +interface RequestSignTransactionsOptions { + /** list of transactions to sign */ + transactions: Transaction[]; + /** url NEAR Wallet will redirect to after transaction signing is complete */ + callbackUrl?: string; + /** meta information NEAR Wallet will send back to the application. `meta` will be attached to the `callbackUrl` as a url search param */ + meta?: string; +} + +/** + * This class is used in conjunction with the {@link key_stores/browser_local_storage_key_store!BrowserLocalStorageKeyStore}. + * It redirects users to [NEAR Wallet](https://wallet.near.org) for key management. + * This class is not intended for use outside the browser. Without `window` (i.e. in server contexts), it will instantiate but will throw a clear error when used. + * + * @see [https://docs.near.org/tools/near-api-js/quick-reference#wallet](https://docs.near.org/tools/near-api-js/quick-reference#wallet) + * @example + * ```js + * // create new WalletConnection instance + * const wallet = new WalletConnection(near, 'my-app'); + * + * // If not signed in redirect to the NEAR wallet to sign in + * // keys will be stored in the BrowserLocalStorageKeyStore + * if(!wallet.isSignedIn()) return wallet.requestSignIn() + * ``` + */ +export class WalletConnection { + /** @hidden */ + _walletBaseUrl: string; + + /** @hidden */ + _authDataKey: string; + + /** @hidden */ + _keyStore: KeyStore; + + /** @hidden */ + _authData: { accountId?: string; allKeys?: string[] }; + + /** @hidden */ + _networkId: string; + + /** @hidden */ + // _near: Near; + _near: Near; + + /** @hidden */ + _connectedAccount: ConnectedWalletAccount; + + /** @hidden */ + _completeSignInPromise: Promise; + + constructor(near: Near, appKeyPrefix: string) { + if (typeof(appKeyPrefix) !== 'string') { + throw new Error('Please define a clear appKeyPrefix for this WalletConnection instance as the second argument to the constructor'); + } + + if (typeof window === 'undefined') { + return new Proxy(this, { + get(target, property) { + if(property === 'isSignedIn') { + return () => false; + } + if(property === 'getAccountId') { + return () => ''; + } + if(target[property] && typeof target[property] === 'function') { + return () => { + throw new Error('No window found in context, please ensure you are using WalletConnection on the browser'); + }; + } + return target[property]; + } + }); + } + this._near = near; + const authDataKey = appKeyPrefix + LOCAL_STORAGE_KEY_SUFFIX; + const authData = JSON.parse(window.localStorage.getItem(authDataKey)); + this._networkId = near.config.networkId; + this._walletBaseUrl = near.config.walletUrl; + appKeyPrefix = appKeyPrefix || near.config.contractName || 'default'; + this._keyStore = (near.connection.signer as InMemorySigner).keyStore; + this._authData = authData || { allKeys: [] }; + this._authDataKey = authDataKey; + if (!this.isSignedIn()) { + this._completeSignInPromise = this._completeSignInWithAccessKey(); + } + } + + /** + * Returns true, if this WalletConnection is authorized with the wallet. + * @example + * ```js + * const wallet = new WalletConnection(near, 'my-app'); + * wallet.isSignedIn(); + * ``` + */ + isSignedIn() { + return !!this._authData.accountId; + } + + /** + * Returns promise of completing signing in after redirecting from wallet + * @example + * ```js + * // on login callback page + * const wallet = new WalletConnection(near, 'my-app'); + * wallet.isSignedIn(); // false + * await wallet.isSignedInAsync(); // true + * ``` + */ + async isSignedInAsync() { + if (!this._completeSignInPromise) { + return this.isSignedIn(); + } + + await this._completeSignInPromise; + return this.isSignedIn(); + } + + /** + * Returns authorized Account ID. + * @example + * ```js + * const wallet = new WalletConnection(near, 'my-app'); + * wallet.getAccountId(); + * ``` + */ + getAccountId() { + return this._authData.accountId || ''; + } + + /** + * Redirects current page to the wallet authentication page. + * @param options An optional options object + * @param options.contractId The NEAR account where the contract is deployed + * @param options.successUrl URL to redirect upon success. Default: current url + * @param options.failureUrl URL to redirect upon failure. Default: current url + * + * @example + * ```js + * const wallet = new WalletConnection(near, 'my-app'); + * // redirects to the NEAR Wallet + * wallet.requestSignIn({ contractId: 'account-with-deploy-contract.near' }); + * ``` + */ + async requestSignIn({ contractId, methodNames, successUrl, failureUrl }: SignInOptions) { + const currentUrl = new URL(window.location.href); + const newUrl = new URL(this._walletBaseUrl + LOGIN_WALLET_URL_SUFFIX); + newUrl.searchParams.set('success_url', successUrl || currentUrl.href); + newUrl.searchParams.set('failure_url', failureUrl || currentUrl.href); + if (contractId) { + /* Throws exception if contract account does not exist */ + const contractAccount = await this._near.account(contractId); + await contractAccount.state(); + + newUrl.searchParams.set('contract_id', contractId); + const accessKey = KeyPair.fromRandom('ed25519'); + newUrl.searchParams.set('public_key', accessKey.getPublicKey().toString()); + await this._keyStore.setKey(this._networkId, PENDING_ACCESS_KEY_PREFIX + accessKey.getPublicKey(), accessKey); + } + + if (methodNames) { + methodNames.forEach(methodName => { + newUrl.searchParams.append('methodNames', methodName); + }); + } + + window.location.assign(newUrl.toString()); + } + + /** + * Requests the user to quickly sign for a transaction or batch of transactions by redirecting to the NEAR wallet. + */ + async requestSignTransactions({ transactions, meta, callbackUrl }: RequestSignTransactionsOptions): Promise { + const currentUrl = new URL(window.location.href); + const newUrl = new URL('sign', this._walletBaseUrl); + + newUrl.searchParams.set('transactions', transactions + .map(transaction => serialize(SCHEMA, transaction)) + .map(serialized => Buffer.from(serialized).toString('base64')) + .join(',')); + newUrl.searchParams.set('callbackUrl', callbackUrl || currentUrl.href); + if (meta) newUrl.searchParams.set('meta', meta); + + window.location.assign(newUrl.toString()); + } + + /** + * @hidden + * Complete sign in for a given account id and public key. To be invoked by the app when getting a callback from the wallet. + */ + async _completeSignInWithAccessKey() { + const currentUrl = new URL(window.location.href); + const publicKey = currentUrl.searchParams.get('public_key') || ''; + const allKeys = (currentUrl.searchParams.get('all_keys') || '').split(','); + const accountId = currentUrl.searchParams.get('account_id') || ''; + // TODO: Handle errors during login + if (accountId) { + const authData = { + accountId, + allKeys + }; + window.localStorage.setItem(this._authDataKey, JSON.stringify(authData)); + if (publicKey) { + await this._moveKeyFromTempToPermanent(accountId, publicKey); + } + this._authData = authData; + } + currentUrl.searchParams.delete('public_key'); + currentUrl.searchParams.delete('all_keys'); + currentUrl.searchParams.delete('account_id'); + currentUrl.searchParams.delete('meta'); + currentUrl.searchParams.delete('transactionHashes'); + + window.history.replaceState({}, document.title, currentUrl.toString()); + } + + /** + * @hidden + * @param accountId The NEAR account owning the given public key + * @param publicKey The public key being set to the key store + */ + async _moveKeyFromTempToPermanent(accountId: string, publicKey: string) { + const keyPair = await this._keyStore.getKey(this._networkId, PENDING_ACCESS_KEY_PREFIX + publicKey); + await this._keyStore.setKey(this._networkId, accountId, keyPair); + await this._keyStore.removeKey(this._networkId, PENDING_ACCESS_KEY_PREFIX + publicKey); + } + + /** + * Sign out from the current account + * @example + * walletConnection.signOut(); + */ + signOut() { + this._authData = {}; + window.localStorage.removeItem(this._authDataKey); + } + + /** + * Returns the current connected wallet account + */ + account() { + if (!this._connectedAccount) { + this._connectedAccount = new ConnectedWalletAccount(this, this._near.connection, this._authData.accountId); + } + return this._connectedAccount; + } +} + +/** + * {@link account!Account} implementation which redirects to wallet using {@link WalletConnection} when no local key is available. + */ +export class ConnectedWalletAccount extends Account { + walletConnection: WalletConnection; + + constructor(walletConnection: WalletConnection, connection: Connection, accountId: string) { + super(connection, accountId); + this.walletConnection = walletConnection; + } + + // Overriding Account methods + + /** + * Sign a transaction by redirecting to the NEAR Wallet + * @see {@link WalletConnection.requestSignTransactions} + */ + async signAndSendTransaction({ receiverId, actions, walletMeta, walletCallbackUrl = window.location.href }: SignAndSendTransactionOptions): Promise { + const localKey = await this.connection.signer.getPublicKey(this.accountId, this.connection.networkId); + let accessKey = await this.accessKeyForTransaction(receiverId, actions, localKey); + if (!accessKey) { + throw new Error(`Cannot find matching key for transaction sent to ${receiverId}`); + } + + if (localKey && localKey.toString() === accessKey.public_key) { + try { + return await super.signAndSendTransaction({ receiverId, actions }); + } catch (e) { + if (e.type === 'NotEnoughAllowance') { + accessKey = await this.accessKeyForTransaction(receiverId, actions); + } else { + throw e; + } + } + } + + const block = await this.connection.provider.block({ finality: 'final' }); + const blockHash = baseDecode(block.header.hash); + + const publicKey = PublicKey.from(accessKey.public_key); + // TODO: Cache & listen for nonce updates for given access key + const nonce = accessKey.access_key.nonce.add(new BN(1)); + const transaction = createTransaction(this.accountId, publicKey, receiverId, nonce, actions, blockHash); + await this.walletConnection.requestSignTransactions({ + transactions: [transaction], + meta: walletMeta, + callbackUrl: walletCallbackUrl + }); + + return new Promise((resolve, reject) => { + setTimeout(() => { + reject(new Error('Failed to redirect to sign transaction')); + }, 1000); + }); + + // TODO: Aggregate multiple transaction request with "debounce". + // TODO: Introduce TrasactionQueue which also can be used to watch for status? + } + + /** + * Check if given access key allows the function call or method attempted in transaction + * @param accessKey Array of \{access_key: AccessKey, public_key: PublicKey\} items + * @param receiverId The NEAR account attempting to have access + * @param actions The action(s) needed to be checked for access + */ + async accessKeyMatchesTransaction(accessKey, receiverId: string, actions: Action[]): Promise { + const { access_key: { permission } } = accessKey; + if (permission === 'FullAccess') { + return true; + } + + if (permission.FunctionCall) { + const { receiver_id: allowedReceiverId, method_names: allowedMethods } = permission.FunctionCall; + /******************************** + Accept multisig access keys and let wallets attempt to signAndSendTransaction + If an access key has itself as receiverId and method permission add_request_and_confirm, then it is being used in a wallet with multisig contract: https://github.com/near/core-contracts/blob/671c05f09abecabe7a7e58efe942550a35fc3292/multisig/src/lib.rs#L149-L153 + ********************************/ + if (allowedReceiverId === this.accountId && allowedMethods.includes(MULTISIG_HAS_METHOD)) { + return true; + } + if (allowedReceiverId === receiverId) { + if (actions.length !== 1) { + return false; + } + const [{ functionCall }] = actions; + return functionCall && + (!functionCall.deposit || functionCall.deposit.toString() === '0') && // TODO: Should support charging amount smaller than allowance? + (allowedMethods.length === 0 || allowedMethods.includes(functionCall.methodName)); + // TODO: Handle cases when allowance doesn't have enough to pay for gas + } + } + // TODO: Support other permissions than FunctionCall + + return false; + } + + /** + * Helper function returning the access key (if it exists) to the receiver that grants the designated permission + * @param receiverId The NEAR account seeking the access key for a transaction + * @param actions The action(s) sought to gain access to + * @param localKey A local public key provided to check for access + */ + async accessKeyForTransaction(receiverId: string, actions: Action[], localKey?: PublicKey): Promise { + const accessKeys = await this.getAccessKeys(); + + if (localKey) { + const accessKey = accessKeys.find(key => key.public_key.toString() === localKey.toString()); + if (accessKey && await this.accessKeyMatchesTransaction(accessKey, receiverId, actions)) { + return accessKey; + } + } + + const walletKeys = this.walletConnection._authData.allKeys; + for (const accessKey of accessKeys) { + if (walletKeys.indexOf(accessKey.public_key) !== -1 && await this.accessKeyMatchesTransaction(accessKey, receiverId, actions)) { + return accessKey; + } + } + + return null; + } +} diff --git a/packages/wallet-account/test/wallet_account.test.js b/packages/wallet-account/test/wallet_account.test.js new file mode 100644 index 0000000000..33d441dd0d --- /dev/null +++ b/packages/wallet-account/test/wallet_account.test.js @@ -0,0 +1,519 @@ +import { KeyPair, PublicKey } from '@near-js/crypto'; +import { InMemoryKeyStore } from '@near-js/keystores'; +import { InMemorySigner } from '@near-js/signers'; +import { actionCreators, createTransaction, SCHEMA, Transaction } from '@near-js/transactions'; +import BN from 'bn.js'; +import { baseDecode, deserialize } from 'borsh'; +import localStorage from 'localstorage-memory'; +import url from 'url'; + +import { WalletConnection } from '../lib/esm'; + +const { functionCall, transfer } = actionCreators; + +// If an access key has itself as receiverId and method permission add_request_and_confirm, then it is being used in a wallet with multisig contract: https://github.com/near/core-contracts/blob/671c05f09abecabe7a7e58efe942550a35fc3292/multisig/src/lib.rs#L149-L153 +const MULTISIG_HAS_METHOD = 'add_request_and_confirm'; + +let lastRedirectUrl; +let lastTransaction; +global.window = { + localStorage +}; +global.document = { + title: 'documentTitle' +}; + +let history; +let nearFake; +let walletConnection; +let keyStore = new InMemoryKeyStore(); +beforeEach(() => { + keyStore.clear(); + nearFake = { + config: { + networkId: 'networkId', + contractName: 'contractId', + walletUrl: 'http://example.com/wallet', + }, + connection: { + networkId: 'networkId', + signer: new InMemorySigner(keyStore) + }, + account() { + return { + state() {} + }; + } + }; + lastRedirectUrl = null; + history = []; + Object.assign(global.window, { + location: { + href: 'http://example.com/location', + assign(url) { + lastRedirectUrl = url; + } + }, + history: { + replaceState: (state, title, url) => history.push([state, title, url]) + } + }); + walletConnection = new WalletConnection(nearFake, ''); +}); + +it('not signed in by default', () => { + expect(walletConnection.isSignedIn()).not.toBeTruthy(); +}); + +it('throws if non string appKeyPrefix', () => { + expect(() => new WalletConnection(nearFake)).toThrow(/appKeyPrefix/); + expect(() => new WalletConnection(nearFake, 1)).toThrow(/appKeyPrefix/); + expect(() => new WalletConnection(nearFake, null)).toThrow(/appKeyPrefix/); + expect(() => new WalletConnection(nearFake, undefined)).toThrow(/appKeyPrefix/); +}); + +describe('fails gracefully on the server side (without window)', () => { + const windowValueBefore = global.window; + + beforeEach(() => { + global.window = undefined; + keyStore.clear(); + }); + + afterEach(() => { + global.window = windowValueBefore; + }); + + it('does not throw on instantiation', () => { + expect(() => new WalletConnection(nearFake, '')).not.toThrowError(); + }); + + it('throws if non string appKeyPrefix in server context', () => { + expect(() => new WalletConnection(nearFake)).toThrow(/appKeyPrefix/); + expect(() => new WalletConnection(nearFake, 1)).toThrow(/appKeyPrefix/); + expect(() => new WalletConnection(nearFake, null)).toThrow(/appKeyPrefix/); + expect(() => new WalletConnection(nearFake, undefined)).toThrow(/appKeyPrefix/); + }); + + it('returns an empty string as accountId', () => { + const serverWalletConnection = new WalletConnection(nearFake, ''); + expect(serverWalletConnection.getAccountId()).toEqual(''); + }); + + it('returns false as isSignedIn', () => { + const serverWalletConnection = new WalletConnection(nearFake, ''); + expect(serverWalletConnection.isSignedIn()).toEqual(false); + }); + + it('throws explicit error when calling other methods on the instance', () => { + const serverWalletConnection = new WalletConnection(nearFake, ''); + expect(() => serverWalletConnection.requestSignIn('signInContract', 'signInTitle', 'http://example.com/success', 'http://example.com/fail')).toThrow(/please ensure you are using WalletConnection on the browser/); + }); + + it('can access other props on the instance', () => { + const serverWalletConnection = new WalletConnection(nearFake, ''); + expect(serverWalletConnection['randomValue']).toEqual(undefined); + }); +}); + +describe('can request sign in', () => { + beforeEach(() => keyStore.clear()); + + it('V2', () => { + return walletConnection.requestSignIn({ + contractId: 'signInContract', + successUrl: 'http://example.com/success', + failureUrl: 'http://example.com/fail' + }); + }); + + afterEach(async () => { + let accounts = await keyStore.getAccounts('networkId'); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatch(/^pending_key.+/); + expect(url.parse(lastRedirectUrl, true)).toMatchObject({ + protocol: 'http:', + host: 'example.com', + query: { + contract_id: 'signInContract', + success_url: 'http://example.com/success', + failure_url: 'http://example.com/fail', + public_key: (await keyStore.getKey('networkId', accounts[0])).publicKey.toString() + } + }); + }); +}); + +it('can request sign in with methodNames', async () => { + await walletConnection.requestSignIn({ + contractId: 'signInContract', + methodNames: ['hello', 'goodbye'], + successUrl: 'http://example.com/success', + failureUrl: 'http://example.com/fail' + }); + + let accounts = await keyStore.getAccounts('networkId'); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatch(/^pending_key.+/); + expect(url.parse(lastRedirectUrl, true)).toMatchObject({ + protocol: 'http:', + host: 'example.com', + query: { + contract_id: 'signInContract', + methodNames: ['hello', 'goodbye'], + success_url: 'http://example.com/success', + failure_url: 'http://example.com/fail', + public_key: (await keyStore.getKey('networkId', accounts[0])).publicKey.toString() + } + }); +}); + +it('can complete sign in', async () => { + const keyPair = KeyPair.fromRandom('ed25519'); + global.window.location.href = `http://example.com/location?account_id=near.account&public_key=${keyPair.publicKey}`; + await keyStore.setKey('networkId', 'pending_key' + keyPair.publicKey, keyPair); + + await walletConnection._completeSignInWithAccessKey(); + + expect(await keyStore.getKey('networkId', 'near.account')).toEqual(keyPair); + expect(localStorage.getItem('contractId_wallet_auth_key')); + expect(history.slice(1)).toEqual([ + [{}, 'documentTitle', 'http://example.com/location'] + ]); +}); + +it('Promise until complete sign in', async () => { + const keyPair = KeyPair.fromRandom('ed25519'); + global.window.location.href = `http://example.com/location?account_id=near2.account&public_key=${keyPair.publicKey}`; + await keyStore.setKey('networkId', 'pending_key' + keyPair.publicKey, keyPair); + + const newWalletConn = new WalletConnection(nearFake, 'promise_on_complete_signin'); + + expect(newWalletConn.isSignedIn()).toEqual(false); + expect(await newWalletConn.isSignedInAsync()).toEqual(true); + expect(await keyStore.getKey('networkId', 'near2.account')).toEqual(keyPair); + expect(localStorage.getItem('promise_on_complete_signin_wallet_auth_key')); + expect(history).toEqual([ + [{}, 'documentTitle', 'http://example.com/location'] + ]); +}); + +const BLOCK_HASH = '244ZQ9cgj3CQ6bWBdytfrJMuMQ1jdXLFGnr4HhvtCTnM'; +const blockHash = baseDecode(BLOCK_HASH); +function createTransferTx() { + const actions = [ + transfer(1), + ]; + return createTransaction( + 'test.near', + PublicKey.fromString('Anu7LYDfpLtkP7E16LT9imXF694BdQaa9ufVkQiwTQxC'), + 'whatever.near', + 1, + actions, + blockHash); +} + +describe('can request transaction signing', () => { + it('V1', async () => { + await walletConnection.requestSignTransactions({ + transactions: [createTransferTx()], + callbackUrl: 'http://example.com/callback' + }); + + expect(url.parse(lastRedirectUrl, true)).toMatchObject({ + protocol: 'http:', + host: 'example.com', + query: { + callbackUrl: 'http://example.com/callback', + transactions: 'CQAAAHRlc3QubmVhcgCRez0mjUtY9/7BsVC9aNab4+5dTMOYVeNBU4Rlu3eGDQEAAAAAAAAADQAAAHdoYXRldmVyLm5lYXIPpHP9JpAd8pa+atxMxN800EDvokNSJLaYaRDmMML+9gEAAAADAQAAAAAAAAAAAAAAAAAAAA==' + } + }); + }); + + it('V2', async () => { + await walletConnection.requestSignTransactions({ + transactions: [createTransferTx()], + meta: 'something', + callbackUrl: 'http://example.com/after' + }); + + expect(url.parse(lastRedirectUrl, true)).toMatchObject({ + protocol: 'http:', + host: 'example.com', + query: { + meta: 'something', + callbackUrl: 'http://example.com/after', + transactions: 'CQAAAHRlc3QubmVhcgCRez0mjUtY9/7BsVC9aNab4+5dTMOYVeNBU4Rlu3eGDQEAAAAAAAAADQAAAHdoYXRldmVyLm5lYXIPpHP9JpAd8pa+atxMxN800EDvokNSJLaYaRDmMML+9gEAAAADAQAAAAAAAAAAAAAAAAAAAA==' + } + }); + }); +}); + +function parseTransactionsFromUrl(urlToParse, callbackUrl = 'http://example.com/location') { + const parsedUrl = url.parse(urlToParse, true); + expect(parsedUrl).toMatchObject({ + protocol: 'http:', + host: 'example.com', + query: { + callbackUrl + } + }); + const transactions = parsedUrl.query.transactions.split(',') + .map(txBase64 => deserialize( + SCHEMA, + Transaction, + Buffer.from(txBase64, 'base64'))); + return transactions; +} + +function setupWalletConnectionForSigning({ allKeys, accountAccessKeys }) { + walletConnection._authData = { + allKeys: allKeys, + accountId: 'signer.near' + }; + nearFake.connection.provider = { + query(params) { + if (params.request_type === 'view_account' && params.account_id === 'signer.near') { + return { }; + } + if (params.request_type === 'view_access_key_list' && params.account_id === 'signer.near') { + return { keys: accountAccessKeys }; + } + if (params.request_type === 'view_access_key' && params.account_id === 'signer.near') { + for (let accessKey of accountAccessKeys) { + if (accessKey.public_key === params.public_key) { + return accessKey; + } + } + } + fail(`Unexpected query: ${JSON.stringify(params)}`); + }, + sendTransaction(signedTransaction) { + lastTransaction = signedTransaction; + return { + transaction_outcome: { outcome: { logs: [] } }, + receipts_outcome: [] + }; + }, + block() { + return { + header: { + hash: BLOCK_HASH + } + }; + } + }; +} + +describe('requests transaction signing automatically when there is no local key', () => { + const keyPair = KeyPair.fromRandom('ed25519'); + let transactions; + beforeEach(() => { + setupWalletConnectionForSigning({ + allKeys: [ 'no_such_access_key', keyPair.publicKey.toString() ], + accountAccessKeys: [{ + access_key: { + nonce: 1, + permission: 'FullAccess' + }, + public_key: keyPair.publicKey.toString() + }] + }); + }); + + it('V2', async() => { + try { + await walletConnection.account().signAndSendTransaction({ + receiverId: 'receiver.near', + actions: [transfer(1)], + walletCallbackUrl: 'http://callback.com/callback' + }); + fail('expected to throw'); + } catch (e) { + expect(e.message).toEqual('Failed to redirect to sign transaction'); + } + transactions = parseTransactionsFromUrl(lastRedirectUrl, 'http://callback.com/callback'); + }); + + afterEach(() => { + expect(transactions).toHaveLength(1); + expect(transactions[0]).toMatchObject({ + signerId: 'signer.near', + // nonce: new BN(2) + receiverId: 'receiver.near', + actions: [{ + transfer: { + // deposit: new BN(1) + } + }] + }); + expect(transactions[0].nonce.toString()).toEqual('2'); + expect(transactions[0].actions[0].transfer.deposit.toString()).toEqual('1'); + expect(Buffer.from(transactions[0].publicKey.data)).toEqual(Buffer.from(keyPair.publicKey.data)); + }); +}); + +describe('requests transaction signing automatically when function call has attached deposit', () => { + beforeEach(async() => { + const localKeyPair = KeyPair.fromRandom('ed25519'); + const walletKeyPair = KeyPair.fromRandom('ed25519'); + setupWalletConnectionForSigning({ + allKeys: [ walletKeyPair.publicKey.toString() ], + accountAccessKeys: [{ + access_key: { + nonce: 1, + permission: { + FunctionCall: { + allowance: '1000000000', + receiver_id: 'receiver.near', + method_names: [] + } + } + }, + public_key: localKeyPair.publicKey.toString() + }, { + access_key: { + nonce: 1, + permission: 'FullAccess' + }, + public_key: walletKeyPair.publicKey.toString() + }] + }); + await keyStore.setKey('networkId', 'signer.near', localKeyPair); + }); + + it('V2', async() => { + try { + await walletConnection.account().signAndSendTransaction({ + receiverId: 'receiver.near', + actions: [functionCall('someMethod', new Uint8Array(), new BN('1'), new BN('1'))], + walletCallbackUrl: 'http://example.com/after', + walletMeta: 'someStuff' + }); + fail('expected to throw'); + } catch (e) { + expect(e.message).toEqual('Failed to redirect to sign transaction'); + } + + const transactions = parseTransactionsFromUrl(lastRedirectUrl, 'http://example.com/after'); + expect(transactions).toHaveLength(1); + }); +}); + +describe('requests transaction signing with 2fa access key', () => { + beforeEach(async () => { + let localKeyPair = KeyPair.fromRandom('ed25519'); + let walletKeyPair = KeyPair.fromRandom('ed25519'); + setupWalletConnectionForSigning({ + allKeys: [ walletKeyPair.publicKey.toString() ], + accountAccessKeys: [{ + access_key: { + nonce: 1, + permission: { + FunctionCall: { + allowance: '1000000000', + receiver_id: 'signer.near', + method_names: [MULTISIG_HAS_METHOD] + } + } + }, + public_key: localKeyPair.publicKey.toString() + }] + }); + await keyStore.setKey('networkId', 'signer.near', localKeyPair); + }); + + it('V2', async () => { + try { + const res = await walletConnection.account().signAndSendTransaction({ + receiverId: 'receiver.near', + actions: [functionCall('someMethod', new Uint8Array(), new BN('1'), new BN('1'))] + }); + + // multisig access key is accepted res is object representing transaction, populated upon wallet redirect to app + expect(res).toHaveProperty('transaction_outcome'); + expect(res).toHaveProperty('receipts_outcome'); + } catch (e) { + fail('expected transaction outcome'); + } + }); +}); + +describe('fails requests transaction signing without 2fa access key', () => { + beforeEach(async () => { + const localKeyPair = KeyPair.fromRandom('ed25519'); + const walletKeyPair = KeyPair.fromRandom('ed25519'); + setupWalletConnectionForSigning({ + allKeys: [ walletKeyPair.publicKey.toString() ], + accountAccessKeys: [{ + access_key: { + nonce: 1, + permission: { + FunctionCall: { + allowance: '1000000000', + receiver_id: 'signer.near', + method_names: ['not_a_valid_2fa_method'] + } + } + }, + public_key: localKeyPair.publicKey.toString() + }] + }); + await keyStore.setKey('networkId', 'signer.near', localKeyPair); + }); + + it('V2', () => { + return expect( + walletConnection.account().signAndSendTransaction({ + receiverId: 'receiver.near', + actions: [functionCall('someMethod', new Uint8Array(), new BN('1'), new BN('1'))] + }) + ).rejects.toThrow('Cannot find matching key for transaction sent to receiver.near'); + }); +}); + +describe('can sign transaction locally when function call has no attached deposit', () => { + beforeEach(async () => { + const localKeyPair = KeyPair.fromRandom('ed25519'); + setupWalletConnectionForSigning({ + allKeys: [ /* no keys in wallet needed */ ], + accountAccessKeys: [{ + access_key: { + nonce: 1, + permission: { + FunctionCall: { + allowance: '1000000000', + receiver_id: 'receiver.near', + method_names: [] + } + } + }, + public_key: localKeyPair.publicKey.toString() + }] + }); + await keyStore.setKey('networkId', 'signer.near', localKeyPair); + }); + + it.each([ + functionCall('someMethod', new Uint8Array(), new BN('1'), new BN('0')), + functionCall('someMethod', new Uint8Array(), new BN('1')), + functionCall('someMethod', new Uint8Array()) + ])('V2', async (functionCall) => { + await walletConnection.account().signAndSendTransaction({ + receiverId: 'receiver.near', + actions: [ functionCall ] + }); + // NOTE: Transaction gets signed without wallet in this test + expect(lastTransaction).toMatchObject({ + transaction: { + receiverId: 'receiver.near', + signerId: 'signer.near', + actions: [{ + functionCall: { + methodName: 'someMethod', + } + }] + } + }); + }); +}); diff --git a/packages/wallet-account/tsconfig.cjs.json b/packages/wallet-account/tsconfig.cjs.json new file mode 100644 index 0000000000..d0e594ad2d --- /dev/null +++ b/packages/wallet-account/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "tsconfig/browser.json", + "compilerOptions": { + "outDir": "./lib/cjs", + "preserveSymlinks": false + }, + "files": [ + "src/index.ts" + ] +} diff --git a/packages/wallet-account/tsconfig.esm.json b/packages/wallet-account/tsconfig.esm.json new file mode 100644 index 0000000000..6142e83566 --- /dev/null +++ b/packages/wallet-account/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "tsconfig/browser.esm.json", + "compilerOptions": { + "outDir": "./lib/esm", + "preserveSymlinks": false + }, + "files": [ + "src/index.ts" + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9f92bc1ed..882c3766d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,25 +11,72 @@ importers: '@typescript-eslint/eslint-plugin': ^5.31.0 '@typescript-eslint/parser': ^5.31.0 commitlint: ^17.0.3 - eslint: ^8.20.0 + concurrently: ^7.6.0 + eslint: ^8.32.0 husky: ^7.0.4 rimraf: ^3.0.2 turbo: ^1.4.5 - typescript: ^4.7.4 + typescript: ^4.9.4 devDependencies: '@changesets/changelog-github': 0.4.7 '@changesets/cli': 2.25.2 '@commitlint/cli': 17.3.0 '@commitlint/config-conventional': 17.3.0 - '@typescript-eslint/eslint-plugin': 5.46.1_imrg37k3svwu377c6q7gkarwmi - '@typescript-eslint/parser': 5.46.1_ha6vam6werchizxrnqvarmz2zu + '@typescript-eslint/eslint-plugin': 5.46.1_r6k47pdd6iv64eqbfzxfkmzwzu + '@typescript-eslint/parser': 5.46.1_7uibuqfxkfaozanbtbziikiqje commitlint: 17.3.0 - eslint: 8.29.0 + concurrently: 7.6.0 + eslint: 8.32.0 husky: 7.0.4 rimraf: 3.0.2 turbo: 1.6.3 typescript: 4.9.4 + packages/accounts: + specifiers: + '@near-js/crypto': workspace:* + '@near-js/keystores': workspace:* + '@near-js/providers': workspace:* + '@near-js/signers': workspace:* + '@near-js/transactions': workspace:* + '@near-js/types': workspace:* + '@near-js/utils': workspace:* + '@types/node': ^18.11.18 + ajv: ^8.11.2 + ajv-formats: ^2.1.1 + bn.js: 5.2.1 + borsh: ^0.7.0 + bs58: ^4.0.0 + depd: ^2.0.0 + jest: ^26.0.1 + near-abi: 0.1.1 + near-hello: ^0.5.1 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + '@near-js/crypto': link:../crypto + '@near-js/providers': link:../providers + '@near-js/signers': link:../signers + '@near-js/transactions': link:../transactions + '@near-js/types': link:../types + '@near-js/utils': link:../utils + ajv: 8.11.2 + ajv-formats: 2.1.1_ajv@8.11.2 + bn.js: 5.2.1 + borsh: 0.7.0 + depd: 2.0.0 + near-abi: 0.1.1 + devDependencies: + '@near-js/keystores': link:../keystores + '@types/node': 18.11.18 + bs58: 4.0.1 + jest: 26.6.3 + near-hello: 0.5.1 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + packages/cookbook: specifiers: chalk: ^4.1.1 @@ -40,11 +87,129 @@ importers: homedir: 0.6.0 near-api-js: link:../near-api-js + packages/crypto: + specifiers: + '@near-js/types': workspace:* + '@types/node': ^18.11.18 + bn.js: 5.2.1 + borsh: ^0.7.0 + jest: ^26.0.1 + ts-jest: ^26.5.6 + tsconfig: workspace:* + tweetnacl: ^1.0.1 + typescript: ^4.9.4 + dependencies: + '@near-js/types': link:../types + bn.js: 5.2.1 + borsh: 0.7.0 + tweetnacl: 1.0.3 + devDependencies: + '@types/node': 18.11.18 + jest: 26.6.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + + packages/example-esm: + specifiers: + '@near-js/accounts': workspace:* + dependencies: + '@near-js/accounts': link:../accounts + + packages/example-vite: + specifiers: + '@near-js/accounts': workspace:* + '@vitejs/plugin-vue': ^4.0.0 + dayjs: ^1.11.7 + typescript: ^4.9.4 + vite: ^4.0.0 + vue: ^3.2.45 + vue-tsc: ^1.0.11 + dependencies: + '@near-js/accounts': link:../accounts + dayjs: 1.11.7 + typescript: 4.9.4 + vue: 3.2.45 + devDependencies: + '@vitejs/plugin-vue': 4.0.0_vite@4.0.4+vue@3.2.45 + vite: 4.0.4 + vue-tsc: 1.0.24_typescript@4.9.4 + + packages/keystores: + specifiers: + '@near-js/crypto': workspace:* + '@near-js/types': workspace:* + '@types/node': ^18.11.18 + jest: ^26.0.1 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + '@near-js/crypto': link:../crypto + '@near-js/types': link:../types + devDependencies: + '@types/node': 18.11.18 + jest: 26.6.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + + packages/keystores-browser: + specifiers: + '@near-js/crypto': workspace:* + '@near-js/keystores': workspace:* + '@types/node': ^18.11.18 + jest: ^26.0.1 + localstorage-memory: ^1.0.3 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + '@near-js/crypto': link:../crypto + '@near-js/keystores': link:../keystores + devDependencies: + '@types/node': 18.11.18 + jest: 26.6.3 + localstorage-memory: 1.0.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + + packages/keystores-node: + specifiers: + '@near-js/crypto': workspace:* + '@near-js/keystores': workspace:* + '@types/node': ^18.11.18 + jest: ^26.0.1 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + '@near-js/crypto': link:../crypto + '@near-js/keystores': link:../keystores + devDependencies: + '@types/node': 18.11.18 + jest: 26.6.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + packages/near-api-js: specifiers: + '@near-js/accounts': workspace:* + '@near-js/crypto': workspace:* + '@near-js/keystores': workspace:* + '@near-js/keystores-browser': workspace:* + '@near-js/keystores-node': workspace:* + '@near-js/providers': workspace:* + '@near-js/signers': workspace:* + '@near-js/transactions': workspace:* + '@near-js/types': workspace:* + '@near-js/utils': workspace:* + '@near-js/wallet-account': workspace:* '@types/bn.js': ^5.1.0 '@types/http-errors': ^1.6.1 - '@types/node': ^18.7.14 + '@types/node': ^18.11.18 ajv: ^8.11.2 ajv-formats: ^2.1.1 bn.js: 5.2.1 @@ -60,38 +225,43 @@ importers: http-errors: ^1.7.2 in-publish: ^2.0.0 jest: ^26.0.1 - js-sha256: ^0.9.0 localstorage-memory: ^1.0.3 - mustache: ^4.0.0 near-abi: 0.1.1 near-hello: ^0.5.1 node-fetch: ^2.6.1 rimraf: ^3.0.0 semver: ^7.1.1 - text-encoding-utf-8: ^1.0.2 ts-jest: ^26.5.6 tweetnacl: ^1.0.1 uglifyify: ^5.0.1 dependencies: + '@near-js/accounts': link:../accounts + '@near-js/crypto': link:../crypto + '@near-js/keystores': link:../keystores + '@near-js/keystores-browser': link:../keystores-browser + '@near-js/keystores-node': link:../keystores-node + '@near-js/providers': link:../providers + '@near-js/signers': link:../signers + '@near-js/transactions': link:../transactions + '@near-js/types': link:../types + '@near-js/utils': link:../utils + '@near-js/wallet-account': link:../wallet-account ajv: 8.11.2 ajv-formats: 2.1.1_ajv@8.11.2 bn.js: 5.2.1 borsh: 0.7.0 - bs58: 4.0.1 depd: 2.0.0 error-polyfill: 0.1.3 http-errors: 1.8.1 - js-sha256: 0.9.0 - mustache: 4.2.0 near-abi: 0.1.1 node-fetch: 2.6.7 - text-encoding-utf-8: 1.0.2 tweetnacl: 1.0.3 devDependencies: '@types/bn.js': 5.1.1 '@types/http-errors': 1.8.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 browserify: 16.5.2 + bs58: 4.0.1 bundlewatch: 0.3.3 concurrently: 7.6.0 danger: 11.2.0 @@ -105,6 +275,166 @@ importers: ts-jest: 26.5.6_jest@26.6.3 uglifyify: 5.0.2 + packages/providers: + specifiers: + '@near-js/transactions': workspace:* + '@near-js/types': workspace:* + '@near-js/utils': workspace:* + '@types/node': ^18.11.18 + bn.js: 5.2.1 + borsh: ^0.7.0 + http-errors: ^1.7.2 + jest: ^26.0.1 + node-fetch: ^2.6.1 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + '@near-js/transactions': link:../transactions + '@near-js/types': link:../types + '@near-js/utils': link:../utils + bn.js: 5.2.1 + borsh: 0.7.0 + http-errors: 1.8.1 + optionalDependencies: + node-fetch: 2.6.7 + devDependencies: + '@types/node': 18.11.18 + jest: 26.6.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + + packages/signers: + specifiers: + '@near-js/crypto': workspace:* + '@near-js/keystores': workspace:* + '@types/node': ^18.11.18 + jest: ^26.0.1 + js-sha256: ^0.9.0 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + '@near-js/crypto': link:../crypto + '@near-js/keystores': link:../keystores + js-sha256: 0.9.0 + devDependencies: + '@types/node': 18.11.18 + jest: 26.6.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + + packages/transactions: + specifiers: + '@near-js/crypto': workspace:* + '@near-js/keystores': workspace:* + '@near-js/signers': workspace:* + '@near-js/types': workspace:* + '@near-js/utils': workspace:* + '@types/node': ^18.11.18 + bn.js: 5.2.1 + borsh: ^0.7.0 + jest: ^26.0.1 + js-sha256: ^0.9.0 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + '@near-js/crypto': link:../crypto + '@near-js/signers': link:../signers + '@near-js/types': link:../types + '@near-js/utils': link:../utils + bn.js: 5.2.1 + borsh: 0.7.0 + js-sha256: 0.9.0 + devDependencies: + '@near-js/keystores': link:../keystores + '@types/node': 18.11.18 + jest: 26.6.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + + packages/tsconfig: + specifiers: {} + + packages/types: + specifiers: + '@types/node': ^18.11.18 + bn.js: 5.2.1 + jest: ^26.0.1 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + bn.js: 5.2.1 + devDependencies: + '@types/node': 18.11.18 + jest: 26.6.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + + packages/utils: + specifiers: + '@near-js/types': workspace:* + '@types/node': ^18.11.18 + bn.js: 5.2.1 + depd: ^2.0.0 + jest: ^26.0.1 + mustache: ^4.0.0 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + '@near-js/types': link:../types + bn.js: 5.2.1 + depd: 2.0.0 + mustache: 4.2.0 + devDependencies: + '@types/node': 18.11.18 + jest: 26.6.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + + packages/wallet-account: + specifiers: + '@near-js/accounts': workspace:* + '@near-js/crypto': workspace:* + '@near-js/keystores': workspace:* + '@near-js/signers': workspace:* + '@near-js/transactions': workspace:* + '@near-js/types': workspace:* + '@near-js/utils': workspace:* + '@types/node': ^18.11.18 + bn.js: 5.2.1 + borsh: ^0.7.0 + jest: ^26.0.1 + localstorage-memory: ^1.0.3 + ts-jest: ^26.5.6 + tsconfig: workspace:* + typescript: ^4.9.4 + dependencies: + '@near-js/accounts': link:../accounts + '@near-js/crypto': link:../crypto + '@near-js/keystores': link:../keystores + '@near-js/signers': link:../signers + '@near-js/transactions': link:../transactions + '@near-js/types': link:../types + '@near-js/utils': link:../utils + bn.js: 5.2.1 + borsh: 0.7.0 + devDependencies: + '@types/node': 18.11.18 + jest: 26.6.3 + localstorage-memory: 1.0.3 + ts-jest: 26.5.6_vxa7amr3o4p5wmsiameezakoli + tsconfig: link:../tsconfig + typescript: 4.9.4 + packages: /@ampproject/remapping/2.2.0: @@ -237,12 +567,10 @@ packages: /@babel/helper-string-parser/7.19.4: resolution: {integrity: sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==} engines: {node: '>=6.9.0'} - dev: true /@babel/helper-validator-identifier/7.19.1: resolution: {integrity: sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==} engines: {node: '>=6.9.0'} - dev: true /@babel/helper-validator-option/7.18.6: resolution: {integrity: sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw==} @@ -275,7 +603,6 @@ packages: hasBin: true dependencies: '@babel/types': 7.20.5 - dev: true /@babel/plugin-syntax-async-generators/7.8.4_@babel+core@7.20.5: resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} @@ -427,7 +754,6 @@ packages: '@babel/helper-string-parser': 7.19.4 '@babel/helper-validator-identifier': 7.19.1 to-fast-properties: 2.0.0 - dev: true /@bcoe/v8-coverage/0.2.3: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -819,8 +1145,206 @@ packages: '@jridgewell/trace-mapping': 0.3.9 dev: true - /@eslint/eslintrc/1.3.3: - resolution: {integrity: sha512-uj3pT6Mg+3t39fvLrj8iuCIJ38zKO9FpGtJ4BBJebJhEwjoT+KLVNCcHT5QC9NGRIEi7fZ0ZR8YRb884auB4Lg==} + /@esbuild/android-arm/0.16.17: + resolution: {integrity: sha512-N9x1CMXVhtWEAMS7pNNONyA14f71VPQN9Cnavj1XQh6T7bskqiLLrSca4O0Vr8Wdcga943eThxnVp3JLnBMYtw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64/0.16.17: + resolution: {integrity: sha512-MIGl6p5sc3RDTLLkYL1MyL8BMRN4tLMRCn+yRJJmEDvYZ2M7tmAf80hx1kbNEUX2KJ50RRtxZ4JHLvCfuB6kBg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64/0.16.17: + resolution: {integrity: sha512-a3kTv3m0Ghh4z1DaFEuEDfz3OLONKuFvI4Xqczqx4BqLyuFaFkuaG4j2MtA6fuWEFeC5x9IvqnX7drmRq/fyAQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64/0.16.17: + resolution: {integrity: sha512-/2agbUEfmxWHi9ARTX6OQ/KgXnOWfsNlTeLcoV7HSuSTv63E4DqtAc+2XqGw1KHxKMHGZgbVCZge7HXWX9Vn+w==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64/0.16.17: + resolution: {integrity: sha512-2By45OBHulkd9Svy5IOCZt376Aa2oOkiE9QWUK9fe6Tb+WDr8hXL3dpqi+DeLiMed8tVXspzsTAvd0jUl96wmg==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64/0.16.17: + resolution: {integrity: sha512-mt+cxZe1tVx489VTb4mBAOo2aKSnJ33L9fr25JXpqQqzbUIw/yzIzi+NHwAXK2qYV1lEFp4OoVeThGjUbmWmdw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64/0.16.17: + resolution: {integrity: sha512-8ScTdNJl5idAKjH8zGAsN7RuWcyHG3BAvMNpKOBaqqR7EbUhhVHOqXRdL7oZvz8WNHL2pr5+eIT5c65kA6NHug==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm/0.16.17: + resolution: {integrity: sha512-iihzrWbD4gIT7j3caMzKb/RsFFHCwqqbrbH9SqUSRrdXkXaygSZCZg1FybsZz57Ju7N/SHEgPyaR0LZ8Zbe9gQ==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64/0.16.17: + resolution: {integrity: sha512-7S8gJnSlqKGVJunnMCrXHU9Q8Q/tQIxk/xL8BqAP64wchPCTzuM6W3Ra8cIa1HIflAvDnNOt2jaL17vaW+1V0g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32/0.16.17: + resolution: {integrity: sha512-kiX69+wcPAdgl3Lonh1VI7MBr16nktEvOfViszBSxygRQqSpzv7BffMKRPMFwzeJGPxcio0pdD3kYQGpqQ2SSg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64/0.16.17: + resolution: {integrity: sha512-dTzNnQwembNDhd654cA4QhbS9uDdXC3TKqMJjgOWsC0yNCbpzfWoXdZvp0mY7HU6nzk5E0zpRGGx3qoQg8T2DQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el/0.16.17: + resolution: {integrity: sha512-ezbDkp2nDl0PfIUn0CsQ30kxfcLTlcx4Foz2kYv8qdC6ia2oX5Q3E/8m6lq84Dj/6b0FrkgD582fJMIfHhJfSw==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64/0.16.17: + resolution: {integrity: sha512-dzS678gYD1lJsW73zrFhDApLVdM3cUF2MvAa1D8K8KtcSKdLBPP4zZSLy6LFZ0jYqQdQ29bjAHJDgz0rVbLB3g==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64/0.16.17: + resolution: {integrity: sha512-ylNlVsxuFjZK8DQtNUwiMskh6nT0vI7kYl/4fZgV1llP5d6+HIeL/vmmm3jpuoo8+NuXjQVZxmKuhDApK0/cKw==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x/0.16.17: + resolution: {integrity: sha512-gzy7nUTO4UA4oZ2wAMXPNBGTzZFP7mss3aKR2hH+/4UUkCOyqmjXiKpzGrY2TlEUhbbejzXVKKGazYcQTZWA/w==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64/0.16.17: + resolution: {integrity: sha512-mdPjPxfnmoqhgpiEArqi4egmBAMYvaObgn4poorpUaqmvzzbvqbowRllQ+ZgzGVMGKaPkqUmPDOOFQRUFDmeUw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64/0.16.17: + resolution: {integrity: sha512-/PzmzD/zyAeTUsduZa32bn0ORug+Jd1EGGAUJvqfeixoEISYpGnAezN6lnJoskauoai0Jrs+XSyvDhppCPoKOA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64/0.16.17: + resolution: {integrity: sha512-2yaWJhvxGEz2RiftSk0UObqJa/b+rIAjnODJgv2GbGGpRwAfpgzyrg1WLK8rqA24mfZa9GvpjLcBBg8JHkoodg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64/0.16.17: + resolution: {integrity: sha512-xtVUiev38tN0R3g8VhRfN7Zl42YCJvyBhRKw1RJjwE1d2emWTVToPLNEQj/5Qxc6lVFATDiy6LjVHYhIPrLxzw==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64/0.16.17: + resolution: {integrity: sha512-ga8+JqBDHY4b6fQAmOgtJJue36scANy4l/rL97W+0wYmijhxKetzZdKOJI7olaBaMhWt8Pac2McJdZLxXWUEQw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32/0.16.17: + resolution: {integrity: sha512-WnsKaf46uSSF/sZhwnqE4L/F89AYNMiD4YtEcYekBt9Q7nj0DiId2XH2Ng2PHM54qi5oPrQ8luuzGszqi/veig==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64/0.16.17: + resolution: {integrity: sha512-y+EHuSchhL7FjHgvQL/0fnnFmO4T1bhvWANX6gcnqTjtnKWbTvUMCpGnv2+t+31d7RzyEAYAd4u2fnIhHL6N/Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@eslint/eslintrc/1.4.1: + resolution: {integrity: sha512-XXrH9Uarn0stsyldqDYq8r++mROmWRI1xKMXa640Bb//SY1+ECYX6VzT6Lcx5frD0V30XieqJ0oX9I2Xj5aoMA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dependencies: ajv: 6.12.6 @@ -907,7 +1431,7 @@ packages: engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 chalk: 4.1.2 jest-message-util: 26.6.2 jest-util: 26.6.2 @@ -923,7 +1447,7 @@ packages: '@jest/test-result': 26.6.2 '@jest/transform': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 ansi-escapes: 4.3.2 chalk: 4.1.2 exit: 0.1.2 @@ -960,7 +1484,7 @@ packages: dependencies: '@jest/fake-timers': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 jest-mock: 26.6.2 dev: true @@ -970,7 +1494,7 @@ packages: dependencies: '@jest/types': 26.6.2 '@sinonjs/fake-timers': 6.0.1 - '@types/node': 18.11.15 + '@types/node': 18.11.18 jest-message-util: 26.6.2 jest-mock: 26.6.2 jest-util: 26.6.2 @@ -1084,7 +1608,7 @@ packages: dependencies: '@types/istanbul-lib-coverage': 2.0.4 '@types/istanbul-reports': 3.0.1 - '@types/node': 18.11.15 + '@types/node': 18.11.18 '@types/yargs': 15.0.14 chalk: 4.1.2 dev: true @@ -1364,7 +1888,7 @@ packages: /@types/bn.js/5.1.1: resolution: {integrity: sha512-qNrYbZqMx0uJAfKnKclPh+dTwK33KfLHYqtyODwd5HnXOjnkhc4qgn3BrK6RWyGZm5+sIFE7Q7Vz6QQtJB7w7g==} dependencies: - '@types/node': 18.11.15 + '@types/node': 18.11.18 dev: true /@types/cacheable-request/6.0.3: @@ -1372,14 +1896,14 @@ packages: dependencies: '@types/http-cache-semantics': 4.0.1 '@types/keyv': 3.1.4 - '@types/node': 18.11.15 + '@types/node': 18.11.18 '@types/responselike': 1.0.0 dev: true /@types/graceful-fs/4.1.5: resolution: {integrity: sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==} dependencies: - '@types/node': 18.11.15 + '@types/node': 18.11.18 dev: true /@types/http-cache-semantics/4.0.1: @@ -1418,7 +1942,7 @@ packages: /@types/keyv/3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: - '@types/node': 18.11.15 + '@types/node': 18.11.18 dev: true /@types/minimist/1.2.2: @@ -1433,8 +1957,8 @@ packages: resolution: {integrity: sha512-hcU9AIQVHmPnmjRK+XUUYlILlr9pQrsqSrwov/JK1pnf3GTQowVBhx54FbvM0AU/VXGH4i3+vgXS5EguR7fysA==} dev: true - /@types/node/18.11.15: - resolution: {integrity: sha512-VkhBbVo2+2oozlkdHXLrb3zjsRkpdnaU2bXmX8Wgle3PUi569eLRaHGlgETQHR7lLL1w7GiG3h9SnePhxNDecw==} + /@types/node/18.11.18: + resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==} dev: true /@types/normalize-package-data/2.4.1: @@ -1452,7 +1976,7 @@ packages: /@types/responselike/1.0.0: resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} dependencies: - '@types/node': 18.11.15 + '@types/node': 18.11.18 dev: true /@types/semver/6.2.3: @@ -1477,7 +2001,7 @@ packages: '@types/yargs-parser': 21.0.0 dev: true - /@typescript-eslint/eslint-plugin/5.46.1_imrg37k3svwu377c6q7gkarwmi: + /@typescript-eslint/eslint-plugin/5.46.1_r6k47pdd6iv64eqbfzxfkmzwzu: resolution: {integrity: sha512-YpzNv3aayRBwjs4J3oz65eVLXc9xx0PDbIRisHj+dYhvBn02MjYOD96P8YGiWEIFBrojaUjxvkaUpakD82phsA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1488,12 +2012,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/parser': 5.46.1_ha6vam6werchizxrnqvarmz2zu + '@typescript-eslint/parser': 5.46.1_7uibuqfxkfaozanbtbziikiqje '@typescript-eslint/scope-manager': 5.46.1 - '@typescript-eslint/type-utils': 5.46.1_ha6vam6werchizxrnqvarmz2zu - '@typescript-eslint/utils': 5.46.1_ha6vam6werchizxrnqvarmz2zu + '@typescript-eslint/type-utils': 5.46.1_7uibuqfxkfaozanbtbziikiqje + '@typescript-eslint/utils': 5.46.1_7uibuqfxkfaozanbtbziikiqje debug: 4.3.4 - eslint: 8.29.0 + eslint: 8.32.0 ignore: 5.2.1 natural-compare-lite: 1.4.0 regexpp: 3.2.0 @@ -1504,7 +2028,7 @@ packages: - supports-color dev: true - /@typescript-eslint/parser/5.46.1_ha6vam6werchizxrnqvarmz2zu: + /@typescript-eslint/parser/5.46.1_7uibuqfxkfaozanbtbziikiqje: resolution: {integrity: sha512-RelQ5cGypPh4ySAtfIMBzBGyrNerQcmfA1oJvPj5f+H4jI59rl9xxpn4bonC0tQvUKOEN7eGBFWxFLK3Xepneg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1518,7 +2042,7 @@ packages: '@typescript-eslint/types': 5.46.1 '@typescript-eslint/typescript-estree': 5.46.1_typescript@4.9.4 debug: 4.3.4 - eslint: 8.29.0 + eslint: 8.32.0 typescript: 4.9.4 transitivePeerDependencies: - supports-color @@ -1532,7 +2056,7 @@ packages: '@typescript-eslint/visitor-keys': 5.46.1 dev: true - /@typescript-eslint/type-utils/5.46.1_ha6vam6werchizxrnqvarmz2zu: + /@typescript-eslint/type-utils/5.46.1_7uibuqfxkfaozanbtbziikiqje: resolution: {integrity: sha512-V/zMyfI+jDmL1ADxfDxjZ0EMbtiVqj8LUGPAGyBkXXStWmCUErMpW873zEHsyguWCuq2iN4BrlWUkmuVj84yng==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1543,9 +2067,9 @@ packages: optional: true dependencies: '@typescript-eslint/typescript-estree': 5.46.1_typescript@4.9.4 - '@typescript-eslint/utils': 5.46.1_ha6vam6werchizxrnqvarmz2zu + '@typescript-eslint/utils': 5.46.1_7uibuqfxkfaozanbtbziikiqje debug: 4.3.4 - eslint: 8.29.0 + eslint: 8.32.0 tsutils: 3.21.0_typescript@4.9.4 typescript: 4.9.4 transitivePeerDependencies: @@ -1578,7 +2102,7 @@ packages: - supports-color dev: true - /@typescript-eslint/utils/5.46.1_ha6vam6werchizxrnqvarmz2zu: + /@typescript-eslint/utils/5.46.1_7uibuqfxkfaozanbtbziikiqje: resolution: {integrity: sha512-RBdBAGv3oEpFojaCYT4Ghn4775pdjvwfDOfQ2P6qzNVgQOVrnSPe5/Pb88kv7xzYQjoio0eKHKB9GJ16ieSxvA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -1589,9 +2113,9 @@ packages: '@typescript-eslint/scope-manager': 5.46.1 '@typescript-eslint/types': 5.46.1 '@typescript-eslint/typescript-estree': 5.46.1_typescript@4.9.4 - eslint: 8.29.0 + eslint: 8.32.0 eslint-scope: 5.1.1 - eslint-utils: 3.0.0_eslint@8.29.0 + eslint-utils: 3.0.0_eslint@8.32.0 semver: 7.3.8 transitivePeerDependencies: - supports-color @@ -1606,6 +2130,129 @@ packages: eslint-visitor-keys: 3.3.0 dev: true + /@vitejs/plugin-vue/4.0.0_vite@4.0.4+vue@3.2.45: + resolution: {integrity: sha512-e0X4jErIxAB5oLtDqbHvHpJe/uWNkdpYV83AOG2xo2tEVSzCzewgJMtREZM30wXnM5ls90hxiOtAuVU6H5JgbA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.0.0 + vue: ^3.2.25 + dependencies: + vite: 4.0.4 + vue: 3.2.45 + dev: true + + /@volar/language-core/1.0.24: + resolution: {integrity: sha512-vTN+alJiWwK0Pax6POqrmevbtFW2dXhjwWiW/MW4f48eDYPLdyURWcr8TixO7EN/nHsUBj2udT7igFKPtjyAKg==} + dependencies: + '@volar/source-map': 1.0.24 + muggle-string: 0.1.0 + dev: true + + /@volar/source-map/1.0.24: + resolution: {integrity: sha512-Qsv/tkplx18pgBr8lKAbM1vcDqgkGKQzbChg6NW+v0CZc3G7FLmK+WrqEPzKlN7Cwdc6XVL559Nod8WKAfKr4A==} + dependencies: + muggle-string: 0.1.0 + dev: true + + /@volar/typescript/1.0.24: + resolution: {integrity: sha512-f8hCSk+PfKR1/RQHxZ79V1NpDImHoivqoizK+mstphm25tn/YJ/JnKNjZHB+o21fuW0yKlI26NV3jkVb2Cc/7A==} + dependencies: + '@volar/language-core': 1.0.24 + dev: true + + /@volar/vue-language-core/1.0.24: + resolution: {integrity: sha512-2NTJzSgrwKu6uYwPqLiTMuAzi7fAY3yFy5PJ255bGJc82If0Xr+cW8pC80vpjG0D/aVLmlwAdO4+Ya2BI8GdDg==} + dependencies: + '@volar/language-core': 1.0.24 + '@volar/source-map': 1.0.24 + '@vue/compiler-dom': 3.2.45 + '@vue/compiler-sfc': 3.2.45 + '@vue/reactivity': 3.2.45 + '@vue/shared': 3.2.45 + minimatch: 5.1.6 + vue-template-compiler: 2.7.14 + dev: true + + /@volar/vue-typescript/1.0.24: + resolution: {integrity: sha512-9a25oHDvGaNC0okRS47uqJI6FxY4hUQZUsxeOUFHcqVxZEv8s17LPuP/pMMXyz7jPygrZubB/qXqHY5jEu/akA==} + dependencies: + '@volar/typescript': 1.0.24 + '@volar/vue-language-core': 1.0.24 + dev: true + + /@vue/compiler-core/3.2.45: + resolution: {integrity: sha512-rcMj7H+PYe5wBV3iYeUgbCglC+pbpN8hBLTJvRiK2eKQiWqu+fG9F+8sW99JdL4LQi7Re178UOxn09puSXvn4A==} + dependencies: + '@babel/parser': 7.20.5 + '@vue/shared': 3.2.45 + estree-walker: 2.0.2 + source-map: 0.6.1 + + /@vue/compiler-dom/3.2.45: + resolution: {integrity: sha512-tyYeUEuKqqZO137WrZkpwfPCdiiIeXYCcJ8L4gWz9vqaxzIQRccTSwSWZ/Axx5YR2z+LvpUbmPNXxuBU45lyRw==} + dependencies: + '@vue/compiler-core': 3.2.45 + '@vue/shared': 3.2.45 + + /@vue/compiler-sfc/3.2.45: + resolution: {integrity: sha512-1jXDuWah1ggsnSAOGsec8cFjT/K6TMZ0sPL3o3d84Ft2AYZi2jWJgRMjw4iaK0rBfA89L5gw427H4n1RZQBu6Q==} + dependencies: + '@babel/parser': 7.20.5 + '@vue/compiler-core': 3.2.45 + '@vue/compiler-dom': 3.2.45 + '@vue/compiler-ssr': 3.2.45 + '@vue/reactivity-transform': 3.2.45 + '@vue/shared': 3.2.45 + estree-walker: 2.0.2 + magic-string: 0.25.9 + postcss: 8.4.21 + source-map: 0.6.1 + + /@vue/compiler-ssr/3.2.45: + resolution: {integrity: sha512-6BRaggEGqhWht3lt24CrIbQSRD5O07MTmd+LjAn5fJj568+R9eUD2F7wMQJjX859seSlrYog7sUtrZSd7feqrQ==} + dependencies: + '@vue/compiler-dom': 3.2.45 + '@vue/shared': 3.2.45 + + /@vue/reactivity-transform/3.2.45: + resolution: {integrity: sha512-BHVmzYAvM7vcU5WmuYqXpwaBHjsS8T63jlKGWVtHxAHIoMIlmaMyurUSEs1Zcg46M4AYT5MtB1U274/2aNzjJQ==} + dependencies: + '@babel/parser': 7.20.5 + '@vue/compiler-core': 3.2.45 + '@vue/shared': 3.2.45 + estree-walker: 2.0.2 + magic-string: 0.25.9 + + /@vue/reactivity/3.2.45: + resolution: {integrity: sha512-PRvhCcQcyEVohW0P8iQ7HDcIOXRjZfAsOds3N99X/Dzewy8TVhTCT4uXpAHfoKjVTJRA0O0K+6QNkDIZAxNi3A==} + dependencies: + '@vue/shared': 3.2.45 + + /@vue/runtime-core/3.2.45: + resolution: {integrity: sha512-gzJiTA3f74cgARptqzYswmoQx0fIA+gGYBfokYVhF8YSXjWTUA2SngRzZRku2HbGbjzB6LBYSbKGIaK8IW+s0A==} + dependencies: + '@vue/reactivity': 3.2.45 + '@vue/shared': 3.2.45 + + /@vue/runtime-dom/3.2.45: + resolution: {integrity: sha512-cy88YpfP5Ue2bDBbj75Cb4bIEZUMM/mAkDMfqDTpUYVgTf/kuQ2VQ8LebuZ8k6EudgH8pYhsGWHlY0lcxlvTwA==} + dependencies: + '@vue/runtime-core': 3.2.45 + '@vue/shared': 3.2.45 + csstype: 2.6.21 + + /@vue/server-renderer/3.2.45_vue@3.2.45: + resolution: {integrity: sha512-ebiMq7q24WBU1D6uhPK//2OTR1iRIyxjF5iVq/1a5I1SDMDyDu4Ts6fJaMnjrvD3MqnaiFkKQj+LKAgz5WIK3g==} + peerDependencies: + vue: 3.2.45 + dependencies: + '@vue/compiler-ssr': 3.2.45 + '@vue/shared': 3.2.45 + vue: 3.2.45 + + /@vue/shared/3.2.45: + resolution: {integrity: sha512-Ewzq5Yhimg7pSztDV+RH1UDKBzmtqieXQlpTVm2AwraoRL/Rks96mvd8Vgi7Lj+h+TH8dv7mXD3FRZR3TUvbSg==} + /JSONStream/1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -1931,7 +2578,6 @@ packages: resolution: {integrity: sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==} dependencies: safe-buffer: 5.2.1 - dev: false /base/0.11.2: resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==} @@ -1983,6 +2629,12 @@ packages: concat-map: 0.0.1 dev: true + /brace-expansion/2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + /braces/2.3.2: resolution: {integrity: sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==} engines: {node: '>=0.10.0'} @@ -2172,7 +2824,6 @@ packages: resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} dependencies: base-x: 3.0.9 - dev: false /bser/2.1.1: resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} @@ -2693,6 +3344,9 @@ packages: cssom: 0.3.8 dev: true + /csstype/2.6.21: + resolution: {integrity: sha512-Z1PhmomIfypOpoMjRQB70jfvy/wxT50qW08YXO5lMIJkrdq4yOTR+AW7FqutScmB9NkLwxo+jU+kZLbofZZq/w==} + /csv-generate/3.4.3: resolution: {integrity: sha512-w/T+rqR0vwvHqWs/1ZyMDWtHHSJaN06klRqJXBEpDJaM/+dZkso0OKh1VcuuYvK3XM53KysVNq8Ko/epCK8wOw==} dev: true @@ -2806,6 +3460,14 @@ packages: engines: {node: '>=0.11'} dev: true + /dayjs/1.11.7: + resolution: {integrity: sha512-+Yw9U6YO5TQohxLcIkrXBeY73WP3ejHWVvx8XCk3gxvQDCTEmS48ZrSZCKciI7Bhl/uCMyxYtE9UqRILmFphkQ==} + dev: false + + /de-indent/1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + dev: true + /debug/2.6.9: resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} peerDependencies: @@ -3138,6 +3800,36 @@ packages: is-symbol: 1.0.4 dev: true + /esbuild/0.16.17: + resolution: {integrity: sha512-G8LEkV0XzDMNwXKgM0Jwu3nY3lSTwSGY6XbxM9cr9+s0T/qSV1q1JVPBGzm3dcjhCic9+emZDmMffkwgPeOeLg==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.16.17 + '@esbuild/android-arm64': 0.16.17 + '@esbuild/android-x64': 0.16.17 + '@esbuild/darwin-arm64': 0.16.17 + '@esbuild/darwin-x64': 0.16.17 + '@esbuild/freebsd-arm64': 0.16.17 + '@esbuild/freebsd-x64': 0.16.17 + '@esbuild/linux-arm': 0.16.17 + '@esbuild/linux-arm64': 0.16.17 + '@esbuild/linux-ia32': 0.16.17 + '@esbuild/linux-loong64': 0.16.17 + '@esbuild/linux-mips64el': 0.16.17 + '@esbuild/linux-ppc64': 0.16.17 + '@esbuild/linux-riscv64': 0.16.17 + '@esbuild/linux-s390x': 0.16.17 + '@esbuild/linux-x64': 0.16.17 + '@esbuild/netbsd-x64': 0.16.17 + '@esbuild/openbsd-x64': 0.16.17 + '@esbuild/sunos-x64': 0.16.17 + '@esbuild/win32-arm64': 0.16.17 + '@esbuild/win32-ia32': 0.16.17 + '@esbuild/win32-x64': 0.16.17 + dev: true + /escalade/3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} engines: {node: '>=6'} @@ -3187,13 +3879,13 @@ packages: estraverse: 5.3.0 dev: true - /eslint-utils/3.0.0_eslint@8.29.0: + /eslint-utils/3.0.0_eslint@8.32.0: resolution: {integrity: sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==} engines: {node: ^10.0.0 || ^12.0.0 || >= 14.0.0} peerDependencies: eslint: '>=5' dependencies: - eslint: 8.29.0 + eslint: 8.32.0 eslint-visitor-keys: 2.1.0 dev: true @@ -3207,12 +3899,12 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint/8.29.0: - resolution: {integrity: sha512-isQ4EEiyUjZFbEKvEGJKKGBwXtvXX+zJbkVKCgTuB9t/+jUBcy8avhkEwWJecI15BkRkOYmvIM5ynbhRjEkoeg==} + /eslint/8.32.0: + resolution: {integrity: sha512-nETVXpnthqKPFyuY2FNjz/bEd6nbosRgKbkgS/y1C7LJop96gYHWpiguLecMHQ2XCPxn77DS0P+68WzG6vkZSQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} hasBin: true dependencies: - '@eslint/eslintrc': 1.3.3 + '@eslint/eslintrc': 1.4.1 '@humanwhocodes/config-array': 0.11.8 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 @@ -3223,7 +3915,7 @@ packages: doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.1.1 - eslint-utils: 3.0.0_eslint@8.29.0 + eslint-utils: 3.0.0_eslint@8.32.0 eslint-visitor-keys: 3.3.0 espree: 9.4.1 esquery: 1.4.0 @@ -3294,6 +3986,9 @@ packages: engines: {node: '>=4.0'} dev: true + /estree-walker/2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + /esutils/2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -3919,6 +4614,11 @@ packages: minimalistic-assert: 1.0.1 dev: true + /he/1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + dev: true + /hmac-drbg/1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} dependencies: @@ -4603,7 +5303,7 @@ packages: '@jest/environment': 26.6.2 '@jest/fake-timers': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 jest-mock: 26.6.2 jest-util: 26.6.2 jsdom: 16.7.0 @@ -4621,7 +5321,7 @@ packages: '@jest/environment': 26.6.2 '@jest/fake-timers': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 jest-mock: 26.6.2 jest-util: 26.6.2 dev: true @@ -4637,7 +5337,7 @@ packages: dependencies: '@jest/types': 26.6.2 '@types/graceful-fs': 4.1.5 - '@types/node': 18.11.15 + '@types/node': 18.11.18 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.10 @@ -4663,7 +5363,7 @@ packages: '@jest/source-map': 26.6.2 '@jest/test-result': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 chalk: 4.1.2 co: 4.6.0 expect: 26.6.2 @@ -4722,7 +5422,7 @@ packages: engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 dev: true /jest-pnp-resolver/1.2.3_jest-resolve@26.6.2: @@ -4775,7 +5475,7 @@ packages: '@jest/environment': 26.6.2 '@jest/test-result': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 chalk: 4.1.2 emittery: 0.7.2 exit: 0.1.2 @@ -4843,7 +5543,7 @@ packages: resolution: {integrity: sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==} engines: {node: '>= 10.14.2'} dependencies: - '@types/node': 18.11.15 + '@types/node': 18.11.18 graceful-fs: 4.2.10 dev: true @@ -4876,7 +5576,7 @@ packages: engines: {node: '>= 10.14.2'} dependencies: '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 chalk: 4.1.2 graceful-fs: 4.2.10 is-ci: 2.0.0 @@ -4901,7 +5601,7 @@ packages: dependencies: '@jest/test-result': 26.6.2 '@jest/types': 26.6.2 - '@types/node': 18.11.15 + '@types/node': 18.11.18 ansi-escapes: 4.3.2 chalk: 4.1.2 jest-util: 26.6.2 @@ -4912,7 +5612,7 @@ packages: resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} engines: {node: '>= 10.13.0'} dependencies: - '@types/node': 18.11.15 + '@types/node': 18.11.18 merge-stream: 2.0.0 supports-color: 7.2.0 dev: true @@ -5321,6 +6021,11 @@ packages: yallist: 4.0.0 dev: true + /magic-string/0.25.9: + resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} + dependencies: + sourcemap-codec: 1.4.8 + /make-dir/3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -5502,6 +6207,13 @@ packages: brace-expansion: 1.1.11 dev: true + /minimatch/5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + /minimist-options/4.1.0: resolution: {integrity: sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==} engines: {node: '>= 6'} @@ -5572,11 +6284,20 @@ packages: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true + /muggle-string/0.1.0: + resolution: {integrity: sha512-Tr1knR3d2mKvvWthlk7202rywKbiOm4rVFLsfAaSIhJ6dt9o47W4S+JMtWhd/PW9Wrdew2/S2fSvhz3E2gkfEg==} + dev: true + /mustache/4.2.0: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true dev: false + /nanoid/3.3.4: + resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + /nanomatch/1.2.13: resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==} engines: {node: '>=0.10.0'} @@ -5625,6 +6346,7 @@ packages: /node-fetch/2.6.7: resolution: {integrity: sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==} engines: {node: 4.x || >=6.0.0} + requiresBuild: true peerDependencies: encoding: ^0.1.0 peerDependenciesMeta: @@ -6002,7 +6724,6 @@ packages: /picocolors/1.0.0: resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} - dev: true /picomatch/2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} @@ -6035,6 +6756,14 @@ packages: engines: {node: '>=0.10.0'} dev: true + /postcss/8.4.21: + resolution: {integrity: sha512-tP7u/Sn/dVxK2NnruI4H9BG+x+Wxz6oeZ1cJ8P6G/PZY0IKk4k/63TDsQf2kQq3+qoJeLm2kIBUNlZe3zgb4Zg==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.4 + picocolors: 1.0.0 + source-map-js: 1.0.2 + /preferred-pm/3.0.3: resolution: {integrity: sha512-+wZgbxNES/KlJs9q40F/1sfOd/j7f1O9JaHcW5Dsn3aUUOZg3L2bjpVUcKV2jvtElYfoTuQiNeMfQJ4kwUAhCQ==} engines: {node: '>=10'} @@ -6398,6 +7127,14 @@ packages: inherits: 2.0.4 dev: true + /rollup/3.10.0: + resolution: {integrity: sha512-JmRYz44NjC1MjVF2VKxc0M1a97vn+cDxeqWmnwyAF4FvpjK8YFdHpaqvQB+3IxCvX05vJxKZkoMDU8TShhmJVA==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.2 + dev: true + /rsvp/4.8.5: resolution: {integrity: sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==} engines: {node: 6.* || >= 7.*} @@ -6634,6 +7371,10 @@ packages: - supports-color dev: true + /source-map-js/1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + /source-map-resolve/0.5.3: resolution: {integrity: sha512-Htz+RnsXWk5+P2slx5Jh3Q66vhQj1Cllm0zvnaY98+NFx+Dv2CF/f5O/t8x+KaNdrdIAsruNzoh/KpialbqAnw==} deprecated: See https://github.com/lydell/source-map-resolve#deprecated @@ -6665,13 +7406,16 @@ packages: /source-map/0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - dev: true /source-map/0.7.4: resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} engines: {node: '>= 8'} dev: true + /sourcemap-codec/1.4.8: + resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} + deprecated: Please use @jridgewell/sourcemap-codec instead + /spawn-command/0.0.2-1: resolution: {integrity: sha512-n98l9E2RMSJ9ON1AKisHzz7V42VDiBQGY6PB1BwRglz99wpVsSuGzQ+jOi6lFXBGVTCrRpltvjm+/XA+tpeJrg==} dev: true @@ -7017,7 +7761,6 @@ packages: /to-fast-properties/2.0.0: resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} engines: {node: '>=4'} - dev: true /to-object-path/0.3.0: resolution: {integrity: sha512-9mWHdnGRuh3onocaHzukyvCZhzvr6tiflAy/JRFXcJX0TjgfWA9pk9t8CMbzmBE4Jfw58pXbkngtBtqYxzNEyg==} @@ -7103,7 +7846,29 @@ packages: lodash: 4.17.21 make-error: 1.3.6 mkdirp: 1.0.4 - semver: 7.3.8 + semver: 7.3.7 + yargs-parser: 20.2.9 + dev: true + + /ts-jest/26.5.6_vxa7amr3o4p5wmsiameezakoli: + resolution: {integrity: sha512-rua+rCP8DxpA8b4DQD/6X2HQS8Zy/xzViVYfEs2OQu68tkCuKLV0Md8pmX55+W24uRIyAsf/BajRfxOs+R2MKA==} + engines: {node: '>= 10'} + hasBin: true + peerDependencies: + jest: '>=26 <27' + typescript: '>=3.8 <5.0' + dependencies: + bs-logger: 0.2.6 + buffer-from: 1.1.2 + fast-json-stable-stringify: 2.1.0 + jest: 26.6.3 + jest-util: 26.6.2 + json5: 2.2.1 + lodash: 4.17.21 + make-error: 1.3.6 + mkdirp: 1.0.4 + semver: 7.3.7 + typescript: 4.9.4 yargs-parser: 20.2.9 dev: true @@ -7302,7 +8067,6 @@ packages: resolution: {integrity: sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==} engines: {node: '>=4.2.0'} hasBin: true - dev: true /u3/0.1.1: resolution: {integrity: sha512-+J5D5ir763y+Am/QY6hXNRlwljIeRMZMGs0cT6qqZVVzzT3X3nFPXVyPOFRMOR4kupB0T8JnCdpWdp6Q/iXn3w==} @@ -7462,10 +8226,70 @@ packages: spdx-expression-parse: 3.0.1 dev: true + /vite/4.0.4: + resolution: {integrity: sha512-xevPU7M8FU0i/80DMR+YhgrzR5KS2ORy1B4xcX/cXLsvnUWvfHuqMmVU6N0YiJ4JWGRJJsLCgjEzKjG9/GKoSw==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + esbuild: 0.16.17 + postcss: 8.4.21 + resolve: 1.22.1 + rollup: 3.10.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + /vm-browserify/1.1.2: resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} dev: true + /vue-template-compiler/2.7.14: + resolution: {integrity: sha512-zyA5Y3ArvVG0NacJDkkzJuPQDF8RFeRlzV2vLeSnhSpieO6LK2OVbdLPi5MPPs09Ii+gMO8nY4S3iKQxBxDmWQ==} + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + dev: true + + /vue-tsc/1.0.24_typescript@4.9.4: + resolution: {integrity: sha512-mmU1s5SAqE1nByQAiQnao9oU4vX+mSdsgI8H57SfKH6UVzq/jP9+Dbi2GaV+0b4Cn361d2ln8m6xeU60ApiEXg==} + hasBin: true + peerDependencies: + typescript: '*' + dependencies: + '@volar/vue-language-core': 1.0.24 + '@volar/vue-typescript': 1.0.24 + typescript: 4.9.4 + dev: true + + /vue/3.2.45: + resolution: {integrity: sha512-9Nx/Mg2b2xWlXykmCwiTUCWHbWIj53bnkizBxKai1g61f2Xit700A1ljowpTIM11e3uipOeiPcSqnmBg6gyiaA==} + dependencies: + '@vue/compiler-dom': 3.2.45 + '@vue/compiler-sfc': 3.2.45 + '@vue/runtime-dom': 3.2.45 + '@vue/server-renderer': 3.2.45_vue@3.2.45 + '@vue/shared': 3.2.45 + /w3c-hr-time/1.0.2: resolution: {integrity: sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==} deprecated: Use your platform's native performance.now() and performance.timeOrigin. diff --git a/turbo.json b/turbo.json index 27e8bde219..18325e5630 100644 --- a/turbo.json +++ b/turbo.json @@ -3,16 +3,34 @@ "pipeline": { "build": { "dependsOn": ["^build"], - "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"], - "outputs": ["dist/"] + "inputs": ["src/**/*.ts", "test/**/*.js"], + "outputs": ["dist/**", "lib/**"] }, "test": { - "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"] + /* TODO remove once near-api-js tests are removed or packages/accounts/tests gets its own set of keys */ + "dependsOn": ["^test"], + "inputs": ["src/**/*.ts", "test/**/*.js"] }, "lint": { - "inputs": ["src/**/*.tsx", "src/**/*.ts", "test/**/*.ts", "test/**/*.tsx"], + "inputs": ["src/**/*.ts", "test/**/*.js"], "outputs": [] }, + "lint:js": { + "inputs": ["test/**/*.js"], + "outputs": [] + }, + "lint:js:fix": { + "inputs": ["test/**/*.js"], + "outputs": [] + }, + "lint:ts": { + "inputs": ["src/**/*.ts"], + "outputs": [] + }, + "lint:ts:fix": { + "inputs": ["src/**/*.ts"], + "outputs": [] + }, "clean": { "outputs": [], "cache": false