Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/errors/WhitechainErrors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
8 changes: 8 additions & 0 deletions src/errors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
20 changes: 19 additions & 1 deletion src/providers/RpcProvider.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -23,6 +23,16 @@ export interface JsonRpcResponse<T = unknown> {
}
}

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).
Expand Down Expand Up @@ -97,6 +107,14 @@ export class RpcProvider {
const json = (await response.json()) as JsonRpcResponse<T>
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}`)
}

Expand Down
24 changes: 24 additions & 0 deletions tests/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
RpcError,
ValidationError,
TimeoutError,
ContractRevertError,
TransactionRevertedError,
} from '../src/index.js'

describe('Error classes', () => {
Expand Down Expand Up @@ -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)', () => {
Expand Down
43 changes: 41 additions & 2 deletions tests/providers/RpcProvider.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand Down