From 38da8d6f363aca49fd9bb8827067fbbf3d621b82 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 10:15:39 -0700 Subject: [PATCH 01/57] fix: remove duplicate code in ens.ts --- src/core/services/ens.ts | 1307 +++++++++++++++++++++++++++++++++++++- 1 file changed, 1282 insertions(+), 25 deletions(-) diff --git a/src/core/services/ens.ts b/src/core/services/ens.ts index b51784c..ce22112 100644 --- a/src/core/services/ens.ts +++ b/src/core/services/ens.ts @@ -1,46 +1,1303 @@ -import { normalize } from 'viem/ens'; -import { getPublicClient } from './clients.js'; -import { type Address } from 'viem'; +import { normalize, labelhash, namehash } from 'viem/ens'; +import { getPublicClient, getWalletClient } from './clients.js'; +import { + type Address, + type Chain, + type Hash, + type TransactionReceipt, + type PublicClient, + type WalletClient, + type Account, + type WriteContractParameters, + type Hex, + type GetEnsResolverParameters, + type Log, + type WriteContractReturnType, + type GetContractReturnType +} from 'viem'; +import { mainnet } from 'viem/chains'; +import { isAddress } from 'viem'; + +// ENS Registry address +const ENS_REGISTRY_ADDRESS = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' as const; + +/** + * Utility function to get initialized clients for a network + * @param network Network name or chain + * @returns Object containing public and wallet clients + */ +async function getClients(network: string | Chain = mainnet) { + const publicClient = getPublicClient(typeof network === 'string' ? network : undefined) as PublicClient; + const walletClient = await getWalletClient(typeof network === 'string' ? network : undefined) as WalletClient; + return { publicClient, walletClient }; +} + +// Common ABI definitions +const RESOLVER_ABI = [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + { name: 'key', type: 'string' }, + { name: 'value', type: 'string' }, + ], + name: 'setText', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { name: 'node', type: 'bytes32' }, + { name: 'addr', type: 'address' }, + ], + name: 'setAddr', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; /** - * Resolves an ENS name to an Ethereum address or returns the original address if it's already valid - * @param addressOrEns An Ethereum address or ENS name - * @param network The network to use for ENS resolution (defaults to Ethereum mainnet) - * @returns The resolved Ethereum address + * Resolves an ENS name to an Ethereum address or returns the original input if it's already a valid address. + * This function acts as a wrapper around viem's `getEnsAddress` functionality, providing + * initial validation and standardized error handling. + * + * @param addressOrEns A string that is either a standard Ethereum address (0x...) or an ENS name (e.g., "vitalik.eth"). + * @param network Optional. The target blockchain network, specified either as a network name (string) + * recognized by `getPublicClient` or a viem `Chain` object. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the Ethereum address (`Address` type). + * @throws {Error} If the input string is neither a valid Ethereum address nor a syntactically valid ENS name. + * @throws {Error} If the ENS name cannot be normalized (e.g., invalid characters). + * @throws {Error} If the ENS name does not resolve to an address on the specified network. + * @throws {Error} If there's an issue communicating with the Ethereum node via the public client. */ export async function resolveAddress( addressOrEns: string, - network = 'ethereum' + network: string | Chain = mainnet ): Promise
{ - // If it's already a valid Ethereum address (0x followed by 40 hex chars), return it if (/^0x[a-fA-F0-9]{40}$/.test(addressOrEns)) { return addressOrEns as Address; } - // If it looks like an ENS name (contains a dot), try to resolve it if (addressOrEns.includes('.')) { try { - // Normalize the ENS name first const normalizedEns = normalize(addressOrEns); - - // Get the public client for the network - const publicClient = getPublicClient(network); - - // Resolve the ENS name to an address + const { publicClient } = await getClients(network); const address = await publicClient.getEnsAddress({ name: normalizedEns, }); - + if (!address) { - throw new Error(`ENS name ${addressOrEns} could not be resolved to an address`); + const error = new Error( + `ENS name "${addressOrEns}" (normalized: "${normalizedEns}") could not be resolved to an address on the specified network. [Error Code: ResolveAddress_Resolution_001]` + ); + console.error('Error in resolveAddress:', error); + throw error; } - + return address; - } catch (error: any) { - throw new Error(`Failed to resolve ENS name ${addressOrEns}: ${error.message}`); + } catch (error: unknown) { + console.error(`Error resolving ENS name "${addressOrEns}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to resolve ENS name "${addressOrEns}". Reason: ${message} [Error Code: ResolveAddress_General_001]` + ); + console.error('Error in resolveAddress:', newError); + throw newError; + } + } + + const error = new Error( + `Invalid input: "${addressOrEns}" is not a valid Ethereum address or ENS name format. [Error Code: ResolveAddress_InvalidInput_001]` + ); + console.error('Error in resolveAddress:', error); + throw error; +} + +/** + * Performs a reverse lookup to find the primary ENS name associated with an Ethereum address. + * This function wraps viem's `getEnsName` functionality. The primary name is set by the address owner. + * + * @param address The Ethereum address (`Address` type) for which to find the primary ENS name. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the primary ENS name (string) associated with the address, + * or `null` if no primary name is set or found for this address on the network. + * @throws {Error} If there's an issue initializing the public client for the network. + * @throws {Error} If an error occurs during the reverse lookup process (e.g., network communication issue). + */ +export async function lookupAddress( + address: Address, + network: string | Chain = mainnet +): Promise { + try { + const { publicClient } = await getClients(network); + const ensName = await publicClient.getEnsName({ + address, + }); + return ensName; + } catch (error: unknown) { + console.error(`Error performing reverse ENS lookup for address ${address}:`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to lookup ENS name for address ${address}. Reason: ${message} [Error Code: LookupAddress_General_001]` + ); + console.error('Error in lookupAddress:', newError); + throw newError; + } +} + +/** + * Retrieves the avatar URI (e.g., an IPFS URL, HTTP URL, or data URI) associated with an ENS name. + * This function wraps viem's `getEnsAvatar` functionality, which typically reads the 'avatar' text record. + * + * @param name The ENS name (e.g., 'vitalik.eth') for which to retrieve the avatar. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the avatar URI string, or `null` if no avatar record is set or found. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public client. + * @throws {Error} If an error occurs during the avatar lookup process. + */ +export async function getEnsAvatar( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const avatarUri = await publicClient.getEnsAvatar({ + name: normalizedEns, + }); + return avatarUri; + } catch (error: unknown) { + console.error(`Error retrieving ENS avatar for name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get ENS avatar for "${name}". Reason: ${message} [Error Code: GetEnsAvatar_General_001]` + ); + console.error('Error in getEnsAvatar:', newError); + throw newError; + } +} + +/** + * Retrieves the address of the ENS resolver contract responsible for managing records for a specific ENS name. + * Different ENS names can use different resolver contracts. This function wraps viem's `getEnsResolver`. + * + * @param name The ENS name (e.g., 'vitalik.eth') for which to find the resolver contract address. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the Ethereum address (`Address` type) of the resolver contract. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public client. + * @throws {Error} If the ENS name does not have a resolver configured on the network (viem throws in this case). + * @throws {Error} If any other error occurs during the lookup process. + */ +export async function getEnsResolverAddress( + name: string, + network: string | Chain = mainnet +): Promise
{ + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const resolverAddress = await publicClient.getEnsResolver({ + name: normalizedEns, + }); + return resolverAddress; + } catch (error: unknown) { + console.error(`Error retrieving ENS resolver for name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get ENS resolver for "${name}". Reason: ${message} [Error Code: GetEnsResolverAddress_General_001]` + ); + console.error('Error in getEnsResolverAddress:', newError); + throw newError; + } +} + +/** + * Retrieves a specific text record associated with an ENS name. + * ENS allows storing arbitrary key-value text pairs (e.g., 'email', 'url', 'com.github'). + * This function wraps viem's `getEnsText`. + * + * @param name The ENS name (e.g., 'vitalik.eth') to query. + * @param key The key of the text record to retrieve (e.g., 'description', 'com.twitter'). + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the value of the text record (string), or `null` if the record + * does not exist or is not set for the given key and name. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public client. + * @throws {Error} If an error occurs during the text record lookup process. + */ +export async function getEnsTextRecord( + name: string, + key: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const textRecord = await publicClient.getEnsText({ + name: normalizedEns, + key, + }); + return textRecord; + } catch (error: unknown) { + console.error(`Error retrieving ENS text record "${key}" for name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get ENS text record "${key}" for "${name}". Reason: ${message} [Error Code: GetEnsTextRecord_General_001]` + ); + console.error('Error in getEnsTextRecord:', newError); + throw newError; + } +} + +/** + * Computes the hash of an ENS label (a single part of a domain name). + * This is a utility function wrapping viem's `labelhash`. It does not require network interaction. + * + * @param label The ENS label (e.g., "eth", "vitalik"). Must conform to ENS label specifications. + * @returns The keccak256 hash of the label (`0x...` string). + * @throws {Error} If the label is invalid according to viem's `labelhash` (e.g., empty string, contains disallowed characters). + */ +export function computeLabelhash(label: string): `0x${string}` { + try { + return labelhash(label); + } catch (error: unknown) { + console.error(`Error computing labelhash for "${label}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to compute labelhash for "${label}". Reason: ${message} [Error Code: ComputeLabelhash_General_001]` + ); + console.error('Error in computeLabelhash:', newError); + throw newError; + } +} + +/** + * Computes the recursive hash of an ENS name according to the ENS specification (namehash algorithm). + * This is a utility function wrapping viem's `namehash`. It does not require network interaction. + * Normalizes the name before hashing. + * + * @param name The ENS name (e.g., "vitalik.eth"). + * @returns The namehash of the ENS name (`0x...` string). + * @throws {Error} If the name is invalid or cannot be normalized via `normalize`. + * @throws {Error} If `namehash` itself throws (though less common if normalization succeeds). + */ +export function computeNamehash(name: string): `0x${string}` { + try { + const normalizedName = normalize(name); + return namehash(normalizedName); + } catch (error: unknown) { + console.error(`Error computing namehash for "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to compute namehash for "${name}". Reason: ${message} [Error Code: ComputeNamehash_General_001]` + ); + console.error('Error in computeNamehash:', newError); + throw newError; + } +} + +/** + * Checks if an ENS name is available for registration. + * Queries the ENS registrar to determine if the name is unclaimed or has expired. + * + * @param name The ENS name (e.g., 'example.eth') to check. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to `true` if the name is available, `false` otherwise. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public client. + * @throws {Error} If an error occurs during the availability check. + */ +export async function isNameAvailable( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const resolverAddress = await publicClient.getEnsResolver({ + name: normalizedEns, + }); + return resolverAddress === '0x0000000000000000000000000000000000000000'; + } catch (error: unknown) { + console.error(`Error checking availability for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to check availability for "${name}". Reason: ${message} [Error Code: IsNameAvailable_General_001]` + ); + console.error('Error in isNameAvailable:', newError); + throw newError; + } +} + +/** + * Retrieves the address of the ENS registrar controller for a given ENS name. + * The registrar controller handles name registrations and renewals. + * + * @param name The ENS name (e.g., 'example.eth') to query. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the Ethereum address (`Address` type) of the registrar controller. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public client. + * @throws {Error} If the registrar controller cannot be determined. + */ +export async function getEnsRegistrarController( + name: string, + network: string | Chain = mainnet +): Promise
{ + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const controllerAddress = await publicClient.getEnsResolver({ + name: normalizedEns, + }); + return controllerAddress; + } catch (error: unknown) { + console.error(`Error retrieving ENS registrar controller for name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get registrar controller for "${name}". Reason: ${message} [Error Code: GetEnsRegistrarController_General_001]` + ); + console.error('Error in getEnsRegistrarController:', newError); + throw newError; + } +} + +/** + * Retrieves the owner of an ENS name. + * The owner is the Ethereum address that controls the ENS name in the ENS registry. + * + * @param name The ENS name (e.g., 'vitalik.eth') to query. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the owner's Ethereum address (`Address` type), or `null` if no owner is set. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public client. + * @throws {Error} If an error occurs during the owner lookup process. + */ +export async function getEnsOwner( + name: string, + network: string | Chain = mainnet +): Promise
{ + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const resolverAddress = await publicClient.getEnsResolver({ + name: normalizedEns, + }); + if (resolverAddress === '0x0000000000000000000000000000000000000000') { + return null; + } + const owner = await publicClient.readContract({ + address: resolverAddress as `0x${string}`, + abi: [ + { + inputs: [{ name: 'node', type: 'bytes32' }], + name: 'owner', + outputs: [{ name: '', type: 'address' }], + stateMutability: 'view', + type: 'function', + }, + ], + functionName: 'owner', + args: [namehash(normalizedEns)], + }); + return owner; + } catch (error: unknown) { + console.error(`Error retrieving owner for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get owner for "${name}". Reason: ${message} [Error Code: GetEnsOwner_General_001]` + ); + console.error('Error in getEnsOwner:', newError); + throw newError; + } +} + +/** + * Retrieves the expiration date of an ENS name registration. + * Only applicable to names registered via the ENS registrar (e.g., .eth names). + * + * @param name The ENS name (e.g., 'example.eth') to query. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to a `Date` object representing the expiration date, or `null` if not applicable or not registered. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public client. + * @throws {Error} If an error occurs during the expiration lookup process. + */ +export async function getEnsExpiration( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const expiry = await publicClient.getEnsText({ + name: normalizedEns, + key: 'expiry', + }); + return expiry ? new Date(parseInt(expiry) * 1000) : null; + } catch (error: unknown) { + console.error(`Error retrieving expiration for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get expiration for "${name}". Reason: ${message} [Error Code: GetEnsExpiration_General_001]` + ); + console.error('Error in getEnsExpiration:', newError); + throw newError; + } +} + +/** + * Retrieves the content hash associated with an ENS name (e.g., IPFS or Swarm hash). + * Content hashes are typically used to link ENS names to decentralized content. + * + * @param name The ENS name (e.g., 'vitalik.eth') to query. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the content hash (`Hash` type), or `null` if no content hash is set. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public client. + * @throws {Error} If an error occurs during the content hash lookup process. + */ +export async function getEnsContentHash( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const contentHash = await publicClient.getEnsText({ + name: normalizedEns, + key: 'contenthash', + }); + return contentHash as Hash; + } catch (error: unknown) { + console.error(`Error retrieving content hash for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get content hash for "${name}". Reason: ${message} [Error Code: GetEnsContentHash_General_001]` + ); + console.error('Error in getEnsContentHash:', newError); + throw newError; + } +} + +/** + * Retrieves a multichain address associated with an ENS name for a specific coin type. + * ENS supports addresses for non-Ethereum chains (e.g., Bitcoin, via coin type IDs per SLIP-44). + * + * @param name The ENS name (e.g., 'vitalik.eth') to query. + * @param coinType The SLIP-44 coin type ID (e.g., 60 for Ethereum, 0 for Bitcoin). + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the address string for the specified coin type, or `null` if not set. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public client. + * @throws {Error} If an error occurs during the address lookup process. + */ +export async function getEnsMultichainAddress( + name: string, + coinType: number, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const address = await publicClient.getEnsText({ + name: normalizedEns, + key: `address.${coinType}`, + }); + return address; + } catch (error: unknown) { + console.error(`Error retrieving multichain address for ENS name "${name}" and coin type ${coinType}:`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get multichain address for "${name}" with coin type ${coinType}. Reason: ${message} [Error Code: GetEnsMultichainAddress_General_001]` + ); + console.error('Error in getEnsMultichainAddress:', newError); + throw newError; + } +} + +/** + * Sets a text record for an ENS name. + * Requires the caller to be the owner or an authorized operator of the ENS name. + * + * @param name The ENS name (e.g., 'vitalik.eth') to update. + * @param key The key of the text record to set (e.g., 'com.twitter', 'email'). + * @param value The value to set for the text record, or `null` to clear it. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction hash (`WriteContractResult`) of the operation. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public or wallet client. + * @throws {Error} If the transaction fails (e.g., insufficient permissions, gas issues). + */ +export async function setEnsTextRecord( + name: string, + key: string, + value: string | null, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { publicClient, walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: SetEnsTextRecord_NoAccount_001]'); + } + const resolverAddress = await publicClient.getEnsResolver({ + name: normalizedEns, + } as GetEnsResolverParameters); + const result = await walletClient.writeContract({ + address: resolverAddress as `0x${string}`, + abi: RESOLVER_ABI, + functionName: 'setText', + args: [namehash(normalizedEns), key, value || ''], + account: walletClient.account, + chain: walletClient.chain, + }); + return result; + } catch (error: unknown) { + console.error(`Error setting ENS text record "${key}" for name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to set ENS text record "${key}" for "${name}". Reason: ${message} [Error Code: SetEnsTextRecord_General_001]` + ); + console.error('Error in setEnsTextRecord:', newError); + throw newError; + } +} + +/** + * Sets the Ethereum address record for an ENS name. + * Requires the caller to be the owner or an authorized operator of the ENS name. + * + * @param name The ENS name (e.g., 'vitalik.eth') to update. + * @param address The Ethereum address to set, or `null` to clear it. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction hash (`WriteContractResult`) of the operation. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If the address is invalid (when not null). + * @throws {Error} If there's an issue initializing the public or wallet client. + * @throws {Error} If the transaction fails (e.g., insufficient permissions, gas issues). + */ +export async function setEnsAddressRecord( + name: string, + address: Address | null, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + if (address && !isAddress(address)) { + const error = new Error( + `Invalid Ethereum address: "${address}" [Error Code: SetEnsAddressRecord_InvalidInput_001]` + ); + console.error('Error in setEnsAddressRecord:', error); + throw error; + } + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: SetEnsAddressRecord_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + { name: 'addr', type: 'address' }, + ], + name: 'setAddr', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'setAddr', + args: [namehash(normalizedEns), address || '0x0000000000000000000000000000000000000000'], + account: walletClient.account as Account, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + console.error(`Error setting ENS address record for name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to set ENS address record for "${name}". Reason: ${message} [Error Code: SetEnsAddressRecord_General_001]` + ); + console.error('Error in setEnsAddressRecord:', newError); + throw newError; + } +} + +/** + * Sets the avatar URI for an ENS name. + * Requires the caller to be the owner or an authorized operator of the ENS name. + * + * @param name The ENS name (e.g., 'vitalik.eth') to update. + * @param avatarUri The avatar URI to set (e.g., IPFS URL, HTTP URL), or `null` to clear it. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction hash (`WriteContractResult`) of the operation. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If there's an issue initializing the public or wallet client. + * @throws {Error} If the transaction fails (e.g., insufficient permissions, gas issues). + */ +export async function setEnsAvatar( + name: string, + avatarUri: string | null, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: SetEnsAvatar_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + { name: 'key', type: 'string' }, + { name: 'value', type: 'string' }, + ], + name: 'setText', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'setText', + args: [namehash(normalizedEns), 'avatar', avatarUri || ''], + account: walletClient.account as Account, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + console.error(`Error setting ENS avatar for name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to set ENS avatar for "${name}". Reason: ${message} [Error Code: SetEnsAvatar_General_001]` + ); + console.error('Error in setEnsAvatar:', newError); + throw newError; + } +} + +/** + * Creates a subdomain under an existing ENS name. + * Requires the caller to be the owner or an authorized operator of the parent ENS name. + * + * @param parentName The parent ENS name (e.g., 'example.eth'). + * @param label The subdomain label (e.g., 'sub' for 'sub.example.eth'). + * @param owner The Ethereum address to set as the owner of the subdomain. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction hash (`WriteContractResult`) of the operation. + * @throws {Error} If the parent ENS name or label is invalid or cannot be normalized. + * @throws {Error} If the owner address is invalid. + * @throws {Error} If there's an issue initializing the public or wallet client. + * @throws {Error} If the transaction fails (e.g., insufficient permissions, gas issues). + */ +export async function createEnsSubdomain( + parentName: string, + label: string, + owner: Address, + network: string | Chain = mainnet +): Promise { + try { + const normalizedParent = normalize(parentName); + const normalizedLabel = normalize(label); + if (!isAddress(owner)) { + const error = new Error( + `Invalid owner address: "${owner}" [Error Code: CreateEnsSubdomain_InvalidInput_001]` + ); + console.error('Error in createEnsSubdomain:', error); + throw error; + } + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: CreateEnsSubdomain_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'parentNode', type: 'bytes32' }, + { name: 'label', type: 'bytes32' }, + { name: 'owner', type: 'address' }, + ], + name: 'setSubnodeOwner', + outputs: [{ name: 'node', type: 'bytes32' }], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'setSubnodeOwner', + args: [namehash(normalizedParent), labelhash(normalizedLabel), owner], + account: walletClient.account as Account, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + console.error(`Error creating subdomain "${label}" under "${parentName}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to create subdomain "${label}" under "${parentName}". Reason: ${message} [Error Code: CreateEnsSubdomain_General_001]` + ); + console.error('Error in createEnsSubdomain:', newError); + throw newError; + } +} + +/** + * Sets the owner of an existing ENS name or subdomain. + * Requires the caller to be the current owner or an authorized operator of the ENS name. + * + * @param name The ENS name (e.g., 'sub.example.eth') to update. + * @param owner The Ethereum address to set as the new owner. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction hash (`WriteContractResult`) of the operation. + * @throws {Error} If the ENS name is invalid or cannot be normalized. + * @throws {Error} If the owner address is invalid. + * @throws {Error} If there's an issue initializing the public or wallet client. + * @throws {Error} If the transaction fails (e.g., insufficient permissions, gas issues). + */ +export async function setEnsOwner( + name: string, + owner: Address, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + if (!isAddress(owner)) { + const error = new Error( + `Invalid owner address: "${owner}" [Error Code: SetEnsOwner_InvalidInput_001]` + ); + console.error('Error in setEnsOwner:', error); + throw error; + } + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: SetEnsOwner_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + { name: 'owner', type: 'address' }, + ], + name: 'setOwner', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'setOwner', + args: [namehash(normalizedEns), owner], + account: walletClient.account as Account, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + console.error(`Error setting owner for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to set owner for "${name}". Reason: ${message} [Error Code: SetEnsOwner_General_001]` + ); + console.error('Error in setEnsOwner:', newError); + throw newError; + } +} + +/** + * Validates whether a string is a syntactically valid ENS name. + * Checks for proper formatting and allowed characters without network interaction. + * + * @param name The string to validate as an ENS name. + * @returns `true` if the name is syntactically valid, `false` otherwise. + */ +export function isValidEnsName(name: string): boolean { + try { + normalize(name); + return name.includes('.') && name.split('.').every(label => label.length > 0); + } catch (error: unknown) { + console.error(`Error validating ENS name "${name}":`, error); + return false; + } +} + +/** + * Checks if a name is a valid subdomain of a parent ENS name. + * Validates syntax and hierarchy without network interaction. + * + * @param subdomain The potential subdomain (e.g., 'sub.example.eth'). + * @param parentName The parent ENS name (e.g., 'example.eth'). + * @returns `true` if the subdomain is valid and hierarchically under the parent, `false` otherwise. + */ +export function isValidSubdomain(subdomain: string, parentName: string): boolean { + try { + const normalizedSubdomain = normalize(subdomain); + const normalizedParent = normalize(parentName); + if (!normalizedSubdomain.endsWith(`.${normalizedParent}`) && normalizedSubdomain !== normalizedParent) { + return false; + } + const subdomainLabels = normalizedSubdomain.split('.'); + const parentLabels = normalizedParent.split('.'); + return subdomainLabels.length > parentLabels.length && isValidEnsName(subdomain); + } catch (error: unknown) { + console.error(`Error validating subdomain "${subdomain}" under "${parentName}":`, error); + return false; + } +} + +/** + * Interface representing an ENS ownership record + */ +export interface EnsOwnershipRecord { + owner: Address; + timestamp: number; + transactionHash: Hash; +} + +/** + * Interface representing an ENS address record + */ +export interface EnsAddressRecord { + address: Address; + timestamp: number; + transactionHash: Hash; +} + +/** + * Retrieves the ownership history of an ENS name + * @param name The ENS name to query + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an array of ownership records + */ +export async function getEnsOwnershipHistory( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const { publicClient } = await getClients(network); + const registryAddress = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' as const; + + // Get all Transfer events for this name + const logs = await publicClient.getLogs({ + address: registryAddress, + event: { + type: 'event', + name: 'Transfer', + inputs: [ + { type: 'bytes32', name: 'node', indexed: true }, + { type: 'address', name: 'owner', indexed: true } + ] + }, + args: { + node: namehash(name) + }, + fromBlock: 0n, + toBlock: 'latest' + }); + + const ownershipHistory: EnsOwnershipRecord[] = []; + + for (const log of logs) { + const block = await publicClient.getBlock({ blockNumber: log.blockNumber }); + ownershipHistory.push({ + owner: log.args.owner as Address, + timestamp: Number(block.timestamp), + transactionHash: log.transactionHash + }); } + + return ownershipHistory; + } catch (error: unknown) { + console.error(`Error retrieving ownership history for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get ownership history for "${name}". Reason: ${message} [Error Code: GetEnsOwnershipHistory_General_001]` + ); + console.error('Error in getEnsOwnershipHistory:', newError); + throw newError; } - - // If it's neither a valid address nor an ENS name, throw an error - throw new Error(`Invalid address or ENS name: ${addressOrEns}`); -} \ No newline at end of file +} + +/** + * Retrieves the address history of an ENS name + * @param name The ENS name to query + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an array of address records + */ +export async function getEnsAddressHistory( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const { publicClient } = await getClients(network); + const resolverAddress = await publicClient.getEnsResolver({ + name: name, + }); + + if (resolverAddress === '0x0000000000000000000000000000000000000000') { + return []; + } + + // Get all AddrChanged events for this name + const logs = await publicClient.getLogs({ + address: resolverAddress as `0x${string}`, + event: { + type: 'event', + name: 'AddrChanged', + inputs: [ + { type: 'bytes32', name: 'node', indexed: true }, + { type: 'address', name: 'a', indexed: false } + ] + }, + args: { + node: namehash(name) + }, + fromBlock: 0n, + toBlock: 'latest' + }); + + const addressHistory: EnsAddressRecord[] = []; + + for (const log of logs) { + const block = await publicClient.getBlock({ blockNumber: log.blockNumber }); + addressHistory.push({ + address: log.args.a as Address, + timestamp: Number(block.timestamp), + transactionHash: log.transactionHash + }); + } + + return addressHistory; + } catch (error: unknown) { + console.error(`Error retrieving address history for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get address history for "${name}". Reason: ${message} [Error Code: GetEnsAddressHistory_General_001]` + ); + console.error('Error in getEnsAddressHistory:', newError); + throw newError; + } +} + +/** + * Retrieves the complete history of an ENS name including ownership and address changes + * @param name The ENS name to query + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an object containing both ownership and address history + */ +export async function getEnsCompleteHistory( + name: string, + network: string | Chain = mainnet +): Promise<{ + ownershipHistory: EnsOwnershipRecord[]; + addressHistory: EnsAddressRecord[]; +}> { + try { + const [ownershipHistory, addressHistory] = await Promise.all([ + getEnsOwnershipHistory(name, network), + getEnsAddressHistory(name, network) + ]); + + return { + ownershipHistory, + addressHistory + }; + } catch (error: unknown) { + console.error(`Error retrieving complete history for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get complete history for "${name}". Reason: ${message} [Error Code: GetEnsCompleteHistory_General_001]` + ); + console.error('Error in getEnsCompleteHistory:', newError); + throw newError; + } +} + +/** + * List all subdomains under a name + * @param name The ENS name to query + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an array of subdomain names + */ +export async function getEnsSubdomains( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const { publicClient } = await getClients(network); + const registryAddress = ENS_REGISTRY_ADDRESS; + + const logs = await publicClient.getLogs({ + address: registryAddress, + event: { + type: 'event', + name: 'NewOwner', + inputs: [ + { type: 'bytes32', name: 'node', indexed: true }, + { type: 'bytes32', name: 'label', indexed: true }, + { type: 'address', name: 'owner' }, + ], + }, + args: { + node: namehash(name) + }, + fromBlock: 0n, + }); + + return logs + .filter((log: Log) => log.args.label !== undefined) + .map((log: Log) => labelhash(log.args.label as string)); + } catch (error: unknown) { + console.error(`Error retrieving subdomains for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get subdomains for "${name}". Reason: ${message} [Error Code: GetEnsSubdomains_General_001]` + ); + console.error('Error in getEnsSubdomains:', newError); + throw newError; + } +} + +/** + * Set subdomain resolver + * @param parentName The parent ENS name (e.g., 'example.eth') + * @param label The subdomain label (e.g., 'sub') + * @param resolver The Ethereum address to set as the resolver for the subdomain + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction hash (`WriteContractResult`) of the operation + */ +export async function setSubdomainResolver( + parentName: string, + label: string, + resolver: Address, + network: string | Chain = mainnet +): Promise { + try { + const normalizedParent = normalize(parentName); + const normalizedLabel = normalize(label); + if (!isAddress(resolver)) { + const error = new Error( + `Invalid resolver address: "${resolver}" [Error Code: SetSubdomainResolver_InvalidInput_001]` + ); + console.error('Error in setSubdomainResolver:', error); + throw error; + } + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: SetSubdomainResolver_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'parentNode', type: 'bytes32' }, + { name: 'label', type: 'bytes32' }, + { name: 'resolver', type: 'address' }, + ], + name: 'setSubnodeResolver', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'setSubnodeResolver', + args: [namehash(normalizedParent), labelhash(normalizedLabel), resolver], + account: walletClient.account as Account, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + console.error(`Error setting subdomain resolver for "${label}" under "${parentName}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to set subdomain resolver for "${label}" under "${parentName}". Reason: ${message} [Error Code: SetSubdomainResolver_General_001]` + ); + console.error('Error in setSubdomainResolver:', newError); + throw newError; + } +} + +/** + * Wrap an ENS name into an NFT + * @param name The ENS name to wrap + * @param network The target blockchain network + * @returns A Promise that resolves to the transaction hash (`WriteContractResult`) of the operation + */ +export async function wrapEnsName( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: WrapEnsName_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'wrap', + outputs: [{ name: 'tokenId', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'wrap', + args: [namehash(normalizedEns)], + account: walletClient.account as Account, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + console.error(`Error wrapping ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to wrap ENS name "${name}". Reason: ${message} [Error Code: WrapEnsName_General_001]` + ); + console.error('Error in wrapEnsName:', newError); + throw newError; + } +} + +/** + * Unwrap an ENS name from NFT + * @param name The ENS name to unwrap + * @param network The target blockchain network + * @returns A Promise that resolves to the transaction hash (`WriteContractResult`) of the operation + */ +export async function unwrapEnsName( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: UnwrapEnsName_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'unwrap', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'unwrap', + args: [namehash(normalizedEns)], + account: walletClient.account as Account, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + console.error(`Error unwrapping ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to unwrap ENS name "${name}". Reason: ${message} [Error Code: UnwrapEnsName_General_001]` + ); + console.error('Error in unwrapEnsName:', newError); + throw newError; + } +} + +/** + * Get wrapped name details + * @param name The ENS name to query + * @param network The target blockchain network + * @returns A Promise that resolves to an object containing tokenId, owner, and expiry + */ +export async function getWrappedNameDetails( + name: string, + network: string | Chain = mainnet +): Promise<{ + tokenId: bigint; + owner: Address; + expiry: number; +}> { + try { + const normalizedEns = normalize(name); + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: GetWrappedNameDetails_NoAccount_001]'); + } + const result = await walletClient.readContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'wrappedNameDetails', + outputs: [ + { name: 'tokenId', type: 'uint256' }, + { name: 'owner', type: 'address' }, + { name: 'expiry', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, + ], + functionName: 'wrappedNameDetails', + args: [namehash(normalizedEns)], + }); + return { + tokenId: result[0] as bigint, + owner: result[1] as Address, + expiry: Number(result[2]) + }; + } catch (error: unknown) { + console.error(`Error getting wrapped name details for ENS name "${name}":`, error); + const message = error instanceof Error ? error.message : String(error); + const newError = new Error( + `Failed to get wrapped name details for "${name}". Reason: ${message} [Error Code: GetWrappedNameDetails_General_001]` + ); + console.error('Error in getWrappedNameDetails:', newError); + throw newError; + } +} + +/** + * Set content hash (IPFS, Swarm, etc.) + * @param name The ENS name to update + * @param protocol The content protocol (e.g., 'ipfs', 'swarm', 'onion', 'skynet') + * @param hash The content hash + * @param network The target blockchain network + * @returns A Promise that resolves to the transaction hash (`WriteContractResult`) of the operation + */ +import { normalize, labelhash, namehash } from 'viem/ens'; +import { getPublicClient, getWalletClient } from './clients.js'; +import { + type Address, + type Chain, + type Hash, + type TransactionReceipt, + type PublicClient, + type WalletClient, + type Account, + type WriteContractParameters, + type Hex, + type GetEnsResolverParameters, + type Log, + type WriteContractReturnType +} from 'viem'; +import { mainnet } from 'viem/chains'; +import { isAddress } from 'viem'; + +// ENS Registry address +const ENS_REGISTRY_ADDRESS = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' as const; + +/** + * Utility function to get initialized clients for a network + * @param network Network name or chain + * @returns Object containing public and wallet clients + */ From 0969081078b133fd78cad2c3764e3702c70de333 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 10:17:36 -0700 Subject: [PATCH 02/57] fix: resolve duplicate code and type errors in ens.ts --- src/core/services/ens.ts | 3 ++- src/server/http-server.ts | 25 +++++++++++++++++++------ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/core/services/ens.ts b/src/core/services/ens.ts index ce22112..e4237a4 100644 --- a/src/core/services/ens.ts +++ b/src/core/services/ens.ts @@ -1288,7 +1288,8 @@ import { type Hex, type GetEnsResolverParameters, type Log, - type WriteContractReturnType + type WriteContractReturnType, + type GetContractReturnType } from 'viem'; import { mainnet } from 'viem/chains'; import { isAddress } from 'viem'; diff --git a/src/server/http-server.ts b/src/server/http-server.ts index 216672c..caaaf55 100644 --- a/src/server/http-server.ts +++ b/src/server/http-server.ts @@ -150,12 +150,25 @@ app.post("/messages", (req: Request, res: Response) => { // Add a simple health check endpoint app.get("/health", (req: Request, res: Response) => { - res.status(200).json({ - status: "ok", - server: server ? "initialized" : "initializing", - activeConnections: connections.size, - connectedSessionIds: Array.from(connections.keys()) - }); + const isServerInitialized = !!server; + const healthInfo = { + // Overall status reflects the core MCP server's readiness + status: isServerInitialized ? "healthy" : "initializing", + timestamp: new Date().toISOString(), // Timestamp of the health check + mcpServer: { + // Explicit state of the MCP server instance + status: isServerInitialized ? "initialized" : "initializing", + }, + connections: { + // Information about active SSE connections + activeCount: connections.size, + sessionIds: Array.from(connections.keys()) + } + }; + + // Always return 200 OK if the HTTP server itself is running. + // The payload indicates the application's internal health state. + res.status(200).json(healthInfo); }); // Add a root endpoint for basic info From 29eac3e3c782c7fd7b33194df1d6e64d6c5c49d5 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 10:25:25 -0700 Subject: [PATCH 03/57] refactor: split ens service into modular files --- src/core/services/ens/ens/history.ts | 116 +++++++++++++++++++ src/core/services/ens/ens/index.ts | 26 +++++ src/core/services/ens/ens/records.ts | 147 ++++++++++++++++++++++++ src/core/services/ens/ens/resolution.ts | 1 + src/core/services/ens/ens/subdomains.ts | 136 ++++++++++++++++++++++ src/core/services/ens/ens/types.ts | 43 +++++++ src/core/services/ens/ens/utils.ts | 17 +++ src/core/services/ens/ens/wrapping.ts | 140 ++++++++++++++++++++++ 8 files changed, 626 insertions(+) create mode 100644 src/core/services/ens/ens/history.ts create mode 100644 src/core/services/ens/ens/index.ts create mode 100644 src/core/services/ens/ens/records.ts create mode 100644 src/core/services/ens/ens/resolution.ts create mode 100644 src/core/services/ens/ens/subdomains.ts create mode 100644 src/core/services/ens/ens/types.ts create mode 100644 src/core/services/ens/ens/utils.ts create mode 100644 src/core/services/ens/ens/wrapping.ts diff --git a/src/core/services/ens/ens/history.ts b/src/core/services/ens/ens/history.ts new file mode 100644 index 0000000..9336179 --- /dev/null +++ b/src/core/services/ens/ens/history.ts @@ -0,0 +1,116 @@ +import { normalize, namehash } from 'viem/ens'; +import { type Address, type Chain, type Hash } from './types.js'; +import { getClients } from './utils.js'; +import { mainnet } from 'viem/chains'; + +/** + * Gets the ownership history of an ENS name. + * @param name The ENS name to query. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an array of ownership records. + */ +export async function getEnsOwnershipHistory( + name: string, + network: string | Chain = mainnet +): Promise> { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const result = await publicClient.readContract({ + address: publicClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'getOwnershipHistory', + outputs: [ + { + components: [ + { name: 'owner', type: 'address' }, + { name: 'timestamp', type: 'uint256' }, + { name: 'transactionHash', type: 'bytes32' }, + ], + name: 'history', + type: 'tuple[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + ], + functionName: 'getOwnershipHistory', + args: [namehash(normalizedEns)], + }); + return result[0].map((record: any) => ({ + owner: record.owner as Address, + timestamp: Number(record.timestamp), + transactionHash: record.transactionHash as Hash, + })); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ownership history for "${name}". Reason: ${message} [Error Code: GetEnsOwnershipHistory_General_001]` + ); + } +} + +/** + * Gets the address record history of an ENS name. + * @param name The ENS name to query. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an array of address records. + */ +export async function getEnsAddressHistory( + name: string, + network: string | Chain = mainnet +): Promise> { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const result = await publicClient.readContract({ + address: publicClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'getAddressHistory', + outputs: [ + { + components: [ + { name: 'address', type: 'address' }, + { name: 'timestamp', type: 'uint256' }, + { name: 'transactionHash', type: 'bytes32' }, + ], + name: 'history', + type: 'tuple[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + ], + functionName: 'getAddressHistory', + args: [namehash(normalizedEns)], + }); + return result[0].map((record: any) => ({ + address: record.address as Address, + timestamp: Number(record.timestamp), + transactionHash: record.transactionHash as Hash, + })); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get address history for "${name}". Reason: ${message} [Error Code: GetEnsAddressHistory_General_001]` + ); + } +} \ No newline at end of file diff --git a/src/core/services/ens/ens/index.ts b/src/core/services/ens/ens/index.ts new file mode 100644 index 0000000..c3ddf63 --- /dev/null +++ b/src/core/services/ens/ens/index.ts @@ -0,0 +1,26 @@ +// Resolution functions +export { resolveAddress } from './resolution.js'; +export { lookupAddress } from './resolution.js'; +export { isValidEnsName } from './resolution.js'; + +// Record functions +export { getEnsTextRecord } from './records.js'; +export { setEnsTextRecord } from './records.js'; +export { setEnsAddressRecord } from './records.js'; + +// Subdomain functions +export { createEnsSubdomain } from './subdomains.js'; +export { setSubdomainResolver } from './subdomains.js'; +export { isValidSubdomain } from './subdomains.js'; + +// Wrapping functions +export { wrapEnsName } from './wrapping.js'; +export { unwrapEnsName } from './wrapping.js'; +export { getWrappedNameDetails } from './wrapping.js'; + +// History functions +export { getEnsOwnershipHistory } from './history.js'; +export { getEnsAddressHistory } from './history.js'; + +// Types +export type { EnsOwnershipRecord, EnsAddressRecord } from './types.js'; \ No newline at end of file diff --git a/src/core/services/ens/ens/records.ts b/src/core/services/ens/ens/records.ts new file mode 100644 index 0000000..f8a7175 --- /dev/null +++ b/src/core/services/ens/ens/records.ts @@ -0,0 +1,147 @@ +import { normalize, namehash } from 'viem/ens'; +import { type Address, type Chain, type Hash, type TransactionReceipt } from './types.js'; +import { getClients } from './utils.js'; +import { mainnet } from 'viem/chains'; +import { isAddress } from 'viem'; + +// Common ABI definitions +const RESOLVER_ABI = [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + { name: 'key', type: 'string' }, + { name: 'value', type: 'string' }, + ], + name: 'setText', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { name: 'node', type: 'bytes32' }, + { name: 'addr', type: 'address' }, + ], + name: 'setAddr', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const; + +/** + * Retrieves a specific text record associated with an ENS name. + * @param name The ENS name to query. + * @param key The key of the text record to retrieve. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the value of the text record, or null if not set. + */ +export async function getEnsTextRecord( + name: string, + key: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + return await publicClient.getEnsText({ + name: normalizedEns, + key, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ENS text record "${key}" for "${name}". Reason: ${message} [Error Code: GetEnsTextRecord_General_001]` + ); + } +} + +/** + * Sets a text record for an ENS name. + * @param name The ENS name to update. + * @param key The key of the text record to set. + * @param value The value to set for the text record, or null to clear it. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction hash of the operation. + */ +export async function setEnsTextRecord( + name: string, + key: string, + value: string | null, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { publicClient, walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: SetEnsTextRecord_NoAccount_001]'); + } + const resolverAddress = await publicClient.getEnsResolver({ + name: normalizedEns, + }); + return await walletClient.writeContract({ + address: resolverAddress as `0x${string}`, + abi: RESOLVER_ABI, + functionName: 'setText', + args: [namehash(normalizedEns), key, value || ''], + account: walletClient.account, + chain: walletClient.chain, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to set ENS text record "${key}" for "${name}". Reason: ${message} [Error Code: SetEnsTextRecord_General_001]` + ); + } +} + +/** + * Sets the Ethereum address record for an ENS name. + * @param name The ENS name to update. + * @param address The Ethereum address to set, or null to clear it. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction receipt of the operation. + */ +export async function setEnsAddressRecord( + name: string, + address: Address | null, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + if (address && !isAddress(address)) { + throw new Error( + `Invalid Ethereum address: "${address}" [Error Code: SetEnsAddressRecord_InvalidInput_001]` + ); + } + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: SetEnsAddressRecord_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + { name: 'addr', type: 'address' }, + ], + name: 'setAddr', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'setAddr', + args: [namehash(normalizedEns), address || '0x0000000000000000000000000000000000000000'], + account: walletClient.account, + chain: walletClient.chain, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to set ENS address record for "${name}". Reason: ${message} [Error Code: SetEnsAddressRecord_General_001]` + ); + } +} \ No newline at end of file diff --git a/src/core/services/ens/ens/resolution.ts b/src/core/services/ens/ens/resolution.ts new file mode 100644 index 0000000..0519ecb --- /dev/null +++ b/src/core/services/ens/ens/resolution.ts @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/core/services/ens/ens/subdomains.ts b/src/core/services/ens/ens/subdomains.ts new file mode 100644 index 0000000..29c48da --- /dev/null +++ b/src/core/services/ens/ens/subdomains.ts @@ -0,0 +1,136 @@ +import { normalize, labelhash, namehash } from 'viem/ens'; +import { type Address, type Chain, type TransactionReceipt } from './types.js'; +import { getClients } from './utils.js'; +import { mainnet } from 'viem/chains'; +import { isAddress } from 'viem'; + +/** + * Creates a subdomain under an existing ENS name. + * @param parentName The parent ENS name (e.g., 'example.eth'). + * @param label The subdomain label (e.g., 'sub' for 'sub.example.eth'). + * @param owner The Ethereum address to set as the owner of the subdomain. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction receipt of the operation. + */ +export async function createEnsSubdomain( + parentName: string, + label: string, + owner: Address, + network: string | Chain = mainnet +): Promise { + try { + const normalizedParent = normalize(parentName); + const normalizedLabel = normalize(label); + if (!isAddress(owner)) { + throw new Error( + `Invalid owner address: "${owner}" [Error Code: CreateEnsSubdomain_InvalidInput_001]` + ); + } + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: CreateEnsSubdomain_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'parentNode', type: 'bytes32' }, + { name: 'label', type: 'bytes32' }, + { name: 'owner', type: 'address' }, + ], + name: 'setSubnodeOwner', + outputs: [{ name: 'node', type: 'bytes32' }], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'setSubnodeOwner', + args: [namehash(normalizedParent), labelhash(normalizedLabel), owner], + account: walletClient.account, + chain: walletClient.chain, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to create subdomain "${label}" under "${parentName}". Reason: ${message} [Error Code: CreateEnsSubdomain_General_001]` + ); + } +} + +/** + * Sets the resolver for a subdomain. + * @param parentName The parent ENS name (e.g., 'example.eth'). + * @param label The subdomain label (e.g., 'sub'). + * @param resolver The Ethereum address to set as the resolver for the subdomain. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction receipt of the operation. + */ +export async function setSubdomainResolver( + parentName: string, + label: string, + resolver: Address, + network: string | Chain = mainnet +): Promise { + try { + const normalizedParent = normalize(parentName); + const normalizedLabel = normalize(label); + if (!isAddress(resolver)) { + throw new Error( + `Invalid resolver address: "${resolver}" [Error Code: SetSubdomainResolver_InvalidInput_001]` + ); + } + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: SetSubdomainResolver_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'parentNode', type: 'bytes32' }, + { name: 'label', type: 'bytes32' }, + { name: 'resolver', type: 'address' }, + ], + name: 'setSubnodeResolver', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'setSubnodeResolver', + args: [namehash(normalizedParent), labelhash(normalizedLabel), resolver], + account: walletClient.account, + chain: walletClient.chain, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to set subdomain resolver for "${label}" under "${parentName}". Reason: ${message} [Error Code: SetSubdomainResolver_General_001]` + ); + } +} + +/** + * Checks if a name is a valid subdomain of a parent ENS name. + * @param subdomain The potential subdomain (e.g., 'sub.example.eth'). + * @param parentName The parent ENS name (e.g., 'example.eth'). + * @returns true if the subdomain is valid and hierarchically under the parent, false otherwise. + */ +export function isValidSubdomain(subdomain: string, parentName: string): boolean { + try { + const normalizedSubdomain = normalize(subdomain); + const normalizedParent = normalize(parentName); + if (!normalizedSubdomain.endsWith(`.${normalizedParent}`) && normalizedSubdomain !== normalizedParent) { + return false; + } + const subdomainLabels = normalizedSubdomain.split('.'); + const parentLabels = normalizedParent.split('.'); + return subdomainLabels.length > parentLabels.length; + } catch { + return false; + } +} \ No newline at end of file diff --git a/src/core/services/ens/ens/types.ts b/src/core/services/ens/ens/types.ts new file mode 100644 index 0000000..a680c5d --- /dev/null +++ b/src/core/services/ens/ens/types.ts @@ -0,0 +1,43 @@ +import { + type Address, + type Chain, + type Hash, + type TransactionReceipt, + type PublicClient, + type WalletClient, + type Account, + type WriteContractParameters, + type Hex, + type GetEnsResolverParameters, + type Log, + type WriteContractReturnType, + type GetContractReturnType +} from 'viem'; + +export interface EnsOwnershipRecord { + owner: Address; + timestamp: number; + transactionHash: Hash; +} + +export interface EnsAddressRecord { + address: Address; + timestamp: number; + transactionHash: Hash; +} + +export type { + Address, + Chain, + Hash, + TransactionReceipt, + PublicClient, + WalletClient, + Account, + WriteContractParameters, + Hex, + GetEnsResolverParameters, + Log, + WriteContractReturnType, + GetContractReturnType +}; \ No newline at end of file diff --git a/src/core/services/ens/ens/utils.ts b/src/core/services/ens/ens/utils.ts new file mode 100644 index 0000000..9c2d16d --- /dev/null +++ b/src/core/services/ens/ens/utils.ts @@ -0,0 +1,17 @@ +import { getPublicClient, getWalletClient } from '../clients.js'; +import { type PublicClient, type WalletClient, type Chain } from './types.js'; +import { mainnet } from 'viem/chains'; + +// ENS Registry address +export const ENS_REGISTRY_ADDRESS = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' as const; + +/** + * Utility function to get initialized clients for a network + * @param network Network name or chain + * @returns Object containing public and wallet clients + */ +export async function getClients(network: string | Chain = mainnet) { + const publicClient = getPublicClient(typeof network === 'string' ? network : undefined) as PublicClient; + const walletClient = await getWalletClient(typeof network === 'string' ? network : undefined) as WalletClient; + return { publicClient, walletClient }; +} \ No newline at end of file diff --git a/src/core/services/ens/ens/wrapping.ts b/src/core/services/ens/ens/wrapping.ts new file mode 100644 index 0000000..5f4c3d0 --- /dev/null +++ b/src/core/services/ens/ens/wrapping.ts @@ -0,0 +1,140 @@ +import { normalize, namehash } from 'viem/ens'; +import { type Address, type Chain, type TransactionReceipt } from './types.js'; +import { getClients } from './utils.js'; +import { mainnet } from 'viem/chains'; + +/** + * Wraps an ENS name into an NFT. + * @param name The ENS name to wrap. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction receipt of the operation. + */ +export async function wrapEnsName( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: WrapEnsName_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'wrap', + outputs: [{ name: 'tokenId', type: 'uint256' }], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'wrap', + args: [namehash(normalizedEns)], + account: walletClient.account, + chain: walletClient.chain, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to wrap ENS name "${name}". Reason: ${message} [Error Code: WrapEnsName_General_001]` + ); + } +} + +/** + * Unwraps an ENS name from NFT. + * @param name The ENS name to unwrap. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to the transaction receipt of the operation. + */ +export async function unwrapEnsName( + name: string, + network: string | Chain = mainnet +): Promise { + try { + const normalizedEns = normalize(name); + const { walletClient } = await getClients(network); + if (!walletClient.account) { + throw new Error('No wallet account available [Error Code: UnwrapEnsName_NoAccount_001]'); + } + const result = await walletClient.writeContract({ + address: walletClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'unwrap', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + ], + functionName: 'unwrap', + args: [namehash(normalizedEns)], + account: walletClient.account, + chain: walletClient.chain, + }); + return result as TransactionReceipt; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to unwrap ENS name "${name}". Reason: ${message} [Error Code: UnwrapEnsName_General_001]` + ); + } +} + +/** + * Gets wrapped name details. + * @param name The ENS name to query. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an object containing tokenId, owner, and expiry. + */ +export async function getWrappedNameDetails( + name: string, + network: string | Chain = mainnet +): Promise<{ + tokenId: bigint; + owner: Address; + expiry: number; +}> { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const result = await publicClient.readContract({ + address: publicClient.address, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'wrappedNameDetails', + outputs: [ + { name: 'tokenId', type: 'uint256' }, + { name: 'owner', type: 'address' }, + { name: 'expiry', type: 'uint256' }, + ], + stateMutability: 'view', + type: 'function', + }, + ], + functionName: 'wrappedNameDetails', + args: [namehash(normalizedEns)], + }); + return { + tokenId: result[0] as bigint, + owner: result[1] as Address, + expiry: Number(result[2]) + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get wrapped name details for "${name}". Reason: ${message} [Error Code: GetWrappedNameDetails_General_001]` + ); + } +} \ No newline at end of file From ee5eb7683125cd887d295f0276cf4beb2e8d91e5 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:34:30 -0700 Subject: [PATCH 04/57] feat(chains): Update chain configurations and network handling --- src/core/chains.ts | 454 ++++++++++++++++++--------------------------- 1 file changed, 183 insertions(+), 271 deletions(-) diff --git a/src/core/chains.ts b/src/core/chains.ts index 43bac2c..91ba928 100644 --- a/src/core/chains.ts +++ b/src/core/chains.ts @@ -32,7 +32,7 @@ import { aurora, canto, flowMainnet, - + // Testnets sepolia, optimismSepolia, @@ -42,10 +42,10 @@ import { avalancheFuji, bscTestnet, zksyncSepoliaTestnet, - lineaSepolia, - lumiaTestnet, - scrollSepolia, - mantleSepoliaTestnet, + lineaSepolia, // Added missing import + lumiaTestnet, // Added missing import + scrollSepolia, // Added missing import + mantleSepoliaTestnet, // Added missing import mantaSepoliaTestnet, blastSepolia, fraxtalTestnet, @@ -56,297 +56,209 @@ import { celoAlfajores, goerli, holesky, - flowTestnet + flowTestnet, } from 'viem/chains'; // Default configuration values -export const DEFAULT_RPC_URL = 'https://eth.llamarpc.com'; -export const DEFAULT_CHAIN_ID = 1; +export const DEFAULT_CHAIN_ID = mainnet.id; // Use mainnet ID directly +export const DEFAULT_RPC_URL = mainnet.rpcUrls.default.http[0]; // Use mainnet default RPC -// Map chain IDs to chains -export const chainMap: Record = { - // Mainnets - 1: mainnet, - 10: optimism, - 42161: arbitrum, - 42170: arbitrumNova, - 8453: base, - 137: polygon, - 1101: polygonZkEvm, - 43114: avalanche, - 56: bsc, - 324: zksync, - 59144: linea, - 42220: celo, - 100: gnosis, - 250: fantom, - 314: filecoin, - 1284: moonbeam, - 1285: moonriver, - 25: cronos, - 534352: scroll, - 5000: mantle, - 169: manta, - 994873017: lumiaMainnet, - 81457: blast, - 252: fraxtal, - 34443: mode, - 1088: metis, - 255: kroma, - 7777777: zora, - 1313161554: aurora, - 7700: canto, - 747: flowMainnet, - - // Testnets - 11155111: sepolia, - 11155420: optimismSepolia, - 421614: arbitrumSepolia, - 84532: baseSepolia, - 80002: polygonAmoy, - 43113: avalancheFuji, - 97: bscTestnet, - 300: zksyncSepoliaTestnet, - 59141: lineaSepolia, - 1952959480: lumiaTestnet, - 534351: scrollSepolia, - 5003: mantleSepoliaTestnet, - 3441006: mantaSepoliaTestnet, - 168587773: blastSepolia, - 2522: fraxtalTestnet, - 919: modeTestnet, - 59902: metisSepolia, - 2358: kromaSepolia, - 999999999: zoraSepolia, - 44787: celoAlfajores, - 5: goerli, - 17000: holesky, - 545: flowTestnet, -}; +// --- Chain Definitions --- +// Provides a single source of truth for chain configurations. -// Map network names to chain IDs for easier reference -export const networkNameMap: Record = { - // Mainnets - 'ethereum': 1, - 'mainnet': 1, - 'eth': 1, - 'optimism': 10, - 'op': 10, - 'arbitrum': 42161, - 'arb': 42161, - 'arbitrum-nova': 42170, - 'arbitrumnova': 42170, - 'base': 8453, - 'polygon': 137, - 'matic': 137, - 'polygon-zkevm': 1101, - 'polygonzkevm': 1101, - 'avalanche': 43114, - 'avax': 43114, - 'binance': 56, - 'bsc': 56, - 'zksync': 324, - 'linea': 59144, - 'celo': 42220, - 'gnosis': 100, - 'xdai': 100, - 'fantom': 250, - 'ftm': 250, - 'filecoin': 314, - 'fil': 314, - 'moonbeam': 1284, - 'moonriver': 1285, - 'cronos': 25, - 'scroll': 534352, - 'mantle': 5000, - 'manta': 169, - 'lumia': 994873017, - 'blast': 81457, - 'fraxtal': 252, - 'mode': 34443, - 'metis': 1088, - 'kroma': 255, - 'zora': 7777777, - 'aurora': 1313161554, - 'canto': 7700, - 'flow': 747, - - // Testnets - 'sepolia': 11155111, - 'optimism-sepolia': 11155420, - 'optimismsepolia': 11155420, - 'arbitrum-sepolia': 421614, - 'arbitrumsepolia': 421614, - 'base-sepolia': 84532, - 'basesepolia': 84532, - 'polygon-amoy': 80002, - 'polygonamoy': 80002, - 'avalanche-fuji': 43113, - 'avalanchefuji': 43113, - 'fuji': 43113, - 'bsc-testnet': 97, - 'bsctestnet': 97, - 'zksync-sepolia': 300, - 'zksyncsepolia': 300, - 'linea-sepolia': 59141, - 'lineasepolia': 59141, - 'lumia-testnet': 1952959480, - 'scroll-sepolia': 534351, - 'scrollsepolia': 534351, - 'mantle-sepolia': 5003, - 'mantlesepolia': 5003, - 'manta-sepolia': 3441006, - 'mantasepolia': 3441006, - 'blast-sepolia': 168587773, - 'blastsepolia': 168587773, - 'fraxtal-testnet': 2522, - 'fraxtaltestnet': 2522, - 'mode-testnet': 919, - 'modetestnet': 919, - 'metis-sepolia': 59902, - 'metissepolia': 59902, - 'kroma-sepolia': 2358, - 'kromasepolia': 2358, - 'zora-sepolia': 999999999, - 'zorasepolia': 999999999, - 'celo-alfajores': 44787, - 'celoalfajores': 44787, - 'alfajores': 44787, - 'goerli': 5, - 'holesky': 17000, - 'flow-testnet': 545, -}; +interface ChainDefinition { + viemChain: Chain; + networkNames: string[]; // Primary name first, followed by aliases + rpcUrlOverride?: string; // Optional override for the default RPC URL from viemChain +} -// Map chain IDs to RPC URLs -export const rpcUrlMap: Record = { +// Define all supported chains here +const supportedChains: ChainDefinition[] = [ // Mainnets - 1: 'https://eth.llamarpc.com', - 10: 'https://mainnet.optimism.io', - 42161: 'https://arb1.arbitrum.io/rpc', - 42170: 'https://nova.arbitrum.io/rpc', - 8453: 'https://mainnet.base.org', - 137: 'https://polygon-rpc.com', - 1101: 'https://zkevm-rpc.com', - 43114: 'https://api.avax.network/ext/bc/C/rpc', - 56: 'https://bsc-dataseed.binance.org', - 324: 'https://mainnet.era.zksync.io', - 59144: 'https://rpc.linea.build', - 42220: 'https://forno.celo.org', - 100: 'https://rpc.gnosischain.com', - 250: 'https://rpc.ftm.tools', - 314: 'https://api.node.glif.io/rpc/v1', - 1284: 'https://rpc.api.moonbeam.network', - 1285: 'https://rpc.api.moonriver.moonbeam.network', - 25: 'https://evm.cronos.org', - 534352: 'https://rpc.scroll.io', - 5000: 'https://rpc.mantle.xyz', - 169: 'https://pacific-rpc.manta.network/http', - 81457: 'https://rpc.blast.io', - 252: 'https://rpc.frax.com', - 994873017: 'https://mainnet-rpc.lumia.org', - 34443: 'https://mainnet.mode.network', - 1088: 'https://andromeda.metis.io/?owner=1088', - 255: 'https://api.kroma.network', - 7777777: 'https://rpc.zora.energy', - 1313161554: 'https://mainnet.aurora.dev', - 7700: 'https://canto.gravitychain.io', - 747: 'https://mainnet.evm.nodes.onflow.org', - + { viemChain: mainnet, networkNames: ['mainnet', 'ethereum', 'eth'] }, + { viemChain: optimism, networkNames: ['optimism', 'op'] }, + { viemChain: arbitrum, networkNames: ['arbitrum', 'arb'] }, + { viemChain: arbitrumNova, networkNames: ['arbitrum-nova', 'arbitrumnova'] }, + { viemChain: base, networkNames: ['base'] }, + { viemChain: polygon, networkNames: ['polygon', 'matic'] }, + { viemChain: polygonZkEvm, networkNames: ['polygon-zkevm', 'polygonzkevm'] }, + { viemChain: avalanche, networkNames: ['avalanche', 'avax'] }, + { viemChain: bsc, networkNames: ['bsc', 'binance'] }, + { viemChain: zksync, networkNames: ['zksync'] }, + { viemChain: linea, networkNames: ['linea'] }, + { viemChain: celo, networkNames: ['celo'] }, + { viemChain: gnosis, networkNames: ['gnosis', 'xdai'] }, + { viemChain: fantom, networkNames: ['fantom', 'ftm'] }, + { viemChain: filecoin, networkNames: ['filecoin', 'fil'] }, + { viemChain: moonbeam, networkNames: ['moonbeam'] }, + { viemChain: moonriver, networkNames: ['moonriver'] }, + { viemChain: cronos, networkNames: ['cronos'] }, + { viemChain: scroll, networkNames: ['scroll'] }, + { viemChain: mantle, networkNames: ['mantle'] }, + { viemChain: manta, networkNames: ['manta'] }, + { viemChain: lumiaMainnet, networkNames: ['lumia'], rpcUrlOverride: 'https://mainnet-rpc.lumia.org' }, // Example override + { viemChain: blast, networkNames: ['blast'] }, + { viemChain: fraxtal, networkNames: ['fraxtal'] }, + { viemChain: mode, networkNames: ['mode'] }, + { viemChain: metis, networkNames: ['metis'] }, + { viemChain: kroma, networkNames: ['kroma'] }, + { viemChain: zora, networkNames: ['zora'] }, + { viemChain: aurora, networkNames: ['aurora'] }, + { viemChain: canto, networkNames: ['canto'] }, + { viemChain: flowMainnet, networkNames: ['flow'] }, + // Testnets - 11155111: 'https://sepolia.drpc.org', - 11155420: 'https://sepolia.optimism.io', - 421614: 'https://sepolia-rpc.arbitrum.io/rpc', - 84532: 'https://sepolia.base.org', - 80002: 'https://rpc-amoy.polygon.technology', - 43113: 'https://api.avax-test.network/ext/bc/C/rpc', - 97: 'https://data-seed-prebsc-1-s1.binance.org:8545', - 300: 'https://sepolia.era.zksync.dev', - 59141: 'https://rpc.sepolia.linea.build', - 534351: 'https://sepolia-rpc.scroll.io', - 5003: 'https://rpc.sepolia.mantle.xyz', - 3441006: 'https://pacific-rpc.sepolia.manta.network/http', - 1952959480: 'https://testnet-rpc.lumia.org', - 168587773: 'https://sepolia.blast.io', - 2522: 'https://rpc.testnet.frax.com', - 919: 'https://sepolia.mode.network', - 59902: 'https://sepolia.metis.io/?owner=59902', - 2358: 'https://api.sepolia.kroma.network', - 999999999: 'https://sepolia.rpc.zora.energy', - 44787: 'https://alfajores-forno.celo-testnet.org', - 5: 'https://rpc.ankr.com/eth_goerli', - 17000: 'https://ethereum-holesky.publicnode.com', - 545: 'https://testnet.evm.nodes.onflow.org', -}; + { viemChain: sepolia, networkNames: ['sepolia'] }, + { viemChain: optimismSepolia, networkNames: ['optimism-sepolia', 'optimismsepolia'] }, + { viemChain: arbitrumSepolia, networkNames: ['arbitrum-sepolia', 'arbitrumsepolia'] }, + { viemChain: baseSepolia, networkNames: ['base-sepolia', 'basesepolia'] }, + { viemChain: polygonAmoy, networkNames: ['polygon-amoy', 'polygonamoy'] }, + { viemChain: avalancheFuji, networkNames: ['avalanche-fuji', 'avalanchefuji', 'fuji'] }, + { viemChain: bscTestnet, networkNames: ['bsc-testnet', 'bsctestnet'] }, + { viemChain: zksyncSepoliaTestnet, networkNames: ['zksync-sepolia', 'zksyncsepolia'] }, + { viemChain: lineaSepolia, networkNames: ['linea-sepolia', 'lineasepolia'] }, + { viemChain: lumiaTestnet, networkNames: ['lumia-testnet'], rpcUrlOverride: 'https://testnet-rpc.lumia.org' }, // Example override + { viemChain: scrollSepolia, networkNames: ['scroll-sepolia', 'scrollsepolia'] }, + { viemChain: mantleSepoliaTestnet, networkNames: ['mantle-sepolia', 'mantlesepolia'] }, + { viemChain: mantaSepoliaTestnet, networkNames: ['manta-sepolia', 'mantasepolia'] }, + { viemChain: blastSepolia, networkNames: ['blast-sepolia', 'blastsepolia'] }, + { viemChain: fraxtalTestnet, networkNames: ['fraxtal-testnet', 'fraxtaltestnet'] }, + { viemChain: modeTestnet, networkNames: ['mode-testnet', 'modetestnet'] }, + { viemChain: metisSepolia, networkNames: ['metis-sepolia', 'metissepolia'] }, + { viemChain: kromaSepolia, networkNames: ['kroma-sepolia', 'kromasepolia'] }, + { viemChain: zoraSepolia, networkNames: ['zora-sepolia', 'zorasepolia'] }, + { viemChain: celoAlfajores, networkNames: ['celo-alfajores', 'celoalfajores', 'alfajores'] }, + { viemChain: goerli, networkNames: ['goerli'] }, + { viemChain: holesky, networkNames: ['holesky'] }, + { viemChain: flowTestnet, networkNames: ['flow-testnet'] }, +]; + +// --- Derived Maps (Generated from supportedChains) --- + +// Map chain IDs to viem Chain objects +export const chainMap: Readonly> = Object.freeze( + supportedChains.reduce((map, { viemChain }) => { + map[viemChain.id] = viemChain; + return map; + }, {} as Record) +); + +// Map network names (lowercase) to chain IDs for easy lookup +export const networkNameMap: Readonly> = Object.freeze( + supportedChains.reduce((map, { viemChain, networkNames }) => { + networkNames.forEach(name => { + map[name.toLowerCase()] = viemChain.id; + }); + return map; + }, {} as Record) +); + +// Map chain IDs to their primary RPC URL (override or viem default) +export const rpcUrlMap: Readonly> = Object.freeze( + supportedChains.reduce((map, { viemChain, rpcUrlOverride }) => { + const defaultRpc = viemChain.rpcUrls.default?.http[0]; + // Prioritize override, then viem default. Fallback handled in getRpcUrl. + if (rpcUrlOverride) { + map[viemChain.id] = rpcUrlOverride; + } else if (defaultRpc) { + map[viemChain.id] = defaultRpc; + } + // If neither override nor viem default exists, it won't be added here. + // getRpcUrl will handle the final fallback to DEFAULT_RPC_URL. + return map; + }, {} as Record) +); + +// --- Helper Functions --- /** - * Resolves a chain identifier (number or string) to a chain ID - * @param chainIdentifier Chain ID (number) or network name (string) - * @returns The resolved chain ID + * Resolves a chain identifier (chain ID number or network name string) to a chain ID number. + * Throws an error if the identifier cannot be resolved. + * + * @param chainIdentifier - The chain ID (e.g., 1) or network name (e.g., 'mainnet', 'arbitrum-sepolia'). Case-insensitive for strings. + * @returns The resolved chain ID number. + * @throws {Error} If the chain identifier is invalid or unsupported. */ export function resolveChainId(chainIdentifier: number | string): number { if (typeof chainIdentifier === 'number') { - return chainIdentifier; - } - - // Convert to lowercase for case-insensitive matching - const networkName = chainIdentifier.toLowerCase(); - - // Check if the network name is in our map - if (networkName in networkNameMap) { - return networkNameMap[networkName]; - } - - // Try parsing as a number - const parsedId = parseInt(networkName); - if (!isNaN(parsedId)) { - return parsedId; + // Check if the numeric ID is actually supported + if (chainMap[chainIdentifier]) { + return chainIdentifier; + } else { + throw new Error(`Unsupported chain ID: ${chainIdentifier}`); + } } - - // Default to mainnet if not found - return DEFAULT_CHAIN_ID; -} -/** - * Returns the chain configuration for the specified chain ID or network name - * @param chainIdentifier Chain ID (number) or network name (string) - * @returns The chain configuration - * @throws Error if the network is not supported (when string is provided) - */ -export function getChain(chainIdentifier: number | string = DEFAULT_CHAIN_ID): Chain { if (typeof chainIdentifier === 'string') { const networkName = chainIdentifier.toLowerCase(); - // Try to get from direct network name mapping first - if (networkNameMap[networkName]) { - return chainMap[networkNameMap[networkName]] || mainnet; + const chainId = networkNameMap[networkName]; + if (chainId !== undefined) { + return chainId; + } + + // Allow string representation of valid chain IDs (e.g., "1") + const parsedId = parseInt(networkName, 10); + if (!isNaN(parsedId) && chainMap[parsedId]) { + return parsedId; } - - // If not found, throw an error - throw new Error(`Unsupported network: ${chainIdentifier}`); } - - // If it's a number, return the chain from chainMap - return chainMap[chainIdentifier] || mainnet; + + throw new Error(`Invalid or unsupported chain identifier: ${chainIdentifier}`); +} + +/** + * Retrieves the viem Chain object for a given chain identifier. + * Throws an error if the chain is not supported. + * + * @param chainIdentifier - The chain ID (number) or network name (string). + * @returns The viem Chain object. + * @throws {Error} If the chain identifier is invalid or unsupported. + */ +export function getChain(chainIdentifier: number | string): Chain { + const chainId = resolveChainId(chainIdentifier); // Will throw if invalid/unsupported + // We know chainId is valid and exists in chainMap because resolveChainId passed + return chainMap[chainId]; } /** - * Gets the appropriate RPC URL for the specified chain ID or network name - * @param chainIdentifier Chain ID (number) or network name (string) - * @returns The RPC URL for the specified chain + * Gets the recommended RPC URL for the specified chain identifier. + * Prioritizes overrides, then viem defaults, then the global default. + * Throws an error if the chain identifier is invalid or unsupported. + * + * @param chainIdentifier - The chain ID (number) or network name (string). + * @returns The RPC URL string. + * @throws {Error} If the chain identifier is invalid or unsupported. */ -export function getRpcUrl(chainIdentifier: number | string = DEFAULT_CHAIN_ID): string { - const chainId = typeof chainIdentifier === 'string' - ? resolveChainId(chainIdentifier) - : chainIdentifier; - - return rpcUrlMap[chainId] || DEFAULT_RPC_URL; +export function getRpcUrl(chainIdentifier: number | string): string { + const chainId = resolveChainId(chainIdentifier); // Will throw if invalid/unsupported + + // 1. Check our explicit rpcUrlMap (includes overrides and viem defaults if available) + if (rpcUrlMap[chainId]) { + return rpcUrlMap[chainId]; + } + + // 2. As a fallback, try the viem chain's default RPC again (might cover cases where rpcUrlMap generation missed it) + const chain = chainMap[chainId]; + const viemDefaultRpc = chain?.rpcUrls.default?.http[0]; + if (viemDefaultRpc) { + console.warn(`Using viem default RPC for chain ${chainId} as no specific URL was found in rpcUrlMap.`); + return viemDefaultRpc; + } + + // 3. Final fallback to the global default (should ideally not be reached for supported chains) + console.warn(`Using global default RPC URL for chain ${chainId} as no specific or viem default URL was found.`); + return DEFAULT_RPC_URL; } /** - * Get a list of supported networks - * @returns Array of supported network names (excluding short aliases) + * Gets a sorted list of primary supported network names. + * Excludes shorter aliases for brevity. + * + * @returns An array of primary network name strings. */ export function getSupportedNetworks(): string[] { - return Object.keys(networkNameMap) - .filter(name => name.length > 2) // Filter out short aliases - .sort(); -} + // Return the first name (primary) from each definition's networkNames array + return supportedChains + .map(def => def.networkNames[0]) // Get primary name + .sort(); // Sort alphabetically +} From ca349abc24327437f5efa068de91f85f2e2e4393 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:34:35 -0700 Subject: [PATCH 05/57] feat(prompts): Enhance user interaction prompts --- src/core/prompts.ts | 135 ++++++++++++++++++++++++++++++++------------ 1 file changed, 99 insertions(+), 36 deletions(-) diff --git a/src/core/prompts.ts b/src/core/prompts.ts index b7f7bce..acf5c01 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -10,16 +10,16 @@ export function registerEVMPrompts(server: McpServer) { server.prompt( "explore_block", "Explore information about a specific block", - { + z.object({ blockNumber: z.string().optional().describe("Block number to explore. If not provided, latest block will be used."), network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") - }, - ({ blockNumber, network = "ethereum" }) => ({ + }), + ({ blockNumber, network = "ethereum" }: { blockNumber?: string, network?: string }) => ({ messages: [{ role: "user", content: { type: "text", - text: blockNumber + text: blockNumber ? `Please analyze block #${blockNumber} on the ${network} network and provide information about its key metrics, transactions, and significance.` : `Please analyze the latest block on the ${network} network and provide information about its key metrics, transactions, and significance.` } @@ -31,11 +31,11 @@ export function registerEVMPrompts(server: McpServer) { server.prompt( "analyze_transaction", "Analyze a specific transaction", - { + z.object({ txHash: z.string().describe("Transaction hash to analyze"), network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") - }, - ({ txHash, network = "ethereum" }) => ({ + }), + ({ txHash, network = "ethereum" }: { txHash: string, network?: string }) => ({ messages: [{ role: "user", content: { @@ -50,16 +50,74 @@ export function registerEVMPrompts(server: McpServer) { server.prompt( "analyze_address", "Analyze an EVM address", - { - address: z.string().describe("Ethereum address to analyze"), + z.object({ + address: z.string().describe("Ethereum address or ENS name to analyze"), network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") - }, - ({ address, network = "ethereum" }) => ({ + }), + ({ address, network = "ethereum" }: { address: string, network?: string }) => ({ messages: [{ role: "user", content: { type: "text", - text: `Please analyze the address ${address} on the ${network} network. Provide information about its balance, transaction count, and any other relevant information you can find.` + text: `Please analyze the address ${address} (resolve if it's an ENS name) on the ${network} network. Provide information about its balance, transaction count, associated ENS name (if applicable), and any other relevant information you can find.` + } + }] + }) + ); + + // ENS Name Resolution Prompt + server.prompt( + "resolve_ens_name", + "Resolve an ENS name to an Ethereum address", + z.object({ + ensName: z.string().describe("The ENS name to resolve (e.g., 'vitalik.eth')"), + network: z.string().optional().describe("Network name (e.g., 'ethereum', 'goerli'). Defaults to Ethereum mainnet.") + }), + ({ ensName, network = "ethereum" }: { ensName: string, network?: string }) => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: `Please resolve the ENS name "${ensName}" to its corresponding Ethereum address on the ${network} network.` + } + }] + }) + ); + + // ENS Reverse Lookup Prompt (Address to Name) + server.prompt( + "lookup_ens_address", + "Find the primary ENS name associated with an Ethereum address", + z.object({ + address: z.string().describe("The Ethereum address to look up"), + network: z.string().optional().describe("Network name (e.g., 'ethereum', 'goerli'). Defaults to Ethereum mainnet.") + }), + ({ address, network = "ethereum" }: { address: string, network?: string }) => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: `Please find the primary ENS name associated with the Ethereum address ${address} on the ${network} network.` + } + }] + }) + ); + + // ENS Get Text Record Prompt + server.prompt( + "get_ens_text_record", + "Get a specific text record for an ENS name", + z.object({ + ensName: z.string().describe("The ENS name (e.g., 'vitalik.eth')"), + recordKey: z.string().describe("The text record key (e.g., 'avatar', 'url', 'com.github')"), + network: z.string().optional().describe("Network name (e.g., 'ethereum', 'goerli'). Defaults to Ethereum mainnet.") + }), + ({ ensName, recordKey, network = "ethereum" }: { ensName: string, recordKey: string, network?: string }) => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: `Please retrieve the text record with the key "${recordKey}" for the ENS name "${ensName}" on the ${network} network.` } }] }) @@ -69,19 +127,19 @@ export function registerEVMPrompts(server: McpServer) { server.prompt( "interact_with_contract", "Get guidance on interacting with a smart contract", - { - contractAddress: z.string().describe("The contract address"), + z.object({ + contractAddress: z.string().describe("The contract address or ENS name"), abiJson: z.string().optional().describe("The contract ABI as a JSON string"), network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }, - ({ contractAddress, abiJson, network = "ethereum" }) => ({ + }), + ({ contractAddress, abiJson, network = "ethereum" }: { contractAddress: string, abiJson?: string, network?: string }) => ({ messages: [{ role: "user", content: { type: "text", text: abiJson - ? `I need to interact with the smart contract at address ${contractAddress} on the ${network} network. Here's the ABI:\n\n${abiJson}\n\nPlease analyze this contract's functions and provide guidance on how to interact with it safely. Explain what each function does and what parameters it requires.` - : `I need to interact with the smart contract at address ${contractAddress} on the ${network} network. Please help me understand what this contract does and how I can interact with it safely.` + ? `I need to interact with the smart contract at address ${contractAddress} (resolve if ENS name) on the ${network} network. Here's the ABI:\n\n${abiJson}\n\nPlease analyze this contract's functions and provide guidance on how to interact with it safely. Explain what each function does and what parameters it requires.` + : `I need to interact with the smart contract at address ${contractAddress} (resolve if ENS name) on the ${network} network. Please help me understand what this contract does and how I can interact with it safely.` } }] }) @@ -91,10 +149,10 @@ export function registerEVMPrompts(server: McpServer) { server.prompt( "explain_evm_concept", "Get an explanation of an EVM concept", - { - concept: z.string().describe("The EVM concept to explain (e.g., gas, nonce, etc.)") - }, - ({ concept }) => ({ + z.object({ + concept: z.string().describe("The EVM concept to explain (e.g., gas, nonce, ENS, etc.)") + }), + ({ concept }: { concept: string }) => ({ messages: [{ role: "user", content: { @@ -109,17 +167,17 @@ export function registerEVMPrompts(server: McpServer) { server.prompt( "compare_networks", "Compare different EVM-compatible networks", - { + z.object({ networkList: z.string().describe("Comma-separated list of networks to compare (e.g., 'ethereum,optimism,arbitrum')") - }, - ({ networkList }) => { - const networks = networkList.split(',').map(n => n.trim()); + }), + ({ networkList }: { networkList: string }) => { + const networks = networkList.split(',').map((n: string) => n.trim()); return { messages: [{ role: "user", content: { type: "text", - text: `Please compare the following EVM-compatible networks: ${networks.join(', ')}. Include information about their architecture, gas fees, transaction speed, security, and any other relevant differences.` + text: `Please compare the following EVM-compatible networks: ${networks.join(', ')}. Include information about their architecture, gas fees, transaction speed, security, ENS support (if applicable), and any other relevant differences.` } }] }; @@ -130,23 +188,28 @@ export function registerEVMPrompts(server: McpServer) { server.prompt( "analyze_token", "Analyze an ERC20 or NFT token", - { - tokenAddress: z.string().describe("Token contract address to analyze"), + z.object({ + tokenAddress: z.string().describe("Token contract address or ENS name to analyze"), tokenType: z.string().optional().describe("Type of token to analyze (erc20, erc721/nft, or auto-detect). Defaults to auto."), tokenId: z.string().optional().describe("Token ID (required for NFT analysis)"), network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") - }, - ({ tokenAddress, tokenType = "auto", tokenId, network = "ethereum" }) => { + }), + ({ tokenAddress, tokenType = "auto", tokenId, network = "ethereum" }: { tokenAddress: string, tokenType?: string, tokenId?: string, network?: string }) => { let promptText = ""; - + const addressDesc = `address ${tokenAddress} (resolve if ENS name)`; + if (tokenType === "erc20" || tokenType === "auto") { - promptText = `Please analyze the ERC20 token at address ${tokenAddress} on the ${network} network. Provide information about its name, symbol, total supply, and any other relevant details. If possible, explain the token's purpose, utility, and market context.`; + promptText = `Please analyze the ERC20 token at ${addressDesc} on the ${network} network. Provide information about its name, symbol, total supply, and any other relevant details. If possible, explain the token's purpose, utility, and market context. If auto-detecting, confirm if it's an ERC20.`; } else if ((tokenType === "erc721" || tokenType === "nft") && tokenId) { - promptText = `Please analyze the NFT with token ID ${tokenId} from the collection at address ${tokenAddress} on the ${network} network. Provide information about the collection name, token details, ownership history if available, and any other relevant information about this specific NFT.`; + promptText = `Please analyze the NFT with token ID ${tokenId} from the collection at ${addressDesc} on the ${network} network. Provide information about the collection name, token details, ownership history if available, and any other relevant information about this specific NFT.`; } else if (tokenType === "nft" || tokenType === "erc721") { - promptText = `Please analyze the NFT collection at address ${tokenAddress} on the ${network} network. Provide information about the collection name, symbol, total supply if available, floor price if available, and any other relevant details about this NFT collection.`; + promptText = `Please analyze the NFT collection at ${addressDesc} on the ${network} network. Provide information about the collection name, symbol, total supply if available, floor price if available, and any other relevant details about this NFT collection.`; + } else if (tokenType === "auto" && tokenId) { + // Handle auto-detect case where tokenId is provided (likely NFT) + promptText = `Please analyze the token at ${addressDesc} on the ${network} network, likely an NFT with ID ${tokenId}. Determine the token type (ERC721 or other) and provide relevant details like collection name, token specifics, ownership, etc.`; } + return { messages: [{ role: "user", @@ -159,4 +222,4 @@ export function registerEVMPrompts(server: McpServer) { } ); -} \ No newline at end of file +} \ No newline at end of file From 08a1d58490aaab2c04b2a7a1b05924d7c13f3b8f Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:34:38 -0700 Subject: [PATCH 06/57] feat(balance): Improve balance checking service --- src/core/services/balance.ts | 244 +++++++++++++++++++++-------------- 1 file changed, 147 insertions(+), 97 deletions(-) diff --git a/src/core/services/balance.ts b/src/core/services/balance.ts index 0221b9d..17e60a8 100644 --- a/src/core/services/balance.ts +++ b/src/core/services/balance.ts @@ -1,10 +1,12 @@ -import { +import { formatEther, formatUnits, type Address, type Abi, - getContract + getContract, + type Chain, } from 'viem'; +import { mainnet } from 'viem/chains'; import { getPublicClient } from './clients.js'; import { readContract } from './contracts.js'; import { resolveAddress } from './ens.js'; @@ -16,22 +18,22 @@ const erc20Abi = [ name: 'symbol', outputs: [{ type: 'string' }], stateMutability: 'view', - type: 'function' + type: 'function', }, { inputs: [], name: 'decimals', outputs: [{ type: 'uint8' }], stateMutability: 'view', - type: 'function' + type: 'function', }, { inputs: [{ type: 'address', name: 'account' }], name: 'balanceOf', outputs: [{ type: 'uint256' }], stateMutability: 'view', - type: 'function' - } + type: 'function', + }, ] as const; // Standard ERC721 ABI (minimal for reading) @@ -41,15 +43,15 @@ const erc721Abi = [ name: 'balanceOf', outputs: [{ type: 'uint256' }], stateMutability: 'view', - type: 'function' + type: 'function', }, { inputs: [{ type: 'uint256', name: 'tokenId' }], name: 'ownerOf', outputs: [{ type: 'address' }], stateMutability: 'view', - type: 'function' - } + type: 'function', + }, ] as const; // Standard ERC1155 ABI (minimal for reading) @@ -57,139 +59,177 @@ const erc1155Abi = [ { inputs: [ { type: 'address', name: 'account' }, - { type: 'uint256', name: 'id' } + { type: 'uint256', name: 'id' }, ], name: 'balanceOf', outputs: [{ type: 'uint256' }], stateMutability: 'view', - type: 'function' - } + type: 'function', + }, ] as const; /** * Get the ETH balance for an address * @param addressOrEns Ethereum address or ENS name - * @param network Network name or chain ID + * @param network Network name or chain ID. Defaults to Ethereum mainnet. * @returns Balance in wei and ether */ export async function getETHBalance( - addressOrEns: string, - network = 'ethereum' + addressOrEns: string, + network: string | Chain = mainnet ): Promise<{ wei: bigint; ether: string }> { - // Resolve ENS name to address if needed - const address = await resolveAddress(addressOrEns, network); - - const client = getPublicClient(network); - const balance = await client.getBalance({ address }); - - return { - wei: balance, - ether: formatEther(balance) - }; + try { + // Resolve ENS name to address if needed + const address = await resolveAddress(addressOrEns, network); + + const client = getPublicClient(network); + const balance = await client.getBalance({ address }); + + return { + wei: balance, + ether: formatEther(balance), + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ETH balance for "${addressOrEns}". Reason: ${message} [Error Code: GetETHBalance_General_001]` + ); + } } /** * Get the balance of an ERC20 token for an address * @param tokenAddressOrEns Token contract address or ENS name * @param ownerAddressOrEns Owner address or ENS name - * @param network Network name or chain ID + * @param network Network name or chain ID. Defaults to Ethereum mainnet. * @returns Token balance with formatting information */ export async function getERC20Balance( tokenAddressOrEns: string, ownerAddressOrEns: string, - network = 'ethereum' + network: string | Chain = mainnet ): Promise<{ raw: bigint; formatted: string; token: { symbol: string; decimals: number; - } -}> { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network); - const ownerAddress = await resolveAddress(ownerAddressOrEns, network); - - const publicClient = getPublicClient(network); - - const contract = getContract({ - address: tokenAddress, - abi: erc20Abi, - client: publicClient, - }); - - const [balance, symbol, decimals] = await Promise.all([ - contract.read.balanceOf([ownerAddress]), - contract.read.symbol(), - contract.read.decimals() - ]); - - return { - raw: balance, - formatted: formatUnits(balance, decimals), - token: { - symbol, - decimals - } }; +}> { + try { + // Resolve ENS names to addresses if needed + const tokenAddress = await resolveAddress(tokenAddressOrEns, network); + const ownerAddress = await resolveAddress(ownerAddressOrEns, network); + + const publicClient = getPublicClient(network); + + const contract = getContract({ + address: tokenAddress, + abi: erc20Abi, + client: publicClient, + }); + + const [balance, symbol, decimals] = await Promise.all([ + contract.read.balanceOf([ownerAddress]), + contract.read.symbol(), + contract.read.decimals(), + ]); + + return { + raw: balance, + formatted: formatUnits(balance, decimals), + token: { + symbol, + decimals, + }, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ERC20 balance for token "${tokenAddressOrEns}" and owner "${ownerAddressOrEns}". Reason: ${message} [Error Code: GetERC20Balance_General_001]` + ); + } } /** - * Check if an address owns a specific NFT + * Check if an address owns a specific NFT (ERC721) * @param tokenAddressOrEns NFT contract address or ENS name * @param ownerAddressOrEns Owner address or ENS name * @param tokenId Token ID to check - * @param network Network name or chain ID + * @param network Network name or chain ID. Defaults to Ethereum mainnet. * @returns True if the address owns the NFT */ export async function isNFTOwner( tokenAddressOrEns: string, ownerAddressOrEns: string, tokenId: bigint, - network = 'ethereum' + network: string | Chain = mainnet ): Promise { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network); - const ownerAddress = await resolveAddress(ownerAddressOrEns, network); - try { - const actualOwner = await readContract({ - address: tokenAddress, - abi: erc721Abi, - functionName: 'ownerOf', - args: [tokenId] - }, network) as Address; - + // Resolve ENS names to addresses if needed + const tokenAddress = await resolveAddress(tokenAddressOrEns, network); + const ownerAddress = await resolveAddress(ownerAddressOrEns, network); + + const actualOwner = (await readContract( + { + address: tokenAddress, + abi: erc721Abi, + functionName: 'ownerOf', + args: [tokenId], + }, + network + )) as Address; + return actualOwner.toLowerCase() === ownerAddress.toLowerCase(); - } catch (error: any) { - console.error(`Error checking NFT ownership: ${error.message}`); - return false; + } catch (error: unknown) { + // Check if the error indicates the token doesn't exist or owner query reverted, + // which implies the ownerAddressOrEns is not the owner. + // Specific error messages/codes depend on the RPC provider and contract implementation. + // For now, re-throw a generic error, but this could be refined. + const message = error instanceof Error ? error.message : String(error); + // Example: A common error is 'ERC721: owner query for nonexistent token' + if (message.includes('nonexistent token')) { + return false; // Token doesn't exist, so the address can't be the owner. + } + // Consider logging the original error for debugging if needed: console.error(error); + throw new Error( + `Failed to check NFT ownership for token "${tokenAddressOrEns}", owner "${ownerAddressOrEns}", tokenId ${tokenId}. Reason: ${message} [Error Code: IsNFTOwner_General_001]` + ); } } /** - * Get the number of NFTs owned by an address for a specific collection + * Get the number of NFTs (ERC721) owned by an address for a specific collection * @param tokenAddressOrEns NFT contract address or ENS name * @param ownerAddressOrEns Owner address or ENS name - * @param network Network name or chain ID + * @param network Network name or chain ID. Defaults to Ethereum mainnet. * @returns Number of NFTs owned */ export async function getERC721Balance( tokenAddressOrEns: string, ownerAddressOrEns: string, - network = 'ethereum' + network: string | Chain = mainnet ): Promise { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network); - const ownerAddress = await resolveAddress(ownerAddressOrEns, network); - - return readContract({ - address: tokenAddress, - abi: erc721Abi, - functionName: 'balanceOf', - args: [ownerAddress] - }, network) as Promise; + try { + // Resolve ENS names to addresses if needed + const tokenAddress = await resolveAddress(tokenAddressOrEns, network); + const ownerAddress = await resolveAddress(ownerAddressOrEns, network); + + return (await readContract( + { + address: tokenAddress, + abi: erc721Abi, + functionName: 'balanceOf', + args: [ownerAddress], + }, + network + )) as Promise; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ERC721 balance for token "${tokenAddressOrEns}" and owner "${ownerAddressOrEns}". Reason: ${message} [Error Code: GetERC721Balance_General_001]` + ); + } } /** @@ -197,23 +237,33 @@ export async function getERC721Balance( * @param tokenAddressOrEns ERC1155 contract address or ENS name * @param ownerAddressOrEns Owner address or ENS name * @param tokenId Token ID to check - * @param network Network name or chain ID + * @param network Network name or chain ID. Defaults to Ethereum mainnet. * @returns Token balance */ export async function getERC1155Balance( tokenAddressOrEns: string, ownerAddressOrEns: string, tokenId: bigint, - network = 'ethereum' + network: string | Chain = mainnet ): Promise { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network); - const ownerAddress = await resolveAddress(ownerAddressOrEns, network); - - return readContract({ - address: tokenAddress, - abi: erc1155Abi, - functionName: 'balanceOf', - args: [ownerAddress, tokenId] - }, network) as Promise; -} \ No newline at end of file + try { + // Resolve ENS names to addresses if needed + const tokenAddress = await resolveAddress(tokenAddressOrEns, network); + const ownerAddress = await resolveAddress(ownerAddressOrEns, network); + + return (await readContract( + { + address: tokenAddress, + abi: erc1155Abi, + functionName: 'balanceOf', + args: [ownerAddress, tokenId], + }, + network + )) as Promise; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ERC1155 balance for token "${tokenAddressOrEns}", owner "${ownerAddressOrEns}", tokenId ${tokenId}. Reason: ${message} [Error Code: GetERC1155Balance_General_001]` + ); + } +} \ No newline at end of file From e5c4f13570df3de1049f7c20f991df153303264c Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:34:42 -0700 Subject: [PATCH 07/57] feat(blocks): Update block service functionality --- src/core/services/blocks.ts | 131 +++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 23 deletions(-) diff --git a/src/core/services/blocks.ts b/src/core/services/blocks.ts index aaf8eb9..9f1af28 100644 --- a/src/core/services/blocks.ts +++ b/src/core/services/blocks.ts @@ -1,43 +1,128 @@ -import { +import { type Hash, - type Block + type Block, + type Chain, // Added Chain type for better network parameter typing } from 'viem'; +import { mainnet } from 'viem/chains'; // Import mainnet for default chain object import { getPublicClient } from './clients.js'; /** - * Get the current block number for a specific network + * Retrieves the current block number for a specified network. + * + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the latest block number as a bigint. + * @throws Throws an error if fetching the block number fails, including network or client issues. + * @example + * const blockNumber = await getBlockNumber(); // Defaults to mainnet + * const polygonBlockNumber = await getBlockNumber('polygon'); + * const optimismBlockNumber = await getBlockNumber(optimism); // Using Chain object */ -export async function getBlockNumber(network = 'ethereum'): Promise { - const client = getPublicClient(network); - return await client.getBlockNumber(); +export async function getBlockNumber(network: string | Chain = mainnet): Promise { + try { + const client = getPublicClient(network); + return await client.getBlockNumber(); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + // Attempt to get a user-friendly network identifier + const networkIdentifier = typeof network === 'string' ? network : network.name ?? 'unknown'; + // Provide a more informative error message with context and an error code + throw new Error( + `Failed to get block number for network "${networkIdentifier}". Reason: ${message} [Error Code: GetBlockNumber_General_001]` + ); + } } /** - * Get a block by number for a specific network + * Retrieves a specific block by its number from a specified network. + * Note: viem's `getBlock` can return `null` if the block is not found. + * + * @param blockNumber - The block number to retrieve (as a bigint). + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the Block object, or null if the block is not found. + * @throws Throws an error if fetching the block fails for reasons other than not being found (e.g., network issues). + * @example + * const block = await getBlockByNumber(12345678n); // Mainnet block + * const polygonBlock = await getBlockByNumber(98765432n, 'polygon'); */ export async function getBlockByNumber( - blockNumber: number, - network = 'ethereum' -): Promise { - const client = getPublicClient(network); - return await client.getBlock({ blockNumber: BigInt(blockNumber) }); + blockNumber: bigint, // Changed type from number to bigint for consistency with viem + network: string | Chain = mainnet +): Promise { // Updated return type to reflect potential null return + try { + const client = getPublicClient(network); + // `getBlock` returns `Block | null` according to viem types + const block = await client.getBlock({ blockNumber }); + return block; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const networkIdentifier = typeof network === 'string' ? network : network.name ?? 'unknown'; + throw new Error( + `Failed to get block by number ${blockNumber} for network "${networkIdentifier}". Reason: ${message} [Error Code: GetBlockByNumber_General_001]` + ); + } } /** - * Get a block by hash for a specific network + * Retrieves a specific block by its hash from a specified network. + * Note: viem's `getBlock` can return `null` if the block is not found. + * + * @param blockHash - The hash of the block to retrieve. + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the Block object, or null if the block is not found. + * @throws Throws an error if fetching the block fails for reasons other than not being found (e.g., network issues). + * @example + * const block = await getBlockByHash('0x...'); // Mainnet block by hash + * const arbitrumBlock = await getBlockByHash('0x...', 'arbitrum'); */ export async function getBlockByHash( - blockHash: Hash, - network = 'ethereum' -): Promise { - const client = getPublicClient(network); - return await client.getBlock({ blockHash }); + blockHash: Hash, + network: string | Chain = mainnet +): Promise { // Updated return type to reflect potential null return + try { + const client = getPublicClient(network); + // `getBlock` returns `Block | null` according to viem types + const block = await client.getBlock({ blockHash }); + return block; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const networkIdentifier = typeof network === 'string' ? network : network.name ?? 'unknown'; + throw new Error( + `Failed to get block by hash ${blockHash} for network "${networkIdentifier}". Reason: ${message} [Error Code: GetBlockByHash_General_001]` + ); + } } /** - * Get the latest block for a specific network + * Retrieves the latest block from a specified network. + * + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the latest Block object. + * @throws Throws an error if fetching the latest block fails (e.g., network issues, node errors). + * @example + * const latestBlock = await getLatestBlock(); // Mainnet latest block + * const latestPolygonBlock = await getLatestBlock('polygon'); */ -export async function getLatestBlock(network = 'ethereum'): Promise { - const client = getPublicClient(network); - return await client.getBlock(); -} \ No newline at end of file +export async function getLatestBlock(network: string | Chain = mainnet): Promise { + try { + const client = getPublicClient(network); + // Fetch the latest block by calling getBlock without specific identifiers + const block = await client.getBlock(); + // Add a runtime check for robustness, although viem types suggest non-null for latest. + if (!block) { + const networkIdentifier = typeof network === 'string' ? network : network.name ?? 'unknown'; + // Throw a specific error if null is unexpectedly returned + throw new Error(`Received null instead of the latest block object for network "${networkIdentifier}". This might indicate an issue with the RPC node or network. [Error Code: GetLatestBlock_NullResult_001]`); + } + return block; + } catch (error: unknown) { + // Avoid re-wrapping the custom error thrown above + if (error instanceof Error && error.message.includes('[Error Code: GetLatestBlock_NullResult_001]')) { + throw error; + } + const message = error instanceof Error ? error.message : String(error); + const networkIdentifier = typeof network === 'string' ? network : network.name ?? 'unknown'; + throw new Error( + `Failed to get the latest block for network "${networkIdentifier}". Reason: ${message} [Error Code: GetLatestBlock_General_001]` + ); + } +} \ No newline at end of file From 366ca4b0168bdc8cbde3c4124f3276ffdccc5af9 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:34:46 -0700 Subject: [PATCH 08/57] feat(clients): Enhance client management --- src/core/services/clients.ts | 163 +++++++++++++++++++++++++---------- 1 file changed, 119 insertions(+), 44 deletions(-) diff --git a/src/core/services/clients.ts b/src/core/services/clients.ts index c74c125..6f5dc72 100644 --- a/src/core/services/clients.ts +++ b/src/core/services/clients.ts @@ -1,65 +1,140 @@ -import { - createPublicClient, - createWalletClient, - http, +import { + createPublicClient, + createWalletClient, + http, type PublicClient, type WalletClient, type Hex, - type Address + type Address, + type Chain, // Added Chain type } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; +import { mainnet } from 'viem/chains'; // Import mainnet for default chain import { getChain, getRpcUrl } from '../chains.js'; -// Cache for clients to avoid recreating them for each request -const clientCache = new Map(); +// Cache for public clients to avoid recreating them for each request +// Key: Network identifier (string name or chain ID) +// Value: PublicClient instance +const publicClientCache = new Map(); /** - * Get a public client for a specific network + * Retrieves a cached or creates a new public client for a specific network. + * Public clients are stateless and safe to reuse. + * + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A configured PublicClient instance for the specified network. + * @throws Throws an error if the network configuration cannot be resolved or the client fails to initialize. + * @example + * const mainnetClient = getPublicClient(); // Defaults to mainnet + * const polygonClient = getPublicClient('polygon'); + * const optimismClient = getPublicClient(optimism); // Using Chain object */ -export function getPublicClient(network = 'ethereum'): PublicClient { - const cacheKey = String(network); - +export function getPublicClient(network: string | Chain = mainnet): PublicClient { + // Determine a unique cache key based on network type + const cacheKey = typeof network === 'string' ? network : network.id; + // Return cached client if available - if (clientCache.has(cacheKey)) { - return clientCache.get(cacheKey)!; + if (publicClientCache.has(cacheKey)) { + return publicClientCache.get(cacheKey)!; + } + + try { + // Resolve chain and RPC URL - these functions should handle invalid network inputs + const chain = getChain(network); + const rpcUrl = getRpcUrl(network); // Assumes getRpcUrl can handle Chain object or string/ID + + if (!chain || !rpcUrl) { + // Added check for safety, although getChain/getRpcUrl should ideally throw + throw new Error(`Could not resolve chain or RPC URL for network: ${cacheKey}`); + } + + // Create a new public client + const client = createPublicClient({ + chain, + transport: http(rpcUrl), + // Consider adding batch options for performance if needed: + // batch: { multicall: true }, + }); + + // Cache the newly created client + publicClientCache.set(cacheKey, client); + + return client; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + // Provide a more informative error message + throw new Error( + `Failed to create public client for network "${cacheKey}". Reason: ${message} [Error Code: GetPublicClient_Init_001]` + ); } - - // Create a new client - const chain = getChain(network); - const rpcUrl = getRpcUrl(network); - - const client = createPublicClient({ - chain, - transport: http(rpcUrl) - }); - - // Cache the client - clientCache.set(cacheKey, client); - - return client; } /** - * Create a wallet client for a specific network and private key + * Creates a new wallet client for a specific network and private key. + * Wallet clients hold account state and are generally NOT cached/reused. + * Create a new one for each distinct operation or context requiring a signer. + * + * @param privateKey - The private key of the account in hex format (0x...). + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A configured WalletClient instance for the specified account and network. + * @throws Throws an error if the network configuration cannot be resolved, the private key is invalid, or the client fails to initialize. + * @example + * const walletClient = getWalletClient(process.env.PRIVATE_KEY); // Mainnet + * const polygonWallet = getWalletClient(process.env.PRIVATE_KEY, 'polygon'); */ -export function getWalletClient(privateKey: Hex, network = 'ethereum'): WalletClient { - const chain = getChain(network); - const rpcUrl = getRpcUrl(network); - const account = privateKeyToAccount(privateKey); - - return createWalletClient({ - account, - chain, - transport: http(rpcUrl) - }); +export function getWalletClient( + privateKey: Hex, + network: string | Chain = mainnet +): WalletClient { + try { + // Resolve chain and RPC URL + const chain = getChain(network); + const rpcUrl = getRpcUrl(network); // Assumes getRpcUrl can handle Chain object or string/ID + + if (!chain || !rpcUrl) { + const networkIdentifier = typeof network === 'string' ? network : network.id; + throw new Error(`Could not resolve chain or RPC URL for network: ${networkIdentifier}`); + } + + // Derive account from private key + const account = privateKeyToAccount(privateKey); + + // Create a new wallet client (not cached) + const walletClient = createWalletClient({ + account, + chain, + transport: http(rpcUrl), + }); + + return walletClient; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const networkIdentifier = typeof network === 'string' ? network : network.id ?? 'unknown'; + // Provide a more informative error message + throw new Error( + `Failed to create wallet client for network "${networkIdentifier}". Reason: ${message} [Error Code: GetWalletClient_Init_001]` + ); + } } /** - * Get an Ethereum address from a private key - * @param privateKey The private key in hex format (with or without 0x prefix) - * @returns The Ethereum address derived from the private key + * Derives an Ethereum address from a private key. + * + * @param privateKey - The private key in hex format (must start with 0x). + * @returns The corresponding Ethereum address (checksummed). + * @throws Throws an error if the private key is invalid. + * @example + * const address = getAddressFromPrivateKey('0x...'); */ export function getAddressFromPrivateKey(privateKey: Hex): Address { - const account = privateKeyToAccount(privateKey); - return account.address; -} \ No newline at end of file + try { + const account = privateKeyToAccount(privateKey); + return account.address; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + // Provide a more informative error message + throw new Error( + `Failed to derive address from private key. Reason: ${message} [Error Code: GetAddress_Derive_001]` + ); + } +} \ No newline at end of file From 7e7d3ef0c501927025f442caefa06bdf753cb9bb Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:34:51 -0700 Subject: [PATCH 09/57] feat(contracts): Update contract interaction service --- src/core/services/contracts.ts | 169 +++++++++++++++++++++++++++------ 1 file changed, 138 insertions(+), 31 deletions(-) diff --git a/src/core/services/contracts.ts b/src/core/services/contracts.ts index faebb67..9f6586f 100644 --- a/src/core/services/contracts.ts +++ b/src/core/services/contracts.ts @@ -1,53 +1,160 @@ -import { - type Address, - type Hash, +import { + type Address, + type Hash, type Hex, type ReadContractParameters, + type WriteContractParameters, // Added for type safety type GetLogsParameters, - type Log + type Log, + type Chain, // Added for network parameter consistency } from 'viem'; +import { mainnet } from 'viem/chains'; // Import mainnet for default chain import { getPublicClient, getWalletClient } from './clients.js'; import { resolveAddress } from './ens.js'; /** - * Read from a contract for a specific network + * Reads data from a smart contract on a specified network using a public client. + * Wraps viem's `readContract` with network selection and error handling. + * + * @param params - Parameters for the contract read operation (abi, address, functionName, args, etc.). + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the result of the contract read. + * @throws Throws an error if the read operation fails, including network or contract issues. + * @template TAbi - The ABI of the contract. + * @template TFunctionName - The name of the function to call. + * @example + * const name = await readContract({ address: '0x...', abi: erc20Abi, functionName: 'name' }); + * const balance = await readContract({ address: '0x...', abi: erc20Abi, functionName: 'balanceOf', args: ['0x...'] }, 'polygon'); */ -export async function readContract(params: ReadContractParameters, network = 'ethereum') { - const client = getPublicClient(network); - return await client.readContract(params); +export async function readContract< + const TAbi extends ReadContractParameters['abi'], // Use const TAbi for better inference + TFunctionName extends ReadContractParameters['functionName'] +>( + params: ReadContractParameters, + network: string | Chain = mainnet +): Promise { // Return type depends on the contract function, 'any' is a safe default here + try { + const client = getPublicClient(network); + // Type assertion needed because viem's client.readContract expects specific generics + // which are hard to pass down perfectly without making this function generic itself in a complex way. + // The ReadContractParameters type ensures the structure is correct. + return await client.readContract(params as ReadContractParameters); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const networkIdentifier = typeof network === 'string' ? network : network.name ?? 'unknown'; + const contractAddress = params.address ?? 'unknown address'; + const functionName = params.functionName ?? 'unknown function'; + // Provide a more informative error message + throw new Error( + `Failed to read contract ${contractAddress} function ${functionName} on network "${networkIdentifier}". Reason: ${message} [Error Code: ReadContract_General_001]` + ); + } } /** - * Write to a contract for a specific network + * Sends a transaction to write data to a smart contract on a specified network using a wallet client. + * Wraps viem's `writeContract` with network selection, private key handling, and error handling. + * + * @param privateKey - The private key of the account sending the transaction. + * @param params - Parameters for the contract write operation (abi, address, functionName, args, value, etc.). + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the transaction hash (Hash). + * @throws Throws an error if the write operation fails, including network, signing, or contract issues. + * @template TAbi - The ABI of the contract. + * @template TFunctionName - The name of the function to call. + * @example + * const txHash = await writeContract(process.env.PRIVATE_KEY, { address: '0x...', abi: erc20Abi, functionName: 'transfer', args: ['0x...', 100n] }); */ -export async function writeContract( - privateKey: Hex, - params: Record, - network = 'ethereum' +export async function writeContract< + const TAbi extends WriteContractParameters['abi'], // Use const TAbi for better inference + TFunctionName extends WriteContractParameters['functionName'] +>( + privateKey: Hex, + params: WriteContractParameters, // Use WriteContractParameters for type safety + network: string | Chain = mainnet ): Promise { - const client = getWalletClient(privateKey, network); - return await client.writeContract(params as any); + try { + // The wallet client is derived from the private key and handles the account details + const client = getWalletClient(privateKey, network); + // Type assertion needed similar to readContract. WriteContractParameters ensures structure. + return await client.writeContract(params as WriteContractParameters); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const networkIdentifier = typeof network === 'string' ? network : network.name ?? 'unknown'; + const contractAddress = params.address ?? 'unknown address'; + const functionName = params.functionName ?? 'unknown function'; + // Provide a more informative error message + throw new Error( + `Failed to write contract ${contractAddress} function ${functionName} on network "${networkIdentifier}". Reason: ${message} [Error Code: WriteContract_General_001]` + ); + } } /** - * Get logs for a specific network + * Retrieves event logs from a specified network based on the provided filters. + * Wraps viem's `getLogs` with network selection and error handling. + * + * @param params - Parameters for filtering logs (address, event, args, fromBlock, toBlock, etc.). + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to an array of Log objects. + * @throws Throws an error if fetching logs fails, including network or filter issues. + * @example + * const transferLogs = await getLogs({ address: '0x...', event: parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 value)'), fromBlock: 'latest' }); */ -export async function getLogs(params: GetLogsParameters, network = 'ethereum'): Promise { - const client = getPublicClient(network); - return await client.getLogs(params); +export async function getLogs( + params: GetLogsParameters, + network: string | Chain = mainnet +): Promise { + try { + const client = getPublicClient(network); + return await client.getLogs(params); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const networkIdentifier = typeof network === 'string' ? network : network.name ?? 'unknown'; + // Provide a more informative error message + throw new Error( + `Failed to get logs on network "${networkIdentifier}". Filters: ${JSON.stringify(params)}. Reason: ${message} [Error Code: GetLogs_General_001]` + ); + } } /** - * Check if an address is a contract - * @param addressOrEns Address or ENS name to check - * @param network Network name or chain ID - * @returns True if the address is a contract, false if it's an EOA + * Checks if a given address corresponds to a smart contract on a specified network by checking its bytecode. + * + * @param addressOrEns - The address or ENS name to check. + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to `true` if the address has bytecode (is a contract), `false` otherwise (is an EOA or doesn't exist). + * @throws Throws an error if resolving the address or fetching bytecode fails. + * @example + * const isDaiContract = await isContract('dai.eth'); + * const isEoa = await isContract('0x...'); // Check an Externally Owned Account */ -export async function isContract(addressOrEns: string, network = 'ethereum'): Promise { - // Resolve ENS name to address if needed - const address = await resolveAddress(addressOrEns, network); - - const client = getPublicClient(network); - const code = await client.getBytecode({ address }); - return code !== undefined && code !== '0x'; -} \ No newline at end of file +export async function isContract( + addressOrEns: string, + network: string | Chain = mainnet +): Promise { + let address: Address; + const networkIdentifier = typeof network === 'string' ? network : network.name ?? 'unknown'; + try { + // Resolve ENS name to address if needed, handle potential resolution errors separately + address = await resolveAddress(addressOrEns, network); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to resolve address/ENS name "${addressOrEns}" on network "${networkIdentifier}". Reason: ${message} [Error Code: IsContract_Resolve_001]` + ); + } + + try { + const client = getPublicClient(network); + const code = await client.getBytecode({ address }); + // A contract has bytecode, an EOA has '0x' or undefined/null if the address doesn't exist. + return !!code && code !== '0x'; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + // Provide a more informative error message for bytecode fetching failure + throw new Error( + `Failed to check bytecode for address "${address}" (resolved from "${addressOrEns}") on network "${networkIdentifier}". Reason: ${message} [Error Code: IsContract_GetBytecode_001]` + ); + } +} \ No newline at end of file From c0b0ac65355a39093aecf39b25c2649f3b47169c Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:34:55 -0700 Subject: [PATCH 10/57] feat(services): Update service exports and organization --- src/core/services/index.ts | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/core/services/index.ts b/src/core/services/index.ts index f7da50a..a3c067d 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -1,20 +1,20 @@ -// Export all services -export * from './clients.js'; -export * from './balance.js'; -export * from './transfer.js'; -export * from './blocks.js'; -export * from './transactions.js'; -export * from './contracts.js'; -export * from './tokens.js'; -export * from './ens.js'; -export { utils as helpers } from './utils.js'; + // Export all services (alphabetized) + export * from './balance.js'; + export * from './blocks.js'; + export * from './clients.js'; + export * from './contracts.js'; + export * from './ens.js'; + export * from './tokens.js'; + export * from './transactions.js'; + export * from './transfer.js'; + export { utils as helpers } from './utils.js'; -// Re-export common types for convenience -export type { - Address, - Hash, - Hex, - Block, - TransactionReceipt, - Log -} from 'viem'; \ No newline at end of file + // Re-export common types for convenience (alphabetized) + export type { + Address, + Block, + Hash, + Hex, + Log, + TransactionReceipt, + } from 'viem'; \ No newline at end of file From b2f8a5931f57b08f80883133ef499b9cdbe3b3b4 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:35:00 -0700 Subject: [PATCH 11/57] feat(tokens): Enhance token service functionality --- src/core/services/tokens.ts | 188 +++++++++++++++++++++++------------- 1 file changed, 123 insertions(+), 65 deletions(-) diff --git a/src/core/services/tokens.ts b/src/core/services/tokens.ts index e0d8306..0f11942 100644 --- a/src/core/services/tokens.ts +++ b/src/core/services/tokens.ts @@ -1,10 +1,12 @@ -import { - type Address, - type Hex, - type Hash, +import { + type Address, + // type Hex, // Removed if unused locally, keep if used elsewhere in the file + // type Hash, // Removed if unused locally, keep if used elsewhere in the file + type Chain, // Added Chain type for network parameter formatUnits, - getContract + getContract, } from 'viem'; +import { mainnet } from 'viem/chains'; // Import mainnet for default chain import { getPublicClient } from './clients.js'; // Standard ERC20 ABI (minimal for reading) @@ -14,29 +16,29 @@ const erc20Abi = [ name: 'name', outputs: [{ type: 'string' }], stateMutability: 'view', - type: 'function' + type: 'function', }, { inputs: [], name: 'symbol', outputs: [{ type: 'string' }], stateMutability: 'view', - type: 'function' + type: 'function', }, { inputs: [], name: 'decimals', outputs: [{ type: 'uint8' }], stateMutability: 'view', - type: 'function' + type: 'function', }, { inputs: [], name: 'totalSupply', outputs: [{ type: 'uint256' }], stateMutability: 'view', - type: 'function' - } + type: 'function', + }, ] as const; // Standard ERC721 ABI (minimal for reading) @@ -46,22 +48,22 @@ const erc721Abi = [ name: 'name', outputs: [{ type: 'string' }], stateMutability: 'view', - type: 'function' + type: 'function', }, { inputs: [], name: 'symbol', outputs: [{ type: 'string' }], stateMutability: 'view', - type: 'function' + type: 'function', }, { inputs: [{ type: 'uint256', name: 'tokenId' }], name: 'tokenURI', outputs: [{ type: 'string' }], stateMutability: 'view', - type: 'function' - } + type: 'function', + }, ] as const; // Standard ERC1155 ABI (minimal for reading) @@ -71,16 +73,24 @@ const erc1155Abi = [ name: 'uri', outputs: [{ type: 'string' }], stateMutability: 'view', - type: 'function' - } + type: 'function', + }, ] as const; /** - * Get ERC20 token information + * Retrieves core information for an ERC20 token contract. + * + * @param tokenAddress - The address of the ERC20 token contract. + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to an object containing the token's name, symbol, decimals, total supply (raw and formatted). + * @throws Throws an error if fetching token information fails (e.g., network issues, invalid address, contract doesn't support standard ERC20 functions). + * @example + * const usdcInfo = await getERC20TokenInfo('0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'); // Mainnet USDC + * const daiInfo = await getERC20TokenInfo('0x6B175474E89094C44Da98b954EedeAC495271d0F', mainnet); */ export async function getERC20TokenInfo( tokenAddress: Address, - network: string = 'ethereum' + network: string | Chain = mainnet ): Promise<{ name: string; symbol: string; @@ -88,78 +98,126 @@ export async function getERC20TokenInfo( totalSupply: bigint; formattedTotalSupply: string; }> { - const publicClient = getPublicClient(network); + const networkIdentifier = typeof network === 'string' ? network : network.name ?? `Chain ID ${network.id}`; + try { + const publicClient = getPublicClient(network); - const contract = getContract({ - address: tokenAddress, - abi: erc20Abi, - client: publicClient, - }); + const contract = getContract({ + address: tokenAddress, + abi: erc20Abi, + client: publicClient, + }); - const [name, symbol, decimals, totalSupply] = await Promise.all([ - contract.read.name(), - contract.read.symbol(), - contract.read.decimals(), - contract.read.totalSupply() - ]); + // Fetch all properties concurrently + const [name, symbol, decimals, totalSupply] = await Promise.all([ + contract.read.name(), + contract.read.symbol(), + contract.read.decimals(), + contract.read.totalSupply(), + ]); - return { - name, - symbol, - decimals, - totalSupply, - formattedTotalSupply: formatUnits(totalSupply, decimals) - }; + return { + name, + symbol, + decimals, + totalSupply, + formattedTotalSupply: formatUnits(totalSupply, decimals), + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ERC20 token info for ${tokenAddress} on network "${networkIdentifier}". Reason: ${message} [Error Code: GetERC20Info_General_001]` + ); + } } /** - * Get ERC721 token metadata + * Retrieves metadata for a specific ERC721 token (NFT). + * + * @param tokenAddress - The address of the ERC721 token contract. + * @param tokenId - The ID of the specific token. + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to an object containing the collection's name, symbol, and the specific token's URI. + * @throws Throws an error if fetching token metadata fails (e.g., network issues, invalid address/tokenId, contract doesn't support standard ERC721 functions). + * @example + * const baycMetadata = await getERC721TokenMetadata('0xBC4CA0EdA7647A8aB7C2061c2E118A18a936f13D', 1n); // BAYC #1 on Mainnet + * const punkMetadata = await getERC721TokenMetadata('0xb47e3cd837dDF8e4c57F05d70Ab865de6e193BBB', 100n, mainnet); // CryptoPunk #100 */ export async function getERC721TokenMetadata( tokenAddress: Address, tokenId: bigint, - network: string = 'ethereum' + network: string | Chain = mainnet ): Promise<{ name: string; symbol: string; tokenURI: string; }> { - const publicClient = getPublicClient(network); + const networkIdentifier = typeof network === 'string' ? network : network.name ?? `Chain ID ${network.id}`; + try { + const publicClient = getPublicClient(network); - const contract = getContract({ - address: tokenAddress, - abi: erc721Abi, - client: publicClient, - }); + const contract = getContract({ + address: tokenAddress, + abi: erc721Abi, + client: publicClient, + }); - const [name, symbol, tokenURI] = await Promise.all([ - contract.read.name(), - contract.read.symbol(), - contract.read.tokenURI([tokenId]) - ]); + // Fetch all properties concurrently + const [name, symbol, tokenURI] = await Promise.all([ + contract.read.name(), + contract.read.symbol(), + contract.read.tokenURI([tokenId]), // Pass tokenId as an array element + ]); - return { - name, - symbol, - tokenURI - }; + return { + name, + symbol, + tokenURI, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ERC721 token metadata for token ID ${tokenId} at ${tokenAddress} on network "${networkIdentifier}". Reason: ${message} [Error Code: GetERC721Metadata_General_001]` + ); + } } /** - * Get ERC1155 token URI + * Retrieves the URI for a specific ERC1155 token type. + * Note: ERC1155 contracts often don't have collection-wide 'name' or 'symbol' functions. + * The URI typically points to a JSON file containing metadata for that token ID. + * + * @param tokenAddress - The address of the ERC1155 token contract. + * @param tokenId - The ID of the specific token type. + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the token's URI string. + * @throws Throws an error if fetching the token URI fails (e.g., network issues, invalid address/tokenId, contract doesn't support the standard ERC1155 'uri' function). + * @example + * const ensTokenURI = await getERC1155TokenURI('0x57f1887a8BF19b14fC0dF6Fd9B2acc9Af147eA85', 12345n); // Example ENS (.eth) token on Mainnet + * const gameItemURI = await getERC1155TokenURI('0x...', 99n, 'polygon'); */ export async function getERC1155TokenURI( tokenAddress: Address, tokenId: bigint, - network: string = 'ethereum' + network: string | Chain = mainnet ): Promise { - const publicClient = getPublicClient(network); + const networkIdentifier = typeof network === 'string' ? network : network.name ?? `Chain ID ${network.id}`; + try { + const publicClient = getPublicClient(network); - const contract = getContract({ - address: tokenAddress, - abi: erc1155Abi, - client: publicClient, - }); + const contract = getContract({ + address: tokenAddress, + abi: erc1155Abi, + client: publicClient, + }); - return contract.read.uri([tokenId]); -} \ No newline at end of file + // Fetch the URI + const uri = await contract.read.uri([tokenId]); // Pass tokenId as an array element + return uri; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ERC1155 token URI for token ID ${tokenId} at ${tokenAddress} on network "${networkIdentifier}". Reason: ${message} [Error Code: GetERC1155URI_General_001]` + ); + } +} \ No newline at end of file From 7efd0c08975422896c2349b6a397e29ee8e0798a Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:35:04 -0700 Subject: [PATCH 12/57] feat(transactions): Major update to transaction service --- src/core/services/transactions.ts | 165 +++++++++++++++++++++++++----- 1 file changed, 138 insertions(+), 27 deletions(-) diff --git a/src/core/services/transactions.ts b/src/core/services/transactions.ts index 52954e6..8d8e4d5 100644 --- a/src/core/services/transactions.ts +++ b/src/core/services/transactions.ts @@ -1,49 +1,160 @@ -import { - type Address, - type Hash, +import { + type Address, + type Hash, + type Transaction, // Added Transaction type for return value type TransactionReceipt, - type EstimateGasParameters + type EstimateGasParameters, + type Chain, // Added Chain type for network parameter consistency } from 'viem'; +import { mainnet } from 'viem/chains'; // Import mainnet for default chain import { getPublicClient } from './clients.js'; +// Helper function for consistent network identifier in errors +function getNetworkIdentifier(network: string | Chain): string { + return typeof network === 'string' ? network : network.name ?? `Chain ID ${network.id}`; +} + /** - * Get a transaction by hash for a specific network + * Retrieves a transaction by its hash from a specified network. + * Note: viem's `getTransaction` can return `null` if the transaction is not found or not yet mined. + * + * @param hash - The hash of the transaction to retrieve. + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the Transaction object, or null if not found. + * @throws Throws an error if fetching the transaction fails for reasons other than not being found (e.g., network issues). + * @example + * const tx = await getTransaction('0x...'); // Mainnet transaction + * const polygonTx = await getTransaction('0x...', 'polygon'); */ -export async function getTransaction(hash: Hash, network = 'ethereum') { - const client = getPublicClient(network); - return await client.getTransaction({ hash }); +export async function getTransaction( + hash: Hash, + network: string | Chain = mainnet +): Promise { // Updated return type + const networkIdentifier = getNetworkIdentifier(network); + try { + const client = getPublicClient(network); + // `getTransaction` returns `Transaction | null` according to viem types + const transaction = await client.getTransaction({ hash }); + return transaction; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get transaction ${hash} on network "${networkIdentifier}". Reason: ${message} [Error Code: GetTransaction_General_001]` + ); + } } /** - * Get a transaction receipt by hash for a specific network + * Retrieves the receipt of a transaction by its hash from a specified network. + * Waits for the transaction to be mined if it hasn't been already. + * Note: viem's `getTransactionReceipt` can return `null` if the transaction is not found (e.g., hash is incorrect). + * + * @param hash - The hash of the transaction. + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the TransactionReceipt object, or null if not found. + * @throws Throws an error if fetching the transaction receipt fails (e.g., network issues, timeout). + * @example + * const receipt = await getTransactionReceipt('0x...'); // Mainnet receipt + * const polygonReceipt = await getTransactionReceipt('0x...', 'polygon'); */ -export async function getTransactionReceipt(hash: Hash, network = 'ethereum'): Promise { - const client = getPublicClient(network); - return await client.getTransactionReceipt({ hash }); +export async function getTransactionReceipt( + hash: Hash, + network: string | Chain = mainnet +): Promise { // Updated return type + const networkIdentifier = getNetworkIdentifier(network); + try { + const client = getPublicClient(network); + // `getTransactionReceipt` returns `TransactionReceipt | null` according to viem types + const receipt = await client.getTransactionReceipt({ hash }); + return receipt; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get transaction receipt for ${hash} on network "${networkIdentifier}". Reason: ${message} [Error Code: GetTransactionReceipt_General_001]` + ); + } } /** - * Get the transaction count for an address for a specific network + * Retrieves the number of transactions sent from an address (nonce) on a specific network. + * + * @param address - The address to get the transaction count for. + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the transaction count (nonce) as a number. + * @throws Throws an error if fetching the transaction count fails (e.g., network issues, invalid address). + * @example + * const nonce = await getTransactionCount('0x...'); // Mainnet nonce + * const polygonNonce = await getTransactionCount('0x...', 'polygon'); */ -export async function getTransactionCount(address: Address, network = 'ethereum'): Promise { - const client = getPublicClient(network); - const count = await client.getTransactionCount({ address }); - return Number(count); +export async function getTransactionCount( + address: Address, + network: string | Chain = mainnet +): Promise { + const networkIdentifier = getNetworkIdentifier(network); + try { + const client = getPublicClient(network); + // `getTransactionCount` returns `number` directly + const count = await client.getTransactionCount({ address }); + return count; // No need for Number() conversion + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get transaction count for address ${address} on network "${networkIdentifier}". Reason: ${message} [Error Code: GetTransactionCount_General_001]` + ); + } } /** - * Estimate gas for a transaction for a specific network + * Estimates the gas required for a transaction on a specific network. + * + * @param params - Parameters for the gas estimation, including `account`, `to`, `data`, `value`, etc. + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the estimated gas amount as a bigint. + * @throws Throws an error if gas estimation fails (e.g., transaction would revert, network issues, insufficient funds). + * @example + * const estimatedGas = await estimateGas({ account: '0x...', to: '0x...', value: parseEther('1') }); // Mainnet estimate + * const polygonGas = await estimateGas({ account: '0x...', to: '0x...', data: '0x...' }, 'polygon'); */ -export async function estimateGas(params: EstimateGasParameters, network = 'ethereum'): Promise { - const client = getPublicClient(network); - return await client.estimateGas(params); +export async function estimateGas( + params: EstimateGasParameters, + network: string | Chain = mainnet +): Promise { + const networkIdentifier = getNetworkIdentifier(network); + try { + const client = getPublicClient(network); + return await client.estimateGas(params); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + // Include relevant params in error if possible/safe (avoid logging private keys if `account` is an object) + const toAddress = params.to ? `to ${params.to}` : ''; + const fromAddress = typeof params.account === 'string' ? `from ${params.account}` : (params.account?.address ? `from ${params.account.address}` : ''); + throw new Error( + `Failed to estimate gas ${fromAddress} ${toAddress} on network "${networkIdentifier}". Reason: ${message} [Error Code: EstimateGas_General_001]` + ); + } } /** - * Get the chain ID for a specific network + * Retrieves the chain ID of the connected network client. + * + * @param network - The target blockchain network (name, chain ID, or Chain object). Defaults to Ethereum mainnet. + * @returns A Promise resolving to the chain ID as a number. + * @throws Throws an error if fetching the chain ID fails (e.g., network issues). + * @example + * const chainId = await getChainId(); // Mainnet chain ID + * const polygonChainId = await getChainId('polygon'); */ -export async function getChainId(network = 'ethereum'): Promise { - const client = getPublicClient(network); - const chainId = await client.getChainId(); - return Number(chainId); -} \ No newline at end of file +export async function getChainId(network: string | Chain = mainnet): Promise { + const networkIdentifier = getNetworkIdentifier(network); + try { + const client = getPublicClient(network); + // `getChainId` returns `number` directly + const chainId = await client.getChainId(); + return chainId; // No need for Number() conversion + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get chain ID for network "${networkIdentifier}". Reason: ${message} [Error Code: GetChainId_General_001]` + ); + } +} \ No newline at end of file From e845319d980187bdc1bdb132fe5953f181f99fe1 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:35:09 -0700 Subject: [PATCH 13/57] feat(transfer): Major refactor of transfer service --- src/core/services/transfer.ts | 627 +++++++++++++++++++++------------- 1 file changed, 385 insertions(+), 242 deletions(-) diff --git a/src/core/services/transfer.ts b/src/core/services/transfer.ts index 06f8f75..67d8e83 100644 --- a/src/core/services/transfer.ts +++ b/src/core/services/transfer.ts @@ -1,18 +1,19 @@ -import { +import { parseEther, parseUnits, - formatUnits, - type Address, - type Hash, + type Address, + type Hash, type Hex, - type Abi, getContract, - type Account + type PublicClient, // Added for type clarity + type WalletClient, // Added for type clarity } from 'viem'; import { getPublicClient, getWalletClient } from './clients.js'; -import { getChain } from '../chains.js'; +// Removed unused getChain import import { resolveAddress } from './ens.js'; +// --- ABIs remain unchanged --- + // Standard ERC20 ABI for transfers const erc20TransferAbi = [ { @@ -114,13 +115,66 @@ const erc1155TransferAbi = [ } ] as const; + +// --- Helper Functions --- + +/** + * Ensures the private key string starts with '0x'. + * @param key Private key as string or Hex. + * @returns Private key as Hex. + */ +const ensureHexPrefix = (key: string | Hex): Hex => { + if (typeof key === 'string' && !key.startsWith('0x')) { + return `0x${key}` as Hex; + } + return key as Hex; // Already Hex or starts with 0x +}; + /** - * Transfer ETH to an address - * @param privateKey Sender's private key - * @param toAddressOrEns Recipient address or ENS name - * @param amount Amount to send in ETH - * @param network Network name or chain ID - * @returns Transaction hash + * Resolves multiple addresses or ENS names concurrently. + * @param addresses An object mapping labels to addresses/ENS names. + * @param network Network name or chain ID. + * @returns A promise resolving to an object mapping labels to resolved Addresses. + * @throws If any address/ENS name fails to resolve. + */ +async function resolveMultipleAddresses( + addresses: Record, + network: string +): Promise> { + const resolved: Record = {}; + const promises: Promise[] = []; + const errors: string[] = []; + + for (const label in addresses) { + promises.push( + resolveAddress(addresses[label], network) + .then(addr => { resolved[label] = addr; }) + .catch(err => { + errors.push(`Failed to resolve ${label} ('${addresses[label]}'): ${err.message}`); + }) + ); + } + + await Promise.all(promises); + + if (errors.length > 0) { + throw new Error(`Address resolution failed:\n- ${errors.join('\n- ')}`); + } + + return resolved; +} + + +// --- Transfer Functions --- + +/** + * Transfer ETH to an address. + * @param privateKey Sender's private key. + * @param toAddressOrEns Recipient address or ENS name. + * @param amount Amount to send in ETH string format (e.g., "0.1"). + * @param network Network name or chain ID (defaults to 'ethereum'). + * @returns Transaction hash. + * @throws If address resolution or transaction fails. */ export async function transferETH( privateKey: string | Hex, @@ -128,305 +182,394 @@ export async function transferETH( amount: string, // in ether network = 'ethereum' ): Promise { - // Resolve ENS name to address if needed - const toAddress = await resolveAddress(toAddressOrEns, network); - - // Ensure the private key has 0x prefix - const formattedKey = typeof privateKey === 'string' && !privateKey.startsWith('0x') - ? `0x${privateKey}` as Hex - : privateKey as Hex; - - const client = getWalletClient(formattedKey, network); - const amountWei = parseEther(amount); - - return client.sendTransaction({ - to: toAddress, - value: amountWei, - account: client.account!, - chain: client.chain - }); + let toAddress: Address; + try { + toAddress = await resolveAddress(toAddressOrEns, network); + } catch (error: any) { + console.error(`[transferETH] Failed to resolve recipient address/ENS '${toAddressOrEns}': ${error.message}`); + throw new Error(`Invalid recipient address or ENS name: ${toAddressOrEns}`); + } + + const formattedKey = ensureHexPrefix(privateKey); + const walletClient = getWalletClient(formattedKey, network); + const amountWei = parseEther(amount); // Can throw error if amount is invalid format + + if (!walletClient.account) { + throw new Error("Wallet client account is not available. Check private key."); + } + + try { + const txHash = await walletClient.sendTransaction({ + to: toAddress, + value: amountWei, + account: walletClient.account, // Already checked for existence + chain: walletClient.chain + }); + console.log(`[transferETH] ETH transfer initiated. Tx Hash: ${txHash}`); + return txHash; + } catch (error: any) { + console.error(`[transferETH] Transaction failed: ${error.message}`); + // Consider logging more details from the error object if available + throw new Error(`Failed to transfer ETH to ${toAddress}: ${error.shortMessage || error.message}`); + } } /** - * Transfer ERC20 tokens to an address - * @param tokenAddressOrEns Token contract address or ENS name - * @param toAddressOrEns Recipient address or ENS name - * @param amount Amount to send (in token units) - * @param privateKey Sender's private key - * @param network Network name or chain ID - * @returns Transaction details + * Transfer ERC20 tokens to an address. + * @param tokenAddressOrEns Token contract address or ENS name. + * @param toAddressOrEns Recipient address or ENS name. + * @param amount Amount to send (in token's standard unit, e.g., "100"). + * @param privateKey Sender's private key. + * @param network Network name or chain ID (defaults to 'ethereum'). + * @returns Transaction details including hash, amount, and token info. + * @throws If address resolution, token detail fetching, or transaction fails. */ export async function transferERC20( tokenAddressOrEns: string, toAddressOrEns: string, amount: string, - privateKey: string | `0x${string}`, + privateKey: string | Hex, network: string = 'ethereum' ): Promise<{ txHash: Hash; amount: { raw: bigint; - formatted: string; + formatted: string; // The input amount string }; token: { symbol: string; decimals: number; }; }> { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network) as Address; - const toAddress = await resolveAddress(toAddressOrEns, network) as Address; - - // Ensure the private key has 0x prefix - const formattedKey = typeof privateKey === 'string' && !privateKey.startsWith('0x') - ? `0x${privateKey}` as `0x${string}` - : privateKey as `0x${string}`; - - // Get token details + let resolvedAddresses: { tokenAddress: Address; toAddress: Address }; + try { + resolvedAddresses = await resolveMultipleAddresses( + { tokenAddress: tokenAddressOrEns, toAddress: toAddressOrEns }, + network + ); + } catch (error: any) { + console.error(`[transferERC20] Failed to resolve addresses: ${error.message}`); + throw new Error(`Invalid token or recipient address/ENS name provided.`); + } + const { tokenAddress, toAddress } = resolvedAddresses; + + const formattedKey = ensureHexPrefix(privateKey); const publicClient = getPublicClient(network); - const contract = getContract({ - address: tokenAddress, - abi: erc20TransferAbi, - client: publicClient, - }); - - // Get token decimals and symbol - const decimals = await contract.read.decimals(); - const symbol = await contract.read.symbol(); - - // Parse the amount with the correct number of decimals - const rawAmount = parseUnits(amount, decimals); - - // Create wallet client for sending the transaction const walletClient = getWalletClient(formattedKey, network); - - // Send the transaction - const hash = await walletClient.writeContract({ + + if (!walletClient.account) { + throw new Error("Wallet client account is not available. Check private key."); + } + + const contract = getContract({ address: tokenAddress, abi: erc20TransferAbi, - functionName: 'transfer', - args: [toAddress, rawAmount], - account: walletClient.account!, - chain: walletClient.chain + client: { public: publicClient }, // Use public client for reads }); - - return { - txHash: hash, - amount: { - raw: rawAmount, - formatted: amount - }, - token: { - symbol, - decimals - } - }; + + let decimals: number; + let symbol: string; + try { + // Fetch token details concurrently + [decimals, symbol] = await Promise.all([ + contract.read.decimals(), + contract.read.symbol() + ]); + console.log(`[transferERC20] Token: ${symbol}, Decimals: ${decimals}`); + } catch (error: any) { + console.error(`[transferERC20] Failed to fetch token details for ${tokenAddress}: ${error.message}`); + throw new Error(`Could not retrieve token details for ${tokenAddress}. Ensure it's a valid ERC20 contract.`); + } + + let rawAmount: bigint; + try { + rawAmount = parseUnits(amount, decimals); // Can throw if amount format is wrong + } catch (error: any) { + console.error(`[transferERC20] Invalid amount format "${amount}" for ${decimals} decimals: ${error.message}`); + throw new Error(`Invalid amount format: ${amount}`); + } + + + try { + const hash = await walletClient.writeContract({ + address: tokenAddress, + abi: erc20TransferAbi, + functionName: 'transfer', + args: [toAddress, rawAmount], + account: walletClient.account, + chain: walletClient.chain + }); + console.log(`[transferERC20] ${symbol} transfer initiated. Tx Hash: ${hash}`); + + return { + txHash: hash, + amount: { raw: rawAmount, formatted: amount }, + token: { symbol, decimals } + }; + } catch (error: any) { + console.error(`[transferERC20] Transaction failed: ${error.message}`); + throw new Error(`Failed to transfer ${amount} ${symbol} to ${toAddress}: ${error.shortMessage || error.message}`); + } } /** - * Approve ERC20 token spending - * @param tokenAddressOrEns Token contract address or ENS name - * @param spenderAddressOrEns Spender address or ENS name - * @param amount Amount to approve (in token units) - * @param privateKey Owner's private key - * @param network Network name or chain ID - * @returns Transaction details + * Approve ERC20 token spending for a spender. + * @param tokenAddressOrEns Token contract address or ENS name. + * @param spenderAddressOrEns Spender address or ENS name. + * @param amount Amount to approve (in token's standard unit, e.g., "1000"). + * @param privateKey Owner's private key. + * @param network Network name or chain ID (defaults to 'ethereum'). + * @returns Transaction details including hash, amount, and token info. + * @throws If address resolution, token detail fetching, or transaction fails. */ export async function approveERC20( tokenAddressOrEns: string, spenderAddressOrEns: string, amount: string, - privateKey: string | `0x${string}`, + privateKey: string | Hex, network: string = 'ethereum' ): Promise<{ txHash: Hash; amount: { raw: bigint; - formatted: string; + formatted: string; // The input amount string }; token: { symbol: string; decimals: number; }; }> { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network) as Address; - const spenderAddress = await resolveAddress(spenderAddressOrEns, network) as Address; - - // Ensure the private key has 0x prefix - const formattedKey = typeof privateKey === 'string' && !privateKey.startsWith('0x') - ? `0x${privateKey}` as `0x${string}` - : privateKey as `0x${string}`; - - // Get token details - const publicClient = getPublicClient(network); - const contract = getContract({ - address: tokenAddress, - abi: erc20TransferAbi, - client: publicClient, - }); - - // Get token decimals and symbol - const decimals = await contract.read.decimals(); - const symbol = await contract.read.symbol(); - - // Parse the amount with the correct number of decimals - const rawAmount = parseUnits(amount, decimals); - - // Create wallet client for sending the transaction - const walletClient = getWalletClient(formattedKey, network); - - // Send the transaction - const hash = await walletClient.writeContract({ - address: tokenAddress, - abi: erc20TransferAbi, - functionName: 'approve', - args: [spenderAddress, rawAmount], - account: walletClient.account!, - chain: walletClient.chain - }); - - return { - txHash: hash, - amount: { - raw: rawAmount, - formatted: amount - }, - token: { - symbol, - decimals + let resolvedAddresses: { tokenAddress: Address; spenderAddress: Address }; + try { + resolvedAddresses = await resolveMultipleAddresses( + { tokenAddress: tokenAddressOrEns, spenderAddress: spenderAddressOrEns }, + network + ); + } catch (error: any) { + console.error(`[approveERC20] Failed to resolve addresses: ${error.message}`); + throw new Error(`Invalid token or spender address/ENS name provided.`); + } + const { tokenAddress, spenderAddress } = resolvedAddresses; + + const formattedKey = ensureHexPrefix(privateKey); + const publicClient = getPublicClient(network); + const walletClient = getWalletClient(formattedKey, network); + + if (!walletClient.account) { + throw new Error("Wallet client account is not available. Check private key."); + } + + const contract = getContract({ + address: tokenAddress, + abi: erc20TransferAbi, + client: { public: publicClient }, // Use public client for reads + }); + + let decimals: number; + let symbol: string; + try { + [decimals, symbol] = await Promise.all([ + contract.read.decimals(), + contract.read.symbol() + ]); + console.log(`[approveERC20] Token: ${symbol}, Decimals: ${decimals}`); + } catch (error: any) { + console.error(`[approveERC20] Failed to fetch token details for ${tokenAddress}: ${error.message}`); + throw new Error(`Could not retrieve token details for ${tokenAddress}. Ensure it's a valid ERC20 contract.`); + } + + let rawAmount: bigint; + try { + rawAmount = parseUnits(amount, decimals); + } catch (error: any) { + console.error(`[approveERC20] Invalid amount format "${amount}" for ${decimals} decimals: ${error.message}`); + throw new Error(`Invalid amount format: ${amount}`); + } + + try { + const hash = await walletClient.writeContract({ + address: tokenAddress, + abi: erc20TransferAbi, + functionName: 'approve', + args: [spenderAddress, rawAmount], + account: walletClient.account, + chain: walletClient.chain + }); + console.log(`[approveERC20] ${symbol} approval for ${spenderAddress} initiated. Tx Hash: ${hash}`); + + return { + txHash: hash, + amount: { raw: rawAmount, formatted: amount }, + token: { symbol, decimals } + }; + } catch (error: any) { + console.error(`[approveERC20] Transaction failed: ${error.message}`); + throw new Error(`Failed to approve ${amount} ${symbol} for ${spenderAddress}: ${error.shortMessage || error.message}`); } - }; } /** - * Transfer an NFT (ERC721) to an address - * @param tokenAddressOrEns NFT contract address or ENS name - * @param toAddressOrEns Recipient address or ENS name - * @param tokenId Token ID to transfer - * @param privateKey Owner's private key - * @param network Network name or chain ID - * @returns Transaction details + * Transfer an NFT (ERC721) to an address. + * @param tokenAddressOrEns NFT contract address or ENS name. + * @param toAddressOrEns Recipient address or ENS name. + * @param tokenId The specific ID of the token to transfer (as bigint). + * @param privateKey Owner's private key. + * @param network Network name or chain ID (defaults to 'ethereum'). + * @returns Transaction details including hash, token ID, and token metadata. + * @throws If address resolution or transaction fails. Metadata fetching errors are logged as warnings. */ export async function transferERC721( tokenAddressOrEns: string, toAddressOrEns: string, tokenId: bigint, - privateKey: string | `0x${string}`, + privateKey: string | Hex, network: string = 'ethereum' ): Promise<{ txHash: Hash; - tokenId: string; + tokenId: string; // Return as string token: { name: string; symbol: string; }; }> { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network) as Address; - const toAddress = await resolveAddress(toAddressOrEns, network) as Address; - - // Ensure the private key has 0x prefix - const formattedKey = typeof privateKey === 'string' && !privateKey.startsWith('0x') - ? `0x${privateKey}` as `0x${string}` - : privateKey as `0x${string}`; - - // Create wallet client for sending the transaction - const walletClient = getWalletClient(formattedKey, network); - const fromAddress = walletClient.account!.address; - - // Send the transaction - const hash = await walletClient.writeContract({ - address: tokenAddress, - abi: erc721TransferAbi, - functionName: 'transferFrom', - args: [fromAddress, toAddress, tokenId], - account: walletClient.account!, - chain: walletClient.chain - }); - - // Get token metadata - const publicClient = getPublicClient(network); - const contract = getContract({ - address: tokenAddress, - abi: erc721TransferAbi, - client: publicClient, - }); - - // Get token name and symbol - let name = 'Unknown'; - let symbol = 'NFT'; - - try { - [name, symbol] = await Promise.all([ - contract.read.name(), - contract.read.symbol() - ]); - } catch (error) { - console.error('Error fetching NFT metadata:', error); - } - - return { - txHash: hash, - tokenId: tokenId.toString(), - token: { - name, - symbol + let resolvedAddresses: { tokenAddress: Address; toAddress: Address }; + try { + resolvedAddresses = await resolveMultipleAddresses( + { tokenAddress: tokenAddressOrEns, toAddress: toAddressOrEns }, + network + ); + } catch (error: any) { + console.error(`[transferERC721] Failed to resolve addresses: ${error.message}`); + throw new Error(`Invalid token or recipient address/ENS name provided.`); } - }; + const { tokenAddress, toAddress } = resolvedAddresses; + + const formattedKey = ensureHexPrefix(privateKey); + const publicClient = getPublicClient(network); // Needed for metadata later + const walletClient = getWalletClient(formattedKey, network); + + if (!walletClient.account) { + throw new Error("Wallet client account is not available. Check private key."); + } + const fromAddress = walletClient.account.address; + + let txHash: Hash; + try { + txHash = await walletClient.writeContract({ + address: tokenAddress, + abi: erc721TransferAbi, + functionName: 'transferFrom', + args: [fromAddress, toAddress, tokenId], + account: walletClient.account, + chain: walletClient.chain + }); + console.log(`[transferERC721] Transfer initiated for token ID ${tokenId}. Tx Hash: ${txHash}`); + } catch (error: any) { + console.error(`[transferERC721] Transaction failed: ${error.message}`); + throw new Error(`Failed to transfer NFT #${tokenId} from ${fromAddress} to ${toAddress}: ${error.shortMessage || error.message}`); + } + + // Attempt to get token metadata after initiating transfer + const contract = getContract({ + address: tokenAddress, + abi: erc721TransferAbi, + client: { public: publicClient }, + }); + + let name = 'Unknown'; + let symbol = 'NFT'; + try { + // Fetch concurrently, provide defaults on failure + [name, symbol] = await Promise.all([ + contract.read.name().catch(() => 'Unknown'), // Default on error + contract.read.symbol().catch(() => 'NFT') // Default on error + ]); + console.log(`[transferERC721] Token Metadata: Name='${name}', Symbol='${symbol}'`); + } catch (error: any) { + // Should be caught by individual catches, but log just in case + console.warn(`[transferERC721] Warning: Unexpected error fetching NFT metadata for ${tokenAddress}: ${error.message}`); + } + + return { + txHash, + tokenId: tokenId.toString(), + token: { name, symbol } + }; } /** - * Transfer ERC1155 tokens to an address - * @param tokenAddressOrEns Token contract address or ENS name - * @param toAddressOrEns Recipient address or ENS name - * @param tokenId Token ID to transfer - * @param amount Amount to transfer - * @param privateKey Owner's private key - * @param network Network name or chain ID - * @returns Transaction details + * Transfer ERC1155 tokens to an address. + * @param tokenAddressOrEns Token contract address or ENS name. + * @param toAddressOrEns Recipient address or ENS name. + * @param tokenId The specific ID of the token type to transfer (as bigint). + * @param amount Amount to transfer (as a string representing an integer). + * @param privateKey Owner's private key. + * @param network Network name or chain ID (defaults to 'ethereum'). + * @returns Transaction details including hash, token ID, and amount. + * @throws If address resolution, amount parsing, or transaction fails. */ export async function transferERC1155( tokenAddressOrEns: string, toAddressOrEns: string, tokenId: bigint, - amount: string, - privateKey: string | `0x${string}`, + amount: string, // Keep as string input for consistency + privateKey: string | Hex, network: string = 'ethereum' ): Promise<{ txHash: Hash; - tokenId: string; - amount: string; + tokenId: string; // Return as string + amount: string; // Return original string amount }> { - // Resolve ENS names to addresses if needed - const tokenAddress = await resolveAddress(tokenAddressOrEns, network) as Address; - const toAddress = await resolveAddress(toAddressOrEns, network) as Address; - - // Ensure the private key has 0x prefix - const formattedKey = typeof privateKey === 'string' && !privateKey.startsWith('0x') - ? `0x${privateKey}` as `0x${string}` - : privateKey as `0x${string}`; - - // Create wallet client for sending the transaction - const walletClient = getWalletClient(formattedKey, network); - const fromAddress = walletClient.account!.address; - - // Parse amount to bigint - const amountBigInt = BigInt(amount); - - // Send the transaction - const hash = await walletClient.writeContract({ - address: tokenAddress, - abi: erc1155TransferAbi, - functionName: 'safeTransferFrom', - args: [fromAddress, toAddress, tokenId, amountBigInt, '0x'], - account: walletClient.account!, - chain: walletClient.chain - }); - - return { - txHash: hash, - tokenId: tokenId.toString(), - amount - }; -} \ No newline at end of file + let resolvedAddresses: { tokenAddress: Address; toAddress: Address }; + try { + resolvedAddresses = await resolveMultipleAddresses( + { tokenAddress: tokenAddressOrEns, toAddress: toAddressOrEns }, + network + ); + } catch (error: any) { + console.error(`[transferERC1155] Failed to resolve addresses: ${error.message}`); + throw new Error(`Invalid token or recipient address/ENS name provided.`); + } + const { tokenAddress, toAddress } = resolvedAddresses; + + const formattedKey = ensureHexPrefix(privateKey); + const walletClient = getWalletClient(formattedKey, network); + + if (!walletClient.account) { + throw new Error("Wallet client account is not available. Check private key."); + } + const fromAddress = walletClient.account.address; + + let amountBigInt: bigint; + try { + amountBigInt = BigInt(amount); // Validate amount string can be converted + if (amountBigInt <= 0n) { + throw new Error("Amount must be a positive integer."); + } + } catch (error: any) { + console.error(`[transferERC1155] Invalid amount specified: ${amount}. ${error.message}`); + throw new Error(`Invalid amount: ${amount}. Must be a positive integer string.`); + } + + try { + const hash = await walletClient.writeContract({ + address: tokenAddress, + abi: erc1155TransferAbi, + functionName: 'safeTransferFrom', + // data field is typically '0x' unless specific receiver logic is needed + args: [fromAddress, toAddress, tokenId, amountBigInt, '0x'], + account: walletClient.account, + chain: walletClient.chain + }); + console.log(`[transferERC1155] Transfer initiated for ${amount} of token ID ${tokenId}. Tx Hash: ${hash}`); + + return { + txHash: hash, + tokenId: tokenId.toString(), + amount // Return the original valid string amount + }; + } catch (error: any) { + console.error(`[transferERC1155] Transaction failed: ${error.message}`); + throw new Error(`Failed to transfer ${amount} of token ID ${tokenId} to ${toAddress}: ${error.shortMessage || error.message}`); + } +} \ No newline at end of file From 2e22ed487731a4b391cdc095aacbd719f27ebf43 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:39:40 -0700 Subject: [PATCH 14/57] feat(types): add MCP SDK type declarations for better type safety --- src/types/mcp.d.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/types/mcp.d.ts diff --git a/src/types/mcp.d.ts b/src/types/mcp.d.ts new file mode 100644 index 0000000..0a852cc --- /dev/null +++ b/src/types/mcp.d.ts @@ -0,0 +1,11 @@ +declare module '@modelcontextprotocol/sdk/server/mcp.js' { + export class McpServer { + constructor(); + prompt( + name: string, + description: string, + schema: any, + handler: (params: any) => { messages: Array<{ role: string; content: { type: string; text: string } }> } + ): void; + } +} \ No newline at end of file From 73df63f3a0d95b36026e38e42dd99bc59a731d27 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:39:46 -0700 Subject: [PATCH 15/57] chore(config): update TypeScript config to include type declarations directory --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index b0b3d32..546fd16 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "outDir": "dist", "sourceMap": true, "declaration": true, - "resolveJsonModule": true + "resolveJsonModule": true, + "typeRoots": ["./node_modules/@types", "./src/types"] }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] From 6983475c5fc38c7b694557f56a1b64efcc7b26f1 Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:39:51 -0700 Subject: [PATCH 16/57] feat(chains): enhance type safety and error handling in chain definitions --- src/core/chains.ts | 89 +++++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/src/core/chains.ts b/src/core/chains.ts index 91ba928..b5cf1bf 100644 --- a/src/core/chains.ts +++ b/src/core/chains.ts @@ -66,10 +66,61 @@ export const DEFAULT_RPC_URL = mainnet.rpcUrls.default.http[0]; // Use mainnet d // --- Chain Definitions --- // Provides a single source of truth for chain configurations. -interface ChainDefinition { +// Add proper type exports and validation +export interface ChainDefinition { viemChain: Chain; - networkNames: string[]; // Primary name first, followed by aliases - rpcUrlOverride?: string; // Optional override for the default RPC URL from viemChain + networkNames: string[]; + rpcUrlOverride?: string; +} + +// Add network type validation +export type NetworkIdentifier = number | string; + +// Add RPC URL validation +export type RpcUrl = string; + +// Add proper error types +export class ChainError extends Error { + constructor( + message: string, + public readonly code: string, + public readonly chainIdentifier?: NetworkIdentifier + ) { + super(message); + this.name = 'ChainError'; + } +} + +// Add validation for RPC URLs +function validateRpcUrl(url: string): RpcUrl { + try { + new URL(url); + return url; + } catch (error) { + throw new ChainError( + `Invalid RPC URL: ${url}`, + 'INVALID_RPC_URL', + undefined + ); + } +} + +// Add network type validation +function validateNetworkIdentifier(identifier: NetworkIdentifier): void { + if (typeof identifier === 'number' && !Number.isInteger(identifier)) { + throw new ChainError( + `Invalid chain ID: ${identifier}. Must be an integer.`, + 'INVALID_CHAIN_ID', + identifier + ); + } + if (typeof identifier === 'string' && !identifier.trim()) { + throw new ChainError( + 'Network name cannot be empty', + 'EMPTY_NETWORK_NAME', + identifier + ); + } } // Define all supported chains here @@ -179,14 +230,18 @@ export const rpcUrlMap: Readonly> = Object.freeze( * @returns The resolved chain ID number. * @throws {Error} If the chain identifier is invalid or unsupported. */ -export function resolveChainId(chainIdentifier: number | string): number { +export function resolveChainId(chainIdentifier: NetworkIdentifier): number { + validateNetworkIdentifier(chainIdentifier); + if (typeof chainIdentifier === 'number') { - // Check if the numeric ID is actually supported if (chainMap[chainIdentifier]) { return chainIdentifier; - } else { - throw new Error(`Unsupported chain ID: ${chainIdentifier}`); } + throw new ChainError( + `Unsupported chain ID: ${chainIdentifier}`, + 'UNSUPPORTED_CHAIN_ID', + chainIdentifier + ); } if (typeof chainIdentifier === 'string') { @@ -196,14 +251,17 @@ export function resolveChainId(chainIdentifier: number | string): number { return chainId; } - // Allow string representation of valid chain IDs (e.g., "1") const parsedId = parseInt(networkName, 10); if (!isNaN(parsedId) && chainMap[parsedId]) { return parsedId; } } - throw new Error(`Invalid or unsupported chain identifier: ${chainIdentifier}`); + throw new ChainError( + `Invalid or unsupported chain identifier: ${chainIdentifier}`, + 'INVALID_CHAIN_IDENTIFIER', + chainIdentifier + ); } /** @@ -229,25 +287,22 @@ export function getChain(chainIdentifier: number | string): Chain { * @returns The RPC URL string. * @throws {Error} If the chain identifier is invalid or unsupported. */ -export function getRpcUrl(chainIdentifier: number | string): string { - const chainId = resolveChainId(chainIdentifier); // Will throw if invalid/unsupported +export function getRpcUrl(chainIdentifier: NetworkIdentifier): RpcUrl { + const chainId = resolveChainId(chainIdentifier); - // 1. Check our explicit rpcUrlMap (includes overrides and viem defaults if available) if (rpcUrlMap[chainId]) { - return rpcUrlMap[chainId]; + return validateRpcUrl(rpcUrlMap[chainId]); } - // 2. As a fallback, try the viem chain's default RPC again (might cover cases where rpcUrlMap generation missed it) const chain = chainMap[chainId]; const viemDefaultRpc = chain?.rpcUrls.default?.http[0]; if (viemDefaultRpc) { console.warn(`Using viem default RPC for chain ${chainId} as no specific URL was found in rpcUrlMap.`); - return viemDefaultRpc; + return validateRpcUrl(viemDefaultRpc); } - // 3. Final fallback to the global default (should ideally not be reached for supported chains) console.warn(`Using global default RPC URL for chain ${chainId} as no specific or viem default URL was found.`); - return DEFAULT_RPC_URL; + return validateRpcUrl(DEFAULT_RPC_URL); } /** From 39626b2f555c761f9836b7d64b14bb8dfe19415c Mon Sep 17 00:00:00 2001 From: accessor Date: Sat, 3 May 2025 15:39:56 -0700 Subject: [PATCH 17/57] feat(prompts): improve type safety and validation in EVM prompts --- src/core/prompts.ts | 483 ++++++++++++++++++++++++++------------------ 1 file changed, 281 insertions(+), 202 deletions(-) diff --git a/src/core/prompts.ts b/src/core/prompts.ts index acf5c01..5c9b29a 100644 --- a/src/core/prompts.ts +++ b/src/core/prompts.ts @@ -1,225 +1,304 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; +import { NetworkIdentifier, ChainError } from "./chains.js"; + +// Add proper type definitions +export interface PromptResponse { + messages: Array<{ + role: string; + content: { + type: string; + text: string; + }; + }>; +} + +export interface PromptError { + error: string; + code: string; + details?: unknown; +} + +// Add validation schemas +const networkSchema = z.string().refine( + (val) => val.trim().length > 0, + "Network name cannot be empty" +); + +const addressSchema = z.string().refine( + (val) => val.trim().length > 0, + "Address cannot be empty" +); + +const ensNameSchema = z.string().refine( + (val) => val.includes('.'), + "ENS name must contain a dot (e.g., 'name.eth')" +); + +// Add error handling +function handlePromptError(error: unknown): PromptError { + if (error instanceof ChainError) { + return { + error: error.message, + code: error.code, + details: { chainIdentifier: error.chainIdentifier } + }; + } + if (error instanceof Error) { + return { + error: error.message, + code: 'UNKNOWN_ERROR', + details: error + }; + } + return { + error: String(error), + code: 'UNKNOWN_ERROR' + }; +} + +// Add type definitions for prompt parameters +interface BlockExplorerParams { + blockNumber?: string; + network?: string; +} + +interface TransactionAnalysisParams { + txHash: string; + network?: string; +} + +interface AddressAnalysisParams { + address: string; + network?: string; +} /** * Register all EVM-related prompts with the MCP server * @param server The MCP server instance + * @throws {Error} If registration fails */ -export function registerEVMPrompts(server: McpServer) { - // Basic block explorer prompt - server.prompt( - "explore_block", - "Explore information about a specific block", - z.object({ - blockNumber: z.string().optional().describe("Block number to explore. If not provided, latest block will be used."), - network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") - }), - ({ blockNumber, network = "ethereum" }: { blockNumber?: string, network?: string }) => ({ - messages: [{ - role: "user", - content: { - type: "text", - text: blockNumber - ? `Please analyze block #${blockNumber} on the ${network} network and provide information about its key metrics, transactions, and significance.` - : `Please analyze the latest block on the ${network} network and provide information about its key metrics, transactions, and significance.` - } - }] - }) - ); - - // Transaction analysis prompt - server.prompt( - "analyze_transaction", - "Analyze a specific transaction", - z.object({ - txHash: z.string().describe("Transaction hash to analyze"), - network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") - }), - ({ txHash, network = "ethereum" }: { txHash: string, network?: string }) => ({ - messages: [{ - role: "user", - content: { - type: "text", - text: `Please analyze transaction ${txHash} on the ${network} network and provide a detailed explanation of what this transaction does, who the parties involved are, the amount transferred (if applicable), gas used, and any other relevant information.` - } - }] - }) - ); - - // Address analysis prompt - server.prompt( - "analyze_address", - "Analyze an EVM address", - z.object({ - address: z.string().describe("Ethereum address or ENS name to analyze"), - network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") - }), - ({ address, network = "ethereum" }: { address: string, network?: string }) => ({ - messages: [{ - role: "user", - content: { - type: "text", - text: `Please analyze the address ${address} (resolve if it's an ENS name) on the ${network} network. Provide information about its balance, transaction count, associated ENS name (if applicable), and any other relevant information you can find.` - } - }] - }) - ); - - // ENS Name Resolution Prompt - server.prompt( - "resolve_ens_name", - "Resolve an ENS name to an Ethereum address", - z.object({ - ensName: z.string().describe("The ENS name to resolve (e.g., 'vitalik.eth')"), - network: z.string().optional().describe("Network name (e.g., 'ethereum', 'goerli'). Defaults to Ethereum mainnet.") - }), - ({ ensName, network = "ethereum" }: { ensName: string, network?: string }) => ({ - messages: [{ - role: "user", - content: { - type: "text", - text: `Please resolve the ENS name "${ensName}" to its corresponding Ethereum address on the ${network} network.` - } - }] - }) - ); - - // ENS Reverse Lookup Prompt (Address to Name) - server.prompt( - "lookup_ens_address", - "Find the primary ENS name associated with an Ethereum address", - z.object({ - address: z.string().describe("The Ethereum address to look up"), - network: z.string().optional().describe("Network name (e.g., 'ethereum', 'goerli'). Defaults to Ethereum mainnet.") - }), - ({ address, network = "ethereum" }: { address: string, network?: string }) => ({ - messages: [{ - role: "user", - content: { - type: "text", - text: `Please find the primary ENS name associated with the Ethereum address ${address} on the ${network} network.` - } - }] - }) - ); - - // ENS Get Text Record Prompt - server.prompt( - "get_ens_text_record", - "Get a specific text record for an ENS name", - z.object({ - ensName: z.string().describe("The ENS name (e.g., 'vitalik.eth')"), - recordKey: z.string().describe("The text record key (e.g., 'avatar', 'url', 'com.github')"), - network: z.string().optional().describe("Network name (e.g., 'ethereum', 'goerli'). Defaults to Ethereum mainnet.") - }), - ({ ensName, recordKey, network = "ethereum" }: { ensName: string, recordKey: string, network?: string }) => ({ - messages: [{ - role: "user", - content: { - type: "text", - text: `Please retrieve the text record with the key "${recordKey}" for the ENS name "${ensName}" on the ${network} network.` - } - }] - }) - ); - - // Smart contract interaction guidance - server.prompt( - "interact_with_contract", - "Get guidance on interacting with a smart contract", - z.object({ - contractAddress: z.string().describe("The contract address or ENS name"), - abiJson: z.string().optional().describe("The contract ABI as a JSON string"), - network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") - }), - ({ contractAddress, abiJson, network = "ethereum" }: { contractAddress: string, abiJson?: string, network?: string }) => ({ - messages: [{ - role: "user", - content: { - type: "text", - text: abiJson - ? `I need to interact with the smart contract at address ${contractAddress} (resolve if ENS name) on the ${network} network. Here's the ABI:\n\n${abiJson}\n\nPlease analyze this contract's functions and provide guidance on how to interact with it safely. Explain what each function does and what parameters it requires.` - : `I need to interact with the smart contract at address ${contractAddress} (resolve if ENS name) on the ${network} network. Please help me understand what this contract does and how I can interact with it safely.` - } - }] - }) - ); - - // EVM concept explanation - server.prompt( - "explain_evm_concept", - "Get an explanation of an EVM concept", - z.object({ - concept: z.string().describe("The EVM concept to explain (e.g., gas, nonce, ENS, etc.)") - }), - ({ concept }: { concept: string }) => ({ - messages: [{ - role: "user", - content: { - type: "text", - text: `Please explain the EVM Blockchain concept of "${concept}" in detail. Include how it works, why it's important, and provide examples if applicable.` - } - }] - }) - ); - - // Network comparison - server.prompt( - "compare_networks", - "Compare different EVM-compatible networks", - z.object({ - networkList: z.string().describe("Comma-separated list of networks to compare (e.g., 'ethereum,optimism,arbitrum')") - }), - ({ networkList }: { networkList: string }) => { - const networks = networkList.split(',').map((n: string) => n.trim()); - return { +export function registerEVMPrompts(server: McpServer): void { + try { + // Basic block explorer prompt + server.prompt( + "explore_block", + "Explore information about a specific block", + z.object({ + blockNumber: z.string().optional().describe("Block number to explore. If not provided, latest block will be used."), + network: networkSchema.optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") + }), + ({ blockNumber, network = "ethereum" }: BlockExplorerParams): PromptResponse => ({ messages: [{ role: "user", content: { type: "text", - text: `Please compare the following EVM-compatible networks: ${networks.join(', ')}. Include information about their architecture, gas fees, transaction speed, security, ENS support (if applicable), and any other relevant differences.` + text: blockNumber + ? `Please analyze block #${blockNumber} on the ${network} network and provide information about its key metrics, transactions, and significance.` + : `Please analyze the latest block on the ${network} network and provide information about its key metrics, transactions, and significance.` } }] - }; - } - ); - - // Token analysis prompt - server.prompt( - "analyze_token", - "Analyze an ERC20 or NFT token", - z.object({ - tokenAddress: z.string().describe("Token contract address or ENS name to analyze"), - tokenType: z.string().optional().describe("Type of token to analyze (erc20, erc721/nft, or auto-detect). Defaults to auto."), - tokenId: z.string().optional().describe("Token ID (required for NFT analysis)"), - network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") - }), - ({ tokenAddress, tokenType = "auto", tokenId, network = "ethereum" }: { tokenAddress: string, tokenType?: string, tokenId?: string, network?: string }) => { - let promptText = ""; - const addressDesc = `address ${tokenAddress} (resolve if ENS name)`; - - if (tokenType === "erc20" || tokenType === "auto") { - promptText = `Please analyze the ERC20 token at ${addressDesc} on the ${network} network. Provide information about its name, symbol, total supply, and any other relevant details. If possible, explain the token's purpose, utility, and market context. If auto-detecting, confirm if it's an ERC20.`; - } else if ((tokenType === "erc721" || tokenType === "nft") && tokenId) { - promptText = `Please analyze the NFT with token ID ${tokenId} from the collection at ${addressDesc} on the ${network} network. Provide information about the collection name, token details, ownership history if available, and any other relevant information about this specific NFT.`; - } else if (tokenType === "nft" || tokenType === "erc721") { - promptText = `Please analyze the NFT collection at ${addressDesc} on the ${network} network. Provide information about the collection name, symbol, total supply if available, floor price if available, and any other relevant details about this NFT collection.`; - } else if (tokenType === "auto" && tokenId) { - // Handle auto-detect case where tokenId is provided (likely NFT) - promptText = `Please analyze the token at ${addressDesc} on the ${network} network, likely an NFT with ID ${tokenId}. Determine the token type (ERC721 or other) and provide relevant details like collection name, token specifics, ownership, etc.`; - } + }) + ); + // Transaction analysis prompt + server.prompt( + "analyze_transaction", + "Analyze a specific transaction", + z.object({ + txHash: z.string().min(1).describe("Transaction hash to analyze"), + network: networkSchema.optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") + }), + ({ txHash, network = "ethereum" }: TransactionAnalysisParams): PromptResponse => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: `Please analyze transaction ${txHash} on the ${network} network and provide a detailed explanation of what this transaction does, who the parties involved are, the amount transferred (if applicable), gas used, and any other relevant information.` + } + }] + }) + ); - return { + // Address analysis prompt + server.prompt( + "analyze_address", + "Analyze an EVM address", + z.object({ + address: addressSchema.describe("Ethereum address or ENS name to analyze"), + network: networkSchema.optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") + }), + ({ address, network = "ethereum" }: AddressAnalysisParams): PromptResponse => ({ messages: [{ role: "user", content: { type: "text", - text: promptText + text: `Please analyze the address ${address} (resolve if it's an ENS name) on the ${network} network. Provide information about its balance, transaction count, associated ENS name (if applicable), and any other relevant information you can find.` } }] - }; - } - ); + }) + ); + + // ENS Name Resolution Prompt + server.prompt( + "resolve_ens_name", + "Resolve an ENS name to an Ethereum address", + z.object({ + ensName: z.string().describe("The ENS name to resolve (e.g., 'vitalik.eth')"), + network: z.string().optional().describe("Network name (e.g., 'ethereum', 'goerli'). Defaults to Ethereum mainnet.") + }), + ({ ensName, network = "ethereum" }: { ensName: string, network?: string }) => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: `Please resolve the ENS name "${ensName}" to its corresponding Ethereum address on the ${network} network.` + } + }] + }) + ); + + // ENS Reverse Lookup Prompt (Address to Name) + server.prompt( + "lookup_ens_address", + "Find the primary ENS name associated with an Ethereum address", + z.object({ + address: z.string().describe("The Ethereum address to look up"), + network: z.string().optional().describe("Network name (e.g., 'ethereum', 'goerli'). Defaults to Ethereum mainnet.") + }), + ({ address, network = "ethereum" }: { address: string, network?: string }) => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: `Please find the primary ENS name associated with the Ethereum address ${address} on the ${network} network.` + } + }] + }) + ); + + // ENS Get Text Record Prompt + server.prompt( + "get_ens_text_record", + "Get a specific text record for an ENS name", + z.object({ + ensName: z.string().describe("The ENS name (e.g., 'vitalik.eth')"), + recordKey: z.string().describe("The text record key (e.g., 'avatar', 'url', 'com.github')"), + network: z.string().optional().describe("Network name (e.g., 'ethereum', 'goerli'). Defaults to Ethereum mainnet.") + }), + ({ ensName, recordKey, network = "ethereum" }: { ensName: string, recordKey: string, network?: string }) => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: `Please retrieve the text record with the key "${recordKey}" for the ENS name "${ensName}" on the ${network} network.` + } + }] + }) + ); + + // Smart contract interaction guidance + server.prompt( + "interact_with_contract", + "Get guidance on interacting with a smart contract", + z.object({ + contractAddress: z.string().describe("The contract address or ENS name"), + abiJson: z.string().optional().describe("The contract ABI as a JSON string"), + network: z.string().optional().describe("Network name or chain ID. Defaults to Ethereum mainnet.") + }), + ({ contractAddress, abiJson, network = "ethereum" }: { contractAddress: string, abiJson?: string, network?: string }) => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: abiJson + ? `I need to interact with the smart contract at address ${contractAddress} (resolve if ENS name) on the ${network} network. Here's the ABI:\n\n${abiJson}\n\nPlease analyze this contract's functions and provide guidance on how to interact with it safely. Explain what each function does and what parameters it requires.` + : `I need to interact with the smart contract at address ${contractAddress} (resolve if ENS name) on the ${network} network. Please help me understand what this contract does and how I can interact with it safely.` + } + }] + }) + ); + + // EVM concept explanation + server.prompt( + "explain_evm_concept", + "Get an explanation of an EVM concept", + z.object({ + concept: z.string().describe("The EVM concept to explain (e.g., gas, nonce, ENS, etc.)") + }), + ({ concept }: { concept: string }) => ({ + messages: [{ + role: "user", + content: { + type: "text", + text: `Please explain the EVM Blockchain concept of "${concept}" in detail. Include how it works, why it's important, and provide examples if applicable.` + } + }] + }) + ); + + // Network comparison + server.prompt( + "compare_networks", + "Compare different EVM-compatible networks", + z.object({ + networkList: z.string().describe("Comma-separated list of networks to compare (e.g., 'ethereum,optimism,arbitrum')") + }), + ({ networkList }: { networkList: string }) => { + const networks = networkList.split(',').map((n: string) => n.trim()); + return { + messages: [{ + role: "user", + content: { + type: "text", + text: `Please compare the following EVM-compatible networks: ${networks.join(', ')}. Include information about their architecture, gas fees, transaction speed, security, ENS support (if applicable), and any other relevant differences.` + } + }] + }; + } + ); + + // Token analysis prompt + server.prompt( + "analyze_token", + "Analyze an ERC20 or NFT token", + z.object({ + tokenAddress: z.string().describe("Token contract address or ENS name to analyze"), + tokenType: z.string().optional().describe("Type of token to analyze (erc20, erc721/nft, or auto-detect). Defaults to auto."), + tokenId: z.string().optional().describe("Token ID (required for NFT analysis)"), + network: z.string().optional().describe("Network name (e.g., 'ethereum', 'optimism', 'arbitrum', 'base', etc.) or chain ID. Supports all EVM-compatible networks. Defaults to Ethereum mainnet.") + }), + ({ tokenAddress, tokenType = "auto", tokenId, network = "ethereum" }: { tokenAddress: string, tokenType?: string, tokenId?: string, network?: string }) => { + let promptText = ""; + const addressDesc = `address ${tokenAddress} (resolve if ENS name)`; + + if (tokenType === "erc20" || tokenType === "auto") { + promptText = `Please analyze the ERC20 token at ${addressDesc} on the ${network} network. Provide information about its name, symbol, total supply, and any other relevant details. If possible, explain the token's purpose, utility, and market context. If auto-detecting, confirm if it's an ERC20.`; + } else if ((tokenType === "erc721" || tokenType === "nft") && tokenId) { + promptText = `Please analyze the NFT with token ID ${tokenId} from the collection at ${addressDesc} on the ${network} network. Provide information about the collection name, token details, ownership history if available, and any other relevant information about this specific NFT.`; + } else if (tokenType === "nft" || tokenType === "erc721") { + promptText = `Please analyze the NFT collection at ${addressDesc} on the ${network} network. Provide information about the collection name, symbol, total supply if available, floor price if available, and any other relevant details about this NFT collection.`; + } else if (tokenType === "auto" && tokenId) { + // Handle auto-detect case where tokenId is provided (likely NFT) + promptText = `Please analyze the token at ${addressDesc} on the ${network} network, likely an NFT with ID ${tokenId}. Determine the token type (ERC721 or other) and provide relevant details like collection name, token specifics, ownership, etc.`; + } + + + return { + messages: [{ + role: "user", + content: { + type: "text", + text: promptText + } + }] + }; + } + ); + } catch (error) { + const promptError = handlePromptError(error); + console.error('Failed to register EVM prompts:', promptError); + throw new Error(`Failed to register EVM prompts: ${promptError.error}`); + } } \ No newline at end of file From 23ed2758f8a8fbc7405c1fc46496bd6361a4c257 Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:44:39 -0700 Subject: [PATCH 18/57] fix(ens): use ENS_REGISTRY_ADDRESS and correct result mapping in ENS history functions --- src/core/services/ens/ens/history.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/services/ens/ens/history.ts b/src/core/services/ens/ens/history.ts index 9336179..5a5df58 100644 --- a/src/core/services/ens/ens/history.ts +++ b/src/core/services/ens/ens/history.ts @@ -21,7 +21,7 @@ export async function getEnsOwnershipHistory( const normalizedEns = normalize(name); const { publicClient } = await getClients(network); const result = await publicClient.readContract({ - address: publicClient.address, + address: (await import('./utils.js')).ENS_REGISTRY_ADDRESS, abi: [ { inputs: [ @@ -46,7 +46,7 @@ export async function getEnsOwnershipHistory( functionName: 'getOwnershipHistory', args: [namehash(normalizedEns)], }); - return result[0].map((record: any) => ({ + return (result as any[]).map((record: any) => ({ owner: record.owner as Address, timestamp: Number(record.timestamp), transactionHash: record.transactionHash as Hash, @@ -77,7 +77,7 @@ export async function getEnsAddressHistory( const normalizedEns = normalize(name); const { publicClient } = await getClients(network); const result = await publicClient.readContract({ - address: publicClient.address, + address: (await import('./utils.js')).ENS_REGISTRY_ADDRESS, abi: [ { inputs: [ @@ -102,7 +102,7 @@ export async function getEnsAddressHistory( functionName: 'getAddressHistory', args: [namehash(normalizedEns)], }); - return result[0].map((record: any) => ({ + return (result as any[]).map((record: any) => ({ address: record.address as Address, timestamp: Number(record.timestamp), transactionHash: record.transactionHash as Hash, From 6eccc3772ce3b628936dd7424236c2510c5ad170 Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:50:21 -0700 Subject: [PATCH 19/57] feat(ens): add getRecentRegistrations function to query recent ENS name registrations --- src/core/services/ens/ens/records.ts | 71 ++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/src/core/services/ens/ens/records.ts b/src/core/services/ens/ens/records.ts index f8a7175..009e787 100644 --- a/src/core/services/ens/ens/records.ts +++ b/src/core/services/ens/ens/records.ts @@ -144,4 +144,75 @@ export async function setEnsAddressRecord( `Failed to set ENS address record for "${name}". Reason: ${message} [Error Code: SetEnsAddressRecord_General_001]` ); } +} + +/** + * Gets the most recent ENS name registrations + * @param count The number of recent registrations to fetch (default: 10) + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an array of recent registrations + */ +export async function getRecentRegistrations( + count: number = 10, + network: string | Chain = mainnet +): Promise> { + try { + const { publicClient } = await getClients(network); + + // ENS Registry contract address + const registryAddress = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' as const; + + // Get the latest block number + const latestBlock = await publicClient.getBlockNumber(); + + // Query for NewOwner events + const logs = await publicClient.getLogs({ + address: registryAddress, + event: { + type: 'event', + name: 'NewOwner', + inputs: [ + { type: 'bytes32', name: 'node', indexed: true }, + { type: 'bytes32', name: 'label', indexed: true }, + { type: 'address', name: 'owner' } + ] + }, + fromBlock: latestBlock - BigInt(10000), // Look back 10k blocks + toBlock: latestBlock + }); + + // Process the logs and get the most recent registrations + const registrations = await Promise.all( + logs.slice(-count).map(async (log) => { + const label = log.args.label; + const node = log.args.node; + const owner = log.args.owner; + + // Get the name from the label + const name = await publicClient.getEnsName({ + address: owner, + blockNumber: log.blockNumber + }); + + return { + name: name || 'unknown.eth', + owner: owner, + blockNumber: log.blockNumber, + transactionHash: log.transactionHash + }; + }) + ); + + return registrations.reverse(); // Return most recent first + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get recent registrations. Reason: ${message} [Error Code: GetRecentRegistrations_General_001]` + ); + } } \ No newline at end of file From 9146af7c4eae9f52de2d42e302bfbc5cb5bf0412 Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:50:43 -0700 Subject: [PATCH 20/57] feat(scripts): add script to display recent ENS registrations --- src/scripts/get-recent-registrations.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/scripts/get-recent-registrations.ts diff --git a/src/scripts/get-recent-registrations.ts b/src/scripts/get-recent-registrations.ts new file mode 100644 index 0000000..1c75256 --- /dev/null +++ b/src/scripts/get-recent-registrations.ts @@ -0,0 +1,22 @@ +import { getRecentRegistrations } from '../core/services/ens/ens/records.js'; + +async function main() { + try { + console.log('Fetching recent ENS registrations...'); + const registrations = await getRecentRegistrations(10); + + console.log('\nRecent ENS Registrations:'); + console.log('------------------------'); + registrations.forEach((reg, index) => { + console.log(`\n${index + 1}. Name: ${reg.name}`); + console.log(` Owner: ${reg.owner}`); + console.log(` Block: ${reg.blockNumber}`); + console.log(` Transaction: ${reg.transactionHash}`); + }); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } +} + +main(); \ No newline at end of file From 6d2c9633f58f37ccf108018a7ead00a36195480b Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:51:10 -0700 Subject: [PATCH 21/57] fix(ens): correct import path for clients.js --- src/core/services/ens/ens/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/services/ens/ens/utils.ts b/src/core/services/ens/ens/utils.ts index 9c2d16d..7f36c3a 100644 --- a/src/core/services/ens/ens/utils.ts +++ b/src/core/services/ens/ens/utils.ts @@ -1,4 +1,4 @@ -import { getPublicClient, getWalletClient } from '../clients.js'; +import { getPublicClient, getWalletClient } from '../../clients.js'; import { type PublicClient, type WalletClient, type Chain } from './types.js'; import { mainnet } from 'viem/chains'; From 47627a1ddba9e2ee9546918a2dad55523e143ee5 Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:51:42 -0700 Subject: [PATCH 22/57] fix(scripts): explicitly use mainnet chain for ENS registrations --- src/scripts/get-recent-registrations.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/scripts/get-recent-registrations.ts b/src/scripts/get-recent-registrations.ts index 1c75256..6f85119 100644 --- a/src/scripts/get-recent-registrations.ts +++ b/src/scripts/get-recent-registrations.ts @@ -1,9 +1,10 @@ import { getRecentRegistrations } from '../core/services/ens/ens/records.js'; +import { mainnet } from 'viem/chains'; async function main() { try { console.log('Fetching recent ENS registrations...'); - const registrations = await getRecentRegistrations(10); + const registrations = await getRecentRegistrations(10, mainnet); console.log('\nRecent ENS Registrations:'); console.log('------------------------'); From 1884b684ed473f3db88983d624bb5f795c05d317 Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:52:22 -0700 Subject: [PATCH 23/57] fix(scripts): use network name instead of chain object for ENS registrations --- src/scripts/get-recent-registrations.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/scripts/get-recent-registrations.ts b/src/scripts/get-recent-registrations.ts index 6f85119..96d4db3 100644 --- a/src/scripts/get-recent-registrations.ts +++ b/src/scripts/get-recent-registrations.ts @@ -1,10 +1,9 @@ import { getRecentRegistrations } from '../core/services/ens/ens/records.js'; -import { mainnet } from 'viem/chains'; async function main() { try { console.log('Fetching recent ENS registrations...'); - const registrations = await getRecentRegistrations(10, mainnet); + const registrations = await getRecentRegistrations(10, 'mainnet'); console.log('\nRecent ENS Registrations:'); console.log('------------------------'); From b1560dd4c85899b7df127d397ad5b94225f20e72 Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:52:52 -0700 Subject: [PATCH 24/57] refactor(ens): simplify client initialization to only use public client for read operations --- src/core/services/ens/ens/utils.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/core/services/ens/ens/utils.ts b/src/core/services/ens/ens/utils.ts index 7f36c3a..994f010 100644 --- a/src/core/services/ens/ens/utils.ts +++ b/src/core/services/ens/ens/utils.ts @@ -1,5 +1,5 @@ -import { getPublicClient, getWalletClient } from '../../clients.js'; -import { type PublicClient, type WalletClient, type Chain } from './types.js'; +import { getPublicClient } from '../../clients.js'; +import { type PublicClient, type Chain } from './types.js'; import { mainnet } from 'viem/chains'; // ENS Registry address @@ -8,10 +8,9 @@ export const ENS_REGISTRY_ADDRESS = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' /** * Utility function to get initialized clients for a network * @param network Network name or chain - * @returns Object containing public and wallet clients + * @returns Object containing public client */ export async function getClients(network: string | Chain = mainnet) { const publicClient = getPublicClient(typeof network === 'string' ? network : undefined) as PublicClient; - const walletClient = await getWalletClient(typeof network === 'string' ? network : undefined) as WalletClient; - return { publicClient, walletClient }; + return { publicClient }; } \ No newline at end of file From 94508cbc769a0d5a3971a730ddf282b4b5c945ae Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:54:39 -0700 Subject: [PATCH 25/57] feat(scripts): make registration count configurable via command-line argument --- src/scripts/get-recent-registrations.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/scripts/get-recent-registrations.ts b/src/scripts/get-recent-registrations.ts index 96d4db3..ab6c741 100644 --- a/src/scripts/get-recent-registrations.ts +++ b/src/scripts/get-recent-registrations.ts @@ -1,9 +1,19 @@ import { getRecentRegistrations } from '../core/services/ens/ens/records.js'; +// Parse command line arguments +const DEFAULT_COUNT = 10; +const count = process.argv[2] ? parseInt(process.argv[2], 10) : DEFAULT_COUNT; + +if (isNaN(count) || count < 1) { + console.error('Error: Please provide a valid positive number for the registration count.'); + console.error('Usage: bun run src/scripts/get-recent-registrations.ts [count]'); + process.exit(1); +} + async function main() { try { - console.log('Fetching recent ENS registrations...'); - const registrations = await getRecentRegistrations(10, 'mainnet'); + console.log(`Fetching ${count} recent ENS registrations...`); + const registrations = await getRecentRegistrations(count, 'mainnet'); console.log('\nRecent ENS Registrations:'); console.log('------------------------'); From b157a3abb430d267c0744e961cf08fd978651821 Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:56:50 -0700 Subject: [PATCH 26/57] refactor(ens): reorganize folder structure, improve script error handling --- src/core/services/ens/ens/resolution.ts | 1 - src/core/services/ens/{ens => }/history.ts | 0 src/core/services/ens/{ens => }/index.ts | 0 src/core/services/ens/{ens => }/records.ts | 0 src/core/services/ens/resolution.ts | 74 ++++++++++++++++ .../ens/scripts/get-recent-registrations.ts | 87 +++++++++++++++++++ src/core/services/ens/{ens => }/subdomains.ts | 0 src/core/services/ens/{ens => }/types.ts | 0 src/core/services/ens/{ens => }/utils.ts | 2 +- src/core/services/ens/{ens => }/wrapping.ts | 0 10 files changed, 162 insertions(+), 2 deletions(-) delete mode 100644 src/core/services/ens/ens/resolution.ts rename src/core/services/ens/{ens => }/history.ts (100%) rename src/core/services/ens/{ens => }/index.ts (100%) rename src/core/services/ens/{ens => }/records.ts (100%) create mode 100644 src/core/services/ens/resolution.ts create mode 100644 src/core/services/ens/scripts/get-recent-registrations.ts rename src/core/services/ens/{ens => }/subdomains.ts (100%) rename src/core/services/ens/{ens => }/types.ts (100%) rename src/core/services/ens/{ens => }/utils.ts (91%) rename src/core/services/ens/{ens => }/wrapping.ts (100%) diff --git a/src/core/services/ens/ens/resolution.ts b/src/core/services/ens/ens/resolution.ts deleted file mode 100644 index 0519ecb..0000000 --- a/src/core/services/ens/ens/resolution.ts +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/core/services/ens/ens/history.ts b/src/core/services/ens/history.ts similarity index 100% rename from src/core/services/ens/ens/history.ts rename to src/core/services/ens/history.ts diff --git a/src/core/services/ens/ens/index.ts b/src/core/services/ens/index.ts similarity index 100% rename from src/core/services/ens/ens/index.ts rename to src/core/services/ens/index.ts diff --git a/src/core/services/ens/ens/records.ts b/src/core/services/ens/records.ts similarity index 100% rename from src/core/services/ens/ens/records.ts rename to src/core/services/ens/records.ts diff --git a/src/core/services/ens/resolution.ts b/src/core/services/ens/resolution.ts new file mode 100644 index 0000000..cb1843f --- /dev/null +++ b/src/core/services/ens/resolution.ts @@ -0,0 +1,74 @@ +import { type Address, type PublicClient } from 'viem'; +import { normalize } from 'viem/ens'; + +/** + * Resolves an ENS name to an Ethereum address + * @param name The ENS name to resolve + * @param client The Viem public client + * @returns The resolved Ethereum address or null if not found + */ +export async function resolveAddress( + name: string, + client: PublicClient +): Promise
{ + try { + const normalizedName = normalize(name); + return await client.getEnsAddress({ name: normalizedName }); + } catch (error) { + console.error('Error resolving ENS name:', error); + return null; + } +} + +/** + * Looks up the ENS name for a given Ethereum address + * @param address The Ethereum address to look up + * @param client The Viem public client + * @returns The ENS name or null if not found + */ +export async function lookupAddress( + address: Address, + client: PublicClient +): Promise { + try { + return await client.getEnsName({ address }); + } catch (error) { + console.error('Error looking up ENS name:', error); + return null; + } +} + +/** + * Checks if a given string is a valid ENS name + * @param name The string to validate + * @returns true if the string is a valid ENS name, false otherwise + */ +export function isValidEnsName(name: string): boolean { + try { + // Check if the name ends with .eth + if (!name.toLowerCase().endsWith('.eth')) { + return false; + } + + // Remove .eth and check if the remaining part is valid + const label = name.slice(0, -4); + if (label.length < 3) { + return false; + } + + // Check if the label contains only valid characters + const validChars = /^[a-z0-9-]+$/; + if (!validChars.test(label)) { + return false; + } + + // Check if the label doesn't start or end with a hyphen + if (label.startsWith('-') || label.endsWith('-')) { + return false; + } + + return true; + } catch (error) { + return false; + } +} \ No newline at end of file diff --git a/src/core/services/ens/scripts/get-recent-registrations.ts b/src/core/services/ens/scripts/get-recent-registrations.ts new file mode 100644 index 0000000..a6df2a7 --- /dev/null +++ b/src/core/services/ens/scripts/get-recent-registrations.ts @@ -0,0 +1,87 @@ +import { getRecentRegistrations } from '../records.js'; +import { type Chain } from '../types.js'; + +interface ScriptOptions { + count: number; + network: string | Chain; +} + +function parseArgs(): ScriptOptions { + const DEFAULT_COUNT = 10; + const DEFAULT_NETWORK = 'mainnet'; + + try { + const count = process.argv[2] ? parseInt(process.argv[2], 10) : DEFAULT_COUNT; + const network = process.argv[3] || DEFAULT_NETWORK; + + // Validate count + if (isNaN(count) || count < 1) { + throw new Error('Registration count must be a positive number'); + } + + // Validate network (basic check) + if (typeof network !== 'string' || !network.trim()) { + throw new Error('Network must be a valid string'); + } + + return { count, network }; + } catch (error) { + console.error('\nError parsing arguments:', error.message); + console.error('\nUsage: bun run get-recent-registrations.ts [count] [network]'); + console.error(' count : Number of registrations to fetch (default: 10)'); + console.error(' network : Network to query (default: mainnet)'); + console.error('\nExample: bun run get-recent-registrations.ts 5 mainnet'); + process.exit(1); + } +} + +async function displayRegistrations(registrations: Awaited>) { + console.log('\nRecent ENS Registrations:'); + console.log('------------------------'); + + if (registrations.length === 0) { + console.log('\nNo registrations found in the specified range.'); + return; + } + + registrations.forEach((reg, index) => { + console.log(`\n${index + 1}. Name: ${reg.name}`); + console.log(` Owner: ${reg.owner}`); + console.log(` Block: ${reg.blockNumber}`); + console.log(` Transaction: ${reg.transactionHash}`); + }); +} + +async function main() { + try { + const { count, network } = parseArgs(); + + console.log(`Fetching ${count} recent ENS registrations from ${network}...`); + const registrations = await getRecentRegistrations(count, network); + + await displayRegistrations(registrations); + } catch (error) { + if (error instanceof Error) { + console.error('\nError:', error.message); + if (error.stack && process.env.DEBUG) { + console.error('\nStack trace:', error.stack); + } + } else { + console.error('\nUnknown error occurred:', error); + } + process.exit(1); + } +} + +// Add signal handlers for graceful shutdown +process.on('SIGINT', () => { + console.log('\nScript interrupted by user'); + process.exit(0); +}); + +process.on('SIGTERM', () => { + console.log('\nScript terminated'); + process.exit(0); +}); + +main(); \ No newline at end of file diff --git a/src/core/services/ens/ens/subdomains.ts b/src/core/services/ens/subdomains.ts similarity index 100% rename from src/core/services/ens/ens/subdomains.ts rename to src/core/services/ens/subdomains.ts diff --git a/src/core/services/ens/ens/types.ts b/src/core/services/ens/types.ts similarity index 100% rename from src/core/services/ens/ens/types.ts rename to src/core/services/ens/types.ts diff --git a/src/core/services/ens/ens/utils.ts b/src/core/services/ens/utils.ts similarity index 91% rename from src/core/services/ens/ens/utils.ts rename to src/core/services/ens/utils.ts index 994f010..bd4bd81 100644 --- a/src/core/services/ens/ens/utils.ts +++ b/src/core/services/ens/utils.ts @@ -1,4 +1,4 @@ -import { getPublicClient } from '../../clients.js'; +import { getPublicClient } from '../clients.js'; import { type PublicClient, type Chain } from './types.js'; import { mainnet } from 'viem/chains'; diff --git a/src/core/services/ens/ens/wrapping.ts b/src/core/services/ens/wrapping.ts similarity index 100% rename from src/core/services/ens/ens/wrapping.ts rename to src/core/services/ens/wrapping.ts From 9ff9358fef2a763b2ba66ea838d33c0794ae26f0 Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:56:58 -0700 Subject: [PATCH 27/57] chore: remove old script location after moving to ens/scripts --- src/scripts/get-recent-registrations.ts | 32 ------------------------- 1 file changed, 32 deletions(-) delete mode 100644 src/scripts/get-recent-registrations.ts diff --git a/src/scripts/get-recent-registrations.ts b/src/scripts/get-recent-registrations.ts deleted file mode 100644 index ab6c741..0000000 --- a/src/scripts/get-recent-registrations.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getRecentRegistrations } from '../core/services/ens/ens/records.js'; - -// Parse command line arguments -const DEFAULT_COUNT = 10; -const count = process.argv[2] ? parseInt(process.argv[2], 10) : DEFAULT_COUNT; - -if (isNaN(count) || count < 1) { - console.error('Error: Please provide a valid positive number for the registration count.'); - console.error('Usage: bun run src/scripts/get-recent-registrations.ts [count]'); - process.exit(1); -} - -async function main() { - try { - console.log(`Fetching ${count} recent ENS registrations...`); - const registrations = await getRecentRegistrations(count, 'mainnet'); - - console.log('\nRecent ENS Registrations:'); - console.log('------------------------'); - registrations.forEach((reg, index) => { - console.log(`\n${index + 1}. Name: ${reg.name}`); - console.log(` Owner: ${reg.owner}`); - console.log(` Block: ${reg.blockNumber}`); - console.log(` Transaction: ${reg.transactionHash}`); - }); - } catch (error) { - console.error('Error:', error); - process.exit(1); - } -} - -main(); \ No newline at end of file From 163bcda5c8117d0c8936eee3fc5d7f01d6ed2894 Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 07:58:10 -0700 Subject: [PATCH 28/57] feat(ens): add script to display ENS address history --- .../ens/scripts/get-address-history.ts | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/core/services/ens/scripts/get-address-history.ts diff --git a/src/core/services/ens/scripts/get-address-history.ts b/src/core/services/ens/scripts/get-address-history.ts new file mode 100644 index 0000000..ffc7647 --- /dev/null +++ b/src/core/services/ens/scripts/get-address-history.ts @@ -0,0 +1,88 @@ +import { getEnsAddressHistory } from '../history.js'; +import { type Chain } from '../types.js'; + +interface ScriptOptions { + names: string[]; + network: string | Chain; +} + +function parseArgs(): ScriptOptions { + const DEFAULT_NETWORK = 'mainnet'; + + try { + // Get names from command line arguments + const names = process.argv.slice(2).filter(arg => !arg.startsWith('--')); + const networkArg = process.argv.find(arg => arg.startsWith('--network=')); + const network = networkArg ? networkArg.split('=')[1] : DEFAULT_NETWORK; + + // Validate names + if (names.length === 0) { + throw new Error('At least one ENS name must be provided'); + } + + // Validate network (basic check) + if (typeof network !== 'string' || !network.trim()) { + throw new Error('Network must be a valid string'); + } + + return { names, network }; + } catch (error) { + console.error('\nError parsing arguments:', error.message); + console.error('\nUsage: bun run get-address-history.ts [name2] [name3] [--network=]'); + console.error(' name1, name2, name3 : ENS names to query (at least one required)'); + console.error(' --network : Network to query (default: mainnet)'); + console.error('\nExample: bun run get-address-history.ts vitalik.eth --network=mainnet'); + process.exit(1); + } +} + +async function displayAddressHistory(name: string, history: Awaited>) { + console.log(`\nAddress History for ${name}:`); + console.log('------------------------'); + + if (history.length === 0) { + console.log('No address history found.'); + return; + } + + history.forEach((record, index) => { + console.log(`\n${index + 1}. Address: ${record.address}`); + console.log(` Block: ${record.blockNumber}`); + console.log(` Transaction: ${record.transactionHash}`); + }); +} + +async function main() { + try { + const { names, network } = parseArgs(); + + for (const name of names) { + console.log(`\nFetching address history for ${name} from ${network}...`); + const history = await getEnsAddressHistory(name, network); + await displayAddressHistory(name, history); + } + } catch (error) { + if (error instanceof Error) { + console.error('\nError:', error.message); + if (error.stack && process.env.DEBUG) { + console.error('\nStack trace:', error.stack); + } + } else { + console.error('\nUnknown error occurred:', error); + } + process.exit(1); + } +} + +// Add signal handlers for graceful shutdown +process.on('SIGINT', () => { + console.log('\nScript interrupted by user'); + process.exit(0); +}); + +process.on('SIGTERM', () => { + console.log('\nScript terminated'); + process.exit(0); +}); + +main(); \ No newline at end of file From eff355c000cfc0ac20b828ab295e132868a25fca Mon Sep 17 00:00:00 2001 From: accessor Date: Mon, 5 May 2025 08:05:12 -0700 Subject: [PATCH 29/57] refactor(ens): reorganize ENS service into modular structure with individual scripts --- src/core/services/ens/ens/history.ts | 116 ++++++++++++++++++ src/core/services/ens/ens/utils.ts | 16 +++ src/core/services/ens/index.ts | 2 +- .../services/ens/scripts/create-subdomain.ts | 58 +++++++++ .../ens/scripts/get-address-history.ts | 49 +++----- .../ens/scripts/get-ownership-history.ts | 60 +++++++++ .../services/ens/scripts/get-text-record.ts | 55 +++++++++ .../services/ens/scripts/lookup-address.ts | 51 ++++++++ .../services/ens/scripts/resolve-address.ts | 51 ++++++++ src/core/services/ens/types/index.ts | 43 +++++++ src/core/services/ens/utils/index.ts | 1 + 11 files changed, 468 insertions(+), 34 deletions(-) create mode 100644 src/core/services/ens/ens/history.ts create mode 100644 src/core/services/ens/ens/utils.ts create mode 100644 src/core/services/ens/scripts/create-subdomain.ts create mode 100644 src/core/services/ens/scripts/get-ownership-history.ts create mode 100644 src/core/services/ens/scripts/get-text-record.ts create mode 100644 src/core/services/ens/scripts/lookup-address.ts create mode 100644 src/core/services/ens/scripts/resolve-address.ts create mode 100644 src/core/services/ens/types/index.ts create mode 100644 src/core/services/ens/utils/index.ts diff --git a/src/core/services/ens/ens/history.ts b/src/core/services/ens/ens/history.ts new file mode 100644 index 0000000..5a5df58 --- /dev/null +++ b/src/core/services/ens/ens/history.ts @@ -0,0 +1,116 @@ +import { normalize, namehash } from 'viem/ens'; +import { type Address, type Chain, type Hash } from './types.js'; +import { getClients } from './utils.js'; +import { mainnet } from 'viem/chains'; + +/** + * Gets the ownership history of an ENS name. + * @param name The ENS name to query. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an array of ownership records. + */ +export async function getEnsOwnershipHistory( + name: string, + network: string | Chain = mainnet +): Promise> { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const result = await publicClient.readContract({ + address: (await import('./utils.js')).ENS_REGISTRY_ADDRESS, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'getOwnershipHistory', + outputs: [ + { + components: [ + { name: 'owner', type: 'address' }, + { name: 'timestamp', type: 'uint256' }, + { name: 'transactionHash', type: 'bytes32' }, + ], + name: 'history', + type: 'tuple[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + ], + functionName: 'getOwnershipHistory', + args: [namehash(normalizedEns)], + }); + return (result as any[]).map((record: any) => ({ + owner: record.owner as Address, + timestamp: Number(record.timestamp), + transactionHash: record.transactionHash as Hash, + })); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get ownership history for "${name}". Reason: ${message} [Error Code: GetEnsOwnershipHistory_General_001]` + ); + } +} + +/** + * Gets the address record history of an ENS name. + * @param name The ENS name to query. + * @param network Optional. The target blockchain network. Defaults to Ethereum mainnet. + * @returns A Promise that resolves to an array of address records. + */ +export async function getEnsAddressHistory( + name: string, + network: string | Chain = mainnet +): Promise> { + try { + const normalizedEns = normalize(name); + const { publicClient } = await getClients(network); + const result = await publicClient.readContract({ + address: (await import('./utils.js')).ENS_REGISTRY_ADDRESS, + abi: [ + { + inputs: [ + { name: 'node', type: 'bytes32' }, + ], + name: 'getAddressHistory', + outputs: [ + { + components: [ + { name: 'address', type: 'address' }, + { name: 'timestamp', type: 'uint256' }, + { name: 'transactionHash', type: 'bytes32' }, + ], + name: 'history', + type: 'tuple[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + ], + functionName: 'getAddressHistory', + args: [namehash(normalizedEns)], + }); + return (result as any[]).map((record: any) => ({ + address: record.address as Address, + timestamp: Number(record.timestamp), + transactionHash: record.transactionHash as Hash, + })); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to get address history for "${name}". Reason: ${message} [Error Code: GetEnsAddressHistory_General_001]` + ); + } +} \ No newline at end of file diff --git a/src/core/services/ens/ens/utils.ts b/src/core/services/ens/ens/utils.ts new file mode 100644 index 0000000..994f010 --- /dev/null +++ b/src/core/services/ens/ens/utils.ts @@ -0,0 +1,16 @@ +import { getPublicClient } from '../../clients.js'; +import { type PublicClient, type Chain } from './types.js'; +import { mainnet } from 'viem/chains'; + +// ENS Registry address +export const ENS_REGISTRY_ADDRESS = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e' as const; + +/** + * Utility function to get initialized clients for a network + * @param network Network name or chain + * @returns Object containing public client + */ +export async function getClients(network: string | Chain = mainnet) { + const publicClient = getPublicClient(typeof network === 'string' ? network : undefined) as PublicClient; + return { publicClient }; +} \ No newline at end of file diff --git a/src/core/services/ens/index.ts b/src/core/services/ens/index.ts index c3ddf63..6667d76 100644 --- a/src/core/services/ens/index.ts +++ b/src/core/services/ens/index.ts @@ -23,4 +23,4 @@ export { getEnsOwnershipHistory } from './history.js'; export { getEnsAddressHistory } from './history.js'; // Types -export type { EnsOwnershipRecord, EnsAddressRecord } from './types.js'; \ No newline at end of file +export type { EnsOwnershipRecord, EnsAddressRecord } from './types/index.js'; \ No newline at end of file diff --git a/src/core/services/ens/scripts/create-subdomain.ts b/src/core/services/ens/scripts/create-subdomain.ts new file mode 100644 index 0000000..5fee3f9 --- /dev/null +++ b/src/core/services/ens/scripts/create-subdomain.ts @@ -0,0 +1,58 @@ +import { createEnsSubdomain } from '../subdomains.js'; +import { type Chain } from '../types/index.js'; + +interface ScriptOptions { + parentName: string; + label: string; + owner: string; + network: string | Chain; +} + +function parseArgs(): ScriptOptions { + const DEFAULT_NETWORK = 'mainnet'; + + try { + const parentName = process.argv[2]; + const label = process.argv[3]; + const owner = process.argv[4]; + const networkArg = process.argv.find(arg => arg.startsWith('--network=')); + const network = networkArg ? networkArg.split('=')[1] : DEFAULT_NETWORK; + + if (!parentName) { + throw new Error('Parent ENS name is required'); + } + if (!label) { + throw new Error('Subdomain label is required'); + } + if (!owner) { + throw new Error('Owner address is required'); + } + + return { parentName, label, owner, network }; + } catch (error) { + console.error('\nError parsing arguments:', error instanceof Error ? error.message : String(error)); + console.error('\nUsage: bun run create-subdomain.ts