--- This is a Stylus-specific bug, filing under Nitro because offchainlabs/stylus is archived ---
Title:
Empty Error(string) revert from Stylus ABI for receiveFlashLoan(address[],uint256[],uint256[],bytes) callback
Environment:
- Stylus node image: offchainlabs/nitro-node:v3.9.3-8bc5554
- RPC: http://localhost:8547, WS: ws://localhost:8548
- Contracts compiled with cargo stylus targeting wasm32-unknown-unknown
- Project: Rust Stylus app (apex-contract) plus several mock contracts:
- mock_vault (Balancer-style flash loan provider)
- mock_erc20
- mock_dex
- Integration test harness in a separate Rust crate using ethers-rs
- OS: Windows 10/11, running node via Docker
- Stylus node: offchainlabs/nitro-node:v3.9.3-8bc5554 (Docker on Windows)
- Tooling: cargo stylus
- stylus-sdk = "0.9.2"
- alloy-primitives = { version = "0.8.20", default-features = false, features = ["std"] }
- alloy-sol-types = { version = "0.8.20", default-features = false, features = ["std"] }
[patch.crates-io] overrides:
- alloy-primitives = { path = "./patches/alloy-primitives-0.8.20" }
- ruint = { path = "./patches/ruint" }
- The alloy-primitives and ruint crates are patched locally (minor changes for Windows build compatibility / etc.); I can share those patches if relevant.
- Also reproducible with:
- stylus-sdk = "0.10.0-rc.1"
- alloy-primitives = { version = "0.8.20", default-features = false, features = ["std"] }
- no patches
Summary
I’m seeing a consistent, generic revert when calling a Balancer-style flash loan callback receiveFlashLoan(address[],uint256[],uint256[],bytes) on a Stylus contract.
- From the client side (ethers-rs), the call() to my executeArbitrage function fails with:
execute_arbitrage call() error: Revert(Bytes(
0xc479f26600000000000000000000000000000000000000000000000000000000
000000200000000000000000000000000000000000000000000000000000000000000000
))
- 0xc479f266 is the selector for Error(string), but the string is empty.
- debug_traceTransaction shows the innermost REVERT coming from the Stylus ABI layer when calling back into my contract’s receiveFlashLoan, before my Rust code’s explicit error branches or logging can run.
I’ve tried simplifying receiveFlashLoan down to a very minimal body (just checking caller and/or immediately returning Ok(())), but the revert remains, which suggests an issue in the Stylus ABI wrapper / dispatch for this signature.
Contract code (ApexProtocol)
Rust Stylus contract snippet (core parts):
use stylus_sdk::{
abi::Bytes,
alloy_primitives::{Address, U256},
alloy_sol_types::sol,
prelude::*,
};
sol_interface! {
interface IERC20 {
function approve(address spender, uint256 amount) external returns (bool);
function transfer(address recipient, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
interface IVault {
function flashLoan(
address recipient,
address[] memory tokens,
uint256[] memory amounts
) external;
}
}
sol_storage! {
#[entrypoint]
pub struct ApexProtocol {
address owner;
address vault;
uint256 total_profit;
uint256 total_trades;
address pending_token_out;
address pending_dex_buy;
address pending_dex_sell;
bool in_flash_loan;
}
}
sol! {
// Balancer-style callback ABI
function receiveFlashLoan(
address[] tokens,
uint256[] amounts,
uint256[] feeAmounts,
bytes userData
) external;
event Debug(uint256 checkpoint);
error NotOwner();
error NotProfitable();
error ExecutionFailed(uint256 checkpoint);
error UnderlyingError(bytes data);
error ReentrancyGuard();
}
#[public]
impl ApexProtocol {
// Only relevant part shown: flash loan callback
/// receiveFlashLoan is called by the Vault during a flash loan
/// Signature: receiveFlashLoan(address[],uint256[],uint256[],bytes)
#[selector(name = "receiveFlashLoan")]
#[allow(non_snake_case)]
pub fn receive_flash_loan(
&mut self,
tokens: Vec<Address>,
amounts: Vec<U256>,
fee_amounts: Vec<U256>,
_user_data: Bytes,
) -> Result<(), Vec<u8>> {
self.log_checkpoint(1000);
let vault = self.vault.get();
let contract = self.vm().contract_address();
if self.vm().msg_sender() != vault {
return Err(b"rf_wrong_caller".to_vec());
}
let token_in = tokens[0];
let loan_amount = amounts[0];
let _fee_amount = fee_amounts[0];
self.log_checkpoint(1001);
// Verify loan received
let token_contract = IERC20::new(token_in);
let balance = token_contract
.balance_of(&mut *self, contract)
.map_err(|_| b"balance_check_failed".to_vec())?;
if balance < loan_amount {
return Err(b"rf_balance_low".to_vec());
}
self.log_checkpoint(1002);
// For now, skip swaps and just repay the loan
match token_contract.transfer(&mut *self, vault, loan_amount) {
Ok(true) => {}
Ok(false) => return Err(b"rf_transfer_false".to_vec()),
Err(_) => return Err(b"repay_error".to_vec()),
}
self.log_checkpoint(1005);
Ok(())
}
#[fallback]
pub fn fallback(&mut self, _input: &[u8]) -> Result<Vec<u8>, Vec<u8>> {
Ok(Vec::new())
}
// executeArbitrage requests the flash loan via IVault::flashLoan(...)
// and maps any revert into an UnderlyingError(UnderlyingError { data })
// (omitted here for brevity, but available in repo)
}
Mock Vault (flash loan provider)
This is the Stylus mock that calls back into receiveFlashLoan:
use stylus_sdk::{
alloy_primitives::{Address, U256},
alloy_sol_types::{sol, SolCall},
call::RawCall,
prelude::*,
};
sol_interface! {
interface IERC20 {
function transfer(address recipient, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
}
sol! {
/// Must match exactly: receiveFlashLoan(address[],uint256[],uint256[],bytes)
function receiveFlashLoan(
address[] tokens,
uint256[] amounts,
uint256[] feeAmounts,
bytes userData
) external;
}
sol_storage! {
#[entrypoint]
pub struct MockVault {
bool initialized;
}
}
#[public] impl MockVault { pub fn init(&mut self) -> Result<(), Vec> { self.initialized.set(true); Ok(()) }
pub fn flashLoan(
&mut self,
recipient: Address,
tokens: Vec<Address>,
amounts: Vec<U256>,
) -> Result<(), Vec<u8>> {
// 1. Transfer tokens to recipient
for (i, token) in tokens.iter().enumerate() {
let amount = amounts[i];
let token_contract = IERC20::new(*token);
#[allow(deprecated)]
match token_contract.transfer(&mut *self, recipient, amount) {
Ok(true) => {}
Ok(false) => return Err(b"MockVault: transfer returned false".to_vec()),
Err(_) => return Err(b"MockVault: transfer failed".to_vec()),
}
}
// 2. Build callback calldata using sol! generated types
let fee_amounts: Vec<U256> = amounts.iter().map(|_| U256::ZERO).collect();
let user_data: alloc::vec::Vec<u8> = alloc::vec::Vec::new();
let call = receiveFlashLoanCall {
tokens: tokens.clone(),
amounts: amounts.clone(),
feeAmounts: fee_amounts,
userData: user_data.into(),
};
let calldata = call.abi_encode();
// 3. Raw call to recipient
let result = unsafe { RawCall::new().call(recipient, &calldata) };
match result {
Ok(_) => {}
Err(revert_data) => {
// Bubble up revert data so the test can see ApexProtocol errors
return Err(revert_data);
}
}
Ok(())
}
}
Integration test harness
Rust test in a separate crate (arbitrage_flow.rs) roughly:
- Spins up Nitro Stylus node (Docker).
- Builds WASM artifacts for:
- mock_erc20.wasm (deployed twice as token A and token B)
- mock_dex.wasm (deployed twice as Uniswap and SushiSwap)
- mock_vault.wasm
- apex_contract.wasm
- Deploys all of them using cargo stylus deploy and ethers-rs.
- Initializes DEXes, vault, and Apex, mints balances.
- Then calls:
let call = apex_test
.execute_arbitrage(
token_a_addr,
token_b_addr,
loan_amount,
sushiswap_addr,
uniswap_addr,
U256::zero(),
)
.gas(10_000_000u64);
match call.call().await {
Ok(_) => println!("execute_arbitrage call() succeeded"),
Err(e) => {
println!("execute_arbitrage call() error: {:?}", e);
if let ContractError::Revert(bytes) = &e {
println!("Revert bytes: {}", bytes);
}
}
}
The output is:
execute_arbitrage call() error: Revert(
Bytes(0xc479f2660000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000000)
)
Revert bytes: 0xc479f2660000...0000
The transaction is then sent and mined with status: 0 and no logs.
debug_traceTransaction output (truncated)
Calling debug_traceTransaction on the tx hash:
$body = '{"jsonrpc":"2.0","id":1,"method":"debug_traceTransaction","params":["0xc804797bdfeba1eef928eb94fc9d7d1ea08b2791085f866989c177822ac8f56e"]}'
Invoke-WebRequest -Uri "http://localhost:8547" -Method POST -Body $body -ContentType "application/json" | Select-Object -ExpandProperty Content
The key part of the result (depths and CALLs):
- depth: 1: call from the test wallet to Apex (executeArbitrage).
- depth: 2: CALL from Apex to MockVault.flashLoan (0x525c2aBA...).
- Inside vault:
- CALL to token_a (0xda52b25d...) to transfer loan.
- Then CALL back into Apex (0x4a2bA922...) with the receiveFlashLoan calldata.
In the trace, the innermost CALL to Apex (depth: 3, address 0x4a2b...) ends with:
{"pc":0,"op":"CALL","gas":7006252,...,"depth":2,"stack":["0x0","0x0","0x164","0x0","0x0","0x4a2ba922052ba54e29c5417bc979daaf7d5fe4f4","0x6ae82c"]},
{"pc":0,"op":"REVERT","gas":6961697,"gasCost":0,"depth":3,"stack":["0x12","0x0"]},
{"pc":0,"op":"STOP","gas":0,"gasCost":0,"depth":3,"error":"execution reverted","stack":[]},
So:
- The inner revert at depth: 3 has offset=0, length=0 → empty revert data.
- This bubbles back up and surfaces as the generic Error(string) with empty payload (0xc479f266...).
Notably:
- I added explicit Err(b"rf_*".to_vec()) branches and Stylus log_checkpoint calls inside receive_flash_loan, but none of these appear:
- I never see the checkpoints in node logs.
- I never see the rf_* strings in revert bytes.
This suggests the revert is happening at or before the Rust body executes (inside the Stylus-generated ABI glue for receiveFlashLoan(address[],uint256[],uint256[],bytes)), not in my explicit code.
What I’ve already tried
- Verified selector is set by name: #[selector(name = "receiveFlashLoan")].
- Matched ABI exactly via sol! in both vault and Apex.
- Confirmed calldata construction in MockVault uses receiveFlashLoanCall from the same sol! definition.
- Simplified receive_flash_loan body to:
- only checking msg_sender == vault, then Ok(()); and also
- completely trivial Ok(()) ignoring all arguments.
- In both cases, I still get:
- An inner REVERT at the call into Apex (depth 3) with empty data.
- The external revert as Error(string) with empty string.
Everything else in the pipeline (deployments, mock ERC20/Dex behavior, vault transfers) appears to work; only this Stylus callback invocation fails generically.
Questions / Request
- Is there any known issue with Stylus handling of receiveFlashLoan(address[],uint256[],uint256[],bytes) signatures, bytes arguments, or Vec
/Vec/Bytes mappings that could cause an ABI-level revert before user code runs?
- Is there a recommended way to instrument or get logs from inside the generated ABI wrapper around receive_flash_loan to see why it’s reverting?
- Could you confirm whether this specific pattern (Vault → ERC20 → Apex receiveFlashLoan using sol!/SolCall) should work as written, or whether there’s a more “idiomatic” Stylus way to declare and expose this callback?
If it helps, I can share the full repo (umbgtt10/apex on GitHub) and the integration test script (test.ps1) that:
- Builds all WASM artifacts,
- Starts the Stylus node via Docker,
- Runs the test_arbitrage_flow integration test which triggers this behavior.
--- This is a Stylus-specific bug, filing under Nitro because offchainlabs/stylus is archived ---
Title:
Empty Error(string) revert from Stylus ABI for receiveFlashLoan(address[],uint256[],uint256[],bytes) callback
Environment:
[patch.crates-io] overrides:
- alloy-primitives = { path = "./patches/alloy-primitives-0.8.20" }
- ruint = { path = "./patches/ruint" }
- The alloy-primitives and ruint crates are patched locally (minor changes for Windows build compatibility / etc.); I can share those patches if relevant.
Summary
I’m seeing a consistent, generic revert when calling a Balancer-style flash loan callback receiveFlashLoan(address[],uint256[],uint256[],bytes) on a Stylus contract.
execute_arbitrage call() error: Revert(Bytes(
0xc479f26600000000000000000000000000000000000000000000000000000000
000000200000000000000000000000000000000000000000000000000000000000000000
))
I’ve tried simplifying receiveFlashLoan down to a very minimal body (just checking caller and/or immediately returning Ok(())), but the revert remains, which suggests an issue in the Stylus ABI wrapper / dispatch for this signature.
Contract code (ApexProtocol)
Rust Stylus contract snippet (core parts):
use stylus_sdk::{
abi::Bytes,
alloy_primitives::{Address, U256},
alloy_sol_types::sol,
prelude::*,
};
sol_interface! {
interface IERC20 {
function approve(address spender, uint256 amount) external returns (bool);
function transfer(address recipient, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
}
sol_storage! {
#[entrypoint]
pub struct ApexProtocol {
address owner;
address vault;
}
sol! {
// Balancer-style callback ABI
function receiveFlashLoan(
address[] tokens,
uint256[] amounts,
uint256[] feeAmounts,
bytes userData
) external;
}
#[public]
impl ApexProtocol {
// Only relevant part shown: flash loan callback
}
Mock Vault (flash loan provider)
This is the Stylus mock that calls back into receiveFlashLoan:
use stylus_sdk::{
alloy_primitives::{Address, U256},
alloy_sol_types::{sol, SolCall},
call::RawCall,
prelude::*,
};
sol_interface! {
interface IERC20 {
function transfer(address recipient, uint256 amount) external returns (bool);
function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
function balanceOf(address account) external view returns (uint256);
}
}
sol! {
/// Must match exactly: receiveFlashLoan(address[],uint256[],uint256[],bytes)
function receiveFlashLoan(
address[] tokens,
uint256[] amounts,
uint256[] feeAmounts,
bytes userData
) external;
}
sol_storage! {
#[entrypoint]
pub struct MockVault {
bool initialized;
}
}
#[public] impl MockVault { pub fn init(&mut self) -> Result<(), Vec> { self.initialized.set(true); Ok(()) }
}
Integration test harness
Rust test in a separate crate (arbitrage_flow.rs) roughly:
let call = apex_test
.execute_arbitrage(
token_a_addr,
token_b_addr,
loan_amount,
sushiswap_addr,
uniswap_addr,
U256::zero(),
)
.gas(10_000_000u64);
match call.call().await {
Ok(_) => println!("execute_arbitrage call() succeeded"),
Err(e) => {
println!("execute_arbitrage call() error: {:?}", e);
if let ContractError::Revert(bytes) = &e {
println!("Revert bytes: {}", bytes);
}
}
}
The output is:
execute_arbitrage call() error: Revert(
Bytes(0xc479f2660000000000000000000000000000000000000000000000000000000000000020
0000000000000000000000000000000000000000000000000000000000000000)
)
Revert bytes: 0xc479f2660000...0000
The transaction is then sent and mined with status: 0 and no logs.
debug_traceTransaction output (truncated)
Calling debug_traceTransaction on the tx hash:
$body = '{"jsonrpc":"2.0","id":1,"method":"debug_traceTransaction","params":["0xc804797bdfeba1eef928eb94fc9d7d1ea08b2791085f866989c177822ac8f56e"]}'
Invoke-WebRequest -Uri "http://localhost:8547" -Method POST -Body $body -ContentType "application/json" | Select-Object -ExpandProperty Content
The key part of the result (depths and CALLs):
In the trace, the innermost CALL to Apex (depth: 3, address 0x4a2b...) ends with:
{"pc":0,"op":"CALL","gas":7006252,...,"depth":2,"stack":["0x0","0x0","0x164","0x0","0x0","0x4a2ba922052ba54e29c5417bc979daaf7d5fe4f4","0x6ae82c"]},
{"pc":0,"op":"REVERT","gas":6961697,"gasCost":0,"depth":3,"stack":["0x12","0x0"]},
{"pc":0,"op":"STOP","gas":0,"gasCost":0,"depth":3,"error":"execution reverted","stack":[]},
So:
Notably:
This suggests the revert is happening at or before the Rust body executes (inside the Stylus-generated ABI glue for receiveFlashLoan(address[],uint256[],uint256[],bytes)), not in my explicit code.
What I’ve already tried
Everything else in the pipeline (deployments, mock ERC20/Dex behavior, vault transfers) appears to work; only this Stylus callback invocation fails generically.
Questions / Request
/Vec/Bytes mappings that could cause an ABI-level revert before user code runs?
If it helps, I can share the full repo (umbgtt10/apex on GitHub) and the integration test script (test.ps1) that: