When working with Solidity contracts, view
or pure
functions generate straightforward “call” methods in Go. But what about functions that aren’t marked as view
? Can we still simulate them and get return values without submitting transactions?
The answer is yes: by using the eth_call
RPC under the hood, go-ethereum provides a Call
method that executes the function in an transitory environment. You can then retrieve the return value—even if the function increments some internal variable or does something that would normally cost gas in a real transaction.
How Call
Works in go-ethereum
In the Go bindings generated by abigen
, there’s a lower-level API on the BoundContract
or <ContractName>Raw
struct:
func (c *BoundContract) Call(
opts *CallOpts,
result *[]interface{},
method string,
params ...interface{},
) error
- It constructs an
ethereum.CallMsg
and issues aneth_call
request to your connected Ethereum node (e.g., Geth). - The node simulates the transaction (executes your Solidity code) but does not commit any changes to the blockchain, nor does it require gas from your account.
- Any attempted state writes are either:
- Performed temporarily and then discarded right after the call; or
- In some client configurations, lead to a
revert
if the node enforces strict “no writes ineth_call
” rules.
Either way, no permanent changes happen on-chain. However, the return value from the function (encoded in its ABI) is returned to you in result
.
Example: Non-View Function Returning a uint256
Suppose your Solidity function is:
uint256 public scoreVar;
function incrementAndGet() public returns (uint256) {
scoreVar++;
return scoreVar;
}
-
You haven’t marked it
view
, soabigen
will generate a transactor method returning(*types.Transaction, error)
. -
But you can still do:
// Assume you have a binding "myContractRaw" created via abigen or manually: myContractRaw := &MyContractRaw{Contract: myContractInstance} var outputs []interface{} err := myContractRaw.Call(nil, &outputs, "incrementAndGet") if err != nil { fmt.Printf("Call revert or RPC error: %v\n", err) return } // If successful, outputs[0] should be the incremented value newVal := outputs[0].(*big.Int) fmt.Printf("New value: %v\n", newVal)
-
When run with an
eth_call
, the node will executescoreVar++
, return the incremented value, then discard the change. On-chain,scoreVar
remains the same as before. -
If you call it again, the starting state is still the on-chain value. So if
scoreVar
on-chain was 0, everyeth_call
toincrementAndGet
will return1
(because each simulated call starts fromscoreVar = 0
, increments it to 1, returns 1, discards changes).
Why This Works Even If It “Writes” State
eth_call
is a simulation endpoint at the RPC level. It’s not a strict “STATICCALL” from the EVM’s perspective. Geth and most clients run the code in a sandbox:- They allow temporary modifications (
scoreVar++
). - They discard them afterward, returning only the final ABI-encoded output.
- They allow temporary modifications (
- No real gas is spent, and no actual state is updated on-chain.
Potential Reverts
Some node configurations might revert if your code tries to do certain modifications in eth_call
. However, the go-ethereum default is typically to permit temporary writes. The result from such writes is visible in your return but never persisted after the call ends.
Steps to Use Call
in Go
- Obtain a contract binding (usually via
abigen
) and a “raw” struct (e.g.,MyContractRaw
) that exposes lower-level operations. - Prepare a
result []interface{}
slice. That’s whereCall
will unpack the function’s return values. - Call:
err = myRawBinding.Call(nil, &result, "functionName", param1, param2, ...)
- The first parameter is
*CallOpts
(can benil
if you don’t need special context). - The second is a pointer to a slice of
interface{}
that will hold the decoded return values. - Followed by the name of the function and its parameters.
- The first parameter is
- If there’s no revert, check
result[i]
for your data. Usually you’ll convertresult[0]
into the expected type (e.g.,*big.Int
). - If there was a revert (or invalid call),
err != nil
.
Common Patterns
- Incrementing a Counter
You might see the returned value increment in memory but revert back on-chain after the call. - Reading / “Simulating” Complex Logic
You can test how a function behaves with specific inputs (maybe it calls multiple sub-contracts, etc.). You get the final output but no persistent effect. - Overriding
view
Sometimes a dev forgets to mark the function asview
in Solidity. It might still be effectively read-only, soeth_call
can retrieve its return value anyway.
Pitfalls & Considerations
-
No On-Chain Changes
”Success” from this call doesn’t mean anything was actually written. It’s purely a local node simulation. -
Every Call Resets State
If your function increments a variable, every call starts from the chain’s stored value, so you might repeatedly see the same “incremented” result. It never accumulates if you’re only usingeth_call
. -
Final State
To truly change on-chain state, you must send a transaction (e.g.,myContract.Transact(opts, "incrementAndGet", ...)
) instead ofCall
. -
Potential Reverts
If a node or dev environment enforces stricter “static call” semantics, writing state might cause a revert. Typically, Geth’s default mode allows transitory changes, but it’s worth testing if that’s consistent in your environment.
Comparison With Other Libraries
ethers.js – staticCall
In ethers.js v6, you can use .staticCall
on a contract method. In previous versions (ethers.js v5), this functionality was called .callStatic
. Refer to the migration guide for more details.
Regardless of the name, both approaches closely mirror eth_call
at the RPC level, meaning they simulate a transaction without broadcasting it to the network. Any writes made during this simulation are transitory and discarded afterward, and the method’s return data is provided to your application.
// Example in ethers.js v6
const contract = new ethers.Contract(contractAddress, abi, provider);
// The ".staticCall" simulates the function call without committing changes on-chain.
const result = await contract.incrementAndGet.staticCall();
console.log(result.toString()); // temporary increment, never actually updates on-chain
viem – simulateContract
Similarly, viem’s simulateContract
function accomplishes the same goal:
Simulates & validates a contract interaction. This is useful for retrieving return data and revert reasons of contract write functions.
This function does not require gas to execute and does not change the state of the blockchain.
Internally, simulateContract
uses a Public Client to issue a call action with ABI-encoded data. Any state modifications are discarded after execution, allowing you to see return data (or revert reasons) without permanently altering the blockchain.
Conclusion
Go-ethereum’s Call
method provides a powerful way to simulate non-view
Solidity functions without gas costs or chain updates. While it won’t persist changes, it’s invaluable for:
- Testing complex contract logic
- Previewing function outcomes
- Working with improperly marked functions
Remember: use view
when possible, Call
for simulations, and real transactions for permanent changes.