From da1ff51f58a357e4cdec2dcb25f81e6e82fc1ec6 Mon Sep 17 00:00:00 2001 From: silentgeckoaudit3801 Date: Wed, 29 Jul 2026 21:08:53 -0600 Subject: [PATCH] fix: add typed contract revert errors --- src/errors/WhitechainErrors.ts | 25 +++++++++++++++++ src/errors/index.ts | 8 ++++++ src/providers/RpcProvider.ts | 20 +++++++++++++- tests/errors.test.ts | 24 ++++++++++++++++ tests/providers/RpcProvider.test.ts | 43 +++++++++++++++++++++++++++-- 5 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/errors/WhitechainErrors.ts b/src/errors/WhitechainErrors.ts index 63a540cc..d883b67a 100644 --- a/src/errors/WhitechainErrors.ts +++ b/src/errors/WhitechainErrors.ts @@ -26,6 +26,31 @@ export class TransactionRevertedError extends SDKError { } } +/** + * Thrown when an RPC call or transaction response explicitly reports a smart + * contract revert. + */ +export class ContractRevertError extends TransactionRevertedError { + public readonly rawData?: unknown; + public readonly rpcCode?: number; + public readonly customErrorName?: string; + + constructor(options: { + message: string; + reason?: string; + rawData?: unknown; + rpcCode?: number; + customErrorName?: string; + args?: readonly unknown[]; + }) { + super(options.message, options.reason, options.args); + this.name = 'ContractRevertError'; + this.rawData = options.rawData; + this.rpcCode = options.rpcCode; + this.customErrorName = options.customErrorName; + } +} + /** * Fallback for unparseable or unknown transaction errors. */ diff --git a/src/errors/index.ts b/src/errors/index.ts index 6ed33501..23b361ef 100644 --- a/src/errors/index.ts +++ b/src/errors/index.ts @@ -2,3 +2,11 @@ export { WhiteChainError } from './BaseError.js' export { RpcError } from './RpcError.js' export { ValidationError } from './ValidationError.js' export { TimeoutError } from './TimeoutError.js' +export { + SDKError, + TransactionRevertedError, + ContractRevertError, + UnknownTransactionError, + InsufficientBalanceError, + UnauthorizedError, +} from './WhitechainErrors.js' diff --git a/src/providers/RpcProvider.ts b/src/providers/RpcProvider.ts index 219f11d2..a7f32c81 100644 --- a/src/providers/RpcProvider.ts +++ b/src/providers/RpcProvider.ts @@ -1,6 +1,6 @@ import type { Transport } from 'viem' import { custom } from 'viem' -import { WhiteChainError } from '../types.js' +import { ContractRevertError, WhiteChainError } from '../errors/index.js' import type { RpcProviderConfig } from '../types/config.js' export type RpcProviderOptions = RpcProviderConfig @@ -23,6 +23,16 @@ export interface JsonRpcResponse { } } +function extractRevertReason(message: string): string | undefined { + const match = message.match(/execution reverted(?::\s*)?(.*)$/i) + const reason = match?.[1]?.trim() + return reason || undefined +} + +function isContractRevertError(error: JsonRpcResponse['error']): boolean { + return error?.code === 3 || /revert/i.test(error?.message ?? '') +} + /** * RpcProvider handles transient network failures (429, 502, 503, 504, ECONNRESET) gracefully * using exponential backoff retries (1s, 2s, 4s, 8s). @@ -97,6 +107,14 @@ export class RpcProvider { const json = (await response.json()) as JsonRpcResponse if (json.error) { // Contract reverts and JSON-RPC execution errors fail immediately + if (isContractRevertError(json.error)) { + throw new ContractRevertError({ + message: `JSON-RPC Error [${json.error.code}]: ${json.error.message}`, + reason: extractRevertReason(json.error.message), + rawData: json.error.data, + rpcCode: json.error.code, + }) + } throw new WhiteChainError(`JSON-RPC Error [${json.error.code}]: ${json.error.message}`) } diff --git a/tests/errors.test.ts b/tests/errors.test.ts index 9b3677ec..67a26098 100644 --- a/tests/errors.test.ts +++ b/tests/errors.test.ts @@ -4,6 +4,8 @@ import { RpcError, ValidationError, TimeoutError, + ContractRevertError, + TransactionRevertedError, } from '../src/index.js' describe('Error classes', () => { @@ -123,11 +125,33 @@ describe('Error classes', () => { }) }) + describe('ContractRevertError', () => { + it('is instanceof WhiteChainError and TransactionRevertedError', () => { + const err = new ContractRevertError({ + message: 'execution reverted: Unauthorized', + reason: 'Unauthorized', + rawData: '0x1234', + rpcCode: -32000, + customErrorName: 'Unauthorized', + }) + + expect(err).toBeInstanceOf(Error) + expect(err).toBeInstanceOf(WhiteChainError) + expect(err).toBeInstanceOf(TransactionRevertedError) + expect(err).toBeInstanceOf(ContractRevertError) + expect(err.reason).toBe('Unauthorized') + expect(err.rawData).toBe('0x1234') + expect(err.rpcCode).toBe(-32000) + expect(err.customErrorName).toBe('Unauthorized') + }) + }) + describe('instanceof discrimination across all types', () => { const errors = [ new RpcError('rpc'), new ValidationError('validation'), new TimeoutError('timeout'), + new ContractRevertError({ message: 'revert' }), ] it('RpcError is only instanceof RpcError (not TimeoutError/ValidationError)', () => { diff --git a/tests/providers/RpcProvider.test.ts b/tests/providers/RpcProvider.test.ts index abc7426d..c0f41ef4 100644 --- a/tests/providers/RpcProvider.test.ts +++ b/tests/providers/RpcProvider.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from 'vitest' -import { RpcProvider, createRpcProvider, createWhiteChainClient } from '../../src/index.js' +import { ContractRevertError, RpcProvider, createRpcProvider, createWhiteChainClient } from '../../src/index.js' import type { Address } from 'viem' describe('RpcProvider', () => { @@ -49,10 +49,49 @@ describe('RpcProvider', () => { fetchFn: mockFetch, }) - await expect(provider.request('eth_call', [])).rejects.toThrow('JSON-RPC Error [-32000]: execution reverted') + await expect(provider.request('eth_call', [])).rejects.toThrow(ContractRevertError) expect(callCount).toBe(1) // Immediate failure, 0 retries }) + it('exposes revert metadata for typed handling', async () => { + const revertData = '0x08c379a0' + const mockFetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + error: { + code: -32000, + message: 'execution reverted: Insufficient balance', + data: revertData, + }, + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ) + + const provider = new RpcProvider({ + url: 'https://rpc.whitechain.io', + maxRetries: 3, + initialDelayMs: 10, + fetchFn: mockFetch, + }) + + try { + await provider.request('eth_call', []) + expect.fail('Expected request to throw') + } catch (error) { + expect(error).toBeInstanceOf(ContractRevertError) + const revert = error as ContractRevertError + expect(revert.reason).toBe('Insufficient balance') + expect(revert.rawData).toBe(revertData) + expect(revert.rpcCode).toBe(-32000) + expect(revert.message).toContain('execution reverted: Insufficient balance') + } + + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + it('does NOT retry eth_sendRawTransaction to prevent double submission to mempool', async () => { let callCount = 0 const mockFetch = vi.fn().mockImplementation(async () => {