From 0ee1fd9c2c01315531e295782c53aa2fda791d2e Mon Sep 17 00:00:00 2001 From: Georgios Konstantopoulos Date: Wed, 18 Feb 2026 23:26:03 +0000 Subject: [PATCH 1/2] fix(explorer): fix contract interact for unverified contracts - Extract shared decodeRawCallResult helper (deduplicates StaticReadFunction and DynamicReadFunction heuristic decoding) - Fix empty array decoding: allow length >= 0 (not > 0) so empty address[] returns are decoded correctly instead of falling through to string - Add uint256[] fallback when array elements don't look like addresses - Improve string heuristic: only accept printable ASCII strings to avoid false positives that hide real array/uint results - Use fnId consistently in useReadContract (fixes unnamed whatsabi functions) - Improve write error messages: surface nested revert reasons and add authorization hint for 'unknown reason' reverts Amp-Thread-ID: https://ampcode.com/threads/T-019c7304-001e-7159-b236-acaf3e0690a8 Co-authored-by: Amp --- apps/explorer/src/comps/ContractReader.tsx | 298 +++++++++------------ apps/explorer/src/comps/ContractWriter.tsx | 23 +- 2 files changed, 147 insertions(+), 174 deletions(-) diff --git a/apps/explorer/src/comps/ContractReader.tsx b/apps/explorer/src/comps/ContractReader.tsx index f09aaf180..0c7f45d40 100644 --- a/apps/explorer/src/comps/ContractReader.tsx +++ b/apps/explorer/src/comps/ContractReader.tsx @@ -28,6 +28,128 @@ import PlayIcon from '~icons/lucide/play' type ReadFunction = AbiFunction & { stateMutability: 'view' | 'pure' } +/** + * Try to decode raw hex call result using type heuristics. + * Used for whatsabi-extracted ABIs where output types are unknown. + */ +function decodeRawCallResult( + fn: ReadFunction, + data: `0x${string}`, +): unknown | undefined { + const fnName = fn.name || getFunctionSelector(fn) + + // Check if it looks like a padded address (32 bytes with 12 leading zero bytes) + const looksLikeAddress = + data.length === 66 && + data.slice(2, 26) === '000000000000000000000000' && + data.slice(26) !== '0000000000000000000000000000000000000000' + + if (looksLikeAddress) { + try { + const addressAbi = [{ ...fn, outputs: [{ type: 'address', name: '' }] }] + return decodeFunctionResult({ + abi: addressAbi, + functionName: fnName, + data, + }) + } catch { + // Fall through to other attempts + } + } + + // Check if it looks like a dynamic array (address[], uint256[], bytes32[], etc.) + // Format: offset (32 bytes) + length (32 bytes) + N elements (32 bytes each) + if (data.length >= 130 || data === `0x${'0'.repeat(128)}`) { + try { + const offset = Number.parseInt(data.slice(2, 66), 16) + const length = Number.parseInt(data.slice(66, 130), 16) + if (offset === 32 && length >= 0 && length < 100) { + const expectedLength = 2 + 64 + 64 + length * 64 + if (data.length === expectedLength) { + // Empty array + if (length === 0) { + const addressArrayAbi = [ + { ...fn, outputs: [{ type: 'address[]', name: '' }] }, + ] + return decodeFunctionResult({ + abi: addressArrayAbi, + functionName: fnName, + data, + }) + } + // Check if elements look like addresses (12 leading zero bytes) + let allAddressesValid = true + for (let i = 0; i < length; i++) { + const start = 2 + 128 + i * 64 + if (data.slice(start, start + 24) !== '000000000000000000000000') { + allAddressesValid = false + break + } + } + if (allAddressesValid) { + const addressArrayAbi = [ + { ...fn, outputs: [{ type: 'address[]', name: '' }] }, + ] + return decodeFunctionResult({ + abi: addressArrayAbi, + functionName: fnName, + data, + }) + } + // Not addresses — try as uint256[] + try { + const uint256ArrayAbi = [ + { ...fn, outputs: [{ type: 'uint256[]', name: '' }] }, + ] + return decodeFunctionResult({ + abi: uint256ArrayAbi, + functionName: fnName, + data, + }) + } catch { + // Fall through + } + } + } + } catch { + // Fall through to other attempts + } + } + + // Try decoding as string (common for functions like typeAndVersion) + try { + const stringAbi = [{ ...fn, outputs: [{ type: 'string', name: '' }] }] + const decoded = decodeFunctionResult({ + abi: stringAbi, + functionName: fnName, + data, + }) as unknown + // Only accept if it decoded to a non-empty, printable string + if ( + typeof decoded === 'string' && + decoded.length > 0 && + /^[\x20-\x7e\s]+$/.test(decoded) + ) { + return decoded + } + } catch { + // Fall through + } + + // Try decoding as uint256 (common for numeric getters) + try { + const uint256Abi = [{ ...fn, outputs: [{ type: 'uint256', name: '' }] }] + return decodeFunctionResult({ + abi: uint256Abi, + functionName: fnName, + data, + }) + } catch { + // Return raw hex if all decode attempts fail + return data + } +} + export function ContractReader(props: { address: Address.Address abi: Abi @@ -166,7 +288,7 @@ function StaticReadFunction(props: { } = useReadContract({ address, abi, - functionName: fn.name, + functionName: fnId, args: [], query: { enabled: mounted && hasOutputs }, }) @@ -175,11 +297,11 @@ function StaticReadFunction(props: { const callData = React.useMemo(() => { if (hasOutputs) return undefined try { - return encodeFunctionData({ abi, functionName: fn.name, args: [] }) + return encodeFunctionData({ abi, functionName: fnId, args: [] }) } catch { return undefined } - }, [abi, fn.name, hasOutputs]) + }, [abi, fnId, hasOutputs]) const { data: rawResult, @@ -198,91 +320,7 @@ function StaticReadFunction(props: { const decodedRawResult = React.useMemo(() => { if (hasOutputs || !rawResult?.data) return undefined - const data = rawResult.data - - // Check if it looks like a padded address (32 bytes with 12 leading zero bytes) - // Address encoding: 0x + 24 zeros + 40 hex chars (20 bytes address) - const looksLikeAddress = - data.length === 66 && - data.slice(2, 26) === '000000000000000000000000' && - data.slice(26) !== '0000000000000000000000000000000000000000' - - if (looksLikeAddress) { - try { - const addressAbi = [{ ...fn, outputs: [{ type: 'address', name: '' }] }] - return decodeFunctionResult({ - abi: addressAbi, - functionName: fn.name, - data, - }) - } catch { - // Fall through to other attempts - } - } - - // Check if it looks like an address[] array - // Format: offset (32 bytes) + length (32 bytes) + N addresses (32 bytes each) - // Minimum: 0x + 32 + 32 + 32 = 98 hex chars (1 empty array = 128 chars, 1 element = 194 chars) - if (data.length >= 130) { - try { - const offset = Number.parseInt(data.slice(2, 66), 16) - const length = Number.parseInt(data.slice(66, 130), 16) - // Verify offset is 32 (0x20) and we have the right amount of data - if (offset === 32 && length > 0 && length < 100) { - const expectedLength = 2 + 64 + 64 + length * 64 // 0x + offset + length + N*address - if (data.length === expectedLength) { - // Verify each element looks like an address (12 leading zeros) - let allAddressesValid = true - for (let i = 0; i < length; i++) { - const start = 2 + 128 + i * 64 // Skip 0x + offset (64) + length (64) - if ( - data.slice(start, start + 24) !== '000000000000000000000000' - ) { - allAddressesValid = false - break - } - } - if (allAddressesValid) { - const addressArrayAbi = [ - { ...fn, outputs: [{ type: 'address[]', name: '' }] }, - ] - return decodeFunctionResult({ - abi: addressArrayAbi, - functionName: fn.name, - data, - }) - } - } - } - } catch { - // Fall through to other attempts - } - } - - // Try decoding as string (common for functions like typeAndVersion) - try { - const stringAbi = [{ ...fn, outputs: [{ type: 'string', name: '' }] }] - return decodeFunctionResult({ - abi: stringAbi, - functionName: fn.name, - data, - }) - } catch { - // Fall through - } - - // Try decoding as uint256 (common for numeric getters) - try { - const uint256Abi = [{ ...fn, outputs: [{ type: 'uint256', name: '' }] }] - return decodeFunctionResult({ - abi: uint256Abi, - functionName: fn.name, - data, - }) - } catch { - // Return raw hex if all decode attempts fail - return data - } + return decodeRawCallResult(fn, rawResult.data) }, [hasOutputs, rawResult, fn]) const isLoading = !mounted || (hasOutputs ? typedLoading : rawLoading) @@ -502,89 +540,7 @@ function DynamicReadFunction(props: { const decodedRawResult = React.useMemo(() => { if (hasOutputs || !rawResult?.data) return undefined - const data = rawResult.data - - // Check if it looks like a padded address - const looksLikeAddress = - data.length === 66 && - data.slice(2, 26) === '000000000000000000000000' && - data.slice(26) !== '0000000000000000000000000000000000000000' - - if (looksLikeAddress) { - try { - const addressAbi = [{ ...fn, outputs: [{ type: 'address', name: '' }] }] - return decodeFunctionResult({ - abi: addressAbi, - functionName: fn.name, - data, - }) - } catch { - // Fall through - } - } - - // Check if it looks like an address[] array - // Format: offset (32 bytes) + length (32 bytes) + N addresses (32 bytes each) - if (data.length >= 130) { - try { - const offset = Number.parseInt(data.slice(2, 66), 16) - const length = Number.parseInt(data.slice(66, 130), 16) - // Verify offset is 32 (0x20) and we have the right amount of data - if (offset === 32 && length > 0 && length < 100) { - const expectedLength = 2 + 64 + 64 + length * 64 // 0x + offset + length + N*address - if (data.length === expectedLength) { - // Verify each element looks like an address (12 leading zeros) - let allAddressesValid = true - for (let i = 0; i < length; i++) { - const start = 2 + 128 + i * 64 // Skip 0x + offset (64) + length (64) - if ( - data.slice(start, start + 24) !== '000000000000000000000000' - ) { - allAddressesValid = false - break - } - } - if (allAddressesValid) { - const addressArrayAbi = [ - { ...fn, outputs: [{ type: 'address[]', name: '' }] }, - ] - return decodeFunctionResult({ - abi: addressArrayAbi, - functionName: fn.name, - data, - }) - } - } - } - } catch { - // Fall through to other attempts - } - } - - // Try decoding as uint256 (common for balanceOf, etc.) - try { - const uint256Abi = [{ ...fn, outputs: [{ type: 'uint256', name: '' }] }] - return decodeFunctionResult({ - abi: uint256Abi, - functionName: fn.name, - data, - }) - } catch { - // Fall through - } - - // Try decoding as string - try { - const stringAbi = [{ ...fn, outputs: [{ type: 'string', name: '' }] }] - return decodeFunctionResult({ - abi: stringAbi, - functionName: fn.name, - data, - }) - } catch { - // Return raw hex if all decode attempts fail - return data - } + return decodeRawCallResult(fn, rawResult.data) }, [hasOutputs, rawResult, fn]) const result = hasOutputs ? typedResult : decodedRawResult diff --git a/apps/explorer/src/comps/ContractWriter.tsx b/apps/explorer/src/comps/ContractWriter.tsx index 7a1e22de1..71fa0a392 100644 --- a/apps/explorer/src/comps/ContractWriter.tsx +++ b/apps/explorer/src/comps/ContractWriter.tsx @@ -22,6 +22,25 @@ import CopyIcon from '~icons/lucide/copy' import LinkIcon from '~icons/lucide/link' import PlayIcon from '~icons/lucide/play' +function getWriteErrorMessage(err: Error): string { + const anyErr = err as Error & { + shortMessage?: string + cause?: Error & { shortMessage?: string } + } + const message = + anyErr.shortMessage ?? + anyErr.cause?.shortMessage ?? + anyErr.cause?.message ?? + err.message ?? + 'Transaction failed' + + if (/unknown reason|reverted/i.test(message)) { + return `${message}. This usually means the caller is not authorized (e.g. only the contract owner/admin can execute this function).` + } + + return message +} + export function ContractWriter(props: ContractWriter.Props) { const { address, abi } = props @@ -271,9 +290,7 @@ function WriteContractFunction(props: { {writeContract.error && (

- {'shortMessage' in writeContract.error - ? writeContract.error.shortMessage - : (writeContract.error.message ?? 'Transaction failed')} + {getWriteErrorMessage(writeContract.error)}

)} From 874072cbc2ee2f0db5d001c0c69a482fe2dbe2e6 Mon Sep 17 00:00:00 2001 From: Georgios Konstantopoulos Date: Wed, 18 Feb 2026 23:33:29 +0000 Subject: [PATCH 2/2] fix(explorer): show unnamed whatsabi functions in interact tab For unverified contracts where whatsabi extracts selectors from bytecode but can't resolve names from signature databases, include the unnamed functions in both Read and Write sections so users can still interact via selector. Previously these functions were invisible. Amp-Thread-ID: https://ampcode.com/threads/T-019c7304-001e-7159-b236-acaf3e0690a8 Co-authored-by: Amp --- apps/explorer/src/lib/domain/contracts.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/explorer/src/lib/domain/contracts.ts b/apps/explorer/src/lib/domain/contracts.ts index 904d56c58..b0e630f63 100644 --- a/apps/explorer/src/lib/domain/contracts.ts +++ b/apps/explorer/src/lib/domain/contracts.ts @@ -496,6 +496,10 @@ export function getReadFunctions(abi: Abi): ReadFunction[] { // (e.g., typeAndVersion(), owner(), MAX_RET_BYTES(), etc.) if (item.inputs.length === 0) return true + // Unnamed functions (selector-only from bytecode extraction) with inputs: + // include them so users can still call by selector + if (!item.name) return true + // Default: only include if explicitly view/pure return item.stateMutability === 'view' || item.stateMutability === 'pure' }) @@ -534,6 +538,9 @@ export function getWriteFunctions(abi: Abi): WriteFunction[] { // Functions with no inputs that don't look like writes are likely getters if (item.inputs.length === 0 && !looksLikeWriteFunction(item.name)) return false + // Unnamed functions with inputs: include in writes too since we can't + // determine mutability from bytecode alone + if (!item.name && item.inputs.length > 0) return true } return true