From 496d6eaf5b9614de03e67e60bc95e1432d026a1a Mon Sep 17 00:00:00 2001 From: jdomingos Date: Tue, 19 May 2026 15:39:57 +0100 Subject: [PATCH 1/2] chore: make solana tx building work --- .../src/providers/misc/solana-connector.ts | 104 +++++++++++--- .../tests/use-cases/solana-connector.test.ts | 130 ++++++++++++++++++ 2 files changed, 215 insertions(+), 19 deletions(-) create mode 100644 packages/widget/tests/use-cases/solana-connector.test.ts diff --git a/packages/widget/src/providers/misc/solana-connector.ts b/packages/widget/src/providers/misc/solana-connector.ts index 4a9982ec..199d1230 100644 --- a/packages/widget/src/providers/misc/solana-connector.ts +++ b/packages/widget/src/providers/misc/solana-connector.ts @@ -25,6 +25,90 @@ 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 = ( + 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"), + }); + } + + candidates.push({ + encoding: "base64", + buffer: Buffer.from(normalizedTx, "base64"), + }); + + return candidates; +}; + +const deserializeCandidate = (candidate: DecodingCandidate) => { + let versionedError: unknown; + try { + return { + tx: VersionedTransaction.deserialize(candidate.buffer), + error: null, + }; + } catch (error) { + versionedError = error; + } + + try { + return { + tx: Transaction.from(candidate.buffer), + error: null, + }; + } catch (legacyError) { + return { + tx: null, + error: `encoding=${candidate.encoding} bufferLength=${candidate.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 = ({ solanaWallet, walletDetailsParams, @@ -42,25 +126,7 @@ const createSolanaConnector = ({ type: solanaWallet.adapter.name, showQrModal: false, sendTransaction: async (tx) => { - const base64Decoded = Buffer.from(tx, "base64"); - const isBase64 = base64Decoded.toString("base64") === tx; - - const buffer = isBase64 ? base64Decoded : Buffer.from(tx, "hex"); - - let solanaTx: Transaction | VersionedTransaction; - let versionedError: unknown; - try { - solanaTx = VersionedTransaction.deserialize(buffer); - } catch (err) { - versionedError = err; - try { - solanaTx = Transaction.from(buffer); - } catch (legacyErr) { - throw new Error( - `Failed to deserialize Solana transaction. VersionedTransaction error: ${versionedError instanceof Error ? versionedError.message : String(versionedError)}. Legacy Transaction error: ${legacyErr instanceof Error ? legacyErr.message : String(legacyErr)}` - ); - } - } + const solanaTx = deserializeSolanaTransaction(tx); const signed = await solanaWallet.adapter.sendTransaction( solanaTx, diff --git a/packages/widget/tests/use-cases/solana-connector.test.ts b/packages/widget/tests/use-cases/solana-connector.test.ts new file mode 100644 index 00000000..1360a71c --- /dev/null +++ b/packages/widget/tests/use-cases/solana-connector.test.ts @@ -0,0 +1,130 @@ +import type { Wallet } from "@solana/wallet-adapter-react"; +import { + type Connection, + Transaction, + VersionedTransaction, +} from "@solana/web3.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + deserializeSolanaTransaction, + getSolanaConnectors, + getSolanaTxDecodingCandidates, +} from "../../src/providers/misc/solana-connector"; + +const createConnectorForTest = ({ + sendTransaction = vi.fn(async () => "signed-hash"), + connection = {} as Connection, +}: { + sendTransaction?: ReturnType; + connection?: Connection; +}) => { + const wallet = { + adapter: { + name: "Mock Solana", + icon: "", + connected: false, + publicKey: null, + readyState: "Installed", + connect: vi.fn(), + disconnect: vi.fn(), + sendTransaction, + }, + } as unknown as Wallet; + + const walletFactory = getSolanaConnectors({ + wallets: [wallet], + forceWalletConnectOnly: false, + connection, + variant: "default", + }).wallets[0]; + + const connectorFactory = walletFactory({} as never).createConnector( + {} as never + ); + return connectorFactory({ + emitter: { emit: vi.fn() }, + storage: undefined, + } as never) as unknown as { + sendTransaction: (tx: string) => Promise; + }; +}; + +describe("solana connector", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("decodes padded base64 payloads before deserializing", () => { + const bytes = Buffer.from([1, 2, 3, 4]); + const candidates = getSolanaTxDecodingCandidates(bytes.toString("base64")); + + expect(candidates[0]?.encoding).toBe("base64"); + expect(candidates[0]?.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); + + expect(candidates[0]?.encoding).toBe("base64"); + expect(candidates[0]?.buffer.equals(bytes)).toBe(true); + }); + + it("supports hex payloads with 0x prefix", () => { + const candidates = getSolanaTxDecodingCandidates("0x01020304"); + + expect(candidates[0]?.encoding).toBe("hex"); + expect(candidates[0]?.buffer.equals(Buffer.from([1, 2, 3, 4]))).toBe(true); + }); + + it("returns helpful error for invalid payloads", () => { + const versionedSpy = vi + .spyOn(VersionedTransaction, "deserialize") + .mockImplementation(() => { + throw new Error("versioned fail"); + }); + + const legacySpy = vi.spyOn(Transaction, "from").mockImplementation(() => { + throw new Error("legacy fail"); + }); + + try { + deserializeSolanaTransaction("not-a-valid-tx"); + throw new Error("expected deserializeSolanaTransaction to throw"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain("Failed to deserialize Solana transaction"); + expect(message).toContain("encoding=base64"); + expect(message).toContain("VersionedTransaction error: versioned fail"); + expect(message).toContain("Legacy Transaction error: legacy fail"); + } + + expect(versionedSpy).toHaveBeenCalledTimes(1); + expect(legacySpy).toHaveBeenCalledTimes(1); + }); + + it("passes deserialized transaction to wallet adapter", async () => { + const deserializedTx = { mocked: true } as unknown as VersionedTransaction; + vi.spyOn(VersionedTransaction, "deserialize").mockReturnValue( + deserializedTx + ); + + const adapterSendTransaction = vi.fn(async () => "signature"); + const connection = {} as Connection; + const connector = createConnectorForTest({ + sendTransaction: adapterSendTransaction, + connection, + }); + + await expect(connector.sendTransaction("AQIDBA==")).resolves.toBe( + "signature" + ); + expect(adapterSendTransaction).toHaveBeenCalledWith( + deserializedTx, + connection + ); + }); +}); From e6e01bcf7e5ae9ddfcaf3c2d8c01c4a3f4aafea5 Mon Sep 17 00:00:00 2001 From: jdomingos Date: Tue, 19 May 2026 15:52:28 +0100 Subject: [PATCH 2/2] chore: pr fixes --- packages/widget/src/providers/misc/solana-connector.ts | 10 +++++----- .../widget/tests/use-cases/solana-connector.test.ts | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/widget/src/providers/misc/solana-connector.ts b/packages/widget/src/providers/misc/solana-connector.ts index 199d1230..a08f8777 100644 --- a/packages/widget/src/providers/misc/solana-connector.ts +++ b/packages/widget/src/providers/misc/solana-connector.ts @@ -47,13 +47,13 @@ export const getSolanaTxDecodingCandidates = ( encoding: "hex", buffer: Buffer.from(withoutHexPrefix, "hex"), }); + } else { + candidates.push({ + encoding: "base64", + buffer: Buffer.from(normalizedTx, "base64"), + }); } - candidates.push({ - encoding: "base64", - buffer: Buffer.from(normalizedTx, "base64"), - }); - return candidates; }; diff --git a/packages/widget/tests/use-cases/solana-connector.test.ts b/packages/widget/tests/use-cases/solana-connector.test.ts index 1360a71c..ae60e45d 100644 --- a/packages/widget/tests/use-cases/solana-connector.test.ts +++ b/packages/widget/tests/use-cases/solana-connector.test.ts @@ -75,6 +75,7 @@ describe("solana connector", () => { it("supports hex payloads with 0x prefix", () => { const candidates = getSolanaTxDecodingCandidates("0x01020304"); + expect(candidates).toHaveLength(1); expect(candidates[0]?.encoding).toBe("hex"); expect(candidates[0]?.buffer.equals(Buffer.from([1, 2, 3, 4]))).toBe(true); });