// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {BaseAdapter} from "../BaseAdapter.sol"; import {AgentRegistry} from "../AgentRegistry.sol"; import {curationOf} from "../AdapterCuration.sol"; interface IERC20 { function approve(address spender, uint256 amount) external returns (bool); function balanceOf(address owner) external view returns (uint256); } /// 0xequity's marketplace, reached through the OCLR router. /// /// SwapArgs carries a `bytes arbCallData`, which looks like a direct collision /// with the rule that an agent supplies parameters and never calldata. It is /// not: `Marketplace.swap` never reads it — it is written only by the bundler's /// Recipe actions during parameter splicing, and is vestigial on this path. That /// was checked against the source rather than assumed, and it is recorded in /// TODO.md because an earlier reading of this file overstated it. /// /// It is passed EMPTY here regardless. A field nothing reads costs nothing to /// leave empty, and a field an agent could fill is one nobody should have to /// re-verify the harmlessness of after the next upgrade. interface IOCLRouter { struct SwapArgs { address from; address to; address recipient; bool isFeeInXeq; address[] vaults; uint256[] amounts; bytes arbCallData; } function buyProperty(SwapArgs memory args, address marketplace) external returns (uint256); function sellProperty(SwapArgs memory args, address marketplace) external returns (uint256); } interface ISBTCheck { function getApprovedSBTCommunities(string memory symbol) external view returns (string[] memory); function getBalanceOf(address user, string memory community) external view returns (uint256); } interface IAllowList { function isAddressOnAllowList(address addressToCheck) external view returns (bool); } /// @title PropertyAdapter — buy and sell 0xequity property tokens. /// /// The property is a NAME. A property is a vault, a token and a marketplace, and /// an agent that could supply those three addresses could point a purchase at /// contracts of its own — so the protocol's curation (AdapterCuration, one table /// per chain) says what each name resolves to, exactly as Morpho markets are /// curated rather than described. /// /// Everything these contracts let a caller choose about DESTINATION is written /// here: `vaults` is the one the curation names, `arbCallData` is empty, and `recipient` /// is owner A — READ from the account, never stored and never signed. /// /// A, and not the account, and that is the opposite of every other adapter here. /// The reason is not preference: a property token is a regulated instrument, and /// 0xequity gates who may hold one on KYC and a soul-bound token. The owner's /// wallet has both; the smart account does not, so delivering there would buy an /// asset into an address the venue will not let hold it. /// /// The consequence for SELLING is worth stating rather than discovering. The /// router does `safeTransferFrom(msg.sender, ...)`, and msg.sender is the /// account — so a sale requires the ACCOUNT to hold the property tokens, which /// it will not if purchases deliver to the owner. Selling is therefore A's own /// act in the normal arrangement, from A's own wallet. OP_SELL exists and works, /// but only for an account that came to hold property tokens some other way, and /// an owner who has not arranged that should simply not authorise it. /// /// One thing worth stating plainly, because it is unusual for this codebase: /// every 0xequity address involved is a PROXY. Pinning an address does not pin /// behaviour — what these contracts do can change without the address changing, /// and no amount of care in this file affects that. What this adapter can still /// guarantee against an upgraded venue is what it MEASURES: the account does not /// pay more than the agent signed for, and does not receive less. contract PropertyAdapter is BaseAdapter { /// The venue name A maps to 0xequity's allow-list. Unmapped means "assume /// the account is not cleared", which is the safe direction: it delivers to /// the owner, who certainly is. bytes32 internal constant WHITELIST_VENUE = "whitelist"; /// The SBT contract, which is where the real credential lives. The /// allow-list above is a bypass 0xequity can grant; this is what a KYC'd /// user actually holds. bytes32 internal constant SBT_VENUE = "sbt"; /// Where a regulated asset may actually land: the account if the venue would /// accept it, otherwise the owner. /// /// It asks the question the VENUE asks, in the venue's own terms — on the /// allow-list, OR holding at least one of the communities approved for this /// property. Checking only the allow-list, which an earlier version did, gets /// the answer wrong in the direction that matters: an account enrolled /// through AccountKycForwarder holds the SBT and is NOT on the list, so /// deliveries kept going to the owner and the account still could not sell /// what it paid for. The allow-list is the bypass; the SBT is the credential. /// /// Both answers are OURS and neither is configured. Nothing signed reaches /// this and there is no third possibility, so the agent's inability to /// redirect is unchanged whichever branch is taken. /// /// It prefers the ACCOUNT because that is where the position belongs, and /// because only an account that HOLDS property tokens can sell them: the /// router pulls from msg.sender. /// /// With no allow-list and no SBT contract mapped it falls back to the owner, /// which is the safe direction — the owner is certainly credentialed, and /// delivering to an uncredentialed account reverts inside the venue. function holderFor(address account, string memory symbol) public view returns (address) { address list = _venueOrZero(WHITELIST_VENUE); if (list != address(0) && IAllowList(list).isAddressOnAllowList(account)) { return account; } address sbt = _venueOrZero(SBT_VENUE); if (sbt != address(0) && bytes(symbol).length != 0) { string[] memory approved = ISBTCheck(sbt).getApprovedSBTCommunities(symbol); for (uint256 i = 0; i < approved.length; i++) { if (ISBTCheck(sbt).getBalanceOf(account, approved[i]) != 0) { return account; } } } return ownerOf(account); } bytes32 public constant OP_BUY = "buy_property"; bytes32 public constant OP_SELL = "sell_property"; bytes32 internal constant PROPERTY_TYPEHASH = keccak256( "Property(address account,address owner,bytes32 taskId,uint256 deadline,bytes32 operation," "bytes32 property,address currency,uint256 tokenCount,uint256 bound)" ); struct Trade { address account; address owner; bytes32 taskId; uint256 deadline; bytes32 operation; /// WHICH property, by name, from the curated set. Never addresses. bytes32 property; /// What is paid with, or received into. Must have a cap. address currency; /// How many property tokens to buy or sell. uint256 tokenCount; /// The agent's bound on the currency side: the MOST that may be paid for /// a buy, the LEAST that must be received for a sell. /// /// One field for both because it is one idea — the limit the agent /// accepted — and the direction is already in `operation`. The price is /// computed by the venue from its own oracle, so this is the only thing /// standing between a signed intent and whatever that oracle says today. uint256 bound; } /// A property, as the curation lists it. The shape `propertyOf` has always /// returned, because cmd/mcp reads it. struct Property { address vault; address token; address marketplace; /// The venue's own symbol for this property, e.g. "WXRWA1". Needed /// because whether an address may HOLD it is answered per property: the /// SBT communities approved for one building are not those approved for /// another. string symbol; bool known; } // THE PROPERTIES ARE NOT HERE ANY MORE. They were a per-account copy of the // guardian's list, written by an install — so a v2 account, which installs // nothing, could buy none of them. They are one chain-global table in // AdapterCuration, whose `listProperty` also makes the checks `curate` // never made (a curated token with code, a marketplace with code). event Traded( address indexed account, bytes32 indexed operation, bytes32 indexed property, uint256 tokenCount, uint256 currencyMoved ); error ApproveFailed(); error UnknownOperation(bytes32 operation); error NoBound(); error PaidMoreThanBound(uint256 bound, uint256 paid); error ReceivedLessThanBound(uint256 bound, uint256 got); error NoTokensArrived(); /// A buy that delivered fewer property tokens than the message named. error ShortFill(uint256 wanted, uint256 got); error TokensNotSold(); constructor(AgentRegistry registry_) BaseAdapter(registry_) {} function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsPropertyAdapter", "1"); } /// The property `name` resolves to. `account` is kept in the signature /// because cmd/mcp calls it with one; the table is the same for every /// account. A retired property still answers, `known` and all: a sale out /// of it is exactly what retiring leaves open. function propertyOf(address, bytes32 name) public view returns (Property memory p) { (p.vault, p.token, p.marketplace, p.symbol,) = curationOf(address(registry)).propertyOf(name); p.known = true; } function digest(Trade calldata t) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode( PROPERTY_TYPEHASH, t.account, t.owner, t.taskId, t.deadline, t.operation, t.property, t.currency, t.tokenCount, t.bound ) ) ); } function trade(Trade calldata t, bytes calldata signature) external { // A trade without a bound is a trade at whatever price the venue's oracle // reports at execution. Refused rather than defaulted: there is no safe // zero here, in either direction. if (t.bound == 0) revert NoBound(); _consume(t.account, t.owner, t.taskId, t.deadline, digest(t), signature); _requireOperation(t.account, t.operation); // A retired property refuses only a purchase; a sale still works. Property memory p; (p.vault, p.token, p.marketplace, p.symbol) = curationOf(address(registry)).useProperty(t.property, t.operation != OP_SELL); p.known = true; address router = _venueOf(t.account, "oclr"); if (t.operation == OP_BUY) { _buy(t, p, router); } else if (t.operation == OP_SELL) { _sell(t, p, router); } else { revert UnknownOperation(t.operation); } } function _buy(Trade calldata t, Property memory p, address router) internal { _charge(t.account, t.currency, t.bound); // The cap is charged for the MOST this may cost. The venue prices the // trade itself, so charging the actual spend would mean charging a // number the agent did not sign and this contract learns too late. // Whose hands the property lands in — the account if 0xequity has cleared // it, the owner otherwise. Read, never configured. address holder = holderFor(t.account, p.symbol); uint256 currencyBefore = IERC20(t.currency).balanceOf(t.account); uint256 tokensBefore = IERC20(p.token).balanceOf(holder); // Approved for the bound, not for the price. The router pulls what the // quote says and the allowance is cleared afterwards either way. _approve(t.account, t.currency, router, t.bound); _exec( t.account, router, 0, abi.encodeCall( IOCLRouter.buyProperty, (_args(t, p, t.currency, p.token, holder, false), p.marketplace) ) ); _requireNoResidual(t.account, t.currency, router); uint256 paid = currencyBefore - IERC20(t.currency).balanceOf(t.account); if (paid > t.bound) revert PaidMoreThanBound(t.bound, paid); if (paid < t.bound) _refund(t.account, t.currency, t.bound - paid, t.bound); // GIVE BACK WHAT THE VENUE DID NOT TAKE. // // The cap is charged the CEILING above, because the venue prices the // trade from its own oracle and the real figure is not knowable until // after the call. That is the right way round — but only if the // difference comes back. Without this a buy that filled at half the // bound burned the whole bound's worth of epoch budget, so an owner's // daily allowance was consumed by the agent's caution rather than by // anything that left the account. Swap, Aave repay and Morpho repay all // refund the same way. // AND THE FILL HAS TO BE THE FILL THAT WAS ASKED FOR. // // Measured at the HOLDER, because that is where it was sent — checking // the account would report nothing arriving on a purchase that worked. // // But "more than nothing" was the whole test. One wei of the property // token satisfied it, against a full `bound` pull of currency, and the // step reported success: the signed `tokenCount` — the only statement in // the message about SIZE — was never compared with anything. A partial // fill is a different trade from the one the owner authorised, and the // currency side alone cannot tell them apart because the venue is // entitled to charge less for less. uint256 got = IERC20(p.token).balanceOf(holder) - tokensBefore; if (got == 0) revert NoTokensArrived(); if (got < t.tokenCount) revert ShortFill(t.tokenCount, got); emit Traded(t.account, OP_BUY, t.property, t.tokenCount, paid); } function _sell(Trade calldata t, Property memory p, address router) internal { // The buy side is fine — `_charge(t.currency, t.bound)` forces a row // C-2(b) OF THE ROUND-2 AUDIT: `t.currency` IS A RAW SIGNED ADDRESS AND // WAS THE ONE THING NOT CHECKED. // // The buy side does not need this: what bounds a buy is `got` measured // on the PROPERTY token, which is curated, so a dishonest currency buys // nothing. Here the currency is the subject of a `balanceOf` delta and // the `got < t.bound` comparison, so an agent that deploys a token whose // `balanceOf` returns 0 and then 2**200 satisfies // `ReceivedLessThanBound` WITH CERTAINTY — it wrote the function that // answers. The sale's entire protection is disarmed without the venue // having to cooperate at all. // // The curated asset set is the list of what this chain settles in, so // requiring the proceeds to be on it closes this without needing a // price. It was `_requireCapRow` until 2026-09-20; same check, asked of // the registry rather than of a per-account table. _requireCuratedAsset(t.currency); // Charge the property-token outflow the short-fill refund below assumes. // Without this, sells ignore setCap / v2 plan allowances and a short // fill's `_refund` forgives unrelated spent or inflates plan allowance. _charge(t.account, p.token, t.tokenCount); uint256 currencyBefore = IERC20(t.currency).balanceOf(t.account); uint256 tokensBefore = IERC20(p.token).balanceOf(t.account); _approve(t.account, p.token, router, t.tokenCount); // The proceeds are ordinary currency rather than a regulated instrument, // so they come back to the account that paid for the position. _exec( t.account, router, 0, abi.encodeCall( IOCLRouter.sellProperty, (_args(t, p, p.token, t.currency, t.account, true), p.marketplace) ) ); _requireNoResidual(t.account, p.token, router); uint256 got = IERC20(t.currency).balanceOf(t.account) - currencyBefore; if (got < t.bound) revert ReceivedLessThanBound(t.bound, got); // HOW MANY ACTUALLY WENT, not merely whether any did. // // `TokensNotSold` was an existence check: any decrease at all passed it. // A fill of eight tokens against a signed ten therefore succeeded with // `spent` charged for ten, so two tokens' worth of the owner's epoch // budget was consumed by a sale that never happened — the same // unused-cap hole as the buy, in the other asset. // // Refunded rather than refused, because a short fill on a SELL is not // the same kind of event as one on a buy: the tokens that did not go are // still the account's, `bound` already guarantees the currency side, and // failing the step would leave a plan unable to sell into a thin book at // all. What must not happen is being charged for them. uint256 sold = tokensBefore - IERC20(p.token).balanceOf(t.account); if (sold == 0) revert TokensNotSold(); if (sold < t.tokenCount) _refund(t.account, p.token, t.tokenCount - sold, t.tokenCount); emit Traded(t.account, OP_SELL, t.property, sold, got); } /// Where the tokens come FROM, split between the buyback vault and the /// primary market. /// /// The venue reads `vaults` as sources: `address(0)` at a slot means "the /// primary market", and a real address means "that vault's inventory". So /// asking a vault for more than it holds is not a slippage problem, it is an /// arithmetic underflow inside the marketplace — which is what happened the /// second time this adapter bought, after the first purchase had emptied the /// vault. /// /// Take what the vault can actually deliver and cover the rest from the /// primary market, which is what the app does. Computed HERE from the /// vault's balance rather than signed: which source fills an order is not /// something an agent should be choosing, and `address(0)` is a sentinel /// this contract writes rather than an address anybody supplied. function _sources(Property memory p, uint256 tokenCount) internal view returns (address[] memory vaults, uint256[] memory amounts) { uint256 held = p.vault == address(0) ? 0 : IERC20(p.token).balanceOf(p.vault); uint256 fromVault = held < tokenCount ? held : tokenCount; uint256 fromPrimary = tokenCount - fromVault; if (fromVault > 0 && fromPrimary > 0) { vaults = new address[](2); amounts = new uint256[](2); // Index 0 is the primary market's slot, and the venue expects it // first. vaults[0] = address(0); amounts[0] = fromPrimary; vaults[1] = p.vault; amounts[1] = fromVault; return (vaults, amounts); } vaults = new address[](1); amounts = new uint256[](1); vaults[0] = fromVault > 0 ? p.vault : address(0); amounts[0] = tokenCount; } /// Who BUYS on a sale: the curated buyback vault, for the whole count. /// /// A SALE IS NOT A BUY READ BACKWARDS, and this used `_sources` — the buy /// helper above — for both. On a sale the marketplace reads `vaults` as the /// BUYERS: `sellPropertyTokens` requires `vaults[0] != address(0)` and /// borrows the proceeds from each `vaults[i]`. `_sources` sizes the vault's /// slot by the PROPERTY TOKENS it already holds, which is a buy's question, /// so a sale got `[address(0)]` from an empty vault and `[address(0), /// vault]` from a part-stocked one, and the venue refused both "Invalid /// Vault or amount" — whether or not the vault had the USDC to buy. Only a /// vault already holding at least the shares being sold produced a sale /// that worked, which is an accident of inventory and not a rule. /// /// The 0xequity app sells exactly this way: one vault, the buyback vault, /// for the whole amount (0x-app actionBase.utils.ts, "Single buyer: the /// on-chain marketplace splits vaults[] with no fallback"). The vault is /// the curated one, never signed. Whether it can pay is the venue's to /// answer, and `ReceivedLessThanBound` below is ours. function _buyer(Property memory p, uint256 tokenCount) internal pure returns (address[] memory vaults, uint256[] memory amounts) { vaults = new address[](1); amounts = new uint256[](1); vaults[0] = p.vault; amounts[0] = tokenCount; } /// The argument struct, with every address the venue would let a caller /// choose written HERE. `selling` picks who is on the other side: the /// sources of a buy (`_sources`), or the buyer of a sale (`_buyer`). function _args( Trade calldata t, Property memory p, address from, address to, address recipient, bool selling ) internal view returns (IOCLRouter.SwapArgs memory) { (address[] memory vaults, uint256[] memory amounts) = selling ? _buyer(p, t.tokenCount) : _sources(p, t.tokenCount); return IOCLRouter.SwapArgs({ from: from, to: to, // Not a parameter. The router takes any address; this one is // resolved by the caller from the account, never signed. recipient: recipient, // Fees in the property's base currency rather than XEQ: paying a fee // in a token the account may not hold is a failure mode the agent // should not be able to choose into. isFeeInXeq: false, vaults: vaults, amounts: amounts, // Inert on this path, and empty regardless. See the interface above. arbCallData: "" }); } function _approve(address account, address asset, address spender, uint256 amount) internal { bytes memory ok = _exec(account, asset, 0, abi.encodeCall(IERC20.approve, (spender, amount))); if (ok.length != 0 && !abi.decode(ok, (bool))) revert ApproveFailed(); } }