diff --git a/package.json b/package.json index 02c64e9..c7493ec 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@gnolang/tm2-js-client", - "version": "1.0.4", + "version": "1.0.5", "description": "Tendermint2 JS / TS Client", "main": "./bin/index.js", "repository": { diff --git a/src/provider/errors/errors.ts b/src/provider/errors/errors.ts new file mode 100644 index 0000000..1fac457 --- /dev/null +++ b/src/provider/errors/errors.ts @@ -0,0 +1,160 @@ +import { + GasOverflowErrorMessage, + InsufficientCoinsErrorMessage, + InsufficientFeeErrorMessage, + InsufficientFundsErrorMessage, + InternalErrorMessage, + InvalidAddressErrorMessage, + InvalidCoinsErrorMessage, + InvalidGasWantedErrorMessage, + InvalidPubKeyErrorMessage, + InvalidSequenceErrorMessage, + MemoTooLargeErrorMessage, + NoSignaturesErrorMessage, + OutOfGasErrorMessage, + TooManySignaturesErrorMessage, + TxDecodeErrorMessage, + UnauthorizedErrorMessage, + UnknownAddressErrorMessage, + UnknownRequestErrorMessage, +} from './messages'; + +class TM2Error extends Error { + public log?: string; + + constructor(message: string, log?: string) { + super(message); + + this.log = log; + } +} + +class InternalError extends TM2Error { + constructor(log?: string) { + super(InternalErrorMessage, log); + } +} + +class TxDecodeError extends TM2Error { + constructor(log?: string) { + super(TxDecodeErrorMessage, log); + } +} + +class InvalidSequenceError extends TM2Error { + constructor(log?: string) { + super(InvalidSequenceErrorMessage, log); + } +} + +class UnauthorizedError extends TM2Error { + constructor(log?: string) { + super(UnauthorizedErrorMessage, log); + } +} + +class InsufficientFundsError extends TM2Error { + constructor(log?: string) { + super(InsufficientFundsErrorMessage, log); + } +} + +class UnknownRequestError extends TM2Error { + constructor(log?: string) { + super(UnknownRequestErrorMessage, log); + } +} + +class InvalidAddressError extends TM2Error { + constructor(log?: string) { + super(InvalidAddressErrorMessage, log); + } +} + +class UnknownAddressError extends TM2Error { + constructor(log?: string) { + super(UnknownAddressErrorMessage, log); + } +} + +class InvalidPubKeyError extends TM2Error { + constructor(log?: string) { + super(InvalidPubKeyErrorMessage, log); + } +} + +class InsufficientCoinsError extends TM2Error { + constructor(log?: string) { + super(InsufficientCoinsErrorMessage, log); + } +} + +class InvalidCoinsError extends TM2Error { + constructor(log?: string) { + super(InvalidCoinsErrorMessage, log); + } +} + +class InvalidGasWantedError extends TM2Error { + constructor(log?: string) { + super(InvalidGasWantedErrorMessage, log); + } +} + +class OutOfGasError extends TM2Error { + constructor(log?: string) { + super(OutOfGasErrorMessage, log); + } +} + +class MemoTooLargeError extends TM2Error { + constructor(log?: string) { + super(MemoTooLargeErrorMessage, log); + } +} + +class InsufficientFeeError extends TM2Error { + constructor(log?: string) { + super(InsufficientFeeErrorMessage, log); + } +} + +class TooManySignaturesError extends TM2Error { + constructor(log?: string) { + super(TooManySignaturesErrorMessage, log); + } +} + +class NoSignaturesError extends TM2Error { + constructor(log?: string) { + super(NoSignaturesErrorMessage, log); + } +} + +class GasOverflowError extends TM2Error { + constructor(log?: string) { + super(GasOverflowErrorMessage, log); + } +} + +export { + TM2Error, + InternalError, + TxDecodeError, + InvalidSequenceError, + UnauthorizedError, + InsufficientFundsError, + UnknownRequestError, + InvalidAddressError, + UnknownAddressError, + InvalidPubKeyError, + InsufficientCoinsError, + InvalidCoinsError, + InvalidGasWantedError, + OutOfGasError, + MemoTooLargeError, + InsufficientFeeError, + TooManySignaturesError, + NoSignaturesError, + GasOverflowError, +}; diff --git a/src/provider/errors/index.ts b/src/provider/errors/index.ts new file mode 100644 index 0000000..f72bc43 --- /dev/null +++ b/src/provider/errors/index.ts @@ -0,0 +1 @@ +export * from './errors'; diff --git a/src/provider/errors/messages.ts b/src/provider/errors/messages.ts new file mode 100644 index 0000000..8b8b7a2 --- /dev/null +++ b/src/provider/errors/messages.ts @@ -0,0 +1,42 @@ +// Errors constructed from: +// https://github.com/gnolang/gno/blob/master/tm2/pkg/std/errors.go + +const InternalErrorMessage = 'internal error encountered'; +const TxDecodeErrorMessage = 'unable to decode tx'; +const InvalidSequenceErrorMessage = 'invalid sequence'; +const UnauthorizedErrorMessage = 'signature is unauthorized'; +const InsufficientFundsErrorMessage = 'insufficient funds'; +const UnknownRequestErrorMessage = 'unknown request'; +const InvalidAddressErrorMessage = 'invalid address'; +const UnknownAddressErrorMessage = 'unknown address'; +const InvalidPubKeyErrorMessage = 'invalid pubkey'; +const InsufficientCoinsErrorMessage = 'insufficient coins'; +const InvalidCoinsErrorMessage = 'invalid coins'; +const InvalidGasWantedErrorMessage = 'invalid gas wanted'; +const OutOfGasErrorMessage = 'out of gas'; +const MemoTooLargeErrorMessage = 'memo too large'; +const InsufficientFeeErrorMessage = 'insufficient fee'; +const TooManySignaturesErrorMessage = 'too many signatures'; +const NoSignaturesErrorMessage = 'no signatures'; +const GasOverflowErrorMessage = 'gas overflow'; + +export { + InternalErrorMessage, + TxDecodeErrorMessage, + InvalidSequenceErrorMessage, + UnauthorizedErrorMessage, + InsufficientFundsErrorMessage, + UnknownRequestErrorMessage, + InvalidAddressErrorMessage, + UnknownAddressErrorMessage, + InvalidPubKeyErrorMessage, + InsufficientCoinsErrorMessage, + InvalidCoinsErrorMessage, + InvalidGasWantedErrorMessage, + OutOfGasErrorMessage, + MemoTooLargeErrorMessage, + InsufficientFeeErrorMessage, + TooManySignaturesErrorMessage, + NoSignaturesErrorMessage, + GasOverflowErrorMessage, +}; diff --git a/src/provider/index.ts b/src/provider/index.ts index e7ed808..740dc4d 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -4,3 +4,4 @@ export * from './utility'; export * from './websocket'; export * from './endpoints'; export * from './provider'; +export * from './errors'; diff --git a/src/provider/jsonrpc/jsonrpc.test.ts b/src/provider/jsonrpc/jsonrpc.test.ts index d236ad1..c527cd3 100644 --- a/src/provider/jsonrpc/jsonrpc.test.ts +++ b/src/provider/jsonrpc/jsonrpc.test.ts @@ -3,7 +3,7 @@ import { ABCIResponse, BlockInfo, BlockResult, - BroadcastTxResult, + BroadcastTxSyncResult, ConsensusParams, NetworkInfo, RPCRequest, @@ -16,6 +16,8 @@ import { mock } from 'jest-mock-extended'; import { Tx } from '../../proto'; import { sha256 } from '@cosmjs/crypto'; import { CommonEndpoint } from '../endpoints'; +import { UnauthorizedErrorMessage } from '../errors/messages'; +import { TM2Error } from '../errors'; jest.mock('axios'); @@ -69,20 +71,49 @@ describe('JSON-RPC Provider', () => { expect(result).toEqual(mockResult); }); - test('sendTransaction', async () => { - const mockResult: BroadcastTxResult = mock(); - mockResult.hash = 'hash123'; + describe('sendTransaction', () => { + const validResult: BroadcastTxSyncResult = { + error: null, + data: null, + Log: '', + hash: 'hash123', + }; - mockedAxios.post.mockResolvedValue({ - data: newResponse(mockResult), - }); + const mockError = '/std.UnauthorizedError'; + const mockLog = 'random error message'; + const invalidResult: BroadcastTxSyncResult = { + error: { + ABCIErrorKey: mockError, + }, + data: null, + Log: mockLog, + hash: '', + }; - // Create the provider - const provider = new JSONRPCProvider(mockURL); - const hash = await provider.sendTransaction('encoded tx'); + test.each([ + [validResult, validResult.hash, '', ''], // no error + [invalidResult, invalidResult.hash, UnauthorizedErrorMessage, mockLog], // error out + ])('case %#', async (response, expectedHash, expectedErr, expectedLog) => { + mockedAxios.post.mockResolvedValue({ + data: newResponse(response), + }); - expect(axios.post).toHaveBeenCalled(); - expect(hash).toEqual(mockResult.hash); + try { + // Create the provider + const provider = new JSONRPCProvider(mockURL); + const hash = await provider.sendTransaction('encoded tx'); + + expect(axios.post).toHaveBeenCalled(); + expect(hash).toEqual(expectedHash); + + if (expectedErr != '') { + fail('expected error'); + } + } catch (e) { + expect((e as Error).message).toBe(expectedErr); + expect((e as TM2Error).log).toBe(expectedLog); + } + }); }); test('waitForTransaction', async () => { diff --git a/src/provider/jsonrpc/jsonrpc.ts b/src/provider/jsonrpc/jsonrpc.ts index 04492f0..a3f587d 100644 --- a/src/provider/jsonrpc/jsonrpc.ts +++ b/src/provider/jsonrpc/jsonrpc.ts @@ -3,9 +3,11 @@ import { ABCIResponse, BlockInfo, BlockResult, - BroadcastTxResult, + BroadcastTxCommitResult, + BroadcastTxSyncResult, ConsensusParams, NetworkInfo, + RPCRequest, Status, } from '../types'; import { RestService } from '../../services'; @@ -24,6 +26,7 @@ import { TransactionEndpoint, } from '../endpoints'; import { Tx } from '../../proto'; +import { constructRequestError } from '../utility/errors.utility'; /** * Provider based on JSON-RPC HTTP requests @@ -143,12 +146,69 @@ export class JSONRPCProvider implements Provider { }); } - async sendTransaction(tx: string): Promise { - const response: BroadcastTxResult = - await RestService.post(this.baseURL, { - request: newRequest(TransactionEndpoint.BROADCAST_TX_SYNC, [tx]), + async sendTransaction( + tx: string, + endpoint?: + | TransactionEndpoint.BROADCAST_TX_SYNC + | TransactionEndpoint.BROADCAST_TX_COMMIT + ): Promise { + const queryEndpoint = endpoint + ? endpoint + : TransactionEndpoint.BROADCAST_TX_SYNC; + + const request: RPCRequest = newRequest(queryEndpoint, [tx]); + + if (queryEndpoint == TransactionEndpoint.BROADCAST_TX_SYNC) { + return this.broadcastTxSync(request); + } + + // The endpoint is a commit broadcast + // (it waits for the transaction to be committed) to the chain before returning + return this.broadcastTxCommit(request); + } + + private async broadcastTxSync(request: RPCRequest): Promise { + const response: BroadcastTxSyncResult = + await RestService.post(this.baseURL, { + request, + }); + + // Check if there is an immediate tx-broadcast error + // (originating from basic transaction checks like CheckTx) + if (response.error) { + const errType: string = response.error.ABCIErrorKey; + const log: string = response.Log; + + throw constructRequestError(errType, log); + } + + return response.hash; + } + + private async broadcastTxCommit(request: RPCRequest): Promise { + const response: BroadcastTxCommitResult = + await RestService.post(this.baseURL, { + request, }); + const { check_tx, deliver_tx } = response; + + // Check if there is an immediate tx-broadcast error (in CheckTx) + if (check_tx.ResponseBase.Error) { + const errType: string = check_tx.ResponseBase.Error.ABCIErrorKey; + const log: string = check_tx.ResponseBase.Log; + + throw constructRequestError(errType, log); + } + + // Check if there is a parsing error with the transaction (in DeliverTx) + if (deliver_tx.ResponseBase.Error) { + const errType: string = deliver_tx.ResponseBase.Error.ABCIErrorKey; + const log: string = deliver_tx.ResponseBase.Log; + + throw constructRequestError(errType, log); + } + return response.hash; } diff --git a/src/provider/provider.ts b/src/provider/provider.ts index 80ecf76..e677bb1 100644 --- a/src/provider/provider.ts +++ b/src/provider/provider.ts @@ -6,6 +6,7 @@ import { Status, } from './types'; import { Tx } from '../proto'; +import { TransactionEndpoint } from './endpoints'; /** * Read-only abstraction for accessing blockchain data @@ -96,8 +97,15 @@ export interface Provider { * The transaction needs to be signed beforehand. * Returns the transaction hash * @param {string} tx the base64-encoded signed transaction + * @param {TransactionEndpoint} endpoint the transaction broadcast type (sync / commit). + * Defaults to broadcast_sync */ - sendTransaction(tx: string): Promise; + sendTransaction( + tx: string, + endpoint?: + | TransactionEndpoint.BROADCAST_TX_SYNC + | TransactionEndpoint.BROADCAST_TX_COMMIT + ): Promise; /** * Waits for the transaction to be committed on the chain. diff --git a/src/provider/types/common.ts b/src/provider/types/common.ts index 9368c95..6823239 100644 --- a/src/provider/types/common.ts +++ b/src/provider/types/common.ts @@ -257,7 +257,7 @@ export interface BeginBlock { ResponseBase: ABCIResponseBase; } -export interface BroadcastTxResult { +export interface BroadcastTxSyncResult { error: { // ABCIErrorKey [key: string]: string; @@ -267,3 +267,10 @@ export interface BroadcastTxResult { hash: string; } + +export interface BroadcastTxCommitResult { + check_tx: DeliverTx; + deliver_tx: DeliverTx; + hash: string; + height: string; // decimal number +} diff --git a/src/provider/utility/errors.utility.ts b/src/provider/utility/errors.utility.ts new file mode 100644 index 0000000..9ec6256 --- /dev/null +++ b/src/provider/utility/errors.utility.ts @@ -0,0 +1,76 @@ +import { + GasOverflowError, + InsufficientCoinsError, + InsufficientFeeError, + InsufficientFundsError, + InternalError, + InvalidAddressError, + InvalidCoinsError, + InvalidGasWantedError, + InvalidPubKeyError, + InvalidSequenceError, + MemoTooLargeError, + NoSignaturesError, + OutOfGasError, + TM2Error, + TooManySignaturesError, + TxDecodeError, + UnauthorizedError, + UnknownAddressError, + UnknownRequestError, +} from '../errors'; + +/** + * Constructs the appropriate Tendermint2 + * error based on the error ID. + * Error IDs retrieved from: + * https://github.com/gnolang/gno/blob/64f0fd0fa44021a076e1453b1767fbc914ed3b66/tm2/pkg/std/package.go#L20C1-L38 + * @param {string} errorID the proto ID of the error + * @param {string} [log] the log associated with the error, if any + * @returns {TM2Error} + */ +export const constructRequestError = ( + errorID: string, + log?: string +): TM2Error => { + switch (errorID) { + case '/std.InternalError': + return new InternalError(log); + case '/std.TxDecodeError': + return new TxDecodeError(log); + case '/std.InvalidSequenceError': + return new InvalidSequenceError(log); + case '/std.UnauthorizedError': + return new UnauthorizedError(log); + case '/std.InsufficientFundsError': + return new InsufficientFundsError(log); + case '/std.UnknownRequestError': + return new UnknownRequestError(log); + case '/std.InvalidAddressError': + return new InvalidAddressError(log); + case '/std.UnknownAddressError': + return new UnknownAddressError(log); + case '/std.InvalidPubKeyError': + return new InvalidPubKeyError(log); + case '/std.InsufficientCoinsError': + return new InsufficientCoinsError(log); + case '/std.InvalidCoinsError': + return new InvalidCoinsError(log); + case '/std.InvalidGasWantedError': + return new InvalidGasWantedError(log); + case '/std.OutOfGasError': + return new OutOfGasError(log); + case '/std.MemoTooLargeError': + return new MemoTooLargeError(log); + case '/std.InsufficientFeeError': + return new InsufficientFeeError(log); + case '/std.TooManySignaturesError': + return new TooManySignaturesError(log); + case '/std.NoSignaturesError': + return new NoSignaturesError(log); + case '/std.GasOverflowError': + return new GasOverflowError(log); + default: + return new TM2Error(`unknown error: ${errorID}`, log); + } +}; diff --git a/src/provider/websocket/ws.test.ts b/src/provider/websocket/ws.test.ts index dfeddfa..dc108e2 100644 --- a/src/provider/websocket/ws.test.ts +++ b/src/provider/websocket/ws.test.ts @@ -5,7 +5,7 @@ import { BeginBlock, BlockInfo, BlockResult, - BroadcastTxResult, + BroadcastTxSyncResult, ConsensusParams, EndBlock, NetworkInfo, @@ -17,6 +17,8 @@ import WS from 'jest-websocket-mock'; import { Tx } from '../../proto'; import { sha256 } from '@cosmjs/crypto'; import { CommonEndpoint } from '../endpoints'; +import { UnauthorizedErrorMessage } from '../errors/messages'; +import { TM2Error } from '../errors'; describe('WS Provider', () => { const wsPort = 8545; @@ -250,19 +252,44 @@ describe('WS Provider', () => { expect(blockNumber).toBe(expectedBlockNumber); }); - test('sendTransaction', async () => { - const mockResult: BroadcastTxResult = { + describe('sendTransaction', () => { + const validResult: BroadcastTxSyncResult = { error: null, data: null, Log: '', hash: 'hash123', }; - // Set the response - await setHandler(mockResult); + const mockError = '/std.UnauthorizedError'; + const mockLog = 'random error message'; + const invalidResult: BroadcastTxSyncResult = { + error: { + ABCIErrorKey: mockError, + }, + data: null, + Log: mockLog, + hash: '', + }; - const hash: string = await wsProvider.sendTransaction('encoded tx'); - expect(hash).toBe(mockResult.hash); + test.each([ + [validResult, validResult.hash, '', ''], // no error + [invalidResult, invalidResult.hash, UnauthorizedErrorMessage, mockLog], // error out + ])('case %#', async (response, expectedHash, expectedErr, expectedLog) => { + await setHandler(response); + + try { + const hash = await wsProvider.sendTransaction('encoded tx'); + + expect(hash).toEqual(expectedHash); + + if (expectedErr != '') { + fail('expected error'); + } + } catch (e) { + expect((e as Error).message).toBe(expectedErr); + expect((e as TM2Error).log).toBe(expectedLog); + } + }); }); const getEmptyBlockInfo = (): BlockInfo => { diff --git a/src/provider/websocket/ws.ts b/src/provider/websocket/ws.ts index 54705a6..0d6d549 100644 --- a/src/provider/websocket/ws.ts +++ b/src/provider/websocket/ws.ts @@ -3,7 +3,8 @@ import { ABCIResponse, BlockInfo, BlockResult, - BroadcastTxResult, + BroadcastTxCommitResult, + BroadcastTxSyncResult, ConsensusParams, NetworkInfo, RPCRequest, @@ -25,6 +26,7 @@ import { TransactionEndpoint, } from '../endpoints'; import { Tx } from '../../proto'; +import { constructRequestError } from '../utility/errors.utility'; /** * Provider based on WS JSON-RPC HTTP requests @@ -255,12 +257,72 @@ export class WSProvider implements Provider { return this.parseResponse(response); } - async sendTransaction(tx: string): Promise { - const response = await this.sendRequest( - newRequest(TransactionEndpoint.BROADCAST_TX_SYNC, [tx]) - ); + async sendTransaction( + tx: string, + endpoint?: + | TransactionEndpoint.BROADCAST_TX_SYNC + | TransactionEndpoint.BROADCAST_TX_COMMIT + ): Promise { + const queryEndpoint = endpoint + ? endpoint + : TransactionEndpoint.BROADCAST_TX_SYNC; + + const request: RPCRequest = newRequest(queryEndpoint, [tx]); + + if (queryEndpoint == TransactionEndpoint.BROADCAST_TX_SYNC) { + return this.broadcastTxSync(request); + } + + // The endpoint is a commit broadcast + // (it waits for the transaction to be committed) to the chain before returning + return this.broadcastTxCommit(request); + } + + private async broadcastTxSync(request: RPCRequest): Promise { + const response: RPCResponse = + await this.sendRequest(request); + + const broadcastResponse: BroadcastTxSyncResult = + this.parseResponse(response); + + // Check if there is an immediate tx-broadcast error + // (originating from basic transaction checks like CheckTx) + if (broadcastResponse.error) { + const errType: string = broadcastResponse.error.ABCIErrorKey; + const log: string = broadcastResponse.Log; + + throw constructRequestError(errType, log); + } + + return broadcastResponse.hash; + } + + private async broadcastTxCommit(request: RPCRequest): Promise { + const response: RPCResponse = + await this.sendRequest(request); + + const broadcastResponse: BroadcastTxCommitResult = + this.parseResponse(response); + + const { check_tx, deliver_tx } = broadcastResponse; + + // Check if there is an immediate tx-broadcast error (in CheckTx) + if (check_tx.ResponseBase.Error) { + const errType: string = check_tx.ResponseBase.Error.ABCIErrorKey; + const log: string = check_tx.ResponseBase.Log; + + throw constructRequestError(errType, log); + } + + // Check if there is a parsing error with the transaction (in DeliverTx) + if (deliver_tx.ResponseBase.Error) { + const errType: string = deliver_tx.ResponseBase.Error.ABCIErrorKey; + const log: string = deliver_tx.ResponseBase.Log; + + throw constructRequestError(errType, log); + } - return this.parseResponse(response).hash; + return broadcastResponse.hash; } waitForTransaction(