Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
db24d3f
Connect NEAR, Starknet, Sui, TON and XRP with Bitget Wallet
hippocampusSK Nov 11, 2026
9d2bc78
Ask Bitget for the EVM account once and connect EVM chains first
hippocampusSK Nov 11, 2026
0663a09
TEMP: log each Bitget chain while connecting
hippocampusSK Nov 11, 2026
103e6f4
Stop offering Cosmos with Bitget Wallet for now
hippocampusSK Nov 11, 2026
5491627
Connect Bitget EVM chains after the other chains
hippocampusSK Nov 11, 2026
bacf76d
Retry a Bitget chain refused as already pending
hippocampusSK Nov 11, 2026
82aa84a
TEMP: wait one second between Bitget chain requests
hippocampusSK Nov 11, 2026
a431af4
Stop offering Starknet with Bitget Wallet for now
hippocampusSK Nov 11, 2026
29e09fd
Stop offering NEAR with Bitget Wallet and pause only around non-EVM c…
hippocampusSK Nov 11, 2026
b86f981
Broadcast the requested Tron transaction with Bitget's signature
hippocampusSK Nov 11, 2026
696695e
Move the unused Bitget Cosmos code out with the parked chains
hippocampusSK Nov 11, 2026
f12c9ed
Remove the temporary Bitget debug logs
hippocampusSK Nov 11, 2026
d13d6d2
Drop the note about the parked Bitget chains
hippocampusSK Nov 11, 2026
8c15509
Drop the Bitget pending retries and keep the pause between requests
hippocampusSK Nov 11, 2026
b6f3f79
Verify what Bitget signs for Solana and XRP before sending it
hippocampusSK Nov 11, 2026
c45ee9d
Restore the package.json formatting
hippocampusSK Nov 12, 2026
87c2d6f
Address the Bitget review findings
hippocampusSK Nov 12, 2026
443b455
Share the user rejection check between Bitget and OneKey
hippocampusSK Nov 12, 2026
553b9d6
Reject XRP flags Bitget adds and tidy Bitget errors and types
hippocampusSK Nov 12, 2026
2c4d7ad
Wait 250ms between Bitget EVM chain requests
hippocampusSK Nov 12, 2026
3096b79
Wait two seconds between Bitget non-EVM chain requests
hippocampusSK Nov 12, 2026
5670e37
TEMP: log each Bitget chain request and page focus while connecting
hippocampusSK Nov 12, 2026
a3e31e1
TEMP: keep the Bitget debug logs working without browser events
hippocampusSK Nov 12, 2026
edcec22
Wait one second between Bitget non-EVM chains and none between EVM ch…
hippocampusSK Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/bitget-new-chains.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions bun.lock

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

2 changes: 2 additions & 0 deletions packages/wallet-extensions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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"
},
Expand Down
250 changes: 250 additions & 0 deletions packages/wallet-extensions/src/bitget/__tests__/bitget.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading
Loading