diff --git a/.changeset/tall-lizards-provide.md b/.changeset/tall-lizards-provide.md new file mode 100644 index 0000000..a2f1470 --- /dev/null +++ b/.changeset/tall-lizards-provide.md @@ -0,0 +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 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/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/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/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/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 02fec2c..ed9b971 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,11 @@ 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 }, name: "connectKeepkeyBex", supportedChains: [ @@ -63,8 +66,6 @@ export const keepkeyBexWallet: ExtensionWallet<"connectKeepkeyBex"> = createWall Chain.Maya, Chain.Optimism, Chain.Polygon, - Chain.Ripple, - Chain.Solana, Chain.THORChain, Chain.XLayer, ], @@ -85,6 +86,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/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 0c82330..46c75c9 100644 --- a/packages/wallet-extensions/src/vultisig/index.ts +++ b/packages/wallet-extensions/src/vultisig/index.ts @@ -12,13 +12,19 @@ import { WalletOption, } from "@swapkit/helpers"; 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, getVultisigMethods, getVultisigProvider, prepareNetworkSwitchCosmos, + submitVultisigTransaction, walletTransfer, } from "./walletHelpers"; @@ -61,15 +67,22 @@ export const vultisigWallet: ExtensionWallet<"connectVultisig"> = createWallet({ [Chain.Avalanche]: true, [Chain.Base]: true, [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, - // BTC/ZEC/Cosmos/Kujira/THORChain/Maya/Solana/Ripple: blocked on Vultisig provider — no raw-sign RPC + // ZEC: blocked on Vultisig provider — no raw-sign RPC }, name: "connectVultisig", supportedChains: [ @@ -87,7 +100,6 @@ export const vultisigWallet: ExtensionWallet<"connectVultisig"> = createWallet({ Chain.Litecoin, Chain.Maya, Chain.Optimism, - Chain.Polkadot, Chain.Polygon, Chain.Ripple, Chain.Solana, @@ -113,11 +125,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"), }; }) @@ -126,13 +164,46 @@ 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 () => { 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 }; } @@ -186,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 a1ee0df..4d56aec 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 = { @@ -50,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"); @@ -71,7 +82,7 @@ export async function getVultisigProvider(chain: T): Promise undefined) as VultisigProviderType; } -async function transaction({ +export async function submitVultisigTransaction({ method, params, chain, @@ -93,11 +104,32 @@ async function transaction({ } 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); } }); } @@ -116,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)) { @@ -175,7 +210,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..cc88412 --- /dev/null +++ b/packages/wallet-extensions/test/tclike-transfer-intent.test.ts @@ -0,0 +1,378 @@ +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"; + +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", + }, + }, + ], +}; + +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 }); + 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 + 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("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("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/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/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/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); + }); +}); 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); }); }); 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" }