18 — External Calls via Interface
V0.9 introduces interface — a top-level declaration that defines the surface of an external contract for typed cross-contract calls. The compiler checks call-site signatures against the declared interface at compile time; bytecode emits a single, properly-encoded CALL opcode.
interface IERC20 {
action transfer(to: address, value: amount) returns bool
view balance_of(who: address) returns amount
}
module Withdrawer {
field token: address
action initialize(t: address) {
only deployer
token = t
}
action withdraw(value: amount) {
let iface = call_interface(token, IERC20)
iface.transfer(caller, value)
}
}
Why this exists
Solidity’s address.call(bytes) is untyped — you encode arguments by hand and the compiler can’t tell you when you got it wrong. Covenant’s interface declarations solve that:
- Type-checked at compile time. If you call
iface.transfer(caller, "wrong type"), the compiler refuses. - No selector typos. The selector is computed from the interface declaration, not from a string.
- Auto-decode on return.
iface.balance_of(addr)returnsamountdirectly — notbytesyou have to abi-decode.
What interface does NOT do
It does not import a contract. There is no inheritance. The interface is a type signature the compiler uses to verify your calls. The runtime checks at the EVM layer are the same as a hand-rolled CALL would do.
Storage of external addresses
Store the target contract’s address in a field, then pass it to call_interface:
interface IFactory {
action create_pair(a: address, b: address) returns address
view get_pair(a: address, b: address) returns address
}
module Router {
field factory: address
action route(a: address, b: address, amount_a: amount, amount_b: amount) {
let f = call_interface(factory, IFactory)
let pair = f.get_pair(a, b)
-- ...
}
}
What to notice
- Return type matters. If the external contract reverts, your action reverts too — there’s no try/catch in V0.9.0. Use
try_action(V0.9.x) for revert-tolerant calls. payableis implicit on the call site, not the interface. To send ETH with a call, use the V0.9.x payable variantiface.action_name{value: ...}(args)(deferred, currently use rawcall).- Interfaces live at the top level. They aren’t nested inside modules.
- Forward-compat. Interfaces are compile-time only — they don’t affect bytecode unless used. You can declare an interface and reference it later without runtime cost.
Common pattern: gating actions on external state
interface IERC20 {
view balance_of(who: address) returns amount
}
module GatedAction {
field token: address
field min_balance: amount
action vip_only_action() {
let t = call_interface(token, IERC20)
given t.balance_of(caller) >= min_balance
-- protected logic
}
}
Try it
Open in playground — deploys a mock ERC-20 + a Withdrawer, then calls transfer through the interface.