Smart Contract Security Best Practices: Verify On-Chain State Before You Trust It
A practical guide to one smart contract security habit every dApp developer should build: reading contract state through an RPC endpoint and handling failures explicitly, instead of trusting a single unverified response.
Most smart contract security advice is about the contract itself: reentrancy guards, access control, integer handling. That matters, but a large share of real incidents start on the client side, where a dApp trusts a single RPC response and acts on it without checking. This guide gives you one concrete task you can finish today: write a small Node.js script that reads a contract's state over JSON-RPC, validates the shape of the response, and fails loudly and safely when the node returns an error, a stale block, or something unexpected. Answer first: the habit is to treat every RPC read as untrusted input, verify it against the chain context you asked for, and never let a failed read silently become a default value.
Why RPC reads are a security surface
Your contract may be correct, but your frontend or bot only knows what an RPC node tells it. If that node is behind, misconfigured, or returns an error you swallow, your app can display a wrong balance, enable a button that should stay disabled, or submit a transaction based on stale state. None of that is a contract bug, but all of it is a security problem for users.
The fix is not exotic. It is the same discipline you apply to user input: define what a valid response looks like, check the block context, and handle failure explicitly. RPC access and indexing are different tools here. An RPC call answers a point-in-time question about current chain state. An indexer answers historical or aggregate questions across many blocks. Use RPC when you need the latest value right now; use indexing when you need to query history or join data across contracts. Mixing them up leads to either slow, fragile reads or stale answers.
If you are still choosing an endpoint, the OnFinality RPC Assistant helps you match a network to your use case, and the OnFinality Learn section covers the basics of how RPC and indexing differ in practice.
- Treat every RPC response as untrusted input, even from a provider you pay for.
- Record the block number you read at, so you can reason about staleness.
- Never let a failed read fall back to zero, empty, or a cached value without telling the caller.
- Use RPC for current state; use an indexer for history and aggregates.
The task: read and validate contract state
Here is the concrete task. Using Node.js 18 or later, call eth_call against an ERC-20-style contract to read decimals() and totalSupply(), then validate the response before using it. You will need an RPC endpoint for the network you are targeting. Do not hardcode an endpoint you found in a tutorial; get one for the specific network from a provider you trust, for example the network pages linked from the OnFinality RPC Assistant.
The script below uses only the built-in fetch available in Node 18+, so there are no dependencies to install. It encodes the function selectors by hand so you can see exactly what is being sent, and it checks three things: that the JSON-RPC response has no error, that result is a hex string of the expected length, and that the decoded values are sane.
Run it with RPC_URL and CONTRACT set as environment variables. The expected result is two log lines: the token decimals and the total supply as a decimal string. If anything is wrong, the script throws with a specific message instead of printing a number.
- Function selectors:
decimals()is0x313ce567,totalSupply()is0x18160ddd. - A valid
eth_callresult for auint8is a 32-byte hex word, so 66 characters including0x. BigIntavoids the precision loss you get fromparseInton large supplies.
// read-state.mjs — Node.js 18+
const RPC_URL = process.env.RPC_URL;
const CONTRACT = process.env.CONTRACT;
if (!RPC_URL || !CONTRACT) {
throw new Error('Set RPC_URL and CONTRACT environment variables');
}
async function rpc(method, params) {
const res = await fetch(RPC_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} from RPC endpoint`);
}
const body = await res.json();
if (body.error) {
throw new Error(`RPC error ${body.error.code}: ${body.error.message}`);
}
if (typeof body.result !== 'string') {
throw new Error(`Unexpected result type: ${typeof body.result}`);
}
return body.result;
}
function decodeUint(hex, label) {
if (!/^0x[0-9a-fA-F]{64}$/.test(hex)) {
throw new Error(`${label}: expected 32-byte word, got ${hex}`);
}
return BigInt(hex);
}
const blockTag = 'latest';
const decimalsHex = await rpc('eth_call', [
{ to: CONTRACT, data: '0x313ce567' },
blockTag,
]);
const supplyHex = await rpc('eth_call', [
{ to: CONTRACT, data: '0x18160ddd' },
blockTag,
]);
const decimals = decodeUint(decimalsHex, 'decimals');
const supply = decodeUint(supplyHex, 'totalSupply');
if (decimals > 36n) {
throw new Error(`decimals out of range: ${decimals}`);
}
if (supply === 0n) {
console.warn('Warning: totalSupply is zero; confirm this is expected');
}
console.log('decimals:', decimals.toString());
console.log('totalSupply:', supply.toString());Expected result: two lines such as decimals: 18 and totalSupply: 1000000000000000000000000. The exact numbers depend on the contract you point at, so verify them against a block explorer before trusting the output.
Failure handling is the point of this exercise. If the endpoint returns an HTTP error, a JSON-RPC error object, a malformed result, or a value outside a sane range, the script stops with a message naming the problem. That is the behavior you want in production: a loud, specific failure rather than a silent zero that later becomes a wrong transaction.
Add block context and a second opinion
Reading at latest is convenient but tells you nothing about which block you saw. For anything security-sensitive, pin the block number and record it. You can fetch the current block with eth_blockNumber, then pass that hex block number as the blockTag in your eth_call. Now your read is reproducible: anyone can re-run it at the same block and get the same answer.
For high-value decisions, query a second independent endpoint and compare. If two providers disagree at the same block number, that is a signal to stop and investigate, not to pick the answer you like. This is cheap to implement and catches misconfigured or lagging nodes. It is also a good moment to remember the RPC-versus-indexing split: comparing two eth_call results at one block is an RPC job, while asking how a balance changed over the last thousand blocks is an indexing job.
Do not assume a provider is fast or reliable because a page says so. Measure it yourself: log the wall-clock time around each fetch, run the script a few dozen times, and look at the distribution of latencies and error rates. That gives you real numbers for your own network conditions instead of borrowed claims.
- Pin reads to a specific block number when the result drives a decision.
- Compare two independent endpoints at the same block for high-value reads.
- Measure latency and error rate yourself; do not rely on marketing numbers.
- Keep RPC reads for current state and use an indexer for historical queries.
Where this fits in a security checklist
This read-validation habit sits alongside the contract-level practices you already know. Contract-side: reentrancy guards, checked arithmetic, explicit access control, and tested upgrade paths. Client-side: validated RPC reads, pinned block context, explicit failure handling, and a second source for critical values. Both halves matter, and the client half is the one most teams skip.
Once your reads are trustworthy, the next question is usually historical: how did this value change, who called what, and when. That is where indexing earns its place. SubQuery-style indexing lets you query structured historical data instead of replaying thousands of RPC calls. Keep the two layers separate in your architecture so a slow indexer never blocks a live read, and a flaky RPC node never corrupts your history.
If you want to go deeper on the tooling around these patterns, start with the OnFinality Learn guides, then use the OnFinality RPC Assistant to pick the network and endpoint you need for your next read-validation test.
- Pair contract-side hardening with client-side read validation.
- Pin block numbers and fail loudly on bad responses.
- Use indexing for history, RPC for current state, and keep them decoupled.
- Re-run your validation script against your real endpoint before every release.