Diagnosing Empty Returndata and Opaque Reverts in Vyper raw_call
A Vyper raw_call can fail silently when returndata is empty or misread. Learn to classify revert data, fix unchecked low-level calls, and know when to escalate.
16 Sept 2025, 12:31 UTC

When a Vyper contract uses raw_call to reach another contract, the call can fail without halting your transaction. If your code checks only len(returndata) == 0, a revert with empty data looks identical to a successful call that returns nothing. The practical takeaway: raw_call is a low-level primitive, not a typed interface call. You must capture the success flag, classify any revert bytes, and raise your own error when the callee misbehaves.
Recognizable condition
The condition appears in three common forms:
- After
raw_call,len(returndata) == 0, yet the external contract actually reverted and execution continues. - The transaction reverts with a bare
0xfeopcode (INVALID) or an empty reason string, giving no clue which call failed. - A typed interface call on the same target bubbles a readable reason, but the
raw_callpath swallows it.
These symptoms usually mean the caller treated the low-level call as if it auto-reverts on failure. It does not.
Cause and diagnostic table
Use this table to map what you observe to the likely cause and the immediate check.
| Symptom | Likely cause | Diagnostic check |
|---|---|---|
| Empty returndata, no state change | Callee reverted with empty reason, or caller treated “no return” as failure | Inspect first 4 bytes: 0x08c379a0 = Error(string), 0x4e487b71 = Panic, other 4 bytes = custom error. Compare against a control call that returns data. |
| Returndata present but misread | max_outsize truncation or wrong return ABI | Decode with the declared return type; increase max_outsize to the true return size. |
Bare 0xfe/INVALID at top level | assert or an unbubbled failed call | Trace the opcode; separate Vyper assert/raise from raw_call returndata. |
Ordered checks
Work through these steps before changing code.
- Reproduce minimally. Build a harness that calls a known-reverting target and a known-returning target through the same built-in. Capture the full returndata buffer, not just a boolean.
- Classify the bytes. Empty,
0x08c379a0Error(string),0x4e487b71Panic, or a 4-byte custom-error selector. - Confirm the callee. Verify the target address has code and that the called selector matches the intended function.
- Match the context. Re-run with identical gas, value, and block context to rule out stipend or environment differences.
- Compare call styles. Run the same path through a typed interface external call, which may bubble the reason, and diff the behavior against the low-level call.
- Record the toolchain. Note the exact Vyper compiler version and built-in defaults before editing.
Verify your compiler and harness
Run these in your project terminal; no elevated permissions are needed.
vyper --version
# Replace <harness.vy> with your minimal reproduction contract.
vyper <harness.vy>
Expected check: the version string must match the release your deployment pipeline uses. If it does not, pin the compiler before drawing conclusions.
Risk: compiling with a different version can silently change built-in signatures and revert-bubbling defaults.
Fixes tied to findings
Empty-reason reverts
If the callee reverts with no data, do not infer success from buffer length. Require an explicit success return and raise a custom error on mismatch.
# Sketch only; verify the exact raw_call signature against your pinned compiler.
error CallFailed()
@external
def checked_call(target: address, payload: Bytes[128]) -> Bytes[64]:
success: bool = False
response: Bytes[64] = b""
success, response = raw_call(
target,
payload,
max_outsize=64,
value=0
)
if not success:
# response may contain revert bytes; classify or discard as policy requires.
raise CallFailed()
return response
If your pinned version does not expose a success flag from raw_call, you cannot reliably distinguish an empty revert from an empty success. In that case, prefer a typed interface call or move to a version that surfaces the flag.
Misread returndata
When data is present but garbled, check max_outsize against the callee's declared return size. If the callee returns 96 bytes and max_outsize=32, you will silently truncate. Set max_outsize to the exact expected size and decode with the matching Vyper type.
Bare INVALID opcodes
If the top-level failure is a bare 0xfe, look for assert statements or failed calls whose revert data was discarded. Replace assert with explicit raise or custom-error paths, and stop swallowing low-level failures. When you do not need raw gas or callcode control, use a typed interface external call so the compiler can enforce failure checks and bubble the reason.
Escalation criteria
Escalate to a security review when any of the following apply:
- The call is value-bearing or uses
delegatecall. - The path touches user funds or is reachable by an untrusted caller.
- Behavior differs across compiler versions in ways you cannot explain.
- Returndata is neither empty nor a known selector, which can indicate proxy, metadata, or self-destruct edge cases.
Limitations and practical verification
Vyper built-in names, keyword arguments, and defaults for bubbling revert reasons are version-sensitive. Treat every signature above as a hypothesis to check against your installed toolchain. Do not infer success solely from len(returndata) == 0; without a success flag, an empty revert and an empty success are indistinguishable.
To verify a fix, compile a harness with the same pinned Vyper version that calls a known-reverting target and a known-returning target through the same built-in. Print len(returndata) and the first four bytes to confirm classification. Then run the same transaction on a local fork with identical gas, value, and block context, and diff the returndata against a testnet or mainnet trace. For value-bearing or delegatecall paths, add a failing-case unit test that asserts your custom error is raised, confirm it fails before the fix, and passes after.