Overview
This guide shows how to sign Solana transactions using Dynamic’s Node SDK and broadcast them to the network.signTransaction returns the raw signature, not the full signed transaction.DynamicSvmWalletClient.signTransaction() returns a base58-encoded Ed25519 signature: exactly 88 characters as a string, which decodes to 64 bytes. It is not a serialized transaction — calling VersionedTransaction.deserialize() on the decoded bytes throws immediately.You must decode the signature and add it back to the original transaction before broadcasting. See Step 3 below.Prerequisites
- Created a Solana wallet
- Installed
@solana/web3.js
npm install @solana/web3.js
Step 1: Set Up the Client
import { DynamicSvmWalletClient } from '@dynamic-labs-wallet/node-svm';
const client = new DynamicSvmWalletClient({
environmentId: process.env.DYNAMIC_ENVIRONMENT_ID!,
});
await client.authenticateApiToken(process.env.DYNAMIC_AUTH_TOKEN!);
Step 2: Build the Transaction
import {
Connection,
PublicKey,
Transaction,
SystemProgram,
LAMPORTS_PER_SOL,
} from '@solana/web3.js';
const connection = new Connection(
process.env.SOLANA_RPC_URL ?? 'https://api.mainnet-beta.solana.com',
'confirmed'
);
const { blockhash } = await connection.getLatestBlockhash();
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: new PublicKey(walletMetadata.accountAddress),
toPubkey: new PublicKey('RecipientAddress'),
lamports: LAMPORTS_PER_SOL * 0.001,
})
);
transaction.recentBlockhash = blockhash;
transaction.feePayer = new PublicKey(walletMetadata.accountAddress);
Step 3: Sign and Broadcast
signTransaction returns the 64-byte Ed25519 signature as a base58 string. Add it to the transaction before sending.
Legacy Transaction
import { decodeBase58, addSignatureToTransaction } from '@dynamic-labs-wallet/node-svm';
import { PublicKey } from '@solana/web3.js';
// 1. Sign — returns base58 signature (88 chars), NOT the full signed tx
const signatureBase58 = await client.signTransaction({
walletMetadata,
transaction,
});
// 2. Decode signature from base58 → 64 bytes
const signatureBytes = decodeBase58(signatureBase58);
// 3. Add signature to the transaction
const signedTx = addSignatureToTransaction({
transaction,
signature: signatureBytes,
signerPublicKey: new PublicKey(walletMetadata.accountAddress),
});
// 4. Broadcast
const txid = await connection.sendRawTransaction(signedTx.serialize());
await connection.confirmTransaction(txid, 'confirmed');
console.log('Transaction confirmed:', txid);
VersionedTransaction (e.g. from Checkout API)
If you receive a pre-builtVersionedTransaction — for example the base64-encoded serializedTransaction from the Checkout API /prepare endpoint — decode it first:
import { VersionedTransaction } from '@solana/web3.js';
import { decodeBase58, addSignatureToTransaction } from '@dynamic-labs-wallet/node-svm';
// Decode base64 → VersionedTransaction
const vtx = VersionedTransaction.deserialize(
Buffer.from(payload.serializedTransaction, 'base64')
);
// Sign — returns base58 signature
const signatureBase58 = await client.signTransaction({
walletMetadata,
transaction: vtx,
});
// Decode, add signature, broadcast
const sigBytes = decodeBase58(signatureBase58);
const signedVtx = addSignatureToTransaction({
transaction: vtx,
signature: sigBytes,
signerPublicKey: new PublicKey(walletMetadata.accountAddress),
});
const { lastValidBlockHeight } = await connection.getLatestBlockhash();
const txid = await connection.sendRawTransaction(signedVtx.serialize(), {
skipPreflight: true, // recommended for pre-built txs with oracle dependencies
});
await connection.confirmTransaction(
{ signature: txid, blockhash: vtx.message.recentBlockhash, lastValidBlockHeight },
'confirmed'
);
Why
skipPreflight: true for VersionedTransaction?Pre-built transactions from swap/bridge protocols may include oracle update instructions that simulation rejects with errors like “PythOracleOutdated” — even though the transaction would succeed on validators. Use skipPreflight: true to bypass local simulation for these cases.Do not pass
signTransaction() output directly to connection.sendRawTransaction() or the SDK’s sendTransaction() export.signTransaction() returns a base58 string. Calling Buffer.from(base58string) interprets it as UTF-8 (not decoded bytes), producing a garbled payload that fails with “failed to deserialize VersionedTransaction”. Always follow the decode → add signature → serialize → sendRawTransaction pattern.Manual Backup (externalServerKeyShares)
If the wallet was created withbackUpToDynamic: false, supply key shares explicitly:
const keyShares = await vault.read(`wallet:${walletMetadata.accountAddress}/shares`);
const signatureBase58 = await client.signTransaction({
walletMetadata,
externalServerKeyShares: keyShares,
transaction,
password: 'user-password', // only if wallet is password-protected
});
Complete Example: Send SOL
import { DynamicSvmWalletClient, decodeBase58, addSignatureToTransaction } from '@dynamic-labs-wallet/node-svm';
import {
Connection, PublicKey, Transaction, SystemProgram, LAMPORTS_PER_SOL,
} from '@solana/web3.js';
async function sendSol({
walletMetadata,
toAddress,
amountSol,
}: {
walletMetadata: WalletMetadata;
toAddress: string;
amountSol: number;
}) {
const client = new DynamicSvmWalletClient({
environmentId: process.env.DYNAMIC_ENVIRONMENT_ID!,
});
await client.authenticateApiToken(process.env.DYNAMIC_AUTH_TOKEN!);
const connection = new Connection(
process.env.SOLANA_RPC_URL ?? 'https://api.mainnet-beta.solana.com',
'confirmed'
);
const { blockhash } = await connection.getLatestBlockhash();
const transaction = new Transaction().add(
SystemProgram.transfer({
fromPubkey: new PublicKey(walletMetadata.accountAddress),
toPubkey: new PublicKey(toAddress),
lamports: LAMPORTS_PER_SOL * amountSol,
})
);
transaction.recentBlockhash = blockhash;
transaction.feePayer = new PublicKey(walletMetadata.accountAddress);
const signatureBase58 = await client.signTransaction({ walletMetadata, transaction });
const signatureBytes = decodeBase58(signatureBase58);
const signedTx = addSignatureToTransaction({
transaction,
signature: signatureBytes,
signerPublicKey: new PublicKey(walletMetadata.accountAddress),
});
const txid = await connection.sendRawTransaction(signedTx.serialize());
await connection.confirmTransaction(txid, 'confirmed');
return txid;
}