5050REFERENCE_SPEC_GIT_PATH = ref_spec_staking .git_path
5151REFERENCE_SPEC_VERSION = ref_spec_staking .version
5252
53+ GAS_STIPEND = 2300
54+
5355slot_code_worked = 0x1
5456value_code_worked = 0x1234
5557slot_call_success = 0x2
@@ -238,6 +240,32 @@ def resolve_outcome(
238240 raise ValueError (f"Unknown scenario: { scenario } " )
239241
240242
243+ def _stipend_neutralizes_low_gas (
244+ func : FunctionInfo ,
245+ scenario_set : set [CallScenario ],
246+ ) -> bool :
247+ """
248+ Check if the EVM value-transfer stipend overcomes how little
249+ gas is provided in LOW_GAS scenarios.
250+ """
251+ if not (
252+ {CallScenario .LOW_GAS , CallScenario .NONZERO_VALUE } <= scenario_set
253+ ):
254+ return False
255+
256+ # Given how LOW_GAS gas is calculated, unknown selector
257+ # scenarios can never have stipend compensate for gas
258+ # shortage.
259+ assert GAS_UNKNOWN_SELECTOR > GAS_STIPEND
260+ known_selector = scenario_set .isdisjoint (
261+ {
262+ CallScenario .TRUNCATED_SELECTOR ,
263+ CallScenario .WRONG_SELECTOR ,
264+ }
265+ )
266+ return known_selector and func .gas_cost <= GAS_STIPEND
267+
268+
241269def resolve_outcome_pair (
242270 func : FunctionInfo ,
243271 scenario1 : CallScenario ,
@@ -257,12 +285,10 @@ def resolve_outcome_pair(
257285 if scenario1 .check_priority > scenario2 .check_priority :
258286 scenario1 , scenario2 = scenario2 , scenario1
259287
260- # EVM adds 2300 stipend for value>0; if the stipend covers the
261- # function's gas cost, the call succeeds despite LOW_GAS.
262- if (
263- scenario1 == CallScenario .LOW_GAS
264- and scenario2 == CallScenario .NONZERO_VALUE
265- and func .gas_cost <= 2300
288+ if _stipend_neutralizes_low_gas (
289+ # True iff `scenario1 == LOW_GAS and scenario2 == NONZERO_VALUE`
290+ func ,
291+ {scenario1 , scenario2 },
266292 ):
267293 return resolve_outcome (func , scenario2 )
268294
@@ -285,6 +311,28 @@ def resolve_outcome_pair(
285311 return resolve_outcome (func , scenario1 )
286312
287313
314+ def resolve_outcome_triple (
315+ func : FunctionInfo ,
316+ s1 : CallScenario ,
317+ s2 : CallScenario ,
318+ s3 : CallScenario ,
319+ ) -> ExpectedOutcome :
320+ """Resolve expected outcome for three combined scenarios."""
321+ scenarios = sorted (
322+ [_normalize (s , func ) for s in (s1 , s2 , s3 )],
323+ key = lambda s : s .check_priority ,
324+ )
325+ if _stipend_neutralizes_low_gas (func , set (scenarios )):
326+ scenarios .remove (CallScenario .LOW_GAS )
327+
328+ if scenarios [0 ] == CallScenario .NONZERO_VALUE and func .is_payable :
329+ scenarios = scenarios [1 :]
330+
331+ if len (scenarios ) == 1 :
332+ return resolve_outcome (func , scenarios [0 ])
333+ return resolve_outcome_pair (func , scenarios [0 ], scenarios [1 ])
334+
335+
288336def scenario_call_code (
289337 * scenarios : CallScenario ,
290338 func : FunctionInfo ,
@@ -337,7 +385,12 @@ def scenario_call_code(
337385
338386 if gas is None :
339387 if CallScenario .LOW_GAS in scenario_set :
340- gas = min (func .gas_cost , GAS_UNKNOWN_SELECTOR ) - 1
388+ gas = max (
389+ 0 ,
390+ # Subtracting also the stipend in case the
391+ # value sent would cause stipend to be added.
392+ min (func .gas_cost , GAS_UNKNOWN_SELECTOR ) - GAS_STIPEND - 1 ,
393+ )
341394 else :
342395 gas = max (func .gas_cost , GAS_UNKNOWN_SELECTOR )
343396
@@ -976,6 +1029,122 @@ def test_check_order(
9761029 )
9771030
9781031
1032+ def _pairwise_compatible (
1033+ scenarios : tuple [CallScenario , ...],
1034+ ) -> bool :
1035+ """Check that no pair in the tuple is incompatible."""
1036+ return all (
1037+ frozenset ({a , b }) not in _INCOMPATIBLE_SCENARIOS
1038+ for i , a in enumerate (scenarios )
1039+ for b in scenarios [i + 1 :]
1040+ )
1041+
1042+
1043+ _CHECK_ORDER_TRIPLES = [
1044+ pytest .param (
1045+ s1 ,
1046+ s2 ,
1047+ s3 ,
1048+ id = f"{ s1 .name .lower ()} __{ s2 .name .lower ()} __{ s3 .name .lower ()} " ,
1049+ )
1050+ for s1 in CallScenario
1051+ for s2 in CallScenario
1052+ for s3 in CallScenario
1053+ if CallScenario .SUCCESS not in {s1 , s2 , s3 }
1054+ and s1 .check_priority < s2 .check_priority < s3 .check_priority
1055+ and _pairwise_compatible ((s1 , s2 , s3 ))
1056+ ]
1057+
1058+
1059+ @pytest .mark .parametrize (
1060+ "func" ,
1061+ [pytest .param (f , id = f .name ) for f in REPRESENTATIVE_FUNCTIONS ],
1062+ )
1063+ @pytest .mark .parametrize ("scenario1,scenario2,scenario3" , _CHECK_ORDER_TRIPLES )
1064+ def test_check_order_triple (
1065+ blockchain_test : BlockchainTestFiller ,
1066+ pre : Alloc ,
1067+ fork : Fork ,
1068+ func : FunctionInfo ,
1069+ scenario1 : CallScenario ,
1070+ scenario2 : CallScenario ,
1071+ scenario3 : CallScenario ,
1072+ ) -> None :
1073+ """
1074+ Test precompile check priority with three combined failures.
1075+
1076+ Each combination triggers exactly three failure causes. The test
1077+ derives the expected outcome from the priority interactions.
1078+ """
1079+ normalized = {
1080+ _normalize (s , func ) for s in (scenario1 , scenario2 , scenario3 )
1081+ }
1082+ if not _pairwise_compatible (tuple (normalized )):
1083+ pytest .skip ("normalized scenarios are incompatible" )
1084+
1085+ mem_end = _calldata_mem_end (func .calldata_size )
1086+ ret_offset = max (mem_end , 96 )
1087+ rdc_offset = ret_offset + 32
1088+
1089+ scenarios = {scenario1 , scenario2 , scenario3 }
1090+
1091+ delegating_eoa : Address | None = None
1092+ authorization_list = None
1093+ if CallScenario .DELEGATE_TO_PRECOMPILE in scenarios :
1094+ delegating_eoa = pre .fund_eoa ()
1095+ authorization_list = [
1096+ AuthorizationTuple (
1097+ address = STAKING_PRECOMPILE ,
1098+ nonce = 0 ,
1099+ signer = delegating_eoa ,
1100+ )
1101+ ]
1102+
1103+ contract = (
1104+ Op .SSTORE (
1105+ slot_call_success ,
1106+ scenario_call_code (
1107+ scenario1 ,
1108+ scenario2 ,
1109+ scenario3 ,
1110+ func = func ,
1111+ ret_offset = ret_offset ,
1112+ ret_size = 32 ,
1113+ delegating_eoa = delegating_eoa ,
1114+ ),
1115+ )
1116+ + Op .SSTORE (slot_return_size , Op .RETURNDATASIZE )
1117+ + Op .RETURNDATACOPY (rdc_offset , 0 , Op .RETURNDATASIZE )
1118+ + Op .SSTORE (slot_return_value , Op .MLOAD (rdc_offset ))
1119+ + Op .SSTORE (slot_code_worked , value_code_worked )
1120+ )
1121+ contract_address = pre .deploy_contract (contract , balance = 1 )
1122+
1123+ tx = Transaction (
1124+ gas_limit = generous_gas (fork ),
1125+ to = contract_address ,
1126+ sender = pre .fund_eoa (),
1127+ authorization_list = authorization_list ,
1128+ )
1129+
1130+ outcome = resolve_outcome_triple (func , scenario1 , scenario2 , scenario3 )
1131+
1132+ blockchain_test (
1133+ pre = pre ,
1134+ post = {
1135+ contract_address : Account (
1136+ storage = {
1137+ slot_call_success : outcome .call_success ,
1138+ slot_return_size : outcome .return_size ,
1139+ slot_return_value : outcome .return_word ,
1140+ slot_code_worked : value_code_worked ,
1141+ }
1142+ ),
1143+ },
1144+ blocks = [Block (txs = [tx ])],
1145+ )
1146+
1147+
9791148# --- Direct-transaction tests ---
9801149
9811150
0 commit comments