// SPDX-License-Identifier: MIT pragma solidity 0.8.28; import {EIP712} from "solady/utils/EIP712.sol"; import {SignatureCheckerLib} from "solady/utils/SignatureCheckerLib.sol"; import {RootOwner} from "./RootOwner.sol"; /// 0xequity's soul-bound token: the KYC credential itself. /// /// Both `mint` and `revoke` sit behind KYC_MINTER, which is the role 0xequity /// grants this contract. Note that the two together are the whole surface — a /// holder of that role can issue a credential and take it away, and can do /// nothing else. interface ISBT { function mint(address to, string memory communityName) external; function revoke(address from, string memory communityName) external; function getBalanceOf(address user, string memory community) external view returns (uint256); function DoesCommunityExist(string memory name) external view returns (bool); } interface ISanctionsList { function isSanctioned(address addr) external view returns (bool); } interface IKernelRoot { function rootValidator() external view returns (bytes21); } /// @title AccountKycForwarder — a smart account inherits its owner's credential, /// one community at a time, and nothing else. /// /// The problem. 0xequity gates who may hold a property token or be paid rent on /// a soul-bound token issued per COMMUNITY. A user's own wallet holds it; the /// smart account derived from that wallet does not — so an agent operating the /// account cannot buy property into it or collect rent to it, and everything has /// to be delivered to the owner instead. Which works, and means the account can /// never sell what it paid for: the router pulls from msg.sender. /// /// What this does. Anyone may call `enroll(account, community)`. It reads who /// owns that account from the ACCOUNT — Kernel's root validator, then the /// validator's owner record — checks the owner already holds that community's /// SBT, and mints the account the same one. The account gets exactly what the /// owner has, in the community the owner has it in. /// /// Why it is safe to give this KYC_MINTER, which is the question 0xequity is /// actually being asked: /// /// It cannot mint to an arbitrary address. The only thing it will mint to is a /// contract whose codehash matches a real Kernel ERC1967 proxy AND whose root /// validator is the pinned ECDSAValidator — a plain EOA or a costume contract /// reverts. /// /// It cannot mint a community the owner does not already hold. Per community, /// checked per call. /// /// It cannot be repointed. No admin, no owner, no upgrade path, no setter. /// Every address is immutable and set at construction. /// /// Its grant is withdrawable at any time, and nothing it has done becomes /// permanent: `revoke` below, and 0xequity's own, both still work. /// /// It cannot burn a credential it did not mint, and only the account's own /// side may ask it to burn one it did. `revoke` is scoped to `enrolledHere` /// — the (account, community) pairs this contract issued — and the burn is /// the account's or its owner's act. Every credential 0xequity minted /// directly, to an EOA or to anything else, is out of a stranger's reach /// entirely, including one 0xequity re-minted to an account this contract /// once enrolled (#1 of the 2026-10-08 audit; see `revoke`). contract AccountKycForwarder is EIP712 { /// Kernel's validation type byte for "this is a validator module". bytes1 internal constant VALIDATION_TYPE_VALIDATOR = 0x01; /// What the owner signs, ONCE, to say an account is theirs. /// /// No nonce and no deadline, deliberately: this is a standing statement /// about an ADDRESS, not an action with a moment. The account's address is /// CREATE2 over its own genesis and cannot be re-pointed, so an /// authorisation for it does not go stale and there is nothing for a replay /// to gain — enrolling twice is already a no-op. Making it expire would put /// the owner back on the chain periodically, which is the thing this system /// exists to avoid. /// M-9 OF THE ROUND-2 AUDIT: THE AUTHORISATION WAS ETERNAL AND UNIVERSAL. /// /// The digest carried `(account, owner)` and nothing else — no nonce, no /// deadline — and it appears in PLAINTEXT CALLDATA the first time anybody /// relays it. So the signature was a bearer token for ever: 0xequity revokes /// the account's SBT with their own `revoke`, `enrolledHere` is untouched, /// anybody replays the standing signature and it is RE-MINTED. This /// contract's own `revoke` was reversed the same way, and the header's claim /// that "nothing it has done becomes permanent" was exactly backwards. /// /// `communities[]` made it universal as well: it is a function argument /// outside the signed struct, so one signature authorised every community /// the owner would ever hold, present and future. /// /// Three fields close all of it. `nonce` makes each authorisation single-use /// and lets the owner cancel one it has not used. `deadline` bounds how long /// a leaked signature is worth anything. `communities` binds the list, so a /// signature says which credentials it is for. bytes32 internal constant ENROLL_TYPEHASH = keccak256( "EnrollAccount(address account,address owner,uint256 nonce,uint256 deadline," "bytes32 communities)" ); ISBT public immutable sbt; ISanctionsList public immutable sanctions; /// The four root validators this repo ships, pinned. A costume contract /// that only implements the view calls cannot pass `_rootOwner`, because /// the address itself has to match one of these. address public immutable ecdsaValidator; address public immutable gatedEcdsaValidator; address public immutable erc1271Validator; address public immutable passkeyValidator; /// Runtime codehash of a Kernel ERC1967 proxy from the factory. A costume /// contract that only implements the two view calls cannot match it. bytes32 public immutable kernelProxyCodehash; /// Every (account, community) THIS CONTRACT minted, and nothing else. /// /// `revoke` is permissionless on purpose. What it must not be — and was /// until 2026-09-10 — is a permissionless burn of the entire SBT holder /// set. `qualifies` reaches `ownerOf`, which reverts `CannotEstablishOwner` /// for any address whose codehash is not a Kernel proxy: every EOA, every /// Safe, every PasskeyWallet, i.e. every ordinary user 0xequity KYC'd /// directly. The `catch` read that revert as "does not qualify" and burned /// anyway. Keeping no record of what had been minted is what put the whole /// holder set inside the reach of the one escape hatch meant for orphans. /// /// Cleared on revoke, so a later re-enrolment re-arms it. mapping(address account => mapping(bytes32 community => bool)) public enrolledHere; /// Set when an external SBT burn leaves `enrolledHere` stale (balance already /// zero). Blocks forwarder remint the way the sticky bit did, without /// retaining burn rights over a later foreign remint. mapping(address account => mapping(bytes32 community => bool)) public barredHere; /// Enrolment authorisations the owner has spent or cancelled — M-9. Keyed /// by OWNER, because the signature is the owner's and one owner may enrol /// several accounts. mapping(address owner => mapping(uint256 nonce => bool)) public usedNonce; /// WHAT THE CODEHASH DOES NOT PROVE, written down because it reads as /// though it does. /// /// An ERC-1967 proxy's runtime code is the same few bytes whatever it /// points at — the implementation lives in a storage SLOT, not in the code. /// KernelFactory produces that code through LibClone, and so does anybody /// else who calls LibClone with an implementation of their own. So a clone /// whose implementation is a costume contract has an identical codehash, /// answers `rootValidator()` with any address it likes, and passed every /// check this contract used to make. /// /// It cannot be closed by reading the slot: the EVM gives a contract no way /// to read another contract's storage, so `sload` here would read this /// contract's own. Kernel exposes no `implementation()` getter either, and /// asking a costume for its implementation is asking it to tell the truth. /// /// What closes it is `enroll` requiring the OWNER'S OWN SIGNATURE over the /// account address. A costume proxy is then worthless: an attacker can make /// one claim any owner they like and still cannot produce that owner's /// authorisation for it. The codehash check stays as a cheap filter that /// rejects EOAs and ordinary contracts early, and is no longer load-bearing. event Enrolled(address indexed account, address indexed owner, string community); event Revoked(address indexed account, address indexed owner, string community); event AuthorisationCancelled(address indexed owner, uint256 nonce); error CannotEstablishOwner(address account); error NoSuchCommunity(string community); error OwnerDoesNotHoldIt(address account, address owner, string community); error AccountSanctioned(address account); error OwnerSanctioned(address owner); error NotEnrolled(address account, string community); /// This contract did not mint that credential, so it is not this /// contract's to take away. 0xequity's own `revoke` still is. error NotEnrolledHere(address account, string community); error StillQualifies(address account, string community); error ZeroAddress(); /// Nobody produced the owner's standing authorisation for this account. error NotAuthorisedByOwner(address account, address owner); /// M-9: the three refusals a replayable bearer token needs. error AuthorisationExpired(uint256 deadline); error AuthorisationUsed(address owner, uint256 nonce); error CredentialBarred(address account, string community); /// Only the account, or the owner the account names, may have this /// contract burn the account's credential. See `revoke` — #1 of the /// 2026-10-08 audit. error NotAccountOrOwner(address caller, address account); /// @param sbt_ 0xequity SBT /// @param sanctions_ Chainalysis (or equiv) list /// @param validators_ the four shipped root validators, in the order /// [ECDSA, GatedECDSA, ERC1271, WebAuthn]. Zero is allowed for one /// that is not deployed on this chain and simply never matches. /// @param kernelProxyCodehash_ runtime codehash of a Kernel ERC1967 proxy constructor( ISBT sbt_, ISanctionsList sanctions_, address[4] memory validators_, bytes32 kernelProxyCodehash_ ) { if ( address(sbt_) == address(0) || address(sanctions_) == address(0) || validators_[0] == address(0) ) { revert ZeroAddress(); } if (kernelProxyCodehash_ == bytes32(0)) revert CannotEstablishOwner(address(0)); sbt = sbt_; sanctions = sanctions_; ecdsaValidator = validators_[0]; gatedEcdsaValidator = validators_[1]; erc1271Validator = validators_[2]; passkeyValidator = validators_[3]; kernelProxyCodehash = kernelProxyCodehash_; } function _domainNameAndVersion() internal pure override returns (string memory, string memory) { return ("DeFiLoopsAccountKycForwarder", "1"); } /// The digest an owner signs to say "that account is mine". /// /// Public so the thing a wallet is asked to sign can be checked against the /// chain rather than trusted from whatever produced it. function enrollDigest( address account, address owner, uint256 nonce, uint256 deadline, string[] memory communities ) public view returns (bytes32) { return _hashTypedData( keccak256( abi.encode(ENROLL_TYPEHASH, account, owner, nonce, deadline, _hash(communities)) ) ); } /// Who owns this account, according to the account. /// /// Read rather than supplied, which is what makes the rest safe: a caller /// cannot claim an owner. /// /// THREE THINGS ARE CHECKED, and the middle one was missing. /// /// WHAT IT PROVES AND WHAT IT DOES NOT. The codehash and the validator /// address together say "this looks like one of our accounts", and that is /// all they say — see the note on `kernelProxyCodehash` for why a costume /// ERC-1967 clone passes both. `enroll` therefore does not rely on this /// alone; it asks the owner. /// /// EVERY SHIPPED ROOT, not only the ECDSA one. Pinning a single validator /// address refused GatedECDSA, ERC1271 and WebAuthn accounts outright — so /// exactly the accounts the other three validators exist to support could /// not enroll, and stayed in the buy-to-owner / sell-from-account split this /// forwarder was written to close. function ownerOf(address account) public view returns (address) { if (account.codehash != kernelProxyCodehash) revert CannotEstablishOwner(account); bytes21 root = IKernelRoot(account).rootValidator(); if (bytes1(root) != VALIDATION_TYPE_VALIDATOR) revert CannotEstablishOwner(account); address validator = address(uint160(uint168(root))); if (!_isShippedValidator(validator)) revert CannotEstablishOwner(account); address owner = RootOwner.ownerFrom(validator, account); if (owner == address(0)) revert CannotEstablishOwner(account); return owner; } function _isShippedValidator(address validator) internal view returns (bool) { return validator == ecdsaValidator || validator == gatedEcdsaValidator || validator == erc1271Validator || validator == passkeyValidator; } /// Does this account currently deserve the credential it would be given? function qualifies(address account, string calldata community) public view returns (bool) { address owner = ownerOf(account); if (sbt.getBalanceOf(owner, community) == 0) return false; if (sanctions.isSanctioned(account)) return false; if (sanctions.isSanctioned(owner)) return false; return true; } /// Give an account every credential its owner already holds, from a list. /// /// THE OWNER SAYS WHICH ACCOUNT IS THEIRS, AND THAT IS THE WHOLE FIX. /// /// `ownerOf` reads the owner FROM the account, and that made a self-report /// look like a fact. It is not one: KernelFactory is permissionless, so /// anybody could deploy a real Kernel whose ECDSAValidator names a victim /// as owner — with an executor of their own installed in the same genesis — /// and then call this. Every check passed, because every check was about /// what the account SAID, and a permissionless mint followed. The victim's /// community credential landed on an account the attacker operates: a /// KYC-gated trading account, obtained without KYC, from somebody else's. /// /// No on-chain check can separate "the owner's account" from "an account /// naming the owner" — the two are identical in every readable respect — /// so the owner has to be asked. `signature` is that answer: an EIP-712 /// statement over (account, owner), verified against the owner with /// ERC-1271 support so a contract wallet or a Safe can give it too. /// /// IT IS STILL PERMISSIONLESS TO SUBMIT, which is the property that /// mattered. The owner signs ONCE, off chain, for one address that can /// never be re-pointed; anybody may then relay it, the relayer pays, and /// the owner never appears on any chain. That is the same shape as the /// account's own genesis, and it is the most this contract can concede /// without conceding the credential. function enroll( address account, string[] calldata communities, uint256 nonce, uint256 deadline, bytes calldata signature ) external { address owner = ownerOf(account); // M-9. Expiry, single use, and the owner's own cancellation, in that // order — cheapest refusal first. if (block.timestamp > deadline) revert AuthorisationExpired(deadline); if (usedNonce[owner][nonce]) revert AuthorisationUsed(owner, nonce); if (!SignatureCheckerLib.isValidSignatureNowCalldata( owner, enrollDigest(account, owner, nonce, deadline, communities), signature )) { revert NotAuthorisedByOwner(account, owner); } usedNonce[owner][nonce] = true; if (sanctions.isSanctioned(account)) revert AccountSanctioned(account); if (sanctions.isSanctioned(owner)) revert OwnerSanctioned(owner); for (uint256 i = 0; i < communities.length; i++) { string calldata community = communities[i]; if (!sbt.DoesCommunityExist(community)) revert NoSuchCommunity(community); if (sbt.getBalanceOf(account, community) != 0) continue; if (sbt.getBalanceOf(owner, community) == 0) { revert OwnerDoesNotHoldIt(account, owner, community); } bytes32 key = keccak256(bytes(community)); // 0xEQUITY'S OWN REVOCATION IS NOT OURS TO UNDO — M-9 of the round-2 // audit. // // Reaching this line means the ACCOUNT does not hold the credential // (the `continue` above). If `enrolledHere` still says WE minted it, // then somebody with the SBT's own authority took it away — which // can only be 0xequity, through their `revoke`, because this // contract's `revoke` clears the record on its way out. Re-minting // there would overturn their KYC decision with an enrolment they // never saw. // // `barredHere` is for 0xequity's external revoke (sticky, permanent // for this forwarder). Our own revoke clears `enrolledHere` only, so // `test_RevokingClearsTheRecordAndReEnrollingRestoresIt` still works // when the owner qualifies again. Stale `enrolledHere` after an // external burn is converted to `barredHere` in a SUCCESSFUL call // (a revert would roll the conversion back) so we never retain burn // rights over a later foreign remint. Checks run BEFORE mint. if (barredHere[account][key]) revert CredentialBarred(account, community); if (enrolledHere[account][key]) { delete enrolledHere[account][key]; barredHere[account][key] = true; continue; // tombstoned; do not remint } sbt.mint(account, community); enrolledHere[account][key] = true; emit Enrolled(account, owner, community); } } /// Take back what this contract gave, once it is no longer earned. /// /// WHO MAY ASK, AND WHY IT IS NO LONGER ANYONE — #1 of the 2026-10-08 /// audit, "revoke burns a foreign remint after a stale enrolledHere bit". /// /// `enrolledHere` says this contract minted the account's credential ONCE. /// It cannot say the credential the account holds NOW is that one. When /// 0xequity burns it directly (their `revoke`, which never passes through /// here) the bit goes stale, and if they later mint the account a /// credential of their own — a KYC decision about that address, not about /// its owner — the SBT looks exactly as it did: balance one, in that /// community. The SBT is an ERC-1155 that keeps no mint id, and minting /// twice is refused, so no balance, count or id can tell the two apart. /// The fix the audit proposed, comparing a recorded balance, compares one /// with one and passes. So any stranger, once the owner stopped /// qualifying, burned 0xequity's own grant. /// /// Since the forwarder cannot know whose credential it is looking at, it /// burns only at the request of a party entitled to give that credential /// up: the ACCOUNT, or the OWNER the account names. 0xequity needs nothing /// from here — their SBT `revoke` reaches every holder, and the `Enrolled` /// events list every account this contract credentialled — so the /// compliance path is theirs, as the credential is. /// /// THE TOMBSTONE STAYS PERMISSIONLESS. When the account's balance is /// already zero there is nothing to burn, only a stale bit to retire, and /// anybody retiring it closes the very window a foreign remint needs. It /// writes this contract's own records and moves no credential. /// /// TWO GUARDS, AND BOTH WERE MISSING. /// /// It can only burn what it minted. `enrolledHere` is the record; without /// it a stranger could walk the SBT's mint events and burn every holder on /// the chain, EOAs included, for about 50k gas each — and then, with the /// owners' own credentials gone, the smart accounts fell too, honestly, /// down the qualifying branch. `enroll` cannot repair that: it reverts /// `OwnerDoesNotHoldIt` once the owner no longer holds it. /// /// "Cannot establish the owner" is not "does not qualify". Conflating them /// is what turned an escape hatch into the attack, and it also grieved /// accounts this contract genuinely enrolled: an owner rotating to a root /// validator outside the four pinned addresses, a Kernel proxy codehash /// change, or a sanctions oracle that reverts or pauses all make /// `qualifies` revert on an account that is perfectly entitled to its /// credential. So a revert propagates and nothing is burned. /// /// The orphan case that the old `catch` existed for is 0xequity's, not /// this contract's: KYC_MINTER is a grant, the SBT is theirs, and their own /// `revoke` is unaffected by anything here. function revoke(address account, string calldata community) external { bytes32 key = keccak256(bytes(community)); if (!enrolledHere[account][key]) revert NotEnrolledHere(account, community); // External burn left our bit set but no balance: tombstone to barred // without calling sbt.revoke (nothing to burn, and a later remint must // not inherit our burn authority). if (sbt.getBalanceOf(account, community) == 0) { delete enrolledHere[account][key]; barredHere[account][key] = true; emit Revoked(account, address(0), community); return; } // The account itself, or the owner it names — asked first. `ownerOf` // reverts for an account whose owner cannot be // established, and that propagates exactly as `qualifies` below does. if (msg.sender != account && msg.sender != ownerOf(account)) { revert NotAccountOrOwner(msg.sender, account); } if (qualifies(account, community)) revert StillQualifies(account, community); address owner = ownerOf(account); delete enrolledHere[account][key]; sbt.revoke(account, community); emit Revoked(account, owner, community); } /// THE OWNER WITHDRAWS AN AUTHORISATION IT HAS NOT SPENT — M-9. /// /// The signature is a bearer token the moment it is relayed, and before that /// it is a bearer token wherever it is stored. Burning the nonce is how an /// owner takes one back — and it is the OWNER calling, not the account, for /// the same reason the signature is the owner's. /// /// Idempotent: cancelling a nonce already spent is a no-op, which is the /// right answer to "make sure this cannot be used". function cancelAuthorisation(uint256 nonce) external { usedNonce[msg.sender][nonce] = true; emit AuthorisationCancelled(msg.sender, nonce); } /// The signed form of the community list: the hash of each name's hash, in /// order. Names are dynamic, so EIP-712 hashes each and then the array. function _hash(string[] memory communities) internal pure returns (bytes32) { bytes32[] memory each = new bytes32[](communities.length); for (uint256 i = 0; i < communities.length; i++) { each[i] = keccak256(bytes(communities[i])); } return keccak256(abi.encodePacked(each)); } }