// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; interface IERC20 { function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address owner) external view returns (uint256); } /// The Across SpokePool surface this adapter actually calls. /// /// `depositV3Now` rather than `depositV3`: the spoke stamps `quoteTimestamp` /// from its own clock, so a stale or out-of-buffer quote cannot be smuggled in /// as a signed field. The bytes32 `deposit` exists too; the account is a /// 20-byte address on every chain this system serves, so there is nothing a /// bytes32 encoding can express here. interface ISpokePool { function depositV3Now( address depositor, address recipient, address inputToken, address outputToken, uint256 inputAmount, uint256 outputAmount, uint256 destinationChainId, address exclusiveRelayer, uint32 fillDeadlineOffset, uint32 exclusivityDeadline, bytes calldata message ) external payable; function fillDeadlineBuffer() external view returns (uint32); } /// @title AcrossAdapter — send value to the same account on another chain, via Across. /// /// Across's `depositV3` takes twelve arguments. Five of them are things this /// design exists to keep out of an intent: an output amount (the fee), an /// output token, an exclusive relayer, a fill deadline, and an arbitrary /// `message` that executes on the destination chain. Those are PINNED here, /// with no field to name them. What is signed is the same shape as /// `BridgeAdapter.Bridge`, plus a fee ceiling — because Across needs /// `outputAmount`, and `inputAmount - outputAmount` is the relayer's take. /// /// THE RECIPIENT IS NOT A PARAMETER. The account is CREATE2-derived from its /// owner and the agent grant, so it has the SAME address on every served chain. /// An adapter that took a recipient could send the account's money to any /// address on any chain, which is the arbitrary transfer this design removes. /// /// `outputToken` is NOT signed. Across used to take `address(0)` as "the /// equivalent token on the destination chain"; the live spoke now reverts /// `InvalidOutputToken` on zero. So this contract maps the origin asset to /// its canonical address on the destination chain — USDC, WETH, USDT on the /// three served chains, hardcoded, not a venue. If it were a signed field, a /// compromised keeper could name a worthless token and have its own relayer /// fill the deposit with dust: the cap charges the full input, the loss lands /// entirely on the output, which is the `SwapAdapter` `assetOut` hole at /// `BaseAdapter.sol:891-918`. A token this map does not know is refused. /// /// `message` is empty. A non-empty message is arbitrary calldata executed on /// the destination chain — the precise thing this whole adapter design /// removes. There is no field for it and no way to reach one. /// /// A deposit is FINAL on this side the moment it lands. Nothing here can undo /// it, and what arrives on the far side is somebody else's transaction later — /// which is why this step never publishes to the runtime bus. Transient /// storage is scoped to one transaction on one chain; there is nothing here /// for a later step to read. /// /// `_requiresArming` stays false, matching `BridgeAdapter`. Arming looks /// attractive for a cross-chain operation, but `onInstall` reverts on an /// armed operation in the install payload, so flipping it would make a /// curator blob that lists `bridge` fail outright. contract AcrossAdapter is BaseAdapter { bytes32 public constant OP_BRIDGE = "bridge"; /// The most any crossing may pay, whatever the intent says. 100 bps against /// the ~1-15 bps Across actually charges for these assets, so it binds /// rather than decorates. /// /// PER CROSSING — finding 14 of the 2026-09-26 audit. Unlike CCTP, where /// Circle deducts its ACTUAL fee, Across fixes `outputAmount` at deposit, /// so whoever fills keeps the whole ceiling: a keeper that also runs a /// relayer can round-trip the account and take up to 1% a crossing. /// Tightening this number was weighed and declined: Across's gas component /// is fixed, so small crossings legitimately cost more in bps and a tighter /// ceiling starts refusing them. The aggregate bound used to be only the /// owner's spend cap, which is unset by default; it is now also /// `EPOCH_FEE_BUDGET_BPS` below, which binds whether or not a cap is set. uint16 public constant MAX_BRIDGE_FEE_BPS = 100; /// THE DAY'S FEE BUDGET — #10 and #28 of the 2026-10-08 audit, which /// reopened what finding 14 above accepted. /// /// The ceiling above is PER CROSSING, and Across fixes `outputAmount` at /// deposit, so a keeper that also fills keeps whatever the ceiling allows, /// every hop. With no spend cap set — the default — nothing bounded how /// many hops: a round trip a block took 1% of the account each time, and /// the money never had to leave the account's own address to be drained. /// /// So the fees are SUMMED, per (account, asset) per UTC day, and the sum /// may not pass `EPOCH_FEE_BUDGET_BPS` of the LARGEST crossing that day. /// The largest crossing, not the total bridged: a round trip re-sends the /// same principal, so the total grows with every hop while the largest /// does not — which is exactly the quantity the drain inflates and the /// bound must not. One crossing may still pay the whole ceiling; after /// it, the day's fee room is spent whatever the hops. /// /// WHAT HONEST USE COSTS. The Go side signs Across's QUOTED fee per hop /// (#219, internal/acrossfee) — single-digit bps for these assets — so an /// honest account crosses many times a day inside the budget. A crossing /// that would overrun it is refused, `FeeBudgetExceeded`, naming what is /// left, and tomorrow is a fresh budget. /// /// PER CHAIN, because this storage is: the same account bridging out of /// three chains has three budgets. A round trip pays on both ends, so the /// bound on a drain is this figure per chain per day, not this figure. uint16 public constant EPOCH_FEE_BUDGET_BPS = 100; /// One UTC day, as `AgentRegistry.CAP_EPOCH` is. A constant: a /// configurable window is one somebody widens. uint256 public constant FEE_EPOCH = 1 days; /// What this (account, asset) has paid Across today, and the largest /// crossing it has made today. `day` is `block.timestamp / FEE_EPOCH`; a /// row from another day reads as empty. struct FeeDay { uint256 day; uint256 largest; uint256 paid; } mapping(address account => mapping(address asset => FeeDay)) public feeDay; bytes32 internal constant CROSS_TYPEHASH = keccak256( "Cross(address account,address owner,bytes32 taskId,uint256 deadline,address asset,uint256 amount,address amountFrom,bytes32 amountFromTask,uint256 destinationChainId,uint16 maxFeeBps,bytes32 venue)" ); struct Cross { address account; /// WHOSE account. Signed and checked against what the account itself /// says, so a plan that named the wrong one is refused rather than /// authorised by a signature that happens to be valid for it. address owner; bytes32 taskId; uint256 deadline; address asset; uint256 amount; /// WHERE THE AMOUNT COMES FROM. Zero means the amount above. See /// BaseAdapter. address amountFrom; bytes32 amountFromTask; /// Where it is going. Signed, because it decides which chain ends up /// holding the money. uint256 destinationChainId; /// The most this crossing may pay Across, in bps of `amount`. Zero is /// refused: a plan that forgot the field is a plan nobody checked. /// The adapter takes the tighter of this and `MAX_BRIDGE_FEE_BPS`. uint16 maxFeeBps; /// WHICH spoke, by name. Resolved to an address from what the owner /// installed, never taken as one. bytes32 venue; } event Bridged( address indexed account, address indexed asset, uint256 amount, uint256 indexed destinationChainId, uint256 outputAmount ); error ApproveFailed(); error SameChain(uint256 chainId); /// A destination this system has not proven account-address parity for, so /// `c.account` there is not this owner's account. error ChainNotServed(uint256 chainId); /// Raised when what left the account is not what was authorised — in /// EITHER direction. Too much is theft; nothing at all is a crossing that /// did not happen, reported as one that did. error SentMoreThanAuthorised(uint256 authorised, uint256 sent); /// A signed zero fee bound. Defaulting to the ceiling would let a plan /// that forgot the field through as "pay the maximum". error NoFeeBound(); /// The origin asset has no canonical equivalent on the destination chain /// in the map this adapter owns. Bridging it would mean taking an output /// token from the intent, which is the hole this map exists to close. error NoEquivalent(address asset, uint256 destinationChainId); /// This crossing's fee would take the day's total past /// `EPOCH_FEE_BUDGET_BPS` of the day's largest crossing. `left` is what /// the day still allows. See `EPOCH_FEE_BUDGET_BPS`. error FeeBudgetExceeded(uint256 fee, uint256 left); /// The chains this system derives accounts on, and therefore the only /// chains a bridge may deliver to. /// /// PURE, and a hardcoded set rather than a venue or a config, for the same /// reason `RUNTIME_BUS` is a constant: this decides where money goes, and a /// mutable pointer is one somebody could repoint. /// /// Duplicated from `BridgeAdapter` rather than lifted into `BaseAdapter` — /// touching `BaseAdapter` would move all seventeen adapter addresses. A /// test holds the two lists against each other so they cannot drift. /// /// 1 ethereum · 8453 base · 42161 arbitrum. function _servesChain(uint256 chainId) internal pure returns (bool) { return chainId == 1 || chainId == 8453 || chainId == 42161; } function servesChain(uint256 chainId) external pure returns (bool) { return _servesChain(chainId); } constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsAcrossAdapter", "1"); } function digest(Cross calldata c) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( CROSS_TYPEHASH, c.account, c.owner, c.taskId, c.deadline, c.asset, c.amount, c.amountFrom, c.amountFromTask, c.destinationChainId, c.maxFeeBps, c.venue ) ) ); } function cross(Cross calldata c, bytes calldata signature) external { _consume(c.account, c.owner, c.taskId, c.deadline, digest(c), signature); _requireOperation(c.account, OP_BRIDGE); // CONSUMES AND NEVER PUBLISHES. Across value arrives on ANOTHER chain // in a later transaction, and transient storage is scoped to one // transaction on one chain. uint256 amount = _amountToSpend(c.account, c.amount, c.amountFrom, c.amountFromTask, c.asset); _charge(c.account, c.asset, amount); if (c.destinationChainId == block.chainid) revert SameChain(c.destinationChainId); if (!_servesChain(c.destinationChainId)) revert ChainNotServed(c.destinationChainId); uint256 outputAmount = _outputAmount(amount, c.maxFeeBps); _spendFeeBudget(c.account, c.asset, amount, amount - outputAmount); address venue = _venueOf(c.account, c.venue); uint256 before = IERC20(c.asset).balanceOf(c.account); bytes memory ok = _exec(c.account, c.asset, 0, abi.encodeCall(IERC20.approve, (venue, amount))); if (ok.length != 0 && !abi.decode(ok, (bool))) revert ApproveFailed(); _depositNow( c.account, venue, c.asset, _outputToken(c.asset, c.destinationChainId), amount, outputAmount, c.destinationChainId ); _requireNoResidual(c.account, c.asset, venue); uint256 sent = before - IERC20(c.asset).balanceOf(c.account); if (sent != amount) revert SentMoreThanAuthorised(amount, sent); emit Bridged(c.account, c.asset, amount, c.destinationChainId, outputAmount); } /// TWO BOUNDS, AND THE AGENT IS INSIDE BOTH. `maxFeeBps` is a number the /// agent fills in, and on the production path `_consume` verifies no /// signature, so the constant is the only half a compromised key cannot /// author. function _outputAmount(uint256 amount, uint16 maxFeeBps) internal pure returns (uint256) { if (maxFeeBps == 0) revert NoFeeBound(); uint16 feeBps = maxFeeBps < MAX_BRIDGE_FEE_BPS ? maxFeeBps : MAX_BRIDGE_FEE_BPS; return amount - (amount * feeBps) / 10_000; } /// Charge one crossing's fee to the day's budget, or refuse it. See /// `EPOCH_FEE_BUDGET_BPS`. Written before the deposit, and a refusal /// reverts the whole crossing, so nothing is recorded for a hop that did /// not happen. function _spendFeeBudget(address account, address asset, uint256 amount, uint256 fee) internal { FeeDay storage d = feeDay[account][asset]; uint256 today = block.timestamp / FEE_EPOCH; if (d.day != today) { d.day = today; d.largest = 0; d.paid = 0; } if (amount > d.largest) d.largest = amount; uint256 budget = (d.largest * EPOCH_FEE_BUDGET_BPS) / 10_000; uint256 left = budget > d.paid ? budget - d.paid : 0; if (fee > left) revert FeeBudgetExceeded(fee, left); d.paid += fee; } /// Canonical destination token for `input` on `dest`. Hardcoded rather than /// signed: the output address is exactly the field a compromised keeper /// would use to drain via a junk token. function _outputToken(address input, uint256 dest) internal view returns (address out) { out = _equivalent(input, dest); if (out == address(0)) revert NoEquivalent(input, dest); } function _equivalent(address input, uint256 dest) internal view returns (address) { if (input == _usdc(block.chainid)) return _usdc(dest); if (input == _weth(block.chainid)) return _weth(dest); if (input == _usdt(block.chainid)) return _usdt(dest); return address(0); } function _usdc(uint256 c) internal pure returns (address) { if (c == 1) return 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48; if (c == 8453) return 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913; if (c == 42161) return 0xaf88d065e77c8cC2239327C5EDb3A432268e5831; return address(0); } function _weth(uint256 c) internal pure returns (address) { if (c == 1) return 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; if (c == 8453) return 0x4200000000000000000000000000000000000006; if (c == 42161) return 0x82aF49447D8a07e3bd95BD0d56f35241523fBab1; return address(0); } function _usdt(uint256 c) internal pure returns (address) { if (c == 1) return 0xdAC17F958D2ee523a2206206994597C13D831ec7; if (c == 42161) return 0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9; return address(0); } /// Isolated so `cross` does not run out of stack: `depositV3Now` takes /// eleven arguments and `via_ir` is pinned off. function _depositNow( address account, address venue, address asset, address outputToken, uint256 amount, uint256 outputAmount, uint256 destinationChainId ) internal { // Read the buffer from the spoke when it has code. An empty-code // venue would revert here on a failed ABI decode, masking the // `SentMoreThanAuthorised` check that exists to catch exactly that // case — a deposit that "worked" and moved nothing. uint32 fillDeadlineOffset; if (venue.code.length != 0) { fillDeadlineOffset = ISpokePool(venue).fillDeadlineBuffer(); } _exec( account, venue, 0, abi.encodeCall( ISpokePool.depositV3Now, ( account, account, asset, outputToken, amount, outputAmount, destinationChainId, address(0), fillDeadlineOffset, uint32(0), bytes("") ) ) ); } }