For developers / JSON-RPC

Sending a transaction

Live

Sending is ordinary. Reading the result is not: a receipt exists before the block does, and a revert arrives as text your library cannot decode.

Three things that can be true

On most chains "sent", "mined" and "final" collapse into one wait. Here they are three separate events, and knowing which one you are testing for is most of writing a correct client:

  • Executed. eth_sendRawTransaction blocks until the transaction has actually run, so a successful return already means executed - not queued. A revert is known at this point.
  • Preconfirmed. Within the mini-block scheduling target, a receipt exists. Its blockNumber and blockHash are still null.
  • Confirmed. At the EVM block scheduling target the block seals and those two fields fill in.

Waiting for a block number is waiting for the wrong thing

A library that polls until receipt.blockNumber is non-null is waiting for the confirmation tier when the answer it wants - did this succeed - was available at the preconfirmation tier. That is not a bug, but it does mean you are waiting longer than you need to. Read status as soon as the receipt exists.

Finality is a fourth and separate thing: it happens when the containing batch settles on Ethereum, and is not observable through these fields at all. The block tags do not help either - safe and finalized both resolve to latest.

Sending

Sign locally and send raw. The node holds no keys, so eth_sendTransaction and every signing method answer -32601 by design.

typescript
import { createWalletClient, custom } from "viem";
import { pickleTestnet } from "./pickle";

const wallet = createWalletClient({ chain: pickleTestnet, transport: custom(window.ethereum) });

const hash = await wallet.sendTransaction({
  to: recipient,
  value: 10n ** 16n,
  // Explicit, because there is no fee market to estimate from: the base fee is
  // zero and the minimum accepted price is one wei.
  gasPrice: 1n,
});

Or against a raw EIP-1193 provider, with no library at all. Note which side signs: eth_sendTransaction here is handled by the wallet, which signs and then submits the raw transaction for you - the node itself refuses that method.

javascript
const [account] = await window.ethereum.request({ method: "eth_requestAccounts" });

const hash = await window.ethereum.request({
  method: "eth_sendTransaction",
  params: [{ from: account, to: recipient, value: "0x2386f26fc10000", gasPrice: "0x1" }],
});

Reading the receipt

A missing receipt here is a problem, not patience

null from eth_getTransactionReceipt means the node has no receipt for that hash - and on this chain one normally exists within the mini-block scheduling target, so a null after a second or two is a signal to investigate rather than to keep waiting.

It is also ambiguous by design: a receipt that has been evicted from the window answers null exactly like one that never existed. That is why a long polling backoff is the wrong shape here - wait too long between attempts and the receipt can disappear between two of them.

javascript
// Poll early and tightly: the receipt exists almost immediately, and a long
// backoff risks the window evicting it between two attempts.
async function receiptFor(hash, tries = 40) {
  for (let i = 0; i < tries; i += 1) {
    const receipt = await call("eth_getTransactionReceipt", [hash]);
    if (receipt) return receipt;
    await new Promise((r) => setTimeout(r, 100));
  }
  throw new Error("no receipt after 4s - check the node, not the transaction");
}

const receipt = await receiptFor(hash);

// "0x1" succeeded, "0x0" reverted. Both are known before the block seals.
if (receipt.status !== "0x1") throw new Error("transaction reverted");

// Only if you actually need the block: blockNumber is null until the EVM seal.

With viem, waitForTransactionReceipt does this for you - but set pollingInterval low and do not raise its timeout on the assumption that waiting longer is safer. Here it is not.

Two fields on that receipt do not mean what they do elsewhere. cumulativeGasUsed is this transaction's own gas rather than a running block total, and logIndex on each log is enumerated per transaction rather than per block - so (blockNumber, logIndex) is not a unique key. Include the transaction hash if you are storing events.

When it reverts

Your library cannot decode the reason

A revert comes back as message text - execution reverted: 0x… - and error.data is never populated. Every library decodes custom errors and revert strings out of error.data, so on this chain none of them can: viem and ethers will both surface the raw message instead of a named error.

The return data is in the message, so you can decode it yourself:

typescript
import { decodeErrorResult } from "viem";

try {
  await client.simulateContract({ /* … */ });
} catch (err) {
  const hex = String(err.message).match(/0x[0-9a-fA-F]+/)?.[0];
  if (hex && hex.length > 2) {
    // Decode against your own ABI's error definitions.
    const decoded = decodeErrorResult({ abi: myAbi, data: hex as `0x${string}` });
    console.error(decoded.errorName, decoded.args);
  }
}

One more transport difference worth knowing: the same revert is -32000 over HTTP and -32603 over the WebSocket, because the socket re-wraps every non-send error. A client that branches on the code needs to know which transport it is on.

Gas

Set gasPrice explicitly to 1 wei. There is no fee market to estimate against: the base fee is zero, eth_gasPrice answers one wei, and one wei is also the minimum the chain will accept.

eth_estimateGas works and simulates properly, adding the usual headroom - but it ignores the gas, gasPrice, nonce and block parameters you send it, always simulating at the 30,000,000 block limit against latest state. And do not read eth_feeHistory: it returns constants rather than measurements.

Two limits that surface as -32005

A raw transaction over 128 KiB is rejected on size before it is even decoded, and the admission queue holds 64 pending transactions per sender. Both answer -32005, which means a limit rather than a mistake - back off and retry rather than treating it as a failure.