diff --git a/packages/widget/src/domain/types/transaction.ts b/packages/widget/src/domain/types/transaction.ts index 3436050f..02439c76 100644 --- a/packages/widget/src/domain/types/transaction.ts +++ b/packages/widget/src/domain/types/transaction.ts @@ -121,6 +121,52 @@ export type DecodedSolanaTransaction = GetType< typeof unsignedSolanaTransactionCodec >; +type SolanaTransactionEncoding = "base64" | "hex"; + +type SolanaTransactionBytes = { + encoding: SolanaTransactionEncoding; + buffer: Buffer; +}; + +const solanaHexStringRegex = /^[0-9a-fA-F]+$/u; + +const stripSolanaHexPrefix = (tx: string) => + tx.startsWith("0x") ? tx.slice(2) : tx; + +const isSolanaHexTransaction = (tx: DecodedSolanaTransaction): boolean => { + const withoutHexPrefix = stripSolanaHexPrefix(tx.trim()); + + return ( + withoutHexPrefix.length > 0 && + withoutHexPrefix.length % 2 === 0 && + solanaHexStringRegex.test(withoutHexPrefix) + ); +}; + +export const decodeSolanaTransactionToBuffer = ( + tx: DecodedSolanaTransaction +): SolanaTransactionBytes => { + const normalizedTx = tx.trim(); + const withoutHexPrefix = stripSolanaHexPrefix(normalizedTx); + + if (isSolanaHexTransaction(normalizedTx)) { + return { + encoding: "hex", + buffer: Buffer.from(withoutHexPrefix, "hex"), + }; + } + + return { + encoding: "base64", + buffer: Buffer.from(normalizedTx, "base64"), + }; +}; + +export const normalizeSolanaTransactionToHex = ( + tx: DecodedSolanaTransaction +): DecodedSolanaTransaction => + decodeSolanaTransactionToBuffer(tx).buffer.toString("hex"); + export const unsignedTonTransactionTonConnectCodec = Codec.interface({ seqno: bigintCodec, message: string, diff --git a/packages/widget/src/providers/misc/solana-connector.ts b/packages/widget/src/providers/misc/solana-connector.ts index 94189d27..8716b094 100644 --- a/packages/widget/src/providers/misc/solana-connector.ts +++ b/packages/widget/src/providers/misc/solana-connector.ts @@ -17,6 +17,7 @@ import { createConnector } from "wagmi"; import portoIcon from "../../assets/images/porto.svg"; import { solana } from "../../domain/types/chains/misc"; import { MiscNetworks } from "../../domain/types/chains/networks"; +import { decodeSolanaTransactionToBuffer } from "../../domain/types/transaction"; import { getNetworkLogo } from "../../utils"; import type { VariantProps } from "../settings/types"; import { @@ -25,88 +26,31 @@ import { type StorageItem, } from "./solana-connector-meta"; -type DecodingCandidate = { - encoding: "base64" | "hex"; - buffer: Buffer; -}; - -const isHexString = (value: string): boolean => - /^[0-9a-fA-F]+$/.test(value) && value.length % 2 === 0; - -export const getSolanaTxDecodingCandidates = ( +export const deserializeSolanaTransaction = ( tx: string -): DecodingCandidate[] => { - const normalizedTx = tx.trim(); - const withoutHexPrefix = normalizedTx.startsWith("0x") - ? normalizedTx.slice(2) - : normalizedTx; - - const candidates: DecodingCandidate[] = []; - if (isHexString(withoutHexPrefix)) { - candidates.push({ - encoding: "hex", - buffer: Buffer.from(withoutHexPrefix, "hex"), - }); - } else { - candidates.push({ - encoding: "base64", - buffer: Buffer.from(normalizedTx, "base64"), - }); - } - - return candidates; -}; - -const deserializeCandidate = (candidate: DecodingCandidate) => { +): Transaction | VersionedTransaction => { + const decodedTx = decodeSolanaTransactionToBuffer(tx); let versionedError: unknown; + try { - return { - tx: VersionedTransaction.deserialize(candidate.buffer), - error: null, - }; + return VersionedTransaction.deserialize(decodedTx.buffer); } catch (error) { versionedError = error; } try { - return { - tx: Transaction.from(candidate.buffer), - error: null, - }; + return Transaction.from(decodedTx.buffer); } catch (legacyError) { - return { - tx: null, - error: `encoding=${candidate.encoding} bufferLength=${candidate.buffer.length} VersionedTransaction error: ${ + throw new Error( + `Failed to deserialize Solana transaction. encoding=${decodedTx.encoding} bufferLength=${decodedTx.buffer.length} VersionedTransaction error: ${ versionedError instanceof Error ? versionedError.message : String(versionedError) }. Legacy Transaction error: ${ legacyError instanceof Error ? legacyError.message : String(legacyError) - }`, - }; - } -}; - -export const deserializeSolanaTransaction = ( - tx: string -): Transaction | VersionedTransaction => { - const candidates = getSolanaTxDecodingCandidates(tx); - const attemptErrors: string[] = []; - - for (const candidate of candidates) { - const deserialized = deserializeCandidate(candidate); - if (deserialized.tx) { - return deserialized.tx; - } - - if (deserialized.error) { - attemptErrors.push(deserialized.error); - } + }` + ); } - - throw new Error( - `Failed to deserialize Solana transaction. Tried ${attemptErrors.length} candidate(s). ${attemptErrors.join(" | ")}` - ); }; const createSolanaConnector = ({ diff --git a/packages/widget/src/providers/sk-wallet/index.tsx b/packages/widget/src/providers/sk-wallet/index.tsx index 896f5841..ce9788a6 100644 --- a/packages/widget/src/providers/sk-wallet/index.tsx +++ b/packages/widget/src/providers/sk-wallet/index.tsx @@ -28,6 +28,7 @@ import { import { ExternalProviderError } from "../../domain/types/external-providers"; import { decodeAndPrepareEvmTransaction, + normalizeSolanaTransactionToHex, substratePayloadCodec, unsignedEVMTransactionCodec, unsignedSolanaTransactionCodec, @@ -299,7 +300,10 @@ export const SKWalletProvider = ({ children }: PropsWithChildren) => { if (isSolanaChain(network)) { return unsignedSolanaTransactionCodec .decode(tx) - .map((v) => ({ type: "solana", tx: v })); + .map((v) => ({ + type: "solana", + tx: normalizeSolanaTransactionToHex(v), + })); } if (isTonChain(network)) { diff --git a/packages/widget/tests/use-cases/sk-wallet.test.tsx b/packages/widget/tests/use-cases/sk-wallet.test.tsx index 891fbd23..d2b0711a 100644 --- a/packages/widget/tests/use-cases/sk-wallet.test.tsx +++ b/packages/widget/tests/use-cases/sk-wallet.test.tsx @@ -2,6 +2,7 @@ import { delay, HttpResponse, http } from "msw"; import { solana, ton } from "../../src/domain/types/chains/misc"; import { MiscNetworks } from "../../src/domain/types/chains/networks"; import type { SKExternalProviders } from "../../src/domain/types/wallets"; +import type { SKTxMeta } from "../../src/domain/types/wallets/generic-wallet"; import { SKApiClientProvider } from "../../src/providers/api/api-client-provider"; import { SKQueryClientProvider } from "../../src/providers/query-client"; import { SettingsContextProvider } from "../../src/providers/settings"; @@ -39,6 +40,24 @@ const renderHookWithExternalProvider = ( ), }); +const createSolanaTxMeta = (): SKTxMeta => ({ + txId: "", + actionId: "", + actionType: "STAKE", + txType: "APPROVAL", + amount: "100", + inputToken: { + address: "", + decimals: 0, + symbol: "", + name: "", + network: "solana", + }, + structuredTransaction: null, + annotatedTransaction: null, + providersDetails: [], +}); + describe("SK Wallet", () => { it("should work with solana external provider", async ({ worker }) => { const switchChainSpy = vi.fn(async (_: number) => {}); @@ -67,24 +86,8 @@ describe("SK Wallet", () => { const solanaRes = await solanaWallet.result.current.signTransaction({ network: "solana", - tx: "12345", - txMeta: { - txId: "", - actionId: "", - actionType: "STAKE", - txType: "APPROVAL", - amount: "100", - inputToken: { - address: "", - decimals: 0, - symbol: "", - name: "", - network: "solana", - }, - structuredTransaction: null, - annotatedTransaction: null, - providersDetails: [], - }, + tx: "AQIDBA==", + txMeta: createSolanaTxMeta(), ledgerHwAppId: null, }); @@ -95,25 +98,56 @@ describe("SK Wallet", () => { expect(sendTransactionSpy).toHaveBeenCalledWith( { type: "solana", - tx: "12345", + tx: "01020304", }, + createSolanaTxMeta() + ); + }); + + it("keeps hex solana external provider transactions in hex form", async ({ + worker, + }) => { + const switchChainSpy = vi.fn(async (_: number) => {}); + const sendTransactionSpy = vi.fn(async () => "hash"); + + worker.use( + http.get(legacyApiRoute("/v1/yields/enabled/networks"), async () => { + await delay(); + return HttpResponse.json([MiscNetworks.Solana]); + }) + ); + + const solanaWallet = await renderHookWithExternalProvider({ + type: "generic", + currentAddress: "9TCnDo7Txc5bC9SnE9iKsU5CyffLfeK4nrv1BFUmxkiJ", + currentChain: solana.id, + supportedChainIds: [solana.id], + provider: { + signMessage: async () => "hash", + switchChain: switchChainSpy, + sendTransaction: sendTransactionSpy, + }, + }); + + await expect.poll(() => solanaWallet.result.current.isConnected).toBe(true); + + const solanaRes = await solanaWallet.result.current.signTransaction({ + network: "solana", + tx: "0xA1B2", + txMeta: createSolanaTxMeta(), + ledgerHwAppId: null, + }); + + expect(solanaRes.extract()).toEqual({ + signedTx: "hash", + broadcasted: true, + }); + expect(sendTransactionSpy).toHaveBeenCalledWith( { - txId: "", - actionId: "", - actionType: "STAKE", - txType: "APPROVAL", - amount: "100", - inputToken: { - address: "", - decimals: 0, - symbol: "", - name: "", - network: "solana", - }, - structuredTransaction: null, - annotatedTransaction: null, - providersDetails: [], - } + type: "solana", + tx: "a1b2", + }, + createSolanaTxMeta() ); }); diff --git a/packages/widget/tests/use-cases/solana-connector.test.ts b/packages/widget/tests/use-cases/solana-connector.test.ts index ae60e45d..0c9e7c61 100644 --- a/packages/widget/tests/use-cases/solana-connector.test.ts +++ b/packages/widget/tests/use-cases/solana-connector.test.ts @@ -5,10 +5,10 @@ import { VersionedTransaction, } from "@solana/web3.js"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { decodeSolanaTransactionToBuffer } from "../../src/domain/types/transaction"; import { deserializeSolanaTransaction, getSolanaConnectors, - getSolanaTxDecodingCandidates, } from "../../src/providers/misc/solana-connector"; const createConnectorForTest = ({ @@ -56,28 +56,27 @@ describe("solana connector", () => { it("decodes padded base64 payloads before deserializing", () => { const bytes = Buffer.from([1, 2, 3, 4]); - const candidates = getSolanaTxDecodingCandidates(bytes.toString("base64")); + const decodedTx = decodeSolanaTransactionToBuffer(bytes.toString("base64")); - expect(candidates[0]?.encoding).toBe("base64"); - expect(candidates[0]?.buffer.equals(bytes)).toBe(true); + expect(decodedTx.encoding).toBe("base64"); + expect(decodedTx.buffer.equals(bytes)).toBe(true); }); it("decodes unpadded base64 payloads before deserializing", () => { const bytes = Buffer.from([1, 2, 3, 4]); const unpaddedBase64 = bytes.toString("base64").replace(/=+$/u, ""); - const candidates = getSolanaTxDecodingCandidates(unpaddedBase64); + const decodedTx = decodeSolanaTransactionToBuffer(unpaddedBase64); - expect(candidates[0]?.encoding).toBe("base64"); - expect(candidates[0]?.buffer.equals(bytes)).toBe(true); + expect(decodedTx.encoding).toBe("base64"); + expect(decodedTx.buffer.equals(bytes)).toBe(true); }); it("supports hex payloads with 0x prefix", () => { - const candidates = getSolanaTxDecodingCandidates("0x01020304"); + const decodedTx = decodeSolanaTransactionToBuffer("0x01020304"); - expect(candidates).toHaveLength(1); - expect(candidates[0]?.encoding).toBe("hex"); - expect(candidates[0]?.buffer.equals(Buffer.from([1, 2, 3, 4]))).toBe(true); + expect(decodedTx.encoding).toBe("hex"); + expect(decodedTx.buffer.equals(Buffer.from([1, 2, 3, 4]))).toBe(true); }); it("returns helpful error for invalid payloads", () => {