diff --git a/readme.md b/readme.md index 4c415e3..ce7fdb9 100644 --- a/readme.md +++ b/readme.md @@ -29,8 +29,8 @@ Measured with esbuild. Smaller is better. | What you import | essential-eth@1.0.0 | ethers@6.16.0 | viem@2.45.1 | web3@4.16.0 | ox@0.12.0 | | ---------------------------------------- | :-----------------: | :-----------: | :---------: | :---------: | :------------: | -| **Full library** | **42.2 kB** 🏆 | 394.0 kB | 348.3 kB | 495.8 kB | 612.8 kB | -| **Provider** (getBalance, getBlock, etc) | 29.9 kB | 260.0 kB | 269.5 kB | 454.5 kB | **10.9 kB** 🏆 | +| **Full library** | **43.1 kB** 🏆 | 394.0 kB | 348.3 kB | 495.8 kB | 612.8 kB | +| **Provider** (getBalance, getBlock, etc) | 30.8 kB | 260.0 kB | 269.5 kB | 454.5 kB | **10.9 kB** 🏆 | | **Contract** (read-only calls) | **24.8 kB** 🏆 | 86.6 kB | 179.8 kB | 264.9 kB | 49.9 kB | | **Conversions** (wei, gwei, ether) | **1.2 kB** 🏆 | 10.4 kB | 2.7 kB | 454.5 kB | 3.7 kB | diff --git a/src/providers/AlchemyProvider.ts b/src/providers/AlchemyProvider.ts index 1a135f2..7e2f956 100644 --- a/src/providers/AlchemyProvider.ts +++ b/src/providers/AlchemyProvider.ts @@ -2,7 +2,7 @@ import { JsonRpcProvider } from './JsonRpcProvider'; export class AlchemyProvider extends JsonRpcProvider { constructor(apiKey: string, network = 'mainnet') { - const alchemyUrl = `https://eth-${network}.alchemyapi.io/v2/${apiKey}`; + const alchemyUrl = `https://eth-${network}.g.alchemy.com/v2/${apiKey}`; super(alchemyUrl); } } diff --git a/src/providers/BaseProvider.ts b/src/providers/BaseProvider.ts index 5decde0..d4927f6 100644 --- a/src/providers/BaseProvider.ts +++ b/src/providers/BaseProvider.ts @@ -664,4 +664,127 @@ export abstract class BaseProvider { return toChecksumAddress(rawAddress); } + + /** + * Performs reverse ENS resolution to get the ENS name associated with an address. + * + * Performs the full ENS reverse resolution process: + * 1. Formats the address as a reverse lookup: `{address}.addr.reverse` + * 2. Computes the namehash of the reverse name + * 3. Queries the ENS Registry for the resolver contract + * 4. Queries the resolver for the name + * 5. Verifies the name resolves back to the original address (per ENSIP-3) + * + * * [Identical](/docs/api#isd) to [`viem.getEnsName`](https://viem.sh/docs/ens/actions/getEnsName) in viem + * @param address the Ethereum address to look up (e.g. '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045') + * @returns the ENS name the address resolves to, or null if not found or verification fails + * @example + * ```javascript + * await provider.lookupAddress('0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'); + * // 'vitalik.eth' + * ``` + * @example + * ```javascript + * await provider.lookupAddress('0x0000000000000000000000000000000000000000'); + * // null + * ``` + */ + public async lookupAddress(address: string): Promise { + const ENS_REGISTRY = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e'; + const RESOLVER_SELECTOR = '0x0178b8bf'; // resolver(bytes32) + const NAME_SELECTOR = '0x691f3431'; // name(bytes32) + const ZERO_ADDRESS = + '0x0000000000000000000000000000000000000000000000000000000000000000'; + + // Format the address for reverse lookup: {address}.addr.reverse + const addressLower = address.toLowerCase(); + const addressWithoutPrefix = addressLower.startsWith('0x') + ? addressLower.slice(2) + : addressLower; + const reverseName = `${addressWithoutPrefix}.addr.reverse`; + + // Compute the namehash of the reverse name + const node = namehash(reverseName); + const nodeWithoutPrefix = node.slice(2); + + // Step 1: Get the resolver address from the ENS Registry + const resolverData = RESOLVER_SELECTOR + nodeWithoutPrefix; + const resolverResult = await this.call({ + to: ENS_REGISTRY, + data: resolverData, + }); + + // If no resolver is set, return null + if (!resolverResult || resolverResult === ZERO_ADDRESS) { + return null; + } + + // Extract resolver address from the 32-byte response (last 20 bytes = 40 hex chars) + const resolverAddress = '0x' + resolverResult.slice(26); + + // Check if resolver is zero address + if ( + resolverAddress === '0x0000000000000000000000000000000000000000' || + resolverAddress === '0x' + '0'.repeat(resolverResult.length - 2) // all zeros + ) { + return null; + } + + // Step 2: Get the name from the resolver + const nameData = NAME_SELECTOR + nodeWithoutPrefix; + const nameResult = await this.call({ + to: resolverAddress, + data: nameData, + }); + + // If no name is set, return null + if (!nameResult || nameResult === ZERO_ADDRESS) { + return null; + } + + // Decode the returned name string + // The result is ABI-encoded as a string: offset (32 bytes) + length (32 bytes) + data + // The offset at position 0 typically points to 0x20 (32 bytes) + // At that offset, we have: length (32 bytes) + string data (padded to 32 bytes) + + // Skip 0x and first 64 chars (offset value) + const lengthHex = nameResult.slice(2 + 64, 2 + 128); + const length = parseInt(lengthHex, 16); + + if (length === 0) { + return null; + } + + // The string data starts after the length (position 2 + 128) + const dataHex = nameResult.slice(2 + 128); + + // Convert hex to string + const name = dataHex + .slice(0, length * 2) // length is in bytes, so multiply by 2 for hex chars + .match(/.{1,2}/g) + ?.map((byte) => String.fromCharCode(parseInt(byte, 16))) + .join(''); + + if (!name) { + return null; + } + + // Step 3: Verify the name resolves back to the original address (ENSIP-3) + try { + const verifyAddress = await this.resolveName(name); + const normalizedAddress = addressLower.startsWith('0x') + ? addressLower + : '0x' + addressLower; + const normalizedVerifyAddress = verifyAddress?.toLowerCase(); + + if (normalizedVerifyAddress !== normalizedAddress) { + return null; + } + } catch { + // If verification fails, return null + return null; + } + + return name; + } } diff --git a/src/providers/test/lookup-address.test.ts b/src/providers/test/lookup-address.test.ts new file mode 100644 index 0000000..c0583bf --- /dev/null +++ b/src/providers/test/lookup-address.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { jsonRpcProvider } from '../..'; +import { rpcUrls } from './rpc-urls'; + +// These are integration tests that require network access. +// They will call the ENS Registry and resolver contracts on mainnet. +describe('provider.lookupAddress', () => { + const provider = jsonRpcProvider(rpcUrls.mainnet); + + it('should resolve vitalik.eth address to the correct name', async () => { + const name = await provider.lookupAddress( + '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ); + expect(name).toBe('vitalik.eth'); + }); + + it('should return null for an address without a reverse record', async () => { + // Using a random address that shouldn't have a reverse record + const name = await provider.lookupAddress( + '0x0000000000000000000000000000000000000001', + ); + expect(name).toBeNull(); + }); + + it('should handle lowercase addresses', async () => { + const name = await provider.lookupAddress( + '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', + ); + expect(name).toBe('vitalik.eth'); + }); + + it('should handle mixed case addresses', async () => { + const name = await provider.lookupAddress( + '0xD8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + ); + expect(name).toBe('vitalik.eth'); + }); + + it('should verify forward resolution matches (ENSIP-3)', async () => { + const address = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'; + const name = await provider.lookupAddress(address); + expect(name).not.toBeNull(); + + // Verify that the name resolves back to the same address + if (name) { + const verifiedAddress = await provider.resolveName(name); + expect(verifiedAddress?.toLowerCase()).toBe(address.toLowerCase()); + } + }); +});