Skip to content

Commit

Permalink
chore!: enhance TX error handling and message formatting (#1895)
Browse files Browse the repository at this point in the history
  • Loading branch information
Torres-ssf authored Apr 4, 2024
1 parent 0be814e commit e995aab
Show file tree
Hide file tree
Showing 28 changed files with 496 additions and 562 deletions.
8 changes: 8 additions & 0 deletions .changeset/popular-hairs-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@fuel-ts/account": minor
"fuels": minor
"@fuel-ts/program": minor
"@fuel-ts/transactions": minor
---

chore!: enhance TX error handling and message formatting
81 changes: 31 additions & 50 deletions apps/docs-snippets/src/guide/errors/debugging-revert-errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,56 +1,37 @@
import { generateTestWallet } from '@fuel-ts/account/test-utils';
import { BaseAssetId, FUEL_NETWORK_URL, Provider, Script } from 'fuels';

import {
DocSnippetProjectsEnum,
getDocsSnippetsForcProject,
} from '../../../test/fixtures/forc-projects';
import { DocSnippetProjectsEnum } from '../../../test/fixtures/forc-projects';
import { createAndDeployContractFromProject } from '../../utils';

/**
* @group node
*/
test('logs out custom require messages for error enums when tx reverts', async () => {
const contract = await createAndDeployContractFromProject(DocSnippetProjectsEnum.REVERT_ERRORS);

// #region revert-errors-4
expect(() => contract.functions.test_function_with_custom_error().call()).rejects.toThrow(
'The script reverted with reason RequireFailed. (Reason: "InvalidInput")'
);
// #endregion revert-errors-4
});

test('logs out custom require messages for require statements using str array when tx reverts', async () => {
const contract = await createAndDeployContractFromProject(DocSnippetProjectsEnum.REVERT_ERRORS);

// #region revert-errors-7
expect(() => contract.functions.test_function_with_str_array_message().call()).rejects.toThrow(
'The script reverted with reason RequireFailed. (Reason: "This is also a revert error")'
);
// #endregion revert-errors-7
});

test('logs out a generic error message for require statements with a simple string message', async () => {
const contract = await createAndDeployContractFromProject(DocSnippetProjectsEnum.REVERT_ERRORS);

// #region revert-errors-5
expect(() => contract.functions.test_function().call()).rejects.toThrow(
'String slices can not be decoded from logs. Convert the slice to `str[N]` with `__to_str_array`'
);
// #endregion revert-errors-5
});

test('logs out custom require messages for script calls', async () => {
const { binHexlified, abiContents } = getDocsSnippetsForcProject(
DocSnippetProjectsEnum.REVERT_ERRORS_SCRIPT
);

const provider = await Provider.create(FUEL_NETWORK_URL);
const wallet = await generateTestWallet(provider, [[1_000_000, BaseAssetId]]);

const script = new Script(binHexlified, abiContents, wallet);

expect(() => script.functions.main().call()).rejects.toThrow(
'The script reverted with reason RequireFailed. (Reason: "This is a revert error")'
);
describe(__filename, () => {
it('logs out custom require messages for error enums when tx reverts', async () => {
const contract = await createAndDeployContractFromProject(DocSnippetProjectsEnum.REVERT_ERRORS);

// #region revert-errors-4
expect(() => contract.functions.test_function_with_custom_error().call()).rejects.toThrow(
'The transaction reverted because a "require" statement has thrown "InvalidInput".'
);
// #endregion revert-errors-4
});

it('logs out custom require messages for require statements using str array when tx reverts', async () => {
const contract = await createAndDeployContractFromProject(DocSnippetProjectsEnum.REVERT_ERRORS);

// #region revert-errors-7
expect(() => contract.functions.test_function_with_str_array_message().call()).rejects.toThrow(
'The transaction reverted because a "require" statement has thrown "This is also a revert error".'
);
// #endregion revert-errors-7
});

it('logs out a generic error message for require statements with a simple string message', async () => {
const contract = await createAndDeployContractFromProject(DocSnippetProjectsEnum.REVERT_ERRORS);

// #region revert-errors-5
expect(() => contract.functions.test_function().call()).rejects.toThrow(
'String slices can not be decoded from logs. Convert the slice to `str[N]` with `__to_str_array`'
);
// #endregion revert-errors-5
});
});
2 changes: 1 addition & 1 deletion internal/check-imports/src/references.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ import { BN } from '@fuel-ts/math';
import { DEFAULT_PRECISION, DEFAULT_MIN_PRECISION } from '@fuel-ts/math/configs';
import { SparseMerkleTree, constructTree } from '@fuel-ts/merkle';
import { FunctionInvocationScope } from '@fuel-ts/program';
import { PANIC_REASONS } from '@fuel-ts/program/configs';
import { Script } from '@fuel-ts/script';
import { InputCoinCoder } from '@fuel-ts/transactions';
import { PANIC_REASONS } from '@fuel-ts/transactions/configs';
import { versions } from '@fuel-ts/versions';
import { runVersions } from '@fuel-ts/versions/cli';
// TODO: Add `launchNode` and `launchNodeAndGetWallets` here
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,8 @@ import type Provider from '../provider';
import type { JsonAbisFromAllCalls } from '../transaction-request';
import { assembleTransactionSummary } from '../transaction-summary/assemble-transaction-summary';
import { processGqlReceipt } from '../transaction-summary/receipt';
import type {
TransactionSummary,
FailureStatus,
GqlTransaction,
AbiMap,
} from '../transaction-summary/types';
import type { TransactionSummary, GqlTransaction, AbiMap } from '../transaction-summary/types';
import { extractTxError } from '../utils';

import { getDecodedLogs } from './getDecodedLogs';

Expand Down Expand Up @@ -250,8 +246,10 @@ export class TransactionResponse {
...transactionSummary,
};

let logs: Array<unknown> = [];

if (this.abis) {
const logs = getDecodedLogs(
logs = getDecodedLogs(
transactionSummary.receipts,
this.abis.main,
this.abis.otherContractsAbis
Expand All @@ -260,6 +258,19 @@ export class TransactionResponse {
transactionResult.logs = logs;
}

if (transactionResult.isStatusFailure) {
const {
receipts,
gqlTransaction: { status },
} = transactionResult;

throw extractTxError({
receipts,
status,
logs,
});
}

return transactionResult;
}

Expand All @@ -271,15 +282,6 @@ export class TransactionResponse {
async wait<TTransactionType = void>(
contractsAbiMap?: AbiMap
): Promise<TransactionResult<TTransactionType>> {
const result = await this.waitForResult<TTransactionType>(contractsAbiMap);

if (result.isStatusFailure) {
throw new FuelError(
ErrorCode.TRANSACTION_FAILED,
`Transaction failed: ${(<FailureStatus>result.gqlTransaction.status).reason}`
);
}

return result;
return this.waitForResult<TTransactionType>(contractsAbiMap);
}
}
133 changes: 133 additions & 0 deletions packages/account/src/providers/utils/extract-tx-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { ErrorCode, FuelError } from '@fuel-ts/errors';
import { bn } from '@fuel-ts/math';
import type { ReceiptRevert } from '@fuel-ts/transactions';
import { ReceiptType } from '@fuel-ts/transactions';
import {
FAILED_REQUIRE_SIGNAL,
FAILED_ASSERT_EQ_SIGNAL,
FAILED_ASSERT_NE_SIGNAL,
FAILED_ASSERT_SIGNAL,
FAILED_TRANSFER_TO_ADDRESS_SIGNAL,
PANIC_REASONS,
PANIC_DOC_URL,
} from '@fuel-ts/transactions/configs';

import type { GqlTransactionStatusFragmentFragment } from '../__generated__/operations';
import type { TransactionResultReceipt } from '../transaction-response';
import type { FailureStatus } from '../transaction-summary';

/**
* Assembles an error message for a panic status.
* @param status - The transaction failure status.
* @returns The error message.
*/
export const assemblePanicError = (status: FailureStatus) => {
let errorMessage = `The transaction reverted with reason: "${status.reason}".`;
const reason = status.reason;

if (PANIC_REASONS.includes(status.reason)) {
errorMessage = `${errorMessage}\n\nYou can read more about this error at:\n\n${PANIC_DOC_URL}#variant.${status.reason}`;
}

return { errorMessage, reason };
};

/** @hidden */
const stringify = (obj: unknown) => JSON.stringify(obj, null, 2);

/**
* Assembles an error message for a revert status.
* @param receipts - The transaction result processed receipts.
* @param logs - The transaction decoded logs.
* @returns The error message.
*/
export const assembleRevertError = (
receipts: Array<TransactionResultReceipt>,
logs: Array<unknown>
) => {
let errorMessage = 'The transaction reverted with an unknown reason.';

const revertReceipt = receipts.find(({ type }) => type === ReceiptType.Revert) as ReceiptRevert;
let reason = '';

if (revertReceipt) {
const reasonHex = bn(revertReceipt.val).toHex();

switch (reasonHex) {
case FAILED_REQUIRE_SIGNAL: {
reason = 'require';
errorMessage = `The transaction reverted because a "require" statement has thrown ${
logs.length ? stringify(logs[0]) : 'an error.'
}.`;
break;
}

case FAILED_ASSERT_EQ_SIGNAL: {
const sufix =
logs.length >= 2 ? ` comparing ${stringify(logs[1])} and ${stringify(logs[0])}.` : '.';

reason = 'assert_eq';
errorMessage = `The transaction reverted because of an "assert_eq" statement${sufix}`;
break;
}

case FAILED_ASSERT_NE_SIGNAL: {
const sufix =
logs.length >= 2 ? ` comparing ${stringify(logs[1])} and ${stringify(logs[0])}.` : '.';

reason = 'assert_ne';
errorMessage = `The transaction reverted because of an "assert_ne" statement${sufix}`;
break;
}

case FAILED_ASSERT_SIGNAL:
reason = 'assert';
errorMessage = `The transaction reverted because an "assert" statement failed to evaluate to true.`;
break;

case FAILED_TRANSFER_TO_ADDRESS_SIGNAL:
reason = 'MissingOutputChange';
errorMessage = `The transaction reverted because it's missing an "OutputChange".`;
break;

default:
reason = 'unknown';
errorMessage = `The transaction reverted with an unknown reason: ${revertReceipt.val}`;
}
}

return { errorMessage, reason };
};

interface IExtractTxError {
receipts: Array<TransactionResultReceipt>;
status?: GqlTransactionStatusFragmentFragment | null;
logs: Array<unknown>;
}

/**
* Extracts the transaction error and returns a FuelError object.
* @param IExtractTxError - The parameters for extracting the error.
* @returns The FuelError object.
*/
export const extractTxError = (params: IExtractTxError): FuelError => {
const { receipts, status, logs } = params;

const isPanic = receipts.some(({ type }) => type === ReceiptType.Panic);
const isRevert = receipts.some(({ type }) => type === ReceiptType.Revert);

const { errorMessage, reason } =
status?.type === 'FailureStatus' && isPanic
? assemblePanicError(status)
: assembleRevertError(receipts, logs);

const metadata = {
logs,
receipts,
panic: isPanic,
revert: isRevert,
reason,
};

return new FuelError(ErrorCode.SCRIPT_REVERTED, errorMessage, metadata);
};
1 change: 1 addition & 0 deletions packages/account/src/providers/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './block-explorer';
export * from './gas';
export * from './json';
export * from './sleep';
export * from './extract-tx-error';
5 changes: 3 additions & 2 deletions packages/errors/src/fuel-error.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ it('converts error to plain object', () => {
const code = FuelError.CODES.PARSE_FAILED;
const name = 'FuelError';
const message = 'It happens';
const err = new FuelError(code, message);
expect(err.toObject()).toEqual({ code, name, message, VERSIONS: err.VERSIONS });
const metadata = { name: 'FuelLabs' };
const err = new FuelError(code, message, metadata);
expect(err.toObject()).toEqual({ code, name, message, VERSIONS: err.VERSIONS, metadata });
});
8 changes: 5 additions & 3 deletions packages/errors/src/fuel-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ErrorCode } from './error-codes';
export class FuelError extends Error {
static readonly CODES = ErrorCode;
readonly VERSIONS = versions;
readonly metadata: Record<string, unknown>;

static parse(e: unknown) {
const error = e as FuelError;
Expand All @@ -31,14 +32,15 @@ export class FuelError extends Error {

code: ErrorCode;

constructor(code: ErrorCode, message: string) {
constructor(code: ErrorCode, message: string, metadata: Record<string, unknown> = {}) {
super(message);
this.code = code;
this.name = 'FuelError';
this.metadata = metadata;
}

toObject() {
const { code, name, message, VERSIONS } = this;
return { code, name, message, VERSIONS };
const { code, name, message, metadata, VERSIONS } = this;
return { code, name, message, metadata, VERSIONS };
}
}
4 changes: 4 additions & 0 deletions packages/errors/src/test-utils/expect-to-throw-fuel-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ export const expectToThrowFuelError = async (
);
}

if (expectedError.metadata) {
expect(thrownError.metadata).toEqual(expect.objectContaining(expectedError.metadata));
}

expect(thrownError.name).toEqual('FuelError');
expect(thrownError).toMatchObject(expectedError);
};
Loading

0 comments on commit e995aab

Please sign in to comment.