Overview

This guide teaches you how to sign EVM transactions using Dynamic’s Node SDK. You’ll learn how to prepare transactions, sign them securely, and send them to the blockchain.

Prerequisites

Before you begin, make sure you have:

Step 1: Set Up Your Environment

First, install the required dependencies:
bun add viem

Step 2: Prepare Your Transaction

Create a transaction using viem’s transaction preparation utilities:
import { authenticatedEvmClient } from './client';
import { http } from 'viem';
import { base } from 'viem/chains';
import { parseEther } from 'viem/utils';

const evmClient = await authenticatedEvmClient();

// Create a viem public client for transaction preparation
const publicClient = evmClient.createViemPublicClient({
  chain: base,
  rpcUrl: 'https://mainnet.base.org',
});

// Define your transaction
const transactionRequest = {
  to: '0xRecipientAddress' as `0x${string}`,
  value: parseEther('0.1'), // 0.1 ETH
  // Add other transaction parameters as needed
  // data: '0x...', // For contract interactions
  // gas: 21000n, // Optional gas limit
};

// Prepare the transaction (this will estimate gas, get nonce, etc.)
const preparedTx = await publicClient.prepareTransactionRequest({
  ...transactionRequest,
  chain,
  account: '0xYourWalletAddress' as `0x${string}`,
});

console.log('Transaction prepared:', preparedTx);

Step 3: Key Share Management for Signing

The approach for signing transactions depends on how you created your wallet: If you created your wallet with backUpToClientShareService: true, you can sign directly without retrieving key shares:
// ✅ Simple signing - no externalServerKeyShares needed
const signedTx = await evmClient.signTransaction({
  senderAddress: '0xYourWalletAddress' as `0x${string}`,
  transaction: preparedTx,
  password: 'your-wallet-password', // Only if wallet was created with password
});

console.log('Transaction signed:', signedTx);

With Manual Backup

If you created your wallet with backUpToClientShareService: false, you must retrieve and provide external key shares:
// ❌ First, get external server key shares (required for manual backup)
const keyShares = await evmClient.getExternalServerKeyShares({
  accountAddress: '0xYourWalletAddress',
});

// Or retrieve your stored encrypted key shares
// const keyShares = await retrieveStoredKeyShares('0xYourWalletAddress');

console.log('Key shares retrieved:', keyShares.length);

// Sign the transaction with external key shares
const signedTx = await evmClient.signTransaction({
  senderAddress: '0xYourWalletAddress' as `0x${string}`,
  externalServerKeyShares: keyShares, // Required for manual backup!
  transaction: preparedTx,
  password: 'your-wallet-password', // Only if wallet was created with password
});

console.log('Transaction signed:', signedTx);
Password Handling Notes:
  • If your wallet was created without a password, omit the password parameter
  • If your wallet was created with a password, you must provide it for all operations
  • The password parameter is always optional in the API, but required if the wallet is password-protected

Step 4: Send the Transaction

Send the signed transaction to the network:
import { createWalletClient } from 'viem';

const walletClient = createWalletClient({
  chain: base,
  transport: http('https://mainnet.base.org'),
  account: '0xYourWalletAddress' as `0x${string}`,
});

const txHash = await walletClient.sendRawTransaction({
  serializedTransaction: signedTx,
});

console.log('Transaction hash:', txHash);
console.log('Block explorer URL:', `https://basescan.org/tx/${txHash}`);

Complete Example: Send ETH

Here’s a complete example that sends ETH from one address to another:
import { authenticatedEvmClient } from './client';
import { http, createWalletClient } from 'viem';
import { base } from 'viem/chains';
import { parseEther } from 'viem/utils';

export const sendEth = async ({
  fromAddress,
  toAddress,
  amount,
  password,
}: {
  fromAddress: string;
  toAddress: string;
  amount: string; // Amount in ETH (e.g., "0.1")
  password?: string;
}) => {
  const evmClient = await authenticatedEvmClient();

  // Get external server key shares (required for signing)
  const keyShares = await evmClient.getExternalServerKeyShares({
    accountAddress: fromAddress,
  });

  // Create public client for transaction preparation
  const publicClient = evmClient.createViemPublicClient({
    chain: base,
    rpcUrl: 'https://mainnet.base.org',
  });

  // Prepare transaction
  const preparedTx = await publicClient.prepareTransactionRequest({
    to: toAddress as `0x${string}`,
    value: parseEther(amount),
    chain: base,
    account: fromAddress as `0x${string}`,
  });

  // Sign transaction
  const signedTx = await evmClient.signTransaction({
    senderAddress: fromAddress as `0x${string}`,
    externalServerKeyShares: keyShares,
    transaction: preparedTx,
    password,
  });

  // Send transaction
  const walletClient = createWalletClient({
    chain: base,
    transport: http('https://mainnet.base.org'),
    account: fromAddress as `0x${string}`,
  });

  const txHash = await walletClient.sendRawTransaction({
    serializedTransaction: signedTx,
  });

  return txHash;
};

// Usage
const txHash = await sendEth({
  fromAddress: '0xYourWalletAddress',
  toAddress: '0xRecipientAddress',
  amount: '0.1',
  password: 'your-password',
});

console.log('Transaction sent:', txHash);

Step 5: Handle Transaction Errors

Implement proper error handling for transaction signing and sending:
try {
  const signedTx = await evmClient.signTransaction({
    senderAddress: '0xYourWalletAddress',
    externalServerKeyShares,
    transaction: preparedTx,
  });

  const txHash = await walletClient.sendRawTransaction({
    serializedTransaction: signedTx,
  });

  console.log('Transaction successful:', txHash);
} catch (error) {
  if (error.message.includes('insufficient funds')) {
    console.error('Insufficient balance for transaction');
  } else if (error.message.includes('nonce')) {
    console.error('Nonce error - transaction may have been sent already');
  } else if (error.message.includes('gas')) {
    console.error('Gas estimation failed');
  } else {
    console.error('Transaction failed:', error.message);
  }
}

Best Practices

  1. Gas Estimation: Always let viem estimate gas automatically unless you have specific requirements
  2. Nonce Management: Use viem’s transaction preparation to handle nonces automatically
  3. Error Handling: Implement comprehensive error handling for different failure scenarios
  4. Transaction Monitoring: Monitor transaction status after sending
  5. Security: Never expose key shares in client-side code

Common Transaction Types

Simple ETH Transfer

const transactionRequest = {
  to: '0xRecipientAddress',
  value: parseEther('0.1'),
};

Contract Interaction

const transactionRequest = {
  to: '0xContractAddress',
  data: '0xFunctionSelector...', // Contract function call data
  value: parseEther('0'), // For payable functions
};

Gas Optimization

const transactionRequest = {
  to: '0xRecipientAddress',
  value: parseEther('0.1'),
  gas: 21000n, // Explicit gas limit for simple transfers
};

Next Steps

Now that you can sign transactions, you can: