diff --git a/.changeset/solana-transaction-encoding.md b/.changeset/solana-transaction-encoding.md new file mode 100644 index 000000000..d3f23994d --- /dev/null +++ b/.changeset/solana-transaction-encoding.md @@ -0,0 +1,5 @@ +--- +"@crossmint/wallets-sdk": minor +--- + +Solana `serializedTransaction` values now accept base58 or base64 encoding with automatic detection. Solana web3.js transaction types are re-exported, and the `@solana/web3.js` dependency range is relaxed to reduce duplicate-copy type clashes. diff --git a/packages/wallets/README.md b/packages/wallets/README.md index 3dbd450c4..80eada473 100644 --- a/packages/wallets/README.md +++ b/packages/wallets/README.md @@ -203,7 +203,7 @@ const client = evmWallet.getViemClient(); // Solana const solWallet = SolanaWallet.from(wallet); const solTx = await solWallet.sendTransaction({ - serializedTransaction: "", + serializedTransaction: "", }); // Stellar diff --git a/packages/wallets/package.json b/packages/wallets/package.json index f64e6d204..fe7efc2f7 100644 --- a/packages/wallets/package.json +++ b/packages/wallets/package.json @@ -31,7 +31,7 @@ "@crossmint/common-sdk-auth": "workspace:*", "@crossmint/common-sdk-base": "workspace:*", "@hey-api/client-fetch": "0.8.1", - "@solana/web3.js": "1.98.1", + "@solana/web3.js": "^1.98.1", "abitype": "1.0.8", "base32.js": "0.1.0", "bs58": "5.0.0", diff --git a/packages/wallets/src/index.ts b/packages/wallets/src/index.ts index e0739cfe3..f41b348c5 100644 --- a/packages/wallets/src/index.ts +++ b/packages/wallets/src/index.ts @@ -45,6 +45,12 @@ export type { RemoveSignerOptions, } from "./wallets/types"; export type { Chain, EVMChain, SolanaChain, StellarChain } from "./chains/chains"; +export type { + VersionedTransaction, + Transaction as SolanaTransaction, + Keypair as SolanaKeypair, + PublicKey as SolanaPublicKey, +} from "@solana/web3.js"; // Signer configuration types export { diff --git a/packages/wallets/src/utils/solana-transaction.test.ts b/packages/wallets/src/utils/solana-transaction.test.ts new file mode 100644 index 000000000..afe0b9182 --- /dev/null +++ b/packages/wallets/src/utils/solana-transaction.test.ts @@ -0,0 +1,34 @@ +import { MessageV0, PublicKey, VersionedTransaction } from "@solana/web3.js"; +import bs58 from "bs58"; +import { describe, expect, it } from "vitest"; +import { normalizeSolanaSerializedTransaction } from "./solana-transaction"; + +describe("normalizeSolanaSerializedTransaction", () => { + const transaction = new VersionedTransaction( + MessageV0.compile({ + payerKey: PublicKey.default, + instructions: [], + recentBlockhash: PublicKey.default.toBase58(), + }) + ); + const serializedBytes = transaction.serialize(); + const base58SerializedTransaction = bs58.encode(serializedBytes); + + it("returns a base58-encoded transaction unchanged", () => { + expect(normalizeSolanaSerializedTransaction(base58SerializedTransaction)).toBe(base58SerializedTransaction); + }); + + it("converts a base64-encoded transaction to base58", () => { + const base64SerializedTransaction = Buffer.from(serializedBytes).toString("base64"); + + expect(normalizeSolanaSerializedTransaction(base64SerializedTransaction)).toBe( + bs58.encode(transaction.serialize()) + ); + }); + + it("throws for an invalid serialized transaction", () => { + expect(() => normalizeSolanaSerializedTransaction("garbage")).toThrow( + "Value must be a base58- or base64-encoded Solana transaction" + ); + }); +}); diff --git a/packages/wallets/src/utils/solana-transaction.ts b/packages/wallets/src/utils/solana-transaction.ts new file mode 100644 index 000000000..5aa6d1adf --- /dev/null +++ b/packages/wallets/src/utils/solana-transaction.ts @@ -0,0 +1,17 @@ +import { VersionedTransaction } from "@solana/web3.js"; +import bs58 from "bs58"; + +export function normalizeSolanaSerializedTransaction(serialized: string): string { + try { + VersionedTransaction.deserialize(bs58.decode(serialized)); + return serialized; + } catch { + try { + const bytes = Buffer.from(serialized, "base64"); + VersionedTransaction.deserialize(bytes); + return bs58.encode(bytes); + } catch { + throw new Error("Value must be a base58- or base64-encoded Solana transaction"); + } + } +} diff --git a/packages/wallets/src/wallets/__tests__/test-helpers.ts b/packages/wallets/src/wallets/__tests__/test-helpers.ts index e2c612386..b4eb9f720 100644 --- a/packages/wallets/src/wallets/__tests__/test-helpers.ts +++ b/packages/wallets/src/wallets/__tests__/test-helpers.ts @@ -1,5 +1,7 @@ import { vi, type MockedFunction } from "vitest"; import { APIKeyEnvironmentPrefix } from "@crossmint/common-sdk-base"; +import { MessageV0, PublicKey, VersionedTransaction } from "@solana/web3.js"; +import bs58 from "bs58"; import { Wallet } from "../wallet"; import type { ApiClient } from "../../api"; import type { Chain } from "../../chains/chains"; @@ -110,5 +112,10 @@ export const createMockApiClient = (overrides: Partial = {}): M }); export const createMockSolanaSerializedTransaction = (): string => { - return "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAgEDBQrKxEIIPWsDwcGCzLQ7FGIHQ38p0dZq6bG2v2wUAUqMx3jV1jZ0"; + const message = MessageV0.compile({ + payerKey: PublicKey.default, + instructions: [], + recentBlockhash: PublicKey.default.toBase58(), + }); + return bs58.encode(new VersionedTransaction(message).serialize()); }; diff --git a/packages/wallets/src/wallets/solana.test.ts b/packages/wallets/src/wallets/solana.test.ts index 340a1b990..b21fa0519 100644 --- a/packages/wallets/src/wallets/solana.test.ts +++ b/packages/wallets/src/wallets/solana.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import bs58 from "bs58"; import { SolanaWallet } from "./solana"; import type { CreateTransactionSuccessResponse } from "../api"; import { TransactionNotCreatedError } from "../utils/errors"; @@ -31,7 +32,7 @@ describe("SolanaWallet - sendTransaction()", () => { }); describe("success cases", () => { - it("sends transaction with serialized transaction string", async () => { + it("sends transaction with a base58 serialized transaction string", async () => { const serializedTx = createMockSolanaSerializedTransaction(); const mockTransactionResponse = { @@ -75,9 +76,9 @@ describe("SolanaWallet - sendTransaction()", () => { ); }); - it("sends transaction with serialized transaction string", async () => { - const serializedTx = - "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAgEDBQrKxEIIPWsDwcGCzLQ7FGIHQ38p0dZq6bG2v2wUAUqMx3jV1jZ0"; + it("converts a base64 serialized transaction to base58", async () => { + const base58SerializedTx = createMockSolanaSerializedTransaction(); + const serializedTx = Buffer.from(bs58.decode(base58SerializedTx)).toString("base64"); const mockTransactionResponse = { id: "txn-sol-456", @@ -90,7 +91,7 @@ describe("SolanaWallet - sendTransaction()", () => { "https://explorer.solana.com/tx/5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW", }, params: { - transaction: serializedTx, + transaction: base58SerializedTx, signer: "api-key:test", }, createdAt: Date.now(), @@ -113,7 +114,7 @@ describe("SolanaWallet - sendTransaction()", () => { "me:solana:smart", expect.objectContaining({ params: expect.objectContaining({ - transaction: serializedTx, + transaction: base58SerializedTx, signer: "api-key", }), }) diff --git a/packages/wallets/src/wallets/solana.ts b/packages/wallets/src/wallets/solana.ts index c6a45cd89..a6397ce60 100644 --- a/packages/wallets/src/wallets/solana.ts +++ b/packages/wallets/src/wallets/solana.ts @@ -13,6 +13,7 @@ import { TransactionNotCreatedError } from "../utils/errors"; import { SolanaExternalWalletSigner } from "@/signers/solana-external-wallet"; import type { CreateTransactionSuccessResponse } from "@/api"; import { walletsLogger } from "../logger"; +import { normalizeSolanaSerializedTransaction } from "../utils/solana-transaction"; export class SolanaWallet extends Wallet { constructor(wallet: Wallet) { @@ -43,7 +44,7 @@ export class SolanaWallet extends Wallet { /** * Send a raw Solana transaction. - * @param params - The transaction parameters (serialized transaction or Transaction object) + * @param params - The transaction parameters. Serialized transactions may be base58- or base64-encoded. * @returns The transaction result */ @WithLoggerContext({ @@ -110,7 +111,7 @@ export class SolanaWallet extends Wallet { let serializedTransaction: string; if ("serializedTransaction" in params) { - serializedTransaction = params.serializedTransaction; + serializedTransaction = normalizeSolanaSerializedTransaction(params.serializedTransaction); } else { serializedTransaction = bs58.encode(params.transaction.serialize()); } diff --git a/packages/wallets/src/wallets/types.ts b/packages/wallets/src/wallets/types.ts index 9bd7d83bd..3b36d1ce6 100644 --- a/packages/wallets/src/wallets/types.ts +++ b/packages/wallets/src/wallets/types.ts @@ -112,6 +112,7 @@ export type SolanaTransactionInput = ( transaction: VersionedTransaction; } | { + /** A base58- or base64-encoded serialized Solana transaction. */ serializedTransaction: string; } ) & { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cecd8ed38..1179eb505 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1061,7 +1061,7 @@ importers: specifier: 1.8.0 version: 1.8.0 '@solana/web3.js': - specifier: 1.98.1 + specifier: ^1.98.1 version: 1.98.1(bufferutil@4.0.9)(encoding@0.1.13)(typescript@5.5.3)(utf-8-validate@5.0.10) abitype: specifier: 1.0.8