Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
2 changes: 1 addition & 1 deletion src/providers/AlchemyProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
123 changes: 123 additions & 0 deletions src/providers/BaseProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
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;
}
}
50 changes: 50 additions & 0 deletions src/providers/test/lookup-address.test.ts
Original file line number Diff line number Diff line change
@@ -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());
}
});
});
Loading