From db24d3f4f331b38ac8d1bafd7b179595ef6366cb Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 11:21:39 +0000 Subject: [PATCH 01/24] Connect NEAR, Starknet, Sui, TON and XRP with Bitget Wallet Bitget chains are now connected one at a time so a chain the wallet cannot serve is skipped instead of failing the whole connection, and every listed EVM chain resolves to a provider. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .changeset/bitget-new-chains.md | 6 + .../src/bitget/__tests__/bitget.test.ts | 130 +++++ .../wallet-extensions/src/bitget/helpers.ts | 451 +++++++++++++----- .../wallet-extensions/src/bitget/index.ts | 49 +- .../wallet-extensions/src/bitget/types.ts | 102 ++++ packages/wallet-extensions/src/types.ts | 51 +- 6 files changed, 614 insertions(+), 175 deletions(-) create mode 100644 .changeset/bitget-new-chains.md create mode 100644 packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts create mode 100644 packages/wallet-extensions/src/bitget/types.ts diff --git a/.changeset/bitget-new-chains.md b/.changeset/bitget-new-chains.md new file mode 100644 index 00000000..01787f97 --- /dev/null +++ b/.changeset/bitget-new-chains.md @@ -0,0 +1,6 @@ +--- +"@swapkit/wallet-extensions": minor +"@swapkit/wallets": minor +--- + +Bitget Wallet now connects NEAR, Starknet, Sui, TON and XRP, and every EVM chain it lists. Chains are connected one at a time, so a chain missing in the wallet is skipped instead of failing the whole connection. diff --git a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts new file mode 100644 index 00000000..89c6e6a9 --- /dev/null +++ b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts @@ -0,0 +1,130 @@ +// @ts-nocheck - Test file with intentional mocking of browser globals +import { afterEach, describe, expect, test } from "bun:test"; +import { actionCreators } from "@near-js/transactions"; +import { base64 } from "@scure/base"; +import { Keypair, SystemProgram, Transaction } from "@solana/web3.js"; +import { Chain, SwapKitError } from "@swapkit/helpers"; + +import { getWalletMethods, toBitgetNearAction } from "../helpers"; +import { bitgetWallet } from "../index"; + +const SUI_ADDRESS = `0x${"ab".repeat(32)}`; +const XRP_ADDRESS = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; + +afterEach(() => { + globalThis.window = undefined; +}); + +function suiProvider(connect = async () => ({ accounts: [{ address: SUI_ADDRESS }] })) { + return { connect, signTransaction: async (input) => ({ bytes: input.transaction.serialize(), signature: "sig" }) }; +} + +function connectBitget(chains: Chain[]) { + const connected: Chain[] = []; + const connect = bitgetWallet.connectBitget.connectWallet({ addChain: ({ chain }) => connected.push(chain) }); + + return { connected, result: connect(chains) }; +} + +describe("Bitget connection", () => { + test("keeps the chains that connect when another one is missing in the wallet", async () => { + globalThis.window = { bitkeep: { suiWallet: suiProvider() } }; + + const { connected, result } = connectBitget([Chain.Starknet, Chain.Sui]); + + expect(await result).toBe(true); + expect(connected).toEqual([Chain.Sui]); + }); + + test("stops at a rejected approval instead of asking for the remaining chains", async () => { + const rejection = new SwapKitError("wallet_connection_rejected_by_user"); + globalThis.window = { bitkeep: { suiWallet: suiProvider(() => Promise.reject(rejection)) } }; + + const { connected, result } = connectBitget([Chain.Sui, Chain.Starknet]); + + await expect(result).rejects.toBe(rejection); + expect(connected).toEqual([]); + }); +}); + +describe("Bitget Sui signer", () => { + test("rejects a signature over different bytes", async () => { + const provider = suiProvider(); + provider.signTransaction = async () => ({ bytes: base64.encode(new Uint8Array([9, 9])), signature: "sig" }); + globalThis.window = { bitkeep: { suiWallet: provider } }; + + const wallet = await getWalletMethods(Chain.Sui); + + await expect(wallet.signTransaction(new Uint8Array([1, 2, 3, 4]))).rejects.toThrow("toolbox_sui_signing_error"); + }); +}); + +describe("Bitget Solana signer", () => { + test("rebuilds the transaction when the wallet answers with signed bytes", async () => { + const keypair = Keypair.generate(); + const transaction = new Transaction({ blockhash: "11111111111111111111111111111111", lastValidBlockHeight: 1 }); + transaction.feePayer = keypair.publicKey; + transaction.add( + SystemProgram.transfer({ fromPubkey: keypair.publicKey, lamports: 1, toPubkey: keypair.publicKey }), + ); + + globalThis.window = { + bitkeep: { + solana: { + connect: async () => ({ accounts: [{ address: keypair.publicKey.toBase58() }] }), + publicKey: null, + signTransaction: (tx) => { + tx.sign(keypair); + return Promise.resolve([{ signedTransaction: tx.serialize() }]); + }, + }, + }, + }; + + const wallet = await getWalletMethods(Chain.Solana); + const signed = await wallet.signTransaction(transaction); + + expect(wallet.address).toBe(keypair.publicKey.toBase58()); + expect(signed).toBeInstanceOf(Transaction); + expect(signed.verifySignatures()).toBe(true); + }); +}); + +describe("Bitget Near actions", () => { + test("turns transfers and contract calls into the JSON form Bitget expects", () => { + const call = actionCreators.functionCall("ft_transfer", { amount: "5", receiver_id: "bob.near" }, 30n, 1n); + + expect(toBitgetNearAction(actionCreators.transfer(1000n))).toEqual({ + params: { deposit: "1000" }, + type: "Transfer", + }); + expect(toBitgetNearAction(call)).toEqual({ + params: { args: { amount: "5", receiver_id: "bob.near" }, deposit: "1", gas: "30", methodName: "ft_transfer" }, + type: "FunctionCall", + }); + }); +}); + +describe("Bitget XRP signer", () => { + test("hands the transaction to the wallet and returns the hash it reports", async () => { + let sent: Record | undefined; + globalThis.window = { + bitkeep: { + xrp: { + connect: async () => ({ address: XRP_ADDRESS }), + sendTransaction: (transaction) => { + sent = transaction; + return Promise.resolve({ hash: "ABCDEF" }); + }, + }, + }, + }; + + const wallet = await getWalletMethods(Chain.Ripple); + const payment = { Account: XRP_ADDRESS, Amount: "1", Destination: XRP_ADDRESS, TransactionType: "Payment" }; + + expect(wallet.address).toBe(XRP_ADDRESS); + expect(await wallet.signAndBroadcastTransaction(payment)).toBe("ABCDEF"); + expect(sent).toEqual(payment); + }); +}); diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index e8b367f4..d582a766 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -1,161 +1,368 @@ -import { hex } from "@scure/base"; -import { Chain, type EVMChain, GAIAConfig, prepareNetworkSwitch, SwapKitError } from "@swapkit/helpers"; +import { base64, hex } from "@scure/base"; +import { + Chain, + type EVMChain, + EVMChains, + GAIAConfig, + type GenericTransferParams, + prepareNetworkSwitch, + SwapKitError, + WalletOption, +} from "@swapkit/helpers"; +import type { NearSigner } from "@swapkit/toolboxes/near"; +import type { RippleTransaction } from "@swapkit/toolboxes/ripple"; +import type { SolanaProvider } from "@swapkit/toolboxes/solana"; +import type { SuiChainSigner } from "@swapkit/toolboxes/sui"; import type { TronTransaction } from "@swapkit/toolboxes/tron"; import { Transaction } from "@swapkit/utxo-signer"; import type { Eip1193Provider } from "ethers"; +import { getTonConnectWallet } from "../helpers/tonConnect"; import { getUtxoScriptTypeParams } from "../helpers/utxoScriptType"; +import type { BitgetNearAction, BitgetNearResponse, BitgetProviders, BitgetSolanaTransaction } from "./types"; + +const SUI_MAINNET = "sui:mainnet"; type WalletMethodsWithAddress = Record & { address: string }; -export async function getWalletMethods(chain: Chain): Promise { - const { match, P } = await import("ts-pattern"); - const bitget = window.bitkeep; - - return match(chain) - .with( - P.union( - Chain.Arbitrum, - Chain.Aurora, - Chain.Avalanche, - Chain.Base, - Chain.Berachain, - Chain.BinanceSmartChain, - Chain.Ethereum, - Chain.Gnosis, - Chain.Monad, - Chain.Optimism, - Chain.Polygon, - Chain.XLayer, - ), - async () => { - if (!(bitget && "ethereum" in bitget)) { - throw new SwapKitError("wallet_bitkeep_not_found"); - } - - const wallet = bitget.ethereum; - - const [address] = (await wallet.request({ method: "eth_requestAccounts" })) as [string, ...string[]]; - const evmWallet = await getWeb3WalletMethods({ chain: chain as EVMChain, walletProvider: wallet }); - - return { ...evmWallet, address }; - }, - ) - .with(Chain.Bitcoin, async () => { - if (!(bitget && "unisat" in bitget)) { - throw new SwapKitError("wallet_bitkeep_not_found"); - } - const { unisat: wallet } = bitget; +function notFoundError(chain: Chain) { + return new SwapKitError("wallet_bitkeep_not_found", { chain }); +} - const { getUtxoToolbox } = await import("@swapkit/toolboxes/utxo"); - const [address] = await wallet.requestAccounts(); +function getProvider(key: Key, chain: Chain) { + const provider = window.bitkeep?.[key]; + if (!provider) throw notFoundError(chain); - async function signTransaction(tx: InstanceType) { - const psbtHex = hex.encode(tx.toPSBT(0)); - const signedPsbtHex = await wallet.signPsbt(psbtHex, { autoFinalized: false }); + return provider as BitgetProviders[Key]; +} - return Transaction.fromPSBT(hex.decode(signedPsbtHex)); - } +function mainnetOnlyError(chain: Chain) { + return new SwapKitError({ + errorKey: "wallet_chain_not_supported", + info: { chain, message: "Mainnet only", wallet: WalletOption.BITGET }, + }); +} - const signer = { getAddress: () => Promise.resolve(address), signTransaction }; +function connectionRejectedError(chain: Chain) { + return new SwapKitError("wallet_connection_rejected_by_user", { chain, wallet: WalletOption.BITGET }); +} - const toolbox = getUtxoToolbox(Chain.Bitcoin, { - ...(await getUtxoScriptTypeParams({ address, chain: Chain.Bitcoin })), - signer, - }); +function transactionFailedError(chain: Chain, error?: unknown) { + return new SwapKitError("core_transaction_failed", { chain, error, wallet: WalletOption.BITGET }); +} - return { ...toolbox, address }; - }) - .with(Chain.Cosmos, async () => { - if (!(bitget && "keplr" in bitget)) { - throw new SwapKitError("wallet_bitkeep_not_found"); - } - const { keplr: wallet } = bitget; +async function getEvmWalletMethods(chain: EVMChain) { + const wallet = getProvider("ethereum", chain); + const [address] = (await wallet.request({ method: "eth_requestAccounts" })) as [string, ...string[]]; + const evmWallet = await getWeb3WalletMethods({ chain, walletProvider: wallet }); + + return { ...evmWallet, address }; +} - await wallet.enable(GAIAConfig.chainId); - const offlineSigner = await wallet.getOfflineSignerAuto(GAIAConfig.chainId); - const accounts = await offlineSigner.getAccounts(); - if (!accounts?.[0]) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Cosmos }); +async function getBitcoinWalletMethods() { + const wallet = getProvider("unisat", Chain.Bitcoin); + const [address] = await wallet.requestAccounts(); + if (!address) throw connectionRejectedError(Chain.Bitcoin); + if (wallet.getNetwork && (await wallet.getNetwork()) !== "livenet") throw mainnetOnlyError(Chain.Bitcoin); - const { getCosmosToolbox } = await import("@swapkit/toolboxes/cosmos"); - const [{ address }] = accounts; + const { getUtxoToolbox } = await import("@swapkit/toolboxes/utxo"); - const signer = Object.assign(offlineSigner, { getAddress: () => Promise.resolve(address) }); + async function signTransaction(tx: InstanceType) { + const signedPsbtHex = await wallet.signPsbt(hex.encode(tx.toPSBT(0)), { autoFinalized: false }); - const toolbox = getCosmosToolbox(Chain.Cosmos, { signer }); + return Transaction.fromPSBT(hex.decode(signedPsbtHex)); + } - return { ...toolbox, address }; - }) - .with(Chain.Solana, async () => { - if (!(bitget && "solana" in bitget)) { - throw new SwapKitError("wallet_bitkeep_not_found"); - } + const signer = { getAddress: () => Promise.resolve(address), signTransaction }; + const toolbox = getUtxoToolbox(Chain.Bitcoin, { + ...(await getUtxoScriptTypeParams({ address, chain: Chain.Bitcoin })), + signer, + }); - const { getSolanaToolbox } = await import("@swapkit/toolboxes/solana"); - const provider = bitget?.solana; + return { ...toolbox, address }; +} - const { publicKey } = await provider.connect(); - const address: string = publicKey.toString(); +async function getCosmosWalletMethods() { + const wallet = getProvider("keplr", Chain.Cosmos); - const signer = Object.assign(provider, { getAddress: async () => address }); + await wallet.enable(GAIAConfig.chainId); + const offlineSigner = await wallet.getOfflineSignerAuto(GAIAConfig.chainId); + const accounts = await offlineSigner.getAccounts(); + if (!accounts?.[0]) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Cosmos }); - const toolbox = getSolanaToolbox({ signer }); + const { getCosmosToolbox } = await import("@swapkit/toolboxes/cosmos"); + const [{ address }] = accounts; + const signer = Object.assign(offlineSigner, { getAddress: () => Promise.resolve(address) }); - return { ...toolbox, address }; - }) - .with(Chain.Tron, async () => { - if (!(bitget && "tronLink" in bitget && "tronWeb" in bitget)) { - throw new SwapKitError("wallet_bitkeep_not_found"); - } + return { ...getCosmosToolbox(Chain.Cosmos, { signer }), address }; +} + +async function getSolanaWalletMethods() { + const provider = getProvider("solana", Chain.Solana); + const connection = await provider.connect(); + const address = + provider.publicKey?.toString() ?? connection?.publicKey?.toString() ?? connection?.accounts?.[0]?.address; + if (!address) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Solana }); - const { getTronToolbox } = await import("@swapkit/toolboxes/tron"); - const { tronLink, tronWeb } = bitget; + const { getSolanaToolbox } = await import("@swapkit/toolboxes/solana"); + const { PublicKey, Transaction: SolanaTransaction, VersionedTransaction } = await import("@solana/web3.js"); + const publicKey = new PublicKey(address); + + async function signTransaction(transaction: T) { + const signed = await provider.signTransaction(transaction); + if (!Array.isArray(signed)) return signed as T; + + const bytes = signed[0]?.signedTransaction; + if (!bytes) throw transactionFailedError(Chain.Solana); + + return ("version" in transaction ? VersionedTransaction.deserialize(bytes) : SolanaTransaction.from(bytes)) as T; + } + + const signer: SolanaProvider = { + connect: () => Promise.resolve({ publicKey }), + disconnect: () => provider.disconnect(), + publicKey, + signTransaction, + }; + + return { ...getSolanaToolbox({ signer }), address }; +} + +async function getTronWalletMethods() { + const tronLink = getProvider("tronLink", Chain.Tron); + const tronWeb = getProvider("tronWeb", Chain.Tron); + const response = await tronLink.request({ method: "tron_requestAccounts" }); + + if (response.code !== 200) { + throw new SwapKitError("wallet_connection_rejected_by_user", { + message: response.message || "User rejected connection", + }); + } + + const address = tronWeb.defaultAddress?.base58; + if (!address) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Tron }); + + const { getTronToolbox } = await import("@swapkit/toolboxes/tron"); + const signer = { + getAddress: () => Promise.resolve(address), + signTransaction: (transaction: TronTransaction) => tronWeb.trx.sign(transaction), + }; + + return { ...getTronToolbox({ signer }), address }; +} + +async function getAptosWalletMethods() { + const { createAptosExtensionTransfer, getAptosToolbox, validateAptosAddress } = await import( + "@swapkit/toolboxes/aptos" + ); + const aptosProvider = getProvider("aptos", Chain.Aptos) as import("@swapkit/toolboxes/aptos").AptosExtensionProvider; + + const { address } = await aptosProvider.connect(); + if (!validateAptosAddress(address)) throw notFoundError(Chain.Aptos); + + return { ...getAptosToolbox(), address, transfer: createAptosExtensionTransfer({ provider: aptosProvider }) }; +} - const response = await tronLink.request({ method: "tron_requestAccounts" }); +function getTonWalletMethods() { + const bridge = window.bitgetTonWallet?.tonconnect; + if (!bridge) throw notFoundError(Chain.Ton); - if (response.code !== 200) { - throw new SwapKitError("wallet_connection_rejected_by_user", { - message: response.message || "User rejected connection", + return getTonConnectWallet({ + bridge, + mainnetOnlyError: () => mainnetOnlyError(Chain.Ton), + noAccountError: () => new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Ton }), + wallet: WalletOption.BITGET, + }); +} + +async function getSuiWalletMethods() { + const provider = getProvider("suiWallet", Chain.Sui); + const { accounts } = await provider.connect(); + const account = accounts.find(({ chains }) => !chains || chains.includes(SUI_MAINNET)); + if (!account?.address) throw mainnetOnlyError(Chain.Sui); + + const { getSuiToolbox } = await import("@swapkit/toolboxes/sui"); + + const signer: SuiChainSigner = { + getAddress: () => account.address, + signTransaction: async (txBytes: Uint8Array) => { + const serialized = base64.encode(txBytes); + const transaction = { serialize: () => serialized, toJSON: () => Promise.resolve(serialized) }; + const signed = await provider.signTransaction({ account, chain: SUI_MAINNET, transaction }); + + if (signed.bytes !== serialized) { + throw new SwapKitError("toolbox_sui_signing_error", { + chain: Chain.Sui, + message: "Bitget signed a different transaction than the one requested", }); } - const address = tronWeb.defaultAddress?.base58; + return { bytes: serialized, signature: signed.signature }; + }, + toSuiAddress: () => account.address, + }; - if (!address) { - throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Tron }); - } + return { ...getSuiToolbox({ signer }), address: account.address }; +} - const signer = { - getAddress: () => Promise.resolve(address), - signTransaction: async (transaction: TronTransaction) => { - const signedTx = await tronWeb.trx.sign(transaction); - return signedTx; - }, - }; - - const toolbox = getTronToolbox({ signer }); - - return { ...toolbox, address }; - }) - .with(Chain.Aptos, async () => { - if (!(bitget && "aptos" in bitget)) { - throw new SwapKitError("wallet_bitkeep_not_found"); - } +async function getStarknetWalletMethods() { + const provider = window.starknet_bitkeep; + if (!provider) throw notFoundError(Chain.Starknet); + + await provider.enable(); + const { account } = provider; + if (!account) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Starknet }); + + const { getStarknetToolbox } = await import("@swapkit/toolboxes/starknet"); + + return { ...getStarknetToolbox({ signer: account, walletDeploysAccount: true }), address: account.address }; +} - const { createAptosExtensionTransfer, getAptosToolbox, validateAptosAddress } = await import( - "@swapkit/toolboxes/aptos" +type NearAction = { + functionCall?: { methodName: string; args: Uint8Array; gas: bigint; deposit: bigint }; + transfer?: { deposit: bigint }; +}; + +// Bitget takes NEAR actions in the wallet-selector JSON form instead of the borsh classes +export function toBitgetNearAction({ functionCall, transfer }: NearAction): BitgetNearAction { + if (transfer) return { params: { deposit: transfer.deposit.toString() }, type: "Transfer" }; + + if (functionCall) { + return { + params: { + args: JSON.parse(new TextDecoder().decode(functionCall.args) || "{}"), + deposit: functionCall.deposit.toString(), + gas: functionCall.gas.toString(), + methodName: functionCall.methodName, + }, + type: "FunctionCall", + }; + } + + throw new SwapKitError("wallet_near_method_not_supported", { method: "action", wallet: WalletOption.BITGET }); +} + +function readNearResponse({ error, response }: BitgetNearResponse) { + if (error) throw transactionFailedError(Chain.Near, error); + if (!(Array.isArray(response) && response.length)) throw transactionFailedError(Chain.Near, response); + + return response; +} + +async function getNearWalletMethods() { + const provider = getProvider("near", Chain.Near); + + if (!(provider.isSignedIn() && provider.getAccountId())) { + const { accessKey, error } = await provider.requestSignIn({ contractId: "", methodNames: [] }); + if (!accessKey || error) throw connectionRejectedError(Chain.Near); + } + + const address = provider.getAccountId(); + if (!address) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Near }); + + const { getNearToolbox } = await import("@swapkit/toolboxes/near"); + + const notSupported = (method: string) => + Promise.reject(new SwapKitError("wallet_near_method_not_supported", { method, wallet: WalletOption.BITGET })); + + // Bitget only signs and sends NEAR transactions, so the toolbox falls back to signAndSendTransaction + const signer = { + getAddress: () => Promise.resolve(address), + async getPublicKey() { + const { PublicKey } = await import("@near-js/crypto"); + return PublicKey.fromString((await provider.getPublicKey()).toString()); + }, + async signAndSendTransaction({ actions, receiverId }: { actions: NearAction[]; receiverId?: string }) { + if (!receiverId) throw transactionFailedError(Chain.Near, "Missing receiver"); + + return readNearResponse( + await provider.signAndSendTransaction({ actions: actions.map(toBitgetNearAction), receiverId }), ); - const aptosProvider = bitget.aptos as import("@swapkit/toolboxes/aptos").AptosExtensionProvider; + }, + signDelegateAction: () => notSupported("signDelegateAction"), + signNep413Message: () => notSupported("signNep413Message"), + signTransaction: () => notSupported("signTransaction"), + }; + + return { ...getNearToolbox({ signer: signer as unknown as NearSigner }), address }; +} + +function readXrpAddress(connection: unknown) { + if (typeof connection === "string") return connection; + if (!(connection && typeof connection === "object")) return undefined; - const { address } = await aptosProvider.connect(); - if (!validateAptosAddress(address)) throw new SwapKitError("wallet_bitkeep_not_found"); + const { address, account } = connection as { address?: unknown; account?: unknown }; + return [address, account].find((value): value is string => typeof value === "string"); +} - const toolbox = getAptosToolbox(); +function readXrpHash(result: unknown) { + if (typeof result === "string") return result; + if (!(result && typeof result === "object")) return undefined; - return { ...toolbox, address, transfer: createAptosExtensionTransfer({ provider: aptosProvider }) }; - }) - .otherwise(() => { - throw new SwapKitError("wallet_chain_not_supported"); - }); + const { hash, txid } = result as { hash?: unknown; txid?: unknown }; + return [hash, txid].find((value): value is string => typeof value === "string"); +} + +async function getRippleWalletMethods() { + const provider = getProvider("xrp", Chain.Ripple); + const { getRippleToolbox, validateRippleAddress } = await import("@swapkit/toolboxes/ripple"); + + const address = readXrpAddress(await provider.connect()); + if (!(address && validateRippleAddress(address))) { + throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Ripple }); + } + + const toolbox = getRippleToolbox(); + + // Bitget signs and submits XRP transactions itself and never hands back a signed blob + async function signAndBroadcastTransaction(transaction: RippleTransaction) { + const hash = readXrpHash(await provider.sendTransaction({ ...transaction })); + if (!hash) throw transactionFailedError(Chain.Ripple); + + return hash; + } + + async function transfer({ assetValue, memo, recipient, sweep }: GenericTransferParams) { + const transaction = await toolbox.createTransaction({ assetValue, memo, recipient, sender: address, sweep }); + return signAndBroadcastTransaction(transaction); + } + + return { + ...toolbox, + address, + getAddress: () => Promise.resolve(address), + getBalance: () => toolbox.getBalance(address), + signAndBroadcastTransaction, + transfer, + }; +} + +export function getWalletMethods(chain: Chain): Promise { + if (EVMChains.includes(chain as EVMChain)) return getEvmWalletMethods(chain as EVMChain); + + switch (chain) { + case Chain.Aptos: + return getAptosWalletMethods(); + case Chain.Bitcoin: + return getBitcoinWalletMethods(); + case Chain.Cosmos: + return getCosmosWalletMethods(); + case Chain.Near: + return getNearWalletMethods(); + case Chain.Ripple: + return getRippleWalletMethods(); + case Chain.Solana: + return getSolanaWalletMethods(); + case Chain.Starknet: + return getStarknetWalletMethods(); + case Chain.Sui: + return getSuiWalletMethods(); + case Chain.Ton: + return getTonWalletMethods(); + case Chain.Tron: + return getTronWalletMethods(); + default: + throw new SwapKitError({ errorKey: "wallet_chain_not_supported", info: { chain, wallet: WalletOption.BITGET } }); + } } export const getWeb3WalletMethods = async ({ diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index be4a5e8e..77dae329 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -1,4 +1,4 @@ -import { Chain, EVMChains, filterSupportedChains, WalletOption } from "@swapkit/helpers"; +import { Chain, EVMChains, filterSupportedChains, SwapKitError, WalletOption } from "@swapkit/helpers"; import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; import type { ExtensionWallet } from "../walletTypes"; import { getWalletMethods } from "./helpers"; @@ -8,13 +8,24 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ async function connectBitget(chains: Chain[]) { const filteredChains = filterSupportedChains({ chains, supportedChains, walletType }); - await Promise.all( - filteredChains.map(async (chain) => { - const walletMethods = await getWalletMethods(chain); + const failedChains: unknown[] = []; + // One chain at a time, so a cancelled approval stops the rest and a missing chain does not drop the others + for (const chain of filteredChains) { + try { + const walletMethods = await getWalletMethods(chain); addChain({ ...walletMethods, chain, walletType }); - }), - ); + } catch (error) { + if (isUserRejection(error)) throw error; + failedChains.push(error); + } + } + + if (failedChains.length > 0 && failedChains.length === filteredChains.length) throw failedChains[0]; + + for (const error of failedChains) { + console.warn("connectBitget: skipping a chain Bitget cannot connect", error); + } return true; }, @@ -39,12 +50,36 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ ), [Chain.Bitcoin]: { direct: true }, [Chain.Cosmos]: { direct: true }, + [Chain.Near]: { direct: true }, + [Chain.Ripple]: { direct: true }, [Chain.Solana]: { direct: true }, + [Chain.Starknet]: { direct: true }, + [Chain.Sui]: { direct: true }, + [Chain.Ton]: { direct: true }, [Chain.Tron]: { direct: true }, // [Chain.Aptos]: blocked on toolbox — getAptosToolbox needs to accept AptosExtensionProvider }, - supportedChains: [...EVMChains, Chain.Cosmos, Chain.Bitcoin, Chain.Solana, Chain.Tron], + supportedChains: [ + ...EVMChains, + Chain.Bitcoin, + Chain.Cosmos, + Chain.Near, + Chain.Ripple, + Chain.Solana, + Chain.Starknet, + Chain.Sui, + Chain.Ton, + Chain.Tron, + ], walletType: WalletOption.BITGET, }); export const BITGET_SUPPORTED_CHAINS = getWalletSupportedChains(bitgetWallet); + +function isUserRejection(error: unknown) { + if (error instanceof SwapKitError) return error.errorKey === "wallet_connection_rejected_by_user"; + + // ethers wraps the EIP-1193 4001 rejection of the EVM provider as ACTION_REJECTED + const code = (error as { code?: unknown } | null)?.code; + return code === 4001 || code === "ACTION_REJECTED"; +} diff --git a/packages/wallet-extensions/src/bitget/types.ts b/packages/wallet-extensions/src/bitget/types.ts new file mode 100644 index 00000000..3f0ba5ec --- /dev/null +++ b/packages/wallet-extensions/src/bitget/types.ts @@ -0,0 +1,102 @@ +import type { AminoSignResponse, StdSignature, StdSignDoc } from "@cosmjs/amino"; +import type { PublicKey, Transaction, VersionedTransaction } from "@solana/web3.js"; +import type { EthereumWindowProvider } from "@swapkit/helpers"; +import type { OfflineAminoSigner, OfflineDirectSigner } from "@swapkit/toolboxes/cosmos"; +import type { StarknetToolboxParams } from "@swapkit/toolboxes/starknet"; + +export type BitgetBtcProvider = { + getNetwork?: () => Promise; + requestAccounts: () => Promise; + signMessage: (message: string, type?: "ecdsa" | "bip322-simple") => Promise; + signPsbt: (psbtHex: string, options: { autoFinalized?: boolean }) => Promise; +}; + +export type BitgetKeplrProvider = { + enable: (chainId: string | string[]) => Promise; + signAmino: (chainId: string, signer: string, signDoc: StdSignDoc, signOptions: any) => Promise; + signArbitrary: (chainId: string, signer: string, data: string | Uint8Array) => Promise; + verifyArbitrary: ( + chainId: string, + signer: string, + data: string | Uint8Array, + signature: StdSignature, + ) => Promise; + getOfflineSignerOnlyAmino: (chainId: string) => OfflineAminoSigner; + getOfflineSignerAuto: (chainId: string) => Promise; +}; + +export type BitgetSolanaTransaction = Transaction | VersionedTransaction; + +// Bitget answers either like Phantom or in the Wallet Standard shape, depending on the wallet version +export type BitgetSolanaProvider = { + connect: () => Promise<{ publicKey?: PublicKey; accounts?: { address: string }[] } | undefined>; + disconnect: () => Promise; + publicKey: PublicKey | null; + signTransaction: ( + transaction: BitgetSolanaTransaction, + ) => Promise; +}; + +export type BitgetTronProvider = { + tronLink: { request: (args: { method: string; params?: any }) => Promise; ready: boolean }; + tronWeb: { + defaultAddress: { base58: string; hex: string }; + trx: { + sign: (transaction: any) => Promise; + sendRawTransaction: (signedTransaction: any) => Promise; + getAccount: (address: string) => Promise; + getBalance: (address: string) => Promise; + }; + }; +}; + +export type BitgetSuiAccount = { address: string; chains?: readonly string[] }; + +export type BitgetSuiProvider = { + connect: () => Promise<{ accounts: readonly BitgetSuiAccount[] }>; + signTransaction: (input: { + account: BitgetSuiAccount; + chain: string; + transaction: { serialize: () => string; toJSON: () => Promise }; + }) => Promise<{ bytes: string; signature: string }>; +}; + +export type BitgetNearAction = { type: string; params?: Record }; + +type BitgetNearOutcome = { transaction?: { hash?: string }; transaction_outcome?: { id?: string } }; + +export type BitgetNearResponse = { + error?: string | { type?: string; message?: string }; + response?: BitgetNearOutcome[] | { error: { message: string } }; +}; + +export type BitgetNearProvider = { + getAccountId: () => string; + getPublicKey: () => Promise<{ toString: () => string } | string>; + isSignedIn: () => boolean; + requestSignIn: (params: { + contractId: string; + methodNames?: string[]; + }) => Promise<{ accessKey?: unknown; error?: string | { type?: string } }>; + signAndSendTransaction: (params: { receiverId: string; actions: BitgetNearAction[] }) => Promise; +}; + +type StarknetAccount = NonNullable["signer"]>; + +export type BitgetStarknetProvider = { account?: StarknetAccount; enable: () => Promise }; + +export type BitgetXrpProvider = { + connect: () => Promise; + sendTransaction: (transaction: Record) => Promise; +}; + +export type BitgetProviders = BitgetTronProvider & { + aptos: unknown; + ethereum: EthereumWindowProvider; + keplr: BitgetKeplrProvider; + near: BitgetNearProvider; + solana: BitgetSolanaProvider; + suiWallet: BitgetSuiProvider; + unisat: BitgetBtcProvider; + xrp: BitgetXrpProvider; +}; diff --git a/packages/wallet-extensions/src/types.ts b/packages/wallet-extensions/src/types.ts index 613f4621..be394127 100644 --- a/packages/wallet-extensions/src/types.ts +++ b/packages/wallet-extensions/src/types.ts @@ -4,11 +4,12 @@ import type { EthereumWindowProvider } from "@swapkit/helpers"; // The offline-signer handles feed straight into the cosmos toolbox, whose 5.0.0 // client (backed by @swapkit/cosmos-signer) declares its own signer interfaces // replacing the @cosmjs ones — type the handles with those. -import type { OfflineAminoSigner, OfflineDirectSigner } from "@swapkit/toolboxes/cosmos"; +import type { OfflineAminoSigner } from "@swapkit/toolboxes/cosmos"; import type { SolanaProvider } from "@swapkit/toolboxes/solana"; import type { StarknetToolboxParams } from "@swapkit/toolboxes/starknet"; import type { SubstrateInjectedExtension } from "@swapkit/toolboxes/substrate"; import type { Eip1193Provider } from "ethers"; +import type { BitgetProviders, BitgetStarknetProvider } from "./bitget/types"; import type { NearBrowserWalletProvider } from "./helpers/near"; import type { TronLinkWindow } from "./tronlink"; @@ -39,11 +40,6 @@ export type TonConnectBridge = { }) => Promise<{ id: string; result?: string; error?: { code: number; message: string } }>; }; -type UnisatToSignInputs = { index: number; sighashTypes?: number[]; disableTweakSigner?: boolean } & ( - | { address: string } - | { publicKey: string } -); - type Callback = ( error: Error | null, result?: any, // https://github.com/vultisig/vultisig-windows/blob/baeb3e8099a0003404f9664c43b0183c26029041/clients/extension/src/utils/interfaces.ts#L11 @@ -91,46 +87,9 @@ declare global { zcash: Eip1193Provider; }; - bitkeep?: { - unisat: { - requestAccounts: () => Promise<[string, ...string[]]>; - signMessage: (message: string, type?: "ecdsa" | "bip322-simple") => Promise; - signPsbt: ( - psbtHex: string, - { autoFinalized, toSignInputs }: { autoFinalized?: boolean; toSignInputs?: UnisatToSignInputs[] }, - ) => Promise; - }; - keplr: { - enable: (chainId: string | string[]) => Promise; - signAmino: ( - chainId: string, - signer: string, - signDoc: StdSignDoc, - signOptions: any, - ) => Promise; - signArbitrary: (chainId: string, signer: string, data: string | Uint8Array) => Promise; - verifyArbitrary: ( - chainId: string, - signer: string, - data: string | Uint8Array, - signature: StdSignature, - ) => Promise; - getOfflineSignerOnlyAmino: (chainId: string) => OfflineAminoSigner; - getOfflineSignerAuto: (chainId: string) => Promise; - }; - solana: SolanaProvider; - ethereum: EthereumWindowProvider; - tronLink: { request: (args: { method: string; params?: any }) => Promise; ready: boolean }; - tronWeb: { - defaultAddress: { base58: string; hex: string }; - trx: { - sign: (transaction: any) => Promise; - sendRawTransaction: (signedTransaction: any) => Promise; - getAccount: (address: string) => Promise; - getBalance: (address: string) => Promise; - }; - }; - }; + bitkeep?: Partial; + starknet_bitkeep?: BitgetStarknetProvider; + bitgetTonWallet?: { tonconnect: TonConnectBridge }; okxwallet?: | { From 9d2bc78d84071262dbeb571e71f6295728a5733c Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 13:32:34 +0000 Subject: [PATCH 02/24] Ask Bitget for the EVM account once and connect EVM chains first Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/helpers.ts | 6 +++++- packages/wallet-extensions/src/bitget/index.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index d582a766..c1921e53 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -52,7 +52,11 @@ function transactionFailedError(chain: Chain, error?: unknown) { async function getEvmWalletMethods(chain: EVMChain) { const wallet = getProvider("ethereum", chain); - const [address] = (await wallet.request({ method: "eth_requestAccounts" })) as [string, ...string[]]; + // Asked only once per site: a second eth_requestAccounts while one is open is rejected as already pending + const [connected] = (await wallet.request({ method: "eth_accounts" })) as string[]; + const [address] = connected ? [connected] : ((await wallet.request({ method: "eth_requestAccounts" })) as string[]); + if (!address) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain }); + const evmWallet = await getWeb3WalletMethods({ chain, walletProvider: wallet }); return { ...evmWallet, address }; diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 77dae329..ebb785c3 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -6,7 +6,10 @@ import { getWalletMethods } from "./helpers"; export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ connect: ({ addChain, walletType, supportedChains }) => async function connectBitget(chains: Chain[]) { - const filteredChains = filterSupportedChains({ chains, supportedChains, walletType }); + // EVM goes first: its approval waits for the user, and the site approval it grants covers the other chains + const filteredChains = filterSupportedChains({ chains, supportedChains, walletType }).sort( + (a, b) => Number(isEvmChain(b)) - Number(isEvmChain(a)), + ); const failedChains: unknown[] = []; @@ -83,3 +86,7 @@ function isUserRejection(error: unknown) { const code = (error as { code?: unknown } | null)?.code; return code === 4001 || code === "ACTION_REJECTED"; } + +function isEvmChain(chain: Chain) { + return (EVMChains as readonly Chain[]).includes(chain); +} From 0663a093c2b93dc6afb8139949134c7037addccf Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 13:46:46 +0000 Subject: [PATCH 03/24] TEMP: log each Bitget chain while connecting Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/index.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index ebb785c3..e25b00d1 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -14,16 +14,29 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ const failedChains: unknown[] = []; // One chain at a time, so a cancelled approval stops the rest and a missing chain does not drop the others + // TEMP debug logs, remove before merge + console.info("[bitget-debug] requested", chains, "connecting", filteredChains); + console.info("[bitget-debug] injected", Object.keys(window.bitkeep ?? {}), { + starknet: !!window.starknet_bitkeep, + ton: !!window.bitgetTonWallet, + }); + for (const chain of filteredChains) { + const startedAt = Date.now(); + console.info(`[bitget-debug] ${chain} start`); try { const walletMethods = await getWalletMethods(chain); addChain({ ...walletMethods, chain, walletType }); + console.info(`[bitget-debug] ${chain} connected in ${Date.now() - startedAt}ms`, walletMethods.address); } catch (error) { + console.info(`[bitget-debug] ${chain} failed in ${Date.now() - startedAt}ms`, error); if (isUserRejection(error)) throw error; failedChains.push(error); } } + console.info("[bitget-debug] done, failed:", failedChains.length); + if (failedChains.length > 0 && failedChains.length === filteredChains.length) throw failedChains[0]; for (const error of failedChains) { From 103e6f4301cb7ddaea429490a8dbf3b4eb8ae311 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 14:43:16 +0000 Subject: [PATCH 04/24] Stop offering Cosmos with Bitget Wallet for now Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .changeset/bitget-new-chains.md | 2 +- packages/wallet-extensions/src/bitget/index.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.changeset/bitget-new-chains.md b/.changeset/bitget-new-chains.md index 01787f97..053baed8 100644 --- a/.changeset/bitget-new-chains.md +++ b/.changeset/bitget-new-chains.md @@ -3,4 +3,4 @@ "@swapkit/wallets": minor --- -Bitget Wallet now connects NEAR, Starknet, Sui, TON and XRP, and every EVM chain it lists. Chains are connected one at a time, so a chain missing in the wallet is skipped instead of failing the whole connection. +Bitget Wallet now connects NEAR, Starknet, Sui, TON and XRP, and every EVM chain it lists. Chains are connected one at a time, so a chain missing in the wallet is skipped instead of failing the whole connection. Cosmos is no longer offered with Bitget for now: the wallet refuses the Cosmos Hub connection request. diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index e25b00d1..05dba188 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -65,7 +65,7 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ ].map((chain) => [chain, { direct: true, typedData: true }]), ), [Chain.Bitcoin]: { direct: true }, - [Chain.Cosmos]: { direct: true }, + // [Chain.Cosmos]: Bitget answers "ChainIds not supported" for cosmoshub-4, to be revisited [Chain.Near]: { direct: true }, [Chain.Ripple]: { direct: true }, [Chain.Solana]: { direct: true }, @@ -78,7 +78,6 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ supportedChains: [ ...EVMChains, Chain.Bitcoin, - Chain.Cosmos, Chain.Near, Chain.Ripple, Chain.Solana, From 549162783b528221a8ed83585f417276401d95a7 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 14:48:49 +0000 Subject: [PATCH 05/24] Connect Bitget EVM chains after the other chains Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 05dba188..6e081c4a 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -6,9 +6,9 @@ import { getWalletMethods } from "./helpers"; export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ connect: ({ addChain, walletType, supportedChains }) => async function connectBitget(chains: Chain[]) { - // EVM goes first: its approval waits for the user, and the site approval it grants covers the other chains + // EVM goes last: the chains asked right after its approval were refused as already pending const filteredChains = filterSupportedChains({ chains, supportedChains, walletType }).sort( - (a, b) => Number(isEvmChain(b)) - Number(isEvmChain(a)), + (a, b) => Number(isEvmChain(a)) - Number(isEvmChain(b)), ); const failedChains: unknown[] = []; From bacf76d46818129c3077a69d95218e38468e1343 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 15:05:49 +0000 Subject: [PATCH 06/24] Retry a Bitget chain refused as already pending Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .../wallet-extensions/src/bitget/helpers.ts | 5 +++- .../wallet-extensions/src/bitget/index.ts | 30 ++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index c1921e53..29064e75 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -310,7 +310,10 @@ async function getRippleWalletMethods() { const provider = getProvider("xrp", Chain.Ripple); const { getRippleToolbox, validateRippleAddress } = await import("@swapkit/toolboxes/ripple"); - const address = readXrpAddress(await provider.connect()); + const connection = await provider.connect(); + // TEMP debug log, remove before merge + console.info("[bitget-debug] XRP connect() returned", connection); + const address = readXrpAddress(connection); if (!(address && validateRippleAddress(address))) { throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Ripple }); } diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 6e081c4a..6827ab91 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -12,6 +12,7 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ ); const failedChains: unknown[] = []; + pendingRetriesExhausted = false; // One chain at a time, so a cancelled approval stops the rest and a missing chain does not drop the others // TEMP debug logs, remove before merge @@ -25,7 +26,7 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ const startedAt = Date.now(); console.info(`[bitget-debug] ${chain} start`); try { - const walletMethods = await getWalletMethods(chain); + const walletMethods = await connectWhenIdle(chain); addChain({ ...walletMethods, chain, walletType }); console.info(`[bitget-debug] ${chain} connected in ${Date.now() - startedAt}ms`, walletMethods.address); } catch (error) { @@ -102,3 +103,30 @@ function isUserRejection(error: unknown) { function isEvmChain(chain: Chain) { return (EVMChains as readonly Chain[]).includes(chain); } + +const PENDING_RETRY_MS = 300; +const PENDING_RETRIES = 10; +let pendingRetriesExhausted = false; + +// Bitget refuses a request made right after an approval instead of queueing it, so it is asked again shortly after +async function connectWhenIdle(chain: Chain) { + for (let attempt = 0; ; attempt++) { + try { + return await getWalletMethods(chain); + } catch (error) { + if (!isPendingApproval(error) || pendingRetriesExhausted) throw error; + if (attempt >= PENDING_RETRIES) { + pendingRetriesExhausted = true; + throw error; + } + + console.info(`[bitget-debug] ${chain} pending, retry ${attempt + 1}`); + await new Promise((resolve) => setTimeout(resolve, PENDING_RETRY_MS)); + } + } +} + +function isPendingApproval(error: unknown) { + const { code, message } = (error ?? {}) as { code?: unknown; message?: unknown }; + return code === -32002 || (typeof message === "string" && message.includes("already pending")); +} From 82aa84a440886ac8b154d0e208a9cc4ffb396a3f Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 15:12:59 +0000 Subject: [PATCH 07/24] TEMP: wait one second between Bitget chain requests Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/index.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 6827ab91..128016a8 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -22,7 +22,10 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ ton: !!window.bitgetTonWallet, }); - for (const chain of filteredChains) { + for (const [index, chain] of filteredChains.entries()) { + // TEMP experiment: give Bitget a second between requests + if (index > 0) await new Promise((resolve) => setTimeout(resolve, CHAIN_PAUSE_MS)); + const startedAt = Date.now(); console.info(`[bitget-debug] ${chain} start`); try { @@ -104,6 +107,7 @@ function isEvmChain(chain: Chain) { return (EVMChains as readonly Chain[]).includes(chain); } +const CHAIN_PAUSE_MS = 1000; const PENDING_RETRY_MS = 300; const PENDING_RETRIES = 10; let pendingRetriesExhausted = false; From a431af4725f5ad545c2a1e2dbdd7eff7aa37994a Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 15:16:00 +0000 Subject: [PATCH 08/24] Stop offering Starknet with Bitget Wallet for now Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .changeset/bitget-new-chains.md | 2 +- .../src/bitget/__tests__/bitget.test.ts | 4 ++-- packages/wallet-extensions/src/bitget/helpers.ts | 15 --------------- packages/wallet-extensions/src/bitget/index.ts | 9 ++------- packages/wallet-extensions/src/bitget/types.ts | 5 ----- packages/wallet-extensions/src/types.ts | 3 +-- 6 files changed, 6 insertions(+), 32 deletions(-) diff --git a/.changeset/bitget-new-chains.md b/.changeset/bitget-new-chains.md index 053baed8..605766fd 100644 --- a/.changeset/bitget-new-chains.md +++ b/.changeset/bitget-new-chains.md @@ -3,4 +3,4 @@ "@swapkit/wallets": minor --- -Bitget Wallet now connects NEAR, Starknet, Sui, TON and XRP, and every EVM chain it lists. Chains are connected one at a time, so a chain missing in the wallet is skipped instead of failing the whole connection. Cosmos is no longer offered with Bitget for now: the wallet refuses the Cosmos Hub connection request. +Bitget Wallet now connects NEAR, Sui, TON and XRP, and every EVM chain it lists. Chains are connected one at a time, so a chain missing in the wallet is skipped instead of failing the whole connection. Cosmos is no longer offered with Bitget for now: the wallet refuses the Cosmos Hub connection request. diff --git a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts index 89c6e6a9..f16c1823 100644 --- a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts +++ b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts @@ -30,7 +30,7 @@ describe("Bitget connection", () => { test("keeps the chains that connect when another one is missing in the wallet", async () => { globalThis.window = { bitkeep: { suiWallet: suiProvider() } }; - const { connected, result } = connectBitget([Chain.Starknet, Chain.Sui]); + const { connected, result } = connectBitget([Chain.Ton, Chain.Sui]); expect(await result).toBe(true); expect(connected).toEqual([Chain.Sui]); @@ -40,7 +40,7 @@ describe("Bitget connection", () => { const rejection = new SwapKitError("wallet_connection_rejected_by_user"); globalThis.window = { bitkeep: { suiWallet: suiProvider(() => Promise.reject(rejection)) } }; - const { connected, result } = connectBitget([Chain.Sui, Chain.Starknet]); + const { connected, result } = connectBitget([Chain.Sui, Chain.Ton]); await expect(result).rejects.toBe(rejection); expect(connected).toEqual([]); diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index 29064e75..4c6099cd 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -208,19 +208,6 @@ async function getSuiWalletMethods() { return { ...getSuiToolbox({ signer }), address: account.address }; } -async function getStarknetWalletMethods() { - const provider = window.starknet_bitkeep; - if (!provider) throw notFoundError(Chain.Starknet); - - await provider.enable(); - const { account } = provider; - if (!account) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Starknet }); - - const { getStarknetToolbox } = await import("@swapkit/toolboxes/starknet"); - - return { ...getStarknetToolbox({ signer: account, walletDeploysAccount: true }), address: account.address }; -} - type NearAction = { functionCall?: { methodName: string; args: Uint8Array; gas: bigint; deposit: bigint }; transfer?: { deposit: bigint }; @@ -359,8 +346,6 @@ export function getWalletMethods(chain: Chain): Promise = createWallet({ // One chain at a time, so a cancelled approval stops the rest and a missing chain does not drop the others // TEMP debug logs, remove before merge console.info("[bitget-debug] requested", chains, "connecting", filteredChains); - console.info("[bitget-debug] injected", Object.keys(window.bitkeep ?? {}), { - starknet: !!window.starknet_bitkeep, - ton: !!window.bitgetTonWallet, - }); + console.info("[bitget-debug] injected", Object.keys(window.bitkeep ?? {}), { ton: !!window.bitgetTonWallet }); for (const [index, chain] of filteredChains.entries()) { // TEMP experiment: give Bitget a second between requests @@ -69,11 +66,10 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ ].map((chain) => [chain, { direct: true, typedData: true }]), ), [Chain.Bitcoin]: { direct: true }, - // [Chain.Cosmos]: Bitget answers "ChainIds not supported" for cosmoshub-4, to be revisited + // Cosmos and Starknet are left out: Bitget answers "ChainIds not supported" to their connection request [Chain.Near]: { direct: true }, [Chain.Ripple]: { direct: true }, [Chain.Solana]: { direct: true }, - [Chain.Starknet]: { direct: true }, [Chain.Sui]: { direct: true }, [Chain.Ton]: { direct: true }, [Chain.Tron]: { direct: true }, @@ -85,7 +81,6 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ Chain.Near, Chain.Ripple, Chain.Solana, - Chain.Starknet, Chain.Sui, Chain.Ton, Chain.Tron, diff --git a/packages/wallet-extensions/src/bitget/types.ts b/packages/wallet-extensions/src/bitget/types.ts index 3f0ba5ec..fafa73d1 100644 --- a/packages/wallet-extensions/src/bitget/types.ts +++ b/packages/wallet-extensions/src/bitget/types.ts @@ -2,7 +2,6 @@ import type { AminoSignResponse, StdSignature, StdSignDoc } from "@cosmjs/amino" import type { PublicKey, Transaction, VersionedTransaction } from "@solana/web3.js"; import type { EthereumWindowProvider } from "@swapkit/helpers"; import type { OfflineAminoSigner, OfflineDirectSigner } from "@swapkit/toolboxes/cosmos"; -import type { StarknetToolboxParams } from "@swapkit/toolboxes/starknet"; export type BitgetBtcProvider = { getNetwork?: () => Promise; @@ -81,10 +80,6 @@ export type BitgetNearProvider = { signAndSendTransaction: (params: { receiverId: string; actions: BitgetNearAction[] }) => Promise; }; -type StarknetAccount = NonNullable["signer"]>; - -export type BitgetStarknetProvider = { account?: StarknetAccount; enable: () => Promise }; - export type BitgetXrpProvider = { connect: () => Promise; sendTransaction: (transaction: Record) => Promise; diff --git a/packages/wallet-extensions/src/types.ts b/packages/wallet-extensions/src/types.ts index be394127..04e3e306 100644 --- a/packages/wallet-extensions/src/types.ts +++ b/packages/wallet-extensions/src/types.ts @@ -9,7 +9,7 @@ import type { SolanaProvider } from "@swapkit/toolboxes/solana"; import type { StarknetToolboxParams } from "@swapkit/toolboxes/starknet"; import type { SubstrateInjectedExtension } from "@swapkit/toolboxes/substrate"; import type { Eip1193Provider } from "ethers"; -import type { BitgetProviders, BitgetStarknetProvider } from "./bitget/types"; +import type { BitgetProviders } from "./bitget/types"; import type { NearBrowserWalletProvider } from "./helpers/near"; import type { TronLinkWindow } from "./tronlink"; @@ -88,7 +88,6 @@ declare global { }; bitkeep?: Partial; - starknet_bitkeep?: BitgetStarknetProvider; bitgetTonWallet?: { tonconnect: TonConnectBridge }; okxwallet?: From 29e09fd817567c8b2ff011159fcceb830bd1fed4 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 15:19:44 +0000 Subject: [PATCH 09/24] Stop offering NEAR with Bitget Wallet and pause only around non-EVM chains Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .changeset/bitget-new-chains.md | 2 +- .../src/bitget/__tests__/bitget.test.ts | 18 +---- .../wallet-extensions/src/bitget/helpers.ts | 74 +------------------ .../wallet-extensions/src/bitget/index.ts | 21 ++---- .../wallet-extensions/src/bitget/types.ts | 21 ------ 5 files changed, 10 insertions(+), 126 deletions(-) diff --git a/.changeset/bitget-new-chains.md b/.changeset/bitget-new-chains.md index 605766fd..561aa8c5 100644 --- a/.changeset/bitget-new-chains.md +++ b/.changeset/bitget-new-chains.md @@ -3,4 +3,4 @@ "@swapkit/wallets": minor --- -Bitget Wallet now connects NEAR, Sui, TON and XRP, and every EVM chain it lists. Chains are connected one at a time, so a chain missing in the wallet is skipped instead of failing the whole connection. Cosmos is no longer offered with Bitget for now: the wallet refuses the Cosmos Hub connection request. +Bitget Wallet now connects Sui, TON and XRP, and every EVM chain it lists. Chains are connected one at a time, so a chain missing in the wallet is skipped instead of failing the whole connection. Cosmos is no longer offered with Bitget for now: the wallet refuses the Cosmos Hub connection request. diff --git a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts index f16c1823..ebfe30fd 100644 --- a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts +++ b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts @@ -1,11 +1,10 @@ // @ts-nocheck - Test file with intentional mocking of browser globals import { afterEach, describe, expect, test } from "bun:test"; -import { actionCreators } from "@near-js/transactions"; import { base64 } from "@scure/base"; import { Keypair, SystemProgram, Transaction } from "@solana/web3.js"; import { Chain, SwapKitError } from "@swapkit/helpers"; -import { getWalletMethods, toBitgetNearAction } from "../helpers"; +import { getWalletMethods } from "../helpers"; import { bitgetWallet } from "../index"; const SUI_ADDRESS = `0x${"ab".repeat(32)}`; @@ -90,21 +89,6 @@ describe("Bitget Solana signer", () => { }); }); -describe("Bitget Near actions", () => { - test("turns transfers and contract calls into the JSON form Bitget expects", () => { - const call = actionCreators.functionCall("ft_transfer", { amount: "5", receiver_id: "bob.near" }, 30n, 1n); - - expect(toBitgetNearAction(actionCreators.transfer(1000n))).toEqual({ - params: { deposit: "1000" }, - type: "Transfer", - }); - expect(toBitgetNearAction(call)).toEqual({ - params: { args: { amount: "5", receiver_id: "bob.near" }, deposit: "1", gas: "30", methodName: "ft_transfer" }, - type: "FunctionCall", - }); - }); -}); - describe("Bitget XRP signer", () => { test("hands the transaction to the wallet and returns the hash it reports", async () => { let sent: Record | undefined; diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index 4c6099cd..a2d68e37 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -9,7 +9,6 @@ import { SwapKitError, WalletOption, } from "@swapkit/helpers"; -import type { NearSigner } from "@swapkit/toolboxes/near"; import type { RippleTransaction } from "@swapkit/toolboxes/ripple"; import type { SolanaProvider } from "@swapkit/toolboxes/solana"; import type { SuiChainSigner } from "@swapkit/toolboxes/sui"; @@ -18,7 +17,7 @@ import { Transaction } from "@swapkit/utxo-signer"; import type { Eip1193Provider } from "ethers"; import { getTonConnectWallet } from "../helpers/tonConnect"; import { getUtxoScriptTypeParams } from "../helpers/utxoScriptType"; -import type { BitgetNearAction, BitgetNearResponse, BitgetProviders, BitgetSolanaTransaction } from "./types"; +import type { BitgetProviders, BitgetSolanaTransaction } from "./types"; const SUI_MAINNET = "sui:mainnet"; @@ -208,75 +207,6 @@ async function getSuiWalletMethods() { return { ...getSuiToolbox({ signer }), address: account.address }; } -type NearAction = { - functionCall?: { methodName: string; args: Uint8Array; gas: bigint; deposit: bigint }; - transfer?: { deposit: bigint }; -}; - -// Bitget takes NEAR actions in the wallet-selector JSON form instead of the borsh classes -export function toBitgetNearAction({ functionCall, transfer }: NearAction): BitgetNearAction { - if (transfer) return { params: { deposit: transfer.deposit.toString() }, type: "Transfer" }; - - if (functionCall) { - return { - params: { - args: JSON.parse(new TextDecoder().decode(functionCall.args) || "{}"), - deposit: functionCall.deposit.toString(), - gas: functionCall.gas.toString(), - methodName: functionCall.methodName, - }, - type: "FunctionCall", - }; - } - - throw new SwapKitError("wallet_near_method_not_supported", { method: "action", wallet: WalletOption.BITGET }); -} - -function readNearResponse({ error, response }: BitgetNearResponse) { - if (error) throw transactionFailedError(Chain.Near, error); - if (!(Array.isArray(response) && response.length)) throw transactionFailedError(Chain.Near, response); - - return response; -} - -async function getNearWalletMethods() { - const provider = getProvider("near", Chain.Near); - - if (!(provider.isSignedIn() && provider.getAccountId())) { - const { accessKey, error } = await provider.requestSignIn({ contractId: "", methodNames: [] }); - if (!accessKey || error) throw connectionRejectedError(Chain.Near); - } - - const address = provider.getAccountId(); - if (!address) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Near }); - - const { getNearToolbox } = await import("@swapkit/toolboxes/near"); - - const notSupported = (method: string) => - Promise.reject(new SwapKitError("wallet_near_method_not_supported", { method, wallet: WalletOption.BITGET })); - - // Bitget only signs and sends NEAR transactions, so the toolbox falls back to signAndSendTransaction - const signer = { - getAddress: () => Promise.resolve(address), - async getPublicKey() { - const { PublicKey } = await import("@near-js/crypto"); - return PublicKey.fromString((await provider.getPublicKey()).toString()); - }, - async signAndSendTransaction({ actions, receiverId }: { actions: NearAction[]; receiverId?: string }) { - if (!receiverId) throw transactionFailedError(Chain.Near, "Missing receiver"); - - return readNearResponse( - await provider.signAndSendTransaction({ actions: actions.map(toBitgetNearAction), receiverId }), - ); - }, - signDelegateAction: () => notSupported("signDelegateAction"), - signNep413Message: () => notSupported("signNep413Message"), - signTransaction: () => notSupported("signTransaction"), - }; - - return { ...getNearToolbox({ signer: signer as unknown as NearSigner }), address }; -} - function readXrpAddress(connection: unknown) { if (typeof connection === "string") return connection; if (!(connection && typeof connection === "object")) return undefined; @@ -340,8 +270,6 @@ export function getWalletMethods(chain: Chain): Promise = createWallet({ console.info("[bitget-debug] injected", Object.keys(window.bitkeep ?? {}), { ton: !!window.bitgetTonWallet }); for (const [index, chain] of filteredChains.entries()) { - // TEMP experiment: give Bitget a second between requests - if (index > 0) await new Promise((resolve) => setTimeout(resolve, CHAIN_PAUSE_MS)); + // TEMP experiment: give Bitget a second between requests, except between EVM chains that share one account + const previous = filteredChains[index - 1]; + if (previous && !(isEvmChain(previous) && isEvmChain(chain))) { + await new Promise((resolve) => setTimeout(resolve, CHAIN_PAUSE_MS)); + } const startedAt = Date.now(); console.info(`[bitget-debug] ${chain} start`); @@ -66,8 +69,7 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ ].map((chain) => [chain, { direct: true, typedData: true }]), ), [Chain.Bitcoin]: { direct: true }, - // Cosmos and Starknet are left out: Bitget answers "ChainIds not supported" to their connection request - [Chain.Near]: { direct: true }, + // Left out for now: Bitget refuses Cosmos and Starknet ("ChainIds not supported") and does not inject NEAR [Chain.Ripple]: { direct: true }, [Chain.Solana]: { direct: true }, [Chain.Sui]: { direct: true }, @@ -75,16 +77,7 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ [Chain.Tron]: { direct: true }, // [Chain.Aptos]: blocked on toolbox — getAptosToolbox needs to accept AptosExtensionProvider }, - supportedChains: [ - ...EVMChains, - Chain.Bitcoin, - Chain.Near, - Chain.Ripple, - Chain.Solana, - Chain.Sui, - Chain.Ton, - Chain.Tron, - ], + supportedChains: [...EVMChains, Chain.Bitcoin, Chain.Ripple, Chain.Solana, Chain.Sui, Chain.Ton, Chain.Tron], walletType: WalletOption.BITGET, }); diff --git a/packages/wallet-extensions/src/bitget/types.ts b/packages/wallet-extensions/src/bitget/types.ts index fafa73d1..f70a93af 100644 --- a/packages/wallet-extensions/src/bitget/types.ts +++ b/packages/wallet-extensions/src/bitget/types.ts @@ -60,26 +60,6 @@ export type BitgetSuiProvider = { }) => Promise<{ bytes: string; signature: string }>; }; -export type BitgetNearAction = { type: string; params?: Record }; - -type BitgetNearOutcome = { transaction?: { hash?: string }; transaction_outcome?: { id?: string } }; - -export type BitgetNearResponse = { - error?: string | { type?: string; message?: string }; - response?: BitgetNearOutcome[] | { error: { message: string } }; -}; - -export type BitgetNearProvider = { - getAccountId: () => string; - getPublicKey: () => Promise<{ toString: () => string } | string>; - isSignedIn: () => boolean; - requestSignIn: (params: { - contractId: string; - methodNames?: string[]; - }) => Promise<{ accessKey?: unknown; error?: string | { type?: string } }>; - signAndSendTransaction: (params: { receiverId: string; actions: BitgetNearAction[] }) => Promise; -}; - export type BitgetXrpProvider = { connect: () => Promise; sendTransaction: (transaction: Record) => Promise; @@ -89,7 +69,6 @@ export type BitgetProviders = BitgetTronProvider & { aptos: unknown; ethereum: EthereumWindowProvider; keplr: BitgetKeplrProvider; - near: BitgetNearProvider; solana: BitgetSolanaProvider; suiWallet: BitgetSuiProvider; unisat: BitgetBtcProvider; From b86f981a279c9ccb9028ed64f6a41dbb57d1d70c Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 16:11:53 +0000 Subject: [PATCH 10/24] Broadcast the requested Tron transaction with Bitget's signature Bitget returns the signed transaction without visible and raw_data_hex, so the node could not read it and answered "No contract". Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .../src/bitget/__tests__/bitget.test.ts | 38 +++++++++++++++++++ .../wallet-extensions/src/bitget/helpers.ts | 12 +++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts index ebfe30fd..8c239ff9 100644 --- a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts +++ b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts @@ -1,5 +1,6 @@ // @ts-nocheck - Test file with intentional mocking of browser globals import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; import { base64 } from "@scure/base"; import { Keypair, SystemProgram, Transaction } from "@solana/web3.js"; import { Chain, SwapKitError } from "@swapkit/helpers"; @@ -89,6 +90,43 @@ describe("Bitget Solana signer", () => { }); }); +describe("Bitget Tron signer", () => { + function mockTron(sign) { + globalThis.window = { + bitkeep: { + tronLink: { request: async () => ({ code: 200 }) }, + tronWeb: { defaultAddress: { base58: "TJRabPrwbZy45sbavfcjinPJC18kjpRTv8" }, trx: { sign } }, + }, + }; + } + + function tronTransaction(rawDataHex: string) { + return { + raw_data: { contract: [], expiration: 0, ref_block_bytes: "", ref_block_hash: "", timestamp: 0 }, + raw_data_hex: rawDataHex, + txID: createHash("sha256").update(Buffer.from(rawDataHex, "hex")).digest("hex"), + visible: true, + }; + } + + test("keeps the requested transaction when the wallet returns a stripped copy", async () => { + const transaction = tronTransaction("0a02"); + mockTron(async ({ raw_data, txID }) => ({ raw_data, signature: ["sig"], txID })); + + const wallet = await getWalletMethods(Chain.Tron); + + expect(await wallet.signTransaction(transaction)).toEqual({ ...transaction, signature: ["sig"] }); + }); + + test("rejects a signature over a different transaction", async () => { + mockTron(async () => ({ ...tronTransaction("0b03"), signature: ["sig"] })); + + const wallet = await getWalletMethods(Chain.Tron); + + await expect(wallet.signTransaction(tronTransaction("0a02"))).rejects.toThrow("core_transaction_failed"); + }); +}); + describe("Bitget XRP signer", () => { test("hands the transaction to the wallet and returns the hash it reports", async () => { let sent: Record | undefined; diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index a2d68e37..92acc793 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -147,7 +147,17 @@ async function getTronWalletMethods() { const { getTronToolbox } = await import("@swapkit/toolboxes/tron"); const signer = { getAddress: () => Promise.resolve(address), - signTransaction: (transaction: TronTransaction) => tronWeb.trx.sign(transaction), + signTransaction: async (transaction: TronTransaction) => { + const signed = await tronWeb.trx.sign(structuredClone(transaction)); + + if (signed?.txID !== transaction.txID) { + throw transactionFailedError(Chain.Tron, "Bitget signed a different transaction than the one requested"); + } + if (!signed.signature?.length) throw transactionFailedError(Chain.Tron, "Bitget returned no signature"); + + // Bitget hands back a copy without `visible` and `raw_data_hex`, which the node then cannot read + return { ...transaction, signature: signed.signature }; + }, }; return { ...getTronToolbox({ signer }), address }; From 696695e6ab8d2d3e4a5b8a07c3263fa8aa60a855 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 16:47:47 +0000 Subject: [PATCH 11/24] Move the unused Bitget Cosmos code out with the parked chains Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .../wallet-extensions/src/bitget/helpers.ts | 18 ------------------ packages/wallet-extensions/src/bitget/types.ts | 17 ----------------- 2 files changed, 35 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index 92acc793..e6d1cc86 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -3,7 +3,6 @@ import { Chain, type EVMChain, EVMChains, - GAIAConfig, type GenericTransferParams, prepareNetworkSwitch, SwapKitError, @@ -84,21 +83,6 @@ async function getBitcoinWalletMethods() { return { ...toolbox, address }; } -async function getCosmosWalletMethods() { - const wallet = getProvider("keplr", Chain.Cosmos); - - await wallet.enable(GAIAConfig.chainId); - const offlineSigner = await wallet.getOfflineSignerAuto(GAIAConfig.chainId); - const accounts = await offlineSigner.getAccounts(); - if (!accounts?.[0]) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Cosmos }); - - const { getCosmosToolbox } = await import("@swapkit/toolboxes/cosmos"); - const [{ address }] = accounts; - const signer = Object.assign(offlineSigner, { getAddress: () => Promise.resolve(address) }); - - return { ...getCosmosToolbox(Chain.Cosmos, { signer }), address }; -} - async function getSolanaWalletMethods() { const provider = getProvider("solana", Chain.Solana); const connection = await provider.connect(); @@ -278,8 +262,6 @@ export function getWalletMethods(chain: Chain): Promise Promise; @@ -10,20 +8,6 @@ export type BitgetBtcProvider = { signPsbt: (psbtHex: string, options: { autoFinalized?: boolean }) => Promise; }; -export type BitgetKeplrProvider = { - enable: (chainId: string | string[]) => Promise; - signAmino: (chainId: string, signer: string, signDoc: StdSignDoc, signOptions: any) => Promise; - signArbitrary: (chainId: string, signer: string, data: string | Uint8Array) => Promise; - verifyArbitrary: ( - chainId: string, - signer: string, - data: string | Uint8Array, - signature: StdSignature, - ) => Promise; - getOfflineSignerOnlyAmino: (chainId: string) => OfflineAminoSigner; - getOfflineSignerAuto: (chainId: string) => Promise; -}; - export type BitgetSolanaTransaction = Transaction | VersionedTransaction; // Bitget answers either like Phantom or in the Wallet Standard shape, depending on the wallet version @@ -68,7 +52,6 @@ export type BitgetXrpProvider = { export type BitgetProviders = BitgetTronProvider & { aptos: unknown; ethereum: EthereumWindowProvider; - keplr: BitgetKeplrProvider; solana: BitgetSolanaProvider; suiWallet: BitgetSuiProvider; unisat: BitgetBtcProvider; From f12c9edbb5e1230fe6085c58569b6577bde1dd4c Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 16:48:04 +0000 Subject: [PATCH 12/24] Remove the temporary Bitget debug logs Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/helpers.ts | 5 +---- packages/wallet-extensions/src/bitget/index.ts | 13 +------------ 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index e6d1cc86..3598a7e1 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -221,10 +221,7 @@ async function getRippleWalletMethods() { const provider = getProvider("xrp", Chain.Ripple); const { getRippleToolbox, validateRippleAddress } = await import("@swapkit/toolboxes/ripple"); - const connection = await provider.connect(); - // TEMP debug log, remove before merge - console.info("[bitget-debug] XRP connect() returned", connection); - const address = readXrpAddress(connection); + const address = readXrpAddress(await provider.connect()); if (!(address && validateRippleAddress(address))) { throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Ripple }); } diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 89dfe407..85bd914c 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -15,32 +15,22 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ pendingRetriesExhausted = false; // One chain at a time, so a cancelled approval stops the rest and a missing chain does not drop the others - // TEMP debug logs, remove before merge - console.info("[bitget-debug] requested", chains, "connecting", filteredChains); - console.info("[bitget-debug] injected", Object.keys(window.bitkeep ?? {}), { ton: !!window.bitgetTonWallet }); - for (const [index, chain] of filteredChains.entries()) { - // TEMP experiment: give Bitget a second between requests, except between EVM chains that share one account + // Bitget needs a moment between requests, except between EVM chains that share one account const previous = filteredChains[index - 1]; if (previous && !(isEvmChain(previous) && isEvmChain(chain))) { await new Promise((resolve) => setTimeout(resolve, CHAIN_PAUSE_MS)); } - const startedAt = Date.now(); - console.info(`[bitget-debug] ${chain} start`); try { const walletMethods = await connectWhenIdle(chain); addChain({ ...walletMethods, chain, walletType }); - console.info(`[bitget-debug] ${chain} connected in ${Date.now() - startedAt}ms`, walletMethods.address); } catch (error) { - console.info(`[bitget-debug] ${chain} failed in ${Date.now() - startedAt}ms`, error); if (isUserRejection(error)) throw error; failedChains.push(error); } } - console.info("[bitget-debug] done, failed:", failedChains.length); - if (failedChains.length > 0 && failedChains.length === filteredChains.length) throw failedChains[0]; for (const error of failedChains) { @@ -112,7 +102,6 @@ async function connectWhenIdle(chain: Chain) { throw error; } - console.info(`[bitget-debug] ${chain} pending, retry ${attempt + 1}`); await new Promise((resolve) => setTimeout(resolve, PENDING_RETRY_MS)); } } From d13d6d293a07c7f90b296119f1927d1ed4418086 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 16:57:14 +0000 Subject: [PATCH 13/24] Drop the note about the parked Bitget chains Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 85bd914c..1df0d3dc 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -59,7 +59,6 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ ].map((chain) => [chain, { direct: true, typedData: true }]), ), [Chain.Bitcoin]: { direct: true }, - // Left out for now: Bitget refuses Cosmos and Starknet ("ChainIds not supported") and does not inject NEAR [Chain.Ripple]: { direct: true }, [Chain.Solana]: { direct: true }, [Chain.Sui]: { direct: true }, From 8c15509f7a7f461d68147e8ffbb503ddeeab6b83 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 17:14:56 +0000 Subject: [PATCH 14/24] Drop the Bitget pending retries and keep the pause between requests Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .../wallet-extensions/src/bitget/index.ts | 30 ++----------------- 1 file changed, 2 insertions(+), 28 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 1df0d3dc..dd16d202 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -12,18 +12,17 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ ); const failedChains: unknown[] = []; - pendingRetriesExhausted = false; // One chain at a time, so a cancelled approval stops the rest and a missing chain does not drop the others for (const [index, chain] of filteredChains.entries()) { - // Bitget needs a moment between requests, except between EVM chains that share one account + // With less than a second between requests Bitget mixes up its approval windows; EVM chains share one account const previous = filteredChains[index - 1]; if (previous && !(isEvmChain(previous) && isEvmChain(chain))) { await new Promise((resolve) => setTimeout(resolve, CHAIN_PAUSE_MS)); } try { - const walletMethods = await connectWhenIdle(chain); + const walletMethods = await getWalletMethods(chain); addChain({ ...walletMethods, chain, walletType }); } catch (error) { if (isUserRejection(error)) throw error; @@ -85,28 +84,3 @@ function isEvmChain(chain: Chain) { } const CHAIN_PAUSE_MS = 1000; -const PENDING_RETRY_MS = 300; -const PENDING_RETRIES = 10; -let pendingRetriesExhausted = false; - -// Bitget refuses a request made right after an approval instead of queueing it, so it is asked again shortly after -async function connectWhenIdle(chain: Chain) { - for (let attempt = 0; ; attempt++) { - try { - return await getWalletMethods(chain); - } catch (error) { - if (!isPendingApproval(error) || pendingRetriesExhausted) throw error; - if (attempt >= PENDING_RETRIES) { - pendingRetriesExhausted = true; - throw error; - } - - await new Promise((resolve) => setTimeout(resolve, PENDING_RETRY_MS)); - } - } -} - -function isPendingApproval(error: unknown) { - const { code, message } = (error ?? {}) as { code?: unknown; message?: unknown }; - return code === -32002 || (typeof message === "string" && message.includes("already pending")); -} From b6f3f79ca2f939555a1c9fe8540ee3d7a0af7158 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Wed, 11 Nov 2026 17:28:37 +0000 Subject: [PATCH 15/24] Verify what Bitget signs for Solana and XRP before sending it XRP now only asks Bitget for a signature, compares the signed blob with the requested transaction and broadcasts it from our side. Solana returns the requested transaction with the wallet signatures. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .changeset/bitget-new-chains.md | 2 +- bun.lock | 2 + packages/wallet-extensions/package.json | 11 +- .../src/bitget/__tests__/bitget.test.ts | 96 +++++++++++++---- .../wallet-extensions/src/bitget/helpers.ts | 102 ++++++++++-------- .../wallet-extensions/src/bitget/types.ts | 2 +- 6 files changed, 139 insertions(+), 76 deletions(-) diff --git a/.changeset/bitget-new-chains.md b/.changeset/bitget-new-chains.md index 561aa8c5..3fd889ca 100644 --- a/.changeset/bitget-new-chains.md +++ b/.changeset/bitget-new-chains.md @@ -3,4 +3,4 @@ "@swapkit/wallets": minor --- -Bitget Wallet now connects Sui, TON and XRP, and every EVM chain it lists. Chains are connected one at a time, so a chain missing in the wallet is skipped instead of failing the whole connection. Cosmos is no longer offered with Bitget for now: the wallet refuses the Cosmos Hub connection request. +Bitget Wallet now connects Sui, TON and XRP, and every EVM chain it lists. Chains are connected one at a time, so a chain missing in the wallet is skipped instead of failing the whole connection. Cosmos is no longer offered with Bitget for now: the wallet refuses the Cosmos Hub connection request. Solana, Tron and XRP transactions are checked against what was requested before they are sent, so a transaction the wallet altered is rejected. diff --git a/bun.lock b/bun.lock index cbb00a20..a7c3147d 100644 --- a/bun.lock +++ b/bun.lock @@ -67,6 +67,7 @@ "@wallet-standard/app": "^1.1.1", "cosmjs-types": "0.10.1", "ethers": "6.16.0", + "ripple-binary-codec": "~2.7.0", "sats-connect": "~1.0.0", "ts-pattern": "^5.9.0", }, @@ -86,6 +87,7 @@ "@wallet-standard/app": "1.1.1", "cosmjs-types": "0.10.1", "ethers": "6.16.0", + "ripple-binary-codec": "2.7.0", "sats-connect": "1.0.0", "ts-pattern": "^5.9.0", }, diff --git a/packages/wallet-extensions/package.json b/packages/wallet-extensions/package.json index 816e5e85..91eda96e 100644 --- a/packages/wallet-extensions/package.json +++ b/packages/wallet-extensions/package.json @@ -20,6 +20,7 @@ "@wallet-standard/app": "^1.1.1", "cosmjs-types": "0.10.1", "ethers": "6.16.0", + "ripple-binary-codec": "~2.7.0", "sats-connect": "~1.0.0", "ts-pattern": "^5.9.0" }, @@ -40,6 +41,7 @@ "@wallet-standard/app": "1.1.1", "cosmjs-types": "0.10.1", "ethers": "6.16.0", + "ripple-binary-codec": "2.7.0", "sats-connect": "1.0.0", "ts-pattern": "^5.9.0" }, @@ -147,16 +149,11 @@ "types": "./dist/types/vultisig/index.d.ts" } }, - "files": [ - "dist/", - "src/" - ], + "files": ["dist/", "src/"], "homepage": "https://github.com/swapkit/wallets", "license": "SEE LICENSE IN LICENSE", "name": "@swapkit/wallet-extensions", - "publishConfig": { - "access": "public" - }, + "publishConfig": { "access": "public" }, "repository": { "directory": "packages/wallet-extensions", "type": "git", diff --git a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts index 8c239ff9..0a6e3590 100644 --- a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts +++ b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts @@ -4,6 +4,7 @@ import { createHash } from "node:crypto"; import { base64 } from "@scure/base"; import { Keypair, SystemProgram, Transaction } from "@solana/web3.js"; import { Chain, SwapKitError } from "@swapkit/helpers"; +import { encode } from "ripple-binary-codec"; import { getWalletMethods } from "../helpers"; import { bitgetWallet } from "../index"; @@ -60,34 +61,53 @@ describe("Bitget Sui signer", () => { }); describe("Bitget Solana signer", () => { - test("rebuilds the transaction when the wallet answers with signed bytes", async () => { - const keypair = Keypair.generate(); + const keypair = Keypair.generate(); + + function transfer(lamports: number) { const transaction = new Transaction({ blockhash: "11111111111111111111111111111111", lastValidBlockHeight: 1 }); transaction.feePayer = keypair.publicKey; - transaction.add( - SystemProgram.transfer({ fromPubkey: keypair.publicKey, lamports: 1, toPubkey: keypair.publicKey }), - ); + transaction.add(SystemProgram.transfer({ fromPubkey: keypair.publicKey, lamports, toPubkey: keypair.publicKey })); + return transaction; + } + function mockSolana(signTransaction) { globalThis.window = { bitkeep: { solana: { connect: async () => ({ accounts: [{ address: keypair.publicKey.toBase58() }] }), publicKey: null, - signTransaction: (tx) => { - tx.sign(keypair); - return Promise.resolve([{ signedTransaction: tx.serialize() }]); - }, + signTransaction, }, }, }; + } + + function signedBytes(transaction: Transaction) { + transaction.sign(keypair); + return Promise.resolve([{ signedTransaction: transaction.serialize() }]); + } + + test("returns the requested transaction carrying the wallet signature", async () => { + const transaction = transfer(1); + mockSolana((requested) => + signedBytes(Transaction.from(requested.serialize({ requireAllSignatures: false, verifySignatures: false }))), + ); const wallet = await getWalletMethods(Chain.Solana); const signed = await wallet.signTransaction(transaction); expect(wallet.address).toBe(keypair.publicKey.toBase58()); - expect(signed).toBeInstanceOf(Transaction); + expect(signed).toBe(transaction); expect(signed.verifySignatures()).toBe(true); }); + + test("rejects a signature over a different transaction", async () => { + mockSolana(() => signedBytes(transfer(2))); + + const wallet = await getWalletMethods(Chain.Solana); + + await expect(wallet.signTransaction(transfer(1))).rejects.toThrow("core_transaction_failed"); + }); }); describe("Bitget Tron signer", () => { @@ -128,25 +148,61 @@ describe("Bitget Tron signer", () => { }); describe("Bitget XRP signer", () => { - test("hands the transaction to the wallet and returns the hash it reports", async () => { - let sent: Record | undefined; + const payment = { + Account: XRP_ADDRESS, + Amount: "1000000", + Destination: "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe", + DestinationTag: 123, + Memos: [{ Memo: { MemoData: "74657374" } }], + TransactionType: "Payment", + }; + + function mockXrp(alter = (transaction) => transaction) { + const signature = { SigningPubKey: `02${"ab".repeat(32)}`, TxnSignature: "ABCDEF" }; + const filled = { Fee: "12", Flags: 0, LastLedgerSequence: 20, Sequence: 7 }; + const blobs: string[] = []; + globalThis.window = { bitkeep: { xrp: { - connect: async () => ({ address: XRP_ADDRESS }), - sendTransaction: (transaction) => { - sent = transaction; - return Promise.resolve({ hash: "ABCDEF" }); + connect: async () => XRP_ADDRESS, + signTransaction: (transaction) => { + blobs.push(encode({ ...filled, ...alter(transaction), ...signature })); + return Promise.resolve(blobs[0]); }, }, }, }; + return blobs; + } + + test("returns the blob Bitget signed when it only fills in fee and sequence", async () => { + const blobs = mockXrp(); + + const wallet = await getWalletMethods(Chain.Ripple); + const signed = await wallet.signTransaction(payment); + + const hash = createHash("sha512") + .update(Buffer.from(`54584E00${blobs[0]}`, "hex")) + .digest("hex") + .slice(0, 64) + .toUpperCase(); + + expect(signed).toEqual({ hash, tx_blob: blobs[0] }); + }); + + test.each([ + ["drops the memo", ({ Memos, ...rest }) => rest], + ["drops the destination tag", ({ DestinationTag, ...rest }) => rest], + ["changes the amount", (transaction) => ({ ...transaction, Amount: "2000000" })], + ["changes the destination", (transaction) => ({ ...transaction, Destination: XRP_ADDRESS })], + ["adds a field", (transaction) => ({ ...transaction, SourceTag: 9 })], + ])("rejects a blob where Bitget %s", async (_name, alter) => { + mockXrp(alter); + const wallet = await getWalletMethods(Chain.Ripple); - const payment = { Account: XRP_ADDRESS, Amount: "1", Destination: XRP_ADDRESS, TransactionType: "Payment" }; - expect(wallet.address).toBe(XRP_ADDRESS); - expect(await wallet.signAndBroadcastTransaction(payment)).toBe("ABCDEF"); - expect(sent).toEqual(payment); + await expect(wallet.signTransaction(payment)).rejects.toThrow("core_transaction_failed"); }); }); diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index 3598a7e1..0be2f83a 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -1,13 +1,5 @@ import { base64, hex } from "@scure/base"; -import { - Chain, - type EVMChain, - EVMChains, - type GenericTransferParams, - prepareNetworkSwitch, - SwapKitError, - WalletOption, -} from "@swapkit/helpers"; +import { Chain, type EVMChain, EVMChains, prepareNetworkSwitch, SwapKitError, WalletOption } from "@swapkit/helpers"; import type { RippleTransaction } from "@swapkit/toolboxes/ripple"; import type { SolanaProvider } from "@swapkit/toolboxes/solana"; import type { SuiChainSigner } from "@swapkit/toolboxes/sui"; @@ -94,14 +86,35 @@ async function getSolanaWalletMethods() { const { PublicKey, Transaction: SolanaTransaction, VersionedTransaction } = await import("@solana/web3.js"); const publicKey = new PublicKey(address); - async function signTransaction(transaction: T) { - const signed = await provider.signTransaction(transaction); - if (!Array.isArray(signed)) return signed as T; + function messageOf(transaction: BitgetSolanaTransaction) { + return hex.encode("version" in transaction ? transaction.message.serialize() : transaction.serializeMessage()); + } - const bytes = signed[0]?.signedTransaction; - if (!bytes) throw transactionFailedError(Chain.Solana); + async function signTransaction(transaction: T) { + const requested = messageOf(transaction); + const response = await provider.signTransaction(transaction); + const bytes = Array.isArray(response) ? response[0]?.signedTransaction : undefined; + if (Array.isArray(response) && !bytes) throw transactionFailedError(Chain.Solana, "Bitget returned no signature"); + + const signed = Array.isArray(response) + ? "version" in transaction + ? VersionedTransaction.deserialize(bytes as Uint8Array) + : SolanaTransaction.from(bytes as Uint8Array) + : response; + + if (messageOf(signed) !== requested || messageOf(transaction) !== requested) { + throw transactionFailedError(Chain.Solana, "Bitget signed a different transaction than the one requested"); + } + + if (transaction instanceof VersionedTransaction) { + transaction.signatures = (signed as InstanceType).signatures; + } else { + for (const { publicKey: signerKey, signature } of (signed as InstanceType).signatures) { + if (signature) transaction.addSignature(signerKey, signature); + } + } - return ("version" in transaction ? VersionedTransaction.deserialize(bytes) : SolanaTransaction.from(bytes)) as T; + return transaction; } const signer: SolanaProvider = { @@ -201,54 +214,49 @@ async function getSuiWalletMethods() { return { ...getSuiToolbox({ signer }), address: account.address }; } -function readXrpAddress(connection: unknown) { - if (typeof connection === "string") return connection; - if (!(connection && typeof connection === "object")) return undefined; +const XRP_WALLET_FIELDS = ["Fee", "Flags", "LastLedgerSequence", "Sequence", "SigningPubKey"]; - const { address, account } = connection as { address?: unknown; account?: unknown }; - return [address, account].find((value): value is string => typeof value === "string"); -} - -function readXrpHash(result: unknown) { - if (typeof result === "string") return result; - if (!(result && typeof result === "object")) return undefined; +async function hashSignedXrpTransaction(blob: string) { + const bytes = hex.decode(`54584e00${blob.toLowerCase()}`); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-512", new Uint8Array(bytes))); - const { hash, txid } = result as { hash?: unknown; txid?: unknown }; - return [hash, txid].find((value): value is string => typeof value === "string"); + return hex.encode(digest.slice(0, 32)).toUpperCase(); } async function getRippleWalletMethods() { const provider = getProvider("xrp", Chain.Ripple); const { getRippleToolbox, validateRippleAddress } = await import("@swapkit/toolboxes/ripple"); - const address = readXrpAddress(await provider.connect()); - if (!(address && validateRippleAddress(address))) { + const address = await provider.connect(); + if (!(typeof address === "string" && validateRippleAddress(address))) { throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Ripple }); } - const toolbox = getRippleToolbox(); + const { decode, encode } = await import("ripple-binary-codec"); - // Bitget signs and submits XRP transactions itself and never hands back a signed blob - async function signAndBroadcastTransaction(transaction: RippleTransaction) { - const hash = readXrpHash(await provider.sendTransaction({ ...transaction })); - if (!hash) throw transactionFailedError(Chain.Ripple); + async function signTransaction(transaction: RippleTransaction) { + const blob = await provider.signTransaction({ ...transaction }); + if (typeof blob !== "string") throw transactionFailedError(Chain.Ripple, "Bitget returned no signed transaction"); - return hash; - } + const { TxnSignature, ...signed } = decode(blob); + if (!TxnSignature) throw transactionFailedError(Chain.Ripple, "Bitget returned no signature"); - async function transfer({ assetValue, memo, recipient, sweep }: GenericTransferParams) { - const transaction = await toolbox.createTransaction({ assetValue, memo, recipient, sender: address, sweep }); - return signAndBroadcastTransaction(transaction); + // Only the fields the wallet is expected to fill in may differ from the request + const expected: Record = { ...transaction }; + for (const field of XRP_WALLET_FIELDS) { + if (!(field in expected) && field in signed) expected[field] = signed[field]; + } + + if (encode(expected) !== encode(signed)) { + throw transactionFailedError(Chain.Ripple, "Bitget signed a different transaction than the one requested"); + } + + return { hash: await hashSignedXrpTransaction(blob), tx_blob: blob }; } - return { - ...toolbox, - address, - getAddress: () => Promise.resolve(address), - getBalance: () => toolbox.getBalance(address), - signAndBroadcastTransaction, - transfer, - }; + const signer = { getAddress: () => Promise.resolve(address), signTransaction }; + + return { ...getRippleToolbox({ signer }), address }; } export function getWalletMethods(chain: Chain): Promise { diff --git a/packages/wallet-extensions/src/bitget/types.ts b/packages/wallet-extensions/src/bitget/types.ts index 073e1449..1e0a15ef 100644 --- a/packages/wallet-extensions/src/bitget/types.ts +++ b/packages/wallet-extensions/src/bitget/types.ts @@ -46,7 +46,7 @@ export type BitgetSuiProvider = { export type BitgetXrpProvider = { connect: () => Promise; - sendTransaction: (transaction: Record) => Promise; + signTransaction: (transaction: Record) => Promise; }; export type BitgetProviders = BitgetTronProvider & { From c45ee9dea92729d17ea90443fdb6760339efd48a Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Thu, 12 Nov 2026 09:17:48 +0000 Subject: [PATCH 16/24] Restore the package.json formatting Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/package.json | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/wallet-extensions/package.json b/packages/wallet-extensions/package.json index 91eda96e..13dc0197 100644 --- a/packages/wallet-extensions/package.json +++ b/packages/wallet-extensions/package.json @@ -149,11 +149,16 @@ "types": "./dist/types/vultisig/index.d.ts" } }, - "files": ["dist/", "src/"], + "files": [ + "dist/", + "src/" + ], "homepage": "https://github.com/swapkit/wallets", "license": "SEE LICENSE IN LICENSE", "name": "@swapkit/wallet-extensions", - "publishConfig": { "access": "public" }, + "publishConfig": { + "access": "public" + }, "repository": { "directory": "packages/wallet-extensions", "type": "git", From 87c2d6f77f8e057978f275c7afe470472a44c488 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Thu, 12 Nov 2026 10:17:38 +0000 Subject: [PATCH 17/24] Address the Bitget review findings Take the XRP reference before the wallet sees the transaction, stop treating every Tron failure as a cancellation, and tidy the Solana response handling and the EVM chain check. Co-Authored-By: Claude Fable 5.2 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .../src/bitget/__tests__/bitget.test.ts | 41 +++++++++++++++++++ .../wallet-extensions/src/bitget/helpers.ts | 32 +++++++++------ .../wallet-extensions/src/bitget/index.ts | 6 +-- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts index 0a6e3590..1a8814b4 100644 --- a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts +++ b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts @@ -48,6 +48,34 @@ describe("Bitget connection", () => { }); }); +describe("Bitget Tron connection", () => { + function connectWithTronCode(code: number) { + globalThis.window = { + bitkeep: { + suiWallet: suiProvider(), + tronLink: { request: async () => ({ code, message: "" }) }, + tronWeb: { defaultAddress: {}, trx: {} }, + }, + }; + + return connectBitget([Chain.Tron, Chain.Sui]); + } + + test("skips Tron and connects the rest when the wallet fails without a cancellation", async () => { + const { connected, result } = connectWithTronCode(4000); + + expect(await result).toBe(true); + expect(connected).toEqual([Chain.Sui]); + }); + + test("stops when the user cancels the Tron approval", async () => { + const { connected, result } = connectWithTronCode(4001); + + await expect(result).rejects.toThrow("wallet_connection_rejected_by_user"); + expect(connected).toEqual([]); + }); +}); + describe("Bitget Sui signer", () => { test("rejects a signature over different bytes", async () => { const provider = suiProvider(); @@ -192,6 +220,19 @@ describe("Bitget XRP signer", () => { expect(signed).toEqual({ hash, tx_blob: blobs[0] }); }); + test("rejects a memo changed in place and leaves the request untouched", async () => { + mockXrp((transaction) => { + transaction.Memos[0].Memo.MemoData = "00"; + return transaction; + }); + const request = structuredClone(payment); + + const wallet = await getWalletMethods(Chain.Ripple); + + await expect(wallet.signTransaction(request)).rejects.toThrow("core_transaction_failed"); + expect(request).toEqual(payment); + }); + test.each([ ["drops the memo", ({ Memos, ...rest }) => rest], ["drops the destination tag", ({ DestinationTag, ...rest }) => rest], diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index 0be2f83a..db86d427 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -11,6 +11,7 @@ import { getUtxoScriptTypeParams } from "../helpers/utxoScriptType"; import type { BitgetProviders, BitgetSolanaTransaction } from "./types"; const SUI_MAINNET = "sui:mainnet"; +const TRON_USER_REJECTED = 4001; type WalletMethodsWithAddress = Record & { address: string }; @@ -93,14 +94,14 @@ async function getSolanaWalletMethods() { async function signTransaction(transaction: T) { const requested = messageOf(transaction); const response = await provider.signTransaction(transaction); - const bytes = Array.isArray(response) ? response[0]?.signedTransaction : undefined; - if (Array.isArray(response) && !bytes) throw transactionFailedError(Chain.Solana, "Bitget returned no signature"); + let signed = response as BitgetSolanaTransaction; - const signed = Array.isArray(response) - ? "version" in transaction - ? VersionedTransaction.deserialize(bytes as Uint8Array) - : SolanaTransaction.from(bytes as Uint8Array) - : response; + if (Array.isArray(response)) { + const bytes = response[0]?.signedTransaction; + if (!bytes) throw transactionFailedError(Chain.Solana, "Bitget returned no signature"); + + signed = "version" in transaction ? VersionedTransaction.deserialize(bytes) : SolanaTransaction.from(bytes); + } if (messageOf(signed) !== requested || messageOf(transaction) !== requested) { throw transactionFailedError(Chain.Solana, "Bitget signed a different transaction than the one requested"); @@ -132,9 +133,11 @@ async function getTronWalletMethods() { const tronWeb = getProvider("tronWeb", Chain.Tron); const response = await tronLink.request({ method: "tron_requestAccounts" }); + if (response.code === TRON_USER_REJECTED) throw connectionRejectedError(Chain.Tron); if (response.code !== 200) { - throw new SwapKitError("wallet_connection_rejected_by_user", { - message: response.message || "User rejected connection", + throw new SwapKitError({ + errorKey: "core_wallet_connection_failed", + info: { chain: Chain.Tron, code: response.code, message: response.message, wallet: WalletOption.BITGET }, }); } @@ -235,14 +238,15 @@ async function getRippleWalletMethods() { const { decode, encode } = await import("ripple-binary-codec"); async function signTransaction(transaction: RippleTransaction) { - const blob = await provider.signTransaction({ ...transaction }); + const requested: Record = structuredClone(transaction); + const blob = await provider.signTransaction(structuredClone(requested)); if (typeof blob !== "string") throw transactionFailedError(Chain.Ripple, "Bitget returned no signed transaction"); const { TxnSignature, ...signed } = decode(blob); if (!TxnSignature) throw transactionFailedError(Chain.Ripple, "Bitget returned no signature"); // Only the fields the wallet is expected to fill in may differ from the request - const expected: Record = { ...transaction }; + const expected = { ...requested }; for (const field of XRP_WALLET_FIELDS) { if (!(field in expected) && field in signed) expected[field] = signed[field]; } @@ -259,8 +263,12 @@ async function getRippleWalletMethods() { return { ...getRippleToolbox({ signer }), address }; } +export function isEvmChain(chain: Chain): chain is EVMChain { + return (EVMChains as readonly Chain[]).includes(chain); +} + export function getWalletMethods(chain: Chain): Promise { - if (EVMChains.includes(chain as EVMChain)) return getEvmWalletMethods(chain as EVMChain); + if (isEvmChain(chain)) return getEvmWalletMethods(chain); switch (chain) { case Chain.Aptos: diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index dd16d202..3e633238 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -1,7 +1,7 @@ import { Chain, EVMChains, filterSupportedChains, SwapKitError, WalletOption } from "@swapkit/helpers"; import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; import type { ExtensionWallet } from "../walletTypes"; -import { getWalletMethods } from "./helpers"; +import { getWalletMethods, isEvmChain } from "./helpers"; export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ connect: ({ addChain, walletType, supportedChains }) => @@ -79,8 +79,4 @@ function isUserRejection(error: unknown) { return code === 4001 || code === "ACTION_REJECTED"; } -function isEvmChain(chain: Chain) { - return (EVMChains as readonly Chain[]).includes(chain); -} - const CHAIN_PAUSE_MS = 1000; From 443b455bf56d5846f2faab888baa2d3f493344c0 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Thu, 12 Nov 2026 11:13:36 +0000 Subject: [PATCH 18/24] Share the user rejection check between Bitget and OneKey Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/index.ts | 11 ++--------- .../wallet-extensions/src/helpers/userRejection.ts | 9 +++++++++ packages/wallet-extensions/src/onekey/index.ts | 9 +-------- 3 files changed, 12 insertions(+), 17 deletions(-) create mode 100644 packages/wallet-extensions/src/helpers/userRejection.ts diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 3e633238..5acae69b 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -1,5 +1,6 @@ -import { Chain, EVMChains, filterSupportedChains, SwapKitError, WalletOption } from "@swapkit/helpers"; +import { Chain, EVMChains, filterSupportedChains, WalletOption } from "@swapkit/helpers"; import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; +import { isUserRejection } from "../helpers/userRejection"; import type { ExtensionWallet } from "../walletTypes"; import { getWalletMethods, isEvmChain } from "./helpers"; @@ -71,12 +72,4 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ export const BITGET_SUPPORTED_CHAINS = getWalletSupportedChains(bitgetWallet); -function isUserRejection(error: unknown) { - if (error instanceof SwapKitError) return error.errorKey === "wallet_connection_rejected_by_user"; - - // ethers wraps the EIP-1193 4001 rejection of the EVM provider as ACTION_REJECTED - const code = (error as { code?: unknown } | null)?.code; - return code === 4001 || code === "ACTION_REJECTED"; -} - const CHAIN_PAUSE_MS = 1000; diff --git a/packages/wallet-extensions/src/helpers/userRejection.ts b/packages/wallet-extensions/src/helpers/userRejection.ts new file mode 100644 index 00000000..e9385a08 --- /dev/null +++ b/packages/wallet-extensions/src/helpers/userRejection.ts @@ -0,0 +1,9 @@ +import { SwapKitError } from "@swapkit/helpers"; + +export function isUserRejection(error: unknown) { + if (error instanceof SwapKitError) return error.errorKey === "wallet_connection_rejected_by_user"; + + // ethers wraps the EIP-1193 4001 rejection of the EVM provider as ACTION_REJECTED + const code = (error as { code?: unknown } | null)?.code; + return code === 4001 || code === "ACTION_REJECTED"; +} diff --git a/packages/wallet-extensions/src/onekey/index.ts b/packages/wallet-extensions/src/onekey/index.ts index 7f83f5cc..2291ff9b 100644 --- a/packages/wallet-extensions/src/onekey/index.ts +++ b/packages/wallet-extensions/src/onekey/index.ts @@ -1,5 +1,6 @@ import { Chain, filterSupportedChains, SwapKitError, WalletOption } from "@swapkit/helpers"; import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; +import { isUserRejection } from "../helpers/userRejection"; import type { ExtensionWallet } from "../walletTypes"; import { getWalletMethods } from "./helpers"; @@ -76,11 +77,3 @@ export const onekeyWallet: ExtensionWallet<"connectOnekeyWallet"> = createWallet }); export const ONEKEY_WALLET_SUPPORTED_CHAINS = getWalletSupportedChains(onekeyWallet); - -function isUserRejection(error: unknown) { - if (error instanceof SwapKitError) return error.errorKey === "wallet_connection_rejected_by_user"; - - // ethers wraps the EIP-1193 4001 rejection of the EVM provider as ACTION_REJECTED - const code = (error as { code?: unknown } | null)?.code; - return code === 4001 || code === "ACTION_REJECTED"; -} From 553b9d621eb875b9a97e6c87f667c8e2e9383ec5 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Thu, 12 Nov 2026 12:55:11 +0000 Subject: [PATCH 19/24] Reject XRP flags Bitget adds and tidy Bitget errors and types Only the canonical signature flag may be added by the wallet, so a partial payment flag no longer passes the check. Restores the toSignInputs option on the Bitcoin provider type. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .../src/bitget/__tests__/bitget.test.ts | 3 ++- .../wallet-extensions/src/bitget/helpers.ts | 25 ++++++++++++++----- .../wallet-extensions/src/bitget/types.ts | 10 +++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts index 1a8814b4..d0aace60 100644 --- a/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts +++ b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts @@ -187,7 +187,7 @@ describe("Bitget XRP signer", () => { function mockXrp(alter = (transaction) => transaction) { const signature = { SigningPubKey: `02${"ab".repeat(32)}`, TxnSignature: "ABCDEF" }; - const filled = { Fee: "12", Flags: 0, LastLedgerSequence: 20, Sequence: 7 }; + const filled = { Fee: "12", Flags: 0x80000000, LastLedgerSequence: 20, Sequence: 7 }; const blobs: string[] = []; globalThis.window = { @@ -239,6 +239,7 @@ describe("Bitget XRP signer", () => { ["changes the amount", (transaction) => ({ ...transaction, Amount: "2000000" })], ["changes the destination", (transaction) => ({ ...transaction, Destination: XRP_ADDRESS })], ["adds a field", (transaction) => ({ ...transaction, SourceTag: 9 })], + ["adds the partial payment flag", (transaction) => ({ ...transaction, Flags: 0x80020000 })], ])("rejects a blob where Bitget %s", async (_name, alter) => { mockXrp(alter); diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index db86d427..3b8aa035 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -37,6 +37,10 @@ function connectionRejectedError(chain: Chain) { return new SwapKitError("wallet_connection_rejected_by_user", { chain, wallet: WalletOption.BITGET }); } +function noAccountsError(chain: Chain) { + return new SwapKitError("wallet_bitkeep_no_accounts", { chain, wallet: WalletOption.BITGET }); +} + function transactionFailedError(chain: Chain, error?: unknown) { return new SwapKitError("core_transaction_failed", { chain, error, wallet: WalletOption.BITGET }); } @@ -46,7 +50,7 @@ async function getEvmWalletMethods(chain: EVMChain) { // Asked only once per site: a second eth_requestAccounts while one is open is rejected as already pending const [connected] = (await wallet.request({ method: "eth_accounts" })) as string[]; const [address] = connected ? [connected] : ((await wallet.request({ method: "eth_requestAccounts" })) as string[]); - if (!address) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain }); + if (!address) throw noAccountsError(chain); const evmWallet = await getWeb3WalletMethods({ chain, walletProvider: wallet }); @@ -81,7 +85,7 @@ async function getSolanaWalletMethods() { const connection = await provider.connect(); const address = provider.publicKey?.toString() ?? connection?.publicKey?.toString() ?? connection?.accounts?.[0]?.address; - if (!address) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Solana }); + if (!address) throw noAccountsError(Chain.Solana); const { getSolanaToolbox } = await import("@swapkit/toolboxes/solana"); const { PublicKey, Transaction: SolanaTransaction, VersionedTransaction } = await import("@solana/web3.js"); @@ -142,7 +146,7 @@ async function getTronWalletMethods() { } const address = tronWeb.defaultAddress?.base58; - if (!address) throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Tron }); + if (!address) throw noAccountsError(Chain.Tron); const { getTronToolbox } = await import("@swapkit/toolboxes/tron"); const signer = { @@ -182,7 +186,7 @@ function getTonWalletMethods() { return getTonConnectWallet({ bridge, mainnetOnlyError: () => mainnetOnlyError(Chain.Ton), - noAccountError: () => new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Ton }), + noAccountError: () => noAccountsError(Chain.Ton), wallet: WalletOption.BITGET, }); } @@ -217,7 +221,8 @@ async function getSuiWalletMethods() { return { ...getSuiToolbox({ signer }), address: account.address }; } -const XRP_WALLET_FIELDS = ["Fee", "Flags", "LastLedgerSequence", "Sequence", "SigningPubKey"]; +const XRP_WALLET_FIELDS = ["Fee", "LastLedgerSequence", "Sequence", "SigningPubKey"]; +const XRP_FULLY_CANONICAL_SIG = 0x80000000; async function hashSignedXrpTransaction(blob: string) { const bytes = hex.decode(`54584e00${blob.toLowerCase()}`); @@ -232,7 +237,7 @@ async function getRippleWalletMethods() { const address = await provider.connect(); if (!(typeof address === "string" && validateRippleAddress(address))) { - throw new SwapKitError("wallet_bitkeep_no_accounts", { chain: Chain.Ripple }); + throw noAccountsError(Chain.Ripple); } const { decode, encode } = await import("ripple-binary-codec"); @@ -251,6 +256,14 @@ async function getRippleWalletMethods() { if (!(field in expected) && field in signed) expected[field] = signed[field]; } + // Any other flag, such as partial payment, would change what the payment delivers + const requestedFlags = Number(requested.Flags ?? 0); + const signedFlags = Number(signed.Flags ?? 0); + if (signedFlags !== requestedFlags && signedFlags !== (requestedFlags | XRP_FULLY_CANONICAL_SIG) >>> 0) { + throw transactionFailedError(Chain.Ripple, "Bitget changed the transaction flags"); + } + if ("Flags" in signed) expected.Flags = signed.Flags; + if (encode(expected) !== encode(signed)) { throw transactionFailedError(Chain.Ripple, "Bitget signed a different transaction than the one requested"); } diff --git a/packages/wallet-extensions/src/bitget/types.ts b/packages/wallet-extensions/src/bitget/types.ts index 1e0a15ef..96a0d5ac 100644 --- a/packages/wallet-extensions/src/bitget/types.ts +++ b/packages/wallet-extensions/src/bitget/types.ts @@ -1,11 +1,19 @@ import type { PublicKey, Transaction, VersionedTransaction } from "@solana/web3.js"; import type { EthereumWindowProvider } from "@swapkit/helpers"; +type UnisatToSignInputs = { index: number; sighashTypes?: number[]; disableTweakSigner?: boolean } & ( + | { address: string } + | { publicKey: string } +); + export type BitgetBtcProvider = { getNetwork?: () => Promise; requestAccounts: () => Promise; signMessage: (message: string, type?: "ecdsa" | "bip322-simple") => Promise; - signPsbt: (psbtHex: string, options: { autoFinalized?: boolean }) => Promise; + signPsbt: ( + psbtHex: string, + options: { autoFinalized?: boolean; toSignInputs?: UnisatToSignInputs[] }, + ) => Promise; }; export type BitgetSolanaTransaction = Transaction | VersionedTransaction; From 2c4d7ad54ccf17c4fc685223aa58726965b03fa0 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Thu, 12 Nov 2026 13:08:27 +0000 Subject: [PATCH 20/24] Wait 250ms between Bitget EVM chain requests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/index.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 5acae69b..b4dd5756 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -16,10 +16,11 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ // One chain at a time, so a cancelled approval stops the rest and a missing chain does not drop the others for (const [index, chain] of filteredChains.entries()) { - // With less than a second between requests Bitget mixes up its approval windows; EVM chains share one account + // Requests sent back to back make Bitget mix up its approval windows; EVM chains share one account const previous = filteredChains[index - 1]; - if (previous && !(isEvmChain(previous) && isEvmChain(chain))) { - await new Promise((resolve) => setTimeout(resolve, CHAIN_PAUSE_MS)); + if (previous) { + const pause = isEvmChain(previous) && isEvmChain(chain) ? EVM_CHAIN_PAUSE_MS : CHAIN_PAUSE_MS; + await new Promise((resolve) => setTimeout(resolve, pause)); } try { @@ -73,3 +74,4 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ export const BITGET_SUPPORTED_CHAINS = getWalletSupportedChains(bitgetWallet); const CHAIN_PAUSE_MS = 1000; +const EVM_CHAIN_PAUSE_MS = 250; From 3096b79021c9c43094049be14492e96a8524f60f Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Thu, 12 Nov 2026 13:23:16 +0000 Subject: [PATCH 21/24] Wait two seconds between Bitget non-EVM chain requests Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index b4dd5756..ce32eb5d 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -73,5 +73,5 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ export const BITGET_SUPPORTED_CHAINS = getWalletSupportedChains(bitgetWallet); -const CHAIN_PAUSE_MS = 1000; +const CHAIN_PAUSE_MS = 2000; const EVM_CHAIN_PAUSE_MS = 250; From 5670e3720be064bce6aa30ebda3995bc470086f7 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Thu, 12 Nov 2026 13:26:02 +0000 Subject: [PATCH 22/24] TEMP: log each Bitget chain request and page focus while connecting Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- .../wallet-extensions/src/bitget/helpers.ts | 2 ++ .../wallet-extensions/src/bitget/index.ts | 22 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/wallet-extensions/src/bitget/helpers.ts b/packages/wallet-extensions/src/bitget/helpers.ts index 3b8aa035..c6dd2db5 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -236,6 +236,8 @@ async function getRippleWalletMethods() { const { getRippleToolbox, validateRippleAddress } = await import("@swapkit/toolboxes/ripple"); const address = await provider.connect(); + // TEMP debug log, remove before merge + console.info("[bitget-debug] XRP connect() returned", address); if (!(typeof address === "string" && validateRippleAddress(address))) { throw noAccountsError(Chain.Ripple); } diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index ce32eb5d..c127ca75 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -14,6 +14,15 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ const failedChains: unknown[] = []; + // TEMP debug logs, remove before merge + const startedAt = Date.now(); + const debug = (...args: unknown[]) => console.info(`[bitget-debug] +${Date.now() - startedAt}ms`, ...args); + const onBlur = () => debug("page lost focus (Bitget window opened?)"); + const onFocus = () => debug("page got focus back (Bitget window closed?)"); + window.addEventListener("blur", onBlur); + window.addEventListener("focus", onFocus); + debug("order", filteredChains); + // One chain at a time, so a cancelled approval stops the rest and a missing chain does not drop the others for (const [index, chain] of filteredChains.entries()) { // Requests sent back to back make Bitget mix up its approval windows; EVM chains share one account @@ -23,15 +32,26 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ await new Promise((resolve) => setTimeout(resolve, pause)); } + debug(`${chain} request sent`); try { const walletMethods = await getWalletMethods(chain); addChain({ ...walletMethods, chain, walletType }); + debug(`${chain} connected`, walletMethods.address); } catch (error) { - if (isUserRejection(error)) throw error; + debug(`${chain} failed`, error); + if (isUserRejection(error)) { + window.removeEventListener("blur", onBlur); + window.removeEventListener("focus", onFocus); + throw error; + } failedChains.push(error); } } + debug("done"); + window.removeEventListener("blur", onBlur); + window.removeEventListener("focus", onFocus); + if (failedChains.length > 0 && failedChains.length === filteredChains.length) throw failedChains[0]; for (const error of failedChains) { From a3e31e1e77c542c2002f75f9257c0bc8595a0069 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Thu, 12 Nov 2026 13:26:27 +0000 Subject: [PATCH 23/24] TEMP: keep the Bitget debug logs working without browser events Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/index.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index c127ca75..7aa3239e 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -19,8 +19,8 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ const debug = (...args: unknown[]) => console.info(`[bitget-debug] +${Date.now() - startedAt}ms`, ...args); const onBlur = () => debug("page lost focus (Bitget window opened?)"); const onFocus = () => debug("page got focus back (Bitget window closed?)"); - window.addEventListener("blur", onBlur); - window.addEventListener("focus", onFocus); + window.addEventListener?.("blur", onBlur); + window.addEventListener?.("focus", onFocus); debug("order", filteredChains); // One chain at a time, so a cancelled approval stops the rest and a missing chain does not drop the others @@ -40,8 +40,8 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ } catch (error) { debug(`${chain} failed`, error); if (isUserRejection(error)) { - window.removeEventListener("blur", onBlur); - window.removeEventListener("focus", onFocus); + window.removeEventListener?.("blur", onBlur); + window.removeEventListener?.("focus", onFocus); throw error; } failedChains.push(error); @@ -49,8 +49,8 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ } debug("done"); - window.removeEventListener("blur", onBlur); - window.removeEventListener("focus", onFocus); + window.removeEventListener?.("blur", onBlur); + window.removeEventListener?.("focus", onFocus); if (failedChains.length > 0 && failedChains.length === filteredChains.length) throw failedChains[0]; From edcec220eaa6e2e6673477c55c618434bde92df2 Mon Sep 17 00:00:00 2001 From: HippocampusSK Date: Thu, 12 Nov 2026 13:31:32 +0000 Subject: [PATCH 24/24] Wait one second between Bitget non-EVM chains and none between EVM chains Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y7T5QKQRd8CprqzccbMRJX --- packages/wallet-extensions/src/bitget/index.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/wallet-extensions/src/bitget/index.ts b/packages/wallet-extensions/src/bitget/index.ts index 7aa3239e..cb5d7909 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -27,9 +27,8 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ for (const [index, chain] of filteredChains.entries()) { // Requests sent back to back make Bitget mix up its approval windows; EVM chains share one account const previous = filteredChains[index - 1]; - if (previous) { - const pause = isEvmChain(previous) && isEvmChain(chain) ? EVM_CHAIN_PAUSE_MS : CHAIN_PAUSE_MS; - await new Promise((resolve) => setTimeout(resolve, pause)); + if (previous && !(isEvmChain(previous) && isEvmChain(chain))) { + await new Promise((resolve) => setTimeout(resolve, CHAIN_PAUSE_MS)); } debug(`${chain} request sent`); @@ -93,5 +92,4 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ export const BITGET_SUPPORTED_CHAINS = getWalletSupportedChains(bitgetWallet); -const CHAIN_PAUSE_MS = 2000; -const EVM_CHAIN_PAUSE_MS = 250; +const CHAIN_PAUSE_MS = 1000;