diff --git a/.changeset/little-chefs-decide.md b/.changeset/little-chefs-decide.md new file mode 100644 index 000000000..a8ff0391b --- /dev/null +++ b/.changeset/little-chefs-decide.md @@ -0,0 +1,5 @@ +--- +"@near-js/accounts": minor +--- + +Extend Contract class to accept Connection object diff --git a/.changeset/mighty-paws-tap.md b/.changeset/mighty-paws-tap.md new file mode 100644 index 000000000..683aa87d0 --- /dev/null +++ b/.changeset/mighty-paws-tap.md @@ -0,0 +1,5 @@ +--- +"@near-js/biometric-ed25519": minor +--- + +Include sanitization on navigator.credentials response to support Bitwarden password manager diff --git a/packages/accounts/src/account.ts b/packages/accounts/src/account.ts index 0eb29095d..feb61ab18 100644 --- a/packages/accounts/src/account.ts +++ b/packages/accounts/src/account.ts @@ -15,11 +15,9 @@ import { FinalExecutionOutcome, TypedError, ErrorContext, - ViewStateResult, AccountView, AccessKeyView, AccessKeyViewRaw, - CodeResult, AccessKeyList, AccessKeyInfoView, FunctionCallPermissionView, @@ -31,11 +29,12 @@ import { Logger, parseResultError, DEFAULT_FUNCTION_CALL_GAS, - printTxOutcomeLogs, printTxOutcomeLogsAndFailures, } from '@near-js/utils'; import { Connection } from './connection'; +import { viewFunction, viewState } from './utils'; +import { ChangeFunctionCallOptions, IntoConnection, ViewFunctionCallOptions } from './interface'; const { addKey, @@ -91,50 +90,6 @@ export interface SignAndSendTransactionOptions { returnError?: boolean; } -/** - * Options used to initiate a function call (especially a change function call) - * @see {@link Account#viewFunction | 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?: bigint; - /** amount of NEAR (in yoctoNEAR) to send together with the call */ - attachedDeposit?: bigint; - /** - * 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 RequestSignTransactionsOptions - */ - walletMeta?: string; - /** - * Callback url to send the NEAR Wallet if using it to sign transactions. - * @see RequestSignTransactionsOptions - */ - walletCallbackUrl?: string; -} -export interface ViewFunctionCallOptions extends FunctionCallOptions { - parse?: (response: Uint8Array) => any; - blockQuery?: BlockReference; -} - interface StakedBalance { validatorId: string; amount?: string; @@ -153,18 +108,10 @@ interface SignedDelegateOptions { receiverId: 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 "@near-js/crypto".key_pair.KeyPair | KeyPair}. */ -export class Account { +export class Account implements IntoConnection { readonly connection: Connection; readonly accountId: string; @@ -173,6 +120,10 @@ export class Account { this.accountId = accountId; } + getConnection(): Connection { + return this.connection; + } + /** * 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) @@ -544,38 +495,8 @@ export class Account { * @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)); + async viewFunction(options: ViewFunctionCallOptions): Promise { + return await viewFunction(this.connection, options); } /** @@ -587,17 +508,7 @@ export class Account { * @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') - })); + return await viewState(this.connection, this.accountId, prefix, blockQuery); } /** diff --git a/packages/accounts/src/connection.ts b/packages/accounts/src/connection.ts index 3c27a3a45..c3e20bebb 100644 --- a/packages/accounts/src/connection.ts +++ b/packages/accounts/src/connection.ts @@ -1,5 +1,6 @@ import { Signer, InMemorySigner } from '@near-js/signers'; import { Provider, JsonRpcProvider } from '@near-js/providers'; +import { IntoConnection } from './interface'; /** * @param config Contains connection info details @@ -32,7 +33,7 @@ function getSigner(config: any): Signer { /** * Connects an account to a given network via a given provider */ -export class Connection { +export class Connection implements IntoConnection { readonly networkId: string; readonly provider: Provider; readonly signer: Signer; @@ -45,6 +46,10 @@ export class Connection { this.jsvmAccountId = jsvmAccountId; } + getConnection(): Connection { + return this; + } + /** * @param config Contains connection info details */ diff --git a/packages/accounts/src/contract.ts b/packages/accounts/src/contract.ts index f3afe2da8..a75dcb74c 100644 --- a/packages/accounts/src/contract.ts +++ b/packages/accounts/src/contract.ts @@ -17,6 +17,9 @@ import { ArgumentSchemaError, ConflictingOptions, } from "./errors"; +import { IntoConnection } from "./interface"; +import { Connection } from "./connection"; +import { viewFunction } from "./utils"; // Makes `function.name` return given name function nameFunction(name: string, body: (args?: any[]) => any) { @@ -84,6 +87,7 @@ const isObject = (x: any) => Object.prototype.toString.call(x) === "[object Object]"; interface ChangeMethodOptions { + signerAccount?: Account; args: object; methodName: string; gas?: bigint; @@ -153,7 +157,9 @@ export interface ContractMethods { * ``` */ export class Contract { - readonly account: Account; + /** @deprecated */ + readonly account?: Account; + readonly connection: Connection; readonly contractId: string; readonly lve: LocalViewExecution; @@ -163,13 +169,22 @@ export class Contract { * @param options NEAR smart contract methods that your application will use. These will be available as `contract.methodName` */ constructor( - account: Account, + connection: IntoConnection, contractId: string, options: ContractMethods ) { - this.account = account; + this.connection = connection.getConnection(); + if (connection instanceof Account) { + const deprecate = depd( + "new Contract(account, contractId, options)" + ); + deprecate( + "use `new Contract(connection, contractId, options)` instead" + ); + this.account = connection; + } this.contractId = contractId; - this.lve = new LocalViewExecution(account); + this.lve = new LocalViewExecution(connection); const { viewMethods = [], changeMethods = [], @@ -235,7 +250,16 @@ export class Contract { } } - return this.account.viewFunction({ + if (this.account) { + return this.account.viewFunction({ + contractId: this.contractId, + methodName: name, + args, + ...options, + }); + } + + return viewFunction(this.connection, { contractId: this.contractId, methodName: name, args, @@ -263,7 +287,7 @@ export class Contract { "contract.methodName(args, gas, amount)" ); deprecate( - "use `contract.methodName({ args, gas?, amount?, callbackUrl?, meta? })` instead" + "use `contract.methodName({ signerAccount, args, gas?, amount?, callbackUrl?, meta? })` instead" ); args[0] = { args: args[0], @@ -283,6 +307,7 @@ export class Contract { } private async _changeMethod({ + signerAccount, args, methodName, gas, @@ -292,7 +317,11 @@ export class Contract { }: ChangeMethodOptions) { validateBNLike({ gas, amount }); - const rawResult = await this.account.functionCall({ + const account = this.account || signerAccount; + + if (!account) throw new Error(`signerAccount must be specified`); + + const rawResult = await account.functionCall({ contractId: this.contractId, methodName, args, diff --git a/packages/accounts/src/index.ts b/packages/accounts/src/index.ts index fe936335d..f8fe945e6 100644 --- a/packages/accounts/src/index.ts +++ b/packages/accounts/src/index.ts @@ -2,10 +2,7 @@ export { Account, AccountBalance, AccountAuthorizedApp, - SignAndSendTransactionOptions, - FunctionCallOptions, - ChangeFunctionCallOptions, - ViewFunctionCallOptions, + SignAndSendTransactionOptions } from './account'; export { Account2FA } from './account_2fa'; export { @@ -37,3 +34,8 @@ export { MultisigDeleteRequestRejectionError, MultisigStateStatus, } from './types'; +export { + FunctionCallOptions, + ChangeFunctionCallOptions, + ViewFunctionCallOptions, +} from './interface'; \ No newline at end of file diff --git a/packages/accounts/src/interface.ts b/packages/accounts/src/interface.ts new file mode 100644 index 000000000..bc6977179 --- /dev/null +++ b/packages/accounts/src/interface.ts @@ -0,0 +1,50 @@ +import { BlockReference } from "@near-js/types"; +import type { Connection } from "./connection"; + +export interface IntoConnection { + getConnection(): Connection; +} + +/** + * Options used to initiate a function call (especially a change function call) + * @see {@link Account#viewFunction | 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?: bigint; + /** amount of NEAR (in yoctoNEAR) to send together with the call */ + attachedDeposit?: bigint; + /** + * 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 RequestSignTransactionsOptions + */ + walletMeta?: string; + /** + * Callback url to send the NEAR Wallet if using it to sign transactions. + * @see RequestSignTransactionsOptions + */ + walletCallbackUrl?: string; +} +export interface ViewFunctionCallOptions extends FunctionCallOptions { + parse?: (response: Uint8Array) => any; + blockQuery?: BlockReference; +} \ No newline at end of file diff --git a/packages/accounts/src/local-view-execution/index.ts b/packages/accounts/src/local-view-execution/index.ts index 66af734a0..fab8782a4 100644 --- a/packages/accounts/src/local-view-execution/index.ts +++ b/packages/accounts/src/local-view-execution/index.ts @@ -1,25 +1,28 @@ import { BlockReference, ContractCodeView } from '@near-js/types'; import { printTxOutcomeLogs } from '@near-js/utils'; -import { Account, FunctionCallOptions } from '../account'; +import { FunctionCallOptions } from '../interface'; import { Storage } from './storage'; import { Runtime } from './runtime'; import { ContractState } from './types'; +import { viewState } from '../utils'; +import { Connection } from '../connection'; +import { IntoConnection } from '../interface'; interface ViewFunctionCallOptions extends FunctionCallOptions { blockQuery?: BlockReference } export class LocalViewExecution { - private readonly account: Account; + private readonly connection: Connection; private readonly storage: Storage; - constructor(account: Account) { - this.account = account; + constructor(connection: IntoConnection) { + this.connection = connection.getConnection(); this.storage = new Storage(); } private async fetchContractCode(contractId: string, blockQuery: BlockReference) { - const result = await this.account.connection.provider.query({ + const result = await this.connection.provider.query({ request_type: 'view_code', account_id: contractId, ...blockQuery, @@ -28,18 +31,18 @@ export class LocalViewExecution { return result.code_base64; } - private async fetchContractState(blockQuery: BlockReference): Promise { - return this.account.viewState('', blockQuery); + private async fetchContractState(contractId: string, blockQuery: BlockReference): Promise { + return viewState(this.connection, contractId, '', blockQuery); } private async fetch(contractId: string, blockQuery: BlockReference) { - const block = await this.account.connection.provider.block(blockQuery); + const block = await this.connection.provider.block(blockQuery); const blockHash = block.header.hash; const blockHeight = block.header.height; const blockTimestamp = block.header.timestamp; const contractCode = await this.fetchContractCode(contractId, blockQuery); - const contractState = await this.fetchContractState(blockQuery); + const contractState = await this.fetchContractState(contractId, blockQuery); return { blockHash, diff --git a/packages/accounts/src/utils.ts b/packages/accounts/src/utils.ts new file mode 100644 index 000000000..6a0bdca49 --- /dev/null +++ b/packages/accounts/src/utils.ts @@ -0,0 +1,105 @@ +import { + ViewStateResult, + BlockReference, + CodeResult, + PositionalArgsError, +} from '@near-js/types'; +import { Connection } from './connection'; +import { printTxOutcomeLogs } from '@near-js/utils'; +import { ViewFunctionCallOptions } from './interface'; + +function parseJsonFromRawResponse(response: Uint8Array): any { + return JSON.parse(Buffer.from(response).toString()); +} + +function bytesJsonStringify(input: any): Buffer { + return Buffer.from(JSON.stringify(input)); +} + +export function 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(); + } +} + +export function encodeJSContractArgs(contractId: string, method: string, args) { + return Buffer.concat([Buffer.from(contractId), Buffer.from([0]), Buffer.from(method), Buffer.from([0]), Buffer.from(args)]); +} + +/** + * Returns the state (key value pairs) of 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 connection connection to query state from + * @param accountId account whose state is viewed + * @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). + */ +export async function viewState(connection: Connection, accountId: string, prefix: string | Uint8Array, blockQuery: BlockReference = { finality: 'optimistic' }): Promise> { + const { values } = await connection.provider.query({ + request_type: 'view_state', + ...blockQuery, + account_id: accountId, + prefix_base64: Buffer.from(prefix).toString('base64') + }); + + return values.map(({ key, value }) => ({ + key: Buffer.from(key, 'base64'), + value: Buffer.from(value, 'base64') + })); +} + + +/** + * 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 options Function call options. + * @param options.contractId NEAR account where the contract is deployed + * @param options.methodName The view-only method (no state mutations) name on the contract as it is written in the contract code + * @param options.args Any arguments to the view contract method, wrapped in JSON + * @param options.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 options.stringify Convert input arguments into a bytes array. By default the input is treated as a JSON. + * @param options.jsContract Is contract from JS SDK, automatically encodes args from JS SDK to binary. + * @param options.blockQuery specifies which block to query state at. By default returns last "optimistic" block (i.e. not necessarily finalized). + * @returns {Promise} + */ +export async function viewFunction(connection: Connection, { + contractId, + methodName, + args = {}, + parse = parseJsonFromRawResponse, + stringify = bytesJsonStringify, + jsContract = false, + blockQuery = { finality: 'optimistic' } +}: ViewFunctionCallOptions): Promise { + let encodedArgs; + + validateArgs(args); + + if (jsContract) { + encodedArgs = encodeJSContractArgs(contractId, methodName, Object.keys(args).length > 0 ? JSON.stringify(args) : ''); + } else { + encodedArgs = stringify(args); + } + + const result = await connection.provider.query({ + request_type: 'call_function', + ...blockQuery, + account_id: jsContract ? 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)); +} \ No newline at end of file diff --git a/packages/accounts/test/contract.test.js b/packages/accounts/test/contract.test.js index 452c0a5aa..16587a59b 100644 --- a/packages/accounts/test/contract.test.js +++ b/packages/accounts/test/contract.test.js @@ -1,16 +1,19 @@ const { PositionalArgsError } = require('@near-js/types'); -const { Contract } = require('../lib'); +const { Contract, Account } = require('../lib'); const testUtils = require('./test-utils'); -const account = { +const account = Object.setPrototypeOf({ + getConnection() { + return {}; + }, viewFunction({ contractId, methodName, args, parse, stringify, jsContract, blockQuery }) { return { this: this, contractId, methodName, args, parse, stringify, jsContract, blockQuery }; }, functionCall() { return this; } -}; +}, Account.prototype); const contract = new Contract(account, 'contractId', { viewMethods: ['viewMethod'], @@ -171,4 +174,56 @@ describe('local view execution', () => { }); } }); -}); \ No newline at end of file +}); + +describe('contract without account', () => { + let nearjs; + let workingAccount; + let contract; + + jest.setTimeout(60000); + + beforeAll(async () => { + nearjs = await testUtils.setUpTestConnection(); + workingAccount = await testUtils.createAccount(nearjs); + const contractId = testUtils.generateUniqueString('guestbook'); + await testUtils.deployContractGuestBook(workingAccount, contractId); + + contract = new Contract(nearjs.connection, contractId, { + viewMethods: ['total_messages', 'get_messages'], + changeMethods: ['add_message'], + }); + }); + + test('view & change methods work', async () => { + const totalMessagesBefore = await contract.total_messages({}); + expect(totalMessagesBefore).toBe(0); + + await contract.add_message({ + signerAccount: workingAccount, + args: { + text: 'first message', + } + }); + await contract.add_message({ + signerAccount: workingAccount, + args: { + text: 'second message', + } + }); + + const totalMessagesAfter = await contract.total_messages({}); + expect(totalMessagesAfter).toBe(2); + + const messages = await contract.get_messages({}); + expect(messages.length).toBe(2); + expect(messages[0].text).toEqual('first message'); + expect(messages[1].text).toEqual('second message'); + }); + + test('fails to call add_message() without signerAccount', async () => { + await expect( + contract.add_message({ text: 'third message' }) + ).rejects.toThrow(/signerAccount must be specified/); + }); +}); diff --git a/packages/accounts/test/contract_abi.test.js b/packages/accounts/test/contract_abi.test.js index 72a73187d..278863dc5 100644 --- a/packages/accounts/test/contract_abi.test.js +++ b/packages/accounts/test/contract_abi.test.js @@ -1,5 +1,4 @@ -const { Contract } = require('../src/contract'); -const { ArgumentSchemaError, UnknownArgumentError, UnsupportedSerializationError } = require('../src/errors'); +const { Account, Contract, ArgumentSchemaError, UnknownArgumentError, UnsupportedSerializationError } = require('../lib'); let rawAbi = `{ "schema_version": "0.3.0", @@ -93,14 +92,17 @@ let rawAbi = `{ } }`; -const account = { +const account = Object.setPrototypeOf({ + getConnection() { + return {}; + }, viewFunction({ contractId, methodName, args, parse, stringify, jsContract, blockQuery }) { return { this: this, contractId, methodName, args, parse, stringify, jsContract, blockQuery }; }, functionCall() { return this; } -}; +}, Account.prototype); const abi = JSON.parse(rawAbi); diff --git a/packages/biometric-ed25519/src/index.ts b/packages/biometric-ed25519/src/index.ts index f0016e451..6835b159b 100644 --- a/packages/biometric-ed25519/src/index.ts +++ b/packages/biometric-ed25519/src/index.ts @@ -12,7 +12,8 @@ import { preformatGetAssertReq, publicKeyCredentialToJSON, recoverPublicKey, - uint8ArrayToBigInt + uint8ArrayToBigInt, + convertToArrayBuffer } from './utils'; import { Fido2 } from './fido2'; import { AssertionResponse } from './index.d'; @@ -63,8 +64,10 @@ export const createKey = async (username: string): Promise => { throw new PasskeyProcessCanceled('Failed to retrieve response from navigator.credentials.create'); } + const sanitizedResponse = convertToArrayBuffer(res); + const result = await f2l.attestation({ - clientAttestationResponse: res, + clientAttestationResponse: sanitizedResponse, origin, challenge: challengeMakeCred.challenge }); @@ -93,7 +96,8 @@ export const getKeys = async (username: string): Promise<[KeyPair, KeyPair]> => setBufferIfUndefined(); return navigator.credentials.get({ publicKey }) .then(async (response: Credential) => { - const getAssertionResponse: AssertionResponse = publicKeyCredentialToJSON(response); + const sanitizedResponse = convertToArrayBuffer(response); + const getAssertionResponse: AssertionResponse = publicKeyCredentialToJSON(sanitizedResponse); const signature = base64.toArrayBuffer(getAssertionResponse.response.signature, true); // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/packages/biometric-ed25519/src/utils.ts b/packages/biometric-ed25519/src/utils.ts index c9b5647a3..b5f48bcf7 100644 --- a/packages/biometric-ed25519/src/utils.ts +++ b/packages/biometric-ed25519/src/utils.ts @@ -90,4 +90,19 @@ export const recoverPublicKey = async (r, s, message, recovery) => { export const uint8ArrayToBigInt = (uint8Array: Uint8Array) => { const array = Array.from(uint8Array); return BigInt('0x' + array.map(byte => byte.toString(16).padStart(2, '0')).join('')); +}; + +// This function is tries converts Uint8Array, Array or object to ArrayBuffer. Returns the original object if it doesn't match any of the aforementioned types. +export const convertToArrayBuffer = (obj) => { + if (obj instanceof Uint8Array) { + return obj.buffer.slice(obj.byteOffset, obj.byteOffset + obj.byteLength); + } else if (Array.isArray(obj)) { + return obj.map(convertToArrayBuffer); + } else if (obj !== null && typeof obj === 'object') { + return Object.keys(obj).reduce((acc, key) => { + acc[key] = convertToArrayBuffer(obj[key]); + return acc; + }, {}); + } + return obj; }; \ No newline at end of file