diff --git a/package-lock.json b/package-lock.json index f368f26..b11a6b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@fileverse/crypto", - "version": "0.0.19", + "version": "0.0.19-viem-1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@fileverse/crypto", - "version": "0.0.19", + "version": "0.0.19-viem-1", "license": "ISC", "dependencies": { "@noble/ciphers": "^2.1.1", @@ -21,14 +21,25 @@ "@vitest/ui": "^3.1.2", "terser": "^5.28.1", "typescript": "^5.8.3", + "viem": "^2.13.8", "vite": "^6.3.3", "vite-plugin-dts": "^4.5.3", "vitest": "^3.1.2" }, "engines": { "node": ">=16.0.0" + }, + "peerDependencies": { + "viem": "^2.13.8" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", @@ -1112,6 +1123,87 @@ "string-argv": "~0.3.1" } }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@types/argparse": { "version": "1.0.38", "resolved": "https://registry.npmjs.org/@types/argparse/-/argparse-1.0.38.tgz", @@ -1404,6 +1496,28 @@ "dev": true, "license": "MIT" }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/acorn": { "version": "8.14.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", @@ -1701,6 +1815,13 @@ "dev": true, "license": "MIT" }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "dev": true, + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz", @@ -1860,6 +1981,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/jju": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/jju/-/jju-1.4.0.tgz", @@ -2043,6 +2180,79 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/ox": { + "version": "0.14.20", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.20.tgz", + "integrity": "sha512-rby38C3nDn8eQkf29Zgw4hkCZJ64Qqi0zRPWL8ENUQ7JVuoITqrVtwWQgM/He19SCMUEc7hS/Sjw0jIOSLJhOw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ox/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -2542,6 +2752,66 @@ "punycode": "^2.1.0" } }, + "node_modules/viem": { + "version": "2.48.4", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.48.4.tgz", + "integrity": "sha512-mReP/rgY2P+WeeRSG4sUvccCLKfyAW1C73Y3KkobAqgzYmVna9qyUMNE44xIUkDtfvRuC33r24UhF4baBYovsg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.20", + "ws": "8.18.3" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/vite": { "version": "6.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz", @@ -2775,6 +3045,28 @@ "node": ">=8" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", diff --git a/package.json b/package.json index 045e19d..0243c51 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fileverse/crypto", - "version": "0.0.19", + "version": "0.0.19-viem-1", "description": "Crypto functions and helpers", "type": "module", "exports": { @@ -39,6 +39,11 @@ "import": "./dist/nacl/index.js", "require": "./dist/nacl/index.cjs" }, + "./ens": { + "types": "./dist/ens/index.d.ts", + "import": "./dist/ens/index.js", + "require": "./dist/ens/index.cjs" + }, "./utils": { "types": "./dist/utils/index.d.ts", "import": "./dist/utils/index.js", @@ -86,6 +91,7 @@ "@vitest/ui": "^3.1.2", "terser": "^5.28.1", "typescript": "^5.8.3", + "viem": "^2.13.8", "vite": "^6.3.3", "vite-plugin-dts": "^4.5.3", "vitest": "^3.1.2" @@ -97,5 +103,8 @@ "@peculiar/webcrypto": "^1.5.0", "js-base64": "^3.7.7", "tweetnacl": "^1.0.3" + }, + "peerDependencies": { + "viem": "^2.13.8" } -} +} \ No newline at end of file diff --git a/src/ens/__tests__/core.test.ts b/src/ens/__tests__/core.test.ts new file mode 100644 index 0000000..27bae23 --- /dev/null +++ b/src/ens/__tests__/core.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const getEnsName = vi.fn(); +const createPublicClient = vi.fn(() => ({ getEnsName })); +const http = vi.fn((url?: string) => ({ __transport: "http", url })); + +vi.mock("viem", async () => { + const actual = await vi.importActual("viem"); + return { + ...actual, + createPublicClient: (...args: unknown[]) => createPublicClient(...args as []), + http: (...args: unknown[]) => http(...(args as [string?])), + }; +}); + +import { getAddressName, resolveEnsAddress } from "../index"; +import { __resetMainnetClientsForTest } from "../client"; + +const VALID_ADDRESS = "0x1234567890123456789012345678901234567890"; +const PROVIDER_URL = "https://eth.example/rpc"; + +beforeEach(() => { + vi.clearAllMocks(); + __resetMainnetClientsForTest(); +}); + +describe("ens/getAddressName", () => { + it("returns ENS name when resolver hits", async () => { + getEnsName.mockResolvedValueOnce("vitalik.eth"); + + const result = await getAddressName(VALID_ADDRESS, PROVIDER_URL); + + expect(result).toEqual({ + name: "vitalik.eth", + isEns: true, + resolved: true, + }); + expect(getEnsName).toHaveBeenCalledWith({ address: VALID_ADDRESS }); + }); + + it("returns address with resolved=true when no ENS exists", async () => { + getEnsName.mockResolvedValueOnce(null); + + const result = await getAddressName(VALID_ADDRESS, PROVIDER_URL); + + expect(result).toEqual({ + name: VALID_ADDRESS, + isEns: false, + resolved: true, + }); + }); + + it("short-circuits non-address inputs without calling viem", async () => { + const result = await getAddressName("not-an-address", PROVIDER_URL); + + expect(result).toEqual({ + name: "not-an-address", + isEns: false, + resolved: false, + }); + expect(createPublicClient).not.toHaveBeenCalled(); + expect(getEnsName).not.toHaveBeenCalled(); + }); + + it("throws when providerUrl is missing", async () => { + await expect(getAddressName(VALID_ADDRESS, "")).rejects.toThrow( + /provider url/i, + ); + }); + + it("returns unresolved default and swallows viem errors", async () => { + const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + getEnsName.mockRejectedValueOnce(new Error("network down")); + + const result = await getAddressName(VALID_ADDRESS, PROVIDER_URL); + + expect(result).toEqual({ + name: VALID_ADDRESS, + isEns: false, + resolved: false, + }); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); +}); + +describe("ens client memoization", () => { + it("reuses the client for the same providerUrl", async () => { + getEnsName.mockResolvedValue(null); + + await resolveEnsAddress(VALID_ADDRESS, PROVIDER_URL); + await resolveEnsAddress(VALID_ADDRESS, PROVIDER_URL); + + expect(createPublicClient).toHaveBeenCalledTimes(1); + }); + + it("creates separate clients for distinct URLs", async () => { + getEnsName.mockResolvedValue(null); + + await resolveEnsAddress(VALID_ADDRESS, PROVIDER_URL); + await resolveEnsAddress(VALID_ADDRESS, "https://other.example/rpc"); + + expect(createPublicClient).toHaveBeenCalledTimes(2); + }); +}); \ No newline at end of file diff --git a/src/ens/client.ts b/src/ens/client.ts new file mode 100644 index 0000000..ce41b4b --- /dev/null +++ b/src/ens/client.ts @@ -0,0 +1,30 @@ +import { createPublicClient, http, type PublicClient } from "viem"; +import { mainnet } from "viem/chains"; + +const clientsByUrl = new Map(); + +/** + * Get a memoized viem mainnet `PublicClient` for the given RPC URL. + * + * One client is created per distinct URL and cached for the lifetime of the + * module, so repeated callers share connection pooling and avoid the cost of + * re-instantiating a client on every lookup. + * + * @param url - Mainnet RPC endpoint URL. + */ +export const getMainnetClient = (url: string): PublicClient => { + const existing = clientsByUrl.get(url); + if (existing) return existing; + + const client = createPublicClient({ + transport: http(url), + chain: mainnet, + }); + clientsByUrl.set(url, client); + return client; +}; + +/** @internal Test-only: clear the memoized client map between cases. */ +export const __resetMainnetClientsForTest = () => { + clientsByUrl.clear(); +}; \ No newline at end of file diff --git a/src/ens/index.ts b/src/ens/index.ts new file mode 100644 index 0000000..559e25d --- /dev/null +++ b/src/ens/index.ts @@ -0,0 +1,87 @@ +import { type Hex, isAddress } from "viem"; +import { getMainnetClient } from "./client"; + +/** + * Result of {@link getAddressName}. + * + * - `name`: the ENS name when one was found, otherwise the input `address`. + * - `isEns`: `true` only when an ENS name was resolved. + * - `resolved`: `true` when the lookup completed (hit or confirmed miss); + * `false` when the input wasn't a valid address or the lookup threw. + * Use this to decide whether the result is safe to cache. + */ +export interface AddressNameResult { + name: string; + isEns: boolean; + resolved: boolean; +} + +/** + * Look up the primary ENS name for an Ethereum mainnet address. + * + * Thin wrapper over viem's `getEnsName`. Reuses a memoized mainnet + * `PublicClient` per `providerUrl`, so it's safe to call in a hot path. + * Errors and invalid inputs propagate — prefer {@link getAddressName} for a + * caller-friendly result shape. + * + * @param address - Ethereum address (0x-prefixed hex). Not validated here. + * @param providerUrl - Mainnet RPC endpoint that supports ENS resolution. + * @returns The ENS name if one is set, otherwise `null`. + */ +export const resolveEnsAddress = async ( + address: string, + providerUrl: string, +): Promise => { + const client = getMainnetClient(providerUrl); + return client.getEnsName({ address: address as Hex }); +}; + +/** + * Resolve an address to its display name, falling back to the address itself. + * + * Behavior: + * - If `address` is not a valid Ethereum address, returns `{name: address, + * isEns: false, resolved: false}` without making a network call. + * - On a successful lookup with an ENS name, returns + * `{name: ensName, isEns: true, resolved: true}`. + * - On a successful lookup with no ENS name, returns + * `{name: address, isEns: false, resolved: true}`. + * - On a viem/network error, logs and returns `{name: address, isEns: false, + * resolved: false}` — `resolved: false` signals "don't cache this". + * + * @param address - Ethereum address to resolve. + * @param providerUrl - Mainnet RPC endpoint. Required; throws if empty. + * @throws If `providerUrl` is falsy. + */ +export const getAddressName = async ( + address: string, + providerUrl: string, +): Promise => { + if (!providerUrl) { + throw new Error("cannot fetch ens name without a provider url"); + } + + const response: AddressNameResult = { + name: address, + isEns: false, + resolved: false, + }; + + if (!isAddress(address)) return response; + + try { + const ensName = await resolveEnsAddress(address, providerUrl); + if (ensName) { + response.name = ensName; + response.isEns = true; + response.resolved = true; + } else { + response.name = address; + response.isEns = false; + response.resolved = true; + } + } catch (error) { + console.log(error); + } + return response; +}; \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 3541fb7..9d9d6e7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from "./argon"; export * from "./constants"; export * from "./ecies"; +export * from "./ens"; export * from "./kdf"; export * from "./nacl"; export * from "./utils"; diff --git a/vite.config.ts b/vite.config.ts index 256a093..414e4f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -59,9 +59,11 @@ export default defineConfig({ "@noble/curves", "@noble/hashes", "js-base64", + "viem", /^@noble\/ciphers\/.*/, /^@noble\/curves\/.*/, /^@noble\/hashes\/.*/, + /^viem\/.*/, ], output: [ {