diff --git a/.changeset/okx-sui.md b/.changeset/okx-sui.md new file mode 100644 index 00000000..815150cc --- /dev/null +++ b/.changeset/okx-sui.md @@ -0,0 +1,6 @@ +--- +"@swapkit/wallet-extensions": minor +"@swapkit/wallets": minor +--- + +OKX Wallet now connects Sui. OKX does not inject a Sui provider on `window`, so it is found through the Wallet Standard as "OKX Wallet". diff --git a/packages/wallet-extensions/src/okx/__tests__/okx.test.ts b/packages/wallet-extensions/src/okx/__tests__/okx.test.ts index a1c5b725..b8c46766 100644 --- a/packages/wallet-extensions/src/okx/__tests__/okx.test.ts +++ b/packages/wallet-extensions/src/okx/__tests__/okx.test.ts @@ -3,10 +3,13 @@ import { afterEach, describe, expect, test } from "bun:test"; import { bech32, hex } from "@scure/base"; import { AssetValue, Chain } from "@swapkit/helpers"; import { Address, beginCell } from "@ton/core"; +import { getWallets } from "@wallet-standard/app"; import { getWalletMethods } from "../helpers"; import { okxWallet } from "../index"; +const SUI_ADDRESS = `0x${"7".repeat(64)}`; + const RAW_TON_ADDRESS = `0:${"ab".repeat(32)}`; // CIP-19 mainnet base address test vector @@ -111,6 +114,78 @@ describe("connectOkx", () => { }); }); +describe("OKX Sui", () => { + const unregisterWallets = []; + + function registerSuiWallet({ chains = ["sui:mainnet"], signFeature = true, signedBytes = undefined } = {}) { + const requests = []; + const features = { + "standard:connect": { + connect: () => { + requests.push("connect"); + return Promise.resolve({ accounts: [{ address: SUI_ADDRESS, chains }] }); + }, + }, + }; + if (signFeature) { + features["sui:signTransaction"] = { + signTransaction: async (input) => { + const transaction = await input.transaction.toJSON(); + requests.push({ ...input, transaction }); + return { bytes: signedBytes ?? transaction, signature: "c2lnbmF0dXJl" }; + }, + }; + } + unregisterWallets.push( + getWallets().register({ accounts: [], chains: ["sui:mainnet"], features, name: "OKX Wallet" }), + ); + return requests; + } + + afterEach(() => { + for (const unregister of unregisterWallets.splice(0)) unregister(); + }); + + test("asks the wallet standard account to sign the exact transaction bytes", async () => { + const requests = registerSuiWallet(); + + const wallet = await getWalletMethods(Chain.Sui); + const signed = await wallet.signTransaction(Uint8Array.from([1, 2, 3])); + + expect(wallet.address).toBe(SUI_ADDRESS); + expect(requests).toEqual([ + "connect", + { account: { address: SUI_ADDRESS, chains: ["sui:mainnet"] }, chain: "sui:mainnet", transaction: "AQID" }, + ]); + expect(signed).toEqual({ bytes: "AQID", signature: "c2lnbmF0dXJl" }); + }); + + test("rejects a signature over bytes the wallet changed", async () => { + registerSuiWallet({ signedBytes: "c2lnbmVk" }); + + const wallet = await getWalletMethods(Chain.Sui); + + await expect(wallet.signTransaction(Uint8Array.from([1, 2, 3]))).rejects.toThrow("toolbox_sui_signing_error"); + }); + + test("rejects a wallet that only returns non-mainnet accounts", async () => { + registerSuiWallet({ chains: ["sui:testnet"] }); + + await expect(getWalletMethods(Chain.Sui)).rejects.toThrow("wallet_okx_chain_not_supported"); + }); + + test("skips Sui without a signing feature and keeps the other chains", async () => { + const requests = registerSuiWallet({ signFeature: false }); + globalThis.window = { okxwallet: { cardano: mockCardano() } }; + const added = []; + const connectOkx = okxWallet.connectOkx.connectWallet({ addChain: (wallet) => added.push(wallet) }); + + expect(await connectOkx([Chain.Cardano, Chain.Sui])).toBe(true); + expect(added.map(({ chain }) => chain)).toEqual([Chain.Cardano]); + expect(requests).toEqual([]); + }); +}); + describe("OKX TON", () => { function mockTonBridge(requests: unknown[]) { globalThis.window = { diff --git a/packages/wallet-extensions/src/okx/helpers.ts b/packages/wallet-extensions/src/okx/helpers.ts index be41a5aa..aa30c9b0 100644 --- a/packages/wallet-extensions/src/okx/helpers.ts +++ b/packages/wallet-extensions/src/okx/helpers.ts @@ -12,6 +12,7 @@ import type { TronSignedTransaction, TronSigner, TronTransaction } from "@swapki import { Transaction } from "@swapkit/utxo-signer"; import type { Eip1193Provider } from "ethers"; import { getUtxoScriptTypeParams } from "../helpers/utxoScriptType"; +import { getOkxSuiWallet } from "./sui"; import { getOkxTonWallet } from "./ton"; type WalletMethodsWithAddress = Record & { address: string }; @@ -195,6 +196,7 @@ export async function getWalletMethods(chain: Chain): Promise getOkxSuiWallet()) .with(Chain.Ton, () => getOkxTonWallet()) .with(Chain.Cosmos, async () => { if (!(window.okxwallet && "keplr" in window.okxwallet)) { diff --git a/packages/wallet-extensions/src/okx/index.ts b/packages/wallet-extensions/src/okx/index.ts index e542c0db..12ce8f8c 100644 --- a/packages/wallet-extensions/src/okx/index.ts +++ b/packages/wallet-extensions/src/okx/index.ts @@ -51,6 +51,7 @@ export const okxWallet: ExtensionWallet<"connectOkx"> = createWallet({ [Chain.Robinhood]: { direct: true, typedData: true }, [Chain.Solana]: { direct: true }, [Chain.Starknet]: { direct: true }, + [Chain.Sui]: { direct: true }, [Chain.Ton]: { direct: true }, [Chain.Tron]: { direct: true }, [Chain.XLayer]: { direct: true, typedData: true }, @@ -78,6 +79,7 @@ export const okxWallet: ExtensionWallet<"connectOkx"> = createWallet({ Chain.Robinhood, Chain.Solana, Chain.Starknet, + Chain.Sui, Chain.XLayer, Chain.Ton, Chain.Tron, diff --git a/packages/wallet-extensions/src/okx/sui.ts b/packages/wallet-extensions/src/okx/sui.ts new file mode 100644 index 00000000..6c778a34 --- /dev/null +++ b/packages/wallet-extensions/src/okx/sui.ts @@ -0,0 +1,53 @@ +import { base64 } from "@scure/base"; +import { Chain, SwapKitError } from "@swapkit/helpers"; +import type { SuiWallet } from "@swapkit/toolboxes/sui"; + +const SUI_MAINNET = "sui:mainnet"; + +type SuiAccount = { address: string; chains: readonly string[] }; +type SuiSignInput = { account: SuiAccount; chain: string; transaction: unknown }; +type OkxSuiFeatures = { + "standard:connect"?: { connect: () => Promise<{ accounts: readonly SuiAccount[] }> }; + "sui:signTransaction"?: { signTransaction: (input: SuiSignInput) => Promise<{ bytes: string; signature: string }> }; +}; + +export async function getOkxSuiWallet(): Promise { + const { getWallets } = await import("@wallet-standard/app"); + const wallet = getWallets() + .get() + .find(({ chains, name }) => name === "OKX Wallet" && chains.includes(SUI_MAINNET)); + const features = wallet?.features as OkxSuiFeatures | undefined; + const connect = features?.["standard:connect"]; + const signFeature = features?.["sui:signTransaction"]; + + if (!(connect && signFeature)) { + throw new SwapKitError("wallet_okx_not_found", { chain: Chain.Sui }); + } + + const { accounts } = await connect.connect(); + const account = accounts.find(({ chains }) => chains.includes(SUI_MAINNET)); + + if (!account) { + throw new SwapKitError("wallet_okx_chain_not_supported", { chain: Chain.Sui, message: "Mainnet only" }); + } + + const signTransaction = async (txBytes: Uint8Array) => { + const serialized = base64.encode(txBytes); + const transaction = { serialize: () => serialized, toJSON: () => Promise.resolve(serialized) }; + const signed = await signFeature.signTransaction({ account, chain: SUI_MAINNET, transaction }); + + if (signed.bytes !== serialized) { + throw new SwapKitError("toolbox_sui_signing_error", { + chain: Chain.Sui, + message: "Wallet modified the transaction", + }); + } + + return signed; + }; + + const { getSuiToolbox } = await import("@swapkit/toolboxes/sui"); + const signer = { getAddress: () => account.address, signTransaction, toSuiAddress: () => account.address }; + + return { ...getSuiToolbox({ signer }), address: account.address }; +}