Skip to main content

How to Sign Ethereum Transactions Locally and Broadcast Them

Updated on
Aug 18, 2026

16 min read

Overview​

Why does a Quicknode Ethereum endpoint return unknown account when asked to sign a transaction? The Ethereum JSON-RPC interface includes a method for that purpose, eth_signTransaction, but calling it through hosted infrastructure produces this response:

{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32000, "message": "unknown account" }
}

The response can look like a bug, a permissions problem, or a missing feature. Instead, the node reports that it does not know the account because it does not hold customer private keys.

This guide explains why hosted endpoints behave this way and demonstrates the replacement pattern. It signs transactions through a local account with Viem and broadcasts the serialized bytes with eth_sendRawTransaction. This separation keeps private keys outside hosted infrastructure.

Four-step local signing flow. Step 01 prepares the nonce, gas, and fee data through the Quicknode endpoint. Step 02 signs the payload on your machine with the private key, inside a dashed key boundary, with no RPC call. Step 03 broadcasts the serialized bytes with eth_sendRawTransaction. Step 04 confirms the receipt status. Notes show that eth_signTransaction returns error -32000 unknown account and eth_accounts returns an empty array, and that only prepared data and signed bytes cross the boundary.Four-step local signing flow. Step 01 prepares the nonce, gas, and fee data through the Quicknode endpoint. Step 02 signs the payload on your machine with the private key, inside a dashed key boundary, with no RPC call. Step 03 broadcasts the serialized bytes with eth_sendRawTransaction. Step 04 confirms the receipt status. Notes show that eth_signTransaction returns error -32000 unknown account and eth_accounts returns an empty array, and that only prepared data and signed bytes cross the boundary.
TL;DR
  • Check accounts: eth_accounts returns an empty array because a Quicknode endpoint does not manage customer keys, so eth_signTransaction cannot sign
  • Prepare: Use the endpoint to obtain the pending nonce, gas estimate, and current fees
  • Sign locally: Call a Viem local account's account.signTransaction method with the complete transaction; this step can run offline
  • Broadcast: Submit the signed bytes with eth_sendRawTransaction without exposing the private key
  • Verify: Wait for the receipt and confirm that its status reports success

What You Will Do​

  • Diagnose why hosted endpoints expose no signing accounts
  • Sign an Ethereum transaction locally with Viem and recover its sender
  • Prepare, broadcast, and verify a transaction on Ethereum Sepolia
  • Compare the same workflow in ethers.js v6
  • Interpret broadcast errors and choose an appropriate key-storage model

What You Will Need​

Why the Endpoint Cannot Sign for You​

eth_signTransaction returns unknown account when the node does not manage a private key for the supplied from address. The method works only when a node holds and unlocks that account's key.

The method dates from an era when running a node and using a wallet were the same activity. You ran your own Geth instance, imported a key into its keystore, unlocked the account, and the node signed on your behalf. In that setup the node was your wallet, so asking it to sign made sense.

Hosted infrastructure uses a different model. An endpoint serves many customers across a fleet whose operators replace and rebalance machines. Storing customer private keys on internet-facing nodes would create an attractive target and tie key availability to changing infrastructure. Quicknode does not store customer keys, which is why the eth_signTransaction documentation lists the method as unsupported.

The related eth_accounts method makes this concrete rather than theoretical. It reports the accounts a node can sign for, and you can check it in one call. Verifying this yourself takes a few lines, so let's set up a project and do exactly that before writing any signing code.

Set Up the Project​

Create a directory, install the tested versions of Viem and tsx (which runs TypeScript files directly, with no compile step or tsconfig.json needed), and mark the project as an ES module:

mkdir sign-locally && cd sign-locally
npm init -y
npm pkg set type=module
npm install --save-exact viem@2.55.16
npm install --save-dev --save-exact tsx@4.20.5

Store your endpoint and test key in a .env file. Node reads this natively with --env-file, so no dotenv dependency is required:

QUICKNODE_ENDPOINT=https://your-endpoint.ethereum-sepolia.quiknode.pro/your-token/
PRIVATE_KEY=0xyour_test_private_key

You can also add TO_ADDRESS to send the broadcast example to another Sepolia address. If you omit it, the script sends the test transfer back to the signer.

Before creating the transaction scripts, add one small configuration helper. It validates each environment variable before a client or account uses it, and it only requires a value when the calling script needs that value. Because tsx runs the TypeScript source directly, the examples import this helper through its actual config.ts filename.

Create config.ts:

import { isAddress, type Address, type Hex } from 'viem'

export function requireEndpoint(): string {
const endpoint = process.env.QUICKNODE_ENDPOINT
if (!endpoint) throw new Error('QUICKNODE_ENDPOINT is not set')

const url = new URL(endpoint)
if (url.protocol !== 'https:')
throw new Error('QUICKNODE_ENDPOINT must use HTTPS')
return endpoint
}

export function requirePrivateKey(): Hex {
const privateKey = process.env.PRIVATE_KEY
if (!privateKey || !/^0x[0-9a-fA-F]{64}$/.test(privateKey)) {
throw new Error(
'PRIVATE_KEY must be 0x followed by 64 hexadecimal characters'
)
}
return privateKey as Hex
}

export function getToAddress(fallback: string): Address {
const to = process.env.TO_ADDRESS ?? fallback
if (!isAddress(to))
throw new Error('TO_ADDRESS is not a valid Ethereum address')
return to
}
Use a throwaway key

Use a key generated purely for testing, funded only with testnet ETH. Never put a private key that controls real funds into a .env file, and add .env to your .gitignore before your first commit.

Confirm the Node Holds No Keys​

Rather than take the explanation on faith, ask the endpoint directly. This script does two things: it lists the accounts the node can sign for, then it asks the node to sign a transaction anyway so you can see the exact error.

Create check-node-keys.ts:

import { createWalletClient, http } from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import { requireEndpoint, requirePrivateKey } from './config.ts'

async function main() {
const account = privateKeyToAccount(requirePrivateKey())
const client = createWalletClient({
chain: sepolia,
transport: http(requireEndpoint()),
})

// Ask the node which accounts it manages
const accounts = await client.request({ method: 'eth_accounts' })
console.log('accounts the node can sign for:', accounts)

// Ask the node to sign for an address it does not hold
try {
await client.request({
method: 'eth_signTransaction' as never,
params: [
{
from: account.address,
to: account.address,
gas: '0x5208',
gasPrice: '0x9184e72a000',
value: '0x9184e72a',
nonce: '0x1',
},
] as never,
})
} catch (error) {
const rpcError = error as { details?: string; shortMessage?: string }
console.log(
'eth_signTransaction failed:',
rpcError.details ?? rpcError.shortMessage
)
}
}

main().catch(error => {
console.error(
'failed:',
error instanceof Error ? error.message.split('\n')[0] : error
)
process.exit(1)
})

Run it:

npx tsx --env-file=.env check-node-keys.ts
accounts the node can sign for: []
eth_signTransaction failed: unknown account

The empty array shows that the endpoint exposes no signing accounts. Quicknode does not manage the test account's key, so eth_signTransaction cannot sign for its address. The unknown account message reports that the endpoint cannot find a node-managed key for the supplied from address.

Sign an Ethereum Transaction Locally​

The cryptographic signing operation requires no node. It combines a complete transaction payload with a private key, so Viem can perform it entirely in the local account. The transaction must already include its chain ID, nonce, gas limit, and fee fields.

Create sign-locally.ts. It calls the local account's signTransaction method directly and supplies every field by hand, so no network request occurs:

import {
parseEther,
parseTransaction,
recoverTransactionAddress,
type TransactionSerializedEIP1559,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import { requirePrivateKey } from './config.ts'

async function main() {
const account = privateKeyToAccount(requirePrivateKey())

// Every field is provided, so the local account can sign without a transport
const serialized = (await account.signTransaction({
chainId: sepolia.id,
type: 'eip1559',
to: '0x0000000000000000000000000000000000000000',
value: parseEther('0.0001'),
gas: 21000n,
maxFeePerGas: 2_000_000_000n,
maxPriorityFeePerGas: 1_000_000n,
nonce: 0,
})) as TransactionSerializedEIP1559

console.log('signer address: ', account.address)
console.log('raw signed transaction:', serialized)

const decoded = parseTransaction(serialized)
console.log('\ndecoded type: ', decoded.type)
console.log('decoded chainId:', decoded.chainId)

// Recover the sender from the signed payload and signature
const recovered = await recoverTransactionAddress({
serializedTransaction: serialized,
})
console.log('\nrecovered signer:', recovered)
console.log(
'matches account: ',
recovered.toLowerCase() === account.address.toLowerCase()
)
}

main().catch(error => {
console.error('failed:', error instanceof Error ? error.message : error)
process.exit(1)
})

Run it:

npx tsx --env-file=.env sign-locally.ts
signer address:         <your test address>
raw signed transaction: 0x...

decoded type: eip1559
decoded chainId: 11155111

recovered signer: <your test address>
matches account: true

Three things are worth pulling out of that output.

The long hex string is the complete transaction, signature included, ready for anyone to relay. The chainId of 11155111 identifies Sepolia and forms part of the signed bytes, so this exact transaction is invalid on a network with a different chain ID. EIP-155 introduced chain ID replay protection for legacy transactions. The type 2 transaction shown here includes chain_id directly in its EIP-1559 signing payload.

Most importantly, recoverTransactionAddress recovered the sender from the serialized transaction's signing payload and signature. Anyone can perform that check without knowing the private key. A node performs the same sender recovery before it applies stateful checks such as nonce, balance, and gas validity. The private key never left the process that ran this script.

Signing works offline

Because account.signTransaction uses no transport, the machine holding the key does not need network access. High-value setups can sign on an air-gapped machine and move only the resulting raw transaction to a connected machine for broadcast.

Broadcast with eth_sendRawTransaction​

The previous script hardcoded the nonce and fees, which is fine for a demonstration but will not get mined. A real transaction needs the current nonce for your account and fees high enough for present network conditions, and those are exactly the facts you cannot know locally. This is where the endpoint earns its place: it supplies those values, then relays your signed bytes.

Viem's prepareTransactionRequest action fetches the pending nonce, estimates gas, and fills in EIP-1559 fees. One library action may issue several RPC requests. Create send-signed-tx.ts:

import {
createPublicClient,
createWalletClient,
formatEther,
http,
parseEther,
recoverTransactionAddress,
type TransactionSerializedEIP1559,
} from 'viem'
import { privateKeyToAccount } from 'viem/accounts'
import { sepolia } from 'viem/chains'
import { getToAddress, requireEndpoint, requirePrivateKey } from './config.ts'

async function main() {
const endpoint = requireEndpoint()
const account = privateKeyToAccount(requirePrivateKey())
const to = getToAddress(account.address)

const publicClient = createPublicClient({
chain: sepolia,
transport: http(endpoint),
})

const walletClient = createWalletClient({
account,
chain: sepolia,
transport: http(endpoint),
})

const endpointChainId = await publicClient.getChainId()
if (endpointChainId !== sepolia.id) {
throw new Error(
`expected Sepolia chain ID ${sepolia.id}, received ${endpointChainId}`
)
}

// 1. Ask the endpoint for current nonce, gas, and fee data
const request = await walletClient.prepareTransactionRequest({
type: 'eip1559',
to,
value: parseEther('0.0001'),
})

console.log('1. prepared, unsigned')
console.log(' nonce:', request.nonce)
console.log(' gas: ', request.gas)
console.log(' maxFeePerGas:', request.maxFeePerGas)

// 2. Sign through the local account. This step makes no RPC request
const serialized = (await account.signTransaction({
chainId: request.chainId,
type: 'eip1559',
to: request.to,
value: request.value,
gas: request.gas,
maxFeePerGas: request.maxFeePerGas,
maxPriorityFeePerGas: request.maxPriorityFeePerGas,
nonce: request.nonce,
})) as TransactionSerializedEIP1559
console.log('\n2. signed locally')
console.log(
' recovered signer:',
await recoverTransactionAddress({ serializedTransaction: serialized })
)

// 3. Broadcast the signed bytes. The endpoint relays them and cannot alter them
const hash = await publicClient.sendRawTransaction({
serializedTransaction: serialized,
})
console.log('\n3. broadcast via eth_sendRawTransaction')
console.log(' hash:', hash)

const receipt = await publicClient.waitForTransactionReceipt({ hash })
if (receipt.status !== 'success')
throw new Error(`transaction reverted: ${hash}`)

console.log('\n4. mined in block', receipt.blockNumber)
console.log(' status: ', receipt.status)
console.log(
' fee paid:',
formatEther(receipt.gasUsed * receipt.effectiveGasPrice),
'ETH'
)
}

main().catch(error => {
console.error(
'failed:',
error instanceof Error ? error.message.split('\n')[0] : error
)
process.exit(1)
})

Fund your test address from the Quicknode Multi-Chain Faucet, then run:

npx tsx --env-file=.env send-signed-tx.ts
1. prepared, unsigned
nonce: <current nonce>
gas: <estimated gas>
maxFeePerGas: <current maximum fee>

2. signed locally
recovered signer: <your test address>

3. broadcast via eth_sendRawTransaction
hash: 0x...

4. mined in block <block number>
status: success
fee paid: <amount> ETH

The transaction that arrives onchain is byte for byte the one you signed. The endpoint could refuse to relay it, but it could not change the recipient, the amount, or the fee, because any edit would invalidate the signature. That property is what makes it safe to broadcast through infrastructure you do not run yourself.

Nonce handling under concurrency

prepareTransactionRequest reads the pending nonce from the endpoint, which reflects transactions that endpoint has already seen. If your service signs several transactions concurrently, two requests may still consume the same nonce. Services that send at volume coordinate nonce assignment and increment it atomically. See How to Manage Nonces with Ethereum Transactions for the details.

When to Keep Signing and Broadcasting Separate​

Viem also offers sendTransaction, which prepares, signs, and broadcasts in a single call. For most application code that is the right choice, and it does the same three steps you just wrote out by hand.

Writing them separately pays off when you need control over the gap between signing and sending:

  • Offline signing. Sign on a machine that never connects to the network, then carry the raw transaction to a connected machine to broadcast.
  • Delayed or scheduled sending. Sign now, hold the bytes, and broadcast when a condition triggers. The signature remains cryptographically valid, but inclusion still depends on the nonce, balance, and fee conditions when you broadcast.
  • Replacing a stuck transaction. Sign a replacement using the same nonce with higher fees, then broadcast it to displace the original.
  • Broadcasting to multiple destinations. Send identical signed bytes to a public endpoint and a private relay. The network can include that transaction only once.
  • Inspection before sending. Decode and check the exact bytes, or route them through an approval step, before they reach the network.

Read the Errors the Network Returns​

Once you broadcast signed bytes, the network evaluates the fields inside them. Error text varies across execution clients, provider policy, and client versions. The following table shows representative Geth-style or provider messages and the fields to inspect.

MessageCauseFix
nonce too low: next nonce 4, tx nonce 0The nonce was already used by a mined transactionRead the pending nonce again with getTransactionCount or use prepareTransactionRequest before signing
already knownYou broadcast identical signed bytes twiceTreat it as an idempotent acknowledgement from that node, then continue monitoring the transaction hash
replacement transaction underpricedAnother transaction is pending on the same nonce and your fee is not enough higherRaise maxFeePerGas and maxPriorityFeePerGas meaningfully, not by one wei
insufficient funds for gas * price + valueThe balance cannot cover value plus gas * maxFeePerGasReduce the amount or fund the account. The message reports what you have and what is needed
transaction underpricedThe transaction does not meet the node's transaction-pool pricing policy, which may involve the fee cap or priority feeFetch current fees and inspect the structured RPC error instead of matching only the message text
intrinsic gas too lowThe gas limit is below the minimum the transaction needsUse at least 21,000 for a plain transfer, or call estimateGas
invalid chain IDThe bytes were signed for a different networkSign with the chain ID of the network you are broadcasting to

Two of those deserve a closer look, because they behave differently from how they read.

already known means the queried node has seen the exact transaction bytes. A retrying broadcaster can treat it as an idempotent acknowledgement from that node, then continue monitoring the transaction hash.

A node may queue a future-nonce transaction while it waits for missing nonces. Acceptance, queue capacity, and retention depend on the execution client and provider configuration. For example, Geth transaction-pool settings bound queued transactions and apply a configurable lifetime. Applications should monitor the transaction and rebroadcast when needed instead of assuming that a node will retain it indefinitely.

Check the chain at the online boundary

An offline signer cannot discover which network an endpoint serves. The online script checks the endpoint's chain ID before preparing the transaction. Preserve that check when moving a prepared request to a separate signer. The broadcast node rejects bytes signed for a different chain.

Sign and Broadcast an Ethereum Transaction with ethers.js v6​

The local-signing pattern is not specific to Viem. ethers.js v6 can also prepare a transaction with network data, sign it through a local key, and broadcast the serialized transaction.

Install the tested ethers.js v6 release:

npm install --save-exact ethers@6.17.0

Create ethers-example.ts. The provider supplies chain data, gas estimates, and broadcasting, while wallet.signTransaction performs the signature locally:

import { JsonRpcProvider, Network, Transaction, Wallet, parseEther } from 'ethers'
import { getToAddress, requireEndpoint, requirePrivateKey } from './config.ts'

// ethers resolves registered networks by name, so the chain ID is not hardcoded
const sepolia = Network.from('sepolia')

async function main() {
const provider = new JsonRpcProvider(requireEndpoint())
const wallet = new Wallet(requirePrivateKey(), provider)
const to = getToAddress(wallet.address)
const value = parseEther('0.0001')

const network = await provider.getNetwork()
if (network.chainId !== sepolia.chainId) {
throw new Error(
`expected Sepolia chain ID ${sepolia.chainId}, received ${network.chainId}`
)
}

const fees = await provider.getFeeData()
if (fees.maxFeePerGas === null || fees.maxPriorityFeePerGas === null) {
throw new Error('the endpoint did not return EIP-1559 fee data')
}

const gasLimit = await provider.estimateGas({
from: wallet.address,
to,
value,
})
const nonce = await provider.getTransactionCount(wallet.address, 'pending')

// signTransaction returns serialized signed bytes without broadcasting them
const serialized = await wallet.signTransaction({
to,
value,
gasLimit,
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas,
nonce,
chainId: network.chainId,
type: 2,
})

console.log('recovered signer:', Transaction.from(serialized).from)

// broadcastTransaction sends the bytes through eth_sendRawTransaction
const sent = await provider.broadcastTransaction(serialized)
const receipt = await sent.wait()
if (!receipt) throw new Error(`transaction was not mined: ${sent.hash}`)
if (receipt.status !== 1)
throw new Error(`transaction reverted: ${sent.hash}`)

console.log('block:', receipt.blockNumber, 'status: success')
}

main().catch(error => {
console.error(
'failed:',
error instanceof Error ? error.message.split('\n')[0] : error
)
process.exit(1)
})

Run it with:

npx tsx --env-file=.env ethers-example.ts

The ethers.js script prints output in this form after a successful transaction:

recovered signer: <your test address>
block: <block number> status: success

When both examples use the same private key, ethers.js recovers the same signer address as Viem. Their serialized transactions and signatures may differ because each run reads current nonce, gas, and fee data.

Where the Private Key Should Live​

A .env file is appropriate for a throwaway test script and inappropriate for anything holding real value. The boundary in this guide remains the same as you harden the signer: only the component that performs the signature changes.

For local development, keep using a key generated for testing, funded only with testnet ETH, in a gitignored .env.

For Hardhat or Foundry projects, Secure Your Private Keys in Hardhat and Foundry with Encrypted Secrets explains how to replace plaintext keys with framework-supported encrypted storage.

On a server, do not push the key into the image or the repository. Inject it at runtime from a secret manager such as AWS Secrets Manager, Google Secret Manager, or HashiCorp Vault, so rotation does not require a redeploy and the value never lands in a build artifact or a log.

For anything holding meaningful value, stop handling raw key material in the application. A key management service (KMS) or hardware security module (HSM) can hold a non-exportable key and accept a transaction hash to sign. Both TypeScript libraries expose an integration point for this pattern: Viem's toAccount from viem/accounts accepts a custom signTransaction implementation, and ethers.js lets you extend AbstractSigner. The custom signer sends the signing request to the KMS or HSM, while the preparation and broadcast flow stays the same.

When a person rather than a service controls the funds, a hardware wallet provides the equivalent boundary. The key stays on the device, the device signs, and the application receives a serialized signed transaction in the same format.

Never send a private key to an endpoint

No legitimate RPC method accepts a private key. eth_signTransaction is sometimes misread as a place to pass one, and it is not. Any tool or tutorial asking you to put key material in an RPC request is unsafe.

Wrapping Up​

You now know why eth_signTransaction fails against a hosted endpoint and why that failure protects the key boundary. You built the replacement pattern: prepare current nonce, gas, and fee data through the endpoint, sign through the local account that holds the key, and broadcast the finished bytes with eth_sendRawTransaction. You also recovered the sender from the signed transaction before the network applied its stateful validity checks.

The pattern holds as you scale it up. The library can be Viem or ethers.js, and the signer can use a throwaway .env key, encrypted storage, a secret manager, a KMS, or a hardware wallet. The boundary stays constant: the key remains under your control, and the endpoint receives only serialized bytes that have already been signed.

The same flow works on each supported Ethereum Virtual Machine (EVM) chain. Replace the chain configuration and endpoint, then prepare and sign with that network's chain ID.

Next Steps​

Frequently Asked Questions​

Why does eth_signTransaction return 'unknown account'?

The method asks the node to sign using a private key it manages for your address. Quicknode endpoints do not manage customer keys, so no signing address is available. Calling eth_accounts on the same endpoint returns an empty array. Sign through a local account and broadcast with eth_sendRawTransaction instead.

Is eth_signTransaction ever usable?

Yes, on a node you run yourself that has an unlocked account in its keystore, which is how the method was originally intended to be used. It is unsuitable for shared infrastructure because it requires the node to hold your private key, and most public providers therefore do not support it.

Do I need an endpoint to sign a transaction?

The cryptographic signing operation does not require an endpoint. This guide calls a Viem local account's account.signTransaction method with a complete payload, so that step works offline. An endpoint is still needed to read current nonce, gas, and fee data and to broadcast the signed transaction.

Can the endpoint modify my transaction before broadcasting it?

No. The signature covers every field of the transaction, so any modification invalidates it and the network rejects it. An endpoint can decline to relay your transaction, but it cannot alter the recipient, amount, gas values, or calldata.

Can a signed Sepolia transaction be replayed on mainnet?

No. The shown EIP-1559 transaction includes Sepolia's chain ID of 11155111 in its signed payload, so it is invalid on a network with a different chain ID. You can confirm this by decoding the raw transaction and reading its chainId field.

Does this approach work on other EVM chains?

Yes. Replace the chain configuration and endpoint, then prepare and sign with that network's chain ID. The local-signing and raw-broadcast pattern is available across supported EVM chains.