// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; interface IERC20 { function transferFrom(address from, address to, uint256 amount) external returns (bool); function balanceOf(address owner) external view returns (uint256); function allowance(address owner, address spender) external view returns (uint256); } /// @title PullFromOwnerAdapter — the account draws on a budget its owner set. /// /// The mirror of OwnerTransferAdapter. That one moves value OUT of the account /// and only to the owner; this moves value IN and only from the owner. Together /// they are the two directions of the same relationship, and neither can name a /// counterparty: there is no `from` here and no `to` there, because both ends /// are read from the account rather than passed in. /// /// WHY IT EXISTS. Every adapter spends what the account already holds, so an /// account with an empty balance cannot do anything at all — and the only way to /// fill it was for the owner to send a transaction per top-up. That is a human /// step in front of every run, which for a system whose point is to run things /// unattended is the whole problem rather than a detail of it. /// /// WHAT IT COSTS. A standing ERC-20 allowance is a permission that outlives the /// call it was granted for, and BaseAdapter argues against exactly that when the /// account grants one to a venue — "the drain that happens later, quietly". The /// argument is not wrong; it is answered here rather than dismissed: /// /// - The destination is the account, which is the owner's own. A pull moves /// value between two addresses the same person controls. It cannot take the /// money anywhere, only forward, into the place the caps already govern. /// - It is bounded twice, independently. The ERC-20 allowance the owner set is /// one ceiling; the account's PULL cap in AgentRegistry (`setPullCap`, /// `chargePull`) is another, and it is a separate row from the spend cap /// every other adapter charges, so a pull budget is not a swap budget: /// "pull X, swap X" counts X against each, not 2X against the spend cap. /// Either alone stops a runaway; both have to be wrong. /// - The worst a stolen agent key achieves is moving the owner's money into /// the owner's own account, up to the smaller of those two, and then facing /// the same caps on spending it that it faced before. /// /// That is a real reduction in the owner's control and it is deliberate: the /// owner chose a budget instead of a signature per transfer. What it must never /// become is a way to reach anybody else's tokens, which is why the source is /// `ownerOf(account)` and not a parameter. contract PullFromOwnerAdapter is BaseAdapter { /// Separate from installing the module, for the same reason returning value /// is: this is the direction that touches the owner's own wallet, and an /// owner may reasonably install the adapter and not switch it on. bytes32 public constant OP_PULL = "pull_from_owner"; bytes32 internal constant PULL_TYPEHASH = keccak256( "PullFromOwner(address account,address owner,bytes32 taskId,uint256 deadline,address asset,uint256 amount)" ); struct PullFromOwner { 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 `from`. There is one source and this contract reads it — an // adapter that took one could be pointed at anybody who had ever // approved this account, which is the theft this design removes. // No `to` either: it arrives in the account or nowhere. } event PulledFromOwner( address indexed account, address indexed owner, address indexed asset, uint256 amount ); error PullFailed(); /// The transfer reported success and moved nothing. error NothingArrived(address asset); error TookMoreThanAuthorised(uint256 authorised, uint256 taken); error NoAllowance(address owner, address account, address asset, uint256 wanted, uint256 given); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} /// THIS IS THE SWITCH THE HEADER PROMISES — H-2 of the round-2 audit. /// /// The comment on `OP_PULL` 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_PULL; } function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsPullFromOwnerAdapter", "1"); } function digest(PullFromOwner calldata p) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( PULL_TYPEHASH, p.account, p.owner, p.taskId, p.deadline, p.asset, p.amount ) ) ); } function pullFromOwner(PullFromOwner calldata p, bytes calldata signature) external { _consume(p.account, p.owner, p.taskId, p.deadline, digest(p), signature); _requireOperation(p.account, OP_PULL); // CHARGED AGAINST THE PULL CAP, NOT THE SPEND CAP (2026-10-09). A pull // is not an outflow from the account, but it is an outflow from the // owner, and a cap is what bounds how fast the agent may draw on a // standing allowance (M-9). That cap is the account's own pull budget, // a separate registry row: charging the spend cap here made the swap // that spends the pulled tokens count them a second time. On v2 it // draws nothing from the plan's allowance either: a plan declares what // it spends, and a pull spends nothing. // // The registry records where this charge landed (the pull epoch that // paid, or nowhere when uncapped) for this transaction, so the // `refundPull` below can give back only what this charge took, only to // that epoch, and never to a plan. The returned tag is not needed here: // the refund is in this same call. registry.chargePull(p.account, p.asset, p.amount); address owner = ownerOf(p.account); // Read first, so the refusal names the allowance rather than arriving as // a bare transferFrom failure — which reads as the tokens not being // there and sends somebody looking at the wrong balance. uint256 given = IERC20(p.asset).allowance(owner, p.account); if (given < p.amount) revert NoAllowance(owner, p.account, p.asset, p.amount, given); uint256 ownerBefore = IERC20(p.asset).balanceOf(owner); uint256 accountBefore = IERC20(p.asset).balanceOf(p.account); // The ACCOUNT is msg.sender at the token, so the allowance that matters // is the one the owner granted the account — not one granted to this // contract, which would make every account sharing this adapter a // spender of every owner's tokens. bytes memory ret = _exec( p.account, p.asset, 0, abi.encodeCall(IERC20.transferFrom, (owner, p.account, p.amount)) ); if (ret.length != 0 && !abi.decode(ret, (bool))) revert PullFailed(); // THE CAP FOLLOWS WHAT LEFT THE OWNER (M-9), and the two numbers below // are genuinely different. // // The refund used to be computed against what ARRIVED, so with a taxed // token the owner was debited `p.amount` while the epoch was charged // only the smaller figure that reached the account — an owner paying // `C/(1-f)` for a cap of `C`, repeatable. The cap here exists to bound // how fast the agent may draw on a standing allowance, and what it must // measure is the drawing. uint256 taken = ownerBefore - IERC20(p.asset).balanceOf(owner); if (taken > p.amount) revert TookMoreThanAuthorised(p.amount, taken); if (taken < p.amount) registry.refundPull(p.account, p.asset, p.amount - taken, p.amount); // ACCOUNT RECEIPT, not owner Δ. A fee-on-transfer token drops `p.amount` // from the owner and delivers less; publishing the gross let a later // amountFrom spend a number that never landed. uint256 arrived = IERC20(p.asset).balanceOf(p.account) - accountBefore; if (arrived > taken) revert TookMoreThanAuthorised(taken, arrived); // A ZERO DELIVERY IS NOT A SETTLED PULL (M-9). // // A token whose `transferFrom` returns true and moves nothing — a // no-op, a paused or blocklisting implementation — refunded the ENTIRE // charge and emitted success: cap spent and restored, owner paid // nothing, account received nothing, step reported settled. Its mirror // in BridgeAdapter already refuses this and says why: "Checking only // for too MUCH could not see it: nothing moved, and nothing is not // more than the amount." if (arrived == 0) revert NothingArrived(p.asset); _publishDelivered(p.account, p.taskId, p.asset, arrived); emit PulledFromOwner(p.account, owner, p.asset, arrived); } }