diff --git a/.changeset/fix-phantom-btc-wallet-standard.md b/.changeset/fix-phantom-btc-wallet-standard.md new file mode 100644 index 0000000..bb16d4e --- /dev/null +++ b/.changeset/fix-phantom-btc-wallet-standard.md @@ -0,0 +1,5 @@ +--- +"@swapkit/wallet-extensions": patch +--- + +Fix Phantom Bitcoin connection failing with `wallet_phantom_not_found`. Phantom deprecated the injected `window.phantom.bitcoin` provider and newer builds expose Bitcoin only through the Wallet Standard registry. Connecting now prefers the legacy injected provider when present and falls back to discovering Phantom via Wallet Standard (`bitcoin:connect` / `bitcoin:signTransaction`). diff --git a/bun.lock b/bun.lock index a62d9ce..6430e96 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,7 @@ "@swapkit/toolboxes": "^4.19.0", "@swapkit/utxo-signer": "^2.2.2", "@swapkit/wallet-core": "^4.3.8", + "@wallet-standard/app": "^1.1.1", "cosmjs-types": "0.10.1", "ethers": "^6.14.0", "sats-connect": "~1.0.0", @@ -76,6 +77,7 @@ "@near-js/transactions": "2.5.0", "@scure/base": "2.2.0", "@solana/web3.js": "1.98.4", + "@wallet-standard/app": "1.1.1", "cosmjs-types": "0.10.1", "ethers": "6.16.0", "sats-connect": "1.0.0", @@ -1220,7 +1222,9 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.0", "", { "dependencies": { "@babel/core": "^7.28.4", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.43", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew=="], - "@wallet-standard/base": ["@wallet-standard/base@1.1.0", "", {}, "sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ=="], + "@wallet-standard/app": ["@wallet-standard/app@1.1.1", "", { "dependencies": { "@wallet-standard/base": "^1.1.1" } }, "sha512-WDGwoByhP5gwHH01r5EaLgQdLVkACPCdOMQhmhn8rsm10h/siSgTorShzBxrn0ExSPof+Lu+C3TfgqBrPa1xoQ=="], + + "@wallet-standard/base": ["@wallet-standard/base@1.1.1", "", {}, "sha512-gggIHTtxicF9XFMQ12DkfS6NAG92Ak795JeSA7f2whAQ6Y3AkMWWuCMxSZXG2NIPN42kEaZSNVjqMsJRaJRxMQ=="], "@wallet-standard/features": ["@wallet-standard/features@1.1.0", "", { "dependencies": { "@wallet-standard/base": "^1.1.0" } }, "sha512-hiEivWNztx73s+7iLxsuD1sOJ28xtRix58W7Xnz4XzzA/pF0+aicnWgjOdA10doVDEDZdUuZCIIqG96SFNlDUg=="], @@ -2770,6 +2774,8 @@ "@stacks/transactions/@noble/secp256k1": ["@noble/secp256k1@1.7.1", "", {}, "sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw=="], + "@starknet-io/get-starknet-wallet-standard/@wallet-standard/base": ["@wallet-standard/base@1.1.0", "", {}, "sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ=="], + "@starknet-io/get-starknet-wallet-standard/ox": ["ox@0.4.4", "", { "dependencies": { "@adraffy/ens-normalize": "^1.10.1", "@noble/curves": "^1.6.0", "@noble/hashes": "^1.5.0", "@scure/bip32": "^1.5.0", "@scure/bip39": "^1.4.0", "abitype": "^1.0.6", "eventemitter3": "5.0.1" }, "peerDependencies": { "typescript": ">=5.4.0" }, "optionalPeers": ["typescript"] }, "sha512-oJPEeCDs9iNiPs6J0rTx+Y0KGeCGyCAA3zo94yZhm8G5WpOxrwUtn2Ie/Y8IyARSqqY/j9JTKA3Fc1xs1DvFnw=="], "@stellar/stellar-base/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], @@ -2894,6 +2900,8 @@ "@types/ws/@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="], + "@wallet-standard/features/@wallet-standard/base": ["@wallet-standard/base@1.1.0", "", {}, "sha512-DJDQhjKmSNVLKWItoKThJS+CsJQjR9AOBOirBVT1F9YpRyC9oYHE+ZnSf8y8bxUphtKqdQMPVQ2mHohYdRvDVQ=="], + "@walletconnect/environment/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "@walletconnect/events/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], diff --git a/packages/wallet-extensions/package.json b/packages/wallet-extensions/package.json index 8c2ce8b..50ed83e 100644 --- a/packages/wallet-extensions/package.json +++ b/packages/wallet-extensions/package.json @@ -15,6 +15,7 @@ "@swapkit/toolboxes": "^4.19.0", "@swapkit/utxo-signer": "^2.2.2", "@swapkit/wallet-core": "^4.3.8", + "@wallet-standard/app": "^1.1.1", "cosmjs-types": "0.10.1", "ethers": "^6.14.0", "sats-connect": "~1.0.0", @@ -32,6 +33,7 @@ "@near-js/transactions": "2.5.0", "@scure/base": "2.2.0", "@solana/web3.js": "1.98.4", + "@wallet-standard/app": "1.1.1", "cosmjs-types": "0.10.1", "ethers": "6.16.0", "sats-connect": "1.0.0", diff --git a/packages/wallet-extensions/src/phantom/index.ts b/packages/wallet-extensions/src/phantom/index.ts index 70b25bb..1a8c7b6 100644 --- a/packages/wallet-extensions/src/phantom/index.ts +++ b/packages/wallet-extensions/src/phantom/index.ts @@ -7,6 +7,7 @@ import { WalletOption, } from "@swapkit/helpers"; import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; +import type { getWallets as getStandardWallets } from "@wallet-standard/app"; import type { ExtensionWallet } from "../walletTypes"; export const phantomWallet: ExtensionWallet<"connectPhantom"> = createWallet({ @@ -39,24 +40,104 @@ export const phantomWallet: ExtensionWallet<"connectPhantom"> = createWallet({ export const PHANTOM_SUPPORTED_CHAINS = getWalletSupportedChains(phantomWallet); export type PhantomSupportedChain = (typeof PHANTOM_SUPPORTED_CHAINS)[number]; +type BitcoinAccess = { address: string; signPsbt: (psbt: Uint8Array, signingIndexes: number[]) => Promise }; + +type WalletStandardWallet = ReturnType["get"]>[number]; +type WalletStandardAccount = WalletStandardWallet["accounts"][number]; +type BitcoinStandardWallet = WalletStandardWallet & { + readonly features: WalletStandardWallet["features"] & { + "bitcoin:connect"?: { + connect: (input: { + purposes: ("payment" | "ordinals")[]; + }) => Promise<{ accounts: readonly WalletStandardAccount[] }>; + }; + "bitcoin:signTransaction"?: { + signTransaction: ( + ...inputs: { psbt: Uint8Array; inputsToSign: { account: WalletStandardAccount; signingIndexes: number[] }[] }[] + ) => Promise; + }; + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isBitcoinStandardWallet(candidate: WalletStandardWallet): candidate is BitcoinStandardWallet { + const connectFeature = candidate.features["bitcoin:connect"]; + const signFeature = candidate.features["bitcoin:signTransaction"]; + + return ( + candidate.name === "Phantom" && + isRecord(connectFeature) && + typeof connectFeature.connect === "function" && + isRecord(signFeature) && + typeof signFeature.signTransaction === "function" + ); +} + +/** + * Resolves a Bitcoin signing surface for Phantom. + * + * Phantom has deprecated the injected `window.phantom.bitcoin` provider and newer builds expose + * Bitcoin only through the Wallet Standard registry (Solana/EVM are still injected, which is why + * those chains keep working). We therefore prefer the legacy injected provider when present to + * avoid changing behaviour for existing users, and fall back to Wallet Standard discovery. + */ +export async function getBitcoinAccess(phantom: any): Promise { + const injected = phantom?.bitcoin; + if (injected?.isPhantom) { + const [{ address }] = await injected.requestAccounts(); + + return { + address, + signPsbt: async (psbt, signingIndexes) => + new Uint8Array(await injected.signPSBT(psbt, { inputsToSign: [{ address, signingIndexes }] })), + }; + } + + const { getWallets } = await import("@wallet-standard/app"); + const wallet = getWallets().get().find(isBitcoinStandardWallet); + + const connectFeature = wallet?.features["bitcoin:connect"]; + const signFeature = wallet?.features["bitcoin:signTransaction"]; + if (!(connectFeature && signFeature)) { + throw new SwapKitError("wallet_phantom_not_found"); + } + + const { accounts } = await connectFeature.connect({ purposes: ["payment"] }); + const [account] = accounts; + if (!account) { + throw new SwapKitError("wallet_phantom_not_found"); + } + + return { + address: account.address, + signPsbt: async (psbt, signingIndexes) => { + const [result] = await signFeature.signTransaction({ inputsToSign: [{ account, signingIndexes }], psbt }); + if (!result) { + throw new SwapKitError("core_transaction_failed"); + } + + return result.signedPsbt; + }, + }; +} + async function getWalletMethods(chain: PhantomSupportedChain) { const phantom: any = window?.phantom; switch (chain) { case Chain.Bitcoin: { - const provider = phantom?.bitcoin; - if (!provider?.isPhantom) { - throw new SwapKitError("wallet_phantom_not_found"); - } const { getUtxoToolbox } = await import("@swapkit/toolboxes/utxo"); const { Transaction } = await import("@swapkit/utxo-signer"); - const [{ address }] = await provider.requestAccounts(); + const { address, signPsbt } = await getBitcoinAccess(phantom); async function signTransaction(tx: InstanceType) { - const psbtBytes = tx.toPSBT(); - const signedPsbtBytes = await provider.signPSBT(psbtBytes, { - inputsToSign: [{ address, signingIndexes: Array.from({ length: tx.inputsLength }, (_, i) => i) }], - }); + const signedPsbtBytes = await signPsbt( + tx.toPSBT(), + Array.from({ length: tx.inputsLength }, (_, i) => i), + ); return Transaction.fromPSBT(new Uint8Array(signedPsbtBytes)); } diff --git a/packages/wallet-extensions/test/phantom-bitcoin.test.ts b/packages/wallet-extensions/test/phantom-bitcoin.test.ts new file mode 100644 index 0000000..2144bc4 --- /dev/null +++ b/packages/wallet-extensions/test/phantom-bitcoin.test.ts @@ -0,0 +1,170 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { SwapKitError } from "@swapkit/helpers"; +import { getWallets } from "@wallet-standard/app"; + +type WalletStandardWallet = Parameters["register"]>[0]; +type WalletStandardAccount = WalletStandardWallet["accounts"][number]; + +const BITCOIN_CHAIN = "bitcoin:mainnet"; +const BITCOIN_FEATURES = ["bitcoin:connect", "bitcoin:signTransaction"] as const; +const PHANTOM_ICON = "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=" as const; + +type BitcoinConnectInput = { readonly purposes: ("payment" | "ordinals")[] }; +type BitcoinConnectOutput = { readonly accounts: readonly WalletStandardAccount[] }; +type BitcoinSignTransactionInput = { + readonly inputsToSign: { + readonly account: WalletStandardAccount; + readonly signingIndexes: number[]; + readonly sigHash?: "ALL" | "NONE" | "SINGLE" | "ALL|ANYONECANPAY" | "NONE|ANYONECANPAY" | "SINGLE|ANYONECANPAY"; + }[]; + readonly psbt: Uint8Array; + readonly chain?: string; +}; +type BitcoinSignTransactionOutput = { readonly signedPsbt: Uint8Array }; +type BitcoinStandardFeatures = { + readonly "bitcoin:connect": { + readonly version: "1.0.0"; + readonly connect: (input: BitcoinConnectInput) => Promise; + }; + readonly "bitcoin:signTransaction": { + readonly version: "1.0.0"; + readonly signTransaction: ( + ...inputs: readonly BitcoinSignTransactionInput[] + ) => Promise; + }; +}; + +const unregisterWallets: (() => void)[] = []; + +function ensureWalletStandardWindowEvents() { + const windowObject = globalThis.window as + | ({ addEventListener?: unknown; dispatchEvent?: unknown } & Record) + | undefined; + + if (!windowObject) return; + if (typeof windowObject.addEventListener !== "function") { + windowObject.addEventListener = () => undefined; + } + if (typeof windowObject.dispatchEvent !== "function") { + windowObject.dispatchEvent = () => true; + } +} + +function createAccount(address = "bc1qpayment"): WalletStandardAccount { + return { address, chains: [BITCOIN_CHAIN], features: BITCOIN_FEATURES, publicKey: new Uint8Array([1]) }; +} + +function registerWallet(wallet: WalletStandardWallet) { + ensureWalletStandardWindowEvents(); + unregisterWallets.push(getWallets().register(wallet)); +} + +describe("phantom getBitcoinAccess", () => { + afterEach(() => { + for (const unregisterWallet of unregisterWallets.splice(0)) { + unregisterWallet(); + } + }); + + test("falls back to Wallet Standard when injected provider is absent", async () => { + const { getBitcoinAccess } = await import("../src/phantom"); + + const account = createAccount(); + const connect = mock(async (_input: BitcoinConnectInput) => ({ accounts: [account] })); + const signTransaction = mock(async (_input: BitcoinSignTransactionInput) => [ + { signedPsbt: new Uint8Array([7, 7, 7]) }, + ]); + + registerWallet({ + accounts: [], + chains: [BITCOIN_CHAIN], + features: { + "bitcoin:connect": { connect, version: "1.0.0" }, + "bitcoin:signTransaction": { signTransaction, version: "1.0.0" }, + }, + icon: PHANTOM_ICON, + name: "Phantom", + version: "1.0.0", + } satisfies WalletStandardWallet & { readonly features: BitcoinStandardFeatures }); + + // Newer Phantom builds no longer inject window.phantom.bitcoin; only Solana/EVM are injected. + const access = await getBitcoinAccess({ solana: { isPhantom: true } }); + + expect(access.address).toBe("bc1qpayment"); + expect(connect).toHaveBeenCalledWith({ purposes: ["payment"] }); + + const signed = await access.signPsbt(new Uint8Array([9, 9]), [0, 1]); + + expect(signTransaction).toHaveBeenCalledWith({ + inputsToSign: [{ account, signingIndexes: [0, 1] }], + psbt: new Uint8Array([9, 9]), + }); + expect(signed).toEqual(new Uint8Array([7, 7, 7])); + }); + + test("uses the legacy injected provider when present (regression)", async () => { + const { getBitcoinAccess } = await import("../src/phantom"); + + const requestAccounts = mock(async () => [{ address: "bc1qlegacy" }]); + const signPSBT = mock(async (_bytes: Uint8Array, _opts: unknown) => new Uint8Array([5, 5])); + + const access = await getBitcoinAccess({ bitcoin: { isPhantom: true, requestAccounts, signPSBT } }); + + expect(access.address).toBe("bc1qlegacy"); + + const signed = await access.signPsbt(new Uint8Array([9, 9]), [0, 1]); + + expect(signPSBT).toHaveBeenCalledWith(new Uint8Array([9, 9]), { + inputsToSign: [{ address: "bc1qlegacy", signingIndexes: [0, 1] }], + }); + expect(signed).toEqual(new Uint8Array([5, 5])); + }); + + test("throws wallet_phantom_not_found when neither path is available", async () => { + const { getBitcoinAccess } = await import("../src/phantom"); + + await expect(getBitcoinAccess({ solana: { isPhantom: true } })).rejects.toThrow( + new SwapKitError("wallet_phantom_not_found"), + ); + }); + + test("ignores a Wallet Standard wallet missing required bitcoin features", async () => { + const { getBitcoinAccess } = await import("../src/phantom"); + + registerWallet({ + accounts: [], + chains: [BITCOIN_CHAIN], + features: { "bitcoin:connect": { connect: async () => ({ accounts: [] }), version: "1.0.0" } }, + icon: PHANTOM_ICON, + name: "Phantom", + version: "1.0.0", + } satisfies WalletStandardWallet); + + await expect(getBitcoinAccess({ solana: { isPhantom: true } })).rejects.toThrow( + new SwapKitError("wallet_phantom_not_found"), + ); + }); + + test("throws core_transaction_failed when Wallet Standard signing returns no result", async () => { + const { getBitcoinAccess } = await import("../src/phantom"); + + const account = createAccount(); + registerWallet({ + accounts: [], + chains: [BITCOIN_CHAIN], + features: { + "bitcoin:connect": { connect: async () => ({ accounts: [account] }), version: "1.0.0" }, + "bitcoin:signTransaction": { signTransaction: async () => [], version: "1.0.0" }, + }, + icon: PHANTOM_ICON, + name: "Phantom", + version: "1.0.0", + } satisfies WalletStandardWallet & { readonly features: BitcoinStandardFeatures }); + + const access = await getBitcoinAccess({ solana: { isPhantom: true } }); + + await expect(access.signPsbt(new Uint8Array([9, 9]), [0, 1])).rejects.toThrow( + new SwapKitError("core_transaction_failed"), + ); + }); +});