Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 85 additions & 19 deletions packages/widget/src/providers/misc/solana-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});
} else {
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,
Expand All @@ -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,
Expand Down
131 changes: 131 additions & 0 deletions packages/widget/tests/use-cases/solana-connector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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<typeof vi.fn>;
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<string>;
};
};

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).toHaveLength(1);
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
);
});
});
Loading