// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); function balanceOf(address owner) external view returns (uint256); } /// @title OwnerTransferAdapter — the agent may return value, and only to the owner. /// /// This is the adapter a deleted `SendAdapter` should have been. That one took a /// `payoutTo` address owner A configured at install, which is wrong twice over: /// it let the agent pay an arbitrary address, and it made the destination a /// SETTING. A setting can be mistyped once and send funds to a stranger forever, /// and it goes stale the moment A rotates the owner key — the account changes /// hands and the payouts keep going to whoever used to hold it. /// /// So the destination is not stored here. It is READ, per call, from the account /// itself: Kernel says which validator is root, the root validator says whose /// account it is. A rotates its key and this follows, with nothing to update. If /// the root validator is not one this contract can ask, the call reverts — the /// right failure for "I cannot establish who the owner is". /// /// Why the agent may trigger it at all. Everything else B does keeps value inside /// the account, and this does not, so it deserves the harder look. What bounds it /// is that there is exactly one place the value can go, and it is the address /// that already controls the account. The worst a stolen B key achieves is /// sending the owner's money to the owner, sooner than planned. That is a timing /// problem, not a theft, and it is bounded further by the per-epoch cap. /// /// NOTE — this contract alone does not give the pipeline a sweep, and the reason /// written here was WRONG and has been corrected. It claimed the plan DSL /// resolves exactly one role, `owner_account`, so no plan could express "send to /// the owner's wallet". Both halves of that are false: plan.RoleOwnerWallet /// exists (internal/plan/compile.go), compile.go accepts either role on a send, /// and compile_test.go exercises both. /// /// What actually blocks it is narrower and worth stating, because the comment /// above misled in the opposite direction from the real obstacles: /// /// 1. THE ROLE IS NOT A CHOICE HERE AT ALL. This adapter ignores whatever /// recipient a plan names and sends to ownerOf(account), read from the root /// validator — the TransferToOwner typed data has no `to` field. A plan /// written `to: owner_account` would pay the WALLET; since 2026-10-07 it is /// refused off chain (plan.SendRecipientFault, zkexec.CheckRecipient). /// 2. NOT ARMING (ON unless disarmed, since 11a812e8). v2 grants the pair only /// to an owner holding a key, never a console-minted one (KERNEL_..._ZK §18.7). /// 3. ETH IS NAMED BY A SENTINEL, NOT A TOKEN (gen 7). `asset == NATIVE` /// (0xEeee…EeE, the convention every aggregator uses) sends the chain's /// own currency by value instead of calling `transfer`. Same struct, same /// operation, same adapter name: the v2 policy pair is (adapter NAME, /// operation) and is committed into every v2 account's genesis, so a new /// name or a new operation for ETH would have moved the standard policy /// root and stranded every v2 owner on it. The sentinel is the cap and /// plan-allowance key: AgentRegistry keys caps on any address and /// PolicyGate's buckets on any non-zero one. /// /// So "send everything back to the owner" is served by the OWNER's own direct /// path — internal/account/exit.go and cmd/api/withdraw.go — which needs no /// adapter by design. See that file for why that is not a hole in this model. contract OwnerTransferAdapter is BaseAdapter { /// Separate from installing the module, because returning value to the owner /// is the one thing here that moves value OUT — an owner may reasonably /// install the adapter and not switch it on. bytes32 public constant OP_RETURN = "return_to_owner"; /// THE CHAIN'S OWN CURRENCY, as an asset address. Not address(0): zero is /// what an unset field decodes to, and PolicyGate refuses a zero token /// for an allowance bucket. Public so the off-chain signer can ask a /// deployed adapter whether it sends native value at all — the gen-6 /// adapter has no such getter, and the signer refuses a native step /// against it before anything is signed. address public constant NATIVE = 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE; bytes32 internal constant TRANSFER_TYPEHASH = keccak256( "TransferToOwner(address account,address owner,bytes32 taskId,uint256 deadline,address asset,uint256 amount,address amountFrom,bytes32 amountFromTask)" ); struct TransferToOwner { 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; // No `to`. There is one destination and this contract reads it. /// WHERE THE AMOUNT COMES FROM. Zero means the amount above; non-zero /// names the producing adapter and step. See BaseAdapter. address amountFrom; bytes32 amountFromTask; } event ReturnedToOwner(address indexed account, address indexed owner, address indexed asset, uint256 amount); error TransferFailed(); error PaidMoreThanAuthorised(uint256 authorised, uint256 paid); /// The account holds less native value than the step sends. error InsufficientNative(uint256 held, uint256 amount); /// What left the account, or what reached the owner, is not EXACTLY the /// amount. Either side differing means the owner was not paid what was /// charged — an owner contract that refuses ETH, or one whose receive hook /// moved value in or out of the account mid-call. error NativeNotDelivered(uint256 amount, uint256 accountBefore, uint256 accountAfter, uint256 ownerBefore, uint256 ownerAfter); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} /// THIS IS THE SWITCH THE HEADER PROMISES — H-2 of the round-2 audit. /// /// The comment on `OP_RETURN` has always said this operation is "separate from /// installing the module", and until now nothing implemented that: the /// install payload carried it, `activate` is permissionless, and there was /// no path that installed the adapter without switching it on. Now the /// install payload cannot grant it at all and the account arms it with /// `AgentRegistry.armOperation`, which is a statement only the root validator /// can make. function _requiresArming(bytes32 operation) internal pure override returns (bool) { return operation == OP_RETURN; } function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsOwnerTransferAdapter", "1"); } function digest(TransferToOwner calldata t) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( TRANSFER_TYPEHASH, t.account, t.owner, t.taskId, t.deadline, t.asset, t.amount, t.amountFrom, t.amountFromTask ) ) ); } function transferToOwner(TransferToOwner calldata t, bytes calldata signature) external { _consume(t.account, t.owner, t.taskId, t.deadline, digest(t), signature); _requireOperation(t.account, OP_RETURN); // The literal, or what the step named in amountFrom delivered. Sending // OUT delivers nothing to the account, so this adapter consumes and // never publishes. uint256 amount = _amountToSpend(t.account, t.amount, t.amountFrom, t.amountFromTask, t.asset); _charge(t.account, t.asset, amount); address owner = ownerOf(t.account); if (t.asset == NATIVE) { _sendNative(t.account, owner, amount); emit ReturnedToOwner(t.account, owner, t.asset, amount); return; } uint256 before = IERC20(t.asset).balanceOf(t.account); bytes memory ret = _exec(t.account, t.asset, 0, abi.encodeCall(IERC20.transfer, (owner, amount))); if (ret.length != 0 && !abi.decode(ret, (bool))) revert TransferFailed(); uint256 paid = before - IERC20(t.asset).balanceOf(t.account); // EXACTLY what was authorised has to have left. Checking only for too // MUCH cannot see a no-op token (or empty code): nothing moved, and // nothing is not more than the amount — cap already spent, owner paid // nothing, step reported settled. Bridge already requires `sent == amount`. if (paid != amount) revert PaidMoreThanAuthorised(amount, paid); emit ReturnedToOwner(t.account, owner, t.asset, amount); } /// Send `amount` of the account's native value to its owner, and prove /// both ends moved by exactly that. /// /// THE ACCOUNT MAKES THE CALL, as for a token: `_exec` with the value and /// no calldata, through the account's own executor path (v1) or /// PolicyExecutor (v2), so the value never passes through this contract /// and there is no balance here to drain. /// /// BOTH SIDES ARE MEASURED. Kernel may report a failed call without /// reverting, an owner contract may refuse ETH, and an owner's receive /// hook runs mid-step with the account's root authority — it can move /// value in or out of the account before this returns. Each of those makes /// one of the two deltas differ from `amount`, and the step reverts with /// the four balances rather than reporting a payment that did not happen. /// A revert undoes the charge with everything else. function _sendNative(address account, address owner, uint256 amount) private { uint256 accountBefore = account.balance; if (accountBefore < amount) revert InsufficientNative(accountBefore, amount); uint256 ownerBefore = owner.balance; _exec(account, owner, amount, ""); uint256 accountAfter = account.balance; uint256 ownerAfter = owner.balance; if (accountBefore - amount != accountAfter || ownerBefore + amount != ownerAfter) { revert NativeNotDelivered(amount, accountBefore, accountAfter, ownerBefore, ownerAfter); } } }