Sending a Raw Transaction: Why eth_sendTransaction Doesn't Work on a Provider

August 18, 2026 · 5 min read · #ethereum #rpc #tutorial #web3

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_sendTransaction asks 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 — so eth_accounts is empty and eth_sendTransaction fails with "unknown account."
  • eth_sendRawTransaction takes 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:

  1. 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.
  2. Price it. Estimate the gas limit with eth_estimateGas, and set your fees from the current base fee (via eth_feeHistory or the latest block's baseFeePerGas) plus a priority tip. Getting this wrong gets your transaction stuck or overpaying — the mechanics are in estimating gas right.
  3. Set the chain ID. eth_chainId, or a value you already know. This is mandatory (more below).
  4. 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.
  5. 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

  • chainId is 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_sendRawTransaction returns 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_sendRawTransaction is 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

Try SwiftNodes free — multi-chain RPC across 75+ networks, flat-rate pricing, pay by card or crypto, no KYC. Get an API key in 30 seconds →