diff --git a/.changeset/ctrl-thorchain-signer.md b/.changeset/ctrl-thorchain-signer.md new file mode 100644 index 0000000..a92e243 --- /dev/null +++ b/.changeset/ctrl-thorchain-signer.md @@ -0,0 +1,8 @@ +--- +"@swapkit/wallet-extensions": patch +"@swapkit/wallet-hardware": patch +"@swapkit/wallets": patch +"@swapkit/sdk": patch +--- + +Add CTRL THORChain and Maya sign-and-broadcast transaction support, resolve CTRL providers through both `window.ctrl` and documented `window.xfi` injections, send CTRL Bitcoin PSBT requests with the callback-compatible params shape, broadcast CTRL Bitcoin PSBTs through the extension without local finalization, and wire Ledger THORChain through the toolbox signer path. diff --git a/packages/wallet-extensions/src/ctrl/index.ts b/packages/wallet-extensions/src/ctrl/index.ts index 038c49e..7b6271a 100644 --- a/packages/wallet-extensions/src/ctrl/index.ts +++ b/packages/wallet-extensions/src/ctrl/index.ts @@ -1,14 +1,7 @@ -import { - Chain, - ChainToChainId, - filterSupportedChains, - type GenericTransferParams, - SwapKitError, - WalletOption, -} from "@swapkit/helpers"; +import { Chain, ChainToChainId, filterSupportedChains, SwapKitError, WalletOption } from "@swapkit/helpers"; import { createWallet, getWalletSupportedChains } from "@swapkit/wallet-core"; import type { ExtensionWallet } from "../walletTypes"; -import { getCtrlAddress, getCtrlProvider, walletTransfer } from "./walletHelpers"; +import { getCtrlAddress, getCtrlProvider, signCtrlThorchainTransaction, walletTransfer } from "./walletHelpers"; export const ctrlWallet: ExtensionWallet<"connectCtrl"> = createWallet({ connect: ({ addChain, walletType, supportedChains }) => @@ -38,14 +31,16 @@ export const ctrlWallet: ExtensionWallet<"connectCtrl"> = createWallet({ [Chain.Ethereum]: true, [Chain.Gnosis]: true, [Chain.Kujira]: true, + [Chain.Maya]: true, [Chain.Monad]: true, [Chain.Near]: true, [Chain.Noble]: true, [Chain.Optimism]: true, [Chain.Polygon]: true, [Chain.Solana]: true, + [Chain.THORChain]: true, [Chain.XLayer]: true, - // BCH/DOGE/LTC/THORChain/Maya: blocked on CTRL provider — no raw signing RPC + // BCH/DOGE/LTC: blocked on CTRL provider — no raw signing RPC }, name: "connectCtrl", supportedChains: [ @@ -93,15 +88,25 @@ async function getWalletMethods(chain: (typeof CTRL_SUPPORTED_CHAINS)[number]) { case Chain.Maya: case Chain.THORChain: { - const { getCosmosToolbox, THORCHAIN_GAS_VALUE, MAYA_GAS_VALUE } = await import("@swapkit/toolboxes/cosmos"); + const { getCosmosToolbox } = await import("@swapkit/toolboxes/cosmos"); - const gasLimit = chain === Chain.Maya ? MAYA_GAS_VALUE : THORCHAIN_GAS_VALUE; const toolbox = await getCosmosToolbox(chain); return { ...toolbox, - deposit: (tx: GenericTransferParams) => walletTransfer({ ...tx, recipient: "" }, "deposit"), - transfer: (tx: GenericTransferParams) => walletTransfer({ ...tx, gasLimit }, "transfer"), + signAndBroadcastTransaction: (tx: Parameters[0]) => + signCtrlThorchainTransaction(tx, chain), + signTransaction: () => + Promise.reject( + new SwapKitError({ + errorKey: "wallet_walletconnect_method_not_supported", + info: { + method: "signTransaction", + reason: "CTRL THORChain provider only supports signAndBroadcastTransaction", + wallet: WalletOption.CTRL, + }, + }), + ), }; } @@ -152,28 +157,96 @@ async function getWalletMethods(chain: (typeof CTRL_SUPPORTED_CHAINS)[number]) { } }); + type SignPsbtResponse = { + error?: unknown; + hash?: string; + psbt?: string; + result?: SignPsbtResponse | string; + status?: string; + transactionHash?: string; + txid?: string; + txId?: string; + }; + + const getSignPsbtResult = (response: SignPsbtResponse): SignPsbtResponse => + typeof response.result === "object" && response.result ? response.result : response; + + const getSignedPsbt = (response: SignPsbtResponse) => { + const result = getSignPsbtResult(response); + return result.psbt || response.psbt || (typeof response.result === "string" ? response.result : undefined); + }; + + const getBroadcastTxId = (response: SignPsbtResponse) => { + const result = getSignPsbtResult(response); + return ( + result.txid || + result.txId || + result.hash || + result.transactionHash || + response.txid || + response.txId || + response.hash || + response.transactionHash || + (typeof response.result === "string" ? response.result : undefined) + ); + }; + + const getSignPsbtResponseShape = (response: SignPsbtResponse) => { + const result = getSignPsbtResult(response); + return { + nestedStatus: result.status, + resultKeys: typeof response.result === "object" && response.result ? Object.keys(response.result) : undefined, + rootKeys: Object.keys(response), + status: response.status, + }; + }; + + const signPsbt = (tx: InstanceType, broadcast: boolean) => { + const psbt = Buffer.from(tx.toPSBT()).toString("base64"); + const signingIndexes = Array.from({ length: tx.inputsLength }, (_, i) => i); + + return ctrlRequest({ + method: "sign_psbt", + params: [{ allowedSignHash: 1, broadcast, psbt, signInputs: { [address]: signingIndexes } }], + }); + }; + const signer = { getAddress: async () => address, signTransaction: async (tx: InstanceType) => { - const psbtB64 = Buffer.from(tx.toPSBT()).toString("base64"); - const signingIndexes = Array.from({ length: tx.inputsLength }, (_, i) => i); - - const response = await ctrlRequest<{ status: string; result: { psbt: string } }>({ - method: "sign_psbt", - params: { allowedSignHash: 1, broadcast: false, psbt: psbtB64, signInputs: { [address]: signingIndexes } }, - }); - - if (response?.status !== "success" || !response.result?.psbt) { - throw new SwapKitError("plugin_swapkit_invalid_transaction", { chain: Chain.Bitcoin }); + const response = await signPsbt(tx, false); + const signedPsbt = getSignedPsbt(response); + + if (!signedPsbt) { + throw new SwapKitError("plugin_swapkit_invalid_transaction", { + chain: Chain.Bitcoin, + response: getSignPsbtResponseShape(response), + }); } - return Transaction.fromPSBT(new Uint8Array(Buffer.from(response.result.psbt, "base64"))); + return Transaction.fromPSBT(new Uint8Array(Buffer.from(signedPsbt, "base64"))); }, }; const toolbox = await getUtxoToolbox(Chain.Bitcoin, { signer }); - return { ...toolbox, address }; + return { + ...toolbox, + address, + signAndBroadcastTransaction: async (tx: InstanceType) => { + const response = await signPsbt(tx, true); + const txid = getBroadcastTxId(response); + + if (!txid) { + throw new SwapKitError("plugin_swapkit_invalid_transaction", { + chain: Chain.Bitcoin, + response: getSignPsbtResponseShape(response), + }); + } + + return txid; + }, + }; } case Chain.BitcoinCash: diff --git a/packages/wallet-extensions/src/ctrl/walletHelpers.ts b/packages/wallet-extensions/src/ctrl/walletHelpers.ts index bcb9e7a..f43fa3e 100644 --- a/packages/wallet-extensions/src/ctrl/walletHelpers.ts +++ b/packages/wallet-extensions/src/ctrl/walletHelpers.ts @@ -4,14 +4,17 @@ import { Chain, ChainToChainId, type CosmosChain, + CosmosChainPrefixes, type EVMChain, EVMChains, type FeeOption, + getChainConfig, providerRequest, SwapKitError, type TCLikeChain, WalletOption, } from "@swapkit/helpers"; +import { base64ToBech32 } from "@swapkit/toolboxes/cosmos"; import type { SolanaProvider } from "@swapkit/toolboxes/solana"; import type { Eip1193Provider } from "ethers"; import { match } from "ts-pattern"; @@ -22,10 +25,47 @@ type TransactionParams = { asset: string | { chain: string; symbol: string; ticker: string }; amount: number | string | { amount: number; decimals?: number }; decimal?: number; + from?: string; + gasLimit?: string | bigint; recipient: string; memo?: string; }; +type CtrlRequestProvider = { + request( + args: { method: string; params: unknown[] | Record }, + cb?: (err: unknown, result: unknown) => void, + ): unknown; +}; + +type ThorchainTransferMessage = { + typeUrl?: string; + type?: string; + value: { + amount: { amount: string; denom: string }[]; + fromAddress?: string; + from_address?: string; + toAddress?: string; + to_address?: string; + }; +}; + +type ThorchainDepositMessage = { + typeUrl?: string; + type?: string; + value: { + coins: { amount: string; asset: string | { chain?: string; symbol?: string; ticker?: string } }[]; + memo?: string; + signer: string; + }; +}; + +type ThorchainToolboxTransaction = { + fee?: { gas?: string }; + memo?: string; + msgs: Array; +}; + export type WalletTxParams = { feeOptionKey?: FeeOption; from?: string; @@ -41,22 +81,25 @@ type CtrlProviderType = T extends typeof Chain.Solana ? Keplr : T extends EVMChain ? Eip1193Provider - : undefined; + : T extends TCLikeChain + ? CtrlRequestProvider + : undefined; export function getCtrlProvider(chain: T): CtrlProviderType { - if (!window.ctrl) throw new SwapKitError("wallet_ctrl_not_found"); + const ctrl = window.ctrl || window.xfi; + if (!ctrl) throw new SwapKitError("wallet_ctrl_not_found"); // @ts-expect-error return match(chain as Chain) - .with(...EVMChains, () => window.ctrl?.ethereum) - .with(Chain.Cosmos, Chain.Kujira, Chain.Noble, () => window.ctrl?.keplr) - .with(Chain.Bitcoin, () => window.ctrl?.bitcoin) - .with(Chain.BitcoinCash, () => window.ctrl?.bitcoincash) - .with(Chain.Dogecoin, () => window.ctrl?.dogecoin) - .with(Chain.Litecoin, () => window.ctrl?.litecoin) - .with(Chain.Solana, () => window.ctrl?.solana) - .with(Chain.THORChain, () => window.ctrl?.thorchain) - .with(Chain.Maya, () => window.ctrl?.mayachain) + .with(...EVMChains, () => ctrl.ethereum) + .with(Chain.Cosmos, Chain.Kujira, Chain.Noble, () => ctrl.keplr) + .with(Chain.Bitcoin, () => ctrl.bitcoin) + .with(Chain.BitcoinCash, () => ctrl.bitcoincash) + .with(Chain.Dogecoin, () => ctrl.dogecoin) + .with(Chain.Litecoin, () => ctrl.litecoin) + .with(Chain.Solana, () => ctrl.solana) + .with(Chain.THORChain, () => ctrl.thorchain) + .with(Chain.Maya, () => ctrl.mayachain) .otherwise(() => undefined); } @@ -72,15 +115,142 @@ async function transaction({ const client = await getCtrlProvider(chain); return new Promise((resolve, reject) => { - if (client && "request" in client) { - // @ts-expect-error - client.request({ method, params }, (err: string, tx: string) => { - err ? reject(err) : resolve(tx); - }); + if (!(client && "request" in client)) { + reject(new SwapKitError({ errorKey: "wallet_provider_not_found", info: { chain, wallet: WalletOption.CTRL } })); + return; + } + + const handler = (err: unknown, tx: unknown) => { + err ? reject(err) : resolve(tx as string); + }; + const maybePromise = client.request({ method, params }, handler); + if (maybePromise && typeof (maybePromise as { then?: unknown }).then === "function") { + (maybePromise as Promise).then( + (tx) => handler(null, tx), + (err) => handler(err, null), + ); } }); } +function getCtrlAssetFromThorchainAsset( + asset: ThorchainDepositMessage["value"]["coins"][number]["asset"], + chain: Chain, +) { + if (typeof asset === "string") { + const [, symbol = asset] = asset.split("."); + return { chain, symbol, ticker: symbol.split("-")[0] || symbol }; + } + + const symbol = asset.symbol || asset.ticker || chain; + return { chain: asset.chain || chain, symbol, ticker: asset.ticker || symbol.split("-")[0] || symbol }; +} + +function getCtrlAssetFromThorchainDenom(denom: string, chain: Chain) { + const symbol = denom.includes(".") ? denom.split(".").at(-1) || denom : denom; + const ticker = symbol.split("-")[0] || symbol; + + return { chain, symbol: symbol.toUpperCase(), ticker: ticker.toUpperCase() }; +} + +function normalizeTCLikeAddress(address: string, chain: Chain.THORChain | Chain.Maya) { + const prefix = CosmosChainPrefixes[chain]; + if (address.startsWith(`${prefix}1`)) return address; + + return base64ToBech32(address, prefix); +} + +function getCtrlTransactionMethod(tx: ThorchainToolboxTransaction): TransactionMethod { + 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 }); +} + +async function getCtrlTCLikeAddress(chain: Chain.THORChain | Chain.Maya) { + const provider = await getCtrlProvider(chain); + if (!(provider && "request" in provider)) { + throw new SwapKitError({ errorKey: "wallet_provider_not_found", info: { chain, wallet: WalletOption.CTRL } }); + } + + return new Promise((resolve, reject) => { + const handler = (err: unknown, accounts: unknown) => { + if (err) { + reject(err); + return; + } + + resolve(Array.isArray(accounts) ? accounts[0] : undefined); + }; + const maybePromise = provider.request({ method: "request_accounts", params: [] }, handler); + if (maybePromise && typeof (maybePromise as { then?: unknown }).then === "function") { + (maybePromise as Promise).then( + (accounts) => handler(null, accounts), + (err) => handler(err, null), + ); + } + }); +} + +export function convertThorchainTransactionToCtrlParams( + tx: ThorchainToolboxTransaction, + chain: Chain.THORChain | Chain.Maya, +): TransactionParams { + const [msg] = tx.msgs; + if (!msg) throw new SwapKitError("plugin_swapkit_invalid_transaction"); + + if (getCtrlTransactionMethod(tx) === "deposit") { + const { coins, memo = tx.memo || "", signer } = (msg as ThorchainDepositMessage).value; + const [coin] = coins; + if (!coin) throw new SwapKitError("plugin_swapkit_invalid_transaction"); + + return { + amount: { amount: Number(coin.amount), decimals: getChainConfig(chain).baseDecimal }, + asset: getCtrlAssetFromThorchainAsset(coin.asset, chain), + from: normalizeTCLikeAddress(signer, chain), + gasLimit: tx.fee?.gas, + memo, + recipient: "", + }; + } + + const { amount, fromAddress, from_address, toAddress, to_address } = (msg as ThorchainTransferMessage).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"); + + return { + amount: { amount: Number(coin.amount), decimals: getChainConfig(chain).baseDecimal }, + asset: getCtrlAssetFromThorchainDenom(coin.denom, chain), + from, + gasLimit: tx.fee?.gas, + memo: tx.memo || "", + recipient, + }; +} + +export async function signCtrlThorchainTransaction( + tx: ThorchainToolboxTransaction, + chain: Chain.THORChain | Chain.Maya, +) { + const method = getCtrlTransactionMethod(tx); + const params = [convertThorchainTransactionToCtrlParams(tx, chain)]; + const expectedAddress = params[0]?.from; + const activeAddress = expectedAddress ? await getCtrlTCLikeAddress(chain) : undefined; + + if (activeAddress && expectedAddress && activeAddress !== expectedAddress) { + throw new SwapKitError("wallet_ctrl_not_found", { activeAddress, chain, expectedAddress, reason: "Wrong account" }); + } + + return transaction({ chain, method, params }); +} + export async function getCtrlAddress(chain: Chain) { try { const eipProvider = (await getCtrlProvider(chain)) as Eip1193Provider; diff --git a/packages/wallet-extensions/src/types.ts b/packages/wallet-extensions/src/types.ts index f97aa01..138a9a1 100644 --- a/packages/wallet-extensions/src/types.ts +++ b/packages/wallet-extensions/src/types.ts @@ -22,6 +22,20 @@ export type VultisigCosmosProvider = { request(request: { method: string; params?: any[] | Record }, callback?: Callback): Promise; }; +type CtrlInjectedProviders = { + binance: Eip1193Provider; + bitcoin: Eip1193Provider; + bitcoincash: Eip1193Provider; + dogecoin: Eip1193Provider; + ethereum: Eip1193Provider; + keplr: Keplr; + litecoin: Eip1193Provider; + thorchain: Eip1193Provider; + mayachain: Eip1193Provider; + solana: SolanaProvider & { isXDEFI: boolean }; + near: NearBrowserWalletProvider; +}; + declare global { interface Window { injectedWeb3?: SubstrateInjectedExtension; @@ -35,19 +49,8 @@ declare global { trustwallet: EthereumWindowProvider & { ton?: import("./trustwallet").TrustWalletTonProvider }; phantom: { solana: SolanaProvider }; - ctrl?: { - binance: Eip1193Provider; - bitcoin: Eip1193Provider; - bitcoincash: Eip1193Provider; - dogecoin: Eip1193Provider; - ethereum: Eip1193Provider; - keplr: Keplr; - litecoin: Eip1193Provider; - thorchain: Eip1193Provider; - mayachain: Eip1193Provider; - solana: SolanaProvider & { isXDEFI: boolean }; - near: NearBrowserWalletProvider; - }; + ctrl?: CtrlInjectedProviders; + xfi?: CtrlInjectedProviders; vultisig?: { bitcoin: Eip1193Provider; diff --git a/packages/wallet-extensions/test/ctrl-thorchain.test.ts b/packages/wallet-extensions/test/ctrl-thorchain.test.ts new file mode 100644 index 0000000..3d84314 --- /dev/null +++ b/packages/wallet-extensions/test/ctrl-thorchain.test.ts @@ -0,0 +1,239 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { Chain } from "@swapkit/helpers"; +import { ctrlWallet } from "../src/ctrl"; +import { convertThorchainTransactionToCtrlParams, signCtrlThorchainTransaction } from "../src/ctrl/walletHelpers"; + +describe("convertThorchainTransactionToCtrlParams", () => { + afterEach(() => { + // @ts-expect-error test cleanup + delete globalThis.window; + }); + + test("converts THORChain deposit transactions for CTRL", () => { + const params = convertThorchainTransactionToCtrlParams( + { + 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", + }, + }, + ], + }, + Chain.THORChain, + ); + + expect(params).toEqual({ + amount: { amount: 123456789, decimals: 8 }, + asset: { chain: "THOR", symbol: "RUNE", ticker: "RUNE" }, + from: "thor1sender", + gasLimit: "500000000", + memo: "=:ETH.ETH:0xabc", + recipient: "", + }); + }); + + test("converts base64 THORChain deposit signer bytes to bech32", () => { + const params = convertThorchainTransactionToCtrlParams( + { + fee: { gas: "500000000" }, + memo: "=:b:bc1qeemjtfyru0gn9gcu3zu066zjrtun7yjuy2tfe4:10359:-_/nc:15/0", + msgs: [ + { + typeUrl: "/types.MsgDeposit", + value: { + coins: [{ amount: "1800000000", asset: { chain: "THOR", symbol: "RUNE", synth: false, ticker: "RUNE" } }], + memo: "=:b:bc1qeemjtfyru0gn9gcu3zu066zjrtun7yjuy2tfe4:10359:-_/nc:15/0", + signer: "nlf3C5LoXHB9Z3muFI3zB1i6lq8=", + }, + }, + ], + }, + Chain.THORChain, + ); + + expect(params.from).toBe("thor1netlwzujapw8qlt80xhpfr0nqavt49407zwr5f"); + }); + + test("converts base64 Maya deposit signer bytes to bech32", () => { + const params = convertThorchainTransactionToCtrlParams( + { + fee: { gas: "500000000" }, + memo: "=:b:bc1qeemjtfyru0gn9gcu3zu066zjrtun7yjuy2tfe4:10359:-_/nc:15/0", + msgs: [ + { + typeUrl: "/types.MsgDeposit", + value: { + coins: [{ amount: "1800000000", asset: { chain: "MAYA", symbol: "CACAO", ticker: "CACAO" } }], + memo: "=:b:bc1qeemjtfyru0gn9gcu3zu066zjrtun7yjuy2tfe4:10359:-_/nc:15/0", + signer: "nlf3C5LoXHB9Z3muFI3zB1i6lq8=", + }, + }, + ], + }, + Chain.Maya, + ); + + expect(params).toEqual({ + amount: { amount: 1800000000, decimals: 8 }, + asset: { chain: "MAYA", symbol: "CACAO", ticker: "CACAO" }, + from: "maya1netlwzujapw8qlt80xhpfr0nqavt494074s0ze", + gasLimit: "500000000", + memo: "=:b:bc1qeemjtfyru0gn9gcu3zu066zjrtun7yjuy2tfe4:10359:-_/nc:15/0", + recipient: "", + }); + }); + + test("converts THORChain transfer transactions for CTRL", () => { + const params = convertThorchainTransactionToCtrlParams( + { + fee: { gas: "500000000" }, + memo: "memo", + msgs: [ + { + typeUrl: "/types.MsgSend", + value: { + amount: [{ amount: "200000000", denom: "rune" }], + fromAddress: "thor1sender", + toAddress: "thor1recipient", + }, + }, + ], + }, + Chain.THORChain, + ); + + expect(params).toEqual({ + amount: { amount: 200000000, decimals: 8 }, + asset: { chain: "THOR", symbol: "RUNE", ticker: "RUNE" }, + from: "thor1sender", + gasLimit: "500000000", + memo: "memo", + recipient: "thor1recipient", + }); + }); + + test("submits deposit transactions through the CTRL THORChain provider", async () => { + const requests: unknown[] = []; + + // @ts-expect-error test window shim + globalThis.window = { + ctrl: { + thorchain: { + request: (request: unknown, cb: (err: unknown, result: unknown) => void) => { + requests.push(request); + if ((request as { method?: string }).method === "request_accounts") { + cb(null, ["thor1sender"]); + return; + } + cb(null, "0xhash"); + }, + }, + }, + }; + + await expect( + signCtrlThorchainTransaction( + { + 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", + }, + }, + ], + }, + Chain.THORChain, + ), + ).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 Maya deposits through the documented xfi mayachain provider", async () => { + const requests: unknown[] = []; + + // @ts-expect-error test window shim + globalThis.window = { + xfi: { + mayachain: { + request: (request: unknown) => { + requests.push(request); + if ((request as { method?: string }).method === "request_accounts") { + return Promise.resolve(["maya1sender"]); + } + return Promise.resolve("0xmaya"); + }, + }, + }, + }; + + await expect( + signCtrlThorchainTransaction( + { + fee: { gas: "500000000" }, + memo: "=:BTC.BTC:bc1qrecipient", + msgs: [ + { + typeUrl: "/types.MsgDeposit", + value: { + coins: [{ amount: "1234567890", asset: { chain: "MAYA", symbol: "CACAO", ticker: "CACAO" } }], + memo: "=:BTC.BTC:bc1qrecipient", + signer: "maya1sender", + }, + }, + ], + }, + Chain.Maya, + ), + ).resolves.toBe("0xmaya"); + + expect(requests).toEqual([ + { method: "request_accounts", params: [] }, + { + method: "deposit", + params: [ + { + amount: { amount: 1234567890, decimals: 8 }, + asset: { chain: "MAYA", symbol: "CACAO", ticker: "CACAO" }, + from: "maya1sender", + gasLimit: "500000000", + memo: "=:BTC.BTC:bc1qrecipient", + recipient: "", + }, + ], + }, + ]); + }); + + test("marks CTRL Maya direct signing as supported", () => { + expect(ctrlWallet.connectCtrl.directSigningSupport[Chain.Maya]).toBe(true); + }); +}); diff --git a/packages/wallet-hardware/src/ledger/clients/thorchain/index.ts b/packages/wallet-hardware/src/ledger/clients/thorchain/index.ts index 1c85416..ef5f64e 100644 --- a/packages/wallet-hardware/src/ledger/clients/thorchain/index.ts +++ b/packages/wallet-hardware/src/ledger/clients/thorchain/index.ts @@ -1,3 +1,4 @@ +import type { AccountData, AminoSignResponse, StdSignDoc } from "@cosmjs/amino"; import type Transport from "@ledgerhq/hw-transport"; import { base64 } from "@scure/base"; import { type DerivationPathArray, NetworkDerivationPath, SwapKitError } from "@swapkit/helpers"; @@ -74,6 +75,42 @@ export class THORChainLedger extends CosmosLedgerInterface { ]; }; + signAmino = async (signerAddress: string, signDoc: StdSignDoc): Promise => { + await this.checkOrCreateTransportAndLedger(true); + + const account = (await this.getAccounts()).find((item) => item.address === signerAddress); + if (!account) { + throw new SwapKitError("wallet_ledger_address_not_found", { address: signerAddress }); + } + + const importedAmino = await import("@cosmjs/amino"); + const encodeSecp256k1Signature = + importedAmino.encodeSecp256k1Signature ?? importedAmino.default?.encodeSecp256k1Signature; + const serializeSignDoc = importedAmino.serializeSignDoc ?? importedAmino.default?.serializeSignDoc; + + const { return_code, error_message, signature } = await this.ledgerApp.sign( + this.derivationPath, + serializeSignDoc(signDoc), + ); + + this.validateResponse(return_code, error_message); + + return { + signature: encodeSecp256k1Signature(account.pubkey, base64.decode(getSignature(signature))), + signed: signDoc, + }; + }; + + getAccounts = async (): Promise => { + await this.checkOrCreateTransportAndLedger(true); + + const { bech32_address, compressed_pk }: GetAddressAndPubKeyResponse = await this.getAddressAndPubKey(); + + this.pubKey = base64.encode(compressed_pk); + + return [{ address: bech32_address, algo: "secp256k1", pubkey: compressed_pk }]; + }; + sign = async (message: string) => { await this.checkOrCreateTransportAndLedger(true); diff --git a/packages/wallet-hardware/src/ledger/index.ts b/packages/wallet-hardware/src/ledger/index.ts index 1b53415..165684c 100644 --- a/packages/wallet-hardware/src/ledger/index.ts +++ b/packages/wallet-hardware/src/ledger/index.ts @@ -10,11 +10,9 @@ import { getRPCUrl, NetworkDerivationPath, SwapKitError, - THORConfig, type UTXOChain, WalletOption, } from "@swapkit/helpers"; -import type { ThorchainDepositParams } from "@swapkit/toolboxes/cosmos"; import { addInputsAndOutputs, assertDerivationIndex, @@ -80,10 +78,10 @@ export const ledgerWallet = createWallet({ [Chain.Polygon]: true, [Chain.Ripple]: true, [Chain.Sui]: true, + [Chain.THORChain]: true, [Chain.Tron]: true, [Chain.XLayer]: true, // ZEC: still on bespoke signPCZT path - // THORChain: needs signAmino added to THORChainLedger (V3 plan PR) }, name: "connectLedger", supportedChains: [ @@ -126,32 +124,6 @@ function reduceMemo(memo?: string, affiliateAddress = "t") { return removedAffiliate?.substring(0, removedAffiliate.lastIndexOf(":")); } -function recursivelyOrderKeys(unordered: any) { - // If it's an array - recursively order any - // dictionary items within the array - if (Array.isArray(unordered)) { - unordered.forEach((item, index) => { - unordered[index] = recursivelyOrderKeys(item); - }); - return unordered; - } - - // If it's an object - let's order the keys - if (typeof unordered !== "object") return unordered; - const ordered: any = {}; - const sortedKeys = Object.keys(unordered).sort(); - - for (const key of sortedKeys) { - ordered[key] = recursivelyOrderKeys(unordered[key]); - } - - return ordered; -} - -function stringifyKeysInOrder(data: any) { - return JSON.stringify(recursivelyOrderKeys(data)); -} - async function getWalletMethods({ chain, derivationPath, @@ -444,85 +416,13 @@ async function getWalletMethods({ } case Chain.THORChain: { - const { SignMode } = await import("cosmjs-types/cosmos/tx/signing/v1beta1/signing.js"); - const { TxRaw } = await import("cosmjs-types/cosmos/tx/v1beta1/tx.js"); - const importedSigning = await import("@cosmjs/proto-signing"); - const encodePubkey = importedSigning.encodePubkey ?? importedSigning.default?.encodePubkey; - const makeAuthInfoBytes = importedSigning.makeAuthInfoBytes ?? importedSigning.default?.makeAuthInfoBytes; - const { - createStargateClient, - buildEncodedTxBody, - getCosmosToolbox, - buildAminoMsg, - getDefaultChainFee, - fromBase64, - parseAminoMessageForDirectSigning, - } = await import("@swapkit/toolboxes/cosmos"); - const toolbox = getCosmosToolbox(chain); const signer = await getLedgerClient({ chain, derivationPath, transport }); + const { getCosmosToolbox } = await import("@swapkit/toolboxes/cosmos"); + const toolbox = getCosmosToolbox(chain, { signer }); const address = await getLedgerAddress({ chain, ledgerClient: signer }); + const { sign: signMessage } = signer; - const fee = getDefaultChainFee(chain); - const { pubkey: value, signTransaction, sign: signMessage } = signer; - - // ANCHOR (@Chillios): Same parts in methods + can extract StargateClient init to toolbox - const thorchainTransfer = async ({ - memo = "", - assetValue, - ...rest - }: GenericTransferParams | ThorchainDepositParams) => { - const account = await toolbox.getAccount(address); - if (!account) throw new SwapKitError("wallet_ledger_invalid_account"); - if (!assetValue) throw new SwapKitError("wallet_ledger_invalid_asset"); - if (!value) throw new SwapKitError("wallet_ledger_pubkey_not_found"); - - const { accountNumber, sequence: sequenceNumber } = account; - const sequence = (sequenceNumber || 0).toString(); - - const orderedMessages = recursivelyOrderKeys([buildAminoMsg({ assetValue, memo, sender: address, ...rest })]); - - // get tx signing msg - const rawSendTx = stringifyKeysInOrder({ - account_number: accountNumber?.toString(), - chain_id: THORConfig.chainId, - fee, - memo, - msgs: orderedMessages, - sequence, - }); - - const signatures = await signTransaction(rawSendTx, sequence); - if (!signatures) throw new SwapKitError("wallet_ledger_signing_error"); - - const pubkey = encodePubkey({ type: "tendermint/PubKeySecp256k1", value }); - const msgs = orderedMessages.map(parseAminoMessageForDirectSigning); - const bodyBytes = await buildEncodedTxBody({ chain, memo, msgs }); - - const authInfoBytes = makeAuthInfoBytes( - [{ pubkey, sequence: Number(sequence) }], - fee.amount, - Number.parseInt(fee.gas, 10), - undefined, - undefined, - SignMode.SIGN_MODE_LEGACY_AMINO_JSON, - ); - - const signature = signatures?.[0]?.signature ? fromBase64(signatures[0].signature) : Uint8Array.from([]); - - const txRaw = TxRaw.fromPartial({ authInfoBytes, bodyBytes, signatures: [signature] }); - const txBytes = TxRaw.encode(txRaw).finish(); - const rpcUrl = await getRPCUrl(Chain.THORChain); - - const broadcaster = await createStargateClient(rpcUrl); - const { transactionHash } = await broadcaster.broadcastTx(txBytes); - - return transactionHash; - }; - - const transfer = (params: GenericTransferParams) => thorchainTransfer(params); - const deposit = (params: ThorchainDepositParams) => thorchainTransfer(params); - - return { ...toolbox, address, deposit, signMessage, transfer }; + return { ...toolbox, address, signMessage }; } case Chain.Near: {