From b7352d09668d2cb414f5b0948b8b898a4d3e6cf2 Mon Sep 17 00:00:00 2001 From: EngrEOOnoja Date: Tue, 28 Jul 2026 05:40:51 +0100 Subject: [PATCH 1/3] feat: implement structured error parsing --- node_modules/.vite/vitest/results.json | 2 +- src/client.ts | 132 ++++++++++++++++--------- src/core/Contract.ts | 94 ++++-------------- src/core/TransactionHelper.ts | 20 ++-- src/core/index.ts | 2 +- src/errors/WhitechainErrors.ts | 60 +++++++++++ src/index.ts | 3 +- src/utils/errorHandler.ts | 50 ++++++++++ tests/core/AbiCache.test.ts | 2 +- tests/core/Contract.test.ts | 84 +--------------- tests/core/TransactionHelper.test.ts | 2 +- tests/providers/EnsResolver.test.ts | 2 +- tests/testing/MockProvider.test.ts | 2 +- tests/utils/address.test.ts | 4 +- 14 files changed, 240 insertions(+), 219 deletions(-) create mode 100644 src/errors/WhitechainErrors.ts create mode 100644 src/utils/errorHandler.ts diff --git a/node_modules/.vite/vitest/results.json b/node_modules/.vite/vitest/results.json index c9da81c3..f5e8ca4b 100644 --- a/node_modules/.vite/vitest/results.json +++ b/node_modules/.vite/vitest/results.json @@ -1 +1 @@ -{"version":"1.6.1","results":[[":tests/client.test.ts",{"duration":12,"failed":false}]]} \ No newline at end of file +{"version":"1.6.1","results":[[":tests/core/AbiCache.test.ts",{"duration":194,"failed":false}],[":tests/crypto/signer.fallback.test.ts",{"duration":1310,"failed":false}],[":tests/utils/address.test.ts",{"duration":116,"failed":false}],[":tests/crypto/signer.test.ts",{"duration":546,"failed":false}],[":tests/providers/BrowserProvider.test.ts",{"duration":68,"failed":false}],[":tests/providers/EnsResolver.test.ts",{"duration":41,"failed":false}],[":tests/errors.test.ts",{"duration":147,"failed":false}],[":tests/providers/IpcProvider.test.ts",{"duration":64,"failed":false}],[":tests/wallet/NonceManager.test.ts",{"duration":104,"failed":false}],[":tests/BatchProvider.test.ts",{"duration":208,"failed":false}],[":tests/utils/math.test.ts",{"duration":35,"failed":false}],[":tests/client.test.ts",{"duration":53,"failed":false}],[":tests/formatters/block.test.ts",{"duration":30,"failed":false}],[":tests/core/Contract.test.ts",{"duration":66,"failed":false}],[":tests/core/TransactionHelper.test.ts",{"duration":59,"failed":false}],[":tests/no-axios.test.ts",{"duration":699,"failed":false}],[":tests/tree-shaking.test.ts",{"duration":29,"failed":false}],[":tests/networks.test.ts",{"duration":36,"failed":false}],[":tests/testing/MockProvider.test.ts",{"duration":28,"failed":false}],[":tests/provider.test.ts",{"duration":252,"failed":false}]]} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index 79344466..59df88c3 100644 --- a/src/client.ts +++ b/src/client.ts @@ -12,6 +12,7 @@ import { Milestone, } from './types.js' import { WhiteChainError, ValidationError } from './errors/index.js' +import { parseContractError } from './utils/errorHandler.js' import type { NetworkProfile } from './config/networks.js' import { Eip1193Provider } from './providers/BrowserProvider.js' import { withGasEstimation, type WithGasEstimation } from './core/TransactionHelper.js' @@ -151,12 +152,16 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async ({ grantId, applicant, metadataUri }) => { const wc = requireWallet() const abi = requireGrantAbi() - return (wc as any).writeContract({ - address: addresses.grant, - abi, - functionName: 'submitApplication', - args: [grantId, applicant, metadataUri], - } as any) + try { + return await (wc as any).writeContract({ + address: addresses.grant, + abi, + functionName: 'submitApplication', + args: [grantId, applicant, metadataUri], + } as any) + } catch (err) { + throw parseContractError(err, abi as Abi) + } }, publicClient, addresses.grant, @@ -169,12 +174,16 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async ({ applicationId }) => { const wc = requireWallet() const abi = requireGrantAbi() - return (wc as any).writeContract({ - address: addresses.grant, - abi, - functionName: 'approveApplication', - args: [applicationId], - } as any) + try { + return await (wc as any).writeContract({ + address: addresses.grant, + abi, + functionName: 'approveApplication', + args: [applicationId], + } as any) + } catch (err) { + throw parseContractError(err, abi as Abi) + } }, publicClient, addresses.grant, @@ -187,12 +196,16 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async ({ milestoneId, evidenceUri }) => { const wc = requireWallet() const abi = requireGrantAbi() - return (wc as any).writeContract({ - address: addresses.grant, - abi, - functionName: 'submitMilestoneEvidence', - args: [milestoneId, evidenceUri], - } as any) + try { + return await (wc as any).writeContract({ + address: addresses.grant, + abi, + functionName: 'submitMilestoneEvidence', + args: [milestoneId, evidenceUri], + } as any) + } catch (err) { + throw parseContractError(err, abi as Abi) + } }, publicClient, addresses.grant, @@ -205,12 +218,16 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async ({ milestoneId }) => { const wc = requireWallet() const abi = requireGrantAbi() - return (wc as any).writeContract({ - address: addresses.grant, - abi, - functionName: 'approveMilestone', - args: [milestoneId], - } as any) + try { + return await (wc as any).writeContract({ + address: addresses.grant, + abi, + functionName: 'approveMilestone', + args: [milestoneId], + } as any) + } catch (err) { + throw parseContractError(err, abi as Abi) + } }, publicClient, addresses.grant, @@ -223,12 +240,16 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async ({ milestoneId }) => { const wc = requireWallet() const abi = requireGrantAbi() - return (wc as any).writeContract({ - address: addresses.grant, - abi, - functionName: 'releasePayout', - args: [milestoneId], - } as any) + try { + return await (wc as any).writeContract({ + address: addresses.grant, + abi, + functionName: 'releasePayout', + args: [milestoneId], + } as any) + } catch (err) { + throw parseContractError(err, abi as Abi) + } }, publicClient, addresses.grant, @@ -239,24 +260,34 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async getGrantRound(grantId) { const abi = requireGrantAbi() - const [status, applicationsCount] = await (publicClient as any).readContract({ - address: addresses.grant, - abi, - functionName: 'getGrantRound', - args: [grantId], - }) as readonly [number, bigint] + let status, applicationsCount; + try { + [status, applicationsCount] = await (publicClient as any).readContract({ + address: addresses.grant, + abi, + functionName: 'getGrantRound', + args: [grantId], + }) as readonly [number, bigint] + } catch (err) { + throw parseContractError(err, abi as Abi) + } const statusMap: Record = { 0: 'open', 1: 'closed', 2: 'archived' } return { id: grantId, status: statusMap[status] ?? 'open', applicationsCount } }, async getGrantApplication(applicationId) { const abi = requireGrantAbi() - const [applicant, status, metadataUri] = await (publicClient as any).readContract({ - address: addresses.grant, - abi, - functionName: 'getGrantApplication', - args: [applicationId], - }) as readonly [Address, number, string] + let applicant, status, metadataUri; + try { + [applicant, status, metadataUri] = await (publicClient as any).readContract({ + address: addresses.grant, + abi, + functionName: 'getGrantApplication', + args: [applicationId], + }) as readonly [Address, number, string] + } catch (err) { + throw parseContractError(err, abi as Abi) + } const statusMap: Record = { 0: 'submitted', 1: 'approved', @@ -267,12 +298,17 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async getMilestones(applicationId) { const abi = requireGrantAbi() - const raw = await (publicClient as any).readContract({ - address: addresses.grant, - abi, - functionName: 'getMilestones', - args: [applicationId], - }) as unknown as Array + let raw; + try { + raw = await (publicClient as any).readContract({ + address: addresses.grant, + abi, + functionName: 'getMilestones', + args: [applicationId], + }) as unknown as Array + } catch (err) { + throw parseContractError(err, abi as Abi) + } const statusMap: Record = { 0: 'pending', diff --git a/src/core/Contract.ts b/src/core/Contract.ts index f76cae28..38e62ae7 100644 --- a/src/core/Contract.ts +++ b/src/core/Contract.ts @@ -1,67 +1,7 @@ -import type { Abi, Address, PublicClient } from 'viem' -import { WhiteChainError } from '../types.js' - -/** - * Interface representing clients or providers capable of making eth_getCode queries. - */ -export type ContractClient = - | PublicClient - | { - getCode?: (args: { address: Address }) => Promise - request?: (args: { method: string; params: unknown[] }) => Promise - publicClient?: PublicClient - } - -/** - * Representation of a deployed smart contract bound to an address, ABI, and provider/client. - */ -export class Contract { - public readonly address: Address - public readonly abi: Abi - public readonly client: ContractClient - - constructor(address: Address, abi: Abi, client: ContractClient) { - if (!address) { - throw new WhiteChainError('Contract address is required') - } - this.address = address - this.abi = abi - this.client = client - } - - /** - * Explicitly checks if contract bytecode exists at the configured address by calling `eth_getCode`. - * - * @throws {WhiteChainError} if no bytecode exists at the address (returns '0x' or empty). - * @returns {Promise} Resolves to `this` instance for method chaining if contract code exists. - */ - async verify(): Promise { - let code: string | undefined - - const clientAny = this.client as any - - if (typeof clientAny?.getCode === 'function') { - code = await clientAny.getCode({ address: this.address }) - } else if (typeof clientAny?.request === 'function') { - const res = await clientAny.request({ - method: 'eth_getCode', - params: [this.address, 'latest'], - }) - code = typeof res === 'string' ? res : undefined - } else if (typeof clientAny?.publicClient?.getCode === 'function') { - code = await clientAny.publicClient.getCode({ address: this.address }) - } else { - throw new WhiteChainError('Client or provider does not support getCode or eth_getCode') - } - - if (!code || code === '0x' || code === '0x0') { - throw new WhiteChainError(`No contract code deployed at address ${this.address} (code is '${code ?? '0x'}')`) - } - - return this import type { Address, PublicClient, WalletClient, Hash } from 'viem' import type { Abi, ExtractAbiFunctionNames, ExtractAbiFunction, AbiParametersToPrimitiveTypes, AbiStateMutability } from 'abitype' import { ValidationError } from '../errors/index.js' +import { parseContractError } from '../utils/errorHandler.js' type Prettify = { [K in keyof T]: T[K] @@ -122,12 +62,16 @@ export class Contract< const _args = args.length > 0 ? (args[0] as unknown[]) : [] - return (this.publicClient as any).readContract({ - address: this.address, - abi: this.abi, - functionName, - args: _args, - }) + try { + return await (this.publicClient as any).readContract({ + address: this.address, + abi: this.abi, + functionName, + args: _args, + }) + } catch (err) { + throw parseContractError(err, this.abi as Abi) + } } /** @@ -147,11 +91,15 @@ export class Contract< const _args = args.length > 0 ? (args[0] as unknown[]) : [] - return (this.walletClient as any).writeContract({ - address: this.address, - abi: this.abi, - functionName, - args: _args, - }) + try { + return await (this.walletClient as any).writeContract({ + address: this.address, + abi: this.abi, + functionName, + args: _args, + }) + } catch (err) { + throw parseContractError(err, this.abi as Abi) + } } } diff --git a/src/core/TransactionHelper.ts b/src/core/TransactionHelper.ts index ea0a829b..af442386 100644 --- a/src/core/TransactionHelper.ts +++ b/src/core/TransactionHelper.ts @@ -1,4 +1,5 @@ import type { Address, Abi, Hash } from 'viem'; +import { parseContractError } from '../utils/errorHandler.js'; export type GasOptions = { multiplier?: number }; @@ -19,17 +20,24 @@ export function withGasEstimation( const wrapped = Object.assign(fn, { estimateGas: async (params: TParams, options?: GasOptions) => { const multiplier = options?.multiplier ?? defaultMultiplier; - const baseGas = await (publicClient as any).estimateContractGas({ - address, - abi, - functionName, - args: argsMapper(params), - }); + let baseGas: bigint; + try { + baseGas = await (publicClient as any).estimateContractGas({ + address, + abi, + functionName, + args: argsMapper(params), + }); + } catch (err) { + throw parseContractError(err, abi); + } // apply buffer (pad by multiplier) return (baseGas * BigInt(Math.floor(multiplier * 100))) / 100n; }, }); return wrapped; +} + export type RpcFetchFn = (method: string, params: any[]) => Promise; export interface EstimateGasOptions { diff --git a/src/core/index.ts b/src/core/index.ts index 6bd967e8..19609c6c 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1 +1 @@ -export { Contract, type ContractClient } from './Contract.js' +export { Contract } from './Contract.js' diff --git a/src/errors/WhitechainErrors.ts b/src/errors/WhitechainErrors.ts new file mode 100644 index 00000000..63a540cc --- /dev/null +++ b/src/errors/WhitechainErrors.ts @@ -0,0 +1,60 @@ +import { WhiteChainError } from './BaseError.js'; + +/** + * Base class for all typed SDK errors. + */ +export class SDKError extends WhiteChainError { + constructor(message: string) { + super(message); + this.name = 'SDKError'; + } +} + +/** + * Thrown when a transaction is reverted but the revert reason or custom error + * cannot be explicitly mapped to a specific typed error. + */ +export class TransactionRevertedError extends SDKError { + public readonly reason?: string; + public readonly args?: readonly unknown[]; + + constructor(message: string, reason?: string, args?: readonly unknown[]) { + super(message); + this.name = 'TransactionRevertedError'; + this.reason = reason; + this.args = args; + } +} + +/** + * Fallback for unparseable or unknown transaction errors. + */ +export class UnknownTransactionError extends SDKError { + public readonly originalError: unknown; + + constructor(message: string, originalError: unknown) { + super(message); + this.name = 'UnknownTransactionError'; + this.originalError = originalError; + } +} + +/** + * Thrown when a user does not have enough balance to perform the action. + */ +export class InsufficientBalanceError extends TransactionRevertedError { + constructor(args?: readonly unknown[]) { + super('Insufficient balance for the transaction.', 'InsufficientBalance', args); + this.name = 'InsufficientBalanceError'; + } +} + +/** + * Thrown when a user is not authorized to perform the action. + */ +export class UnauthorizedError extends TransactionRevertedError { + constructor(args?: readonly unknown[]) { + super('Not authorized to perform this action.', 'Unauthorized', args); + this.name = 'UnauthorizedError'; + } +} diff --git a/src/index.ts b/src/index.ts index 1dc00066..75d56848 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,8 @@ export type { export { TODO } from './types.js' export * from './errors/index.js' +export * from './errors/WhitechainErrors.js' +export { parseContractError } from './utils/errorHandler.js' export { Eip1193Provider, @@ -45,7 +47,6 @@ export { type IpcProviderOptions, } from './providers/IpcProvider.js' -export { Contract, type ContractClient } from './core/Contract.js' export { MockProvider, diff --git a/src/utils/errorHandler.ts b/src/utils/errorHandler.ts new file mode 100644 index 00000000..f475d407 --- /dev/null +++ b/src/utils/errorHandler.ts @@ -0,0 +1,50 @@ +import { decodeErrorResult, BaseError, type Abi } from 'viem'; +import { + InsufficientBalanceError, + UnauthorizedError, + TransactionRevertedError, + UnknownTransactionError +} from '../errors/WhitechainErrors.js'; + +export function parseContractError(error: unknown, abi?: Abi): Error { + if (!(error instanceof Error)) { + return new UnknownTransactionError('An unknown error occurred.', error); + } + + // Attempt to extract revert data from typical viem/ethers error structures + let revertData: `0x${string}` | undefined; + + if (error instanceof BaseError) { + // Viem error: walk down the error chain to find the RPC error with data + const cause = error.walk() as any; + revertData = cause?.data || cause?.error?.data || cause?.cause?.data; + } else { + // Ethers / Generic RPC error + revertData = (error as any).data || (error as any).error?.data; + } + + if (revertData && typeof revertData === 'string' && revertData.startsWith('0x') && abi) { + try { + const decoded = decodeErrorResult({ abi, data: revertData }); + + switch (decoded.errorName) { + case 'InsufficientBalance': + return new InsufficientBalanceError(decoded.args); + case 'Unauthorized': + return new UnauthorizedError(decoded.args); + default: + return new TransactionRevertedError( + `Transaction reverted: ${decoded.errorName}`, + decoded.errorName, + decoded.args + ); + } + } catch (decodeErr) { + // Decode failed, maybe ABI didn't contain this error + return new TransactionRevertedError(`Transaction reverted with data: ${revertData}`, revertData); + } + } + + // If we can't extract revert data or ABI is missing, return a generic transaction error + return new UnknownTransactionError(error.message, error); +} diff --git a/tests/core/AbiCache.test.ts b/tests/core/AbiCache.test.ts index 3dd92400..56fab60d 100644 --- a/tests/core/AbiCache.test.ts +++ b/tests/core/AbiCache.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { AbiCache } from '../../src/core/AbiCache' +import { AbiCache } from '../../src/core/AbiCache.js' import type { Abi } from 'viem' const mockAbi: Abi = [ diff --git a/tests/core/Contract.test.ts b/tests/core/Contract.test.ts index f37fe8b9..6d94457d 100644 --- a/tests/core/Contract.test.ts +++ b/tests/core/Contract.test.ts @@ -1,86 +1,5 @@ -import { describe, expect, it, vi } from 'vitest' -import { Contract } from '../../src/core/Contract.js' -import { WhiteChainError } from '../../src/types.js' - -describe('Contract class and verify() opt-in check', () => { - const dummyAddress = '0x1234567890123456789012345678901234567890' - const dummyAbi: any[] = [] - - it('throws WhiteChainError if initialized without an address', () => { - expect(() => new Contract('' as any, dummyAbi, {} as any)).toThrow(WhiteChainError) - }); - - it('is an explicit opt-in check and does not call RPC upon instantiation', () => { - const getCodeMock = vi.fn() - const client = { getCode: getCodeMock } - - const contract = new Contract(dummyAddress, dummyAbi, client) - expect(contract.address).toBe(dummyAddress) - expect(getCodeMock).not.toHaveBeenCalled() - }); - - it('succeeds and returns the Contract instance when bytecode exists (non-0x)', async () => { - const getCodeMock = vi.fn().mockResolvedValue('0x608060405260043610601157') - const client = { getCode: getCodeMock } - - const contract = new Contract(dummyAddress, dummyAbi, client) - const result = await contract.verify() - - expect(getCodeMock).toHaveBeenCalledWith({ address: dummyAddress }) - expect(result).toBe(contract) - }); - - it('throws WhiteChainError if bytecode is 0x (contract does not exist)', async () => { - const getCodeMock = vi.fn().mockResolvedValue('0x') - const client = { getCode: getCodeMock } - - const contract = new Contract(dummyAddress, dummyAbi, client) - - await expect(contract.verify()).rejects.toThrow(WhiteChainError) - await expect(contract.verify()).rejects.toThrow("No contract code deployed at address 0x1234567890123456789012345678901234567890 (code is '0x')") - }); - - it('throws WhiteChainError if bytecode is 0x0 or empty', async () => { - const getCodeMock = vi.fn().mockResolvedValue('0x0') - const client = { getCode: getCodeMock } - - const contract = new Contract(dummyAddress, dummyAbi, client) - - await expect(contract.verify()).rejects.toThrow(WhiteChainError) - }); - - it('supports providers using request({ method: "eth_getCode", params: [...] })', async () => { - const requestMock = vi.fn().mockResolvedValue('0x60806040') - const provider = { request: requestMock } - - const contract = new Contract(dummyAddress, dummyAbi, provider) - await contract.verify() - - expect(requestMock).toHaveBeenCalledWith({ - method: 'eth_getCode', - params: [dummyAddress, 'latest'], - }) - }); - - it('supports client wrappers with publicClient', async () => { - const getCodeMock = vi.fn().mockResolvedValue('0x60806040') - const clientDeps = { publicClient: { getCode: getCodeMock } } - - const contract = new Contract(dummyAddress, dummyAbi, clientDeps as any) - await contract.verify() - - expect(getCodeMock).toHaveBeenCalledWith({ address: dummyAddress }) - }); - - it('throws WhiteChainError if provider does not support getCode or request', async () => { - const invalidClient = {} - - const contract = new Contract(dummyAddress, dummyAbi, invalidClient as any) - - await expect(contract.verify()).rejects.toThrow('Client or provider does not support getCode or eth_getCode') - }); import { describe, it, expect, vi } from 'vitest' -import { Contract } from '../../src/core/Contract' +import { Contract } from '../../src/core/Contract.js' const mockAbi = [ { @@ -140,7 +59,6 @@ describe('Contract', () => { args: ['0xabcdef1234567890abcdef1234567890abcdef12', 50n], }) }) - it('throws if clients are missing', async () => { const contract = new Contract('0x1234567890123456789012345678901234567890', mockAbi) diff --git a/tests/core/TransactionHelper.test.ts b/tests/core/TransactionHelper.test.ts index 6192e7b6..14cca5c3 100644 --- a/tests/core/TransactionHelper.test.ts +++ b/tests/core/TransactionHelper.test.ts @@ -7,7 +7,7 @@ describe('TransactionHelper', () => { beforeEach(() => { fetchFn = vi.fn(); - helper = new TransactionHelper(fetchFn); + helper = new TransactionHelper(fetchFn as unknown as import("../../src/core/TransactionHelper.js").RpcFetchFn); }); it('estimates gas and applies the default 1.2x buffer correctly', async () => { diff --git a/tests/providers/EnsResolver.test.ts b/tests/providers/EnsResolver.test.ts index fda1a95f..1cb860d1 100644 --- a/tests/providers/EnsResolver.test.ts +++ b/tests/providers/EnsResolver.test.ts @@ -23,7 +23,7 @@ describe('EnsResolver', () => { beforeEach(() => { fetchFn = vi.fn(); - resolver = new EnsResolver(fetchFn); + resolver = new EnsResolver(fetchFn as unknown as import("../../src/core/TransactionHelper.js").RpcFetchFn); }); it('returns the ENS name for a valid address', async () => { diff --git a/tests/testing/MockProvider.test.ts b/tests/testing/MockProvider.test.ts index 2b5626b3..a27d7780 100644 --- a/tests/testing/MockProvider.test.ts +++ b/tests/testing/MockProvider.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { MockProvider, returns } from '../../src/testing/MockProvider' +import { MockProvider, returns } from '../../src/testing/MockProvider.js' describe('MockProvider', () => { it('intercepts calls and returns dummy data', async () => { diff --git a/tests/utils/address.test.ts b/tests/utils/address.test.ts index fd72dd96..d923e9c6 100644 --- a/tests/utils/address.test.ts +++ b/tests/utils/address.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' -import { toChecksumAddress, isAddress, assertChecksumAddress } from '../../src/utils/address' -import { ValidationError } from '../../src/errors' +import { toChecksumAddress, isAddress, assertChecksumAddress } from '../../src/utils/address.js' +import { ValidationError } from '../../src/errors/index.js' // EIP-55 reference vectors from the EIP specification const VALID_CHECKSUMMED = [ From 8793f77bac18dbda2a301ea6307d933a2e1b5527 Mon Sep 17 00:00:00 2001 From: EngrEOOnoja Date: Tue, 28 Jul 2026 09:05:57 +0100 Subject: [PATCH 2/3] feat: implement reactive network context observer --- node_modules/.vite/vitest/results.json | 2 +- src/client.ts | 165 ++++++++++++++++--------- src/core/Contract.ts | 37 +++++- src/core/NetworkContext.ts | 42 +++++++ src/core/TransactionHelper.ts | 14 +-- src/index.ts | 1 + tests/client.test.ts | 17 +++ 7 files changed, 209 insertions(+), 69 deletions(-) create mode 100644 src/core/NetworkContext.ts diff --git a/node_modules/.vite/vitest/results.json b/node_modules/.vite/vitest/results.json index f5e8ca4b..98e48a8d 100644 --- a/node_modules/.vite/vitest/results.json +++ b/node_modules/.vite/vitest/results.json @@ -1 +1 @@ -{"version":"1.6.1","results":[[":tests/core/AbiCache.test.ts",{"duration":194,"failed":false}],[":tests/crypto/signer.fallback.test.ts",{"duration":1310,"failed":false}],[":tests/utils/address.test.ts",{"duration":116,"failed":false}],[":tests/crypto/signer.test.ts",{"duration":546,"failed":false}],[":tests/providers/BrowserProvider.test.ts",{"duration":68,"failed":false}],[":tests/providers/EnsResolver.test.ts",{"duration":41,"failed":false}],[":tests/errors.test.ts",{"duration":147,"failed":false}],[":tests/providers/IpcProvider.test.ts",{"duration":64,"failed":false}],[":tests/wallet/NonceManager.test.ts",{"duration":104,"failed":false}],[":tests/BatchProvider.test.ts",{"duration":208,"failed":false}],[":tests/utils/math.test.ts",{"duration":35,"failed":false}],[":tests/client.test.ts",{"duration":53,"failed":false}],[":tests/formatters/block.test.ts",{"duration":30,"failed":false}],[":tests/core/Contract.test.ts",{"duration":66,"failed":false}],[":tests/core/TransactionHelper.test.ts",{"duration":59,"failed":false}],[":tests/no-axios.test.ts",{"duration":699,"failed":false}],[":tests/tree-shaking.test.ts",{"duration":29,"failed":false}],[":tests/networks.test.ts",{"duration":36,"failed":false}],[":tests/testing/MockProvider.test.ts",{"duration":28,"failed":false}],[":tests/provider.test.ts",{"duration":252,"failed":false}]]} \ No newline at end of file +{"version":"1.6.1","results":[[":tests/core/AbiCache.test.ts",{"duration":183,"failed":false}],[":tests/crypto/signer.fallback.test.ts",{"duration":935,"failed":false}],[":tests/utils/address.test.ts",{"duration":71,"failed":false}],[":tests/crypto/signer.test.ts",{"duration":321,"failed":false}],[":tests/providers/EnsResolver.test.ts",{"duration":43,"failed":false}],[":tests/client.test.ts",{"duration":35,"failed":false}],[":tests/providers/BrowserProvider.test.ts",{"duration":42,"failed":false}],[":tests/errors.test.ts",{"duration":58,"failed":false}],[":tests/BatchProvider.test.ts",{"duration":240,"failed":false}],[":tests/wallet/NonceManager.test.ts",{"duration":70,"failed":false}],[":tests/core/TransactionHelper.test.ts",{"duration":24,"failed":false}],[":tests/utils/math.test.ts",{"duration":33,"failed":false}],[":tests/formatters/block.test.ts",{"duration":9,"failed":false}],[":tests/providers/IpcProvider.test.ts",{"duration":55,"failed":false}],[":tests/core/Contract.test.ts",{"duration":62,"failed":false}],[":tests/tree-shaking.test.ts",{"duration":75,"failed":false}],[":tests/no-axios.test.ts",{"duration":170,"failed":false}],[":tests/provider.test.ts",{"duration":665,"failed":false}],[":tests/testing/MockProvider.test.ts",{"duration":40,"failed":false}],[":tests/networks.test.ts",{"duration":58,"failed":false}]]} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index 59df88c3..a779c2a0 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,9 +13,10 @@ import { } from './types.js' import { WhiteChainError, ValidationError } from './errors/index.js' import { parseContractError } from './utils/errorHandler.js' -import type { NetworkProfile } from './config/networks.js' +import { networks, type NetworkProfile } from './config/networks.js' import { Eip1193Provider } from './providers/BrowserProvider.js' import { withGasEstimation, type WithGasEstimation } from './core/TransactionHelper.js' +import { NetworkContext } from './core/NetworkContext.js' const ensure = (value: T | undefined, message: string): T => { if (value === undefined || value === null) throw new ValidationError(message) @@ -26,15 +27,28 @@ const ensure = (value: T | undefined, message: string): T => { * A configured client for reading and writing to WhiteChain's grant round * contract. Construct one with {@link createWhiteChainClient}. */ -export type WhiteChainClient = ClientDeps & { +export type WhiteChainClient = { + /** The currently active public client. Automatically updates on network switch. */ + get publicClient(): any + /** The currently active wallet client. Automatically updates on network switch. */ + get walletClient(): any | undefined /** Contract addresses this client was configured with. */ - addresses: { grant: Address } + get addresses(): { grant: Address } /** Contract ABIs this client was configured with. */ - abis: { grant?: Abi } + get abis(): { grant?: Abi } /** Optional network profile associated with this client instance. */ - network?: NetworkProfile + get network(): NetworkProfile | undefined /** Standard block explorer URL associated with this client instance. */ - blockExplorerUrl?: string + get blockExplorerUrl(): string | undefined + + /** Exposes the underlying reactive network context. */ + networkContext: NetworkContext + + /** + * Switches the active network provider dynamically without destroying the client. + * @throws {WhiteChainError} if the chainId is not found in the registry. + */ + switchNetwork(chainId: number): Promise /** * Submits a new grant application on behalf of `applicant`. @@ -99,36 +113,26 @@ const defaultTransport = http() * Pass an `account` in `config` to enable write methods (submitting * applications, approving, releasing payouts); omit it to get a read-only * client — calling a write method on it throws a {@link WhiteChainError}. - * - * @example - * ```ts - * const client = createWhiteChainClient({ - * network: networks.sepolia, - * addresses: { grant: '0x...' }, - * abis: { grant: grantAbi }, - * }) - * const round = await client.getGrantRound(1n) - * ``` */ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: any }): WhiteChainClient { - const network = config.network - const blockExplorerUrl = config.blockExplorerUrl ?? network?.blockExplorerUrl + const initialNetwork = config.network + const blockExplorerUrl = config.blockExplorerUrl ?? initialNetwork?.blockExplorerUrl const transport = config.transport ?? - (network - ? http(network.rpcUrl) + (initialNetwork + ? http(initialNetwork.rpcUrl) : config.provider ? (config.provider instanceof Eip1193Provider ? config.provider : new Eip1193Provider(config.provider) ).toTransport() : defaultTransport) - const chain = config.chain ?? network?.chain + const chain = config.chain ?? initialNetwork?.chain - const publicClient = + const initialPublicClient = config.clients?.publicClient ?? createPublicClient({ chain: chain as any, transport }) - const walletClient = + const initialWalletClient = config.clients?.walletClient ?? (config.account ? createWalletClient({ chain: chain as any, transport, account: config.account }) @@ -137,24 +141,63 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a const addresses = config.addresses const abis = config.abis ?? {} - const requireWallet = () => ensure(walletClient, 'Wallet client is required for write actions') + const networkContext = new NetworkContext({ + chainId: initialNetwork?.chainId, + publicClient: initialPublicClient as any, + walletClient: initialWalletClient as any, + addresses: addresses as Record, + }) + + const getPublicClient = () => networkContext.getState().publicClient + const getWalletClient = () => networkContext.getState().walletClient + const getAddresses = () => networkContext.getState().addresses as { grant: Address } + const getAbi = () => abis.grant as Abi + + const requireWallet = () => ensure(getWalletClient(), 'Wallet client is required for write actions') const requireGrantAbi = () => ensure(abis.grant, 'Grant contract ABI must be provided in config.abis.grant') return { - publicClient: publicClient as any, - walletClient: walletClient as any, - addresses, - abis, - network, - blockExplorerUrl, + networkContext, + + get publicClient() { return networkContext.getState().publicClient }, + get walletClient() { return networkContext.getState().walletClient }, + get addresses() { return networkContext.getState().addresses as { grant: Address } }, + get abis() { return abis as { grant?: Abi } }, + get network() { + const currentChainId = networkContext.getState().chainId; + return currentChainId ? Object.values(networks).find(n => n.chainId === currentChainId) : undefined; + }, + get blockExplorerUrl() { return blockExplorerUrl }, + + async switchNetwork(chainId: number) { + const newNetwork = Object.values(networks).find(n => n.chainId === chainId); + if (!newNetwork) throw new WhiteChainError(`Network with chainId ${chainId} not found in registry`); + + // Using generic http transport for the new network + const newTransport = http(newNetwork.rpcUrl); + const newPublicClient = createPublicClient({ chain: newNetwork.chain as any, transport: newTransport }); + + let newWalletClient; + if (config.account) { + newWalletClient = createWalletClient({ chain: newNetwork.chain as any, transport: newTransport, account: config.account }); + } + + networkContext.setNetwork({ + chainId: newNetwork.chainId, + publicClient: newPublicClient as any, + walletClient: newWalletClient as any, + addresses: networkContext.getState().addresses, + }); + }, submitApplication: withGasEstimation( async ({ grantId, applicant, metadataUri }) => { const wc = requireWallet() const abi = requireGrantAbi() + const currentAddresses = getAddresses() try { return await (wc as any).writeContract({ - address: addresses.grant, + address: currentAddresses.grant, abi, functionName: 'submitApplication', args: [grantId, applicant, metadataUri], @@ -163,9 +206,9 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a throw parseContractError(err, abi as Abi) } }, - publicClient, - addresses.grant, - abis.grant as Abi, + getPublicClient, + () => getAddresses().grant, + getAbi, 'submitApplication', (params) => [params.grantId, params.applicant, params.metadataUri] ), @@ -174,9 +217,10 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async ({ applicationId }) => { const wc = requireWallet() const abi = requireGrantAbi() + const currentAddresses = getAddresses() try { return await (wc as any).writeContract({ - address: addresses.grant, + address: currentAddresses.grant, abi, functionName: 'approveApplication', args: [applicationId], @@ -185,9 +229,9 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a throw parseContractError(err, abi as Abi) } }, - publicClient, - addresses.grant, - abis.grant as Abi, + getPublicClient, + () => getAddresses().grant, + getAbi, 'approveApplication', (params) => [params.applicationId] ), @@ -196,9 +240,10 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async ({ milestoneId, evidenceUri }) => { const wc = requireWallet() const abi = requireGrantAbi() + const currentAddresses = getAddresses() try { return await (wc as any).writeContract({ - address: addresses.grant, + address: currentAddresses.grant, abi, functionName: 'submitMilestoneEvidence', args: [milestoneId, evidenceUri], @@ -207,9 +252,9 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a throw parseContractError(err, abi as Abi) } }, - publicClient, - addresses.grant, - abis.grant as Abi, + getPublicClient, + () => getAddresses().grant, + getAbi, 'submitMilestoneEvidence', (params) => [params.milestoneId, params.evidenceUri] ), @@ -218,9 +263,10 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async ({ milestoneId }) => { const wc = requireWallet() const abi = requireGrantAbi() + const currentAddresses = getAddresses() try { return await (wc as any).writeContract({ - address: addresses.grant, + address: currentAddresses.grant, abi, functionName: 'approveMilestone', args: [milestoneId], @@ -229,9 +275,9 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a throw parseContractError(err, abi as Abi) } }, - publicClient, - addresses.grant, - abis.grant as Abi, + getPublicClient, + () => getAddresses().grant, + getAbi, 'approveMilestone', (params) => [params.milestoneId] ), @@ -240,9 +286,10 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async ({ milestoneId }) => { const wc = requireWallet() const abi = requireGrantAbi() + const currentAddresses = getAddresses() try { return await (wc as any).writeContract({ - address: addresses.grant, + address: currentAddresses.grant, abi, functionName: 'releasePayout', args: [milestoneId], @@ -251,19 +298,21 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a throw parseContractError(err, abi as Abi) } }, - publicClient, - addresses.grant, - abis.grant as Abi, + getPublicClient, + () => getAddresses().grant, + getAbi, 'releasePayout', (params) => [params.milestoneId] ), async getGrantRound(grantId) { const abi = requireGrantAbi() + const pc = getPublicClient() + const currentAddresses = getAddresses() let status, applicationsCount; try { - [status, applicationsCount] = await (publicClient as any).readContract({ - address: addresses.grant, + [status, applicationsCount] = await (pc as any).readContract({ + address: currentAddresses.grant, abi, functionName: 'getGrantRound', args: [grantId], @@ -277,10 +326,12 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async getGrantApplication(applicationId) { const abi = requireGrantAbi() + const pc = getPublicClient() + const currentAddresses = getAddresses() let applicant, status, metadataUri; try { - [applicant, status, metadataUri] = await (publicClient as any).readContract({ - address: addresses.grant, + [applicant, status, metadataUri] = await (pc as any).readContract({ + address: currentAddresses.grant, abi, functionName: 'getGrantApplication', args: [applicationId], @@ -298,10 +349,12 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a async getMilestones(applicationId) { const abi = requireGrantAbi() + const pc = getPublicClient() + const currentAddresses = getAddresses() let raw; try { - raw = await (publicClient as any).readContract({ - address: addresses.grant, + raw = await (pc as any).readContract({ + address: currentAddresses.grant, abi, functionName: 'getMilestones', args: [applicationId], diff --git a/src/core/Contract.ts b/src/core/Contract.ts index 38e62ae7..8886b49c 100644 --- a/src/core/Contract.ts +++ b/src/core/Contract.ts @@ -2,6 +2,7 @@ import type { Address, PublicClient, WalletClient, Hash } from 'viem' import type { Abi, ExtractAbiFunctionNames, ExtractAbiFunction, AbiParametersToPrimitiveTypes, AbiStateMutability } from 'abitype' import { ValidationError } from '../errors/index.js' import { parseContractError } from '../utils/errorHandler.js' +import { NetworkContext, NetworkObserver, NetworkState } from './NetworkContext.js' type Prettify = { [K in keyof T]: T[K] @@ -37,13 +38,39 @@ type ExtractReturnType< export class Contract< TAbi extends Abi | readonly unknown[] = Abi, -> { +> implements NetworkObserver { + public publicClient?: PublicClient; + public walletClient?: WalletClient; + public address: Address; + constructor( - public readonly address: Address, + address: Address, public readonly abi: TAbi, - public readonly publicClient?: PublicClient, - public readonly walletClient?: WalletClient, - ) {} + publicClientOrContext?: PublicClient | NetworkContext, + walletClient?: WalletClient, + public readonly addressKey?: string + ) { + this.address = address; + + if (publicClientOrContext instanceof NetworkContext) { + publicClientOrContext.subscribe(this); + } else { + this.publicClient = publicClientOrContext; + this.walletClient = walletClient; + } + } + + onNetworkChanged(state: NetworkState) { + this.publicClient = state.publicClient; + this.walletClient = state.walletClient; + if (this.addressKey && state.addresses[this.addressKey]) { + this.address = state.addresses[this.addressKey]; + } + } + + destroy(context: NetworkContext) { + context.unsubscribe(this); + } /** * Strongly typed wrapper for publicClient.readContract diff --git a/src/core/NetworkContext.ts b/src/core/NetworkContext.ts new file mode 100644 index 00000000..3f2a071c --- /dev/null +++ b/src/core/NetworkContext.ts @@ -0,0 +1,42 @@ +import type { PublicClient, WalletClient, Address } from 'viem'; + +export interface NetworkState { + chainId?: number; + publicClient: PublicClient; + walletClient?: WalletClient; + addresses: Record; +} + +export interface NetworkObserver { + onNetworkChanged(state: NetworkState): void; +} + +export class NetworkContext { + private observers: Set = new Set(); + private state: NetworkState; + + constructor(initialState: NetworkState) { + this.state = initialState; + } + + subscribe(observer: NetworkObserver) { + this.observers.add(observer); + // Immediately notify the new observer of the current state + observer.onNetworkChanged(this.state); + } + + unsubscribe(observer: NetworkObserver) { + this.observers.delete(observer); + } + + setNetwork(newState: NetworkState) { + this.state = newState; + for (const observer of this.observers) { + observer.onNetworkChanged(this.state); + } + } + + getState(): NetworkState { + return this.state; + } +} diff --git a/src/core/TransactionHelper.ts b/src/core/TransactionHelper.ts index af442386..53bfb495 100644 --- a/src/core/TransactionHelper.ts +++ b/src/core/TransactionHelper.ts @@ -10,9 +10,9 @@ export type WithGasEstimation = { export function withGasEstimation( fn: (params: TParams) => Promise, - publicClient: any, - address: Address, - abi: Abi, + getPublicClient: () => any, + getAddress: () => Address, + getAbi: () => Abi, functionName: string, argsMapper: (params: TParams) => any[], defaultMultiplier: number = 1.2 @@ -22,14 +22,14 @@ export function withGasEstimation( const multiplier = options?.multiplier ?? defaultMultiplier; let baseGas: bigint; try { - baseGas = await (publicClient as any).estimateContractGas({ - address, - abi, + baseGas = await (getPublicClient() as any).estimateContractGas({ + address: getAddress(), + abi: getAbi(), functionName, args: argsMapper(params), }); } catch (err) { - throw parseContractError(err, abi); + throw parseContractError(err, getAbi()); } // apply buffer (pad by multiplier) return (baseGas * BigInt(Math.floor(multiplier * 100))) / 100n; diff --git a/src/index.ts b/src/index.ts index 75d56848..1396744f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from './network/provider.js' export * from './network/BatchProvider.js' export { Contract } from './core/Contract.js' export * from './core/TransactionHelper.js' +export { NetworkContext, type NetworkObserver, type NetworkState } from './core/NetworkContext.js' export { AbiCache, abiCache } from './core/AbiCache.js' export type { diff --git a/tests/client.test.ts b/tests/client.test.ts index ffb0244e..25a39c94 100644 --- a/tests/client.test.ts +++ b/tests/client.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi } from 'vitest' import type { Abi, Address } from 'viem' import { createWhiteChainClient } from '../src/index.js' +import { networks } from '../src/config/networks.js' const dummyAbi = [ { type: 'function', name: 'submitApplication', stateMutability: 'nonpayable', inputs: [ @@ -99,5 +100,21 @@ describe('WhiteChainClient', () => { { id: 2n, status: 'paid', evidenceUri: undefined }, ]) }) + + it('updates clients dynamically when switchNetwork is called', async () => { + const client = createWhiteChainClient({ + network: networks.sepolia, + addresses: { grant: grantAddress }, + abis: { grant: dummyAbi }, + }) + + expect(client.network?.name).toBe('Sepolia') + const originalPublicClient = client.publicClient + + await client.switchNetwork(1875) // WhiteChain Mainnet + + expect(client.network?.name).toBe('WhiteChain Mainnet') + expect(client.publicClient).not.toBe(originalPublicClient) + }) }) From 93c271d0942111fa376615acf458ff773880fadb Mon Sep 17 00:00:00 2001 From: EngrEOOnoja Date: Tue, 28 Jul 2026 10:46:04 +0100 Subject: [PATCH 3/3] feat: implement transaction simulator with trace fallback --- node_modules/.vite/vitest/results.json | 2 +- src/client.ts | 17 +++ src/index.ts | 3 + src/services/Simulator.ts | 181 +++++++++++++++++++++++++ src/types/simulation.ts | 31 +++++ tests/services/Simulator.test.ts | 101 ++++++++++++++ 6 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 src/services/Simulator.ts create mode 100644 src/types/simulation.ts create mode 100644 tests/services/Simulator.test.ts diff --git a/node_modules/.vite/vitest/results.json b/node_modules/.vite/vitest/results.json index 98e48a8d..471fd9a3 100644 --- a/node_modules/.vite/vitest/results.json +++ b/node_modules/.vite/vitest/results.json @@ -1 +1 @@ -{"version":"1.6.1","results":[[":tests/core/AbiCache.test.ts",{"duration":183,"failed":false}],[":tests/crypto/signer.fallback.test.ts",{"duration":935,"failed":false}],[":tests/utils/address.test.ts",{"duration":71,"failed":false}],[":tests/crypto/signer.test.ts",{"duration":321,"failed":false}],[":tests/providers/EnsResolver.test.ts",{"duration":43,"failed":false}],[":tests/client.test.ts",{"duration":35,"failed":false}],[":tests/providers/BrowserProvider.test.ts",{"duration":42,"failed":false}],[":tests/errors.test.ts",{"duration":58,"failed":false}],[":tests/BatchProvider.test.ts",{"duration":240,"failed":false}],[":tests/wallet/NonceManager.test.ts",{"duration":70,"failed":false}],[":tests/core/TransactionHelper.test.ts",{"duration":24,"failed":false}],[":tests/utils/math.test.ts",{"duration":33,"failed":false}],[":tests/formatters/block.test.ts",{"duration":9,"failed":false}],[":tests/providers/IpcProvider.test.ts",{"duration":55,"failed":false}],[":tests/core/Contract.test.ts",{"duration":62,"failed":false}],[":tests/tree-shaking.test.ts",{"duration":75,"failed":false}],[":tests/no-axios.test.ts",{"duration":170,"failed":false}],[":tests/provider.test.ts",{"duration":665,"failed":false}],[":tests/testing/MockProvider.test.ts",{"duration":40,"failed":false}],[":tests/networks.test.ts",{"duration":58,"failed":false}]]} \ No newline at end of file +{"version":"1.6.1","results":[[":tests/core/AbiCache.test.ts",{"duration":159,"failed":false}],[":tests/crypto/signer.fallback.test.ts",{"duration":732,"failed":false}],[":tests/utils/address.test.ts",{"duration":85,"failed":false}],[":tests/crypto/signer.test.ts",{"duration":596,"failed":false}],[":tests/client.test.ts",{"duration":68,"failed":false}],[":tests/providers/BrowserProvider.test.ts",{"duration":185,"failed":false}],[":tests/errors.test.ts",{"duration":52,"failed":false}],[":tests/BatchProvider.test.ts",{"duration":181,"failed":false}],[":tests/providers/EnsResolver.test.ts",{"duration":83,"failed":false}],[":tests/wallet/NonceManager.test.ts",{"duration":88,"failed":false}],[":tests/services/Simulator.test.ts",{"duration":45,"failed":false}],[":tests/providers/IpcProvider.test.ts",{"duration":34,"failed":false}],[":tests/utils/math.test.ts",{"duration":29,"failed":false}],[":tests/core/Contract.test.ts",{"duration":44,"failed":false}],[":tests/core/TransactionHelper.test.ts",{"duration":42,"failed":false}],[":tests/formatters/block.test.ts",{"duration":24,"failed":false}],[":tests/no-axios.test.ts",{"duration":101,"failed":false}],[":tests/networks.test.ts",{"duration":26,"failed":false}],[":tests/testing/MockProvider.test.ts",{"duration":16,"failed":false}],[":tests/tree-shaking.test.ts",{"duration":17,"failed":false}],[":tests/provider.test.ts",{"duration":162,"failed":false}]]} \ No newline at end of file diff --git a/src/client.ts b/src/client.ts index a779c2a0..311a7a52 100644 --- a/src/client.ts +++ b/src/client.ts @@ -17,6 +17,8 @@ import { networks, type NetworkProfile } from './config/networks.js' import { Eip1193Provider } from './providers/BrowserProvider.js' import { withGasEstimation, type WithGasEstimation } from './core/TransactionHelper.js' import { NetworkContext } from './core/NetworkContext.js' +import { Simulator } from './services/Simulator.js' +import type { SimulationResult, SimulationOptions } from './types/simulation.js' const ensure = (value: T | undefined, message: string): T => { if (value === undefined || value === null) throw new ValidationError(message) @@ -50,6 +52,15 @@ export type WhiteChainClient = { */ switchNetwork(chainId: number): Promise + /** + * Dry-runs a transaction against the node's current state to validate outcomes + * before signing (e.g., checking for expected token transfers or revert reasons). + */ + simulateTransaction( + tx: { to: Address; data: string; from?: Address; value?: bigint }, + options?: SimulationOptions + ): Promise + /** * Submits a new grant application on behalf of `applicant`. * @throws {WhiteChainError} if the client has no signing account, or no `abis.grant` was provided. @@ -190,6 +201,12 @@ export function createWhiteChainClient(config: WhiteChainConfig & { provider?: a }); }, + async simulateTransaction(tx, options) { + const simulator = new Simulator(networkContext.getState().publicClient); + // We pass the grant contract ABI by default so it can decode grant errors natively + return simulator.simulateTransaction(tx, abis.grant, options); + }, + submitApplication: withGasEstimation( async ({ grantId, applicant, metadataUri }) => { const wc = requireWallet() diff --git a/src/index.ts b/src/index.ts index 1396744f..16e355ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,3 +62,6 @@ export { type Signature, type SignerBackend, } from './crypto/index.js' + +export { Simulator } from './services/Simulator.js' +export type { SimulationResult, SimulationOptions, TransferEvent, StateOverrides } from './types/simulation.js' diff --git a/src/services/Simulator.ts b/src/services/Simulator.ts new file mode 100644 index 00000000..54705208 --- /dev/null +++ b/src/services/Simulator.ts @@ -0,0 +1,181 @@ +import { type Address, type PublicClient, type Abi, decodeEventLog } from 'viem'; +import { parseContractError } from '../utils/errorHandler.js'; +import type { SimulationResult, SimulationOptions, TransferEvent, StateOverrides } from '../types/simulation.js'; + +const ERC20_TRANSFER_EVENT_ABI = [ + { + anonymous: false, + inputs: [ + { indexed: true, name: 'from', type: 'address' }, + { indexed: true, name: 'to', type: 'address' }, + { indexed: false, name: 'value', type: 'uint256' }, + ], + name: 'Transfer', + type: 'event', + } +] as const; + +export class Simulator { + constructor(private publicClient: PublicClient) {} + + /** + * Simulates a transaction by first trying debug_traceCall to extract internal + * state changes (like ERC20 transfers). If the node doesn't support tracing, + * it falls back to a standard eth_call to at least check success/revert. + */ + async simulateTransaction( + tx: { to: Address; data: string; from?: Address; value?: bigint }, + abi?: Abi, + options?: SimulationOptions + ): Promise { + const formattedTx = { + to: tx.to, + data: tx.data, + ...(tx.from ? { from: tx.from } : {}), + ...(tx.value ? { value: `0x${tx.value.toString(16)}` } : {}), + }; + + let formattedOverrides = undefined; + if (options?.stateOverrides) { + formattedOverrides = this.formatStateOverrides(options.stateOverrides); + } + + try { + // 1. Try debug_traceCall with callTracer + const traceArgs: any[] = [formattedTx, 'latest', { tracer: 'callTracer' }]; + + const rawTrace = await (this.publicClient as any).request({ + method: 'debug_traceCall', + params: formattedOverrides ? [...traceArgs, formattedOverrides] : traceArgs + }); + + return this.parseTraceResult(rawTrace, abi); + } catch (traceError: any) { + // If debug_traceCall fails (e.g. method not supported), fallback to eth_call + const isMethodNotSupported = traceError?.message?.toLowerCase().includes('not supported') || + traceError?.message?.toLowerCase().includes('does not exist'); + + if (isMethodNotSupported) { + try { + const callArgs: any[] = [formattedTx, 'latest']; + if (formattedOverrides) callArgs.push(formattedOverrides); + + await (this.publicClient as any).request({ + method: 'eth_call', + params: callArgs + }); + + return { + status: 'success', + expectedTransfers: [], + gasUsed: 0n, // cannot determine gas from eth_call + rawData: { note: 'Fallback to eth_call used. No trace available.' } + }; + } catch (callError: any) { + return { + status: 'revert', + expectedTransfers: [], + gasUsed: 0n, + errorReason: abi ? parseContractError(callError, abi).message : callError.message, + rawData: callError + }; + } + } + + // If it was a legitimate revert during trace (some nodes throw on revert during trace) + return { + status: 'revert', + expectedTransfers: [], + gasUsed: 0n, + errorReason: abi ? parseContractError(traceError, abi).message : traceError.message, + rawData: traceError + }; + } + } + + private parseTraceResult(trace: any, abi?: Abi): SimulationResult { + // If the trace contains an error field at the top level + if (trace.error) { + let reason = trace.error; + if (trace.revertReason) { + reason = trace.revertReason; + } else if (trace.output && trace.output !== '0x' && abi) { + // Attempt to decode revert data if present + try { + const decoded = parseContractError({ data: trace.output }, abi); + reason = decoded.message; + } catch (e) {} + } + return { + status: 'revert', + expectedTransfers: [], + gasUsed: BigInt(trace.gasUsed || 0), + errorReason: reason, + rawData: trace + }; + } + + const expectedTransfers: TransferEvent[] = []; + this.extractTransfers(trace, expectedTransfers); + + return { + status: 'success', + expectedTransfers, + gasUsed: BigInt(trace.gasUsed || 0), + rawData: trace + }; + } + + private extractTransfers(callFrame: any, transfers: TransferEvent[]) { + if (callFrame.logs && Array.isArray(callFrame.logs)) { + for (const log of callFrame.logs) { + // Try decoding as ERC20 Transfer + try { + const decoded = decodeEventLog({ + abi: ERC20_TRANSFER_EVENT_ABI, + data: log.data, + topics: log.topics, + }); + + if (decoded.eventName === 'Transfer') { + transfers.push({ + from: (decoded.args as any).from, + to: (decoded.args as any).to, + value: (decoded.args as any).value, + token: callFrame.to || log.address // callFrame.to in callTracer + }); + } + } catch (e) { + // Not a transfer event or decoding failed, safely ignore + } + } + } + + // Recursively check subcalls + if (callFrame.calls && Array.isArray(callFrame.calls)) { + for (const subcall of callFrame.calls) { + this.extractTransfers(subcall, transfers); + } + } + } + + private formatStateOverrides(overrides: StateOverrides): any { + const formatted: any = {}; + for (const [address, override] of Object.entries(overrides)) { + formatted[address] = {}; + if (override.balance !== undefined) { + formatted[address].balance = `0x${override.balance.toString(16)}`; + } + if (override.nonce !== undefined) { + formatted[address].nonce = `0x${override.nonce.toString(16)}`; + } + if (override.code !== undefined) { + formatted[address].code = override.code; + } + if (override.state !== undefined) { + formatted[address].stateDiff = override.state; // stateDiff is often used + } + } + return formatted; + } +} diff --git a/src/types/simulation.ts b/src/types/simulation.ts new file mode 100644 index 00000000..8a356a43 --- /dev/null +++ b/src/types/simulation.ts @@ -0,0 +1,31 @@ +import type { Address } from 'viem'; + +export interface TransferEvent { + from: Address; + to: Address; + value: bigint; + token: Address; +} + +export interface SimulationResult { + status: 'success' | 'revert'; + expectedTransfers: TransferEvent[]; + gasUsed: bigint; + errorReason?: string; + rawData?: any; +} + +export interface StateOverrides { + [address: string]: { + balance?: bigint; + nonce?: number; + code?: string; + state?: { + [slot: string]: string; + }; + }; +} + +export interface SimulationOptions { + stateOverrides?: StateOverrides; +} diff --git a/tests/services/Simulator.test.ts b/tests/services/Simulator.test.ts new file mode 100644 index 00000000..debd74f0 --- /dev/null +++ b/tests/services/Simulator.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Simulator } from '../../src/services/Simulator.js'; +import { encodeEventTopics, type Address } from 'viem'; + +const TRANSFER_ABI = { + anonymous: false, + inputs: [ + { indexed: true, name: 'from', type: 'address' }, + { indexed: true, name: 'to', type: 'address' }, + { indexed: false, name: 'value', type: 'uint256' }, + ], + name: 'Transfer', + type: 'event', +} as const; + +describe('Simulator', () => { + it('parses debug_traceCall successfully and extracts transfers', async () => { + const token = '0x1111111111111111111111111111111111111111' as Address; + const from = '0x2222222222222222222222222222222222222222' as Address; + const to = '0x3333333333333333333333333333333333333333' as Address; + + // We pad the addresses to 32 bytes for topics + const fromTopic = `0x000000000000000000000000${from.slice(2)}` as `0x${string}`; + const toTopic = `0x000000000000000000000000${to.slice(2)}` as `0x${string}`; + + const topics = encodeEventTopics({ + abi: [TRANSFER_ABI], + eventName: 'Transfer' + }); + + const rawTrace = { + gasUsed: '0x1234', + calls: [ + { + to: token, + logs: [ + { + address: token, + topics: [topics[0], fromTopic, toTopic], + data: '0x00000000000000000000000000000000000000000000000000000000000003e8' // 1000 + } + ] + } + ] + }; + + const requestMock = vi.fn().mockResolvedValue(rawTrace); + const publicClient = { request: requestMock } as any; + + const simulator = new Simulator(publicClient); + + const res = await simulator.simulateTransaction({ to: '0x4444', data: '0x' }); + + expect(res.status).toBe('success'); + expect(res.gasUsed).toBe(BigInt(0x1234)); + expect(res.expectedTransfers).toHaveLength(1); + expect(res.expectedTransfers[0]).toEqual({ + from, + to, + value: 1000n, + token + }); + }); + + it('falls back to eth_call if trace is not supported', async () => { + const requestMock = vi.fn().mockImplementation(async ({ method }) => { + if (method === 'debug_traceCall') { + throw new Error('Method debug_traceCall not supported'); + } + if (method === 'eth_call') { + return '0x0000'; // success return data + } + }); + const publicClient = { request: requestMock } as any; + + const simulator = new Simulator(publicClient); + const res = await simulator.simulateTransaction({ to: '0x4444', data: '0x' }); + + expect(requestMock).toHaveBeenCalledTimes(2); + expect(res.status).toBe('success'); + expect(res.expectedTransfers).toHaveLength(0); // can't extract without trace + }); + + it('returns revert status if eth_call fallback fails', async () => { + const requestMock = vi.fn().mockImplementation(async ({ method }) => { + if (method === 'debug_traceCall') { + throw new Error('Method not supported'); + } + if (method === 'eth_call') { + throw new Error('execution reverted'); + } + }); + const publicClient = { request: requestMock } as any; + + const simulator = new Simulator(publicClient); + const res = await simulator.simulateTransaction({ to: '0x4444', data: '0x' }); + + expect(res.status).toBe('revert'); + expect(res.errorReason).toBe('execution reverted'); + }); +});