--- rip: 7614 title: Expose call stack to contracts description: Implement a call stack to record opcodes, addresses and function selectors and expose through a precompiled contract. author: Caner Çıdam (@canercidam) , Jonathan Alexander (@jalex206) , Andrew Beal (@ajbealETH) , Ariel Tempelhof (@ArielTM) , Oren Fine (@orenfine) , Assaf Eli (@assafIronblocks) , Or Dadosh (@ordd) , Idan Levin , Alejandro Navarro (Grover-a5) discussions-to: https://ethereum-magicians.org/t/rip-expose-call-stack-to-contracts/18535 status: Draft type: Standards Track category: Core created: 2024-07-02 --- ## Abstract Implement a call stack to record opcodes, addresses and function selectors and expose through a precompiled contract. If implemented, the precompile will give protocols deeper visibility into addresses involved at any point in execution. ## Motivation This proposal seeks to advance smart contract security in the Ethereum L2 ecosystem by enabling more robust exploit prevention solutions that depend on deeper visibility into the transaction call stack. Threat detection has advanced a lot in the last year. There are at least a dozen security projects focused on monitoring and exploit detection, and collectively they are proving that attackers can be identified in advance. Early detection hinges on being able to identify malicious smart contracts as soon as they are deployed on-chain using a combination of static and dynamic analysis. Once malicious contracts are flagged, protocols can screen incoming transactions and revert if they include one of these addresses. In parallel, anomaly detection - identifying transactions outside of normal user behavior - is also emerging as a legitimate approach to preventing exploits. Being able to consistently identify attackers and anomalies in advance opens the door to transaction screening, where protocols can choose to automatically revert transactions from high risk entities, or transactions that fall significantly outside "expected behavior". One technical challenge limiting the long-term effectiveness of transaction screening is address visibility. Today, a smart contract only has visibility into `msg.sender` and `tx.origin`, not the full call stack. An attacker can use various forms of proxies to "obfuscate" the true source of the call and circumvent detection. While these circumvention techniques are not being used today, we expect hackers to quickly adopt them once transaction screening becomes more pervasive. This proposal introduces a non-intrusive way to increase visibility into hackers' obfuscation techniques by keeping track of the call stack and exposing the latest list via an EVM precompiled contract when requested at any specific point of EVM execution. This helps the contracts screen more addresses and patterns before proceeding. ## Specification The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.html) and [RFC 8174](https://www.ietf.org/rfc/rfc8174.html). ### Constants | Name | Value | |----------------------------------|----------| | PRECOMPILE_ADDRESS | TBD | | CALL_STACK_PER_CALL_COST | 5 | | PRECOMPILE_BASE_GAS_COST | TBD | | PRECOMPILE_PER_CALL_COST | 2 | ### Call A Call is defined by an opcode, an address and a function selector. The selector MUST be the first four bytes of the call data for CALL-style opcodes, if call input is non-zero. For CREATE-style opcodes it MUST be an empty value. ### CallStack This is proposed as a new type of stack for client implementations to include and is based on the "message-call/contract-creation stack" mentioned in [Ethereum Yellow Paper](https://ethereum.github.io/yellowpaper/paper.pdf). It is separate from the machine stack used for execution. CallStack MUST be initialized per transaction simulation, unlike how a new machine stack is initialized per call frame. All operations that create a new call frame MUST push a Call to the CallStack and MUST pop after the call frame has exited. In the Cancun hard fork specification, this list operations consists of `CALL`, `CALLCODE`, `DELEGATECALL`, `STATICCALL`, `CREATE` and `CREATE2`. The initial transaction call frame MUST always be pushed as a Call when the transaction execution starts and must be popped when the execution finishes. ### Precompiled contract The call stack SHOULD implement the precompiled contract interface defined in the client implementation and MUST encode and return the contents of the call stack. The encoder logic MUST follow [Solidity Contract ABI Specification](https://docs.soliditylang.org/en/latest/abi-spec.html) to encode the list of Calls. While this provides an encoding standard and Solidity friendliness, it does not introduce any difficulty in supporting another language, since the encoded bytes are trivial to parse. The encoder MUST write each value as a 32-byte padded word as in below pseudocode: ``` let b: ByteArray b.append(0x20) // default offset b.append(len(CallStack)) for call in CallStack: b.append(call.Op) b.append(call.Address) b.append(call.Selector) ``` Assuming that the call stack has one call such as - Opcode: `CALL` (`0xf1`) - Address: `0xafafafafafafafafafafafafafafafafafafafaf` - Selector: `0xabcdef12` then the 32-byte words in the encoded output would be: ``` 0000000000000000000000000000000000000000000000000000000000000020 // default offset 0000000000000000000000000000000000000000000000000000000000000001 // call stack size 00000000000000000000000000000000000000000000000000000000000000f1 // CALL opcode 000000000000000000000000afafafafafafafafafafafafafafafafafafafaf // address 00000000000000000000000000000000000000000000000000000000abcdef12 // selector ``` Please note that the original output is a contiguous array and is not line-delimited. ### Gas costs #### Call stack gas cost The call stack implementation adds minimal and negligible overhead to EVM execution. Each call stack item is smaller than 32-byte words pushed to the machine stack. For these reasons, clients MAY charge transaction senders a total of `CALL_STACK_PER_CALL_COST` per Call pushed/popped to/from the CallStack. This value is chosen to follow the total cost of `PUSH1` and `POP`. Given that `CALL_STACK_PER_CALL_COST` is very small compared to costs of the opcodes listed in the [CallStack](#callstack) section, it is OPTIONAL to charge transaction senders with this amount. #### Precompiled contract gas cost ``` PRECOMPILE_BASE_GAS_COST + PRECOMPILE_PER_CALL_COST * len(CallStack) ``` Encoder overhead at the time of precompile execution is at a reasonable level when a reference implementation is benchmarked against readily available precompiled contracts. For this reason, `PRECOMPILE_BASE_GAS_COST` SHOULD be introduced to cover any base level overhead from encoding and the call stack. `PRECOMPILE_PER_CALL_COST` value SHOULD either be equal to or greater than the gas cost of `CALLER` and `ADDRESS` opcodes so that competition would be avoided if `PRECOMPILE_BASE_GAS_COST` was chosen as zero and when CallStack size is one. Having a per-call gas cost is also in line with scaling up the precompile cost with every call. ### Precompile call example ```solidity contract CallStack { address constant PRECOMPILE_ADDRESS = address(...); // TBD struct Call { uint8 opCode; address addr; uint32 selector; } function getCallStack() internal returns (Call[] memory) { (, bytes memory returndata) = address(PRECOMPILE_ADDRESS).call(bytes("")); Call[] memory calls = abi.decode(returndata, (Call[])); return calls; } } ``` ## Rationale ### Observing addresses beyond `tx.origin` and `msg.sender` This functionality is useful in determining the other contracts involved in a transaction up to the point of execution, between `tx.origin` and the latest `msg.sender`. Moreover, blackhats deploy attack contracts before launching the attack and that gives a time frame to scan and detect the malicious contract. If the attack transaction does a `DELEGATECALL` from a proxy to the attack contract, to call the victim contract, then the `msg.sender` observed by the victim contract is the proxy contract (and not the actual malicious attack contract). The call stack breaks this evasion by exposing the `DELEGATECALL`ed addresses in the call stack. ### Evasion concerns Logic exploits happen in the form of a malicious contract calling a victim contract, directly or indirectly, one or many times. Off-chain detection mechanisms are able to update on-chain risk/reputation oracles after analyzing deployed contracts. This means that, a screening solution that uses the call stack should ideally check each address either in a whitelist oracle _or_ check contract age and then check in a negative reputation oracle. Combined with such checks, any timing of attack contract deployment and attack transaction/call will not help an attacker evade on-chain detection and this proposal's aim to make extra visibility useful will succeed. However, please note that this proposal does not try to suggest or solve anything whatsoever about how such checks should be implemented since we intend to solve only the visibility disadvantage protocols have and would like to level protocols' transaction visibility with attackers. ### Account abstraction support By design, the call stack and the precompiled contract support the account abstraction outlined in [ERC-4337](https://ercs.ethereum.org/ERCS/erc-4337). In ERC-4337, UserOperations are calls to (non-EOA) smart wallets validated and bundled into a single transaction to be executed. In the reference implementations, each UserOperation is executed sequentially and isolated from each other. The call stack implementation maintains this isolation by not exposing addresses from one UserOperation to another. A smart wallet and any deeper callees are only exposed to the global singleton EntryPoint contract commonly with other UserOperation execution. ### Pattern checks With the help of the opcodes and function selectors, contracts can implement security mechanisms which reason about the call patterns in a transaction. We believe that this is one step further in implementing transparent on-chain anomaly-based threat prevention solutions. ### Address checks While this proposal enhances visibility into addresses involved in a transaction, it does not prescribe how such addresses should be checked. Combining this call stack precompile with complimentary checks for contract reputation and age checks will further enhance transaction screening, but those checks are also outside the scope of this proposal. ### Impact on composability By itself, the precompile does not impact composability. However, if implemented and leveraged in conjunction with a transaction screening solution, composability may be impacted. Nevertheless, this is viewed as an acceptable result as the authors believe each protocol has the right to manage its own risk and decide for itself what transactions it allows/disallows. ## Backwards Compatibility The call stack does not affect any previous execution and requires no consensus changes. The precompiled contract, however, can affect how contracts react during transaction execution and should require all clients to upgrade. ## Reference Implementation https://github.com/ethereum/go-ethereum/pull/28947 ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).