diff --git a/.gitignore b/.gitignore index eea5380..1718219 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ test-results/ .superpowers/ .kilo/ kilo.json +.local-sdk/ diff --git a/app/api/register/[name]/fees/[coin]/route.ts b/app/api/register/[name]/fees/[coin]/route.ts index a6d9aba..45388b8 100644 --- a/app/api/register/[name]/fees/[coin]/route.ts +++ b/app/api/register/[name]/fees/[coin]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import type { BYOCSymbol } from "@metanames/sdk/dist/providers/config"; +import type { BYOCSymbol } from "@metanames/sdk/providers/config"; import { getServerSdk } from "@/lib/sdk"; import { handleError, jsonError } from "@/lib/server-error"; import { validateDomainName, normalizeDomain } from "@/lib/domain-validator"; diff --git a/app/tld/page.tsx b/app/tld/page.tsx index 3f3d3ce..b1ffa6c 100644 --- a/app/tld/page.tsx +++ b/app/tld/page.tsx @@ -3,7 +3,7 @@ import { getServerSdk } from "@/lib/sdk"; import { TldPageClient } from "./TldPageClient"; import Loading from "./loading"; import type { Domain } from "@/lib/types"; -import type { Domain as SdkDomain } from "@metanames/sdk/dist/models/domain"; +import type { Domain as SdkDomain } from "@metanames/sdk/models/domain"; export const metadata = { title: "TLD Information", diff --git a/components/domain-payment.tsx b/components/domain-payment.tsx index ab063fd..5bfeed4 100644 --- a/components/domain-payment.tsx +++ b/components/domain-payment.tsx @@ -14,7 +14,7 @@ import { RequireWalletConnection } from "@/components/require-wallet-connection" import { useDomainPayment } from "@/lib/hooks/use-domain-payment"; import { Minus, Plus, Loader2, Check } from "lucide-react"; import { cn } from "@/lib/utils"; -import type { BYOCSymbol as SdkBYOCSymbol } from "@metanames/sdk/dist/providers/config"; +import type { BYOCSymbol as SdkBYOCSymbol } from "@metanames/sdk/providers/config"; interface DomainPaymentProps { domain: string; diff --git a/components/subdomain-registration.tsx b/components/subdomain-registration.tsx index 2efa5c6..800c2e8 100644 --- a/components/subdomain-registration.tsx +++ b/components/subdomain-registration.tsx @@ -7,7 +7,7 @@ import { useWalletStore } from "@/lib/stores/wallet-store"; import { useSdkStore } from "@/lib/stores/sdk-store"; import { explorerTransactionUrl } from "@/lib/url"; import { toast } from "sonner"; -import type { BYOCSymbol as SdkBYOCSymbol } from "@metanames/sdk/dist/providers/config"; +import type { BYOCSymbol as SdkBYOCSymbol } from "@metanames/sdk/providers/config"; interface SubdomainRegistrationProps { domain: string; diff --git a/lib/__tests__/wallet-connect.test.ts b/lib/__tests__/wallet-connect.test.ts index 22050ac..b33b3c6 100644 --- a/lib/__tests__/wallet-connect.test.ts +++ b/lib/__tests__/wallet-connect.test.ts @@ -19,7 +19,7 @@ vi.mock("@ledgerhq/hw-transport-webusb", () => ({ })); const ledgerGetAddress = vi.fn(); -vi.mock("@metanames/sdk/dist/transactions/ledger", () => ({ +vi.mock("@metanames/sdk/transactions/ledger", () => ({ PartisiaLedgerClient: class { constructor(public transport: unknown) {} getAddress = ledgerGetAddress; @@ -56,17 +56,20 @@ function mockSdk() { }; } -/** Install (or remove) the MetaMask-shaped globals the connector reads. */ +/** Install (or remove) the injected provider the connector reads. */ function setEthereum( - value: { isMetaMask?: boolean; request?: unknown } | null, + value: { + isMetaMask?: boolean; + request?: unknown; + providers?: unknown[]; + } | null, ) { const w = window as unknown as Record; if (value === null) { - delete w.isMetaMask; - delete w.request; + delete w.ethereum; return; } - Object.assign(w, value); + w.ethereum = value; } describe("connectMetaMask", () => { @@ -99,6 +102,53 @@ describe("connectMetaMask", () => { expect(sdk.setSigningStrategy).not.toHaveBeenCalled(); }); + // Extensions inject on `window.ethereum`, never on `window` itself: a + // connector reading the flag off the window finds nothing in a real browser. + it("refuses when the flag is on the window instead of the provider", async () => { + setEthereum(null); + const w = window as unknown as Record; + w.isMetaMask = true; + w.request = vi.fn(); + const sdk = mockSdk(); + + await expect(connectMetaMask(sdk)).rejects.toThrow("MetaMask not found"); + + delete w.isMetaMask; + delete w.request; + }); + + // Several wallets installed at once: the last one to load owns + // `window.ethereum` and lists the others under `providers`. + it("finds MetaMask among several injected providers", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce("mm-address"); + setEthereum({ + isMetaMask: false, + request: vi.fn(), + providers: [ + { isMetaMask: false, request: vi.fn() }, + { isMetaMask: true, request }, + ], + }); + const sdk = mockSdk(); + + await expect(connectMetaMask(sdk)).resolves.toBe("mm-address"); + }); + + // The snap answers with the address itself; older builds wrapped it. + it("accepts the address as a bare string", async () => { + const request = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce("bare-address"); + setEthereum({ isMetaMask: true, request }); + const sdk = mockSdk(); + + await expect(connectMetaMask(sdk)).resolves.toBe("bare-address"); + }); + // A snap that resolves without an address must not leave the app "connected" // to an account it cannot name. it("refuses when the snap returns no address", async () => { diff --git a/lib/hooks/use-domain-payment.ts b/lib/hooks/use-domain-payment.ts index 08a6274..354a4b3 100644 --- a/lib/hooks/use-domain-payment.ts +++ b/lib/hooks/use-domain-payment.ts @@ -15,7 +15,7 @@ import { } from "@/lib/error"; import { bridgeUrl, explorerTransactionUrl } from "@/lib/url"; import type { FeesResponse } from "@/lib/types"; -import type { BYOCSymbol } from "@metanames/sdk/dist/providers/config"; +import type { BYOCSymbol } from "@metanames/sdk/providers/config"; import { toast } from "sonner"; import { track } from "@vercel/analytics"; diff --git a/lib/stores/sdk-store.ts b/lib/stores/sdk-store.ts index 2a9c322..bc6e0c4 100644 --- a/lib/stores/sdk-store.ts +++ b/lib/stores/sdk-store.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { MetaNamesSdk } from "@metanames/sdk"; -import type { BYOCSymbol } from "@metanames/sdk/dist/providers/config"; +import type { BYOCSymbol } from "@metanames/sdk/providers/config"; interface SdkStore { metaNamesSdk: MetaNamesSdk | null; diff --git a/lib/types.ts b/lib/types.ts index d86f4b6..9f474da 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -26,7 +26,7 @@ export interface AlertMessage { message: string; action?: { label: string; onClick: () => void }; } -import type { BYOCSymbol as SdkBYOCSymbol } from "@metanames/sdk/dist/providers/config"; +import type { BYOCSymbol as SdkBYOCSymbol } from "@metanames/sdk/providers/config"; export type BYOCSymbol = SdkBYOCSymbol; // Testnet coins - for mainnet coins use sdk.config.byoc at runtime diff --git a/lib/wallet.ts b/lib/wallet.ts index fa25659..9ef0373 100644 --- a/lib/wallet.ts +++ b/lib/wallet.ts @@ -1,29 +1,51 @@ import type { MetaNamesSdk } from "@metanames/sdk"; -import type { MetaMaskSdk } from "@metanames/sdk/dist/interface"; +import type { MetaMaskSdk } from "@metanames/sdk/interface"; import type { PermissionTypes } from "partisia-blockchain-applications-sdk/lib/sdk-listeners"; import { config } from "./config"; interface EthereumProvider extends MetaMaskSdk { isMetaMask?: boolean; + /** Set when several wallet extensions are installed side by side. */ + providers?: EthereumProvider[]; +} + +/** + * The MetaMask provider, or nothing when it is not installed. + * + * Extensions inject themselves on `window.ethereum`, not on `window`. When more + * than one is installed they share that slot: whichever loaded last owns it and + * the rest are listed under `providers`, so the flag has to be checked on each + * entry rather than on the slot itself. + */ +function metaMaskProvider(): EthereumProvider | undefined { + const injected = (window as { ethereum?: EthereumProvider }).ethereum; + if (!injected) return undefined; + if (injected.providers?.length) + return injected.providers.find((provider) => provider.isMetaMask); + + return injected.isMetaMask ? injected : undefined; } export async function connectMetaMask(sdk: MetaNamesSdk): Promise { - const eth = window as unknown as EthereumProvider; - if (!eth?.isMetaMask) throw new Error("MetaMask not found"); + const eth = metaMaskProvider(); + if (!eth) throw new Error("MetaMask not found"); await eth.request({ method: "wallet_requestSnaps", params: { "npm:@partisiablockchain/snap": {} }, }); + // The snap answers `get_address` with the address itself. Older builds + // wrapped it in an object, so both shapes are read. const res = (await eth.request({ method: "wallet_invokeSnap", params: { snapId: "npm:@partisiablockchain/snap", request: { method: "get_address" }, }, - })) as { address?: string }; - if (!res?.address) throw new Error("No address from MetaMask"); + })) as string | { address?: string } | undefined; + const address = typeof res === "string" ? res : res?.address; + if (!address) throw new Error("No address from MetaMask"); sdk.setSigningStrategy("MetaMask", eth); - return res.address; + return address; } export async function connectPartisiaWallet( sdk: MetaNamesSdk, @@ -45,7 +67,7 @@ export async function connectLedger(sdk: MetaNamesSdk): Promise { const { default: TransportWebUSB } = await import("@ledgerhq/hw-transport-webusb"); const { PartisiaLedgerClient } = - await import("@metanames/sdk/dist/transactions/ledger"); + await import("@metanames/sdk/transactions/ledger"); const transport = await TransportWebUSB.create(); const client = new PartisiaLedgerClient(transport); const address = await client.getAddress(); diff --git a/package-lock.json b/package-lock.json index 3cb489d..c81c59b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@base-ui/react": "^1.3.0", "@ledgerhq/hw-transport-webusb": "^6.33.0", - "@metanames/sdk": "^6.3.1", + "@metanames/sdk": "^7.0.0", "@sentry/nextjs": "^10.47.0", "@tanstack/react-table": "^8.21.3", "@vercel/analytics": "^2.0.1", @@ -2182,20 +2182,33 @@ "license": "Apache-2.0" }, "node_modules/@metanames/sdk": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@metanames/sdk/-/sdk-6.3.1.tgz", - "integrity": "sha512-jIDXeeTvgQHwm3Sx3NLgA+8l8IdLHhD4M9DuD1M2ozqVXj7q6veB0DWLiVWy/UO+NtRXwgpHhoJ78fRJHzHBJg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@metanames/sdk/-/sdk-7.0.0.tgz", + "integrity": "sha512-ydl9cxSW1oU14tJUtz0p+zi9NVeGBNPagw862+TIaoc7bvYVeENIC5iizyNjA9BpLPviSoQplKn7i9TN5hMykA==", "license": "MIT", "dependencies": { "@ledgerhq/hw-transport": "^6.34.0", "@partisiablockchain/abi-client": "^6.0.0", + "@partisiablockchain/blockchain-api-transaction-client": "^6.142.0", "@secata-public/bitmanipulation-ts": "^3.4.0", "bip32-path": "^0.4.2", - "node-fetch": "2", - "partisia-blockchain-applications-crypto": "^1.0.34", - "partisia-blockchain-applications-rpc": "^1.0.13", - "partisia-blockchain-applications-sdk": "^0.1.4", - "tr46": "^4.1.1" + "partisia-blockchain-applications-sdk": "^0.1.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@metanames/sdk/node_modules/@partisiablockchain/blockchain-api-transaction-client": { + "version": "6.142.0", + "resolved": "https://registry.npmjs.org/@partisiablockchain/blockchain-api-transaction-client/-/blockchain-api-transaction-client-6.142.0.tgz", + "integrity": "sha512-3VOVBlinzZq815aTfNmO9DnN3gkIVo6N70JN61zPCgT1GQz8leror1Z4jAq7q8bAQOvZgZQlRMLDudFxm57Arw==", + "license": "AGPL-3.0", + "dependencies": { + "@secata-public/bitmanipulation-ts": "^3.1.0", + "@types/elliptic": "^6.4.15", + "bn.js": "^5.2.1", + "elliptic": "^6.5.5", + "hash.js": "^1.1.7" } }, "node_modules/@modelcontextprotocol/sdk": { @@ -6677,12 +6690,6 @@ "node": ">= 0.4" } }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -6708,26 +6715,6 @@ "node": ">=4" } }, - "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios/node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -7362,18 +7349,6 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -7791,15 +7766,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -8132,6 +8098,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9242,26 +9209,6 @@ "dev": true, "license": "ISC" }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9277,22 +9224,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -11671,6 +11602,7 @@ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.6" } @@ -11680,6 +11612,7 @@ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", + "peer": true, "dependencies": { "mime-db": "1.52.0" }, @@ -12517,15 +12450,6 @@ "node": ">=16.20.0" } }, - "node_modules/partisia-blockchain-applications-rpc": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/partisia-blockchain-applications-rpc/-/partisia-blockchain-applications-rpc-1.0.13.tgz", - "integrity": "sha512-4W1RLkIzruhBMql4Ls7W2n2oIEiGhpbVy3g78QOw/Eb8BX6xIBHUmkf4q2ldBcTN7bs5UQROvhKyksfPKGbXng==", - "license": "ISC", - "dependencies": { - "axios": "^1.6.2" - } - }, "node_modules/partisia-blockchain-applications-sdk": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/partisia-blockchain-applications-sdk/-/partisia-blockchain-applications-sdk-0.1.4.tgz", diff --git a/package.json b/package.json index 8f8c5a7..ffc83eb 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,13 @@ "format": "prettier --write . && eslint --fix .", "test": "vitest", "test:run": "vitest run", - "test:e2e": "playwright test" + "test:e2e": "playwright test", + "sdk:local": "bash scripts/use-local-sdk.sh" }, "dependencies": { "@base-ui/react": "^1.3.0", "@ledgerhq/hw-transport-webusb": "^6.33.0", - "@metanames/sdk": "^6.3.1", + "@metanames/sdk": "^7.0.0", "@sentry/nextjs": "^10.47.0", "@tanstack/react-table": "^8.21.3", "@vercel/analytics": "^2.0.1", diff --git a/scripts/use-local-sdk.sh b/scripts/use-local-sdk.sh new file mode 100755 index 0000000..3a49c17 --- /dev/null +++ b/scripts/use-local-sdk.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Install the sibling SDK checkout into this app as a tarball. +# +# A tarball, not `file:../sdk` or `npm link`: those symlink the whole working +# tree, so the app resolves files the published package never contains and +# `next build` follows the SDK's own node_modules. `npm pack` produces exactly +# what `npm publish` would, which is the thing worth testing before a release. +# +# Usage: npm run sdk:local [path-to-sdk] (default: ../sdk) +set -euo pipefail + +sdk_dir=$(cd "${1:-../sdk}" && pwd) +app_dir=$(cd "$(dirname "$0")/.." && pwd) +out_dir="$app_dir/.local-sdk" + +mkdir -p "$out_dir" +rm -f "$out_dir"/*.tgz + +echo "building $sdk_dir" +(cd "$sdk_dir" && yarn build >/dev/null) + +# The tarball name carries a timestamp: npm caches by name+version, so reusing +# one name silently keeps the previous contents installed. +tarball=$(cd "$sdk_dir" && npm pack --pack-destination "$out_dir" --silent | tail -1) +stamped="metanames-sdk-local-$(date +%s).tgz" +mv "$out_dir/$tarball" "$out_dir/$stamped" + +echo "installing $stamped" +npm install --no-audit --no-fund "file:$out_dir/$stamped" + +node -e "const p=require('@metanames/sdk/package.json');console.log('installed @metanames/sdk',p.version)" +echo "restore the published SDK with: npm install @metanames/sdk@latest"