Sending a Raw Transaction: Why eth_sendTransaction Doesn't Work on a Provider
Reading from a chain is easy — eth_call, eth_getBalance, done. Writing is where developers get stuck, and it usually starts with the same confusing moment: you try eth_sendTransaction, and your provider rejects it. Here's what that looks like against a real endpoint:
# Ask the node to sign and send for us:
curl ... -d '{"method":"eth_sendTransaction","params":[{"from":"0x00..","to":"0x00..","value":"0x0"}]}'
# → {"error":{"code":-32000,"message":"unknown account"}}
# Ask which accounts the node controls:
curl ... -d '{"method":"eth_accounts","params":[]}'
# → {"result":[]}
The node controls no accounts, so it can't sign for you. That's not a bug — it's the whole security model. Understanding it is the key to the entire write path.
Two methods, and why only one works
There are two ways to submit a transaction, and they differ entirely in who signs:
eth_sendTransactionasks the node to sign the transaction using a private key in the node's own keystore, for an unlocked account. This only works if you're running your own node and have loaded your key into it. A shared RPC provider holds no keys — it can't and shouldn't sign on your behalf — soeth_accountsis empty andeth_sendTransactionfails with "unknown account."eth_sendRawTransactiontakes a transaction you've already signed yourself, as a blob of bytes, and broadcasts it. The node never sees your private key — only the signed result. This is how every application that uses a provider sends transactions.
So the rule is simple: you sign locally, then send the signed bytes. The node is a broadcaster, not a signer. If you send eth_sendRawTransaction a malformed payload, you get an RLP-decoding error — proof that it expects properly signed, encoded transaction bytes and nothing less.
Anatomy of a transaction
Before you can sign one, you assemble it. A modern (EIP-1559, "type 2") transaction has these fields:
| Field | What it is |
|---|---|
nonce |
The sender's transaction count — strict per-account ordering |
to |
Recipient (or omitted for contract creation) |
value |
Wei to send |
data |
Calldata (empty for a plain transfer, or an encoded function call) |
gas |
Gas limit — the max units the tx may consume |
maxPriorityFeePerGas |
The tip to the validator |
maxFeePerGas |
The ceiling you'll pay per gas (base fee + tip) |
chainId |
Which chain this tx is valid on |
Every one of these has to be correct before you sign, because the signature commits to all of them.
The build → sign → send flow
Five steps, each mapping to an RPC call or a local operation:
- Get the nonce.
eth_getTransactionCount(from, "pending")gives the next nonce. Under any real send rate this is the single trickiest field — see nonce management for high-throughput senders for why "pending" flaps and how to manage nonces locally. - Price it. Estimate the gas limit with
eth_estimateGas, and set your fees from the current base fee (viaeth_feeHistoryor the latest block'sbaseFeePerGas) plus a priority tip. Getting this wrong gets your transaction stuck or overpaying — the mechanics are in estimating gas right. - Set the chain ID.
eth_chainId, or a value you already know. This is mandatory (more below). - Sign locally. Your library signs the assembled fields with your private key and RLP-encodes the result into a
0x-prefixed byte string. This happens entirely in your process — the key never leaves it. - Broadcast.
eth_sendRawTransaction(signedBytes)returns the transaction hash immediately. That hash means "accepted into the mempool," not "mined" — you still have to wait for a receipt.
Then poll eth_getTransactionReceipt(hash) until it's mined and check status — because a mined transaction can still have reverted.
Doing it from a library
Libraries wrap all five steps, but they're all doing the same thing under the hood: sign locally, then call eth_sendRawTransaction.
viem:
import { createWalletClient, http, parseEther } from "viem";
import { privateKeyToAccount } from "viem/accounts";
import { mainnet } from "viem/chains";
const account = privateKeyToAccount("0xYOUR_PRIVATE_KEY");
const wallet = createWalletClient({
account,
chain: mainnet,
transport: http("https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY"),
});
// Fills nonce/gas/fees, signs locally, and broadcasts via eth_sendRawTransaction:
const hash = await wallet.sendTransaction({ to: "0xRecipient", value: parseEther("0.01") });
ethers v6:
import { JsonRpcProvider, Wallet, parseEther } from "ethers";
const provider = new JsonRpcProvider("https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY");
const wallet = new Wallet("0xYOUR_PRIVATE_KEY", provider);
const tx = await wallet.sendTransaction({ to: "0xRecipient", value: parseEther("0.01") });
await tx.wait(); // waits for the receipt
To see the raw path explicitly — sign, then broadcast the bytes yourself:
const signed = await wallet.signTransaction({
to: "0xRecipient", value: parseEther("0.01"),
nonce: await provider.getTransactionCount(wallet.address, "pending"),
chainId: 1, gasLimit: 21000n,
maxFeePerGas: 30_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n,
});
const hash = await provider.send("eth_sendRawTransaction", [signed]); // the actual broadcast
web3.py:
signed = w3.eth.account.sign_transaction(tx_dict, private_key="0xYOUR_KEY")
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) # calls eth_sendRawTransaction
Gotchas
chainIdis not optional. Since EIP-155, the chain ID is baked into the signature for replay protection — a transaction signed for one chain is invalid on another. Omit it or get it wrong and the transaction is rejected. This is also why the same signed transaction can't be replayed across chains.- The returned hash isn't a confirmation.
eth_sendRawTransactionreturns as soon as the node accepts the transaction into its mempool. It might still be dropped, replaced, or reverted. Treat the hash as a receipt to track, not proof of success — the mempool explainer covers what happens between broadcast and mining. - Nonce gaps stall everything. Transactions execute in strict nonce order. A missing nonce blocks every higher one behind it until it's filled.
- Never send your private key to the node. The entire point of
eth_sendRawTransactionis that signing happens locally. If a tutorial ever has you putting a private key in an RPC request, stop. - Account abstraction changes the path. Smart-contract accounts don't originate transactions the EOA way — an ERC-4337 UserOperation goes to a bundler, not to
eth_sendRawTransaction. See what is account abstraction for how that submission path differs.
The takeaway
On a provider you always sign locally and broadcast with eth_sendRawTransaction — the node is a relay, never a keyholder, which is exactly why eth_sendTransaction returns "unknown account." Assemble the fields, price it, set the chain ID, sign in your own process, send the bytes, then wait for a receipt and check its status. Your library does this for you, but knowing the five steps underneath is what lets you debug a stuck or rejected transaction.
Need an endpoint to broadcast against? SwiftNodes serves eth_sendRawTransaction (and the rest of the write path) across 60-plus chains behind one key — sign up and point your wallet client at https://rpc.swiftnodes.io/rpc/eth?key=YOUR_API_KEY.
Related posts
- eth_call State Overrides: Simulate Against State That Doesn't Exist
eth_call's third parameter lets you override balances, code, and storage before simulating. Here's how state overrides work, with a reproducible example and real use cases.
- Is Your RPC Node Actually at the Chain Tip? How to Catch a Stale Endpoint
eth_syncing returns false when a node is fully synced — and also when it hasn't started syncing. That trap, plus how to really tell if an RPC node (your own or a provider's) is at the chain tip: compare block height to a reference, check block-timestamp freshness, and the Solana equivalents (getHealth, catchup). A liveness check is not a freshness check.
- Ankr Alternative: Public Endpoints, API Credits, and the Flat-Rate Option
Ankr's free public RPC is everywhere — until production. Here's where the public endpoints stop, how Ankr's API-credit pricing works, and when flat-rate is the better fit.