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
46 changes: 46 additions & 0 deletions packages/widget/src/domain/types/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
};
Comment on lines +136 to +162

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Resolve ambiguous unprefixed base64-vs-hex payloads.

Line 152 makes any even-length hex-only string decode as hex, but those strings can also be valid base64 text. That changes the decoded bytes before both Solana deserialization and external-provider normalization. Prefer an explicit contract, e.g. require 0x for hex auto-detection, or reintroduce a base64 fallback when hex decoding cannot deserialize.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/widget/src/domain/types/transaction.ts` around lines 136 - 162, The
transaction decoding logic in decodeSolanaTransactionToBuffer and
isSolanaHexTransaction is too permissive because any even-length hex-looking
string is auto-treated as hex, which can misdecode valid base64 payloads.
Tighten the detection contract so hex is only chosen with an explicit prefix
like 0x, or add a safe fallback path that tries base64 when hex is ambiguous or
cannot be deserialized. Keep the change localized to the helpers in
transaction.ts and preserve the returned SolanaTransactionBytes encoding/buffer
shape.

};

export const normalizeSolanaTransactionToHex = (
tx: DecodedSolanaTransaction
): DecodedSolanaTransaction =>
decodeSolanaTransactionToBuffer(tx).buffer.toString("hex");

export const unsignedTonTransactionTonConnectCodec = Codec.interface({
seqno: bigintCodec,
message: string,
Expand Down
78 changes: 11 additions & 67 deletions packages/widget/src/providers/misc/solana-connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 = ({
Expand Down
6 changes: 5 additions & 1 deletion packages/widget/src/providers/sk-wallet/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { ExternalProviderError } from "../../domain/types/external-providers";
import {
decodeAndPrepareEvmTransaction,
normalizeSolanaTransactionToHex,
substratePayloadCodec,
unsignedEVMTransactionCodec,
unsignedSolanaTransactionCodec,
Expand Down Expand Up @@ -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)) {
Expand Down
104 changes: 69 additions & 35 deletions packages/widget/tests/use-cases/sk-wallet.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) => {});
Expand Down Expand Up @@ -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,
});

Expand All @@ -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()
);
});

Expand Down
21 changes: 10 additions & 11 deletions packages/widget/tests/use-cases/solana-connector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ({
Expand Down Expand Up @@ -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", () => {
Expand Down