// SPDX-License-Identifier: MIT pragma solidity 0.8.28; interface IEcdsaOwned { function ecdsaValidatorStorage(address account) external view returns (address owner); } interface IErc1271Owned { function ownerOf(address account) external view returns (address owner); } interface IPasskeyOwned { /// x, y, exitCaller, owner. /// /// THE FOURTH IS THE ONE THIS FILE WANTS — H-10 of the round-2 audit. The /// third is a KEY that drives the account; the fourth is a statement about /// who the account is FOR, which is the only thing a credential or a /// transfer may name. They were the same word, and they are not the same /// question. See WebAuthnRootValidator.PublicKey. function passkeyOf(address account) external view returns (bytes32, bytes32, address, address); } /// @title RootOwner — whose account is this, whichever root validator it uses. /// /// THE PROBLEM THIS EXISTS FOR. Four root validators ship in this repo and every /// caller that asked "who owns this account" spoke to exactly one of them: /// /// ECDSAValidator ecdsaValidatorStorage(account) -> address /// GatedECDSAValidator ecdsaValidatorStorage(account) -> address /// ERC1271RootValidator ownerOf(account) -> address /// WebAuthnRootValidator passkeyOf(account) -> (x, y, exitCaller, owner) /// /// `BaseAdapter.ownerOf` and `AccountKycForwarder.ownerOf` both hardcoded the /// first. The consequence was not a drain — it was the opposite, and quieter: a /// Safe-rooted or passkey-rooted account REVERTED in `_consume`, in /// OwnerTransfer, in PullFromOwner and in the `holderFor` fallback, so the /// accounts the other three validators exist to support could not use the /// system at all. The KYC forwarder simply refused to enroll them, which left /// them in the buy-to-owner / sell-from-account split the forwarder was written /// to close. /// /// THE ANSWER IS THE SAME QUESTION IN THREE DIALECTS, so it is asked in one /// place. Every caller reads it the same way and a fifth validator is one arm /// here rather than a fix in six files. /// /// STATICCALL AND DECODE, NOT AN INTERFACE CALL. A validator that does not /// implement a selector returns empty rather than reverting the whole /// transaction, so each shape is tried and the first non-zero answer wins. /// Ordering matters only for a validator that answers two of them, and none /// does. /// /// WEBAUTHN HAS NO OWNER ADDRESS, and `exitCaller` is the nearest thing that /// exists: the one address permitted to drive the account directly, fixed at /// install and inside the account's CREATE2 salt. It is OPTIONAL there — zero /// means "every owner action goes through a bundler" — and a zero answer here /// is reported as "cannot say" rather than papered over, because an adapter /// that guessed would be moving somebody's money on a guess. library RootOwner { /// The account's owner as its root validator records it, or zero when the /// validator is not one of the shapes above (or has no owner recorded). /// /// ZERO IS THE CALLER'S PROBLEM TO REPORT. This library does not revert: /// its callers already have their own errors for an unestablished owner — /// `CannotEstablishOwner` in both of them — and those name the account, /// which is the fact somebody debugging needs. /// ONE HELPER, THREE SHAPES, and the third differs only in which word it /// wants. Written this way for SIZE: `ownerOf` is reached from every /// adapter, so three separate encode-call-decode sequences are three copies /// of the same bytecode in seventeen contracts — and two of them were /// already within a few hundred bytes of EIP-170's ceiling. function ownerFrom(address validator, address account) internal view returns (address) { address owner = _ask(validator, IEcdsaOwned.ecdsaValidatorStorage.selector, account, 0); if (owner != address(0)) return owner; owner = _ask(validator, IErc1271Owned.ownerOf.selector, account, 0); if (owner != address(0)) return owner; // passkeyOf returns (x, y, exitCaller, owner) and the BENEFICIARY is the // FOURTH word — H-10 of the round-2 audit. // // It used to read word 2, the exit caller, which is a key that drives // the account and not a statement about who the account is for. So // `BaseAdapter.ownerOf` answered with it, `OwnerTransferAdapter` paid // "return value to the owner" TO IT, and `AccountKycForwarder` minted // the account ITS credential and took ITS signature as owner consent — // while the passkey holder, the actual beneficial owner, was neither // KYC-checked nor asked. // // FALLS BACK TO ZERO, NEVER TO `exitCaller`. `_ask` returns zero for an // answer shorter than four words, so a validator that predates the // field reports no owner rather than the wrong one — and no owner is a // clean `CannotEstablishOwner` at the adapter, which is a refusal // somebody can act on. return _ask(validator, IPasskeyOwned.passkeyOf.selector, account, 3); } /// Ask `validator` one question about `account` and read word `at` of the /// answer as an address. Zero for anything that reverts, returns too /// little, or answers with the zero address. function _ask(address validator, bytes4 selector, address account, uint256 at) private view returns (address owner) { (bool ok, bytes memory ret) = validator.staticcall(abi.encodeWithSelector(selector, account)); if (!ok || ret.length < (at + 1) * 32) return address(0); assembly ("memory-safe") { owner := and( mload(add(add(ret, 32), mul(at, 32))), 0xffffffffffffffffffffffffffffffffffffffff ) } } }