From 5e829cc0323b9b13a5bbb06679386f35225ff116 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 May 2026 15:12:57 +0400 Subject: [PATCH 1/4] Enable TC direct signing for extension wallets --- .changeset/tall-lizards-provide.md | 7 + packages/sdk/package.json | 15 +- packages/wallet-extensions/package.json | 9 +- .../src/helpers/tclikeTransferIntent.ts | 128 +++++++++++ .../src/keepkey-bex/index.ts | 31 ++- .../src/keepkey-bex/walletHelpers.ts | 6 +- .../wallet-extensions/src/vultisig/index.ts | 36 +++- .../src/vultisig/walletHelpers.ts | 5 +- .../test/tclike-transfer-intent.test.ts | 200 ++++++++++++++++++ packages/wallets/package.json | 15 +- playgrounds/vite-lite/package.json | 6 +- 11 files changed, 414 insertions(+), 44 deletions(-) create mode 100644 .changeset/tall-lizards-provide.md create mode 100644 packages/wallet-extensions/src/helpers/tclikeTransferIntent.ts create mode 100644 packages/wallet-extensions/test/tclike-transfer-intent.test.ts diff --git a/.changeset/tall-lizards-provide.md b/.changeset/tall-lizards-provide.md new file mode 100644 index 0000000..ac1830e --- /dev/null +++ b/.changeset/tall-lizards-provide.md @@ -0,0 +1,7 @@ +--- +"@swapkit/wallet-extensions": patch +"@swapkit/wallets": patch +"@swapkit/sdk": patch +--- + +Enable KeepKey BEX and Vultisig THORChain/Maya direct swap submission by translating provided toolbox transactions into provider sign-and-broadcast requests. diff --git a/packages/sdk/package.json b/packages/sdk/package.json index cac44b3..03f5b72 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -45,21 +45,12 @@ "types": "./dist/types/wallets.d.ts" } }, - "files": [ - "dist/", - "src/" - ], + "files": ["dist/", "src/"], "homepage": "https://github.com/swapkit/wallets", "license": "SEE LICENSE IN LICENSE", "name": "@swapkit/sdk", - "publishConfig": { - "access": "public" - }, - "repository": { - "directory": "packages/sdk", - "type": "git", - "url": "git+https://github.com/swapkit/wallets.git" - }, + "publishConfig": { "access": "public" }, + "repository": { "directory": "packages/sdk", "type": "git", "url": "git+https://github.com/swapkit/wallets.git" }, "scripts": { "build": "bun run ./build.ts", "build:clean": "rm -rf dist && bun run ./build.ts", diff --git a/packages/wallet-extensions/package.json b/packages/wallet-extensions/package.json index 68847c1..41958de 100644 --- a/packages/wallet-extensions/package.json +++ b/packages/wallet-extensions/package.json @@ -135,16 +135,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/helpers/tclikeTransferIntent.ts b/packages/wallet-extensions/src/helpers/tclikeTransferIntent.ts new file mode 100644 index 0000000..50dc51f --- /dev/null +++ b/packages/wallet-extensions/src/helpers/tclikeTransferIntent.ts @@ -0,0 +1,128 @@ +import { type Chain, CosmosChainPrefixes, getChainConfig, SwapKitError } from "@swapkit/helpers"; +import { base64ToBech32 } from "@swapkit/toolboxes/cosmos"; + +export type TCLikeChain = typeof Chain.THORChain | typeof Chain.Maya; + +type TCLikeTransferMessage = { + typeUrl?: string; + type?: string; + value: { + amount: { amount: string; denom: string }[]; + fromAddress?: string; + from_address?: string; + toAddress?: string; + to_address?: string; + }; +}; + +type TCLikeDepositMessage = { + typeUrl?: string; + type?: string; + value: { + coins: { amount: string; asset: string | { chain?: string; symbol?: string; ticker?: string } }[]; + memo?: string; + signer: string; + }; +}; + +export type TCLikeToolboxTransaction = { + fee?: { gas?: string }; + memo?: string; + msgs: Array; +}; + +export type TCLikeTransferIntent = { + amount: { amount: number; decimals: number }; + asset: { chain: string; symbol: string; ticker: string }; + from: string; + gasLimit?: string; + memo: string; + method: "deposit" | "transfer"; + recipient: string; +}; + +function getTCLikeTransactionMethod(tx: TCLikeToolboxTransaction): TCLikeTransferIntent["method"] { + const [msg] = tx.msgs; + if (!msg) throw new SwapKitError("plugin_swapkit_invalid_transaction"); + + const messageType = msg.typeUrl || msg.type; + if (messageType?.includes("MsgDeposit") || "coins" in msg.value) return "deposit"; + if (messageType?.includes("MsgSend") || "amount" in msg.value) return "transfer"; + + throw new SwapKitError("plugin_swapkit_invalid_transaction", { messageType }); +} + +function normalizeTCLikeAddress(address: string, chain: TCLikeChain) { + const prefix = CosmosChainPrefixes[chain]; + if (address.startsWith(`${prefix}1`)) return address; + + return base64ToBech32(address, prefix); +} + +function getAssetFromDepositCoin(asset: TCLikeDepositMessage["value"]["coins"][number]["asset"], chain: TCLikeChain) { + if (typeof asset === "string") { + const [assetChain = chain, symbol = assetChain] = asset.includes(".") ? asset.split(".") : [chain, asset]; + return { chain: assetChain, symbol: symbol.toUpperCase(), ticker: symbol.split("-")[0]?.toUpperCase() || symbol }; + } + + const symbol = asset.symbol || asset.ticker || chain; + const ticker = asset.ticker || symbol.split("-")[0] || symbol; + + return { chain: asset.chain || chain, symbol: symbol.toUpperCase(), ticker: ticker.toUpperCase() }; +} + +function getAssetFromTransferDenom(denom: string, chain: TCLikeChain) { + const symbol = (denom.includes(".") ? denom.split(".").at(-1) || denom : denom).toUpperCase(); + const ticker = symbol.split("-")[0] || symbol; + + return { chain, symbol, ticker }; +} + +export function extractTCLikeTransferIntent({ + chain, + tx, +}: { + chain: TCLikeChain; + tx: TCLikeToolboxTransaction; +}): TCLikeTransferIntent { + const [msg] = tx.msgs; + if (!msg) throw new SwapKitError("plugin_swapkit_invalid_transaction"); + + const method = getTCLikeTransactionMethod(tx); + if (method === "deposit") { + const { coins, memo = tx.memo || "", signer } = (msg as TCLikeDepositMessage).value; + const [coin] = coins; + if (!coin) throw new SwapKitError("plugin_swapkit_invalid_transaction"); + + const asset = getAssetFromDepositCoin(coin.asset, chain); + + return { + amount: { amount: Number(coin.amount), decimals: getChainConfig(chain).baseDecimal }, + asset, + from: normalizeTCLikeAddress(signer, chain), + gasLimit: tx.fee?.gas, + memo, + method, + recipient: "", + }; + } + + const { amount, fromAddress, from_address, toAddress, to_address } = (msg as TCLikeTransferMessage).value; + const [coin] = amount; + const from = fromAddress || from_address; + const recipient = toAddress || to_address; + + if (!(coin && from && recipient)) throw new SwapKitError("plugin_swapkit_invalid_transaction"); + + const asset = getAssetFromTransferDenom(coin.denom, chain); + + return { + amount: { amount: Number(coin.amount), decimals: getChainConfig(chain).baseDecimal }, + asset, + from: normalizeTCLikeAddress(from, chain), + gasLimit: tx.fee?.gas, + memo: tx.memo || "", + method, + recipient, + }; +} diff --git a/packages/wallet-extensions/src/keepkey-bex/index.ts b/packages/wallet-extensions/src/keepkey-bex/index.ts index 02fec2c..f4b78a1 100644 --- a/packages/wallet-extensions/src/keepkey-bex/index.ts +++ b/packages/wallet-extensions/src/keepkey-bex/index.ts @@ -1,6 +1,7 @@ import { AssetValue, Chain, ChainId, filterSupportedChains, SwapKitError, WalletOption } from "@swapkit/helpers"; import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; import type { Eip1193Provider } from "ethers"; +import { extractTCLikeTransferIntent } from "../helpers/tclikeTransferIntent"; import { extractUtxoTransferIntent, unsupportedUtxoSignTransaction } from "../helpers/utxoTransferIntent"; import type { ExtensionWallet } from "../walletTypes"; import { @@ -8,6 +9,7 @@ import { getKEEPKEYMethods, getKEEPKEYProvider, getProviderNameFromChain, + submitKeepkeyBexTransaction, type WalletTxParams, walletTransfer, } from "./walletHelpers"; @@ -41,10 +43,12 @@ export const keepkeyBexWallet: ExtensionWallet<"connectKeepkeyBex"> = createWall [Chain.Ethereum]: true, [Chain.Kujira]: true, [Chain.Litecoin]: true, + [Chain.Maya]: true, [Chain.Optimism]: true, [Chain.Polygon]: true, + [Chain.THORChain]: true, [Chain.XLayer]: true, - // Ripple/Solana/THORChain/Maya: provider lacks raw-sign RPC + // Ripple/Solana: provider lacks raw-sign RPC }, name: "connectKeepkeyBex", supportedChains: [ @@ -85,6 +89,31 @@ async function getWalletMethods(chain: (typeof KEEPKEY_BEX_SUPPORTED_CHAINS)[num return { ...toolbox, deposit: (tx: WalletTxParams) => walletTransfer({ ...tx, recipient: "" }, "deposit"), + signAndBroadcastTransaction: (tx: Parameters[0]["tx"]) => { + const intent = extractTCLikeTransferIntent({ chain, tx }); + return submitKeepkeyBexTransaction({ + chain, + method: intent.method, + params: [ + { + amount: intent.amount, + asset: intent.asset, + from: intent.from, + gasLimit: intent.gasLimit, + memo: intent.memo, + recipient: intent.recipient, + }, + ], + }); + }, + signTransaction: () => + Promise.reject( + new SwapKitError("wallet_walletconnect_method_not_supported", { + method: "signTransaction", + reason: "KeepKey BEX THORChain/Maya provider only supports signAndBroadcastTransaction", + wallet: WalletOption.KEEPKEY_BEX, + }), + ), transfer: (tx: WalletTxParams) => walletTransfer({ ...tx, gasLimit }, "transfer"), }; } diff --git a/packages/wallet-extensions/src/keepkey-bex/walletHelpers.ts b/packages/wallet-extensions/src/keepkey-bex/walletHelpers.ts index 30462da..88c753b 100644 --- a/packages/wallet-extensions/src/keepkey-bex/walletHelpers.ts +++ b/packages/wallet-extensions/src/keepkey-bex/walletHelpers.ts @@ -34,6 +34,8 @@ type TransactionParams = { asset: string | { chain: string; symbol: string; ticker: string }; amount: number | string | { amount: string | number; decimals?: number }; decimal?: number; + from?: string; + gasLimit?: string | bigint; recipient: string; memo?: string; }; @@ -114,7 +116,7 @@ export function getKEEPKEYProvider(chain: T) { } } -function transaction({ +export function submitKeepkeyBexTransaction({ method, params, chain, @@ -176,7 +178,7 @@ export async function walletTransfer( }, ]; - return transaction({ chain: assetValue.chain, method, params }); + return submitKeepkeyBexTransaction({ chain: assetValue.chain, method, params }); } export function getKEEPKEYMethods(provider: BrowserProvider, chain: EVMChain) { diff --git a/packages/wallet-extensions/src/vultisig/index.ts b/packages/wallet-extensions/src/vultisig/index.ts index 0c82330..3775407 100644 --- a/packages/wallet-extensions/src/vultisig/index.ts +++ b/packages/wallet-extensions/src/vultisig/index.ts @@ -12,6 +12,7 @@ import { WalletOption, } from "@swapkit/helpers"; import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; +import { extractTCLikeTransferIntent } from "../helpers/tclikeTransferIntent"; import { extractUtxoTransferIntent, unsupportedUtxoSignTransaction } from "../helpers/utxoTransferIntent"; import type { ExtensionWallet } from "../walletTypes"; import { @@ -19,6 +20,7 @@ import { getVultisigMethods, getVultisigProvider, prepareNetworkSwitchCosmos, + submitVultisigTransaction, walletTransfer, } from "./walletHelpers"; @@ -66,10 +68,12 @@ export const vultisigWallet: ExtensionWallet<"connectVultisig"> = createWallet({ [Chain.Dogecoin]: true, [Chain.Ethereum]: true, [Chain.Litecoin]: true, + [Chain.Maya]: true, [Chain.Optimism]: true, [Chain.Polygon]: true, + [Chain.THORChain]: true, [Chain.XLayer]: true, - // BTC/ZEC/Cosmos/Kujira/THORChain/Maya/Solana/Ripple: blocked on Vultisig provider — no raw-sign RPC + // BTC/ZEC/Cosmos/Kujira/Solana/Ripple: blocked on Vultisig provider — no raw-sign RPC }, name: "connectVultisig", supportedChains: [ @@ -113,11 +117,37 @@ async function getWalletMethods(chain: (typeof VULTISIG_SUPPORTED_CHAINS)[number .with(Chain.Maya, Chain.THORChain, async () => { const { getCosmosToolbox, THORCHAIN_GAS_VALUE, MAYA_GAS_VALUE } = await import("@swapkit/toolboxes/cosmos"); - const gasLimit = chain === Chain.Maya ? MAYA_GAS_VALUE : THORCHAIN_GAS_VALUE; - const toolbox = await getCosmosToolbox(chain as Exclude); + const tclikeChain = chain as TCLikeChain; + const gasLimit = tclikeChain === Chain.Maya ? MAYA_GAS_VALUE : THORCHAIN_GAS_VALUE; + const toolbox = await getCosmosToolbox(tclikeChain as Exclude); return { ...toolbox, deposit: (tx: GenericTransferParams) => walletTransfer({ ...tx, recipient: "" }, "deposit_transaction"), + signAndBroadcastTransaction: (tx: Parameters[0]["tx"]) => { + const intent = extractTCLikeTransferIntent({ chain: tclikeChain, tx }); + return submitVultisigTransaction({ + chain: tclikeChain, + method: intent.method === "deposit" ? "deposit_transaction" : "send_transaction", + params: [ + { + amount: intent.amount, + asset: intent.asset, + data: intent.memo, + from: intent.from, + gasLimit: intent.gasLimit, + to: intent.recipient, + }, + ], + }); + }, + signTransaction: () => + Promise.reject( + new SwapKitError("wallet_walletconnect_method_not_supported", { + method: "signTransaction", + reason: "Vultisig THORChain/Maya provider only supports signAndBroadcastTransaction", + wallet: WalletOption.VULTISIG, + }), + ), transfer: (tx: GenericTransferParams) => walletTransfer({ ...tx, gasLimit }, "send_transaction"), }; }) diff --git a/packages/wallet-extensions/src/vultisig/walletHelpers.ts b/packages/wallet-extensions/src/vultisig/walletHelpers.ts index a1ee0df..d3e3542 100644 --- a/packages/wallet-extensions/src/vultisig/walletHelpers.ts +++ b/packages/wallet-extensions/src/vultisig/walletHelpers.ts @@ -29,6 +29,7 @@ type TransactionParams = { to: string; data?: string; from?: string; + gasLimit?: string | bigint; }; export type WalletTxParams = { @@ -71,7 +72,7 @@ export async function getVultisigProvider(chain: T): Promise undefined) as VultisigProviderType; } -async function transaction({ +export async function submitVultisigTransaction({ method, params, chain, @@ -175,7 +176,7 @@ export async function walletTransfer( }, ]; - return transaction({ chain: assetValue.chain, method, params }); + return submitVultisigTransaction({ chain: assetValue.chain, method, params }); } export function getVultisigMethods(provider: BrowserProvider, chain: EVMChain) { diff --git a/packages/wallet-extensions/test/tclike-transfer-intent.test.ts b/packages/wallet-extensions/test/tclike-transfer-intent.test.ts new file mode 100644 index 0000000..dbbb95d --- /dev/null +++ b/packages/wallet-extensions/test/tclike-transfer-intent.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { Chain } from "@swapkit/helpers"; +import { extractTCLikeTransferIntent } from "../src/helpers/tclikeTransferIntent"; +import { keepkeyBexWallet } from "../src/keepkey-bex"; +import { vultisigWallet } from "../src/vultisig"; + +const thorDepositTx = { + fee: { gas: "500000000" }, + memo: "=:ETH.ETH:0xabc", + msgs: [ + { + typeUrl: "/types.MsgDeposit", + value: { + coins: [{ amount: "123456789", asset: { chain: "THOR", symbol: "RUNE", synth: false, ticker: "RUNE" } }], + memo: "=:ETH.ETH:0xabc", + signer: "thor1sender", + }, + }, + ], +}; + +describe("extractTCLikeTransferIntent", () => { + afterEach(() => { + // @ts-expect-error test cleanup + delete globalThis.window; + }); + + test("converts THORChain deposit transactions into provider params", () => { + const intent = extractTCLikeTransferIntent({ chain: Chain.THORChain, tx: thorDepositTx }); + + expect(intent).toMatchObject({ + amount: { amount: 123456789, decimals: 8 }, + asset: { chain: "THOR", symbol: "RUNE", ticker: "RUNE" }, + from: "thor1sender", + gasLimit: "500000000", + memo: "=:ETH.ETH:0xabc", + method: "deposit", + recipient: "", + }); + }); + + test("converts base64 Maya deposit signer bytes to bech32", () => { + const intent = extractTCLikeTransferIntent({ + chain: Chain.Maya, + tx: { + fee: { gas: "500000000" }, + memo: "=:BTC.BTC:bc1qrecipient", + msgs: [ + { + typeUrl: "/types.MsgDeposit", + value: { + coins: [{ amount: "1800000000", asset: { chain: "MAYA", symbol: "CACAO", ticker: "CACAO" } }], + memo: "=:BTC.BTC:bc1qrecipient", + signer: "nlf3C5LoXHB9Z3muFI3zB1i6lq8=", + }, + }, + ], + }, + }); + + expect(intent.from).toBe("maya1netlwzujapw8qlt80xhpfr0nqavt494074s0ze"); + expect(intent.amount).toEqual({ amount: 1800000000, decimals: 8 }); + expect(intent.asset).toEqual({ chain: "MAYA", symbol: "CACAO", ticker: "CACAO" }); + }); + + test("converts THORChain transfer transactions into provider params", () => { + const intent = extractTCLikeTransferIntent({ + chain: Chain.THORChain, + tx: { + fee: { gas: "500000000" }, + memo: "memo", + msgs: [ + { + typeUrl: "/types.MsgSend", + value: { + amount: [{ amount: "200000000", denom: "rune" }], + fromAddress: "thor1sender", + toAddress: "thor1recipient", + }, + }, + ], + }, + }); + + expect(intent).toMatchObject({ + amount: { amount: 200000000, decimals: 8 }, + asset: { chain: "THOR", symbol: "RUNE", ticker: "RUNE" }, + from: "thor1sender", + gasLimit: "500000000", + memo: "memo", + method: "transfer", + recipient: "thor1recipient", + }); + }); + + test("submits KeepKey BEX THORChain deposits through signAndBroadcastTransaction", async () => { + const requests: unknown[] = []; + + // @ts-expect-error test window shim + globalThis.window = { + keepkey: { + thorchain: { + request: (request: unknown, cb?: (err: unknown, result: unknown) => void) => { + requests.push(request); + if (cb) { + cb(null, "0xhash"); + return; + } + + return Promise.resolve(["thor1sender"]); + }, + }, + }, + }; + + const addChain = mock(() => {}); + const connectKeepkeyBex = keepkeyBexWallet.connectKeepkeyBex.connectWallet({ addChain }); + + await connectKeepkeyBex([Chain.THORChain]); + + const walletMethods = addChain.mock.calls[0]?.[0] as + | { signAndBroadcastTransaction?: (tx: typeof thorDepositTx) => Promise } + | undefined; + + await expect(walletMethods?.signAndBroadcastTransaction?.(thorDepositTx)).resolves.toBe("0xhash"); + + expect(requests).toEqual([ + { method: "request_accounts", params: [] }, + { + method: "deposit", + params: [ + { + amount: { amount: 123456789, decimals: 8 }, + asset: { chain: "THOR", symbol: "RUNE", ticker: "RUNE" }, + from: "thor1sender", + gasLimit: "500000000", + memo: "=:ETH.ETH:0xabc", + recipient: "", + }, + ], + }, + ]); + }); + + test("submits Vultisig THORChain deposits through signAndBroadcastTransaction", async () => { + const requests: unknown[] = []; + + // @ts-expect-error test window shim + globalThis.window = { + vultisig: { + thorchain: { + request: (request: unknown, cb?: (err: unknown, result: unknown) => void) => { + requests.push(request); + if (cb) { + cb(null, "0xhash"); + return; + } + + return Promise.resolve(["thor1sender"]); + }, + }, + }, + }; + + const addChain = mock(() => {}); + const connectVultisig = vultisigWallet.connectVultisig.connectWallet({ addChain }); + + await connectVultisig([Chain.THORChain]); + + const walletMethods = addChain.mock.calls[0]?.[0] as + | { signAndBroadcastTransaction?: (tx: typeof thorDepositTx) => Promise } + | undefined; + + await expect(walletMethods?.signAndBroadcastTransaction?.(thorDepositTx)).resolves.toBe("0xhash"); + + expect(requests).toEqual([ + { method: "request_accounts", params: [] }, + { + method: "deposit_transaction", + params: [ + { + amount: { amount: 123456789, decimals: 8 }, + asset: { chain: "THOR", symbol: "RUNE", ticker: "RUNE" }, + data: "=:ETH.ETH:0xabc", + from: "thor1sender", + gasLimit: "500000000", + to: "", + }, + ], + }, + ]); + }); + + test("marks KeepKey BEX and Vultisig THORChain and Maya direct signing as available", () => { + expect(keepkeyBexWallet.connectKeepkeyBex.directSigningSupport[Chain.THORChain]).toBe(true); + expect(keepkeyBexWallet.connectKeepkeyBex.directSigningSupport[Chain.Maya]).toBe(true); + expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.THORChain]).toBe(true); + expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.Maya]).toBe(true); + }); +}); diff --git a/packages/wallets/package.json b/packages/wallets/package.json index d12d9f4..620e77f 100644 --- a/packages/wallets/package.json +++ b/packages/wallets/package.json @@ -205,21 +205,12 @@ "types": "./dist/types/xaman/index.d.ts" } }, - "files": [ - "dist/", - "src/" - ], + "files": ["dist/", "src/"], "homepage": "https://github.com/swapkit/wallets", "license": "SEE LICENSE IN LICENSE", "name": "@swapkit/wallets", - "publishConfig": { - "access": "public" - }, - "repository": { - "directory": "packages/wallets", - "type": "git", - "url": "git+https://github.com/swapkit/wallets.git" - }, + "publishConfig": { "access": "public" }, + "repository": { "directory": "packages/wallets", "type": "git", "url": "git+https://github.com/swapkit/wallets.git" }, "scripts": { "build": "bun run ./build.ts", "build:clean": "rm -rf dist && bun run ./build.ts", diff --git a/playgrounds/vite-lite/package.json b/playgrounds/vite-lite/package.json index a70452e..c0235c9 100644 --- a/playgrounds/vite-lite/package.json +++ b/playgrounds/vite-lite/package.json @@ -21,11 +21,7 @@ }, "name": "@internal/playground-vite-lite", "private": true, - "scripts": { - "build": "vite build", - "dev": "vite", - "preview": "vite preview" - }, + "scripts": { "build": "vite build", "dev": "vite", "preview": "vite preview" }, "type": "module", "version": "0.0.9" } From d7d436e5ff885cbe7742628730f6582a5bab5796 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 May 2026 15:26:29 +0400 Subject: [PATCH 2/4] Enable KeepKey SDK UTXO direct signing --- .changeset/tall-lizards-provide.md | 3 +- .../src/keepkey/chains/utxo.ts | 269 ++++++++++++++++-- packages/wallet-hardware/src/keepkey/index.ts | 7 +- .../test/keepkey-utxo-direct-signing.test.ts | 64 +++++ 4 files changed, 312 insertions(+), 31 deletions(-) create mode 100644 packages/wallet-hardware/test/keepkey-utxo-direct-signing.test.ts diff --git a/.changeset/tall-lizards-provide.md b/.changeset/tall-lizards-provide.md index ac1830e..2c6cb88 100644 --- a/.changeset/tall-lizards-provide.md +++ b/.changeset/tall-lizards-provide.md @@ -1,7 +1,8 @@ --- "@swapkit/wallet-extensions": patch +"@swapkit/wallet-hardware": patch "@swapkit/wallets": patch "@swapkit/sdk": patch --- -Enable KeepKey BEX and Vultisig THORChain/Maya direct swap submission by translating provided toolbox transactions into provider sign-and-broadcast requests. +Enable KeepKey BEX and Vultisig THORChain/Maya direct swap submission by translating provided toolbox transactions into provider sign-and-broadcast requests. Add KeepKey SDK UTXO direct swap submission by signing the provided PSBT transaction and broadcasting the serialized transaction returned by KeepKey. diff --git a/packages/wallet-hardware/src/keepkey/chains/utxo.ts b/packages/wallet-hardware/src/keepkey/chains/utxo.ts index 7dd1c6b..dc040d2 100644 --- a/packages/wallet-hardware/src/keepkey/chains/utxo.ts +++ b/packages/wallet-hardware/src/keepkey/chains/utxo.ts @@ -1,4 +1,5 @@ import type { KeepKeySdk } from "@keepkey/keepkey-sdk"; +import { hex } from "@scure/base"; import { Chain, DerivationPath, @@ -23,7 +24,7 @@ import { import type { Transaction } from "@swapkit/utxo-signer"; import { bip32ToAddressNList, ChainToKeepKeyName } from "../coins"; -interface KeepKeyInputObject { +export interface KeepKeyInputObject { addressNList: number[]; scriptType: string; amount: string; @@ -34,6 +35,213 @@ interface KeepKeyInputObject { type KeepKeyUTXOWalletMethods = Record & { address: string }; +type Bip32Derivation = [Uint8Array, { fingerprint: number; path: number[] }]; + +function isBip32Derivation(value: unknown): value is Bip32Derivation { + return ( + Array.isArray(value) && + value[0] instanceof Uint8Array && + typeof value[1] === "object" && + value[1] !== null && + Array.isArray((value[1] as { path?: unknown }).path) + ); +} + +function getFirstBip32Derivation(input: { bip32Derivation?: unknown }) { + if (!Array.isArray(input.bip32Derivation)) return undefined; + + const [derivation] = input.bip32Derivation; + return isBip32Derivation(derivation) ? derivation : undefined; +} + +function decodeOpReturnMemo(script: Uint8Array) { + if (script.length < 2 || script[0] !== 0x6a) return undefined; + + let offset = 1; + const pushOpcode = script[offset]; + if (pushOpcode === undefined) return undefined; + + let dataLength = pushOpcode; + offset += 1; + + if (pushOpcode === 0x4c) { + const length = script[offset]; + if (length === undefined) return undefined; + dataLength = length; + offset += 1; + } else if (pushOpcode === 0x4d) { + const first = script[offset]; + const second = script[offset + 1]; + if (first === undefined || second === undefined) return undefined; + dataLength = first | (second << 8); + offset += 2; + } else if (pushOpcode === 0x4e) { + const first = script[offset]; + const second = script[offset + 1]; + const third = script[offset + 2]; + const fourth = script[offset + 3]; + if (first === undefined || second === undefined || third === undefined || fourth === undefined) return undefined; + dataLength = first | (second << 8) | (third << 16) | (fourth << 24); + offset += 4; + } else if (pushOpcode > 0x4e) { + return undefined; + } + + if (dataLength < 0 || script.length < offset + dataLength) return undefined; + + return Buffer.from(script.slice(offset, offset + dataLength)).toString("utf8"); +} + +function getPrevoutAmount(input: { + index?: number; + nonWitnessUtxo?: { outputs?: Array<{ amount?: bigint | number }> }; + witnessUtxo?: { amount?: bigint | number }; +}) { + if (input.witnessUtxo?.amount !== undefined) return input.witnessUtxo.amount.toString(); + + const prevout = input.index !== undefined ? input.nonWitnessUtxo?.outputs?.[input.index] : undefined; + if (prevout?.amount !== undefined) return prevout.amount.toString(); + + return undefined; +} + +export function extractMemoFromKeepKeyUtxoTransaction(tx: Transaction, network: ReturnType) { + const memos: string[] = []; + + for (let index = 0; index < tx.outputsLength; index++) { + const output = tx.getOutput(index); + const outputAddress = tx.getOutputAddress(index, network); + if (outputAddress) continue; + + const memo = output.script ? decodeOpReturnMemo(output.script) : undefined; + if (memo !== undefined && (output.amount ?? 0n) === 0n) { + memos.push(memo); + continue; + } + + throw new SwapKitError("wallet_keepkey_invalid_params", { + outputIndex: index, + reason: "Unable to decode UTXO output address", + }); + } + + if (memos.length > 1) { + throw new SwapKitError("wallet_keepkey_invalid_params", { reason: "Multiple OP_RETURN outputs are not supported" }); + } + + return memos[0] || ""; +} + +export async function extractKeepKeyInputsFromTransaction({ + chain, + fallbackAddressNList, + scriptType, + tx, +}: { + chain: Exclude; + fallbackAddressNList: number[]; + scriptType: string; + tx: Transaction; +}): Promise { + const { RawTx } = await import("@swapkit/utxo-signer"); + const inputs: KeepKeyInputObject[] = []; + + for (let inputIndex = 0; inputIndex < tx.inputsLength; inputIndex++) { + const input = tx.getInput(inputIndex); + + if (!input.txid || input.index === undefined) { + throw new SwapKitError("wallet_keepkey_invalid_params", { + inputIndex, + reason: "PSBT input is missing txid/index", + }); + } + + const txid = hex.encode(input.txid); + const txHex = input.nonWitnessUtxo + ? hex.encode(RawTx.encode(input.nonWitnessUtxo)) + : await getUtxoApi(chain).getRawTx(txid); + const amount = getPrevoutAmount(input); + + if (!(txHex && amount)) { + throw new SwapKitError("wallet_keepkey_invalid_params", { + chain, + inputIndex, + reason: "Unable to resolve previous output info for KeepKey signing", + txid, + }); + } + + const derivation = getFirstBip32Derivation(input); + + inputs.push({ + addressNList: derivation?.[1].path || fallbackAddressNList, + amount, + hex: txHex, + scriptType, + txid, + vout: input.index, + }); + } + + return inputs; +} + +function buildKeepKeyOutputsFromTransaction({ + chain, + fallbackAddressNList, + network, + scriptType, + tx, + walletAddress, +}: { + chain: Exclude; + fallbackAddressNList: number[]; + network: ReturnType; + scriptType: string; + tx: Transaction; + walletAddress: string; +}) { + const outputs: any[] = []; + + for (let i = 0; i < tx.outputsLength; i++) { + const output = tx.getOutput(i); + const address = tx.getOutputAddress(i, network); + const value = Number(output.amount); + + if (!address) { + const outputMemo = output.script ? decodeOpReturnMemo(output.script) : undefined; + if (outputMemo !== undefined && (output.amount ?? 0n) === 0n) continue; + + throw new SwapKitError("wallet_keepkey_invalid_params", { + outputIndex: i, + reason: "Unable to decode UTXO output address", + }); + } + + const outputDerivation = getFirstBip32Derivation(output); + const changeAddressNList = + outputDerivation?.[1].path || (address === walletAddress ? fallbackAddressNList : undefined); + + if (changeAddressNList) { + outputs.push({ + addressNList: changeAddressNList, + addressType: "change", + amount: value, + isChange: true, + scriptType, + }); + continue; + } + + const outputAddress = chain === Chain.BitcoinCash ? stripToCashAddress(address) : address; + if (outputAddress) { + outputs.push({ address: outputAddress, addressType: "spend", amount: value }); + } + } + + return outputs.filter((item) => item !== null && typeof item === "object" && Object.keys(item).length > 0); +} + export async function utxoWalletMethods({ sdk, chain, @@ -62,44 +270,46 @@ export async function utxoWalletMethods({ const network = getNetworkForChain(chain); const signTransaction = async (tx: Transaction, inputs: KeepKeyInputObject[], memo = "") => { - const outputs: any[] = []; - - for (let i = 0; i < tx.outputsLength; i++) { - const output = tx.getOutput(i); - const address = tx.getOutputAddress(i, network); - const value = Number(output.amount); - - if (address === walletAddress) { - outputs.push({ - addressNList: addressInfo.address_n, - addressType: "change", - amount: value, - isChange: true, - scriptType, - }); - } else if (address) { - const outputAddress = chain === Chain.BitcoinCash ? stripToCashAddress(address) : address; - - if (outputAddress) { - outputs.push({ address: outputAddress, addressType: "spend", amount: value }); - } - } - } - - const removeNullAndEmptyObjectsFromArray = (arr: any[]) => { - return arr.filter((item) => item !== null && typeof item === "object" && Object.keys(item).length > 0); - }; + const outputs = buildKeepKeyOutputsFromTransaction({ + chain, + fallbackAddressNList: addressInfo.address_n, + network, + scriptType, + tx, + walletAddress, + }); const responseSign = await sdk.utxo.utxoSignTransaction({ coin: ChainToKeepKeyName[chain], inputs, opReturnData: memo, - outputs: removeNullAndEmptyObjectsFromArray(outputs), + outputs, }); return responseSign.serializedTx?.toString(); }; + const signAndBroadcastTransaction = async (tx: Transaction) => { + const inputs = await extractKeepKeyInputsFromTransaction({ + chain, + fallbackAddressNList: addressInfo.address_n, + scriptType, + tx, + }); + const memo = extractMemoFromKeepKeyUtxoTransaction(tx, network); + const txHex = await signTransaction(tx, inputs, memo); + + if (!txHex) { + // TODO: Replace wallet-specific signing failures with generic wallet error keys. + throw new SwapKitError("wallet_keepkey_invalid_params", { + chain, + reason: "KeepKey SDK did not return a serialized transaction", + }); + } + + return toolbox.broadcastTx(txHex); + }; + const transfer = async ({ recipient, feeOptionKey, feeRate, memo, ...rest }: GenericTransferParams) => { if (!walletAddress) throw new SwapKitError("wallet_keepkey_invalid_params", { reason: "From address must be provided" }); @@ -356,6 +566,7 @@ export async function utxoWalletMethods({ deriveAddresses, getExtendedPublicKey, getExtendedPublicKeyInfo, + signAndBroadcastTransaction, signTransaction, signTransactionWithMultipleInputs, transfer, diff --git a/packages/wallet-hardware/src/keepkey/index.ts b/packages/wallet-hardware/src/keepkey/index.ts index a3ceaa0..e73eea3 100644 --- a/packages/wallet-hardware/src/keepkey/index.ts +++ b/packages/wallet-hardware/src/keepkey/index.ts @@ -58,14 +58,19 @@ export const keepkeyWallet = createWallet({ [Chain.Base]: true, [Chain.Berachain]: true, [Chain.BinanceSmartChain]: true, + [Chain.Bitcoin]: true, + [Chain.BitcoinCash]: true, + [Chain.Dash]: true, + [Chain.Dogecoin]: true, [Chain.Ethereum]: true, [Chain.Gnosis]: true, + [Chain.Litecoin]: true, [Chain.Monad]: true, [Chain.Optimism]: true, [Chain.Polygon]: true, [Chain.Ripple]: true, [Chain.XLayer]: true, - // BTC/BCH/DASH/DOGE/LTC/Cosmos/THORChain/Maya: pending KeepKey SDK signer wrappers (V3 plan PRs) + // Cosmos/THORChain/Maya: pending KeepKey SDK signer wrappers (V3 plan PRs) }, name: "connectKeepkey", supportedChains: [ diff --git a/packages/wallet-hardware/test/keepkey-utxo-direct-signing.test.ts b/packages/wallet-hardware/test/keepkey-utxo-direct-signing.test.ts new file mode 100644 index 0000000..cc87552 --- /dev/null +++ b/packages/wallet-hardware/test/keepkey-utxo-direct-signing.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "bun:test"; +import { hex } from "@scure/base"; +import { Chain } from "@swapkit/helpers"; +import { compileMemo, getNetworkForChain } from "@swapkit/toolboxes/utxo"; +import { RawTx, Transaction } from "@swapkit/utxo-signer"; + +import { keepkeyWallet } from "../src/keepkey"; +import { extractKeepKeyInputsFromTransaction, extractMemoFromKeepKeyUtxoTransaction } from "../src/keepkey/chains/utxo"; + +describe("KeepKey UTXO direct signing helpers", () => { + it("uses embedded previous tx hex when extracting KeepKey inputs", async () => { + const previousTxHex = hex.encode( + RawTx.encode({ + inputs: [ + { finalScriptSig: new Uint8Array(), index: 0, sequence: 0xffffffff, txid: hex.decode("22".repeat(32)) }, + ], + lockTime: 0, + outputs: [ + { amount: 50_000n, script: new Uint8Array([0x6a]) }, + { amount: 12_345n, script: new Uint8Array([0x76, 0xa9, 0x14, ...Array(20).fill(1), 0x88, 0xac]) }, + ], + segwitFlag: undefined, + version: 2, + witnesses: undefined, + }), + ); + + const txid = "11".repeat(32); + const tx = new Transaction({ allowUnknownOutputs: true }); + const addressNList = [2147483692, 2147483648, 2147483648, 0, 7]; + const publicKey = hex.decode("03308a3a000c2dcb65abbb89d54fb85901dc6ff2dc1d7ee724f958ed8e1a7a5a9d"); + tx.addInput({ + bip32Derivation: [[publicKey, { fingerprint: 0, path: addressNList }]], + index: 1, + nonWitnessUtxo: previousTxHex, + txid: hex.decode(txid), + }); + + const [input] = await extractKeepKeyInputsFromTransaction({ + chain: Chain.BitcoinCash, + fallbackAddressNList: [2147483692, 2147483648, 2147483648, 0, 0], + scriptType: "p2pkh", + tx, + }); + + expect(input).toEqual({ addressNList, amount: "12345", hex: previousTxHex, scriptType: "p2pkh", txid, vout: 1 }); + }); + + it("extracts OP_RETURN memo data for the KeepKey opReturnData field", () => { + const memo = "=:ETH.ETH:0xabc"; + const tx = new Transaction({ allowUnknownOutputs: true }); + tx.addOutput({ amount: 0n, script: compileMemo(memo) }); + + expect(extractMemoFromKeepKeyUtxoTransaction(tx, getNetworkForChain(Chain.Bitcoin))).toBe(memo); + }); + + it("marks KeepKey SDK UTXO chains as direct-signing capable", () => { + expect(keepkeyWallet.connectKeepkey.directSigningSupport[Chain.Bitcoin]).toBe(true); + expect(keepkeyWallet.connectKeepkey.directSigningSupport[Chain.BitcoinCash]).toBe(true); + expect(keepkeyWallet.connectKeepkey.directSigningSupport[Chain.Dash]).toBe(true); + expect(keepkeyWallet.connectKeepkey.directSigningSupport[Chain.Dogecoin]).toBe(true); + expect(keepkeyWallet.connectKeepkey.directSigningSupport[Chain.Litecoin]).toBe(true); + }); +}); From 68d4f5e2bc98426760fc1e256acfd99f0feee676 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 May 2026 15:46:34 +0400 Subject: [PATCH 3/4] Enable Vultisig BTC direct signing --- .changeset/tall-lizards-provide.md | 2 +- .../wallet-extensions/src/vultisig/index.ts | 5 +- .../test/tclike-transfer-intent.test.ts | 66 +++++++++++++++++++ 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/.changeset/tall-lizards-provide.md b/.changeset/tall-lizards-provide.md index 2c6cb88..7a515ba 100644 --- a/.changeset/tall-lizards-provide.md +++ b/.changeset/tall-lizards-provide.md @@ -5,4 +5,4 @@ "@swapkit/sdk": patch --- -Enable KeepKey BEX and Vultisig THORChain/Maya direct swap submission by translating provided toolbox transactions into provider sign-and-broadcast requests. Add KeepKey SDK UTXO direct swap submission by signing the provided PSBT transaction and broadcasting the serialized transaction returned by KeepKey. +Enable KeepKey BEX and Vultisig THORChain/Maya direct swap submission by translating provided toolbox transactions into provider sign-and-broadcast requests. Enable Vultisig BTC direct swap submission through UTXO intent extraction. Add KeepKey SDK UTXO direct swap submission by signing the provided PSBT transaction and broadcasting the serialized transaction returned by KeepKey. diff --git a/packages/wallet-extensions/src/vultisig/index.ts b/packages/wallet-extensions/src/vultisig/index.ts index 3775407..1723ae9 100644 --- a/packages/wallet-extensions/src/vultisig/index.ts +++ b/packages/wallet-extensions/src/vultisig/index.ts @@ -63,6 +63,7 @@ export const vultisigWallet: ExtensionWallet<"connectVultisig"> = createWallet({ [Chain.Avalanche]: true, [Chain.Base]: true, [Chain.BinanceSmartChain]: true, + [Chain.Bitcoin]: true, [Chain.BitcoinCash]: true, [Chain.Dash]: true, [Chain.Dogecoin]: true, @@ -73,7 +74,7 @@ export const vultisigWallet: ExtensionWallet<"connectVultisig"> = createWallet({ [Chain.Polygon]: true, [Chain.THORChain]: true, [Chain.XLayer]: true, - // BTC/ZEC/Cosmos/Kujira/Solana/Ripple: blocked on Vultisig provider — no raw-sign RPC + // ZEC/Cosmos/Kujira/Solana/Ripple: blocked on Vultisig provider — no raw-sign RPC }, name: "connectVultisig", supportedChains: [ @@ -162,7 +163,7 @@ async function getWalletMethods(chain: (typeof VULTISIG_SUPPORTED_CHAINS)[number .with(...UTXOChains, async () => { const { getUtxoToolbox } = await import("@swapkit/toolboxes/utxo"); const toolbox = await getUtxoToolbox(chain as UTXOChain); - if (chain === Chain.Zcash || chain === Chain.Bitcoin) { + if (chain === Chain.Zcash) { return { ...toolbox, transfer: walletTransfer }; } diff --git a/packages/wallet-extensions/test/tclike-transfer-intent.test.ts b/packages/wallet-extensions/test/tclike-transfer-intent.test.ts index dbbb95d..780d5d8 100644 --- a/packages/wallet-extensions/test/tclike-transfer-intent.test.ts +++ b/packages/wallet-extensions/test/tclike-transfer-intent.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; import { Chain } from "@swapkit/helpers"; +import { getNetworkForChain } from "@swapkit/toolboxes/utxo"; +import { Transaction } from "@swapkit/utxo-signer"; import { extractTCLikeTransferIntent } from "../src/helpers/tclikeTransferIntent"; import { keepkeyBexWallet } from "../src/keepkey-bex"; import { vultisigWallet } from "../src/vultisig"; @@ -19,6 +21,19 @@ const thorDepositTx = { ], }; +const btcSender = "1BoatSLRHtKNngkdXEeobR76b53LETtpyT"; +const btcRecipient = "1dice8EMZmqKvrGE4Qc9bUFf9PX3xaYDp"; + +function createBtcSwapTx() { + const tx = new Transaction({ allowUnknownOutputs: true }); + const network = getNetworkForChain(Chain.Bitcoin); + + tx.addOutputAddress(btcRecipient, 12_345n, network); + tx.addOutputAddress(btcSender, 67_890n, network); + + return tx; +} + describe("extractTCLikeTransferIntent", () => { afterEach(() => { // @ts-expect-error test cleanup @@ -191,9 +206,60 @@ describe("extractTCLikeTransferIntent", () => { ]); }); + test("submits Vultisig BTC transactions through signAndBroadcastTransaction", async () => { + const requests: unknown[] = []; + + // @ts-expect-error test window shim + globalThis.window = { + vultisig: { + bitcoin: { + request: (request: unknown, cb?: (err: unknown, result: unknown) => void) => { + requests.push(request); + if (cb) { + cb(null, "btcHash"); + return; + } + + return Promise.resolve([btcSender]); + }, + }, + }, + }; + + const addChain = mock(() => {}); + const connectVultisig = vultisigWallet.connectVultisig.connectWallet({ addChain }); + + await connectVultisig([Chain.Bitcoin]); + + const walletMethods = addChain.mock.calls[0]?.[0] as + | { signAndBroadcastTransaction?: (tx: Transaction) => Promise } + | undefined; + + await expect(walletMethods?.signAndBroadcastTransaction?.(createBtcSwapTx())).resolves.toBe("btcHash"); + + expect(requests).toEqual([ + { method: "request_accounts", params: [] }, + { method: "request_accounts", params: [] }, + { + method: "send_transaction", + params: [ + { + amount: { amount: 12345, decimals: 8 }, + asset: { chain: "BTC", symbol: "BTC", ticker: "BTC" }, + data: "", + from: btcSender, + gasLimit: undefined, + to: btcRecipient, + }, + ], + }, + ]); + }); + test("marks KeepKey BEX and Vultisig THORChain and Maya direct signing as available", () => { expect(keepkeyBexWallet.connectKeepkeyBex.directSigningSupport[Chain.THORChain]).toBe(true); expect(keepkeyBexWallet.connectKeepkeyBex.directSigningSupport[Chain.Maya]).toBe(true); + expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.Bitcoin]).toBe(true); expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.THORChain]).toBe(true); expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.Maya]).toBe(true); }); From 7ba03175b98305a43b9fb930c01994268b083135 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 12 May 2026 16:19:34 +0400 Subject: [PATCH 4/4] Enable more direct signing wallet intents --- .changeset/tall-lizards-provide.md | 2 +- packages/sdk/src/index.ts | 3 - .../src/helpers/vultisigTransferIntent.ts | 102 ++++++++++++++++ .../src/keepkey-bex/index.ts | 3 - .../wallet-extensions/src/talisman/index.ts | 1 - .../wallet-extensions/src/vultisig/index.ts | 75 +++++++++++- .../src/vultisig/walletHelpers.ts | 50 ++++++-- .../test/tclike-transfer-intent.test.ts | 114 +++++++++++++++++- packages/wallet-hardware/src/trezor/index.ts | 5 +- .../test/trezor-litecoin-xpub.test.ts | 3 +- 10 files changed, 334 insertions(+), 24 deletions(-) create mode 100644 packages/wallet-extensions/src/helpers/vultisigTransferIntent.ts diff --git a/.changeset/tall-lizards-provide.md b/.changeset/tall-lizards-provide.md index 7a515ba..a2f1470 100644 --- a/.changeset/tall-lizards-provide.md +++ b/.changeset/tall-lizards-provide.md @@ -5,4 +5,4 @@ "@swapkit/sdk": patch --- -Enable KeepKey BEX and Vultisig THORChain/Maya direct swap submission by translating provided toolbox transactions into provider sign-and-broadcast requests. Enable Vultisig BTC direct swap submission through UTXO intent extraction. Add KeepKey SDK UTXO direct swap submission by signing the provided PSBT transaction and broadcasting the serialized transaction returned by KeepKey. +Enable KeepKey BEX and Vultisig THORChain/Maya direct swap submission by translating provided toolbox transactions into provider sign-and-broadcast requests. Enable Vultisig BTC, Cosmos, Kujira, and Ripple direct swap submission through intent extraction and mark Solana direct signing support. Stop advertising unsupported KeepKey BEX XRP/Solana and Vultisig Polkadot combinations. Add KeepKey SDK UTXO direct swap submission by signing the provided PSBT transaction and broadcasting the serialized transaction returned by KeepKey. diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index cdcacd6..89020ae 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -25,7 +25,6 @@ import { okxWallet } from "@swapkit/wallets/okx"; import { onekeyWallet } from "@swapkit/wallets/onekey"; import { passkeysWallet } from "@swapkit/wallets/passkeys"; import { phantomWallet } from "@swapkit/wallets/phantom"; -import { radixWallet } from "@swapkit/wallets/radix"; import { talismanWallet } from "@swapkit/wallets/talisman"; import { trezorWallet } from "@swapkit/wallets/trezor"; import { tronlinkWallet } from "@swapkit/wallets/tronlink"; @@ -70,7 +69,6 @@ export { onekeyWallet, passkeysWallet, phantomWallet, - radixWallet, talismanWallet, trezorWallet, tronlinkWallet, @@ -107,7 +105,6 @@ export const defaultWallets = { ...onekeyWallet, ...phantomWallet, ...passkeysWallet, - ...radixWallet, ...talismanWallet, ...trezorWallet, ...tronlinkWallet, diff --git a/packages/wallet-extensions/src/helpers/vultisigTransferIntent.ts b/packages/wallet-extensions/src/helpers/vultisigTransferIntent.ts new file mode 100644 index 0000000..5b4d856 --- /dev/null +++ b/packages/wallet-extensions/src/helpers/vultisigTransferIntent.ts @@ -0,0 +1,102 @@ +import { Chain, getChainConfig, SwapKitError } from "@swapkit/helpers"; +import type { CosmosTransaction } from "@swapkit/helpers/api"; +import type { RippleTransaction } from "@swapkit/toolboxes/ripple"; + +export type VultisigNativeTransferIntent = { + amount: { amount: number; decimals: number }; + asset: { chain: string; symbol: string; ticker: string }; + from: string; + memo: string; + recipient: string; +}; + +type CosmosSendMessage = { + typeUrl?: string; + type?: string; + value: { + amount: { amount: string; denom: string }[]; + fromAddress?: string; + from_address?: string; + toAddress?: string; + to_address?: string; + }; +}; + +function getNativeCosmosSymbol(chain: Chain.Cosmos | Chain.Kujira) { + return chain === Chain.Kujira ? "KUJI" : "ATOM"; +} + +export function extractVultisigCosmosTransferIntent({ + chain, + tx, +}: { + chain: Chain.Cosmos | Chain.Kujira; + tx: CosmosTransaction; +}): VultisigNativeTransferIntent { + const [msg] = tx.msgs as CosmosSendMessage[]; + const messageType = msg?.typeUrl || msg?.type; + + if (!(msg && messageType?.includes("MsgSend"))) { + throw new SwapKitError("plugin_swapkit_invalid_transaction", { + chain, + messageType, + reason: "Vultisig Cosmos/Kujira direct signing only supports native MsgSend transactions", + }); + } + + const [coin] = msg.value.amount; + const from = msg.value.fromAddress || msg.value.from_address; + const recipient = msg.value.toAddress || msg.value.to_address; + + if (!(coin && from && recipient)) throw new SwapKitError("plugin_swapkit_invalid_transaction", { chain }); + + const symbol = getNativeCosmosSymbol(chain); + + return { + amount: { amount: Number(coin.amount), decimals: getChainConfig(chain).baseDecimal }, + asset: { chain, symbol, ticker: symbol }, + from, + memo: tx.memo || "", + recipient, + }; +} + +function decodeXrplMemo(tx: RippleTransaction) { + const [memo] = "Memos" in tx && Array.isArray(tx.Memos) ? tx.Memos : []; + const memoData = memo?.Memo?.MemoData; + if (!memoData) return ""; + + try { + return Buffer.from(memoData, "hex").toString("utf8"); + } catch { + return ""; + } +} + +export function extractVultisigRippleTransferIntent(tx: RippleTransaction): VultisigNativeTransferIntent { + if (tx.TransactionType !== "Payment") { + throw new SwapKitError("plugin_swapkit_invalid_transaction", { + chain: Chain.Ripple, + reason: "Vultisig Ripple direct signing only supports native Payment transactions", + transactionType: tx.TransactionType, + }); + } + + if (typeof tx.Amount !== "string") { + throw new SwapKitError("plugin_swapkit_invalid_transaction", { + chain: Chain.Ripple, + reason: "Vultisig Ripple direct signing only supports native XRP payments", + }); + } + + if (!(tx.Account && tx.Destination)) + throw new SwapKitError("plugin_swapkit_invalid_transaction", { chain: Chain.Ripple }); + + return { + amount: { amount: Number(tx.Amount), decimals: getChainConfig(Chain.Ripple).baseDecimal }, + asset: { chain: Chain.Ripple, symbol: Chain.Ripple, ticker: Chain.Ripple }, + from: tx.Account, + memo: decodeXrplMemo(tx), + recipient: tx.Destination, + }; +} diff --git a/packages/wallet-extensions/src/keepkey-bex/index.ts b/packages/wallet-extensions/src/keepkey-bex/index.ts index f4b78a1..ed9b971 100644 --- a/packages/wallet-extensions/src/keepkey-bex/index.ts +++ b/packages/wallet-extensions/src/keepkey-bex/index.ts @@ -48,7 +48,6 @@ export const keepkeyBexWallet: ExtensionWallet<"connectKeepkeyBex"> = createWall [Chain.Polygon]: true, [Chain.THORChain]: true, [Chain.XLayer]: true, - // Ripple/Solana: provider lacks raw-sign RPC }, name: "connectKeepkeyBex", supportedChains: [ @@ -67,8 +66,6 @@ export const keepkeyBexWallet: ExtensionWallet<"connectKeepkeyBex"> = createWall Chain.Maya, Chain.Optimism, Chain.Polygon, - Chain.Ripple, - Chain.Solana, Chain.THORChain, Chain.XLayer, ], diff --git a/packages/wallet-extensions/src/talisman/index.ts b/packages/wallet-extensions/src/talisman/index.ts index 79bfecd..89c9ce3 100644 --- a/packages/wallet-extensions/src/talisman/index.ts +++ b/packages/wallet-extensions/src/talisman/index.ts @@ -53,7 +53,6 @@ export const talismanWallet: ExtensionWallet<"connectTalisman"> = createWallet({ Chain.BinanceSmartChain, Chain.Optimism, Chain.XLayer, - Chain.Polkadot, Chain.Chainflip, ], walletType: WalletOption.TALISMAN, diff --git a/packages/wallet-extensions/src/vultisig/index.ts b/packages/wallet-extensions/src/vultisig/index.ts index 1723ae9..46c75c9 100644 --- a/packages/wallet-extensions/src/vultisig/index.ts +++ b/packages/wallet-extensions/src/vultisig/index.ts @@ -14,6 +14,10 @@ import { import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; import { extractTCLikeTransferIntent } from "../helpers/tclikeTransferIntent"; import { extractUtxoTransferIntent, unsupportedUtxoSignTransaction } from "../helpers/utxoTransferIntent"; +import { + extractVultisigCosmosTransferIntent, + extractVultisigRippleTransferIntent, +} from "../helpers/vultisigTransferIntent"; import type { ExtensionWallet } from "../walletTypes"; import { getVultisigAddress, @@ -65,16 +69,20 @@ export const vultisigWallet: ExtensionWallet<"connectVultisig"> = createWallet({ [Chain.BinanceSmartChain]: true, [Chain.Bitcoin]: true, [Chain.BitcoinCash]: true, + [Chain.Cosmos]: true, [Chain.Dash]: true, [Chain.Dogecoin]: true, [Chain.Ethereum]: true, + [Chain.Kujira]: true, [Chain.Litecoin]: true, [Chain.Maya]: true, [Chain.Optimism]: true, [Chain.Polygon]: true, + [Chain.Ripple]: true, + [Chain.Solana]: true, [Chain.THORChain]: true, [Chain.XLayer]: true, - // ZEC/Cosmos/Kujira/Solana/Ripple: blocked on Vultisig provider — no raw-sign RPC + // ZEC: blocked on Vultisig provider — no raw-sign RPC }, name: "connectVultisig", supportedChains: [ @@ -92,7 +100,6 @@ export const vultisigWallet: ExtensionWallet<"connectVultisig"> = createWallet({ Chain.Litecoin, Chain.Maya, Chain.Optimism, - Chain.Polkadot, Chain.Polygon, Chain.Ripple, Chain.Solana, @@ -157,7 +164,40 @@ async function getWalletMethods(chain: (typeof VULTISIG_SUPPORTED_CHAINS)[number const { getCosmosToolbox } = await import("@swapkit/toolboxes/cosmos"); const provider = await getVultisigProvider(chain as Exclude); const toolbox = await getCosmosToolbox(chain as Exclude); - return prepareNetworkSwitchCosmos({ chain, provider, toolbox: { ...toolbox, transfer: walletTransfer } }); + const cosmosChain = chain as Chain.Cosmos | Chain.Kujira; + return prepareNetworkSwitchCosmos({ + chain, + methodNames: ["signAndBroadcastTransaction"], + provider, + toolbox: { + ...toolbox, + signAndBroadcastTransaction: (tx: Parameters[0]["tx"]) => { + const intent = extractVultisigCosmosTransferIntent({ chain: cosmosChain, tx }); + return submitVultisigTransaction({ + chain: cosmosChain, + method: "send_transaction", + params: [ + { + amount: intent.amount, + asset: intent.asset, + data: intent.memo, + from: intent.from, + to: intent.recipient, + }, + ], + }); + }, + signTransaction: () => + Promise.reject( + new SwapKitError("wallet_walletconnect_method_not_supported", { + method: "signTransaction", + reason: "Vultisig Cosmos/Kujira provider only supports signAndBroadcastTransaction", + wallet: WalletOption.VULTISIG, + }), + ), + transfer: walletTransfer, + }, + }); }) .with(...UTXOChains, async () => { @@ -217,7 +257,34 @@ async function getWalletMethods(chain: (typeof VULTISIG_SUPPORTED_CHAINS)[number .with(Chain.Ripple, async () => { const { getRippleToolbox } = await import("@swapkit/toolboxes/ripple"); const toolbox = await getRippleToolbox(); - return { ...toolbox, transfer: walletTransfer }; + return { + ...toolbox, + signAndBroadcastTransaction: (tx: Parameters[0]) => { + const intent = extractVultisigRippleTransferIntent(tx); + return submitVultisigTransaction({ + chain: Chain.Ripple, + method: "send_transaction", + params: [ + { + amount: intent.amount, + asset: intent.asset, + data: intent.memo, + from: intent.from, + to: intent.recipient, + }, + ], + }); + }, + signTransaction: () => + Promise.reject( + new SwapKitError("wallet_walletconnect_method_not_supported", { + method: "signTransaction", + reason: "Vultisig Ripple provider only supports signAndBroadcastTransaction", + wallet: WalletOption.VULTISIG, + }), + ), + transfer: walletTransfer, + }; }) .with(Chain.Polkadot, async () => { diff --git a/packages/wallet-extensions/src/vultisig/walletHelpers.ts b/packages/wallet-extensions/src/vultisig/walletHelpers.ts index d3e3542..4d56aec 100644 --- a/packages/wallet-extensions/src/vultisig/walletHelpers.ts +++ b/packages/wallet-extensions/src/vultisig/walletHelpers.ts @@ -51,6 +51,16 @@ type VultisigProviderType = T extends typeof Chain.Solana ? Eip1193Provider : undefined; +function getVultisigAccountAddress(account: unknown): string | undefined { + if (typeof account === "string") return account; + if (Array.isArray(account)) return getVultisigAccountAddress(account[0]); + if (account && typeof account === "object" && "address" in account && typeof account.address === "string") { + return account.address; + } + + return undefined; +} + export async function getVultisigProvider(chain: T): Promise> { if (!window.vultisig) throw new SwapKitError("wallet_vultisig_not_found"); const { match } = await import("ts-pattern"); @@ -94,11 +104,32 @@ export async function submitVultisigTransaction({ } return new Promise((resolve, reject) => { - if (client && "request" in client) { - // @ts-expect-error - client.request({ method, params: finalParams }, (err: string, tx: string) => { - err ? reject(err) : resolve(tx); - }); + if (!(client && "request" in client)) { + reject(new SwapKitError("wallet_vultisig_not_found", { chain })); + return; + } + + let settled = false; + const settle = (error: unknown, tx?: string) => { + if (settled) return; + settled = true; + error ? reject(error) : resolve(tx as string); + }; + + try { + const request = client.request as ( + payload: { method: TransactionMethod; params: typeof finalParams }, + callback?: (error: unknown, tx?: string) => void, + ) => Promise | undefined; + const result = request({ method, params: finalParams }, settle); + if (result && typeof (result as Promise).then === "function") { + (result as Promise).then( + (tx) => settle(null, tx), + (error) => settle(error), + ); + } + } catch (error) { + settle(error); } }); } @@ -117,10 +148,13 @@ export async function getVultisigAddress(chain: Chain) { let account = await windowProvider.request({ method: "get_accounts" }); if (!account || (Array.isArray(account) && account.length === 0)) { - const connectedAcount = await windowProvider.request({ method: "request_accounts" }); - account = connectedAcount[0].address; + account = await windowProvider.request({ method: "request_accounts" }); } - return account; + + const address = getVultisigAccountAddress(account); + if (!address) throw new SwapKitError("wallet_vultisig_not_found", { chain }); + + return address; } if (EVMChains.includes(chain as EVMChain)) { diff --git a/packages/wallet-extensions/test/tclike-transfer-intent.test.ts b/packages/wallet-extensions/test/tclike-transfer-intent.test.ts index 780d5d8..cc88412 100644 --- a/packages/wallet-extensions/test/tclike-transfer-intent.test.ts +++ b/packages/wallet-extensions/test/tclike-transfer-intent.test.ts @@ -23,6 +23,10 @@ const thorDepositTx = { const btcSender = "1BoatSLRHtKNngkdXEeobR76b53LETtpyT"; const btcRecipient = "1dice8EMZmqKvrGE4Qc9bUFf9PX3xaYDp"; +const cosmosSender = "cosmos1sender"; +const cosmosRecipient = "cosmos1recipient"; +const rippleSender = "rSender"; +const rippleRecipient = "rRecipient"; function createBtcSwapTx() { const tx = new Transaction({ allowUnknownOutputs: true }); @@ -256,10 +260,118 @@ describe("extractTCLikeTransferIntent", () => { ]); }); - test("marks KeepKey BEX and Vultisig THORChain and Maya direct signing as available", () => { + test("submits Vultisig Cosmos transactions through signAndBroadcastTransaction", async () => { + const requests: unknown[] = []; + + // @ts-expect-error test window shim + globalThis.window = { + vultisig: { + cosmos: { + request: (request: unknown, cb?: (err: unknown, result: unknown) => void) => { + requests.push(request); + const method = (request as { method?: string }).method; + + if (method === "get_accounts" || method === "request_accounts") return Promise.resolve([cosmosSender]); + if (cb) { + cb(null, "cosmosHash"); + return; + } + + return Promise.resolve("cosmosHash"); + }, + }, + }, + }; + + const addChain = mock(() => {}); + const connectVultisig = vultisigWallet.connectVultisig.connectWallet({ addChain }); + + await connectVultisig([Chain.Cosmos]); + + const walletMethods = addChain.mock.calls[0]?.[0] as + | { signAndBroadcastTransaction?: (tx: { memo: string; msgs: unknown[] }) => Promise } + | undefined; + + await expect( + walletMethods?.signAndBroadcastTransaction?.({ + memo: "cosmosMemo", + msgs: [ + { + typeUrl: "/cosmos.bank.v1beta1.MsgSend", + value: { + amount: [{ amount: "123456", denom: "uatom" }], + fromAddress: cosmosSender, + toAddress: cosmosRecipient, + }, + }, + ], + }), + ).resolves.toBe("cosmosHash"); + + expect(requests).toEqual([ + { method: "wallet_switch_chain", params: [{ chainId: "cosmoshub-4" }] }, + { method: "get_accounts" }, + { method: "wallet_switch_chain", params: [{ chainId: "cosmoshub-4" }] }, + { + method: "send_transaction", + params: [{ data: "cosmosMemo", from: cosmosSender, to: cosmosRecipient, value: "123456" }], + }, + ]); + }); + + test("submits Vultisig Ripple transactions through signAndBroadcastTransaction", async () => { + const requests: unknown[] = []; + + // @ts-expect-error test window shim + globalThis.window = { + vultisig: { + ripple: { + request: (request: unknown) => { + requests.push(request); + const method = (request as { method?: string }).method; + + if (method === "request_accounts") return Promise.resolve([rippleSender]); + return Promise.resolve("rippleHash"); + }, + }, + }, + }; + + const addChain = mock(() => {}); + const connectVultisig = vultisigWallet.connectVultisig.connectWallet({ addChain }); + + await connectVultisig([Chain.Ripple]); + + const walletMethods = addChain.mock.calls[0]?.[0] as + | { signAndBroadcastTransaction?: (tx: { [key: string]: unknown }) => Promise } + | undefined; + + await expect( + walletMethods?.signAndBroadcastTransaction?.({ + Account: rippleSender, + Amount: "1000000", + Destination: rippleRecipient, + Memos: [{ Memo: { MemoData: Buffer.from("xrpMemo").toString("hex").toUpperCase() } }], + TransactionType: "Payment", + }), + ).resolves.toBe("rippleHash"); + + expect(requests).toEqual([ + { method: "request_accounts", params: [] }, + { + method: "send_transaction", + params: [{ data: "xrpMemo", from: rippleSender, to: rippleRecipient, value: "1000000" }], + }, + ]); + }); + + test("marks KeepKey BEX and Vultisig direct signing as available", () => { expect(keepkeyBexWallet.connectKeepkeyBex.directSigningSupport[Chain.THORChain]).toBe(true); expect(keepkeyBexWallet.connectKeepkeyBex.directSigningSupport[Chain.Maya]).toBe(true); expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.Bitcoin]).toBe(true); + expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.Cosmos]).toBe(true); + expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.Kujira]).toBe(true); + expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.Ripple]).toBe(true); expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.THORChain]).toBe(true); expect(vultisigWallet.connectVultisig.directSigningSupport[Chain.Maya]).toBe(true); }); diff --git a/packages/wallet-hardware/src/trezor/index.ts b/packages/wallet-hardware/src/trezor/index.ts index 86826e0..80d76f9 100644 --- a/packages/wallet-hardware/src/trezor/index.ts +++ b/packages/wallet-hardware/src/trezor/index.ts @@ -462,7 +462,7 @@ function shouldUseTrezorPsbtSigner(chain: Chain) { } function shouldUseTrezorSerializedSigner(chain: Chain) { - return chain === Chain.BitcoinCash || chain === Chain.Dogecoin; + return chain === Chain.BitcoinCash || chain === Chain.Dash || chain === Chain.Dogecoin; } async function getTrezorWallet({ @@ -1337,6 +1337,7 @@ export const trezorWallet = createWallet({ [Chain.BinanceSmartChain]: true, [Chain.Bitcoin]: true, [Chain.BitcoinCash]: true, + [Chain.Dash]: true, [Chain.Ethereum]: true, [Chain.Gnosis]: true, [Chain.Dogecoin]: true, @@ -1345,7 +1346,7 @@ export const trezorWallet = createWallet({ [Chain.Optimism]: true, [Chain.Polygon]: true, [Chain.XLayer]: true, - // DASH/ZEC: pending PSBT→TrezorConnect converter validation (V3 plan PR) + // ZEC: pending PCZT/Zcash converter validation (V3 plan PR) }, getExtendedPublicKey: getTrezorExtendedPublicKey, name: "connectTrezor", diff --git a/packages/wallet-hardware/test/trezor-litecoin-xpub.test.ts b/packages/wallet-hardware/test/trezor-litecoin-xpub.test.ts index 8460fbd..a1c72b7 100644 --- a/packages/wallet-hardware/test/trezor-litecoin-xpub.test.ts +++ b/packages/wallet-hardware/test/trezor-litecoin-xpub.test.ts @@ -36,8 +36,9 @@ describe("Trezor Litecoin xpub handling", () => { ); }); - it("marks BCH and DOGE direct signing as available for testing", () => { + it("marks BCH, DASH, and DOGE direct signing as available for testing", () => { expect(trezorWallet.connectTrezor.directSigningSupport[Chain.BitcoinCash]).toBe(true); + expect(trezorWallet.connectTrezor.directSigningSupport[Chain.Dash]).toBe(true); expect(trezorWallet.connectTrezor.directSigningSupport[Chain.Dogecoin]).toBe(true); }); });