diff --git a/.changeset/bitget-new-chains.md b/.changeset/bitget-new-chains.md new file mode 100644 index 00000000..3fd889ca --- /dev/null +++ b/.changeset/bitget-new-chains.md @@ -0,0 +1,6 @@ +--- +"@swapkit/wallet-extensions": minor +"@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. 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..13dc0197 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" }, 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..d0aace60 --- /dev/null +++ b/packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts @@ -0,0 +1,250 @@ +// @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"; +import { encode } from "ripple-binary-codec"; + +import { getWalletMethods } 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.Ton, 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.Ton]); + + await expect(result).rejects.toBe(rejection); + expect(connected).toEqual([]); + }); +}); + +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(); + 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", () => { + 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, toPubkey: keypair.publicKey })); + return transaction; + } + + function mockSolana(signTransaction) { + globalThis.window = { + bitkeep: { + solana: { + connect: async () => ({ accounts: [{ address: keypair.publicKey.toBase58() }] }), + publicKey: null, + 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).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", () => { + 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", () => { + 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: 0x80000000, LastLedgerSequence: 20, Sequence: 7 }; + const blobs: string[] = []; + + globalThis.window = { + bitkeep: { + xrp: { + 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("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], + ["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); + + const wallet = await getWalletMethods(Chain.Ripple); + + 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 e8b367f4..c6dd2db5 100644 --- a/packages/wallet-extensions/src/bitget/helpers.ts +++ b/packages/wallet-extensions/src/bitget/helpers.ts @@ -1,161 +1,308 @@ -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, 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"; 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 { BitgetProviders, BitgetSolanaTransaction } from "./types"; + +const SUI_MAINNET = "sui:mainnet"; +const TRON_USER_REJECTED = 4001; 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 noAccountsError(chain: Chain) { + return new SwapKitError("wallet_bitkeep_no_accounts", { chain, 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; +function transactionFailedError(chain: Chain, error?: unknown) { + return new SwapKitError("core_transaction_failed", { chain, error, wallet: WalletOption.BITGET }); +} + +async function getEvmWalletMethods(chain: EVMChain) { + const wallet = getProvider("ethereum", chain); + // 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 noAccountsError(chain); + + const evmWallet = await getWeb3WalletMethods({ chain, walletProvider: wallet }); - 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 }); + return { ...evmWallet, address }; +} + +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 { getUtxoToolbox } = await import("@swapkit/toolboxes/utxo"); - const { getCosmosToolbox } = await import("@swapkit/toolboxes/cosmos"); - const [{ address }] = accounts; + async function signTransaction(tx: InstanceType) { + const signedPsbtHex = await wallet.signPsbt(hex.encode(tx.toPSBT(0)), { autoFinalized: false }); - const signer = Object.assign(offlineSigner, { getAddress: () => Promise.resolve(address) }); + return Transaction.fromPSBT(hex.decode(signedPsbtHex)); + } - const toolbox = getCosmosToolbox(Chain.Cosmos, { signer }); + const signer = { getAddress: () => Promise.resolve(address), signTransaction }; + const toolbox = getUtxoToolbox(Chain.Bitcoin, { + ...(await getUtxoScriptTypeParams({ address, chain: Chain.Bitcoin })), + signer, + }); - return { ...toolbox, address }; - }) - .with(Chain.Solana, async () => { - if (!(bitget && "solana" in bitget)) { - throw new SwapKitError("wallet_bitkeep_not_found"); + return { ...toolbox, 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 noAccountsError(Chain.Solana); + + const { getSolanaToolbox } = await import("@swapkit/toolboxes/solana"); + const { PublicKey, Transaction: SolanaTransaction, VersionedTransaction } = await import("@solana/web3.js"); + const publicKey = new PublicKey(address); + + function messageOf(transaction: BitgetSolanaTransaction) { + return hex.encode("version" in transaction ? transaction.message.serialize() : transaction.serializeMessage()); + } + + async function signTransaction(transaction: T) { + const requested = messageOf(transaction); + const response = await provider.signTransaction(transaction); + let signed = response as BitgetSolanaTransaction; + + 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"); + } + + 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 transaction; + } - const { getSolanaToolbox } = await import("@swapkit/toolboxes/solana"); - const provider = bitget?.solana; + const signer: SolanaProvider = { + connect: () => Promise.resolve({ publicKey }), + disconnect: () => provider.disconnect(), + publicKey, + signTransaction, + }; - const { publicKey } = await provider.connect(); - const address: string = publicKey.toString(); + 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 === TRON_USER_REJECTED) throw connectionRejectedError(Chain.Tron); + if (response.code !== 200) { + throw new SwapKitError({ + errorKey: "core_wallet_connection_failed", + info: { chain: Chain.Tron, code: response.code, message: response.message, wallet: WalletOption.BITGET }, + }); + } - const signer = Object.assign(provider, { getAddress: async () => address }); + const address = tronWeb.defaultAddress?.base58; + if (!address) throw noAccountsError(Chain.Tron); - const toolbox = getSolanaToolbox({ signer }); + const { getTronToolbox } = await import("@swapkit/toolboxes/tron"); + const signer = { + getAddress: () => Promise.resolve(address), + signTransaction: async (transaction: TronTransaction) => { + const signed = await tronWeb.trx.sign(structuredClone(transaction)); - return { ...toolbox, address }; - }) - .with(Chain.Tron, async () => { - if (!(bitget && "tronLink" in bitget && "tronWeb" in bitget)) { - throw new SwapKitError("wallet_bitkeep_not_found"); + 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 }; +} + +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); - const { getTronToolbox } = await import("@swapkit/toolboxes/tron"); - const { tronLink, tronWeb } = bitget; + return { ...getAptosToolbox(), address, transfer: createAptosExtensionTransfer({ provider: aptosProvider }) }; +} + +function getTonWalletMethods() { + const bridge = window.bitgetTonWallet?.tonconnect; + if (!bridge) throw notFoundError(Chain.Ton); - const response = await tronLink.request({ method: "tron_requestAccounts" }); + return getTonConnectWallet({ + bridge, + mainnetOnlyError: () => mainnetOnlyError(Chain.Ton), + noAccountError: () => noAccountsError(Chain.Ton), + wallet: WalletOption.BITGET, + }); +} - if (response.code !== 200) { - throw new SwapKitError("wallet_connection_rejected_by_user", { - message: response.message || "User rejected connection", +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"); - } +const XRP_WALLET_FIELDS = ["Fee", "LastLedgerSequence", "Sequence", "SigningPubKey"]; +const XRP_FULLY_CANONICAL_SIG = 0x80000000; - const { createAptosExtensionTransfer, getAptosToolbox, validateAptosAddress } = await import( - "@swapkit/toolboxes/aptos" - ); - const aptosProvider = bitget.aptos as import("@swapkit/toolboxes/aptos").AptosExtensionProvider; +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 { address } = await aptosProvider.connect(); - if (!validateAptosAddress(address)) throw new SwapKitError("wallet_bitkeep_not_found"); + return hex.encode(digest.slice(0, 32)).toUpperCase(); +} - const toolbox = getAptosToolbox(); +async function getRippleWalletMethods() { + const provider = getProvider("xrp", Chain.Ripple); + const { getRippleToolbox, validateRippleAddress } = await import("@swapkit/toolboxes/ripple"); - return { ...toolbox, address, transfer: createAptosExtensionTransfer({ provider: aptosProvider }) }; - }) - .otherwise(() => { - throw new SwapKitError("wallet_chain_not_supported"); - }); + 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); + } + + const { decode, encode } = await import("ripple-binary-codec"); + + async function signTransaction(transaction: RippleTransaction) { + 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 = { ...requested }; + for (const field of XRP_WALLET_FIELDS) { + 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"); + } + + return { hash: await hashSignedXrpTransaction(blob), tx_blob: blob }; + } + + const signer = { getAddress: () => Promise.resolve(address), signTransaction }; + + 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 (isEvmChain(chain)) return getEvmWalletMethods(chain); + + switch (chain) { + case Chain.Aptos: + return getAptosWalletMethods(); + case Chain.Bitcoin: + return getBitcoinWalletMethods(); + case Chain.Ripple: + return getRippleWalletMethods(); + case Chain.Solana: + return getSolanaWalletMethods(); + 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..cb5d7909 100644 --- a/packages/wallet-extensions/src/bitget/index.ts +++ b/packages/wallet-extensions/src/bitget/index.ts @@ -1,20 +1,61 @@ 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 } from "./helpers"; +import { getWalletMethods, isEvmChain } 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 last: the chains asked right after its approval were refused as already pending + const filteredChains = filterSupportedChains({ chains, supportedChains, walletType }).sort( + (a, b) => Number(isEvmChain(a)) - Number(isEvmChain(b)), + ); - await Promise.all( - filteredChains.map(async (chain) => { - const walletMethods = await getWalletMethods(chain); + 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 + const previous = filteredChains[index - 1]; + if (previous && !(isEvmChain(previous) && isEvmChain(chain))) { + await new Promise((resolve) => setTimeout(resolve, CHAIN_PAUSE_MS)); + } + + debug(`${chain} request sent`); + try { + const walletMethods = await getWalletMethods(chain); addChain({ ...walletMethods, chain, walletType }); - }), - ); + debug(`${chain} connected`, walletMethods.address); + } catch (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) { + console.warn("connectBitget: skipping a chain Bitget cannot connect", error); + } return true; }, @@ -38,13 +79,17 @@ export const bitgetWallet: ExtensionWallet<"connectBitget"> = createWallet({ ].map((chain) => [chain, { direct: true, typedData: true }]), ), [Chain.Bitcoin]: { direct: true }, - [Chain.Cosmos]: { direct: true }, + [Chain.Ripple]: { direct: true }, [Chain.Solana]: { 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.Ripple, Chain.Solana, Chain.Sui, Chain.Ton, Chain.Tron], walletType: WalletOption.BITGET, }); export const BITGET_SUPPORTED_CHAINS = getWalletSupportedChains(bitgetWallet); + +const CHAIN_PAUSE_MS = 1000; diff --git a/packages/wallet-extensions/src/bitget/types.ts b/packages/wallet-extensions/src/bitget/types.ts new file mode 100644 index 00000000..96a0d5ac --- /dev/null +++ b/packages/wallet-extensions/src/bitget/types.ts @@ -0,0 +1,67 @@ +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; toSignInputs?: UnisatToSignInputs[] }, + ) => 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 BitgetXrpProvider = { + connect: () => Promise; + signTransaction: (transaction: Record) => Promise; +}; + +export type BitgetProviders = BitgetTronProvider & { + aptos: unknown; + ethereum: EthereumWindowProvider; + solana: BitgetSolanaProvider; + suiWallet: BitgetSuiProvider; + unisat: BitgetBtcProvider; + xrp: BitgetXrpProvider; +}; 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"; -} diff --git a/packages/wallet-extensions/src/types.ts b/packages/wallet-extensions/src/types.ts index 613f4621..04e3e306 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 } 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,8 @@ 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; + bitgetTonWallet?: { tonconnect: TonConnectBridge }; okxwallet?: | {