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
5 changes: 5 additions & 0 deletions .changeset/fix-phantom-btc-wallet-standard.md
Original file line number Diff line number Diff line change
@@ -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`).
10 changes: 9 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/wallet-extensions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
99 changes: 90 additions & 9 deletions packages/wallet-extensions/src/phantom/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<Uint8Array> };

type WalletStandardWallet = ReturnType<ReturnType<typeof getStandardWallets>["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<readonly { signedPsbt: Uint8Array }[]>;
};
};
};

function isRecord(value: unknown): value is Record<string, unknown> {
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<BitcoinAccess> {
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<typeof Transaction>) {
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));
}
Expand Down
170 changes: 170 additions & 0 deletions packages/wallet-extensions/test/phantom-bitcoin.test.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof getWallets>["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<BitcoinConnectOutput>;
};
readonly "bitcoin:signTransaction": {
readonly version: "1.0.0";
readonly signTransaction: (
...inputs: readonly BitcoinSignTransactionInput[]
) => Promise<readonly BitcoinSignTransactionOutput[]>;
};
};

const unregisterWallets: (() => void)[] = [];

function ensureWalletStandardWindowEvents() {
const windowObject = globalThis.window as
| ({ addEventListener?: unknown; dispatchEvent?: unknown } & Record<string, unknown>)
| 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"),
);
});
});
Loading