Skip to main content
Sign transactions from a user’s embedded Ethereum wallet without broadcasting them to the network. This allows you to review, store, or broadcast transactions at a later time.

Overview

The useSignTransaction hook from MoonKey’s React SDK provides an interface for signing transactions without immediately broadcasting them. This is useful for scenarios where you need to:
  • Batch multiple transactions for later submission
  • Implement custom transaction broadcasting logic
  • Store signed transactions for delayed execution
  • Create transactions that will be submitted by a relayer
Signing vs Sending: signTransaction only signs the transaction and returns the signature, while sendTransaction both signs and broadcasts the transaction to the network.

React SDK

  • Basic Usage
  • With UI Customization
To sign a transaction, use the signTransaction method from the useSignTransaction hook:
import { useMoonKey } from '@moon-key/react-auth';
import { useSignTransaction } from '@moon-key/react-auth/ethereum';

function SignTransactionButton() {
  const { user } = useMoonKey();
  const { signTransaction } = useSignTransaction();

  const handleSign = async () => {
    if (!user?.wallet) {
      alert('No wallet found');
      return;
    }

    try {
      const result = await signTransaction({
        to: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
        value: '0x38d7ea4c68000', // 0.001 ETH in wei (hex)
        chainId: 1 // Mainnet
      });

      console.log('Transaction signed:', result.signedTransaction);
      console.log('Signature:', result.signature);
    } catch (error) {
      console.error('Signing failed:', error);
    }
  };

  return (
    <button onClick={handleSign}>
      Sign Transaction
    </button>
  );
}

Parameters

Transaction Object

The first parameter to signTransaction is the transaction object:
to
string
required
The recipient address for the transaction.Example:
to: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045'
value
string
The amount of ETH to send in wei, as a hexadecimal string. Omit for contract interactions with no ETH transfer.Example:
value: '0x38d7ea4c68000' // 0.001 ETH
data
string
The encoded function call data for contract interactions, as a hexadecimal string.Example:
data: '0x095ea7b3000000000000000000000000...'
chainId
number
required
The chain ID for the transaction.Example:
chainId: 1 // Ethereum Mainnet
gasLimit
string
Optional gas limit as a hexadecimal string. MoonKey estimates gas automatically if not provided.Example:
gasLimit: '0x5208' // 21000 in decimal
maxFeePerGas
string
Optional maximum fee per gas for EIP-1559 transactions, as a hexadecimal string.Example:
maxFeePerGas: '0x59682f00' // 1.5 gwei
maxPriorityFeePerGas
string
Optional maximum priority fee per gas for EIP-1559 transactions, as a hexadecimal string.Example:
maxPriorityFeePerGas: '0x3b9aca00' // 1 gwei
nonce
number
Optional nonce for the transaction. MoonKey determines this automatically if not provided.Example:
nonce: 5

Options Object

The second parameter is an optional configuration object:
uiConfig
object
Configuration for the signing confirmation modal UI.
wallet
Wallet
Specific wallet to use for signing. If not provided, uses the user’s default wallet.Example:
wallet: user.wallet

Returns

The signTransaction method returns a Promise that resolves with:
signedTransaction
string
The RLP-encoded signed transaction, ready to be broadcast.Example:
"0x02f8730182012a8459682f00843b9aca0082520894d8da6bf26964af9d7eed9e03e53415d37aa9604587038d7ea4c6800080c080a0..."
signature
string
The transaction signature.Example:
"0xa0cd31b38c3e3a48230bee2d5c687a0b2a5efcb298c58cfc3b43449eefd17857cf..."
encoding
'rlp'
The encoding format of the signed transaction. Currently always 'rlp' for Ethereum.

Complete Examples

Contract Interaction Signing

'use client';
import { useMoonKey } from '@moon-key/react-auth';
import { useSignTransaction } from '@moon-key/react-auth/ethereum';
import { encodeFunctionData } from 'viem';

// ERC-20 ABI
const ERC20_ABI = [{
  name: 'transfer',
  type: 'function',
  inputs: [
    { name: 'to', type: 'address' },
    { name: 'amount', type: 'uint256' }
  ],
  outputs: [{ type: 'bool' }]
}];

export default function SignTokenTransfer() {
  const { user } = useMoonKey();
  const { signTransaction } = useSignTransaction();

  const handleSignTransfer = async () => {
    if (!user?.wallet) return;

    const tokenAddress = '0xTokenContractAddress...';
    const recipientAddress = '0xRecipientAddress...';
    const amount = BigInt('1000000000000000000'); // 1 token (18 decimals)

    try {
      // Encode the transfer function call
      const data = encodeFunctionData({
        abi: ERC20_ABI,
        functionName: 'transfer',
        args: [recipientAddress, amount]
      });

      // Sign the transaction
      const result = await signTransaction({
        to: tokenAddress,
        data,
        chainId: 1
      });

      console.log('Token transfer signed:', result.signedTransaction);

      // Store or submit the signed transaction
      await submitToBackend(result.signedTransaction);

      alert('Token transfer signed successfully!');
    } catch (error) {
      console.error('Signing failed:', error);
    }
  };

  const submitToBackend = async (signedTx: string) => {
    // Submit to your backend for processing
    await fetch('/api/store-signed-transaction', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ signedTransaction: signedTx })
    });
  };

  return (
    <button onClick={handleSignTransfer}>
      Sign Token Transfer
    </button>
  );
}

Broadcasting Signed Transactions

After signing a transaction, you can broadcast it using viem’s public client:
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';

// Create a public client
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http()
});

// Broadcast the signed transaction
const txHash = await publicClient.sendRawTransaction({
  serializedTransaction: signedTransaction as `0x${string}`
});

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

// Wait for confirmation
const receipt = await publicClient.waitForTransactionReceipt({
  hash: txHash
});

console.log('Transaction confirmed:', receipt.status);

UI Customization

Customize the signing confirmation modal:
import { useSignTransaction } from '@moon-key/react-auth/ethereum';

const { signTransaction } = useSignTransaction();

await signTransaction({
  to: '0x...',
  value: '0x38d7ea4c68000',
  chainId: 1
}, {
  uiConfig: {
    title: 'Sign Transaction',
    description: 'Review and sign this transaction',
    confirmButtonText: 'Sign Transaction',
    cancelButtonText: 'Cancel'
  }
});

Hide Modal

For automated flows, you can sign without showing the modal:
await signTransaction({
  to: '0x...',
  value: '0x38d7ea4c68000',
  chainId: 1
}, {
  uiConfig: {
    showWalletUI: false
  }
});
Hiding the modal removes the user’s ability to review transaction details before signing. Only use this for trusted operations or after obtaining explicit user consent.

Signing vs Sending

Understanding the difference between signing and sending transactions:
FeaturesignTransactionsendTransaction
Signs transaction✅ Yes✅ Yes
Broadcasts to network❌ No✅ Yes
ReturnsSigned transactionTransaction hash
Use caseDeferred submissionImmediate execution
Gas required❌ Not yet✅ Yes
Reversible✅ Yes (not broadcasted)❌ No (already broadcasted)

When to use signTransaction:

  • Batch operations: Sign multiple transactions and submit together
  • Relayer services: Submit through a third-party relayer for gasless transactions
  • Delayed execution: Sign now, broadcast later
  • Custom broadcasting: Implement your own transaction submission logic
  • Multi-sig workflows: Collect signatures from multiple parties

When to use sendTransaction:

  • Immediate execution: Transaction should execute right away
  • Simple workflows: Standard transaction flow without special handling
  • User-paid gas: User pays gas fees directly
  • Real-time updates: Need immediate confirmation

Error Handling

Handle common signing errors:
import { useMoonKey } from '@moon-key/react-auth';
import { useSignTransaction } from '@moon-key/react-auth/ethereum';

function SignWithErrorHandling() {
  const { user } = useMoonKey();
  const { signTransaction } = useSignTransaction();

  const handleSign = async () => {
    if (!user?.wallet) return;

    try {
      const result = await signTransaction({
        to: '0x...',
        value: '0x38d7ea4c68000',
        chainId: 1
      });

      console.log('Success:', result.signedTransaction);
    } catch (error: any) {
      // Handle specific error types
      if (error.code === 'USER_REJECTED') {
        console.log('User cancelled signing');
        alert('Signing cancelled');
      } else if (error.code === 'INVALID_PARAMS') {
        console.log('Invalid transaction parameters');
        alert('Invalid transaction data');
      } else if (error.code === 'NETWORK_ERROR') {
        console.log('Network error during signing');
        alert('Network error. Please try again');
      } else {
        console.error('Signing failed:', error);
        alert('Failed to sign transaction: ' + error.message);
      }
    }
  };

  return <button onClick={handleSign}>Sign Transaction</button>;
}

Best Practices

If storing signed transactions, use secure storage:
// ❌ Don't store in localStorage for sensitive transactions
localStorage.setItem('signedTx', signedTransaction);

// ✅ Store securely on your backend
await fetch('/api/store-transaction', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${userToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    signedTransaction,
    userId: user.id,
    timestamp: Date.now()
  })
});
When batching transactions, manage nonces carefully:
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http()
});

// Get current nonce
const currentNonce = await publicClient.getTransactionCount({
  address: user.wallet.address as `0x${string}`
});

// Sign multiple transactions with incremental nonces
const signedTxs = [];
for (let i = 0; i < transactions.length; i++) {
  const result = await signTransaction({
    ...transactions[i],
    nonce: currentNonce + i,
    chainId: 1
  });
  signedTxs.push(result.signedTransaction);
}
Always validate transaction parameters before signing:
import { isAddress } from 'viem';

const handleSign = async () => {
  // Validate address
  if (!isAddress(recipient)) {
    alert('Invalid recipient address');
    return;
  }

  // Validate amount
  if (BigInt(value) <= 0n) {
    alert('Amount must be greater than 0');
    return;
  }

  // Validate chain ID
  const supportedChains = [1, 8453, 137];
  if (!supportedChains.includes(chainId)) {
    alert('Unsupported chain');
    return;
  }

  // Proceed with signing
  await signTransaction({ to: recipient, value, chainId });
};
Signed transactions can become stale. Implement expiration logic:
interface StoredTransaction {
  signedTransaction: string;
  createdAt: number;
  expiresAt: number;
}

const storeSignedTransaction = (signedTx: string) => {
  const now = Date.now();
  const expires = now + (5 * 60 * 1000); // 5 minutes

  const stored: StoredTransaction = {
    signedTransaction: signedTx,
    createdAt: now,
    expiresAt: expires
  };

  localStorage.setItem('signedTx', JSON.stringify(stored));
};

const getSignedTransaction = (): string | null => {
  const stored = localStorage.getItem('signedTx');
  if (!stored) return null;

  const data: StoredTransaction = JSON.parse(stored);

  // Check if expired
  if (Date.now() > data.expiresAt) {
    localStorage.removeItem('signedTx');
    return null;
  }

  return data.signedTransaction;
};
Keep users informed about signing status:
const [status, setStatus] = useState<'idle' | 'signing' | 'signed' | 'error'>('idle');

const handleSign = async () => {
  setStatus('signing');

  try {
    const result = await signTransaction({ to: '0x...', value: '0x38d7ea4c68000', chainId: 1 });
    setStatus('signed');
    console.log('Transaction signed:', result.signedTransaction);
  } catch (error) {
    setStatus('error');
    console.error('Signing failed:', error);
  }
};

return (
  <div>
    <button onClick={handleSign} disabled={status === 'signing'}>
      {status === 'signing' && 'Signing...'}
      {status === 'signed' && 'Signed ✓'}
      {status === 'error' && 'Failed ✗'}
      {status === 'idle' && 'Sign Transaction'}
    </button>
    {status === 'signed' && <p>Transaction signed successfully. Ready to broadcast.</p>}
  </div>
);

Troubleshooting

Common causes when broadcasting fails:
  • Stale nonce: The nonce used when signing is outdated
  • Expired transaction: Too much time passed since signing
  • Insufficient gas: Gas parameters were underestimated
  • Invalid format: Signed transaction is malformed
try {
  const txHash = await publicClient.sendRawTransaction({
    serializedTransaction: signedTransaction as `0x${string}`
  });
} catch (error: any) {
  if (error.message.includes('nonce')) {
    alert('Transaction nonce is invalid. Please sign again.');
  } else if (error.message.includes('underpriced')) {
    alert('Gas price is too low. Please sign with higher gas.');
  } else {
    console.error('Broadcast error:', error);
  }
}
Handle user cancellations gracefully:
try {
  const result = await signTransaction({ to: '0x...', value: '0x38d7ea4c68000', chainId: 1 });
} catch (error: any) {
  if (error.code === 'USER_REJECTED') {
    // Don't show error - this is expected behavior
    console.log('User cancelled signing');
    return;
  }
  // Handle other errors
  alert('Signing failed: ' + error.message);
}
Ensure correct chain ID for the target network:
const CHAIN_IDS = {
  mainnet: 1,
  base: 8453,
  polygon: 137,
  arbitrum: 42161,
  optimism: 10
};

const handleSign = async (network: keyof typeof CHAIN_IDS) => {
  const chainId = CHAIN_IDS[network];

  await signTransaction({
    to: '0x...',
    value: '0x38d7ea4c68000',
    chainId
  });
};

Next Steps