From 838db5539be67da29783835e237d026d90ccc7c0 Mon Sep 17 00:00:00 2001 From: 0xepicode <100600187+0xepicode@users.noreply.github.com> Date: Tue, 23 Jun 2026 16:05:34 +0200 Subject: [PATCH] fix(phantom): connect Bitcoin via Wallet Standard when injected provider absent Phantom deprecated the injected window.phantom.bitcoin provider; newer builds expose Bitcoin only through the Wallet Standard registry (Solana/EVM are still injected, so those chains keep working). Connecting BTC therefore threw wallet_phantom_not_found. getBitcoinAccess now prefers the legacy injected provider when present (no behaviour change for existing users) and falls back to discovering Phantom via @wallet-standard/app getWallets() using the bitcoin:connect / bitcoin:signTransaction features. --- .changeset/fix-phantom-btc-wallet-standard.md | 5 + bun.lock | 13 ++- packages/wallet-extensions/package.json | 4 + .../wallet-extensions/src/phantom/index.ts | 90 +++++++++++++-- .../test/phantom-bitcoin.test.ts | 109 ++++++++++++++++++ 5 files changed, 210 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-phantom-btc-wallet-standard.md create mode 100644 packages/wallet-extensions/test/phantom-bitcoin.test.ts 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 612a266..304e7ce 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "swapkit-wallets", @@ -60,6 +59,8 @@ "@swapkit/toolboxes": "^4.17.5", "@swapkit/utxo-signer": "^2.2.2", "@swapkit/wallet-core": "^4.3.6", + "@wallet-standard/app": "^1.1.1", + "@wallet-standard/base": "^1.1.1", "cosmjs-types": "0.10.1", "ethers": "^6.14.0", "sats-connect": "~1.0.0", @@ -76,6 +77,8 @@ "@near-js/transactions": "2.5.0", "@scure/base": "2.2.0", "@solana/web3.js": "1.98.4", + "@wallet-standard/app": "1.1.1", + "@wallet-standard/base": "1.1.1", "cosmjs-types": "0.10.1", "ethers": "6.16.0", "sats-connect": "1.0.0", @@ -1220,7 +1223,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=="], @@ -2850,6 +2855,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=="], @@ -3024,6 +3031,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 143b8da..e4b7f74 100644 --- a/packages/wallet-extensions/package.json +++ b/packages/wallet-extensions/package.json @@ -15,6 +15,8 @@ "@swapkit/toolboxes": "^4.17.5", "@swapkit/utxo-signer": "^2.2.2", "@swapkit/wallet-core": "^4.3.6", + "@wallet-standard/app": "^1.1.1", + "@wallet-standard/base": "^1.1.1", "cosmjs-types": "0.10.1", "ethers": "^6.14.0", "sats-connect": "~1.0.0", @@ -32,6 +34,8 @@ "@near-js/transactions": "2.5.0", "@scure/base": "2.2.0", "@solana/web3.js": "1.98.4", + "@wallet-standard/app": "1.1.1", + "@wallet-standard/base": "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..22914b3 100644 --- a/packages/wallet-extensions/src/phantom/index.ts +++ b/packages/wallet-extensions/src/phantom/index.ts @@ -39,24 +39,96 @@ 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 }; + +// Minimal shape of the Bitcoin Wallet Standard features Phantom registers. +// See https://github.com/MetaMask/bitcoin-wallet-standard +type WalletStandardAccount = { readonly address: string }; +type BitcoinStandardWallet = { + readonly name: string; + readonly features: { + "bitcoin:connect"?: { + connect: (input: { + purposes: ("payment" | "ordinals")[]; + }) => Promise<{ accounts: readonly WalletStandardAccount[] }>; + }; + "bitcoin:signTransaction"?: { + signTransaction: ( + ...inputs: { psbt: Uint8Array; inputsToSign: { account: WalletStandardAccount; signingIndexes: number[] }[] }[] + ) => Promise; + }; + }; +}; + +/** + * 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( + (candidate) => + candidate.name === "Phantom" && + "bitcoin:connect" in candidate.features && + "bitcoin:signTransaction" in candidate.features, + ) as unknown as BitcoinStandardWallet | undefined; + + 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..2b10f62 --- /dev/null +++ b/packages/wallet-extensions/test/phantom-bitcoin.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { SwapKitError } from "@swapkit/helpers"; + +// Wallet Standard registry contents, swapped per test. +// Only this test file imports "@wallet-standard/app", so the global mock does not leak. +let walletStandardWallets: unknown[] = []; + +mock.module("@wallet-standard/app", () => ({ getWallets: () => ({ get: () => walletStandardWallets }) })); + +describe("phantom getBitcoinAccess", () => { + beforeEach(() => { + walletStandardWallets = []; + }); + + test("falls back to Wallet Standard when injected provider is absent", async () => { + const { getBitcoinAccess } = await import("../src/phantom"); + + const account = { address: "bc1qpayment", publicKey: new Uint8Array([1]) }; + const connect = mock(async (_input: { purposes: string[] }) => ({ accounts: [account] })); + const signTransaction = mock(async (_input: unknown) => [{ signedPsbt: new Uint8Array([7, 7, 7]) }]); + + walletStandardWallets = [ + { + features: { + "bitcoin:connect": { connect, version: "1.0.0" }, + "bitcoin:signTransaction": { signTransaction, version: "1.0.0" }, + }, + name: "Phantom", + }, + ]; + + // 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"); + walletStandardWallets = []; + + 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"); + + walletStandardWallets = [ + { + features: { "bitcoin:connect": { connect: async () => ({ accounts: [] }), version: "1.0.0" } }, + name: "Phantom", + }, + ]; + + 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 = { address: "bc1qpayment", publicKey: new Uint8Array([1]) }; + walletStandardWallets = [ + { + features: { + "bitcoin:connect": { connect: async () => ({ accounts: [account] }), version: "1.0.0" }, + "bitcoin:signTransaction": { signTransaction: async () => [], version: "1.0.0" }, + }, + name: "Phantom", + }, + ]; + + const access = await getBitcoinAccess({ solana: { isPhantom: true } }); + + expect(access.signPsbt(new Uint8Array([9, 9]), [0, 1])).rejects.toThrow( + new SwapKitError("core_transaction_failed"), + ); + }); +});