Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Batch escrow for one or more expected transfers. A bidder uses a constructor-app
- `MPTVerifier.sol` verifies transaction and receipt trie inclusion.
- `ReceiptValidator.sol` validates receipt status, ERC-20 transfer logs, and native transfer fields.
- `RLPParser.sol` provides low-level RLP helpers.
- `utils/ECDSA.sol` recovers `BondAuth` signers.
- `utils/ECDSA.sol` recovers `BondAuth` / `BatchBondAuth` signers.

## Dependency graph

Expand Down
2 changes: 1 addition & 1 deletion artifacts/batch_deployment.hex

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion artifacts/batch_runtime.hex

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion artifacts/erc20_deployment.hex

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion artifacts/erc20_runtime.hex

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion artifacts/native_deployment.hex

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion artifacts/native_runtime.hex

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion out/EscrowBatch.sol/EscrowBatch.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion out/EscrowERC20.sol/EscrowERC20.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion out/EscrowNative.sol/EscrowNative.json

Large diffs are not rendered by default.

87 changes: 55 additions & 32 deletions src/EscrowBase.sol
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,12 @@ abstract contract EscrowBase {
error CancellationRequested();
error ExecutorAlreadyBonded();
error InvalidBondSignature();
error BondTransferFailed();
error ZeroBlindedSigner();
error ProofBeforeBond();
error GasAdvanceAlreadyClaimed();
error GasAdvanceTooLarge();
error GasAdvanceBudgetExceedsReward();
error GasAdvanceTransferFailed();

// EIP-712 typed-data constants. The domain MUST match the off-chain signer
// (nomad `crates/types/src/contracts.rs`) byte-for-byte, otherwise the recovered
Expand All @@ -31,18 +34,19 @@ abstract contract EscrowBase {
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
// BondAuth binds the enclave's authorization to the specific fresh EOA that bonds,
// so a signature cannot be replayed to bond a different executor.
bytes32 private constant _BOND_TYPEHASH = keccak256("BondAuth(address bondingExecutor)");
bytes32 private constant _BOND_TYPEHASH = keccak256("BondAuth(address bondingExecutor,uint256 gasAdvance)");
bytes32 private constant _NAME_HASH = keccak256("MirageEscrow");
bytes32 private constant _VERSION_HASH = keccak256("1");

// Cached EIP-712 domain separator, bound to this contract + chain at deploy.
bytes32 private immutable _domainSeparator;

// The following variables are set up in the constructor.
address immutable deployerAddress;
address public immutable deployerAddress;
uint256 public currentRewardAmount;
uint256 public currentPaymentAmount;
uint256 public originalRewardAmount;
/// @notice Quote-time reward budget reserved for fresh-EOA gas provisioning.
uint256 public immutable maxGasAdvance;

// Blinded enclave key P = G + s.B, stored as address(P). The enclave signs a BondAuth
// with the matching scalar p = g + s; ecrecover of a valid signature yields this address.
Expand All @@ -59,20 +63,19 @@ abstract contract EscrowBase {
address public bondedExecutor;
uint256 public executionDeadline;
uint256 public bondStartBlock;
// ETH bond pot. Sourced at fund time; paid out to the fresh EOA at bond() to bootstrap
// its gas. A one-shot faucet: once spent it does not refill, so a retry after a failed
// serve must use an already-funded EOA.
uint256 public bondPot;
/// @notice Whether this funding cycle has already advanced reward funds.
bool public gasAdvanceClaimed;
bool public cancellationRequest;
bool public funded; // marks if the contract has funds to pay out the executors (if unfunded, no executor is accepted)

constructor(address _expectedRecipient, uint256 _expectedAmount, address _blindedSigner) {
constructor(address _expectedRecipient, uint256 _expectedAmount, address _blindedSigner, uint256 _maxGasAdvance) {
// Zero can't arise from a correct P = G + s.B derivation, so it signals an
// upstream derivation/encoding bug; reject it like a zero token address.
if (_blindedSigner == address(0)) revert ZeroBlindedSigner();
expectedRecipient = _expectedRecipient;
expectedAmount = _expectedAmount;
blindedSigner = _blindedSigner;
maxGasAdvance = _maxGasAdvance;
deployerAddress = msg.sender;
_domainSeparator =
keccak256(abi.encode(_DOMAIN_TYPEHASH, _NAME_HASH, _VERSION_HASH, block.chainid, address(this)));
Expand All @@ -83,15 +86,19 @@ abstract contract EscrowBase {
return _domainSeparator;
}

// EIP-712 digest for a BondAuth authorizing bondingExecutor to bond this escrow.
function _hashBondAuth(address bondingExecutor) internal view returns (bytes32) {
bytes32 structHash = keccak256(abi.encode(_BOND_TYPEHASH, bondingExecutor));
// EIP-712 digest for a BondAuth authorizing the executor and exact advance.
function _hashBondAuth(address bondingExecutor, uint256 gasAdvance) internal view returns (bytes32) {
bytes32 structHash = keccak256(abi.encode(_BOND_TYPEHASH, bondingExecutor, gasAdvance));
return keccak256(abi.encodePacked("\x19\x01", _domainSeparator, structHash));
}

// Recovers the signer of a BondAuth authorizing bondingExecutor.
function _recoverBondSigner(address bondingExecutor, bytes calldata sig) internal view returns (address) {
return ECDSA.recover(_hashBondAuth(bondingExecutor), sig);
// Recovers the signer of a BondAuth authorizing the executor and advance.
function _recoverBondSigner(address bondingExecutor, uint256 gasAdvance, bytes calldata sig)
internal
view
returns (address)
{
return ECDSA.recover(_hashBondAuth(bondingExecutor, gasAdvance), sig);
}

// only deployer can call this. will set the cancellation request to true.
Expand Down Expand Up @@ -146,12 +153,14 @@ abstract contract EscrowBase {

// Internal helper to validate bond requirements. The entry check is the ECDH gate:
// the enclave's BondAuth signature must recover to this escrow's blindedSigner. There
// is no node deposit; the escrow pays out its bond pot instead of receiving one.
function _validateBond(bytes calldata bondSig) internal view {
// is no node deposit; Nomad provisions executor gas outside the escrow.
function _validateBond(uint256 gasAdvance, bytes calldata bondSig) internal view {
if (!funded) revert NotFunded();
if (cancellationRequest) revert CancellationRequested();
if (is_bonded()) revert ExecutorAlreadyBonded();
if (_recoverBondSigner(msg.sender, bondSig) != blindedSigner) revert InvalidBondSignature();
if (gasAdvance > 0 && gasAdvanceClaimed) revert GasAdvanceAlreadyClaimed();
if (gasAdvance > remainingGasAdvance()) revert GasAdvanceTooLarge();
if (_recoverBondSigner(msg.sender, gasAdvance, bondSig) != blindedSigner) revert InvalidBondSignature();
}

// Internal helper to set bond data. bondedExecutor is the fresh EOA that produced a
Expand All @@ -162,24 +171,38 @@ abstract contract EscrowBase {
bondStartBlock = block.number;
}

// Locks the escrow to the calling fresh EOA and pays it the ETH bond pot to bootstrap
// its gas. Gated by the ECDH signature: bondSig must recover to blindedSigner. The bond
// ETH leaving the escrow lets the caller repay the block builder in the same bundle.
// Asset-agnostic (the pot is always ETH), so it lives in the base for both flavors.
function bond(bytes calldata bondSig) external {
/// @notice Remaining one-time advance available from the existing reward.
function remainingGasAdvance() public view returns (uint256) {
if (gasAdvanceClaimed) return 0;
return maxGasAdvance < currentRewardAmount ? maxGasAdvance : currentRewardAmount;
}

function _validateGasAdvanceBudget(uint256 rewardAmount) internal view {
if (maxGasAdvance > rewardAmount) revert GasAdvanceBudgetExceedsReward();
}

// Locks the escrow to the calling EOA for five minutes and optionally releases
// a capped part of the existing reward. Titan fronts the bundle; the EOA swaps
// this advance when necessary, repays the builder, and retains collect() gas.
// No separate ETH bond pot is funded by the sender.
function bond(uint256 gasAdvance, bytes calldata bondSig) external {
// A prior expired bond frees the lock for this fresh enclave.
_clearExpiredBond();

_validateBond(bondSig);
_validateBond(gasAdvance, bondSig);

_setBondData();

uint256 pot = bondPot;
bondPot = 0;
(bool success,) = msg.sender.call{value: pot}("");
if (!success) revert BondTransferFailed();
if (gasAdvance > 0) {
gasAdvanceClaimed = true;
currentRewardAmount -= gasAdvance;
_releaseGasAdvance(msg.sender, gasAdvance);
}
}

/// Transfers an advance in this single escrow's reward asset.
function _releaseGasAdvance(address executor, uint256 gasAdvance) internal virtual;

// Internal helper to clear payout state
function _clearPayoutState() internal {
bondedExecutor = address(0);
Expand All @@ -190,8 +213,7 @@ abstract contract EscrowBase {
currentRewardAmount = 0;
}

// Internal helper to calculate payout amount. The bond pot is spent bootstrapping the
// serve at bond() and is not part of the collect payout.
// Internal helper to calculate the principal reimbursement and reward payout.
function _calculatePayout() internal view returns (uint256) {
return currentRewardAmount + currentPaymentAmount;
}
Expand All @@ -202,9 +224,10 @@ abstract contract EscrowBase {
if (msg.sender != deployerAddress) revert OnlyDeployer();
}

// Internal helper to calculate withdrawable amount and clear state
// An advance has already left the escrow, so cancellation returns only the
// remaining reward. originalRewardAmount remains an immutable-cycle audit value.
function _calculateWithdrawableAmount() internal view returns (uint256) {
return currentPaymentAmount + originalRewardAmount;
return currentPaymentAmount + currentRewardAmount;
}

// Internal helper to clear state after withdraw
Expand Down
41 changes: 35 additions & 6 deletions src/EscrowBatch.sol
Original file line number Diff line number Diff line change
Expand Up @@ -95,13 +95,15 @@ contract EscrowBatch {
error InvalidTransferIndex();
error TransferStateConflict();
error Reentrancy();
error GasAdvanceTooLarge();
error GasAdvanceBudgetExceedsReward();

// ============ Storage ============

bytes32 private constant _DOMAIN_TYPEHASH =
keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
bytes32 private constant _BOND_TYPEHASH =
keccak256("BatchBondAuth(address bondingExecutor,uint256[] transferIndexes)");
keccak256("BatchBondAuth(address bondingExecutor,uint256[] transferIndexes,uint256 gasAdvance)");
bytes32 private constant _NAME_HASH = keccak256("MirageEscrow");
bytes32 private constant _VERSION_HASH = keccak256("1");
bytes32 private immutable _domainSeparator;
Expand All @@ -110,13 +112,16 @@ contract EscrowBatch {
address public immutable rewardAsset;
uint256 public immutable totalTransferAmount;
uint256 public immutable totalValueWeight;
/// @notice Quote-time reward budget reserved for fresh-EOA gas provisioning.
uint256 public immutable maxGasAdvance;

uint256 public currentRewardAmount;
uint256 public currentTransferAmount;
uint256 public currentValueWeight;
uint256 public originalRewardAmount;
uint256 public completedTransferCount;
uint256 public activeBidCount;
uint256 public totalGasAdvanced;

uint256 public constant MAX_BLOCK_LOOKBACK = 256;
uint256 public constant BID_DURATION = 5 minutes;
Expand Down Expand Up @@ -167,6 +172,7 @@ contract EscrowBatch {
address _rewardAsset,
BatchTransfer[] memory _expectedTransfers,
uint256 _currentRewardAmount,
uint256 _maxGasAdvance,
address[] memory _blindedSigners
) payable {
if (_expectedTransfers.length == 0) revert EmptyBatch();
Expand All @@ -175,6 +181,7 @@ contract EscrowBatch {
if (_blindedSigners.length > MAX_BLINDED_SIGNERS) revert TooManyBlindedSigners();

rewardAsset = _rewardAsset;
maxGasAdvance = _maxGasAdvance;
deployerAddress = msg.sender;
_domainSeparator =
keccak256(abi.encode(_DOMAIN_TYPEHASH, _NAME_HASH, _VERSION_HASH, block.chainid, address(this)));
Expand Down Expand Up @@ -253,6 +260,15 @@ contract EscrowBatch {
return bidTransferIndexes[bidder][position];
}

/// @notice Reward funds still available to bootstrap future bidders.
/// The limit applies across the entire escrow, not independently per bid.
function remainingGasAdvance() public view returns (uint256) {
if (totalGasAdvanced >= maxGasAdvance) return 0;

uint256 allowance = maxGasAdvance - totalGasAdvanced;
return Math.min(allowance, currentRewardAmount);
}

/// @notice Whether at least one bid is still inside its execution window.
function is_bonded() public view returns (bool) {
for (uint256 i = 0; i < bidders.length;) {
Expand Down Expand Up @@ -338,11 +354,15 @@ contract EscrowBatch {
/// @notice Place a free bid on a subset of expected transfers.
/// @dev The signature binds this escrow, chain, bidder, and exact row indexes.
/// Its recovered blinded signer must be constructor-approved and unused.
function bid(uint256[] calldata transferIndexes, bytes calldata bidSignature) external nonReentrant {
function bid(uint256[] calldata transferIndexes, uint256 gasAdvance, bytes calldata bidSignature)
external
nonReentrant
{
_handleExpiredBids();
_validateBidRequirements(transferIndexes);
if (gasAdvance > remainingGasAdvance()) revert GasAdvanceTooLarge();

address signer = ECDSA.recover(_hashBidAuthorization(msg.sender, transferIndexes), bidSignature);
address signer = ECDSA.recover(_hashBidAuthorization(msg.sender, transferIndexes, gasAdvance), bidSignature);
if (!isBlindedSigner[signer]) revert InvalidBidSignature();
if (blindedSignerUsed[signer]) revert BlindedSignerAlreadyUsed();

Expand All @@ -366,6 +386,12 @@ contract EscrowBatch {
++i;
}
}

if (gasAdvance > 0) {
totalGasAdvanced += gasAdvance;
currentRewardAmount -= gasAdvance;
_sendAsset(rewardAsset, msg.sender, gasAdvance);
}
}

/// @notice Permanently settle a non-empty proved subset of the caller's active bid.
Expand Down Expand Up @@ -467,20 +493,22 @@ contract EscrowBatch {

// ============ Internal: bid authorization ============

function _hashBidAuthorization(address bondingExecutor, uint256[] calldata transferIndexes)
function _hashBidAuthorization(address bondingExecutor, uint256[] calldata transferIndexes, uint256 gasAdvance)
internal
view
returns (bytes32)
{
bytes32 structHash =
keccak256(abi.encode(_BOND_TYPEHASH, bondingExecutor, keccak256(abi.encodePacked(transferIndexes))));
bytes32 structHash = keccak256(
abi.encode(_BOND_TYPEHASH, bondingExecutor, keccak256(abi.encodePacked(transferIndexes)), gasAdvance)
);
return keccak256(abi.encodePacked("\x19\x01", _domainSeparator, structHash));
}

// ============ Internal: funding ============

function _fund(uint256 _currentRewardAmount) internal {
if (_currentRewardAmount == 0) revert ZeroRewardAmount();
if (maxGasAdvance > _currentRewardAmount) revert GasAdvanceBudgetExceedsReward();

// msg.value must cover any native transfers in the batch plus, if the
// reward currency is ETH, the reward amount itself.
Expand All @@ -495,6 +523,7 @@ contract EscrowBatch {
currentTransferAmount = totalTransferAmount;
currentValueWeight = totalValueWeight;
completedTransferCount = 0;
totalGasAdvanced = 0;
hasBeenFunded = true;
funded = true;

Expand Down
29 changes: 12 additions & 17 deletions src/EscrowERC20.sol
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ contract EscrowERC20 is EscrowBase {
error ZeroAddress();
error AlreadyFunded();
error ZeroRewardAmount();
error ZeroBondAmount();
error InvalidReceiptProof();
error InvalidTransferEvent();
error NoWithdrawableFunds();
Expand All @@ -33,8 +32,9 @@ contract EscrowERC20 is EscrowBase {
address _expectedRecipient,
uint256 _expectedAmount,
address _blindedSigner,
uint256 _currentRewardAmount
) payable EscrowBase(_expectedRecipient, _expectedAmount, _blindedSigner) {
uint256 _currentRewardAmount,
uint256 _maxGasAdvance
) EscrowBase(_expectedRecipient, _expectedAmount, _blindedSigner, _maxGasAdvance) {
if (_tokenContract == address(0)) revert ZeroAddress();
tokenContract = _tokenContract;

Expand All @@ -43,23 +43,26 @@ contract EscrowERC20 is EscrowBase {
}
}

// takes currentRewardAmount + expectedAmount (the payment) from the deployer's balance
// from the tokenContract, and the ETH bond pot (msg.value) that bootstraps the fresh
// EOA's gas. The payment reimburses the proven delivery, so it is always expectedAmount.
function fund(uint256 _currentRewardAmount) public payable {
// Takes currentRewardAmount + expectedAmount from the deployer's token balance. The
// sender deposits no ETH execution-gas surcharge; Nomad provisions executor gas.
function fund(uint256 _currentRewardAmount) public {
if (msg.sender != deployerAddress) revert OnlyDeployer();
if (funded) revert AlreadyFunded();
if (_currentRewardAmount == 0) revert ZeroRewardAmount();
if (msg.value == 0) revert ZeroBondAmount();
_validateGasAdvanceBudget(_currentRewardAmount);

currentRewardAmount = _currentRewardAmount;
originalRewardAmount = _currentRewardAmount;
currentPaymentAmount = expectedAmount;
bondPot = msg.value;
gasAdvanceClaimed = false;
IERC20(tokenContract).safeTransferFrom(msg.sender, address(this), originalRewardAmount + currentPaymentAmount);
funded = true;
}

function _releaseGasAdvance(address executor, uint256 gasAdvance) internal override {
IERC20(tokenContract).safeTransfer(executor, gasAdvance);
}

// Validates a Transfer-event proof against a recent block hash and checks the Transfer
// event's contents, then pays the bonded executor. Gated by the OnlyBondedExecutor guard:
// the ECDH signature was spent at bond(), so the bonded EOA is thereafter the only caller.
Expand Down Expand Up @@ -99,18 +102,10 @@ contract EscrowERC20 is EscrowBase {
_tryResetBondData();

uint256 withdrawableAmount = _calculateWithdrawableAmount();
uint256 pot = bondPot;

_clearWithdrawState();
bondPot = 0;

if (withdrawableAmount == 0) revert NoWithdrawableFunds();

IERC20(tokenContract).safeTransfer(msg.sender, withdrawableAmount);
// Return the unspent ETH bond pot alongside the token reward.
if (pot > 0) {
(bool success,) = msg.sender.call{value: pot}("");
if (!success) revert BondTransferFailed();
}
}
}
Loading
Loading