From 5612fd4ec8a2c94d10a5b8617fec4f9d1b146a0a Mon Sep 17 00:00:00 2001 From: Roisin Dowling Date: Wed, 10 Dec 2025 17:59:26 +0000 Subject: [PATCH 1/7] Add sourcify to services --- src/services/sourcify/index.ts | 3 + src/services/sourcify/schemas.ts | 242 ++++++++++++++++++++++++++++++ src/services/sourcify/utils.ts | 249 +++++++++++++++++++++++++++++++ 3 files changed, 494 insertions(+) create mode 100644 src/services/sourcify/index.ts create mode 100644 src/services/sourcify/schemas.ts create mode 100644 src/services/sourcify/utils.ts diff --git a/src/services/sourcify/index.ts b/src/services/sourcify/index.ts new file mode 100644 index 0000000..05e5e94 --- /dev/null +++ b/src/services/sourcify/index.ts @@ -0,0 +1,3 @@ +export * from './schemas' +export * from './utils' + diff --git a/src/services/sourcify/schemas.ts b/src/services/sourcify/schemas.ts new file mode 100644 index 0000000..7756982 --- /dev/null +++ b/src/services/sourcify/schemas.ts @@ -0,0 +1,242 @@ +import { z } from 'zod' + +// ============================================ +// Contract List Schemas (GET /v2/contracts/{chainId}) +// ============================================ + +/** + * Schema for a contract in the list response + */ +export const SourcifyContractListItemSchema = z + .object({ + match: z.string().describe('Match type: exact_match or match'), + creationMatch: z.string().nullable().optional().describe('Creation bytecode match type'), + runtimeMatch: z.string().nullable().optional().describe('Runtime bytecode match type'), + chainId: z.string().describe('Chain ID'), + address: z.string().describe('Contract address'), + verifiedAt: z.string().optional().describe('Verification timestamp'), + matchId: z.string().optional().describe('Unique match ID for pagination'), + }) + .passthrough() + +/** + * Schema for the contracts list response + */ +export const SourcifyContractsListResponseSchema = z + .object({ + results: z.array(SourcifyContractListItemSchema).describe('List of verified contracts'), + }) + .passthrough() + +// ============================================ +// Single Contract Schemas (GET /v2/contract/{chainId}/{address}) +// ============================================ + +/** + * Schema for signature entry in contract response + */ +export const SourcifySignatureItemSchema = z + .object({ + signature: z.string().describe('Human-readable signature'), + signatureHash32: z.string().optional().describe('32-byte keccak256 hash'), + signatureHash4: z.string().optional().describe('4-byte selector'), + }) + .passthrough() + +/** + * Schema for signatures in contract response + */ +export const SourcifySignaturesSchema = z + .object({ + function: z.array(SourcifySignatureItemSchema).optional().describe('Function signatures'), + event: z.array(SourcifySignatureItemSchema).optional().describe('Event signatures'), + error: z.array(SourcifySignatureItemSchema).optional().describe('Error signatures'), + }) + .passthrough() + +/** + * Schema for proxy implementation + */ +export const SourcifyProxyImplementationSchema = z + .object({ + address: z.string().describe('Implementation contract address'), + name: z.string().optional().describe('Implementation contract name'), + }) + .passthrough() + +/** + * Schema for proxy resolution in contract response + */ +export const SourcifyProxyResolutionSchema = z + .object({ + isProxy: z.boolean().optional().describe('Whether the contract is a proxy'), + proxyType: z.string().nullable().optional().describe('Type of proxy if applicable'), + implementations: z.array(SourcifyProxyImplementationSchema).optional().describe('Implementation contracts'), + }) + .passthrough() + +/** + * Schema for deployment info + */ +export const SourcifyDeploymentSchema = z + .object({ + transactionHash: z.string().optional().describe('Deployment transaction hash'), + blockNumber: z.union([z.string(), z.number()]).optional().describe('Deployment block number'), + transactionIndex: z.union([z.string(), z.number()]).optional().describe('Transaction index in block'), + deployer: z.string().optional().describe('Deployer address'), + }) + .passthrough() + +/** + * Schema for compilation info + */ +export const SourcifyCompilationSchema = z + .object({ + language: z.string().optional().describe('Source language (e.g., Solidity)'), + compiler: z.string().optional().describe('Compiler used'), + compilerVersion: z.string().optional().describe('Compiler version'), + compilerSettings: z.record(z.any()).optional().describe('Compiler settings'), + name: z.string().optional().describe('Contract name'), + fullyQualifiedName: z.string().optional().describe('Fully qualified contract name'), + }) + .passthrough() + +/** + * Schema for Sourcify contract ABI item + */ +export const SourcifyAbiItemSchema = z + .object({ + type: z.string().optional(), + name: z.string().optional(), + inputs: z.array(z.any()).optional(), + outputs: z.array(z.any()).optional(), + stateMutability: z.string().optional(), + anonymous: z.boolean().optional(), + }) + .passthrough() + +/** + * Schema for source file content + */ +export const SourcifySourceFileSchema = z + .object({ + content: z.string().optional().describe('Source file content'), + }) + .passthrough() + +/** + * Schema for verified contract response (full details) + * Very permissive to handle API variations - uses passthrough() on all objects + */ +export const SourcifyVerifiedContractSchema = z + .object({ + // Required fields + match: z.string().describe('Match type: exact_match or match'), + chainId: z.string().describe('Chain ID'), + address: z.string().describe('Contract address'), + // Optional fields + creationMatch: z.string().nullable().optional().describe('Creation bytecode match'), + runtimeMatch: z.string().nullable().optional().describe('Runtime bytecode match'), + verifiedAt: z.string().optional().describe('Verification timestamp'), + matchId: z.string().optional().describe('Unique match ID'), + deployment: SourcifyDeploymentSchema.optional().describe('Deployment information'), + sources: z.record(z.any()).optional().describe('Source files keyed by path'), + compilation: SourcifyCompilationSchema.optional().describe('Compilation information'), + abi: z.array(SourcifyAbiItemSchema).optional().describe('Contract ABI'), + signatures: SourcifySignaturesSchema.optional().describe('Function/event/error signatures'), + proxyResolution: SourcifyProxyResolutionSchema.optional().describe('Proxy resolution info'), + // Bytecode fields (large, usually omitted) + creationBytecode: z.any().optional().describe('Creation bytecode details'), + runtimeBytecode: z.any().optional().describe('Runtime bytecode details'), + // Other optional fields + userdoc: z.any().optional(), + devdoc: z.any().optional(), + storageLayout: z.any().optional(), + metadata: z.any().optional(), + sourceIds: z.any().optional(), + stdJsonInput: z.any().optional(), + stdJsonOutput: z.any().optional(), + }) + .passthrough() + +// ============================================ +// Signature Database Schemas (api.4byte.sourcify.dev) +// ============================================ + +/** + * Schema for signature type (function or event) + */ +export const SignatureTypeSchema = z.enum(['function', 'event']).describe('Type of signature') + +/** + * Schema for a single signature entry from lookup response + */ +export const SignatureLookupEntrySchema = z + .object({ + name: z.string().describe('Function/event signature (e.g., "transfer(address,uint256)")'), + filtered: z.boolean().optional().describe('Whether the signature is filtered'), + hasVerifiedContract: z.boolean().optional().describe('Whether there is a verified contract with this signature'), + }) + .passthrough() + +/** + * Schema for signature lookup response (by hash) + */ +export const SignatureLookupResponseSchema = z + .object({ + ok: z.boolean().describe('Whether the lookup was successful'), + result: z + .object({ + function: z + .record(z.array(SignatureLookupEntrySchema)) + .optional() + .describe('Function signatures keyed by hash'), + event: z.record(z.array(SignatureLookupEntrySchema)).optional().describe('Event signatures keyed by hash'), + }) + .passthrough(), + }) + .passthrough() + +/** + * Schema for a single signature entry from search response + */ +export const SignatureSearchEntrySchema = z + .object({ + id: z.number().optional().describe('Unique identifier'), + created_at: z.string().optional().describe('Creation timestamp'), + name: z.string().describe('Function/event name with parameters'), + hash: z.string().optional().describe('4-byte function selector or 32-byte event topic hash'), + type: z.string().optional().describe('Type: function or event'), + filtered: z.boolean().optional().describe('Whether the signature is filtered'), + }) + .passthrough() + +/** + * Schema for signature search response (by name) + */ +export const SignatureSearchResponseSchema = z + .object({ + ok: z.boolean().describe('Whether the search was successful'), + result: z.array(SignatureSearchEntrySchema).describe('Array of matching signatures'), + }) + .passthrough() + +// ============================================ +// Type exports +// ============================================ + +export type SourcifyContractListItem = z.infer +export type SourcifyContractsListResponse = z.infer +export type SourcifySignatureItem = z.infer +export type SourcifySignatures = z.infer +export type SourcifyProxyResolution = z.infer +export type SourcifyDeployment = z.infer +export type SourcifyCompilation = z.infer +export type SourcifyAbiItem = z.infer +export type SourcifySourceFile = z.infer +export type SourcifyVerifiedContract = z.infer +export type SignatureType = z.infer +export type SignatureLookupEntry = z.infer +export type SignatureLookupResponse = z.infer +export type SignatureSearchEntry = z.infer +export type SignatureSearchResponse = z.infer diff --git a/src/services/sourcify/utils.ts b/src/services/sourcify/utils.ts new file mode 100644 index 0000000..4b2feed --- /dev/null +++ b/src/services/sourcify/utils.ts @@ -0,0 +1,249 @@ +import { getThorNetworkType, ThorNetworkType } from '@/services/thor' +import { logger } from '@/utils/logger' +import { + SignatureLookupResponseSchema, + SignatureSearchResponseSchema, + SourcifyContractsListResponseSchema, + SourcifyVerifiedContractSchema, + type SignatureLookupResponse, + type SignatureSearchEntry, + type SignatureSearchResponse, + type SignatureType, + type SourcifyContractsListResponse, + type SourcifyVerifiedContract, +} from './schemas' + +const SOURCIFY_API_URL = 'https://sourcify.dev/server' +const SOURCIFY_SIGNATURE_DB_URL = 'https://api.4byte.sourcify.dev/signature-database/v1' + +/** + * VeChain chain IDs for Sourcify + */ +export const VECHAIN_CHAIN_IDS = { + [ThorNetworkType.MAINNET]: '100009', + [ThorNetworkType.TESTNET]: '100010', +} as const + +/** + * Get the Sourcify chain ID for the current VeChain network + * Returns null if the network is not supported (e.g., Solo) + */ +export function getSourcifyChainId(): string | null { + const network = getThorNetworkType() + return VECHAIN_CHAIN_IDS[network as keyof typeof VECHAIN_CHAIN_IDS] ?? null +} + +/** + * Fetch a verified contract from Sourcify by chain ID and address + * @param chainId - The chain ID + * @param address - The contract address (case-insensitive) + * @param fields - Fields to include (default: 'all' for full details including ABI and name) + * @returns The verified contract data or null if not found + */ +export async function fetchSourcifyContract( + chainId: string, + address: string, + fields: string = 'all', +): Promise { + const url = `${SOURCIFY_API_URL}/v2/contract/${chainId}/${address}?fields=${fields}` + logger.debug(`Fetching Sourcify contract: ${url}`) + + try { + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + + logger.debug(`Sourcify response status: ${response.status}`) + + if (response.status === 404) { + logger.debug(`Contract not found on Sourcify: chainId=${chainId}, address=${address}`) + return null + } + + if (!response.ok) { + const errorText = await response.text() + logger.warn(`Failed to fetch Sourcify contract: ${response.status} ${response.statusText} - ${errorText}`) + return null + } + + const data = await response.json() + logger.debug(`Sourcify response keys: ${Object.keys(data).join(', ')}`) + + // Use safeParse to get detailed error info + const parseResult = SourcifyVerifiedContractSchema.safeParse(data) + if (!parseResult.success) { + logger.warn(`Schema validation failed for Sourcify contract: ${JSON.stringify(parseResult.error.issues)}`) + // Return raw data cast to type if schema fails - API may have changed + return data as SourcifyVerifiedContract + } + + logger.debug(`Successfully fetched Sourcify contract for address: ${address}`) + return parseResult.data + } catch (error) { + logger.warn(`Error fetching Sourcify contract from ${url}: ${String(error)}`) + return null + } +} + +/** + * Fetch a list of verified contracts from Sourcify by chain ID + * Uses cursor-based pagination with afterMatchId + * @param chainId - The chain ID + * @param limit - Results per page (default: 200, max: 200) + * @param afterMatchId - Cursor for pagination (last matchId from previous response) + * @param sort - Sort order: 'desc' (newest first, default) or 'asc' (oldest first) + * @returns The list of verified contracts + */ +export async function fetchSourcifyContracts( + chainId: string, + limit: number = 200, + afterMatchId?: string, + sort: 'asc' | 'desc' = 'desc', +): Promise { + try { + logger.debug(`Fetching Sourcify contracts for chainId: ${chainId}, limit: ${limit}, afterMatchId: ${afterMatchId}`) + + const params = new URLSearchParams({ + limit: String(limit), + sort, + }) + if (afterMatchId) { + params.append('afterMatchId', afterMatchId) + } + + const url = `${SOURCIFY_API_URL}/v2/contracts/${chainId}?${params.toString()}` + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + logger.warn(`Failed to fetch Sourcify contracts: ${response.status} ${response.statusText}`) + return null + } + + const data = await response.json() + logger.debug(`Successfully fetched Sourcify contracts list for chainId: ${chainId}`) + return SourcifyContractsListResponseSchema.parse(data) + } catch (error) { + logger.warn(`Error fetching Sourcify contracts: ${String(error)}`) + return null + } +} + +// ============================================ +// Signature Database Functions +// ============================================ + +/** + * Lookup signatures by their hash (4-byte function selector or 32-byte event topic) + * @param hashes - Array of signature hashes to look up + * @param type - Type of signatures to look up: 'function', 'event', or both if not specified + * @returns The lookup response with matching signatures + */ +export async function lookupSignatures( + hashes: string[], + type?: SignatureType, +): Promise { + try { + logger.debug(`Looking up signatures: ${hashes.join(', ')}${type ? ` (type: ${type})` : ''}`) + + const params = new URLSearchParams() + + // Add hashes based on type + if (type === 'function' || !type) { + for (const hash of hashes) { + params.append('function', hash) + } + } + if (type === 'event' || !type) { + for (const hash of hashes) { + params.append('event', hash) + } + } + + // Add filter=true for cleaner results + params.append('filter', 'true') + + const url = `${SOURCIFY_SIGNATURE_DB_URL}/lookup?${params.toString()}` + logger.debug(`Looking up signatures at: ${url}`) + + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.warn(`Failed to lookup signatures: ${response.status} ${response.statusText} - ${errorText}`) + return null + } + + const data = await response.json() + logger.debug(`Lookup response: ${JSON.stringify(data).slice(0, 200)}`) + + const parsed = SignatureLookupResponseSchema.safeParse(data) + if (!parsed.success) { + logger.warn(`Schema validation failed for signature lookup: ${JSON.stringify(parsed.error.issues)}`) + // Return raw data if schema fails + return data as SignatureLookupResponse + } + + logger.debug(`Successfully looked up ${hashes.length} signatures`) + return parsed.data + } catch (error) { + logger.warn(`Error looking up signatures: ${String(error)}`) + return null + } +} + +/** + * Search for signatures by name (partial match) + * @param query - The search query (function/event name) + * @param type - Type of signatures to search: 'function', 'event', or both if not specified + * @returns The search response with ok status and result array + */ +export async function searchSignatures( + query: string, + type?: SignatureType, +): Promise { + try { + logger.debug(`Searching signatures for: "${query}"${type ? ` (type: ${type})` : ''}`) + + const params = new URLSearchParams({ q: query }) + if (type) { + params.append('type', type) + } + + const url = `${SOURCIFY_SIGNATURE_DB_URL}/search?${params.toString()}` + logger.debug(`Searching signatures at: ${url}`) + + const response = await fetch(url, { + method: 'GET', + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + const errorText = await response.text() + logger.warn(`Failed to search signatures: ${response.status} ${response.statusText} - ${errorText}`) + return null + } + + const data = await response.json() + logger.debug(`Search response: ${JSON.stringify(data).slice(0, 200)}`) + + const parsed = SignatureSearchResponseSchema.safeParse(data) + if (!parsed.success) { + logger.warn(`Schema validation failed for signature search: ${JSON.stringify(parsed.error.issues)}`) + // Return raw data if schema fails + return data as SignatureSearchResponse + } + + logger.debug(`Found ${parsed.data.result?.length ?? 0} signatures matching "${query}"`) + return parsed.data + } catch (error) { + logger.warn(`Error searching signatures: ${String(error)}`) + return null + } +} From dd786aa1afcd546d62dc3d350dd006cc49fc5eaf Mon Sep 17 00:00:00 2001 From: Roisin Dowling Date: Wed, 10 Dec 2025 18:00:27 +0000 Subject: [PATCH 2/7] add souricify tools --- src/tools/get-sourcify-contract.ts | 231 +++++++++++++++++++++ src/tools/get-sourcify-contracts.ts | 125 +++++++++++ src/tools/get-sourcify-signature-lookup.ts | 109 ++++++++++ src/tools/get-sourcify-signature-search.ts | 89 ++++++++ 4 files changed, 554 insertions(+) create mode 100644 src/tools/get-sourcify-contract.ts create mode 100644 src/tools/get-sourcify-contracts.ts create mode 100644 src/tools/get-sourcify-signature-lookup.ts create mode 100644 src/tools/get-sourcify-signature-search.ts diff --git a/src/tools/get-sourcify-contract.ts b/src/tools/get-sourcify-contract.ts new file mode 100644 index 0000000..83c1c8e --- /dev/null +++ b/src/tools/get-sourcify-contract.ts @@ -0,0 +1,231 @@ +import { z } from 'zod' +import { + fetchSourcifyContract, + getSourcifyChainId, + SourcifyAbiItemSchema, + SourcifyProxyResolutionSchema, + SourcifySignaturesSchema, +} from '@/services/sourcify' +import { getThorNetworkType, ThorAddressSchema } from '@/services/thor' +import type { MCPTool } from '@/types' +import { logger } from '@/utils/logger' + +// Available fields for the Sourcify API (note: 'name' comes from compilation.name, not a separate field) +const AVAILABLE_FIELDS = [ + 'abi', + 'sources', + 'metadata', + 'storageLayout', + 'userdoc', + 'devdoc', + 'stdJsonInput', + 'stdJsonOutput', + 'compilation', // Contains name, compilerVersion + 'deployment', // Contains deployer, transactionHash + 'proxyResolution', // IMPORTANT: needed to detect proxies + 'signatures', + 'creationBytecode', + 'runtimeBytecode', +] as const + +const InputSchema = z + .object({ + address: ThorAddressSchema.describe('Contract address to look up (0x prefixed, 40 hex characters)'), + resolveProxy: z + .boolean() + .optional() + .default(true) + .describe( + 'If true and contract is a proxy, automatically fetch the implementation ABI (default: true). IMPORTANT: Most VeChain contracts are proxies!', + ), + fields: z + .array(z.enum(AVAILABLE_FIELDS)) + .optional() + .describe( + 'Specific fields to fetch. If not provided, fetches ALL fields (recommended). Note: contract name comes from "compilation" field. Options: abi, sources, metadata, storageLayout, userdoc, devdoc, stdJsonInput, stdJsonOutput, compilation, deployment, proxyResolution, signatures, creationBytecode, runtimeBytecode', + ), + }) + .describe('Parameters for fetching a verified contract from Sourcify') + +const OutputContractSchema = z.object({ + match: z.string().describe('Verification match type: exact_match or match'), + chainId: z.string().describe('Chain ID'), + address: z.string().describe('Contract address queried'), + verifiedAt: z.string().optional().describe('Verification timestamp'), + // These are the EFFECTIVE name/abi - for proxies, these will be from the implementation + name: z.string().optional().describe('Contract name to use - for proxies this is the implementation name (e.g. "VeBetterDAO"), not the proxy wrapper'), + abi: z.array(SourcifyAbiItemSchema).optional().describe('Contract ABI to use - for proxies this is the implementation ABI with all functions'), + compilerVersion: z.string().optional().describe('Compiler version'), + signatures: SourcifySignaturesSchema.optional().describe('Function/event/error signatures'), + deployer: z.string().nullable().optional().describe('Deployer address'), + transactionHash: z.string().nullable().optional().describe('Deployment transaction hash'), + sourceCount: z.number().optional().describe('Number of source files'), + // Proxy info + isProxy: z.boolean().optional().describe('Whether this contract is a proxy'), + implementationAddress: z.string().optional().describe('Implementation contract address (only present for proxies)'), + proxyName: z.string().optional().describe('Original proxy contract name (e.g. "TransparentUpgradeableProxy") - only for reference'), +}) + +const OutputSchema = z + .object({ + ok: z.boolean().describe('Whether the fetch was successful'), + network: z.string().describe('The VeChain network used'), + chainId: z.string().describe('The Sourcify chain ID used'), + address: z.string().describe('The contract address queried'), + contract: OutputContractSchema.optional().describe('The verified contract data'), + error: z.string().optional().describe('Error message if fetch failed'), + }) + .describe('Sourcify verified contract result') + +export type GetSourcifyContractResponse = { + content: Array<{ type: 'text'; text: string }> + structuredContent: z.infer +} + +export const getSourcifyContract: MCPTool = { + name: 'getSourcifyContract', + title: 'Sourcify: Get Verified Contract', + description: + 'Fetch a verified smart contract from Sourcify for VeChain. Returns contract name, ABI, and metadata. PROXY HANDLING: Most VeChain contracts are proxies. When isProxy=true, the returned name and abi fields are ALREADY from the implementation contract (the actual contract you want). The proxyName field shows the original proxy wrapper name for reference. Just use the name and abi fields directly - they always contain what you need. Only works on VeChain mainnet (chainId 100009) and testnet (chainId 100010).', + inputSchema: InputSchema.shape, + outputSchema: OutputSchema.shape, + annotations: { + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + destructiveHint: false, + }, + handler: async (params: z.infer): Promise => { + const network = getThorNetworkType() + const chainId = getSourcifyChainId() + + if (!chainId) { + const errorResult = { + ok: false, + network, + chainId: 'unsupported', + address: params.address, + error: `Sourcify is not supported for the ${network} network. Only mainnet and testnet are supported.`, + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + + try { + const parsed = InputSchema.parse(params) + const address = parsed.address + const resolveProxy = parsed.resolveProxy ?? true + + // Build fields parameter - always use 'all' by default for complete proxy detection + // When custom fields specified, ensure we include essential fields for proxy resolution + let fieldsParam = 'all' + if (parsed.fields && parsed.fields.length > 0) { + const fields = new Set(parsed.fields) + // Always include these for proxy detection and name extraction + fields.add('compilation') // For contract name + fields.add('abi') // For contract ABI + if (resolveProxy) { + fields.add('proxyResolution') // For detecting proxies + } + fieldsParam = Array.from(fields).join(',') + } + + logger.debug(`Fetching Sourcify contract with fields: ${fieldsParam}`) + + const contract = await fetchSourcifyContract(chainId, address, fieldsParam) + + if (contract === null) { + const notFoundResult = { + ok: false, + network, + chainId, + address, + error: `Contract not found or not verified on Sourcify for chainId ${chainId}`, + } + return { + content: [{ type: 'text', text: JSON.stringify(notFoundResult) }], + structuredContent: notFoundResult, + } + } + + // Check if this is a proxy and we should resolve it + const isProxy = contract.proxyResolution?.isProxy === true + let implementationAddress: string | undefined + let implementationAbi: z.infer[] | undefined + let implementationName: string | undefined + + if (isProxy && resolveProxy && contract.proxyResolution?.implementations?.length) { + // Get the first (usually only) implementation address + const impl = contract.proxyResolution.implementations[0] + implementationAddress = impl.address + + if (implementationAddress) { + logger.debug(`Contract is a proxy, fetching implementation at: ${implementationAddress}`) + + // Fetch the implementation contract to get its ABI + const implContract = await fetchSourcifyContract(chainId, implementationAddress, 'abi,compilation') + + if (implContract) { + implementationAbi = implContract.abi + implementationName = implContract.compilation?.name + logger.debug(`Fetched implementation ABI: ${implementationName ?? 'unnamed'}`) + } else { + logger.debug(`Implementation contract not verified on Sourcify: ${implementationAddress}`) + } + } + } + + // Build clean response - for proxies, use implementation name/abi as the main fields + const proxyName = isProxy ? contract.compilation?.name : undefined + const effectiveName = isProxy && implementationName ? implementationName : contract.compilation?.name + const effectiveAbi = isProxy && implementationAbi ? implementationAbi : contract.abi + + const contractData = { + match: contract.match, + chainId: contract.chainId, + address: contract.address, + verifiedAt: contract.verifiedAt, + // For proxies: name/abi are from implementation (what you actually want to use) + name: effectiveName, + abi: effectiveAbi, + compilerVersion: contract.compilation?.compilerVersion, + signatures: contract.signatures, + deployer: contract.deployment?.deployer, + transactionHash: contract.deployment?.transactionHash, + sourceCount: contract.sources ? Object.keys(contract.sources).length : undefined, + // Proxy info + isProxy, + implementationAddress, + proxyName, // Original proxy name for reference (e.g. "TransparentUpgradeableProxy") + } + + const successResult = { + ok: true, + network, + chainId, + address, + contract: contractData, + } + + return { + content: [{ type: 'text', text: JSON.stringify(successResult) }], + structuredContent: successResult, + } + } catch (error) { + logger.warn(`Error in getSourcifyContract: ${String(error)}`) + const errorResult = { + ok: false, + network, + chainId, + address: params.address, + error: `Error fetching Sourcify contract: ${String(error)}`, + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + }, +} diff --git a/src/tools/get-sourcify-contracts.ts b/src/tools/get-sourcify-contracts.ts new file mode 100644 index 0000000..133e6de --- /dev/null +++ b/src/tools/get-sourcify-contracts.ts @@ -0,0 +1,125 @@ +import { z } from 'zod' +import { fetchSourcifyContracts, getSourcifyChainId, SourcifyContractListItemSchema } from '@/services/sourcify' +import { getThorNetworkType } from '@/services/thor' +import type { MCPTool } from '@/types' +import { logger } from '@/utils/logger' + +const InputSchema = z + .object({ + limit: z + .number() + .int() + .min(1) + .max(200) + .default(50) + .describe('Number of results to return (1-200, default: 50)'), + afterMatchId: z + .string() + .optional() + .describe('Cursor for pagination - use the last matchId from previous response to get next page'), + sort: z + .enum(['asc', 'desc']) + .default('desc') + .describe('Sort order: "desc" for newest first (default), "asc" for oldest first'), + }) + .describe('Parameters for listing verified contracts from Sourcify') + +const OutputSchema = z + .object({ + ok: z.boolean().describe('Whether the fetch was successful'), + network: z.string().describe('The VeChain network used'), + chainId: z.string().describe('The Sourcify chain ID used'), + contracts: z.array(SourcifyContractListItemSchema).optional().describe('List of verified contracts'), + totalReturned: z.number().optional().describe('Number of contracts returned'), + lastMatchId: z.string().optional().describe('Last matchId for pagination - use as afterMatchId for next page'), + hasMore: z.boolean().optional().describe('Whether there are more results available'), + error: z.string().optional().describe('Error message if fetch failed'), + }) + .describe('Sourcify verified contracts list result') + +export type GetSourcifyContractsResponse = { + content: Array<{ type: 'text'; text: string }> + structuredContent: z.infer +} + +export const getSourcifyContracts: MCPTool = { + name: 'getSourcifyContracts', + title: 'Sourcify: List Verified Contracts', + description: + 'Fetch a list of verified smart contracts from Sourcify for VeChain. Returns contract addresses, match types, and verification timestamps. Use afterMatchId for pagination. Only works on VeChain mainnet (chainId 100009) and testnet (chainId 100010).', + inputSchema: InputSchema.shape, + outputSchema: OutputSchema.shape, + annotations: { + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + destructiveHint: false, + }, + handler: async (params: z.infer): Promise => { + const network = getThorNetworkType() + const chainId = getSourcifyChainId() + + if (!chainId) { + const errorResult = { + ok: false, + network, + chainId: 'unsupported', + error: `Sourcify is not supported for the ${network} network. Only mainnet and testnet are supported.`, + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + + try { + const parsed = InputSchema.parse(params) + + const result = await fetchSourcifyContracts(chainId, parsed.limit, parsed.afterMatchId, parsed.sort) + + if (result === null) { + const errorResult = { + ok: false, + network, + chainId, + error: `Failed to fetch verified contracts from Sourcify for chainId ${chainId}`, + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + + // Get last matchId for pagination + const lastContract = result.results[result.results.length - 1] + const lastMatchId = lastContract?.matchId + + const successResult = { + ok: true, + network, + chainId, + contracts: result.results, + totalReturned: result.results.length, + lastMatchId, + hasMore: result.results.length === parsed.limit, + } + + return { + content: [{ type: 'text', text: JSON.stringify(successResult) }], + structuredContent: successResult, + } + } catch (error) { + logger.warn(`Error in getSourcifyContracts: ${String(error)}`) + const errorResult = { + ok: false, + network, + chainId, + error: `Error fetching Sourcify contracts: ${String(error)}`, + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + }, +} diff --git a/src/tools/get-sourcify-signature-lookup.ts b/src/tools/get-sourcify-signature-lookup.ts new file mode 100644 index 0000000..270fdcd --- /dev/null +++ b/src/tools/get-sourcify-signature-lookup.ts @@ -0,0 +1,109 @@ +import { z } from 'zod' +import { lookupSignatures, SignatureLookupEntrySchema, SignatureTypeSchema } from '@/services/sourcify' +import type { MCPTool } from '@/types' +import { logger } from '@/utils/logger' + +const InputSchema = z + .object({ + hashes: z + .array(z.string().regex(/^0x[a-fA-F0-9]+$/, 'Must be a 0x-prefixed hex string')) + .min(1) + .max(50) + .describe('Array of signature hashes to look up (4-byte function selectors or 32-byte event topics)'), + type: SignatureTypeSchema.optional().describe( + 'Type of signatures to look up: "function" or "event". If not specified, looks up both.', + ), + }) + .describe('Parameters for looking up signatures by hash') + +const OutputSchema = z + .object({ + ok: z.boolean().describe('Whether the lookup was successful'), + functions: z + .record(z.array(SignatureLookupEntrySchema)) + .optional() + .describe('Function signatures found, keyed by hash'), + events: z + .record(z.array(SignatureLookupEntrySchema)) + .optional() + .describe('Event signatures found, keyed by hash'), + totalFound: z.number().describe('Total number of signatures found'), + error: z.string().optional().describe('Error message if lookup failed'), + }) + .describe('Sourcify signature lookup result') + +export type GetSourcifySignatureLookupResponse = { + content: Array<{ type: 'text'; text: string }> + structuredContent: z.infer +} + +export const getSourcifySignatureLookup: MCPTool = { + name: 'getSourcifySignatureLookup', + title: 'Sourcify: Lookup Signatures by Hash', + description: + 'Look up function or event signatures by their hash from the Sourcify 4byte signature database. Provide 4-byte function selectors (e.g., 0xa9059cbb for transfer) or 32-byte event topic hashes to get the corresponding function/event names and parameters. Useful for decoding unknown function calls or events.', + inputSchema: InputSchema.shape, + outputSchema: OutputSchema.shape, + annotations: { + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + destructiveHint: false, + }, + handler: async (params: z.infer): Promise => { + try { + const parsed = InputSchema.parse(params) + const normalizedHashes = parsed.hashes.map((h) => h.toLowerCase()) + + const result = await lookupSignatures(normalizedHashes, parsed.type) + + if (!result || !result.ok) { + const errorResult = { + ok: false, + totalFound: 0, + error: 'Failed to lookup signatures from Sourcify 4byte database', + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + + // Count total signatures found + let totalFound = 0 + if (result.result.function) { + for (const signatures of Object.values(result.result.function)) { + totalFound += signatures.length + } + } + if (result.result.event) { + for (const signatures of Object.values(result.result.event)) { + totalFound += signatures.length + } + } + + const successResult = { + ok: true, + functions: result.result.function, + events: result.result.event, + totalFound, + } + + return { + content: [{ type: 'text', text: JSON.stringify(successResult) }], + structuredContent: successResult, + } + } catch (error) { + logger.warn(`Error in getSourcifySignatureLookup: ${String(error)}`) + const errorResult = { + ok: false, + totalFound: 0, + error: `Error looking up signatures: ${String(error)}`, + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + }, +} diff --git a/src/tools/get-sourcify-signature-search.ts b/src/tools/get-sourcify-signature-search.ts new file mode 100644 index 0000000..0aa9bbe --- /dev/null +++ b/src/tools/get-sourcify-signature-search.ts @@ -0,0 +1,89 @@ +import { z } from 'zod' +import { searchSignatures, SignatureSearchEntrySchema, SignatureTypeSchema } from '@/services/sourcify' +import type { MCPTool } from '@/types' +import { logger } from '@/utils/logger' + +const InputSchema = z + .object({ + query: z.string().min(1).describe('Search query for function/event name (e.g., "transfer", "approve", "Transfer")'), + type: SignatureTypeSchema.optional().describe( + 'Type of signatures to search: "function" or "event". If not specified, searches both.', + ), + }) + .describe('Parameters for searching signatures by name') + +const OutputSchema = z + .object({ + ok: z.boolean().describe('Whether the search was successful'), + query: z.string().describe('The search query used'), + signatures: z.array(SignatureSearchEntrySchema).optional().describe('Matching signatures'), + totalFound: z.number().describe('Number of signatures found'), + error: z.string().optional().describe('Error message if search failed'), + }) + .describe('Sourcify signature search result') + +export type GetSourcifySignatureSearchResponse = { + content: Array<{ type: 'text'; text: string }> + structuredContent: z.infer +} + +export const getSourcifySignatureSearch: MCPTool = { + name: 'getSourcifySignatureSearch', + title: 'Sourcify: Search Signatures by Name', + description: + 'Search for function or event signatures by name from the Sourcify 4byte signature database. Provide a function or event name (e.g., "transfer", "approve", "Transfer") to find matching signatures with their hashes. Useful for finding the selector/topic hash when you know the function/event name.', + inputSchema: InputSchema.shape, + outputSchema: OutputSchema.shape, + annotations: { + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + destructiveHint: false, + }, + handler: async (params: z.infer): Promise => { + try { + const parsed = InputSchema.parse(params) + + const result = await searchSignatures(parsed.query, parsed.type) + + if (!result || !result.ok) { + const errorResult = { + ok: false, + query: parsed.query, + totalFound: 0, + error: 'Failed to search signatures from Sourcify 4byte database', + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + + const signatures = result.result ?? [] + + const successResult = { + ok: true, + query: parsed.query, + signatures: signatures.length > 0 ? signatures : undefined, + totalFound: signatures.length, + } + + return { + content: [{ type: 'text', text: JSON.stringify(successResult) }], + structuredContent: successResult, + } + } catch (error) { + logger.warn(`Error in getSourcifySignatureSearch: ${String(error)}`) + const errorResult = { + ok: false, + query: params.query, + totalFound: 0, + error: `Error searching signatures: ${String(error)}`, + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + }, +} From 883683284f0593b06bf7d4e953b36a4ba3b8adc1 Mon Sep 17 00:00:00 2001 From: Roisin Dowling Date: Wed, 10 Dec 2025 18:00:37 +0000 Subject: [PATCH 3/7] add souricify tests --- .../get-sourcify-contract.integration.test.ts | 109 +++++++++++++++++ ...get-sourcify-contracts.integration.test.ts | 110 ++++++++++++++++++ ...rcify-signature-lookup.integration.test.ts | 93 +++++++++++++++ ...rcify-signature-search.integration.test.ts | 96 +++++++++++++++ 4 files changed, 408 insertions(+) create mode 100644 tests/sourcify/get-sourcify-contract.integration.test.ts create mode 100644 tests/sourcify/get-sourcify-contracts.integration.test.ts create mode 100644 tests/sourcify/get-sourcify-signature-lookup.integration.test.ts create mode 100644 tests/sourcify/get-sourcify-signature-search.integration.test.ts diff --git a/tests/sourcify/get-sourcify-contract.integration.test.ts b/tests/sourcify/get-sourcify-contract.integration.test.ts new file mode 100644 index 0000000..07b01d4 --- /dev/null +++ b/tests/sourcify/get-sourcify-contract.integration.test.ts @@ -0,0 +1,109 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +describe('Sourcify Get Contract', () => { + let client: Client + + beforeAll(async () => { + client = new Client({ + name: 'sourcify-get-contract-client', + version: '1.0.0', + }) + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp')) + await client.connect(transport) + }) + + afterAll(async () => { + await client.close() + }) + + test('should get a verified contract by address', async () => { + // Known verified contract on VeChain mainnet + const response = await client.callTool({ + name: 'getSourcifyContract', + arguments: { + address: '0x1c65C25fABe2fc1bCb82f253fA0C916a322f777C', + }, + }) + + const content = response.content as Array<{ type: string; text: string }> + expect(content.length).toBeGreaterThan(0) + + if (content[0].text.startsWith('MCP error')) { + throw new Error(content[0].text) + } + + const result = JSON.parse(content[0].text) + + expect(result.ok).toBe(true) + expect(result.network).toBe('mainnet') + expect(result.chainId).toBe('100009') + expect(result.contract).toBeDefined() + expect(result.contract.abi).toBeDefined() + expect(Array.isArray(result.contract.abi)).toBe(true) + }) + + test('should get only specific fields when requested', async () => { + const response = await client.callTool({ + name: 'getSourcifyContract', + arguments: { + address: '0x1c65C25fABe2fc1bCb82f253fA0C916a322f777C', + fields: ['abi', 'compilation'], + resolveProxy: false, + }, + }) + + const content = response.content as Array<{ type: string; text: string }> + expect(content.length).toBeGreaterThan(0) + + if (content[0].text.startsWith('MCP error')) { + throw new Error(content[0].text) + } + + const result = JSON.parse(content[0].text) + + expect(result.ok).toBe(true) + expect(result.contract).toBeDefined() + expect(result.contract.abi).toBeDefined() + }) + + test('should resolve proxy contract and get implementation ABI', async () => { + const response = await client.callTool({ + name: 'getSourcifyContract', + arguments: { + address: '0x1c65C25fABe2fc1bCb82f253fA0C916a322f777C', + resolveProxy: true, + }, + }) + + const content = response.content as Array<{ type: string; text: string }> + expect(content.length).toBeGreaterThan(0) + + if (content[0].text.startsWith('MCP error')) { + throw new Error(content[0].text) + } + + const result = JSON.parse(content[0].text) + + expect(result.ok).toBe(true) + expect(result.contract).toBeDefined() + expect(typeof result.contract.isProxy).toBe('boolean') + }) + + test('should return error for non-verified contract', async () => { + const response = await client.callTool({ + name: 'getSourcifyContract', + arguments: { + address: '0x0000000000000000000000000000000000000001', + }, + }) + + const content = response.content as Array<{ type: string; text: string }> + expect(content.length).toBeGreaterThan(0) + + const result = JSON.parse(content[0].text) + + expect(result.ok).toBe(false) + expect(result.error).toBeDefined() + }) +}) diff --git a/tests/sourcify/get-sourcify-contracts.integration.test.ts b/tests/sourcify/get-sourcify-contracts.integration.test.ts new file mode 100644 index 0000000..bfad597 --- /dev/null +++ b/tests/sourcify/get-sourcify-contracts.integration.test.ts @@ -0,0 +1,110 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +describe('Sourcify Get Contracts List', () => { + let client: Client + + beforeAll(async () => { + client = new Client({ + name: 'sourcify-get-contracts-client', + version: '1.0.0', + }) + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp')) + await client.connect(transport) + }) + + afterAll(async () => { + await client.close() + }) + + test('should list verified contracts with default parameters', async () => { + const response = await client.callTool({ + name: 'getSourcifyContracts', + arguments: {}, + }) + + const content = response.content as Array<{ type: string; text: string }> + expect(content.length).toBeGreaterThan(0) + + if (content[0].text.startsWith('MCP error')) { + throw new Error(content[0].text) + } + + const result = JSON.parse(content[0].text) + + expect(result.ok).toBe(true) + expect(result.network).toBe('mainnet') + expect(result.chainId).toBe('100009') + expect(result.contracts).toBeDefined() + expect(Array.isArray(result.contracts)).toBe(true) + expect(result.totalReturned).toBeGreaterThan(0) + }) + + test('should list verified contracts with custom limit', async () => { + const response = await client.callTool({ + name: 'getSourcifyContracts', + arguments: { + limit: 5, + }, + }) + + const content = response.content as Array<{ type: string; text: string }> + expect(content.length).toBeGreaterThan(0) + + if (content[0].text.startsWith('MCP error')) { + throw new Error(content[0].text) + } + + const result = JSON.parse(content[0].text) + + expect(result.ok).toBe(true) + expect(result.contracts).toBeDefined() + expect(result.contracts?.length).toBeLessThanOrEqual(5) + }) + + test('should support pagination with afterMatchId', async () => { + // First request + const firstResponse = await client.callTool({ + name: 'getSourcifyContracts', + arguments: { + limit: 5, + }, + }) + + const firstContent = firstResponse.content as Array<{ type: string; text: string }> + if (firstContent[0].text.startsWith('MCP error')) { + throw new Error(firstContent[0].text) + } + + const firstResult = JSON.parse(firstContent[0].text) + + expect(firstResult.ok).toBe(true) + expect(firstResult.lastMatchId).toBeDefined() + + // Second request with pagination + const secondResponse = await client.callTool({ + name: 'getSourcifyContracts', + arguments: { + limit: 5, + afterMatchId: firstResult.lastMatchId, + }, + }) + + const secondContent = secondResponse.content as Array<{ type: string; text: string }> + if (secondContent[0].text.startsWith('MCP error')) { + throw new Error(secondContent[0].text) + } + + const secondResult = JSON.parse(secondContent[0].text) + + expect(secondResult.ok).toBe(true) + expect(secondResult.contracts).toBeDefined() + + // Ensure we got different contracts + const firstAddresses = firstResult.contracts?.map((c: { address: string }) => c.address) ?? [] + const secondAddresses = secondResult.contracts?.map((c: { address: string }) => c.address) ?? [] + + const overlap = secondAddresses.filter((addr: string) => firstAddresses.includes(addr)) + expect(overlap.length).toBeLessThan(secondAddresses.length) + }) +}) diff --git a/tests/sourcify/get-sourcify-signature-lookup.integration.test.ts b/tests/sourcify/get-sourcify-signature-lookup.integration.test.ts new file mode 100644 index 0000000..d91d3cb --- /dev/null +++ b/tests/sourcify/get-sourcify-signature-lookup.integration.test.ts @@ -0,0 +1,93 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +describe('Sourcify Signature Lookup', () => { + let client: Client + + beforeAll(async () => { + client = new Client({ + name: 'sourcify-signature-lookup-client', + version: '1.0.0', + }) + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp')) + await client.connect(transport) + }) + + afterAll(async () => { + await client.close() + }) + + test('should lookup a known function selector', async () => { + // 0xa9059cbb is the selector for transfer(address,uint256) + const response = await client.callTool({ + name: 'getSourcifySignatureLookup', + arguments: { + hashes: ['0xa9059cbb'], + type: 'function', + }, + }) + + expect(response.content).toBeDefined() + + const content = response.content as Array<{ type: string; text: string }> + expect(content.length).toBeGreaterThan(0) + + const parsed = JSON.parse(content[0].text) + + // Check if API call succeeded + if (parsed.ok) { + expect(parsed.totalFound).toBeGreaterThan(0) + expect(parsed.functions).toBeDefined() + expect(parsed.functions['0xa9059cbb']).toBeDefined() + + // Should find transfer(address,uint256) + const transferSigs = parsed.functions['0xa9059cbb'] + expect(transferSigs.some((sig: { name: string }) => sig.name.includes('transfer'))).toBe(true) + } else { + // API might be unavailable - log the error but don't fail + console.warn('Signature lookup API returned error:', parsed.error) + } + }) + + test('should lookup multiple hashes at once', async () => { + const response = await client.callTool({ + name: 'getSourcifySignatureLookup', + arguments: { + hashes: [ + '0xa9059cbb', // transfer(address,uint256) + '0x095ea7b3', // approve(address,uint256) + ], + type: 'function', + }, + }) + + expect(response.content).toBeDefined() + + const content = response.content as Array<{ type: string; text: string }> + const parsed = JSON.parse(content[0].text) + + if (parsed.ok) { + expect(parsed.totalFound).toBeGreaterThanOrEqual(2) + } else { + console.warn('Signature lookup API returned error:', parsed.error) + } + }) + + test('should handle unknown hash gracefully', async () => { + const response = await client.callTool({ + name: 'getSourcifySignatureLookup', + arguments: { + hashes: ['0xdeadbeef'], + type: 'function', + }, + }) + + expect(response.content).toBeDefined() + + const content = response.content as Array<{ type: string; text: string }> + const parsed = JSON.parse(content[0].text) + + // Should not throw, regardless of whether it found results + expect(parsed).toBeDefined() + }) +}) diff --git a/tests/sourcify/get-sourcify-signature-search.integration.test.ts b/tests/sourcify/get-sourcify-signature-search.integration.test.ts new file mode 100644 index 0000000..0f9d308 --- /dev/null +++ b/tests/sourcify/get-sourcify-signature-search.integration.test.ts @@ -0,0 +1,96 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +describe('Sourcify Signature Search', () => { + let client: Client + + beforeAll(async () => { + client = new Client({ + name: 'sourcify-signature-search-client', + version: '1.0.0', + }) + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp')) + await client.connect(transport) + }) + + afterAll(async () => { + await client.close() + }) + + test('should search for function signatures by name', async () => { + const response = await client.callTool({ + name: 'getSourcifySignatureSearch', + arguments: { + query: 'transfer', + type: 'function', + }, + }) + + expect(response.content).toBeDefined() + + const content = response.content as Array<{ type: string; text: string }> + expect(content.length).toBeGreaterThan(0) + + const parsed = JSON.parse(content[0].text) + expect(parsed.query).toBe('transfer') + + // Check if API call succeeded + if (parsed.ok) { + // If we got results, validate them + if (parsed.totalFound > 0) { + expect(parsed.signatures).toBeDefined() + expect(Array.isArray(parsed.signatures)).toBe(true) + + // Should find transfer-related functions + const hasTransfer = parsed.signatures.some((sig: { name: string }) => + sig.name.toLowerCase().includes('transfer'), + ) + expect(hasTransfer).toBe(true) + } + } else { + // API might be unavailable - log the error but don't fail + console.warn('Signature search API returned error:', parsed.error) + } + }) + + test('should search for event signatures by name', async () => { + const response = await client.callTool({ + name: 'getSourcifySignatureSearch', + arguments: { + query: 'Transfer', + type: 'event', + }, + }) + + expect(response.content).toBeDefined() + + const content = response.content as Array<{ type: string; text: string }> + const parsed = JSON.parse(content[0].text) + + // Should not throw, regardless of whether it found results + expect(parsed).toBeDefined() + expect(parsed.query).toBe('Transfer') + }) + + test('should handle non-matching search gracefully', async () => { + const response = await client.callTool({ + name: 'getSourcifySignatureSearch', + arguments: { + query: 'xyznonexistentfunction12345', + }, + }) + + expect(response.content).toBeDefined() + + const content = response.content as Array<{ type: string; text: string }> + const parsed = JSON.parse(content[0].text) + + // Should not throw + expect(parsed).toBeDefined() + expect(parsed.query).toBe('xyznonexistentfunction12345') + + if (parsed.ok) { + expect(parsed.totalFound).toBe(0) + } + }) +}) From 6db9b3b1f58168e844ca1ecfa81c57661d6d3e27 Mon Sep 17 00:00:00 2001 From: Roisin Dowling Date: Wed, 10 Dec 2025 18:01:08 +0000 Subject: [PATCH 4/7] add b32 service --- src/services/b32/index.ts | 3 + src/services/b32/schemas.ts | 76 +++++++++++++++++++++++++ src/services/b32/utils.ts | 108 ++++++++++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 src/services/b32/index.ts create mode 100644 src/services/b32/schemas.ts create mode 100644 src/services/b32/utils.ts diff --git a/src/services/b32/index.ts b/src/services/b32/index.ts new file mode 100644 index 0000000..05e5e94 --- /dev/null +++ b/src/services/b32/index.ts @@ -0,0 +1,3 @@ +export * from './schemas' +export * from './utils' + diff --git a/src/services/b32/schemas.ts b/src/services/b32/schemas.ts new file mode 100644 index 0000000..323a775 --- /dev/null +++ b/src/services/b32/schemas.ts @@ -0,0 +1,76 @@ +import { z } from 'zod' + +/** + * Schema for ABI input parameter + */ +export const AbiInputSchema = z.object({ + name: z.string().describe('Parameter name'), + type: z.string().describe('Parameter type (e.g., address, uint256)'), + indexed: z.boolean().optional().describe('Whether the parameter is indexed (for events)'), + internalType: z.string().optional().describe('Internal type'), + components: z.array(z.any()).optional().describe('Components for tuple types'), +}) + +/** + * Schema for ABI output parameter + */ +export const AbiOutputSchema = z.object({ + name: z.string().describe('Output name'), + type: z.string().describe('Output type'), + internalType: z.string().optional().describe('Internal type'), + components: z.array(z.any()).optional().describe('Components for tuple types'), +}) + +/** + * Schema for a single ABI item (event, function, etc.) + */ +export const AbiItemSchema = z + .object({ + type: z.enum(['event', 'function', 'constructor', 'fallback', 'receive', 'error']).describe('ABI item type'), + name: z.string().optional().describe('Name of the event/function'), + inputs: z.array(AbiInputSchema).optional().describe('Input parameters'), + outputs: z.array(AbiOutputSchema).optional().describe('Output parameters (for functions)'), + stateMutability: z.enum(['pure', 'view', 'nonpayable', 'payable']).optional().describe('State mutability'), + anonymous: z.boolean().optional().describe('Whether the event is anonymous'), + }) + .passthrough() + +/** + * Schema for a complete ABI (array of ABI items) + */ +export const AbiSchema = z.array(AbiItemSchema).describe('Contract ABI') + +/** + * Schema for B32 signature lookup response + */ +export const B32SignatureLookupResultSchema = z.object({ + signature: z.string().describe('The signature hash that was looked up'), + found: z.boolean().describe('Whether an ABI was found for the signature'), + abi: AbiSchema.optional().describe('The ABI if found'), + matchingItems: z + .array( + z.object({ + type: z.string().describe('Type of ABI item (event, function, etc.)'), + name: z.string().optional().describe('Name of the item'), + signature: z.string().optional().describe('Human-readable signature'), + }), + ) + .optional() + .describe('Summary of matching ABI items'), +}) + +/** + * Schema for hex signature (4 bytes for functions, 32 bytes for events) + */ +export const SignatureHashSchema = z + .string() + .regex(/^0x[a-fA-F0-9]+$/, 'Signature must be a 0x-prefixed hex string') + .describe('Signature hash (0x-prefixed)') + +// Type exports +export type AbiInput = z.infer +export type AbiOutput = z.infer +export type AbiItem = z.infer +export type Abi = z.infer +export type B32SignatureLookupResult = z.infer + diff --git a/src/services/b32/utils.ts b/src/services/b32/utils.ts new file mode 100644 index 0000000..5c6bcb3 --- /dev/null +++ b/src/services/b32/utils.ts @@ -0,0 +1,108 @@ +import type { Abi as ViemAbi } from 'viem' +import { logger } from '@/utils/logger' +import type { Abi } from './schemas' + +const B32_URL = 'https://b32.vecha.in' + +/** + * Fetches ABI from B32 service by event/function signature hash + * B32 is a signature database that maps 4-byte function selectors and + * 32-byte event topic hashes to their corresponding ABI definitions. + * + * @param signature - The signature hash (topic[0] for events, 4-byte selector for functions) + * @returns The ABI if found, null otherwise + */ +export async function fetchAbiBySignature(signature: string): Promise { + try { + logger.debug(`Fetching ABI for signature: ${signature}`) + + const response = await fetch(`${B32_URL}/q/${signature}.json`) + + if (!response.ok) { + if (response.status === 404) { + logger.debug(`No ABI found for signature: ${signature}`) + return null + } + logger.warn(`Failed to fetch ABI: ${response.status} ${response.statusText}`) + return null + } + + const abi = (await response.json()) as ViemAbi + logger.debug(`Successfully fetched ABI for signature: ${signature}`) + + return abi + } catch (error) { + logger.warn(`Error fetching ABI for signature ${signature}:`, error) + return null + } +} + +/** + * Fetches ABI from B32 and returns parsed result with metadata + * @param signature - The signature hash to look up + * @returns Structured result with ABI and matching items summary + */ +export async function lookupSignature(signature: string): Promise<{ + found: boolean + abi: Abi | null + matchingItems: Array<{ type: string; name?: string; signature?: string }> +}> { + const abi = await fetchAbiBySignature(signature) + + if (!abi) { + return { + found: false, + abi: null, + matchingItems: [], + } + } + + // Extract summary of matching items + const matchingItems = abi.map((item) => { + const result: { type: string; name?: string; signature?: string } = { + type: item.type, + } + + if ('name' in item && item.name) { + result.name = item.name + } + + // Build human-readable signature for events and functions + if ((item.type === 'event' || item.type === 'function') && 'name' in item && 'inputs' in item) { + const inputs = item.inputs || [] + const params = inputs.map((input) => input.type).join(', ') + result.signature = `${item.name}(${params})` + } + + return result + }) + + return { + found: true, + abi: abi as Abi, + matchingItems, + } +} + +/** + * Check if a signature hash is a valid format + * @param signature - The signature hash to validate + * @returns true if valid format + */ +export function isValidSignatureHash(signature: string): boolean { + // Must be 0x-prefixed hex string + if (!signature.startsWith('0x')) { + return false + } + + // Remove 0x prefix and check if valid hex + const hex = signature.slice(2) + if (!/^[a-fA-F0-9]+$/.test(hex)) { + return false + } + + // Function selectors are 4 bytes (8 hex chars), event topics are 32 bytes (64 hex chars) + // But B32 accepts various lengths + return hex.length >= 8 +} + From 7f30632a8a2b776330efe53a0bfd260ba5a9c349 Mon Sep 17 00:00:00 2001 From: Roisin Dowling Date: Wed, 10 Dec 2025 18:01:22 +0000 Subject: [PATCH 5/7] add b32 tools and test --- src/services/b32.ts | 30 ---- src/tools/get-b32-signature.ts | 84 ++++++++++ src/tools/index.ts | 5 + .../b32/get-b32-signature.integration.test.ts | 151 ++++++++++++++++++ 4 files changed, 240 insertions(+), 30 deletions(-) delete mode 100644 src/services/b32.ts create mode 100644 src/tools/get-b32-signature.ts create mode 100644 tests/b32/get-b32-signature.integration.test.ts diff --git a/src/services/b32.ts b/src/services/b32.ts deleted file mode 100644 index c28c308..0000000 --- a/src/services/b32.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Abi } from 'viem' -import { logger } from '../utils/logger' - -const B32_URL = 'https://b32.vecha.in' - -/** - * Fetches ABI from B32 service by event signature hash - * @param signature - The event signature hash (topic[0]) - * @returns The ABI if found, null otherwise - */ -export const fetchAbiBySignature = async (signature: string): Promise => { - try { - logger.debug(`Fetching ABI for signature: ${signature}`) - - const response = await fetch(`${B32_URL}/q/${signature}.json`) - - if (!response.ok) { - logger.warn(`Failed to fetch ABI: ${response.status} ${response.statusText}`) - return null - } - - const abi = (await response.json()) as Abi - logger.debug(`Successfully fetched ABI for signature: ${signature}`) - - return abi - } catch (error) { - logger.warn(`Error fetching ABI for signature ${signature}:`, error) - return null - } -} diff --git a/src/tools/get-b32-signature.ts b/src/tools/get-b32-signature.ts new file mode 100644 index 0000000..80ed308 --- /dev/null +++ b/src/tools/get-b32-signature.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' +import { B32SignatureLookupResultSchema, lookupSignature, SignatureHashSchema } from '@/services/b32' +import type { MCPTool } from '@/types' +import { logger } from '@/utils/logger' + +const InputSchema = z + .object({ + signature: SignatureHashSchema.describe( + 'The signature hash to look up (0x-prefixed). Can be a 4-byte function selector or 32-byte event topic hash.', + ), + }) + .describe('Parameters for looking up an ABI by signature hash') + +const OutputSchema = z + .object({ + ok: z.boolean().describe('Whether the lookup was successful'), + result: B32SignatureLookupResultSchema.optional().describe('The lookup result with ABI if found'), + error: z.string().optional().describe('Error message if lookup failed'), + }) + .describe('B32 signature lookup result') + +export type GetB32SignatureResponse = { + content: Array<{ type: 'text'; text: string }> + structuredContent: z.infer +} + +export const getB32Signature: MCPTool = { + name: 'getB32Signature', + title: 'B32: Lookup ABI by Signature', + description: + 'Look up an ABI definition from the B32 signature database (b32.vecha.in). Provide a function selector (4-byte, e.g., 0xa9059cbb for transfer) or event topic hash (32-byte) to get the corresponding ABI. Useful for decoding unknown function calls or events when you only have the signature hash.', + inputSchema: InputSchema.shape, + outputSchema: OutputSchema.shape, + annotations: { + idempotentHint: true, + openWorldHint: true, + readOnlyHint: true, + destructiveHint: false, + }, + handler: async (params: z.infer): Promise => { + try { + const parsed = InputSchema.parse(params) + const signature = parsed.signature.toLowerCase() + + const lookupResult = await lookupSignature(signature) + + const result = { + signature, + found: lookupResult.found, + abi: lookupResult.abi ?? undefined, + matchingItems: lookupResult.matchingItems.length > 0 ? lookupResult.matchingItems : undefined, + } + + if (!lookupResult.found) { + return { + content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }], + structuredContent: { + ok: true, + result, + }, + } + } + + return { + content: [{ type: 'text', text: JSON.stringify({ ok: true, result }) }], + structuredContent: { + ok: true, + result, + }, + } + } catch (error) { + logger.warn(`Error in getB32Signature: ${String(error)}`) + const errorResult = { + ok: false, + error: `Error looking up signature: ${String(error)}`, + } + return { + content: [{ type: 'text', text: JSON.stringify(errorResult) }], + structuredContent: errorResult, + } + } + }, +} + diff --git a/src/tools/index.ts b/src/tools/index.ts index 4caf0ed..c309e02 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -73,3 +73,8 @@ export { getIPFSContent } from './get-ipfs-content' export { getTokenBalances } from './get-token-balances' export { getCurrentRoundTool } from './get-current-round' export { getGMNFTStatus } from './get-gm-nft-status' +export { getSourcifyContract } from './get-sourcify-contract' +export { getSourcifyContracts } from './get-sourcify-contracts' +export { getSourcifySignatureLookup } from './get-sourcify-signature-lookup' +export { getSourcifySignatureSearch } from './get-sourcify-signature-search' +export { getB32Signature } from './get-b32-signature' diff --git a/tests/b32/get-b32-signature.integration.test.ts b/tests/b32/get-b32-signature.integration.test.ts new file mode 100644 index 0000000..b24899e --- /dev/null +++ b/tests/b32/get-b32-signature.integration.test.ts @@ -0,0 +1,151 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' + +describe('B32 Signature Lookup', () => { + let client: Client + + beforeAll(async () => { + client = new Client({ + name: 'b32-signature-lookup-client', + version: '1.0.0', + }) + const transport = new StreamableHTTPClientTransport(new URL('http://localhost:4000/mcp')) + await client.connect(transport) + }) + + afterAll(async () => { + await client.close() + }) + + test('should lookup a known function selector', async () => { + // 0xa9059cbb is the selector for transfer(address,uint256) + const response = await client.callTool({ + name: 'getB32Signature', + arguments: { + signature: '0xa9059cbb', + }, + }) + + expect(response.content).toBeDefined() + expect(response.structuredContent).toBeDefined() + + const structuredContent = response.structuredContent as { + ok: boolean + result?: { + signature: string + found: boolean + abi?: unknown[] + matchingItems?: Array<{ + type: string + name?: string + signature?: string + }> + } + error?: string + } + + expect(structuredContent.ok).toBe(true) + expect(structuredContent.result).toBeDefined() + expect(structuredContent.result?.signature).toBe('0xa9059cbb') + expect(structuredContent.result?.found).toBe(true) + expect(structuredContent.result?.abi).toBeDefined() + expect(Array.isArray(structuredContent.result?.abi)).toBe(true) + }) + + test('should lookup an event topic hash', async () => { + // Transfer(address,address,uint256) event topic + const response = await client.callTool({ + name: 'getB32Signature', + arguments: { + signature: '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef', + }, + }) + + expect(response.content).toBeDefined() + expect(response.structuredContent).toBeDefined() + + const structuredContent = response.structuredContent as { + ok: boolean + result?: { + signature: string + found: boolean + abi?: unknown[] + matchingItems?: Array<{ + type: string + name?: string + }> + } + } + + expect(structuredContent.ok).toBe(true) + expect(structuredContent.result).toBeDefined() + expect(structuredContent.result?.found).toBe(true) + + // Should find event type items + if (structuredContent.result?.matchingItems) { + const hasEvent = structuredContent.result.matchingItems.some((item) => item.type === 'event') + expect(hasEvent).toBe(true) + } + }) + + test('should return not found for unknown signature', async () => { + const response = await client.callTool({ + name: 'getB32Signature', + arguments: { + signature: '0xdeadbeefdeadbeef', + }, + }) + + expect(response.content).toBeDefined() + expect(response.structuredContent).toBeDefined() + + const structuredContent = response.structuredContent as { + ok: boolean + result?: { + signature: string + found: boolean + } + } + + expect(structuredContent.ok).toBe(true) + expect(structuredContent.result).toBeDefined() + expect(structuredContent.result?.found).toBe(false) + }) + + test('should handle approve function selector', async () => { + // 0x095ea7b3 is the selector for approve(address,uint256) + const response = await client.callTool({ + name: 'getB32Signature', + arguments: { + signature: '0x095ea7b3', + }, + }) + + expect(response.content).toBeDefined() + expect(response.structuredContent).toBeDefined() + + const structuredContent = response.structuredContent as { + ok: boolean + result?: { + found: boolean + matchingItems?: Array<{ + type: string + name?: string + signature?: string + }> + } + } + + expect(structuredContent.ok).toBe(true) + expect(structuredContent.result?.found).toBe(true) + + // Should find approve function + if (structuredContent.result?.matchingItems) { + const hasApprove = structuredContent.result.matchingItems.some( + (item) => item.name === 'approve' || item.signature?.includes('approve'), + ) + expect(hasApprove).toBe(true) + } + }) +}) + From 7f59ba2677424144fd5c1d98ba145f2eecea11eb Mon Sep 17 00:00:00 2001 From: Roisin Dowling Date: Mon, 5 Jan 2026 15:38:13 +0000 Subject: [PATCH 6/7] set default env to dev --- .github/workflows/publish-image.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 792eab6..0f87908 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -17,7 +17,7 @@ on: description: 'Environment name (dev/prod) for AWS credentials' required: false type: string - default: '' + default: 'dev' workflow_dispatch: permissions: From 406be0848dd02bde537f5a7a450feeccb519281f Mon Sep 17 00:00:00 2001 From: Roisin Dowling Date: Mon, 5 Jan 2026 15:49:50 +0000 Subject: [PATCH 7/7] workflow updates --- .github/workflows/publish-image.yml | 36 ++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 0f87908..326bbf9 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -19,6 +19,25 @@ on: type: string default: 'dev' workflow_dispatch: + inputs: + image_tag: + description: 'Tag for the Docker image (defaults to git SHA)' + required: false + type: string + default: '' + set_latest: + description: 'Also tag as latest' + required: false + type: boolean + default: false + environment: + description: 'Environment name (dev/prod) for AWS credentials' + required: false + type: choice + options: + - dev + - prod + default: 'dev' permissions: contents: read @@ -155,8 +174,19 @@ jobs: platform: linux/arm64 arch_suffix: arm64 runs-on: ${{ matrix.runs_on }} - environment: ${{ inputs.environment != '' && inputs.environment || '' }} + environment: ${{ inputs.environment || 'dev' }} steps: + - name: Debug - Environment and Secrets Check + run: | + echo "=== Environment Debug ===" + echo "Input environment: '${{ inputs.environment }}'" + echo "Resolved environment: '${{ inputs.environment || 'dev' }}'" + echo "GitHub environment: ${{ github.environment }}" + echo "" + echo "=== Secrets Check ===" + echo "AWS_ROLE_TO_ASSUME is set: ${{ secrets.AWS_ROLE_TO_ASSUME != '' }}" + echo "AWS_REGION is set: ${{ secrets.AWS_REGION != '' }}" + - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 @@ -204,7 +234,7 @@ jobs: merge-manifest: needs: [build, determine-tag] runs-on: ubuntu-latest - environment: ${{ inputs.environment != '' && inputs.environment || '' }} + environment: ${{ inputs.environment || 'dev' }} steps: - name: Set up Docker Buildx uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3 @@ -317,7 +347,7 @@ jobs: needs: [merge-manifest] if: ${{ inputs.set_latest == true && github.event_name != 'workflow_dispatch' }} runs-on: ubuntu-latest - environment: ${{ inputs.environment != '' && inputs.environment || '' }} + environment: ${{ inputs.environment || 'dev' }} env: ECS_CLUSTER: ${{ secrets.ECS_CLUSTER }} ECS_SERVICE: ${{ secrets.ECS_SERVICE }}