Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ with `MetadataNotTrimmed` when there was no metadata trailer to trim at all,
before any hash comparison happens.

`checkNoSolidityCBORMetadata(account)` is the inverse: it reverts when metadata
is detected at all, for bytecode that was compiled with metadata disabled.
is detected at all, for bytecode that was compiled with metadata disabled. It
also reverts with `CodelessAccount` when the account has no code at all: no
absence check answers "no code" as a pass, because a codeless account can gain
any code later.

`isEOFBytecode` and `checkNotEOFBytecode` report and enforce that bytecode is
not EOF formatted.
Expand Down Expand Up @@ -122,6 +125,14 @@ against `METAMORPHIC_OPS` and returns the risky opcodes that are reachable.
`checkNotMetamorphic` reverts with `Metamorphic(riskyOpcodes)` when that result
is non-zero.

Both functions also have address-taking entry points that read the account's
code, revert with `CodelessAccount` when there is none, and delegate to the
bytes functions otherwise. An account with no code is the maximally metamorphic
state — an unoccupied `CREATE2` target, a self-destructed account between
incarnations, or an EOA that can gain code by EIP-7702 delegation — so only the
address boundary has the information to refuse to vouch for it. The bytes entry
points stay total over bytes and answer only about the bytes given.

One fundamental hard requirement of an interpreter is that it is NOT mutable.
Most obviously this includes `SELFDESTRUCT` as that would allow for things like
metamorphic languages, which would completely undermine the integrity of any
Expand Down
12 changes: 7 additions & 5 deletions src/interface/IExtrospectV1.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ pragma solidity ^0.8.25;
/// rain.extrospection.deploy.
/// @dev The custom errors named below are declared by the libraries, not by
/// this interface. `EOFBytecodeNotSupported`, `MetadataNotTrimmed`,
/// `BytecodeHashMismatch` and `UnexpectedMetadata` come from
/// `LibExtrospectBytecode`; `Metamorphic` comes from
/// `BytecodeHashMismatch`, `UnexpectedMetadata` and `CodelessAccount` come
/// from `LibExtrospectBytecode`; `Metamorphic` comes from
/// `LibExtrospectMetamorphic`.
interface IExtrospectV1 {
/// @notice Reads `account`'s runtime bytecode, trims trailing Solidity CBOR
Expand All @@ -33,9 +33,11 @@ interface IExtrospectV1 {
/// @notice Reads `account`'s runtime bytecode and reverts
/// `UnexpectedMetadata` when its last 53 bytes are the exact Solidity CBOR
/// metadata structure `tryTrimSolidityCBORMetadata` recognises. Reverts
/// `EOFBytecodeNotSupported` when `account`'s bytecode is EOF. Returns
/// nothing when no such metadata is detected, including an account with no
/// code, and metadata in any other shape.
/// `CodelessAccount(account)` when `account` has no code: absence of code
/// is not absence of metadata risk, so the check refuses to vouch for a
/// codeless account. Reverts `EOFBytecodeNotSupported` when `account`'s
/// bytecode is EOF. Returns nothing when the account has code in which no
/// such metadata is detected, including metadata in any other shape.
/// @dev See `LibExtrospectBytecode.checkNoSolidityCBORMetadata`.
/// @param account The account whose runtime bytecode is read and checked.
function checkNoSolidityCBORMetadata(address account) external view;
Expand Down
34 changes: 31 additions & 3 deletions src/lib/LibExtrospectBytecode.sol
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,25 @@ library LibExtrospectBytecode {
/// `tryTrimSolidityCBORMetadata` matches.
error UnexpectedMetadata();

/// Thrown when an address-taking absence check is asked about an account
/// that has no code. An account with no code can gain any code later — an
/// unoccupied `CREATE2` target, a self-destructed account between
/// incarnations, or an EOA that can gain code by EIP-7702 delegation — so
/// absence of code proves nothing and no absence check answers "no code"
/// as a pass.
/// @param account The account that has no code.
error CodelessAccount(address account);

/// Returns whether the first two bytes of the bytecode are the EOF magic
/// `0xEF00`. The version byte that follows the magic in an EIP-3540
/// container is not read, so `0xEF00` alone and `0xEF00` followed by any
/// version byte are both reported as EOF. Bytecode shorter than two bytes
/// is not reported as EOF, and neither is bytecode starting with `0xEF`
/// followed by any byte other than `0x00`, including the `0xEF01` of an
/// EIP-7702 delegation designator.
/// is not reported as EOF — explicitly including empty bytecode, such as
/// the code of an account that has none — and neither is bytecode starting
/// with `0xEF` followed by any byte other than `0x00`, including the
/// `0xEF01` of an EIP-7702 delegation designator. A `false` result says
/// the bytes are not an EOF container; over empty bytes it says nothing
/// about what code a codeless account may later gain.
/// @param bytecode The bytecode to check.
/// @return isEOF Whether the first two bytes are `0xEF00`.
function isEOFBytecode(bytes memory bytecode) internal pure returns (bool isEOF) {
Expand Down Expand Up @@ -233,10 +245,18 @@ library LibExtrospectBytecode {
/// plus three bytes. Returning without reverting therefore establishes that
/// this one layout is absent, not that the account carries no metadata.
///
/// NOTE an account with no code reverts with `CodelessAccount` carrying
/// the address, before metadata detection is attempted. Absence of code
/// is not absence of metadata risk: a codeless account can gain any code
/// later, so this check refuses to vouch for it.
///
/// @param account The account whose bytecode to check.
//forge-lint: disable-next-line(mixed-case-function)
function checkNoSolidityCBORMetadata(address account) internal view {
bytes memory bytecode = account.code;
if (bytecode.length == 0) {
revert CodelessAccount(account);
}
bool didTrim = tryTrimSolidityCBORMetadata(bytecode);
if (didTrim) {
revert UnexpectedMetadata();
Expand All @@ -263,6 +283,10 @@ library LibExtrospectBytecode {
/// Adapted from https://github.com/MrLuit/selfdestruct-detect/blob/master/src/index.ts
/// NOTE: Reverts with `EOFBytecodeNotSupported` if the bytecode is EOF
/// (EIP-7692).
/// NOTE: Empty bytecode scans to a zero bitmap: there are no bytes, so no
/// opcode is reachable. The empty code of a codeless account scans the
/// same way, and zero says nothing about what opcodes that account may
/// later gain.
/// @param bytecode The bytecode to scan.
/// @return bytesReachable A `uint256` where each bit represents the presence
/// of a reachable opcode in the source bytecode.
Expand Down Expand Up @@ -321,6 +345,10 @@ library LibExtrospectBytecode {
/// https://github.com/a16z/metamorphic-contract-detector/blob/main/metamorphic_detect/opcodes.py#L52
/// NOTE: Reverts with `EOFBytecodeNotSupported` if the bytecode is EOF
/// (EIP-7692).
/// NOTE: Empty bytecode scans to a zero bitmap: there are no bytes, so no
/// opcode is present. The empty code of a codeless account scans the same
/// way, and zero says nothing about what opcodes that account may later
/// gain.
/// @param bytecode The bytecode to scan.
/// @return bytesPresent A `uint256` where each bit represents the presence
/// of an opcode in the source bytecode.
Expand Down
6 changes: 6 additions & 0 deletions src/lib/LibExtrospectERC1167Proxy.sol
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ library LibExtrospectERC1167Proxy {
/// A `false` result says that the bytecode is not the canonical minimal
/// proxy. It does not say that the account runs its own code.
///
/// Empty bytecode is not 45 bytes long, so it returns
/// `(false, address(0))` like every other length mismatch — explicitly
/// including the empty code of a codeless account, for which `false`
/// says only that no proxy code is present now, not that the account can
/// never gain any.
///
/// The verdict is a function of the 45 bytes alone. It is the same whether
/// or not the implementation account exists or has code.
///
Expand Down
45 changes: 44 additions & 1 deletion src/lib/LibExtrospectMetamorphic.sol
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ import {METAMORPHIC_OPS} from "./EVMOpcodes.sol";

/// @title LibExtrospectMetamorphic
/// @notice Detection and guarding against metamorphic contract risk. Scans
/// bytecode for reachable opcodes in `METAMORPHIC_OPS`.
/// bytecode for reachable opcodes in `METAMORPHIC_OPS`. The bytes-taking
/// entry points are total over bytes and answer only about the bytes given;
/// the address-taking entry points bind the verdict to an account and revert
/// with `CodelessAccount` rather than vouch for an account that has no code.
library LibExtrospectMetamorphic {
/// Thrown when metamorphic risk opcodes are reachable in bytecode.
/// @param riskyOpcodes Bitmap of reachable metamorphic opcodes.
error Metamorphic(uint256 riskyOpcodes);

/// Scans bytecode for reachable metamorphic risk opcodes. Reverts with
/// `EOFBytecodeNotSupported` if the bytecode is EOF.
/// Answers only about the bytes given. Use `scanMetamorphicRisk(address)`
/// to bind the scan to an account and reject a codeless one.
/// @param bytecode The bytecode to scan.
/// @return riskyOpcodes Bitmap of reachable metamorphic opcodes. Zero if
/// no metamorphic risk opcodes are reachable, including when `bytecode` is
Expand All @@ -30,11 +35,49 @@ library LibExtrospectMetamorphic {
/// `CREATE2` target, or a self-destructed account — passes this check on
/// the same terms as an account whose code has no reachable metamorphic
/// opcodes. Whether the account has any code at all is not checked here.
/// Use `checkNotMetamorphic(address)` to bind the verdict to an account
/// and reject a codeless one.
/// @param bytecode The bytecode to check.
function checkNotMetamorphic(bytes memory bytecode) internal pure {
uint256 riskyOpcodes = scanMetamorphicRisk(bytecode);
if (riskyOpcodes != 0) {
revert Metamorphic(riskyOpcodes);
}
}

/// Reads `account`'s code and scans it for reachable metamorphic risk
/// opcodes, delegating to `scanMetamorphicRisk(bytes)`. Reverts with
/// `CodelessAccount` carrying the address when the account has no code:
/// an account with no code is the maximally metamorphic state — an
/// unoccupied `CREATE2` target, a self-destructed account between
/// incarnations, or an EOA that can gain code by EIP-7702 delegation —
/// and the chain cannot distinguish "can never gain code" from "can gain
/// anything", so a zero bitmap for it would vouch for nothing. Reverts
/// with `EOFBytecodeNotSupported` if the account's code is EOF, as the
/// bytes scan does.
/// @param account The account whose code to scan.
/// @return riskyOpcodes Bitmap of reachable metamorphic opcodes in the
/// account's code. Zero if none are reachable.
function scanMetamorphicRisk(address account) internal view returns (uint256) {
bytes memory bytecode = account.code;
if (bytecode.length == 0) {
revert LibExtrospectBytecode.CodelessAccount(account);
}
return scanMetamorphicRisk(bytecode);
}

/// Reads `account`'s code and reverts with `Metamorphic` if any
/// metamorphic risk opcodes are reachable in it, delegating to
/// `scanMetamorphicRisk(address)`. Reverts with `CodelessAccount`
/// carrying the address when the account has no code, on the same terms
/// as `scanMetamorphicRisk(address)`: no absence check answers "no code"
/// as a pass. Reverts with `EOFBytecodeNotSupported` if the account's
/// code is EOF.
/// @param account The account whose code to check.
function checkNotMetamorphic(address account) internal view {
uint256 riskyOpcodes = scanMetamorphicRisk(account);
if (riskyOpcodes != 0) {
revert Metamorphic(riskyOpcodes);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,14 @@ contract LibExtrospectBytecodeCheckNoSolidityCBORMetadataTest is Test {
offsets = [uint256(0), 1, 2, 3, 4, 5, 6, 7, 42, 43, 44, 45, 46, 47, 51, 52];
}

/// Account with no code passes (no metadata to detect).
function testCheckNoMetadataEmptyAccount() external view {
LibExtrospectBytecode.checkNoSolidityCBORMetadata(address(0xdead));
/// Account with no code reverts with `CodelessAccount` carrying the
/// address. An account with no code can gain any code later, so the
/// absence check refuses to vouch for it instead of passing it.
function testCheckNoMetadataRevertsOnCodelessAccount() external {
address codeless = address(0xdead);
assertEq(codeless.code.length, 0);
vm.expectRevert(abi.encodeWithSelector(LibExtrospectBytecode.CodelessAccount.selector, codeless));
this.checkNoSolidityCBORMetadataExternal(codeless);
}

/// Contract compiled without metadata passes. This project compiles with
Expand Down Expand Up @@ -81,9 +86,12 @@ contract LibExtrospectBytecodeCheckNoSolidityCBORMetadataTest is Test {
this.checkNoSolidityCBORMetadataExternal(target);
}

/// Fuzz: bytecode shorter than Solidity CBOR metadata passes. The
/// leading `STOP` keeps the bytecode out of the EOF format.
/// Fuzz: nonempty bytecode shorter than Solidity CBOR metadata passes.
/// The leading `STOP` keeps the bytecode out of the EOF format. Empty
/// bytecode is excluded: an account etched with empty code is codeless
/// and reverts with `CodelessAccount`, pinned deterministically above.
function testCheckNoMetadataPassesShortBytecode(bytes memory code) external {
vm.assume(code.length > 0);
uint256 length = code.length;
if (length >= SOLIDITY_CBOR_METADATA_LENGTH) {
length = SOLIDITY_CBOR_METADATA_LENGTH - 1;
Expand Down
9 changes: 9 additions & 0 deletions test/src/lib/LibExtrospectBytecode.isEOFBytecode.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ contract LibExtrospectBytecodeIsEOFBytecodeTest is Test {
assertFalse(LibExtrospectBytecode.isEOFBytecode(hex""));
}

/// The empty code of a codeless account is not EOF. Pins the documented
/// empty-input meaning at the account reading: `false` here says the
/// bytes are not an EOF container, not that the account has code.
function testIsEOFBytecodeCodelessAccount() external view {
address codeless = address(0xC2);
assertEq(codeless.code.length, 0);
assertFalse(LibExtrospectBytecode.isEOFBytecode(codeless.code));
}

/// Test that a single bytecode is not EOF.
function testIsEOFBytecodeSingleByte() external pure {
assertFalse(LibExtrospectBytecode.isEOFBytecode(hex"EF"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,15 @@ contract LibExtrospectBytecodeScanEVMOpcodesPresentInBytecodeTest is Test {
assertEq(LibExtrospectBytecode.scanEVMOpcodesPresentInBytecode(hex""), 0);
}

/// The empty code of a codeless account scans to a zero bitmap. Pins the
/// documented empty-input meaning at the account reading: zero here says
/// no opcodes were read, not that the account can never gain any.
function testScanEVMOpcodesPresentCodelessAccount() public view {
address codeless = address(0xC2);
assertEq(codeless.code.length, 0);
assertEq(LibExtrospectBytecode.scanEVMOpcodesPresentInBytecode(codeless.code), 0);
}

/// Test single-byte non-PUSH bytecodes.
function testScanEVMOpcodesPresentSingleByte() public pure {
// STOP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@ contract LibExtrospectScanEVMOpcodesReachableInBytecodeTest is Test {
assertEq(LibExtrospectBytecode.scanEVMOpcodesReachableInBytecode(hex""), 0);
}

/// The empty code of a codeless account scans to a zero bitmap. Pins the
/// documented empty-input meaning at the account reading: zero here says
/// no opcodes were scanned as reachable, not that the account can never
/// gain any.
function testScanEVMOpcodesReachableCodelessAccount() public view {
address codeless = address(0xC2);
assertEq(codeless.code.length, 0);
assertEq(LibExtrospectBytecode.scanEVMOpcodesReachableInBytecode(codeless.code), 0);
}

/// Test truncated PUSH1 at end of bytecode (no data byte following).
function testScanEVMOpcodesReachableTruncatedPush1() public pure {
assertEq(LibExtrospectBytecode.scanEVMOpcodesReachableInBytecode(hex"60"), 1 << 0x60);
Expand Down
20 changes: 20 additions & 0 deletions test/src/lib/LibExtrospectERC1167Proxy.isERC1167Proxy.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,26 @@ contract LibExtrospectERC1167ProxyTest is Test {
assertEq(implementationResult, implementation);
}

/// Empty bytecode is not the 45 byte proxy: `(false, address(0))`. Pins
/// the documented empty-input meaning.
function testIsERC1167ProxyEmpty() external pure {
(bool result, address implementationAddress) = LibExtrospectERC1167Proxy.isERC1167Proxy(hex"");
assertFalse(result);
assertEq(implementationAddress, address(0));
}

/// The empty code of a codeless account is not the 45 byte proxy. Pins
/// the documented empty-input meaning at the account reading: `false`
/// here says the bytes are not the canonical minimal proxy, not that the
/// account runs its own code or has any code at all.
function testIsERC1167ProxyCodelessAccount() external view {
address codeless = address(0xC2);
assertEq(codeless.code.length, 0);
(bool result, address implementationAddress) = LibExtrospectERC1167Proxy.isERC1167Proxy(codeless.code);
assertFalse(result);
assertEq(implementationAddress, address(0));
}

/// A 45-byte clone encoding `address(0)` as the implementation is detected
/// as a proxy, so `(true, address(0))` is a reachable return value and the
/// returned address alone does not distinguish a proxy from a non-proxy.
Expand Down
Loading
Loading