Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/contracts/atlas/Atlas.sol
Original file line number Diff line number Diff line change
Expand Up @@ -444,9 +444,9 @@ contract Atlas is Escrow, Factory {
_errorSwitch == PreOpsSimFail.selector || _errorSwitch == UserOpSimFail.selector
|| _errorSwitch == AllocateValueSimFail.selector
) {
// Bubble full revert payload (first 4 bytes remain Atlas error selector)
assembly {
mstore(0, _errorSwitch)
revert(0, 4)
revert(add(revertData, 32), mload(revertData))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI these changes push Atlas over the contract size limit by about 185 bytes. If we go with these changes we will need to lower optimizer_runs to get it back under.

But also try to cut down contract size in the modified areas if possible. There might be a Solady lib function that could do this, not sure

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, there's this helper. We should probably use it once we've build the bytes revertData in each case we want to bubble up

https://github.com/Vectorized/solady/blob/main/src/utils/LibCall.sol#L201

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this would actually reduce contract size but I can add the fix

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

183 bytes without Solady and 209 bytes over the limit using LibCall.bubbleUpRevert

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof okay. Was hoping it would reduce code duplication.

}
}
}
Expand All @@ -456,9 +456,9 @@ contract Atlas is Escrow, Factory {
// nonce as used so the userOp can be reused. Otherwise, the whole metacall doesn't revert but the inner
// execute() does so, no operation changes are persisted.
if (_errorSwitch == UserNotFulfilled.selector || callConfig.allowsReuseUserOps()) {
// Revert with the original payload so app error data after the selector is preserved
assembly {
mstore(0, _errorSwitch)
revert(0, 4)
revert(add(revertData, 32), mload(revertData))
}
}
}
Expand Down
31 changes: 22 additions & 9 deletions src/contracts/atlas/Escrow.sol
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,13 @@ abstract contract Escrow is AtlETH {
}
}

if (ctx.isSimulation) revert PreOpsSimFail();
revert PreOpsFail();
// Append underlying app error data to Atlas error (selector stays first 4 bytes)
bytes memory _err = ctx.isSimulation
? abi.encodePacked(PreOpsSimFail.selector, _data)
: abi.encodePacked(PreOpsFail.selector, _data);
assembly {
revert(add(_err, 32), mload(_err))
}
}

/// @notice Executes the user operation logic defined in the Execution Environment.
Expand Down Expand Up @@ -123,9 +128,13 @@ abstract contract Escrow is AtlETH {
}
}

// revert for failed
if (ctx.isSimulation) revert UserOpSimFail();
revert UserOpFail();
// revert for failed - include app revert data after Atlas error selector
bytes memory _err = ctx.isSimulation
? abi.encodePacked(UserOpSimFail.selector, _data)
: abi.encodePacked(UserOpFail.selector, _data);
assembly {
revert(add(_err, 32), mload(_err))
}
}

/// @notice Checks if the trusted operation hash matches and sets the appropriate error bit if it doesn't.
Expand Down Expand Up @@ -288,7 +297,7 @@ abstract contract Escrow is AtlETH {
{
uint256 _dappGasWaterMark = gasleft();

(bool _success,) = ctx.executionEnvironment.call{ gas: ctx.dappGasLeft }(
(bool _success, bytes memory _data) = ctx.executionEnvironment.call{ gas: ctx.dappGasLeft }(
abi.encodePacked(
abi.encodeCall(
IExecutionEnvironment.allocateValue, (ctx.solverSuccessful, dConfig.bidToken, bidAmount, returnData)
Expand All @@ -299,10 +308,14 @@ abstract contract Escrow is AtlETH {

_updateDAppGasLeft(ctx, _dappGasWaterMark);

// Revert if allocateValue failed at any point.
// Revert if allocateValue failed at any point. Include app revert data after Atlas error selector
if (!_success) {
if (ctx.isSimulation) revert AllocateValueSimFail();
revert AllocateValueFail();
bytes memory _err = ctx.isSimulation
? abi.encodePacked(AllocateValueSimFail.selector, _data)
: abi.encodePacked(AllocateValueFail.selector, _data);
assembly {
revert(add(_err, 32), mload(_err))
}
}
}

Expand Down
62 changes: 49 additions & 13 deletions src/contracts/common/ExecutionEnvironment.sol
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,13 @@ contract ExecutionEnvironment is Base {
bool _success;
(_success, _preOpsData) = _control().delegatecall(_preOpsData);

if (!_success) revert AtlasErrors.PreOpsDelegatecallFail();
if (!_success) {
// Preserve Atlas selector while appending DApp revert payload
bytes memory _err = abi.encodePacked(AtlasErrors.PreOpsDelegatecallFail.selector, _preOpsData);
assembly {
revert(add(_err, 32), mload(_err))
}
}

_preOpsData = abi.decode(_preOpsData, (bytes));
return _preOpsData;
Expand All @@ -77,11 +83,21 @@ contract ExecutionEnvironment is Base {

if (_config().needsDelegateUser()) {
(_success, returnData) = userOp.dapp.delegatecall(_data);
if (!_success) revert AtlasErrors.UserWrapperDelegatecallFail();
if (!_success) {
bytes memory _err = abi.encodePacked(AtlasErrors.UserWrapperDelegatecallFail.selector, returnData);
assembly {
revert(add(_err, 32), mload(_err))
}
}
} else {
// regular user call - executed at regular destination and not performed locally
(_success, returnData) = userOp.dapp.call{ value: userOp.value }(_data);
if (!_success) revert AtlasErrors.UserWrapperCallFail();
if (!_success) {
bytes memory _err2 = abi.encodePacked(AtlasErrors.UserWrapperCallFail.selector, returnData);
assembly {
revert(add(_err2, 32), mload(_err2))
}
}
}
}

Expand Down Expand Up @@ -127,9 +143,15 @@ contract ExecutionEnvironment is Base {
bool _success;

bytes memory _data = _forward(abi.encodeCall(IDAppControl.preSolverCall, (solverOp, returnData)));
(_success,) = _control().delegatecall(_data);

if (!_success) revert AtlasErrors.PreSolverFailed();
bytes memory _ret;
(_success, _ret) = _control().delegatecall(_data);

if (!_success) {
bytes memory _err = abi.encodePacked(AtlasErrors.PreSolverFailed.selector, _ret);
assembly {
revert(add(_err, 32), mload(_err))
}
}
}

// bidValue is not inverted; Higher bids are better; solver must deposit >= bidAmount
Expand Down Expand Up @@ -172,9 +194,15 @@ contract ExecutionEnvironment is Base {
bool _success;

bytes memory _data = _forward(abi.encodeCall(IDAppControl.postSolverCall, (solverOp, returnData)));
(_success,) = _control().delegatecall(_data);

if (!_success) revert AtlasErrors.PostSolverFailed();
bytes memory _ret;
(_success, _ret) = _control().delegatecall(_data);

if (!_success) {
bytes memory _err = abi.encodePacked(AtlasErrors.PostSolverFailed.selector, _ret);
assembly {
revert(add(_err, 32), mload(_err))
}
}
}

// bidValue is not inverted; Higher bids are better; solver must deposit >= bidAmount
Expand Down Expand Up @@ -223,8 +251,13 @@ contract ExecutionEnvironment is Base {
allocateData =
_forward(abi.encodeCall(IDAppControl.allocateValueCall, (solved, bidToken, bidAmount, allocateData)));

(bool _success,) = _control().delegatecall(allocateData);
if (!_success) revert AtlasErrors.AllocateValueDelegatecallFail();
(bool _success, bytes memory _ret2) = _control().delegatecall(allocateData);
if (!_success) {
bytes memory _err2 = abi.encodePacked(AtlasErrors.AllocateValueDelegatecallFail.selector, _ret2);
assembly {
revert(add(_err2, 32), mload(_err2))
}
}

uint256 _balance = address(this).balance;
if (_balance > 0) {
Expand Down Expand Up @@ -299,8 +332,11 @@ contract ExecutionEnvironment is Base {
(bool success, bytes memory data) = token.staticcall(abi.encodeCall(IERC20.balanceOf, address(this)));

if (!success) {
if (inPreSolver) revert AtlasErrors.PreSolverFailed();
revert AtlasErrors.PostSolverFailed();
bytes4 sel = inPreSolver ? AtlasErrors.PreSolverFailed.selector : AtlasErrors.PostSolverFailed.selector;
bytes memory err = abi.encodePacked(sel, data);
assembly {
revert(add(err, 32), mload(err))
}
}

// If the balanceOf call did not revert, decode result to uint256 and return
Expand Down
135 changes: 135 additions & 0 deletions test/AtlasErrorBubble.t.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: MIT
pragma solidity 0.8.28;

import "forge-std/Test.sol";
import { console2 } from "forge-std/console2.sol";

import { BaseTest } from "./base/BaseTest.t.sol";
import { DummyDAppControl } from "./base/DummyDAppControl.sol";
import { DummyDAppControlBuilder } from "./helpers/DummyDAppControlBuilder.sol";
import { CallConfigBuilder } from "./helpers/CallConfigBuilder.sol";
import { UserOperationBuilder } from "./base/builders/UserOperationBuilder.sol";
import { SolverOperationBuilder } from "./base/builders/SolverOperationBuilder.sol";
import { DAppOperationBuilder } from "./base/builders/DAppOperationBuilder.sol";

import { AtlasErrors } from "../src/contracts/types/AtlasErrors.sol";
import { IDAppControl } from "../src/contracts/interfaces/IDAppControl.sol";
import { CallConfig } from "../src/contracts/types/ConfigTypes.sol";

import "../src/contracts/types/UserOperation.sol";
import "../src/contracts/types/SolverOperation.sol";
import "../src/contracts/types/DAppOperation.sol";

contract NoopSolver {
function atlasSolverCall(
address,
address,
address,
uint256,
bytes calldata,
bytes calldata
) external payable { }
}

contract AtlasErrorBubbleTest is BaseTest {

DummyDAppControl dAppControl;
NoopSolver noopSolver;

function _deployControl(CallConfig memory callConfig) internal returns (DummyDAppControl) {
return new DummyDAppControlBuilder()
.withEscrow(address(atlas))
.withGovernance(governanceEOA)
.withCallConfig(callConfig)
.buildAndIntegrate(atlasVerification);
}

function test_bubbles_app_revert_inside_atlas_errors_preOps() public {
// Require preOps and allow reuse so Atlas reverts and bubbles the revert payload
CallConfig memory cfg = new CallConfigBuilder()
.withRequirePreOps(true)
.withReuseUserOp(true)
.build();

dAppControl = _deployControl(cfg);
noopSolver = new NoopSolver();

// Configure DAppControl to revert during preOps
dAppControl.setPreOpsShouldRevert(true);

// Build a minimal metacall
UserOperation memory userOp = new UserOperationBuilder()
.withFrom(userEOA)
.withTo(address(atlas))
.withValue(0)
.withGas(1_000_000)
.withMaxFeePerGas(tx.gasprice + 1)
.withNonce(address(atlasVerification), userEOA)
.withDeadline(block.number + 2)
.withDapp(address(dAppControl))
.withControl(address(dAppControl))
.withCallConfig(IDAppControl(address(dAppControl)).CALL_CONFIG())
.withDAppGasLimit(IDAppControl(address(dAppControl)).getDAppGasLimit())
.withSolverGasLimit(IDAppControl(address(dAppControl)).getSolverGasLimit())
.withBundlerSurchargeRate(IDAppControl(address(dAppControl)).getBundlerSurchargeRate())
.withSessionKey(address(0))
.withData("")
.signAndBuild(address(atlasVerification), userPK);

SolverOperation[] memory solverOps = new SolverOperation[](1);
solverOps[0] = new SolverOperationBuilder()
.withFrom(solverOneEOA)
.withTo(address(atlas))
.withValue(0)
.withGas(1_000_000)
.withMaxFeePerGas(userOp.maxFeePerGas)
.withDeadline(userOp.deadline)
.withSolver(address(noopSolver))
.withControl(userOp.control)
.withUserOpHash(userOp)
.withBidToken(userOp)
.withBidAmount(0)
.withData("")
.signAndBuild(address(atlasVerification), solverOnePK);

DAppOperation memory dappOp = new DAppOperationBuilder()
.withFrom(governanceEOA)
.withTo(address(atlas))
.withNonce(address(atlasVerification), governanceEOA)
.withDeadline(userOp.deadline)
.withControl(userOp.control)
.withBundler(address(0))
.withUserOpHash(userOp)
.withCallChainHash(userOp, solverOps)
.signAndBuild(address(atlasVerification), governancePK);

uint256 gasLim = _gasLim(userOp, solverOps);

// Execute and capture revert data
vm.prank(userEOA);
try atlas.metacall{ gas: gasLim }(userOp, solverOps, dappOp, address(0)) returns (bool) {
fail();
} catch (bytes memory revertData) {
console2.logBytes(revertData);
assertGt(revertData.length, 8, "revert payload too short");
// Top-level Atlas error selector must be first 4 bytes
bytes4 top = bytes4(revertData);
assertEq(top, AtlasErrors.PreOpsFail.selector, "top-level selector");

// Next 4 bytes should be the ExecutionEnvironment's Atlas error selector
// PreOpsDelegatecallFail
bytes4 inner;
{
uint64 head64;
assembly {
head64 := shr(192, mload(add(revertData, 32)))
}
inner = bytes4(uint32(head64));
}
assertEq(inner, AtlasErrors.PreOpsDelegatecallFail.selector, "inner EE selector");

// Remaining bytes include the app revert payload (e.g., Error(string))
assertGt(revertData.length, 8, "missing app payload after selectors");
}
}
}