Skip to content

Commit 9019910

Browse files
authored
Merge pull request #935 from Th0rgal/feat/stmt-external-call-with-return
feat: add Stmt.externalCallWithReturn for ABI-encoded external calls
2 parents 694edac + 353319d commit 9019910

3 files changed

Lines changed: 305 additions & 0 deletions

File tree

Compiler/ContractSpec.lean

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,15 @@ inductive Stmt
307307
(resultVars : List String) -- local vars to bind return values to
308308
(externalName : String) -- name of the external function declaration
309309
(args : List Expr) -- call arguments
310+
/-- High-level ABI-encoded external call with return value binding.
311+
Compiles to: mstore(selector+args), call/staticcall, revert forwarding, returndatacopy+mload.
312+
Covers the common `call(gas(), target, 0, 0, calldataSize, 0, 32)` + decode pattern. -/
313+
| externalCallWithReturn
314+
(resultVar : String) -- local variable to bind the returned uint256
315+
(target : Expr) -- target contract address
316+
(selector : Nat) -- 4-byte function selector (e.g., 0xa035b1fe)
317+
(args : List Expr) -- ABI-encoded arguments (each occupies 32 bytes)
318+
(isStatic : Bool := false) -- use staticcall instead of call
310319
deriving Repr
311320

312321
structure FunctionSpec where
@@ -633,6 +642,8 @@ private partial def collectStmtNames : Stmt → List String
633642
collectExprListNames topics ++ collectExprNames dataOffset ++ collectExprNames dataSize
634643
| Stmt.externalCallBind resultVars externalName args =>
635644
resultVars ++ externalName :: collectExprListNames args
645+
| Stmt.externalCallWithReturn resultVar target _ args _ =>
646+
resultVar :: collectExprNames target ++ collectExprListNames args
636647

637648
private partial def collectStmtListNames : List Stmt → List String
638649
| [] => []
@@ -1098,6 +1109,8 @@ private partial def stmtContainsUnsafeLogicalCallLike : Stmt → Bool
10981109
exprContainsUnsafeLogicalCallLike dataSize
10991110
| Stmt.externalCallBind _ _ args =>
11001111
args.any exprContainsUnsafeLogicalCallLike
1112+
| Stmt.externalCallWithReturn _ target _ args _ =>
1113+
exprContainsUnsafeLogicalCallLike target || args.any exprContainsUnsafeLogicalCallLike
11011114

11021115
private partial def staticParamBindingNames (name : String) (ty : ParamType) : List String :=
11031116
match ty with
@@ -1327,6 +1340,14 @@ private partial def validateScopedStmtIdentifiers
13271340
| Stmt.externalCallBind resultVars _ args => do
13281341
args.forM (validateScopedExprIdentifiers context params paramScope dynamicParams localScope constructorArgCount)
13291342
pure (resultVars.reverse ++ localScope)
1343+
| Stmt.externalCallWithReturn resultVar target _ args _ => do
1344+
validateScopedExprIdentifiers context params paramScope dynamicParams localScope constructorArgCount target
1345+
args.forM (validateScopedExprIdentifiers context params paramScope dynamicParams localScope constructorArgCount)
1346+
if paramScope.contains resultVar then
1347+
throw s!"Compilation error: {context} uses Stmt.externalCallWithReturn with result variable '{resultVar}' that shadows a parameter"
1348+
if localScope.contains resultVar then
1349+
throw s!"Compilation error: {context} uses Stmt.externalCallWithReturn with result variable '{resultVar}' that redeclares an existing local variable"
1350+
pure (resultVar :: localScope)
13301351
| _ => pure localScope
13311352

13321353
private partial def validateScopedStmtListIdentifiers
@@ -1563,6 +1584,8 @@ private partial def stmtWritesState : Stmt → Bool
15631584
args.any exprWritesState || true
15641585
| Stmt.externalCallBind _ _ args =>
15651586
args.any exprWritesState || true
1587+
| Stmt.externalCallWithReturn _ target _ args isStatic =>
1588+
exprWritesState target || args.any exprWritesState || !isStatic
15661589
where
15671590
exprWritesState (expr : Expr) : Bool :=
15681591
match expr with
@@ -1639,6 +1662,8 @@ private partial def stmtReadsStateOrEnv : Stmt → Bool
16391662
args.any exprReadsStateOrEnv || true
16401663
| Stmt.externalCallBind _ _ args =>
16411664
args.any exprReadsStateOrEnv || true
1665+
| Stmt.externalCallWithReturn _ target _ args _ =>
1666+
exprReadsStateOrEnv target || args.any exprReadsStateOrEnv || true
16421667

16431668
private def validateFunctionSpec (spec : FunctionSpec) : Except String Unit := do
16441669
if spec.isPayable && (spec.isView || spec.isPure) then
@@ -2233,6 +2258,9 @@ private partial def validateInteropStmt (context : String) : Stmt → Except Str
22332258
args.forM (validateInteropExpr context)
22342259
| Stmt.externalCallBind _ _ args =>
22352260
args.forM (validateInteropExpr context)
2261+
| Stmt.externalCallWithReturn _ target _ args _ => do
2262+
validateInteropExpr context target
2263+
args.forM (validateInteropExpr context)
22362264
| Stmt.returnValues values =>
22372265
values.forM (validateInteropExpr context)
22382266
| Stmt.rawLog topics dataOffset dataSize => do
@@ -2460,6 +2488,9 @@ private partial def validateInternalCallShapesInStmt
24602488
validateInternalCallShapesInExpr functions callerName dataSize
24612489
| Stmt.externalCallBind _resultVars _ args =>
24622490
args.forM (validateInternalCallShapesInExpr functions callerName)
2491+
| Stmt.externalCallWithReturn _ target _ args _ => do
2492+
validateInternalCallShapesInExpr functions callerName target
2493+
args.forM (validateInternalCallShapesInExpr functions callerName)
24632494
| _ =>
24642495
pure ()
24652496

@@ -2606,6 +2637,9 @@ private partial def validateExternalCallTargetsInStmt
26062637
else
26072638
checkDuplicateVars (name :: seen) rest
26082639
checkDuplicateVars [] resultVars
2640+
| Stmt.externalCallWithReturn _ target _ args _ => do
2641+
validateExternalCallTargetsInExpr externals context target
2642+
args.forM (validateExternalCallTargetsInExpr externals context)
26092643
| Stmt.returnValues values =>
26102644
values.forM (validateExternalCallTargetsInExpr externals context)
26112645
| Stmt.rawLog topics dataOffset dataSize => do
@@ -3751,6 +3785,52 @@ def compileStmt (fields : List Field) (events : List EventDef := [])
37513785
| Stmt.externalCallBind resultVars externalName args => do
37523786
let argExprs ← compileExprList fields dynamicSource args
37533787
pure [YulStmt.letMany resultVars (YulExpr.call externalName argExprs)]
3788+
| Stmt.externalCallWithReturn resultVar target selector args isStatic => do
3789+
let targetExpr ← compileExpr fields dynamicSource target
3790+
let argCompiledExprs ← compileExprList fields dynamicSource args
3791+
-- Step 1: store selector (left-shifted 224 bits) at memory offset 0
3792+
let selectorExpr := YulExpr.call "shl" [YulExpr.lit 224, YulExpr.hex selector]
3793+
let storeSelector := YulStmt.expr (YulExpr.call "mstore" [YulExpr.lit 0, selectorExpr])
3794+
-- Step 2: store each arg at offsets 4, 36, 68, ...
3795+
let storeArgs := argCompiledExprs.zipIdx.map fun (argExpr, i) =>
3796+
YulStmt.expr (YulExpr.call "mstore" [YulExpr.lit (4 + i * 32), argExpr])
3797+
-- Step 3: perform call/staticcall
3798+
let calldataSize := 4 + args.length * 32
3799+
let callExpr :=
3800+
if isStatic then
3801+
YulExpr.call "staticcall" [
3802+
YulExpr.call "gas" [],
3803+
targetExpr,
3804+
YulExpr.lit 0, YulExpr.lit calldataSize,
3805+
YulExpr.lit 0, YulExpr.lit 32
3806+
]
3807+
else
3808+
YulExpr.call "call" [
3809+
YulExpr.call "gas" [],
3810+
targetExpr,
3811+
YulExpr.lit 0,
3812+
YulExpr.lit 0, YulExpr.lit calldataSize,
3813+
YulExpr.lit 0, YulExpr.lit 32
3814+
]
3815+
let letSuccess := YulStmt.let_ "__ecwr_success" callExpr
3816+
-- Step 4: revert forwarding on failure
3817+
let revertBlock := YulStmt.if_ (YulExpr.call "iszero" [YulExpr.ident "__ecwr_success"]) [
3818+
YulStmt.let_ "__ecwr_rds" (YulExpr.call "returndatasize" []),
3819+
YulStmt.expr (YulExpr.call "returndatacopy" [YulExpr.lit 0, YulExpr.lit 0, YulExpr.ident "__ecwr_rds"]),
3820+
YulStmt.expr (YulExpr.call "revert" [YulExpr.lit 0, YulExpr.ident "__ecwr_rds"])
3821+
]
3822+
-- Step 5: validate return data size ≥ 32
3823+
let sizeCheck := YulStmt.if_ (YulExpr.call "lt" [YulExpr.call "returndatasize" [], YulExpr.lit 32]) [
3824+
YulStmt.expr (YulExpr.call "revert" [YulExpr.lit 0, YulExpr.lit 0])
3825+
]
3826+
-- Wrap call + checks in a block so __ecwr_success is block-scoped.
3827+
-- This avoids duplicate let declarations when multiple externalCallWithReturn
3828+
-- statements appear in the same function body.
3829+
let callBlock := YulStmt.block ([storeSelector] ++ storeArgs ++ [letSuccess, revertBlock, sizeCheck])
3830+
-- Step 6: extract return value outside the block (call already copied returndata to memory[0..32])
3831+
-- resultVar is flat-scoped so subsequent statements can reference it.
3832+
let bindResult := YulStmt.let_ resultVar (YulExpr.call "mload" [YulExpr.lit 0])
3833+
pure [callBlock, bindResult]
37543834
| Stmt.returnValues values => do
37553835
if isInternal then
37563836
if values.length != internalRetNames.length then
@@ -4019,6 +4099,7 @@ private partial def collectStmtBindNames : Stmt → List String
40194099
varName :: collectStmtListBindNames body
40204100
| Stmt.internalCallAssign names _ _ => names
40214101
| Stmt.externalCallBind resultVars _ _ => resultVars
4102+
| Stmt.externalCallWithReturn resultVar _ _ _ _ => [resultVar]
40224103
| _ => []
40234104

40244105
private partial def collectStmtListBindNames : List Stmt → List String
@@ -4091,6 +4172,8 @@ private partial def stmtUsesArrayElement : Stmt → Bool
40914172
topics.any exprUsesArrayElement || exprUsesArrayElement dataOffset || exprUsesArrayElement dataSize
40924173
| Stmt.externalCallBind _ _ args =>
40934174
args.any exprUsesArrayElement
4175+
| Stmt.externalCallWithReturn _ target _ args _ =>
4176+
exprUsesArrayElement target || args.any exprUsesArrayElement
40944177
| _ => false
40954178

40964179
private def functionUsesArrayElement (fn : FunctionSpec) : Bool :=

Compiler/ContractSpecFeatureTest.lean

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4887,6 +4887,51 @@ private def externalCallBindSpec : ContractSpec := {
48874887
]
48884888
}
48894889

4890+
-- Stmt.externalCallWithReturn: ABI-encoded external call with return (#926)
4891+
4892+
-- Test: externalCallWithReturn compiles to mstore+call+returndatacopy pattern
4893+
private def externalCallWithReturnSpec : ContractSpec := {
4894+
name := "ExternalCallWithReturn"
4895+
fields := []
4896+
constructor := none
4897+
functions := [
4898+
-- Test 1: simple staticcall with no args (oracle price feed pattern)
4899+
{ name := "getPrice"
4900+
params := [{ name := "oracle", ty := ParamType.address }]
4901+
returnType := some FieldType.uint256
4902+
body := [
4903+
Stmt.externalCallWithReturn "price" (Expr.param "oracle") 0xa035b1fe [] (isStatic := true),
4904+
Stmt.return (Expr.localVar "price")
4905+
]
4906+
},
4907+
-- Test 2: call with args (ERC20 balanceOf pattern)
4908+
{ name := "getBalance"
4909+
params := [
4910+
{ name := "token", ty := ParamType.address },
4911+
{ name := "account", ty := ParamType.address }
4912+
]
4913+
returnType := some FieldType.uint256
4914+
body := [
4915+
Stmt.externalCallWithReturn "bal" (Expr.param "token") 0x70a08231 [Expr.param "account"],
4916+
Stmt.return (Expr.localVar "bal")
4917+
]
4918+
},
4919+
-- Test 3: call with multiple args (IRM borrowRate pattern)
4920+
{ name := "getBorrowRate"
4921+
params := [
4922+
{ name := "irm", ty := ParamType.address },
4923+
{ name := "a", ty := ParamType.uint256 },
4924+
{ name := "b", ty := ParamType.uint256 }
4925+
]
4926+
returnType := some FieldType.uint256
4927+
body := [
4928+
Stmt.externalCallWithReturn "rate" (Expr.param "irm") 0x9451fed4 [Expr.param "a", Expr.param "b"],
4929+
Stmt.return (Expr.localVar "rate")
4930+
]
4931+
}
4932+
]
4933+
}
4934+
48904935
-- Test: single return externalCallBind compiles correctly
48914936
#eval! do
48924937
match compile externalCallBindSpec [1, 2, 3] with
@@ -5012,4 +5057,172 @@ private def externalCallBindSpec : ContractSpec := {
50125057
| .ok _ =>
50135058
throw (IO.userError "✗ externalCallBind should have rejected duplicate vars")
50145059

5060+
-- Test: staticcall with no args (oracle pattern)
5061+
#eval! do
5062+
match compile externalCallWithReturnSpec [1, 2, 3] with
5063+
| .error err =>
5064+
throw (IO.userError s!"✗ externalCallWithReturn spec compile failed: {err}")
5065+
| .ok ir =>
5066+
let rendered := Yul.render (emitYul ir)
5067+
-- Should use shl(224, selector) for selector encoding
5068+
assertContains "externalCallWithReturn staticcall selector" rendered
5069+
["shl(224, 0xa035b1fe)"]
5070+
-- Should use staticcall (not call) since isStatic=true
5071+
assertContains "externalCallWithReturn uses staticcall" rendered
5072+
["staticcall(gas(),"]
5073+
-- Should have revert forwarding
5074+
assertContains "externalCallWithReturn revert forwarding" rendered
5075+
["iszero(__ecwr_success)", "returndatacopy(0, 0, __ecwr_rds)", "revert(0, __ecwr_rds)"]
5076+
-- Should validate returndata size
5077+
assertContains "externalCallWithReturn size check" rendered
5078+
["lt(returndatasize(), 32)"]
5079+
-- Should extract return value (no redundant returndatacopy — call already copied to memory)
5080+
assertContains "externalCallWithReturn return extraction" rendered
5081+
["let price := mload(0)"]
5082+
-- Should NOT have a redundant returndatacopy(0, 0, 32) outside the revert block
5083+
IO.println s!"✓ externalCallWithReturn staticcall compiles correctly"
5084+
5085+
-- Test: call with one arg (balanceOf pattern)
5086+
#eval! do
5087+
match compile externalCallWithReturnSpec [1, 2, 3] with
5088+
| .error err =>
5089+
throw (IO.userError s!"✗ externalCallWithReturn spec compile failed: {err}")
5090+
| .ok ir =>
5091+
let rendered := Yul.render (emitYul ir)
5092+
-- Should use shl(224, selector) for balanceOf selector
5093+
assertContains "externalCallWithReturn call selector" rendered
5094+
["shl(224, 0x70a08231)"]
5095+
-- Should store arg at offset 4
5096+
assertContains "externalCallWithReturn arg encoding" rendered
5097+
["mstore(4,"]
5098+
-- Should use call (not staticcall) since isStatic=false
5099+
assertContains "externalCallWithReturn uses call" rendered
5100+
["call(gas(),"]
5101+
-- Should extract result to bal
5102+
assertContains "externalCallWithReturn bal binding" rendered
5103+
["let bal := mload(0)"]
5104+
IO.println s!"✓ externalCallWithReturn call with args compiles correctly"
5105+
5106+
-- Test: call with multiple args (IRM pattern)
5107+
#eval! do
5108+
match compile externalCallWithReturnSpec [1, 2, 3] with
5109+
| .error err =>
5110+
throw (IO.userError s!"✗ externalCallWithReturn spec compile failed: {err}")
5111+
| .ok ir =>
5112+
let rendered := Yul.render (emitYul ir)
5113+
-- Should store two args at offsets 4 and 36
5114+
assertContains "externalCallWithReturn multi-arg encoding" rendered
5115+
["shl(224, 0x9451fed4)", "mstore(4,", "mstore(36,"]
5116+
-- Calldata size should be 4 + 2*32 = 68
5117+
assertContains "externalCallWithReturn calldata size" rendered
5118+
["call(gas(),"]
5119+
-- Should extract result to rate
5120+
assertContains "externalCallWithReturn rate binding" rendered
5121+
["let rate := mload(0)"]
5122+
IO.println s!"✓ externalCallWithReturn multi-arg call compiles correctly"
5123+
5124+
-- Test: externalCallWithReturn rejects result variable shadowing a parameter
5125+
#eval! do
5126+
let shadowSpec : ContractSpec := {
5127+
name := "ShadowParam"
5128+
fields := []
5129+
constructor := none
5130+
functions := [
5131+
{ name := "bad"
5132+
params := [{ name := "oracle", ty := ParamType.address }]
5133+
returnType := some FieldType.uint256
5134+
body := [
5135+
Stmt.externalCallWithReturn "oracle" (Expr.param "oracle") 0xa035b1fe [] (isStatic := true),
5136+
Stmt.return (Expr.localVar "oracle")
5137+
]
5138+
}
5139+
]
5140+
}
5141+
match compile shadowSpec [1] with
5142+
| .error err =>
5143+
if contains err "shadows a parameter" then
5144+
IO.println s!"✓ externalCallWithReturn rejects parameter shadow: {err}"
5145+
else
5146+
throw (IO.userError s!"✗ externalCallWithReturn wrong error: {err}")
5147+
| .ok _ =>
5148+
throw (IO.userError "✗ externalCallWithReturn should have rejected parameter shadow")
5149+
5150+
-- Test: externalCallWithReturn rejects redeclaring existing local variable
5151+
#eval! do
5152+
let redeclareSpec : ContractSpec := {
5153+
name := "RedeclareLocal"
5154+
fields := []
5155+
constructor := none
5156+
functions := [
5157+
{ name := "bad"
5158+
params := [{ name := "oracle", ty := ParamType.address }]
5159+
returnType := some FieldType.uint256
5160+
body := [
5161+
Stmt.letVar "price" (Expr.literal 0),
5162+
Stmt.externalCallWithReturn "price" (Expr.param "oracle") 0xa035b1fe [] (isStatic := true),
5163+
Stmt.return (Expr.localVar "price")
5164+
]
5165+
}
5166+
]
5167+
}
5168+
match compile redeclareSpec [1] with
5169+
| .error err =>
5170+
if contains err "redeclares an existing local variable" then
5171+
IO.println s!"✓ externalCallWithReturn rejects local redeclaration: {err}"
5172+
else
5173+
throw (IO.userError s!"✗ externalCallWithReturn wrong error: {err}")
5174+
| .ok _ =>
5175+
throw (IO.userError "✗ externalCallWithReturn should have rejected redeclaration")
5176+
5177+
-- Test: staticcall external call allows view mutability
5178+
#eval! do
5179+
let viewSpec : ContractSpec := {
5180+
name := "ViewStaticCall"
5181+
fields := []
5182+
constructor := none
5183+
functions := [
5184+
{ name := "getPrice"
5185+
params := [{ name := "oracle", ty := ParamType.address }]
5186+
returnType := some FieldType.uint256
5187+
isView := true
5188+
body := [
5189+
Stmt.externalCallWithReturn "price" (Expr.param "oracle") 0xa035b1fe [] (isStatic := true),
5190+
Stmt.return (Expr.localVar "price")
5191+
]
5192+
}
5193+
]
5194+
}
5195+
match compile viewSpec [1] with
5196+
| .error err =>
5197+
throw (IO.userError s!"✗ view staticcall should compile: {err}")
5198+
| .ok _ =>
5199+
IO.println "✓ externalCallWithReturn staticcall accepted for view function"
5200+
5201+
-- Test: multiple externalCallWithReturn in same function (no duplicate let collision)
5202+
#eval! do
5203+
let multiCallSpec : ContractSpec := {
5204+
name := "MultiExternalCall"
5205+
fields := []
5206+
constructor := none
5207+
functions := [
5208+
{ name := "getPrices"
5209+
params := [{ name := "oracle1", ty := ParamType.address }, { name := "oracle2", ty := ParamType.address }]
5210+
returnType := none
5211+
body := [
5212+
Stmt.externalCallWithReturn "price1" (Expr.param "oracle1") 0xa035b1fe [] (isStatic := true),
5213+
Stmt.externalCallWithReturn "price2" (Expr.param "oracle2") 0xa035b1fe [] (isStatic := true),
5214+
Stmt.stop
5215+
]
5216+
}
5217+
]
5218+
}
5219+
match compile multiCallSpec [1] with
5220+
| .error err =>
5221+
throw (IO.userError s!"✗ multiple externalCallWithReturn should compile: {err}")
5222+
| .ok ir =>
5223+
let rendered := Yul.render (emitYul ir)
5224+
assertContains "multi externalCallWithReturn both bindings" rendered
5225+
["let price1 := mload(0)", "let price2 := mload(0)"]
5226+
IO.println "✓ multiple externalCallWithReturn in same function compiles without collision"
5227+
50155228
end Compiler.ContractSpecFeatureTest

0 commit comments

Comments
 (0)