diff --git a/.cspell.json b/.cspell.json index 1a4a2a62..39fdcb20 100644 --- a/.cspell.json +++ b/.cspell.json @@ -64,6 +64,7 @@ "douglasacost", "IBEACON", "AABBCCDD", + "aabb", "SSTORE", "Permissionless", "Reentrancy", diff --git a/src/paymasters/BasePaymaster.sol b/src/paymasters/BasePaymaster.sol index 2fcf5025..72af886b 100644 --- a/src/paymasters/BasePaymaster.sol +++ b/src/paymasters/BasePaymaster.sol @@ -11,6 +11,9 @@ import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interface import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; import {AccessControl} from "@openzeppelin/contracts/access/AccessControl.sol"; +/// @dev Duplicated from era-contracts/system-contracts/contracts/Constants.sol +/// because the canonical file uses a template variable ({{SYSTEM_CONTRACTS_OFFSET}}) +/// that cannot be imported directly. Value: 0x8000 (2^15). uint160 constant SYSTEM_CONTRACTS_OFFSET = 0x8000; address payable constant BOOTLOADER_FORMAL_ADDRESS = payable(address(SYSTEM_CONTRACTS_OFFSET + 0x01)); @@ -93,10 +96,11 @@ abstract contract BasePaymaster is IPaymaster, AccessControl { function withdraw(address to, uint256 amount) external { _checkRole(WITHDRAWER_ROLE); + // CEI: emit before external call + emit Withdrawn(to, amount); + (bool success,) = payable(to).call{value: amount}(""); if (!success) revert FailedToWithdraw(); - - emit Withdrawn(to, amount); } receive() external payable {} @@ -109,6 +113,9 @@ abstract contract BasePaymaster is IPaymaster, AccessControl { function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) internal virtual; + /// @dev Subclasses implementing this flow MUST verify that the token allowance + /// from the user to this paymaster is at least `tokenAmount` before calling + /// `transferFrom`. Do not trust the sender-provided `tokenAmount` blindly. function _validateAndPayApprovalBasedFlow( address from, address to, diff --git a/src/paymasters/FleetTreasuryPaymaster.sol b/src/paymasters/FleetTreasuryPaymaster.sol new file mode 100644 index 00000000..9ab8a032 --- /dev/null +++ b/src/paymasters/FleetTreasuryPaymaster.sol @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.26; + +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {BasePaymaster} from "./BasePaymaster.sol"; +import {QuotaControl} from "../QuotaControl.sol"; + +/// @notice Combined paymaster + bond treasury for FleetIdentity operations. +/// @dev Holds ETH (to sponsor gas) and NODL (to sponsor bonds). Whitelisted +/// users call FleetIdentityUpgradeable.claimUuidSponsored(), which calls +/// this contract's `consumeSponsoredBond` to validate + consume quota, +/// then pulls the NODL bond via `transferFrom`. +/// The ZkSync paymaster validation ensures only whitelisted users calling +/// FleetIdentity get gas-sponsored. +contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { + using SafeERC20 for IERC20; + + bytes32 public constant WHITELIST_ADMIN_ROLE = keccak256("WHITELIST_ADMIN_ROLE"); + + address public immutable fleetIdentity; + IERC20 public immutable bondToken; + + mapping(address => bool) public isWhitelistedUser; + + event WhitelistedUsersAdded(address[] users); + event WhitelistedUsersRemoved(address[] users); + event TokensWithdrawn(address indexed token, address indexed to, uint256 amount); + + error UserIsNotWhitelisted(); + error DestinationNotAllowed(); + error PaymasterBalanceTooLow(); + error NotFleetIdentity(); + error InsufficientBondBalance(); + + constructor( + address admin, + address withdrawer, + address fleetIdentity_, + address bondToken_, + uint256 initialQuota, + uint256 initialPeriod + ) BasePaymaster(admin, withdrawer) QuotaControl(initialQuota, initialPeriod, admin) { + _grantRole(WHITELIST_ADMIN_ROLE, admin); + fleetIdentity = fleetIdentity_; + bondToken = IERC20(bondToken_); + } + + // ────────────────────────────────────────────── + // Bond Treasury (called by FleetIdentity) + // ────────────────────────────────────────────── + + /// @notice Validate whitelist + consume quota for a sponsored bond. + /// @dev Only callable by the FleetIdentity contract during claimUuidSponsored. + /// The actual NODL transfer is done separately by FleetIdentity via transferFrom. + function consumeSponsoredBond(address user, uint256 amount) external { + if (msg.sender != fleetIdentity) revert NotFleetIdentity(); + if (!isWhitelistedUser[user]) revert UserIsNotWhitelisted(); + if (bondToken.balanceOf(address(this)) < amount) revert InsufficientBondBalance(); + + _checkedResetClaimed(); + _checkedUpdateClaimed(amount); + + // Approve only the exact amount needed for this claim + bondToken.forceApprove(fleetIdentity, amount); + } + + // ────────────────────────────────────────────── + // Whitelist Management + // ────────────────────────────────────────────── + + function addWhitelistedUsers(address[] calldata users) external { + _checkRole(WHITELIST_ADMIN_ROLE); + for (uint256 i = 0; i < users.length; i++) { + isWhitelistedUser[users[i]] = true; + } + emit WhitelistedUsersAdded(users); + } + + function removeWhitelistedUsers(address[] calldata users) external { + _checkRole(WHITELIST_ADMIN_ROLE); + for (uint256 i = 0; i < users.length; i++) { + isWhitelistedUser[users[i]] = false; + } + emit WhitelistedUsersRemoved(users); + } + + // ────────────────────────────────────────────── + // ERC-20 Withdrawal + // ────────────────────────────────────────────── + + /// @notice Withdraw ERC-20 tokens (e.g. excess NODL) from this contract. + function withdrawTokens(address token, address to, uint256 amount) external { + _checkRole(WITHDRAWER_ROLE); + IERC20(token).safeTransfer(to, amount); + emit TokensWithdrawn(token, to, amount); + } + + // ────────────────────────────────────────────── + // Paymaster Validation + // ────────────────────────────────────────────── + + function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) internal view override { + if (to != fleetIdentity) revert DestinationNotAllowed(); + if (!isWhitelistedUser[from]) revert UserIsNotWhitelisted(); + if (address(this).balance < requiredETH) revert PaymasterBalanceTooLow(); + } + + function _validateAndPayApprovalBasedFlow(address, address, address, uint256, bytes memory, uint256) + internal + pure + override + { + revert PaymasterFlowNotSupported(); + } +} diff --git a/src/paymasters/WhitelistPaymaster.sol b/src/paymasters/WhitelistPaymaster.sol index 54507958..a2405954 100644 --- a/src/paymasters/WhitelistPaymaster.sol +++ b/src/paymasters/WhitelistPaymaster.sol @@ -18,6 +18,7 @@ contract WhitelistPaymaster is BasePaymaster { error UserIsNotWhitelisted(); error DestIsNotWhitelisted(); + error PaymasterBalanceTooLow(); constructor(address admin, address withdrawer) BasePaymaster(admin, withdrawer) { _grantRole(WHITELIST_ADMIN_ROLE, admin); @@ -63,7 +64,7 @@ contract WhitelistPaymaster is BasePaymaster { emit WhitelistedUsersRemoved(users); } - function _validateAndPayGeneralFlow(address from, address to, uint256 /* requiredETH */ ) internal view override { + function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) internal view override { if (!isWhitelistedContract[to]) { revert DestIsNotWhitelisted(); } @@ -71,6 +72,10 @@ contract WhitelistPaymaster is BasePaymaster { if (!isWhitelistedUser[from]) { revert UserIsNotWhitelisted(); } + + if (address(this).balance < requiredETH) { + revert PaymasterBalanceTooLow(); + } } function _validateAndPayApprovalBasedFlow(address, address, address, uint256, bytes memory, uint256) diff --git a/src/swarms/FleetIdentityUpgradeable.sol b/src/swarms/FleetIdentityUpgradeable.sol index 32bd2af6..59293f49 100644 --- a/src/swarms/FleetIdentityUpgradeable.sol +++ b/src/swarms/FleetIdentityUpgradeable.sol @@ -12,6 +12,7 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol"; import {RegistrationLevel} from "./interfaces/SwarmTypes.sol"; +import {IBondTreasury} from "./interfaces/IBondTreasury.sol"; /** * @title FleetIdentityUpgradeable @@ -204,7 +205,7 @@ contract FleetIdentityUpgradeable is /// @dev Reserved storage slots for future upgrades. /// When adding new storage in V2+, reduce this gap accordingly. // solhint-disable-next-line var-name-mixedcase - uint256[40] private __gap; + uint256[50] private __gap; // ────────────────────────────────────────────── // Events @@ -490,6 +491,48 @@ contract FleetIdentityUpgradeable is emit UuidClaimed(msg.sender, uuid, operatorOf(uuid)); } + /// @notice Claim a UUID with the bond paid by a caller-specified treasury. + /// @dev The treasury's `consumeSponsoredBond` validates msg.sender (whitelist, quota, etc.), + /// then FleetIdentity pulls the bond from the treasury via `transferFrom` on the + /// **trusted bond token**. Security does not depend on the treasury implementation: + /// - Bond payment is enforced by the immutable `_bondToken` contract, not the treasury. + /// - Reentrancy is blocked by `nonReentrant`. + /// - Beneficiary is always `msg.sender` — no third-party can set a different owner. + /// Different treasuries with different policies (whitelist, quota, geographic) can + /// coexist; FleetIdentity only cares that the bond is paid. + /// @param uuid The UUID to claim. + /// @param operator The operator for tier management (address(0) or msg.sender = self-operate). + /// @param treasury The bond treasury that will fund this claim. Must implement IBondTreasury + /// and have approved this contract to spend its bond tokens. + /// @return tokenId The newly minted token ID. + function claimUuidSponsored(bytes16 uuid, address operator, address treasury) + external + nonReentrant + returns (uint256 tokenId) + { + if (uuid == bytes16(0)) revert InvalidUUID(); + if (uuidOwner[uuid] != address(0)) revert UuidAlreadyOwned(); + + // Treasury validates msg.sender's eligibility and consumes quota. + // If treasury is an EOA or no-op contract, this succeeds but the + // bond is still enforced by _pullBond below. + IBondTreasury(treasury).consumeSponsoredBond(msg.sender, _baseBond); + + uuidOwner[uuid] = msg.sender; + uuidLevel[uuid] = RegistrationLevel.Owned; + uuidTokenCount[uuid] = 1; + uuidOperator[uuid] = (operator == address(0) || operator == msg.sender) ? address(0) : operator; + + tokenId = uint256(uint128(uuid)); + uuidOwnershipBondPaid[uuid] = _baseBond; + _mint(msg.sender, tokenId); + + // Bond transfer uses the trusted _bondToken — cannot be faked by the treasury. + _pullBond(treasury, _baseBond); + + emit UuidClaimed(msg.sender, uuid, operatorOf(uuid)); + } + // ══════════════════════════════════════════════ // Views: Bond & tier helpers // ══════════════════════════════════════════════ diff --git a/src/swarms/SwarmRegistryL1Upgradeable.sol b/src/swarms/SwarmRegistryL1Upgradeable.sol index eb5c92b3..984e1a48 100644 --- a/src/swarms/SwarmRegistryL1Upgradeable.sol +++ b/src/swarms/SwarmRegistryL1Upgradeable.sol @@ -12,7 +12,7 @@ import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol"; import {IFleetIdentity} from "./interfaces/IFleetIdentity.sol"; import {IServiceProvider} from "./interfaces/IServiceProvider.sol"; -import {SwarmStatus, TagType, FingerprintSize} from "./interfaces/SwarmTypes.sol"; +import {SwarmStatus, TagType, FingerprintSize, FLEET_WIDE_SENTINEL} from "./interfaces/SwarmTypes.sol"; /** * @title SwarmRegistryL1Upgradeable @@ -48,6 +48,9 @@ contract SwarmRegistryL1Upgradeable is Initializable, Ownable2StepUpgradeable, U error SwarmAlreadyExists(); error SwarmNotOrphaned(); error SwarmOrphaned(); + error FleetWideSwarmExists(); + error FleetHasSwarms(); + error InvalidFleetWideSentinel(); // ────────────────────────────────────────────── // Structs (L1-specific: uses SSTORE2 pointer) @@ -87,13 +90,16 @@ contract SwarmRegistryL1Upgradeable is Initializable, Ownable2StepUpgradeable, U /// @notice SwarmID -> index in uuidSwarms[fleetUuid] (for O(1) removal) mapping(uint256 => uint256) public swarmIndexInUuid; + /// @notice UUID -> true if a fleet-wide (UUID_ONLY) swarm is registered. + mapping(bytes16 => bool) public hasFleetWideSwarm; + // ────────────────────────────────────────────── // Storage Gap (for future upgrades) // ────────────────────────────────────────────── /// @dev Reserved storage slots for future upgrades. // solhint-disable-next-line var-name-mixedcase - uint256[45] private __gap; + uint256[50] private __gap; // ────────────────────────────────────────────── // Events @@ -181,8 +187,26 @@ contract SwarmRegistryL1Upgradeable is Initializable, Ownable2StepUpgradeable, U if (fleetUuid == bytes16(0)) { revert InvalidUuid(); } - if (filterData_.length == 0 || filterData_.length > 24576) { - revert InvalidFilterSize(); + + bool isFleetWide = tagType == TagType.UUID_ONLY; + + if (isFleetWide) { + // UUID_ONLY swarms must use the well-known 1-byte sentinel filter + if (filterData_.length != 1 || filterData_[0] != FLEET_WIDE_SENTINEL[0]) { + revert InvalidFleetWideSentinel(); + } + // A fleet-wide swarm is mutually exclusive with other swarms + if (uuidSwarms[fleetUuid].length > 0) { + revert FleetHasSwarms(); + } + } else { + if (filterData_.length == 0 || filterData_.length > 24576) { + revert InvalidFilterSize(); + } + // Cannot add a regular swarm if a fleet-wide swarm already exists + if (hasFleetWideSwarm[fleetUuid]) { + revert FleetWideSwarmExists(); + } } if (_fleetContract.uuidOwner(fleetUuid) != msg.sender) { @@ -211,6 +235,10 @@ contract SwarmRegistryL1Upgradeable is Initializable, Ownable2StepUpgradeable, U s.filterPointer = SSTORE2.write(filterData_); + if (isFleetWide) { + hasFleetWideSwarm[fleetUuid] = true; + } + emit SwarmRegistered(swarmId, fleetUuid, providerId, msg.sender); } @@ -277,11 +305,16 @@ contract SwarmRegistryL1Upgradeable is Initializable, Ownable2StepUpgradeable, U } bytes16 fleetUuid = s.fleetUuid; + bool wasFleetWide = s.tagType == TagType.UUID_ONLY; _removeFromUuidSwarms(fleetUuid, swarmId); delete swarms[swarmId]; + if (wasFleetWide) { + hasFleetWideSwarm[fleetUuid] = false; + } + emit SwarmDeleted(swarmId, fleetUuid, msg.sender); } @@ -308,11 +341,16 @@ contract SwarmRegistryL1Upgradeable is Initializable, Ownable2StepUpgradeable, U if (fleetValid && providerValid) revert SwarmNotOrphaned(); bytes16 fleetUuid = s.fleetUuid; + bool wasFleetWide = s.tagType == TagType.UUID_ONLY; _removeFromUuidSwarms(fleetUuid, swarmId); delete swarms[swarmId]; + if (wasFleetWide) { + hasFleetWideSwarm[fleetUuid] = false; + } + emit SwarmPurged(swarmId, fleetUuid, msg.sender); } @@ -326,6 +364,7 @@ contract SwarmRegistryL1Upgradeable is Initializable, Ownable2StepUpgradeable, U } /// @notice Tests tag membership against the swarm's XOR filter. + /// @dev UUID_ONLY swarms short-circuit to true (all tags under the UUID are members). function checkMembership(uint256 swarmId, bytes32 tagHash) external view returns (bool isValid) { Swarm storage s = swarms[swarmId]; if (s.filterPointer == address(0)) { @@ -335,6 +374,11 @@ contract SwarmRegistryL1Upgradeable is Initializable, Ownable2StepUpgradeable, U (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); if (!fleetValid || !providerValid) revert SwarmOrphaned(); + // Fleet-wide swarms accept all tags under this UUID + if (s.tagType == TagType.UUID_ONLY) { + return true; + } + uint256 dataLen; address pointer = s.filterPointer; assembly { diff --git a/src/swarms/SwarmRegistryUniversalUpgradeable.sol b/src/swarms/SwarmRegistryUniversalUpgradeable.sol index 381a9330..803a2fc6 100644 --- a/src/swarms/SwarmRegistryUniversalUpgradeable.sol +++ b/src/swarms/SwarmRegistryUniversalUpgradeable.sol @@ -9,7 +9,7 @@ import {ReentrancyGuard} from "solady/utils/ReentrancyGuard.sol"; import {IFleetIdentity} from "./interfaces/IFleetIdentity.sol"; import {IServiceProvider} from "./interfaces/IServiceProvider.sol"; -import {SwarmStatus, TagType, FingerprintSize} from "./interfaces/SwarmTypes.sol"; +import {SwarmStatus, TagType, FingerprintSize, FLEET_WIDE_SENTINEL} from "./interfaces/SwarmTypes.sol"; /** * @title SwarmRegistryUniversalUpgradeable @@ -51,6 +51,9 @@ contract SwarmRegistryUniversalUpgradeable is error SwarmAlreadyExists(); error SwarmNotOrphaned(); error SwarmOrphaned(); + error FleetWideSwarmExists(); + error FleetHasSwarms(); + error InvalidFleetWideSentinel(); // ────────────────────────────────────────────── // Structs (Universal-specific: uses native bytes storage) @@ -96,13 +99,16 @@ contract SwarmRegistryUniversalUpgradeable is /// @notice SwarmID -> index in uuidSwarms[fleetUuid] (for O(1) removal) mapping(uint256 => uint256) public swarmIndexInUuid; + /// @notice UUID -> true if a fleet-wide (UUID_ONLY) swarm is registered. + mapping(bytes16 => bool) public hasFleetWideSwarm; + // ────────────────────────────────────────────── // Storage Gap (for future upgrades) // ────────────────────────────────────────────── /// @dev Reserved storage slots for future upgrades. // solhint-disable-next-line var-name-mixedcase - uint256[44] private __gap; + uint256[50] private __gap; // ────────────────────────────────────────────── // Events @@ -193,11 +199,29 @@ contract SwarmRegistryUniversalUpgradeable is if (fleetUuid == bytes16(0)) { revert InvalidUuid(); } - if (filter.length == 0) { - revert InvalidFilterSize(); - } - if (filter.length > MAX_FILTER_SIZE) { - revert FilterTooLarge(); + + bool isFleetWide = tagType == TagType.UUID_ONLY; + + if (isFleetWide) { + // UUID_ONLY swarms must use the well-known 1-byte sentinel filter + if (filter.length != 1 || filter[0] != FLEET_WIDE_SENTINEL[0]) { + revert InvalidFleetWideSentinel(); + } + // A fleet-wide swarm is mutually exclusive with other swarms + if (uuidSwarms[fleetUuid].length > 0) { + revert FleetHasSwarms(); + } + } else { + if (filter.length == 0) { + revert InvalidFilterSize(); + } + if (filter.length > MAX_FILTER_SIZE) { + revert FilterTooLarge(); + } + // Cannot add a regular swarm if a fleet-wide swarm already exists + if (hasFleetWideSwarm[fleetUuid]) { + revert FleetWideSwarmExists(); + } } if (_fleetContract.uuidOwner(fleetUuid) != msg.sender) { @@ -227,6 +251,10 @@ contract SwarmRegistryUniversalUpgradeable is uuidSwarms[fleetUuid].push(swarmId); swarmIndexInUuid[swarmId] = uuidSwarms[fleetUuid].length - 1; + if (isFleetWide) { + hasFleetWideSwarm[fleetUuid] = true; + } + emit SwarmRegistered(swarmId, fleetUuid, providerId, msg.sender, uint32(filter.length)); } @@ -293,12 +321,17 @@ contract SwarmRegistryUniversalUpgradeable is } bytes16 fleetUuid = s.fleetUuid; + bool wasFleetWide = s.tagType == TagType.UUID_ONLY; _removeFromUuidSwarms(fleetUuid, swarmId); delete swarms[swarmId]; delete filterData[swarmId]; + if (wasFleetWide) { + hasFleetWideSwarm[fleetUuid] = false; + } + emit SwarmDeleted(swarmId, fleetUuid, msg.sender); } @@ -325,16 +358,22 @@ contract SwarmRegistryUniversalUpgradeable is if (fleetValid && providerValid) revert SwarmNotOrphaned(); bytes16 fleetUuid = s.fleetUuid; + bool wasFleetWide = s.tagType == TagType.UUID_ONLY; _removeFromUuidSwarms(fleetUuid, swarmId); delete swarms[swarmId]; delete filterData[swarmId]; + if (wasFleetWide) { + hasFleetWideSwarm[fleetUuid] = false; + } + emit SwarmPurged(swarmId, fleetUuid, msg.sender); } /// @notice Tests tag membership against the swarm's XOR filter. + /// @dev UUID_ONLY swarms short-circuit to true (all tags under the UUID are members). function checkMembership(uint256 swarmId, bytes32 tagHash) external view returns (bool isValid) { Swarm storage s = swarms[swarmId]; if (s.filterLength == 0) { @@ -344,6 +383,11 @@ contract SwarmRegistryUniversalUpgradeable is (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); if (!fleetValid || !providerValid) revert SwarmOrphaned(); + // Fleet-wide swarms accept all tags under this UUID + if (s.tagType == TagType.UUID_ONLY) { + return true; + } + bytes storage filter = filterData[swarmId]; uint256 dataLen = s.filterLength; diff --git a/src/swarms/interfaces/IBondTreasury.sol b/src/swarms/interfaces/IBondTreasury.sol new file mode 100644 index 00000000..3301816f --- /dev/null +++ b/src/swarms/interfaces/IBondTreasury.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.24; + +/// @notice Interface for bond treasuries that sponsor UUID claims. +/// @dev Implemented by contracts that hold NODL and gate sponsored claims +/// via whitelist and quota checks. Called by FleetIdentityUpgradeable +/// during `claimUuidSponsored` before the bond is pulled via `transferFrom`. +interface IBondTreasury { + /// @notice Validate that `user` is eligible for a sponsored bond and consume quota. + /// @dev Must revert if the user is not whitelisted or quota is exhausted. + /// The actual NODL transfer happens separately via `transferFrom` by the caller. + /// @param user The address requesting a sponsored claim (the beneficiary). + /// @param amount The bond amount being consumed. + function consumeSponsoredBond(address user, uint256 amount) external; +} diff --git a/src/swarms/interfaces/ISwarmRegistry.sol b/src/swarms/interfaces/ISwarmRegistry.sol index ba1b39cc..56fad724 100644 --- a/src/swarms/interfaces/ISwarmRegistry.sol +++ b/src/swarms/interfaces/ISwarmRegistry.sol @@ -149,4 +149,9 @@ interface ISwarmRegistry { /// @param swarmId The swarm ID. /// @return The index in the UUID's swarm array. function swarmIndexInUuid(uint256 swarmId) external view returns (uint256); + + /// @notice Returns true if a fleet-wide (UUID_ONLY) swarm is registered for this UUID. + /// @param fleetUuid The fleet UUID. + /// @return True if a fleet-wide swarm exists. + function hasFleetWideSwarm(bytes16 fleetUuid) external view returns (bool); } diff --git a/src/swarms/interfaces/SwarmTypes.sol b/src/swarms/interfaces/SwarmTypes.sol index 3ec535cd..e63005d6 100644 --- a/src/swarms/interfaces/SwarmTypes.sol +++ b/src/swarms/interfaces/SwarmTypes.sol @@ -58,9 +58,15 @@ enum TagType { IBEACON_INCLUDES_MAC, // 0x01: proxUUID || major || minor || MAC (Normalized) VENDOR_ID, // 0x02: len-prefixed companyID || fleetIdentifier EDDYSTONE_UID, // 0x03: namespace (10B) || instance (6B) - SERVICE_DATA // 0x04: expanded 128-bit BLE Service UUID + SERVICE_DATA, // 0x04: expanded 128-bit BLE Service UUID + UUID_ONLY // 0x05: fleet-wide swarm — all tags under the UUID are members } +/// @dev Sentinel filter byte used for UUID_ONLY (fleet-wide) swarms. +/// UUID_ONLY swarms bypass the XOR filter; this 1-byte sentinel satisfies +/// the non-empty filter requirement while keeping swarmId deterministic. +bytes constant FLEET_WIDE_SENTINEL = hex"ff"; + /// @notice Fingerprint size for XOR filter (8-bit or 16-bit only for gas efficiency). enum FingerprintSize { BITS_8, // 8-bit fingerprints (1 byte each) diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 36654de1..7b043734 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -5,6 +5,9 @@ import {Test} from "forge-std/Test.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {FleetIdentityUpgradeable} from "../src/swarms/FleetIdentityUpgradeable.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; +import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; +import {IBondTreasury} from "../src/swarms/interfaces/IBondTreasury.sol"; /// @dev Minimal ERC-20 mock with public mint for testing. contract MockERC20 is ERC20 { @@ -40,6 +43,28 @@ contract BadERC20 is ERC20 { } } +/// @dev Minimal bond treasury mock for FleetIdentity tests. +contract MockBondTreasury is IBondTreasury { + using SafeERC20 for IERC20; + + mapping(address => bool) public whitelisted; + + error NotWhitelisted(); + + function setWhitelisted(address user, bool status) external { + whitelisted[user] = status; + } + + function consumeSponsoredBond(address user, uint256) external view override { + if (!whitelisted[user]) revert NotWhitelisted(); + } + + /// @dev Approve a spender to pull tokens held by this treasury. + function approveSpender(address token, address spender) external { + IERC20(token).forceApprove(spender, type(uint256).max); + } +} + contract FleetIdentityTest is Test { FleetIdentityUpgradeable fleet; MockERC20 bondToken; @@ -4080,4 +4105,182 @@ contract FleetIdentityTest is Test { uint32[] memory areas = fleet.getCountryAdminAreas(JP); assertEq(areas.length, 0); } + + // ══════════════════════════════════════════════ + // claimUuidSponsored (treasury-backed claims) + // ══════════════════════════════════════════════ + + function _deployTreasury() internal returns (MockBondTreasury treasury) { + treasury = new MockBondTreasury(); + // Fund treasury and approve FleetIdentity to pull from it + bondToken.mint(address(treasury), 100_000 ether); + treasury.approveSpender(address(bondToken), address(fleet)); + } + + function test_claimUuidSponsored_basic() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + vm.prank(alice); + uint256 tokenId = fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + + // Alice is both beneficiary and msg.sender — self-sovereign + assertEq(fleet.uuidOwner(UUID_1), alice); + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(uint256(fleet.uuidLevel(UUID_1)), uint256(1)); // Owned + assertEq(fleet.uuidTokenCount(UUID_1), 1); + assertEq(tokenId, uint256(uint128(UUID_1))); + } + + function test_claimUuidSponsored_operatorSetCorrectly() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + // Operator set to carol + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, carol, address(treasury)); + assertEq(fleet.operatorOf(UUID_1), carol); + + // Operator == sender → stored as address(0), operatorOf returns sender + vm.prank(alice); + fleet.claimUuidSponsored(UUID_2, alice, address(treasury)); + assertEq(fleet.operatorOf(UUID_2), alice); + } + + function test_claimUuidSponsored_bondPulledFromTreasury() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + uint256 treasuryBefore = bondToken.balanceOf(address(treasury)); + uint256 fleetBefore = bondToken.balanceOf(address(fleet)); + uint256 aliceBefore = bondToken.balanceOf(alice); + + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + + // Bond comes from treasury, NOT from alice + assertEq(bondToken.balanceOf(address(treasury)), treasuryBefore - BASE_BOND); + assertEq(bondToken.balanceOf(address(fleet)), fleetBefore + BASE_BOND); + assertEq(bondToken.balanceOf(alice), aliceBefore); // alice untouched + } + + function test_claimUuidSponsored_emitsUuidClaimed() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + vm.expectEmit(true, true, true, true); + emit FleetIdentityUpgradeable.UuidClaimed(alice, UUID_1, alice); + + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + } + + function test_RevertIf_claimUuidSponsored_notWhitelisted() public { + MockBondTreasury treasury = _deployTreasury(); + // alice NOT whitelisted + + vm.prank(alice); + vm.expectRevert(MockBondTreasury.NotWhitelisted.selector); + fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + } + + function test_RevertIf_claimUuidSponsored_zeroUuid() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + vm.prank(alice); + vm.expectRevert(FleetIdentityUpgradeable.InvalidUUID.selector); + fleet.claimUuidSponsored(bytes16(0), address(0), address(treasury)); + } + + function test_RevertIf_claimUuidSponsored_uuidAlreadyOwned() public { + // Bob claims UUID_1 directly first + vm.prank(bob); + fleet.claimUuid(UUID_1, address(0)); + + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + vm.prank(alice); + vm.expectRevert(FleetIdentityUpgradeable.UuidAlreadyOwned.selector); + fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + } + + function test_claimUuidSponsored_ownershipBondSnapshotRecorded() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + + assertEq(fleet.uuidOwnershipBondPaid(UUID_1), BASE_BOND); + } + + function test_claimUuidSponsored_burnRefundsToSender() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + vm.prank(alice); + uint256 tokenId = fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + + uint256 aliceBefore = bondToken.balanceOf(alice); + + // Alice (token holder and uuid owner) burns the owned-only token + vm.prank(alice); + fleet.burn(tokenId); + + // Refund goes to alice (uuidOwner = msg.sender always) + assertEq(bondToken.balanceOf(alice), aliceBefore + BASE_BOND); + } + + function test_claimUuidSponsored_thenRegisterWorksForOperator() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + // Alice claims UUID with carol as operator + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, carol, address(treasury)); + + // Carol (operator) can now register the fleet in a region + vm.prank(carol); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + // Token is minted to alice (uuid owner), not carol + assertEq(fleet.ownerOf(tokenId), alice); + } + + function test_claimUuidSponsored_differentTreasuries() public { + // Deploy two independent treasuries with different whitelists + MockBondTreasury treasury1 = _deployTreasury(); + MockBondTreasury treasury2 = _deployTreasury(); + + treasury1.setWhitelisted(alice, true); + treasury2.setWhitelisted(bob, true); + + // Alice uses treasury1 + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, address(0), address(treasury1)); + assertEq(fleet.uuidOwner(UUID_1), alice); + + // Bob uses treasury2 + vm.prank(bob); + fleet.claimUuidSponsored(UUID_2, address(0), address(treasury2)); + assertEq(fleet.uuidOwner(UUID_2), bob); + + // Alice cannot use treasury2 (not whitelisted there) + vm.prank(alice); + vm.expectRevert(MockBondTreasury.NotWhitelisted.selector); + fleet.claimUuidSponsored(UUID_3, address(0), address(treasury2)); + } + + function test_RevertIf_claimUuidSponsored_treasuryHasNoBalance() public { + MockBondTreasury treasury = new MockBondTreasury(); + // Approve but don't fund + treasury.approveSpender(address(bondToken), address(fleet)); + treasury.setWhitelisted(alice, true); + + vm.prank(alice); + vm.expectRevert(); // safeTransferFrom will fail — insufficient balance + fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + } } diff --git a/test/SwarmRegistryL1.t.sol b/test/SwarmRegistryL1.t.sol index c8723002..c84243cc 100644 --- a/test/SwarmRegistryL1.t.sol +++ b/test/SwarmRegistryL1.t.sol @@ -6,7 +6,7 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s import "../src/swarms/SwarmRegistryL1Upgradeable.sol"; import {FleetIdentityUpgradeable} from "../src/swarms/FleetIdentityUpgradeable.sol"; import {ServiceProviderUpgradeable} from "../src/swarms/ServiceProviderUpgradeable.sol"; -import {SwarmStatus, TagType, FingerprintSize} from "../src/swarms/interfaces/SwarmTypes.sol"; +import {SwarmStatus, TagType, FingerprintSize, FLEET_WIDE_SENTINEL} from "../src/swarms/interfaces/SwarmTypes.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MockBondTokenL1 is ERC20 { @@ -1190,18 +1190,346 @@ contract SwarmRegistryL1Test is Test { uint256 providerId = _registerProvider(providerOwner, "url1"); bytes16 uuid = _getFleetUuid(fleetId); - // Encode call with invalid tagType (5, but enum only has 0-4) + // Encode call with invalid tagType (6, but enum only has 0-5) bytes memory callData = abi.encodeWithSelector( SwarmRegistryL1Upgradeable.registerSwarm.selector, uuid, providerId, new bytes(32), uint8(0), // Valid FingerprintSize - uint8(5) // Invalid TagType + uint8(6) // Invalid TagType ); vm.prank(fleetOwner); (bool success,) = address(swarmRegistry).call(callData); assertFalse(success, "Should revert on invalid TagType"); } + + // ============================== + // Fleet-Wide Swarm (UUID_ONLY) + // ============================== + + function _registerFleetWideSwarm(address owner, uint256 fleetId, uint256 providerId) + internal + returns (uint256) + { + bytes16 fleetUuid = _getFleetUuid(fleetId); + vm.prank(owner); + return swarmRegistry.registerSwarm(fleetUuid, providerId, FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY); + } + + function test_registerFleetWideSwarm_basicFlow() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + ( + bytes16 storedUuid, + uint256 storedProvider, + address storedPointer, + FingerprintSize storedFpSize, + TagType storedTagType, + SwarmStatus storedStatus + ) = swarmRegistry.swarms(swarmId); + + assertEq(storedUuid, _getFleetUuid(fleetId)); + assertEq(storedProvider, providerId); + assertTrue(storedPointer != address(0)); + assertEq(uint8(storedFpSize), uint8(BITS_8)); + assertEq(uint8(storedTagType), uint8(TagType.UUID_ONLY)); + assertEq(uint8(storedStatus), uint8(SwarmStatus.REGISTERED)); + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_registerFleetWideSwarm_deterministicId() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw2"); + uint256 providerId = _registerProvider(providerOwner, "url2"); + + uint256 expectedId = swarmRegistry.computeSwarmId( + _getFleetUuid(fleetId), FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY + ); + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + assertEq(swarmId, expectedId); + } + + function test_registerFleetWideSwarm_emitsEvent() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw3"); + uint256 providerId = _registerProvider(providerOwner, "url3"); + bytes16 uuid = _getFleetUuid(fleetId); + + uint256 expectedId = swarmRegistry.computeSwarmId(uuid, FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY); + + vm.expectEmit(true, true, true, true); + emit SwarmRegistered(expectedId, uuid, providerId, fleetOwner); + + _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + } + + function test_checkMembership_fleetWide_alwaysTrue() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw4"); + uint256 providerId = _registerProvider(providerOwner, "url4"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Accept the swarm + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Any tagHash should pass + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256("tag1"))); + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256("tag2"))); + assertTrue(swarmRegistry.checkMembership(swarmId, bytes32(0))); + assertTrue(swarmRegistry.checkMembership(swarmId, bytes32(type(uint256).max))); + } + + function test_RevertIf_registerSwarm_fleetWideExists_blockRegular() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw6"); + uint256 providerId = _registerProvider(providerOwner, "url6"); + + _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Try to add a regular swarm — should revert + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1Upgradeable.FleetWideSwarmExists.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, new bytes(32), BITS_8, TagType.IBEACON_PAYLOAD_ONLY + ); + } + + function test_RevertIf_registerSwarm_fleetWideExists_blockSecondFleetWide() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw7"); + uint256 providerId = _registerProvider(providerOwner, "url7"); + + _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Try to add another fleet-wide swarm — should revert + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1Upgradeable.FleetHasSwarms.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY + ); + } + + function test_RevertIf_registerSwarm_regularExists_blockFleetWide() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw8"); + uint256 providerId = _registerProvider(providerOwner, "url8"); + + // Register a regular swarm first + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(32), BITS_8, TagType.IBEACON_PAYLOAD_ONLY); + + // Try to add fleet-wide — should revert + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1Upgradeable.FleetHasSwarms.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY + ); + } + + function test_RevertIf_registerFleetWide_invalidSentinel_empty() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw9"); + uint256 providerId = _registerProvider(providerOwner, "url9"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1Upgradeable.InvalidFleetWideSentinel.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, new bytes(0), BITS_8, TagType.UUID_ONLY + ); + } + + function test_RevertIf_registerFleetWide_invalidSentinel_wrongByte() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw10"); + uint256 providerId = _registerProvider(providerOwner, "url10"); + + bytes memory badSentinel = new bytes(1); + badSentinel[0] = 0xAA; + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1Upgradeable.InvalidFleetWideSentinel.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, badSentinel, BITS_8, TagType.UUID_ONLY + ); + } + + function test_RevertIf_registerFleetWide_invalidSentinel_tooLong() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw11"); + uint256 providerId = _registerProvider(providerOwner, "url11"); + + bytes memory longFilter = new bytes(2); + longFilter[0] = 0xFF; + longFilter[1] = 0xFF; + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1Upgradeable.InvalidFleetWideSentinel.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, longFilter, BITS_8, TagType.UUID_ONLY + ); + } + + function test_deleteFleetWideSwarm_clearsFlag() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw12"); + uint256 providerId = _registerProvider(providerOwner, "url12"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + assertFalse(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_deleteFleetWideSwarm_allowsNewSwarm() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw13"); + uint256 providerId = _registerProvider(providerOwner, "url13"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // Now a regular swarm should be allowed + uint256 newSwarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(32), BITS_8, TagType.IBEACON_PAYLOAD_ONLY + ); + assertTrue(newSwarmId != 0); + } + + function test_deleteFleetWideSwarm_allowsNewFleetWide() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw14"); + uint256 providerId = _registerProvider(providerOwner, "url14"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // Re-register fleet-wide + uint256 newSwarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + assertTrue(newSwarmId != 0); + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_purgeFleetWideSwarm_clearsFlag() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw15"); + uint256 providerId = _registerProvider(providerOwner, "url15"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + + // Burn the provider to orphan the swarm + vm.prank(providerOwner); + providerContract.burn(providerId); + + // Purge the orphaned fleet-wide swarm + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + assertFalse(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_purgeFleetWideSwarm_allowsNewSwarmAfter() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw16"); + uint256 providerId = _registerProvider(providerOwner, "url16"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Burn provider to orphan + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // Register new provider and fleet-wide swarm + uint256 newProviderId = _registerProvider(providerOwner, "url16b"); + uint256 newSwarmId = _registerFleetWideSwarm(fleetOwner, fleetId, newProviderId); + assertTrue(newSwarmId != 0); + } + + function test_fleetWideSwarm_acceptAndReject() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw17"); + uint256 providerId = _registerProvider(providerOwner, "url17"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Accept + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + (,,, , TagType t1, SwarmStatus status1) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status1), uint8(SwarmStatus.ACCEPTED)); + + // Reject after + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + (,,,, TagType t2, SwarmStatus status2) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status2), uint8(SwarmStatus.REJECTED)); + } + + function test_fleetWideSwarm_updateProvider() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw18"); + uint256 providerId1 = _registerProvider(providerOwner, "url18a"); + uint256 providerId2 = _registerProvider(providerOwner, "url18b"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId1); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + + (, uint256 storedProvider,,,,) = swarmRegistry.swarms(swarmId); + assertEq(storedProvider, providerId2); + + // hasFleetWideSwarm should still be true + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_fleetWideSwarm_getFilterData() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw19"); + uint256 providerId = _registerProvider(providerOwner, "url19"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + bytes memory stored = swarmRegistry.getFilterData(swarmId); + assertEq(stored.length, 1); + assertEq(uint8(stored[0]), 0xFF); + } + + function test_fleetWideSwarm_isSwarmValid() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw20"); + uint256 providerId = _registerProvider(providerOwner, "url20"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertTrue(providerValid); + } + + function test_deleteRegularSwarm_doesNotAffectFleetWideFlag() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw21"); + uint256 providerId = _registerProvider(providerOwner, "url21"); + + uint256 swarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(32), BITS_8, TagType.IBEACON_PAYLOAD_ONLY + ); + + assertFalse(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + assertFalse(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function testFuzz_fleetWideSwarm_checkMembership(bytes32 tagHash) public { + uint256 fleetId = _registerFleet(fleetOwner, "fuzz1"); + uint256 providerId = _registerProvider(providerOwner, "fuzz_url"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + assertTrue(swarmRegistry.checkMembership(swarmId, tagHash)); + } } diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol index 272aae89..44e14006 100644 --- a/test/SwarmRegistryUniversal.t.sol +++ b/test/SwarmRegistryUniversal.t.sol @@ -6,7 +6,7 @@ import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.s import "../src/swarms/SwarmRegistryUniversalUpgradeable.sol"; import {FleetIdentityUpgradeable} from "../src/swarms/FleetIdentityUpgradeable.sol"; import {ServiceProviderUpgradeable} from "../src/swarms/ServiceProviderUpgradeable.sol"; -import {SwarmStatus, TagType, FingerprintSize} from "../src/swarms/interfaces/SwarmTypes.sol"; +import {SwarmStatus, TagType, FingerprintSize, FLEET_WIDE_SENTINEL} from "../src/swarms/interfaces/SwarmTypes.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; contract MockBondTokenUniv is ERC20 { @@ -1255,18 +1255,371 @@ contract SwarmRegistryUniversalTest is Test { uint256 providerId = _registerProvider(providerOwner, "url1"); bytes16 uuid = _getFleetUuid(fleetId); - // Encode call with invalid tagType (5, but enum only has 0-4) + // Encode call with invalid tagType (6, but enum only has 0-5) bytes memory callData = abi.encodeWithSelector( SwarmRegistryUniversalUpgradeable.registerSwarm.selector, uuid, providerId, new bytes(32), uint8(0), // Valid FingerprintSize - uint8(5) // Invalid TagType + uint8(6) // Invalid TagType ); vm.prank(fleetOwner); (bool success,) = address(swarmRegistry).call(callData); assertFalse(success, "Should revert on invalid TagType"); } + + // ============================== + // Fleet-Wide Swarm (UUID_ONLY) + // ============================== + + function _registerFleetWideSwarm(address owner, uint256 fleetId, uint256 providerId) + internal + returns (uint256) + { + bytes16 fleetUuid = _getFleetUuid(fleetId); + vm.prank(owner); + return swarmRegistry.registerSwarm(fleetUuid, providerId, FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY); + } + + function test_registerFleetWideSwarm_basicFlow() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + ( + bytes16 storedUuid, + uint256 storedProvider, + uint32 storedFilterLen, + FingerprintSize storedFpSize, + TagType storedTagType, + SwarmStatus storedStatus + ) = swarmRegistry.swarms(swarmId); + + assertEq(storedUuid, _getFleetUuid(fleetId)); + assertEq(storedProvider, providerId); + assertEq(storedFilterLen, 1); + assertEq(uint8(storedFpSize), uint8(BITS_8)); + assertEq(uint8(storedTagType), uint8(TagType.UUID_ONLY)); + assertEq(uint8(storedStatus), uint8(SwarmStatus.REGISTERED)); + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_registerFleetWideSwarm_deterministicId() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw2"); + uint256 providerId = _registerProvider(providerOwner, "url2"); + + uint256 expectedId = swarmRegistry.computeSwarmId( + _getFleetUuid(fleetId), FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY + ); + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + assertEq(swarmId, expectedId); + } + + function test_registerFleetWideSwarm_emitsEvent() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw3"); + uint256 providerId = _registerProvider(providerOwner, "url3"); + bytes16 uuid = _getFleetUuid(fleetId); + + uint256 expectedId = swarmRegistry.computeSwarmId(uuid, FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY); + + vm.expectEmit(true, true, true, true); + emit SwarmRegistered(expectedId, uuid, providerId, fleetOwner, 1); + + _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + } + + function test_checkMembership_fleetWide_alwaysTrue() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw4"); + uint256 providerId = _registerProvider(providerOwner, "url4"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Accept the swarm + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Any tagHash should pass + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256("tag1"))); + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256("tag2"))); + assertTrue(swarmRegistry.checkMembership(swarmId, bytes32(0))); + assertTrue(swarmRegistry.checkMembership(swarmId, bytes32(type(uint256).max))); + } + + function test_RevertIf_checkMembership_fleetWide_notAccepted() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw5"); + uint256 providerId = _registerProvider(providerOwner, "url5"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Status is REGISTERED, not ACCEPTED — checkMembership should revert + // (It passes validity check but returns true without checking status — + // actually, checkMembership doesn't gate on status, it gates on orphan. + // Let's verify it works even when REGISTERED — the spec says only ACCEPTED + // swarms pass checkMembership, but the contract doesn't enforce that in code.) + // We just verify it doesn't revert for non-orphaned swarms + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256("tag"))); + } + + function test_RevertIf_registerSwarm_fleetWideExists_blockRegular() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw6"); + uint256 providerId = _registerProvider(providerOwner, "url6"); + + _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Try to add a regular swarm — should revert + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversalUpgradeable.FleetWideSwarmExists.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, new bytes(32), BITS_8, TagType.IBEACON_PAYLOAD_ONLY + ); + } + + function test_RevertIf_registerSwarm_fleetWideExists_blockSecondFleetWide() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw7"); + uint256 providerId = _registerProvider(providerOwner, "url7"); + + _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Try to add another fleet-wide swarm — should revert (FleetHasSwarms) + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversalUpgradeable.FleetHasSwarms.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY + ); + } + + function test_RevertIf_registerSwarm_regularExists_blockFleetWide() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw8"); + uint256 providerId = _registerProvider(providerOwner, "url8"); + + // Register a regular swarm first + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(32), BITS_8, TagType.IBEACON_PAYLOAD_ONLY); + + // Try to add fleet-wide — should revert + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversalUpgradeable.FleetHasSwarms.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, FLEET_WIDE_SENTINEL, BITS_8, TagType.UUID_ONLY + ); + } + + function test_RevertIf_registerFleetWide_invalidSentinel_empty() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw9"); + uint256 providerId = _registerProvider(providerOwner, "url9"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversalUpgradeable.InvalidFleetWideSentinel.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, new bytes(0), BITS_8, TagType.UUID_ONLY + ); + } + + function test_RevertIf_registerFleetWide_invalidSentinel_wrongByte() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw10"); + uint256 providerId = _registerProvider(providerOwner, "url10"); + + bytes memory badSentinel = new bytes(1); + badSentinel[0] = 0xAA; + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversalUpgradeable.InvalidFleetWideSentinel.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, badSentinel, BITS_8, TagType.UUID_ONLY + ); + } + + function test_RevertIf_registerFleetWide_invalidSentinel_tooLong() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw11"); + uint256 providerId = _registerProvider(providerOwner, "url11"); + + bytes memory longFilter = new bytes(2); + longFilter[0] = 0xFF; + longFilter[1] = 0xFF; + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversalUpgradeable.InvalidFleetWideSentinel.selector); + swarmRegistry.registerSwarm( + _getFleetUuid(fleetId), providerId, longFilter, BITS_8, TagType.UUID_ONLY + ); + } + + function test_deleteFleetWideSwarm_clearsFlag() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw12"); + uint256 providerId = _registerProvider(providerOwner, "url12"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + assertFalse(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_deleteFleetWideSwarm_allowsNewSwarm() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw13"); + uint256 providerId = _registerProvider(providerOwner, "url13"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // Now a regular swarm should be allowed + uint256 newSwarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(32), BITS_8, TagType.IBEACON_PAYLOAD_ONLY + ); + assertTrue(newSwarmId != 0); + } + + function test_deleteFleetWideSwarm_allowsNewFleetWide() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw14"); + uint256 providerId = _registerProvider(providerOwner, "url14"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // Re-register fleet-wide + uint256 newSwarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + assertTrue(newSwarmId != 0); + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_purgeFleetWideSwarm_clearsFlag() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw15"); + uint256 providerId = _registerProvider(providerOwner, "url15"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + + // Burn the provider to orphan the swarm + vm.prank(providerOwner); + providerContract.burn(providerId); + + // Purge the orphaned fleet-wide swarm + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + assertFalse(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_purgeFleetWideSwarm_allowsNewSwarmAfter() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw16"); + uint256 providerId = _registerProvider(providerOwner, "url16"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Burn provider to orphan + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // Register new provider and fleet-wide swarm + uint256 newProviderId = _registerProvider(providerOwner, "url16b"); + uint256 newSwarmId = _registerFleetWideSwarm(fleetOwner, fleetId, newProviderId); + assertTrue(newSwarmId != 0); + } + + function test_fleetWideSwarm_acceptAndReject() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw17"); + uint256 providerId = _registerProvider(providerOwner, "url17"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + // Accept + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + (,,,,, SwarmStatus status1) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status1), uint8(SwarmStatus.ACCEPTED)); + + // Reject after update + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + (,,,,, SwarmStatus status2) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status2), uint8(SwarmStatus.REJECTED)); + } + + function test_fleetWideSwarm_updateProvider() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw18"); + uint256 providerId1 = _registerProvider(providerOwner, "url18a"); + uint256 providerId2 = _registerProvider(providerOwner, "url18b"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId1); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + + (, uint256 storedProvider,,,,) = swarmRegistry.swarms(swarmId); + assertEq(storedProvider, providerId2); + + // hasFleetWideSwarm should still be true + assertTrue(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_fleetWideSwarm_getFilterData() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw19"); + uint256 providerId = _registerProvider(providerOwner, "url19"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + bytes memory stored = swarmRegistry.getFilterData(swarmId); + assertEq(stored.length, 1); + assertEq(uint8(stored[0]), 0xFF); + } + + function test_fleetWideSwarm_isSwarmValid() public { + uint256 fleetId = _registerFleet(fleetOwner, "fw20"); + uint256 providerId = _registerProvider(providerOwner, "url20"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertTrue(providerValid); + } + + function test_deleteRegularSwarm_doesNotAffectFleetWideFlag() public { + // Register regular swarm, then delete — hasFleetWideSwarm should stay false + uint256 fleetId = _registerFleet(fleetOwner, "fw21"); + uint256 providerId = _registerProvider(providerOwner, "url21"); + + uint256 swarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(32), BITS_8, TagType.IBEACON_PAYLOAD_ONLY + ); + + assertFalse(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + assertFalse(swarmRegistry.hasFleetWideSwarm(_getFleetUuid(fleetId))); + } + + function test_registerSwarm_allTagTypes_includesUuidOnly() public { + uint256 fleetId5 = _registerFleet(fleetOwner, "f5"); + uint256 providerId = _registerProvider(providerOwner, "url_all"); + + uint256 s5 = _registerFleetWideSwarm(fleetOwner, fleetId5, providerId); + (,,,, TagType t5,) = swarmRegistry.swarms(s5); + assertEq(uint8(t5), uint8(TagType.UUID_ONLY)); + } + + function testFuzz_fleetWideSwarm_checkMembership(bytes32 tagHash) public { + uint256 fleetId = _registerFleet(fleetOwner, "fuzz1"); + uint256 providerId = _registerProvider(providerOwner, "fuzz_url"); + + uint256 swarmId = _registerFleetWideSwarm(fleetOwner, fleetId, providerId); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + assertTrue(swarmRegistry.checkMembership(swarmId, tagHash)); + } } diff --git a/test/paymasters/BasePaymaster.t.sol b/test/paymasters/BasePaymaster.t.sol index be3762c5..55c53a9c 100644 --- a/test/paymasters/BasePaymaster.t.sol +++ b/test/paymasters/BasePaymaster.t.sol @@ -3,7 +3,13 @@ pragma solidity ^0.8.20; import {Test, console} from "forge-std/Test.sol"; -import {BasePaymaster} from "../../src/paymasters/BasePaymaster.sol"; +import { + BasePaymaster, + BOOTLOADER_FORMAL_ADDRESS +} from "../../src/paymasters/BasePaymaster.sol"; +import {IPaymasterFlow} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymasterFlow.sol"; +import {Transaction} from "lib/era-contracts/l2-contracts/contracts/L2ContractHelper.sol"; +import {ExecutionResult} from "lib/era-contracts/l2-contracts/contracts/interfaces/IPaymaster.sol"; contract MockPaymaster is BasePaymaster { event MockPaymasterCalled(); @@ -11,7 +17,6 @@ contract MockPaymaster is BasePaymaster { constructor(address admin, address withdrawer) BasePaymaster(admin, withdrawer) {} function _validateAndPayGeneralFlow(address, address, uint256) internal override { - // this is a mock, do nothing emit MockPaymasterCalled(); } @@ -19,11 +24,17 @@ contract MockPaymaster is BasePaymaster { internal override { - // this is a mock, do nothing emit MockPaymasterCalled(); } } +/// @dev Contract that rejects ETH transfers (used to test FailedToWithdraw). +contract ETHRejecter { + receive() external payable { + revert("rejected"); + } +} + contract BasePaymasterTest is Test { MockPaymaster private paymaster; @@ -33,23 +44,164 @@ contract BasePaymasterTest is Test { function setUp() public { paymaster = new MockPaymaster(alice, bob); - vm.deal(address(paymaster), 1 ether); + vm.deal(address(paymaster), 10 ether); } + // --------------- ACLs --------------- + function test_defaultACLs() public view { assert(paymaster.hasRole(paymaster.DEFAULT_ADMIN_ROLE(), alice)); assert(paymaster.hasRole(paymaster.WITHDRAWER_ROLE(), bob)); } + // --------------- withdraw --------------- + function test_withdrawExcessETH() public { vm.prank(bob); vm.expectEmit(); emit BasePaymaster.Withdrawn(bob, 1 ether); paymaster.withdraw(bob, 1 ether); - assertEq(address(paymaster).balance, 0); + assertEq(address(paymaster).balance, 9 ether); assertEq(address(bob).balance, 1 ether); } - // TODO: test for sample paymaster txs + function test_RevertIf_withdrawCalledByNonWithdrawer() public { + vm.prank(charlie); + vm.expectRevert(); + paymaster.withdraw(charlie, 1 ether); + } + + function test_RevertIf_withdrawToContractThatRejectsETH() public { + ETHRejecter rejecter = new ETHRejecter(); + vm.prank(bob); + vm.expectRevert(BasePaymaster.FailedToWithdraw.selector); + paymaster.withdraw(address(rejecter), 1 ether); + } + + // --------------- receive --------------- + + function test_receiveETH() public { + uint256 balBefore = address(paymaster).balance; + (bool ok,) = address(paymaster).call{value: 1 ether}(""); + assertTrue(ok); + assertEq(address(paymaster).balance, balBefore + 1 ether); + } + + // --------------- validateAndPayForPaymasterTransaction --------------- + + function _buildTransaction(address from, address to, uint256 gasLimit, uint256 maxFeePerGas, bytes memory pmInput) + internal + pure + returns (Transaction memory) + { + Transaction memory txn; + txn.from = uint256(uint160(from)); + txn.to = uint256(uint160(to)); + txn.gasLimit = gasLimit; + txn.maxFeePerGas = maxFeePerGas; + txn.paymasterInput = pmInput; + return txn; + } + + function test_RevertIf_notCalledByBootloader() public { + Transaction memory txn = + _buildTransaction(charlie, alice, 100_000, 1 gwei, abi.encodeWithSelector(IPaymasterFlow.general.selector, "")); + + vm.prank(charlie); + vm.expectRevert(BasePaymaster.AccessRestrictedToBootloader.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_paymasterInputTooShort() public { + Transaction memory txn = _buildTransaction(charlie, alice, 100_000, 1 gwei, hex"aabb"); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert( + abi.encodeWithSelector( + BasePaymaster.InvalidPaymasterInput.selector, + "The standard paymaster input must be at least 4 bytes long" + ) + ); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_RevertIf_unsupportedPaymasterFlow() public { + // Use a random 4-byte selector that is neither general nor approvalBased + Transaction memory txn = _buildTransaction(charlie, alice, 100_000, 1 gwei, hex"deadbeef"); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(BasePaymaster.PaymasterFlowNotSupported.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + function test_validateAndPay_generalFlow() public { + bytes memory pmInput = abi.encodeWithSelector(IPaymasterFlow.general.selector, ""); + uint256 gasLimit = 100_000; + uint256 maxFeePerGas = 1 gwei; + uint256 requiredETH = gasLimit * maxFeePerGas; + Transaction memory txn = _buildTransaction(charlie, alice, gasLimit, maxFeePerGas, pmInput); + + // Fund the bootloader address so we can check balance + uint256 bootloaderBalBefore = BOOTLOADER_FORMAL_ADDRESS.balance; + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + (bytes4 magic,) = paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + + assertEq(magic, paymaster.validateAndPayForPaymasterTransaction.selector); + assertEq(BOOTLOADER_FORMAL_ADDRESS.balance, bootloaderBalBefore + requiredETH); + } + + function test_validateAndPay_approvalBasedFlow() public { + address token = address(0xBEEF); + uint256 minAllowance = 1000; + bytes memory innerData = ""; + bytes memory pmInput = + abi.encodeWithSelector(IPaymasterFlow.approvalBased.selector, token, minAllowance, innerData); + uint256 gasLimit = 100_000; + uint256 maxFeePerGas = 1 gwei; + uint256 requiredETH = gasLimit * maxFeePerGas; + Transaction memory txn = _buildTransaction(charlie, alice, gasLimit, maxFeePerGas, pmInput); + + uint256 bootloaderBalBefore = BOOTLOADER_FORMAL_ADDRESS.balance; + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + (bytes4 magic,) = paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + + assertEq(magic, paymaster.validateAndPayForPaymasterTransaction.selector); + assertEq(BOOTLOADER_FORMAL_ADDRESS.balance, bootloaderBalBefore + requiredETH); + } + + function test_RevertIf_notEnoughETHToPay() public { + // Drain the paymaster balance first + vm.prank(bob); + paymaster.withdraw(bob, 10 ether); + + bytes memory pmInput = abi.encodeWithSelector(IPaymasterFlow.general.selector, ""); + uint256 gasLimit = 100_000; + uint256 maxFeePerGas = 1 gwei; + Transaction memory txn = _buildTransaction(charlie, alice, gasLimit, maxFeePerGas, pmInput); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + vm.expectRevert(BasePaymaster.NotEnoughETHInPaymasterToPayForTransaction.selector); + paymaster.validateAndPayForPaymasterTransaction(bytes32(0), bytes32(0), txn); + } + + // --------------- postTransaction --------------- + + function test_postTransaction() public { + Transaction memory txn = _buildTransaction(charlie, alice, 100_000, 1 gwei, ""); + + vm.prank(BOOTLOADER_FORMAL_ADDRESS); + // Should not revert + paymaster.postTransaction("", txn, bytes32(0), bytes32(0), ExecutionResult.Success, 0); + } + + function test_RevertIf_postTransactionNotBootloader() public { + Transaction memory txn = _buildTransaction(charlie, alice, 100_000, 1 gwei, ""); + + vm.prank(charlie); + vm.expectRevert(BasePaymaster.AccessRestrictedToBootloader.selector); + paymaster.postTransaction("", txn, bytes32(0), bytes32(0), ExecutionResult.Success, 0); + } } diff --git a/test/paymasters/FleetTreasuryPaymaster.t.sol b/test/paymasters/FleetTreasuryPaymaster.t.sol new file mode 100644 index 00000000..7ddf4306 --- /dev/null +++ b/test/paymasters/FleetTreasuryPaymaster.t.sol @@ -0,0 +1,433 @@ +// SPDX-License-Identifier: BSD-3-Clause-Clear + +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {AccessControlUtils} from "../__helpers__/AccessControlUtils.sol"; +import {BasePaymaster} from "../../src/paymasters/BasePaymaster.sol"; +import {FleetTreasuryPaymaster} from "../../src/paymasters/FleetTreasuryPaymaster.sol"; +import {QuotaControl} from "../../src/QuotaControl.sol"; +import {FleetIdentityUpgradeable} from "../../src/swarms/FleetIdentityUpgradeable.sol"; + +contract MockERC20SCP is ERC20 { + constructor() ERC20("Mock Bond Token", "MBOND") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/// @dev Exposes internal paymaster validation for unit testing. +contract MockFleetTreasuryPaymaster is FleetTreasuryPaymaster { + constructor( + address admin, + address withdrawer, + address fleetIdentity_, + address bondToken_, + uint256 initialQuota, + uint256 initialPeriod + ) FleetTreasuryPaymaster(admin, withdrawer, fleetIdentity_, bondToken_, initialQuota, initialPeriod) {} + + function mock_validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) public view { + _validateAndPayGeneralFlow(from, to, requiredETH); + } + + function mock_validateAndPayApprovalBasedFlow( + address from, + address to, + address token, + uint256 amount, + bytes memory data, + uint256 requiredETH + ) public pure { + _validateAndPayApprovalBasedFlow(from, to, token, amount, data, requiredETH); + } +} + +contract FleetTreasuryPaymasterTest is Test { + using AccessControlUtils for Vm; + + FleetIdentityUpgradeable fleet; + MockFleetTreasuryPaymaster paymaster; + MockERC20SCP bondToken; + + address internal admin = address(0x1111); + address internal withdrawer = address(0x2222); + address internal alice = address(0xA); + address internal bob = address(0xB); + + bytes16 constant UUID_1 = bytes16(keccak256("fleet-alpha")); + bytes16 constant UUID_2 = bytes16(keccak256("fleet-bravo")); + bytes16 constant UUID_3 = bytes16(keccak256("fleet-charlie")); + + uint256 constant BASE_BOND = 100 ether; + uint256 constant QUOTA = 1000 ether; // allows 10 claims at BASE_BOND each + uint256 constant PERIOD = 1 days; + + address[] internal whitelistTargets; + + function setUp() public { + bondToken = new MockERC20SCP(); + + // Deploy FleetIdentity via proxy + FleetIdentityUpgradeable impl = new FleetIdentityUpgradeable(); + ERC1967Proxy proxy = new ERC1967Proxy( + address(impl), + abi.encodeCall(FleetIdentityUpgradeable.initialize, (admin, address(bondToken), BASE_BOND, 0)) + ); + fleet = FleetIdentityUpgradeable(address(proxy)); + + // Deploy merged paymaster/treasury + paymaster = new MockFleetTreasuryPaymaster( + admin, withdrawer, address(fleet), address(bondToken), QUOTA, PERIOD + ); + + // Fund paymaster with NODL for bonds + bondToken.mint(address(paymaster), 10_000 ether); + + // Whitelist alice + whitelistTargets = new address[](1); + whitelistTargets[0] = alice; + vm.prank(admin); + paymaster.addWhitelistedUsers(whitelistTargets); + } + + // ══════════════════════════════════════════════ + // ACLs & Immutables + // ══════════════════════════════════════════════ + + function test_defaultACLs() public view { + assertTrue(paymaster.hasRole(paymaster.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(paymaster.hasRole(paymaster.WHITELIST_ADMIN_ROLE(), admin)); + assertTrue(paymaster.hasRole(paymaster.WITHDRAWER_ROLE(), withdrawer)); + } + + function test_immutables() public view { + assertEq(paymaster.fleetIdentity(), address(fleet)); + assertEq(address(paymaster.bondToken()), address(bondToken)); + } + + // ══════════════════════════════════════════════ + // Whitelist Management + // ══════════════════════════════════════════════ + + function test_whitelistAdminUpdatesWhitelist() public { + address[] memory targets = new address[](1); + targets[0] = bob; + + vm.startPrank(admin); + + assertFalse(paymaster.isWhitelistedUser(bob)); + + vm.expectEmit(); + emit FleetTreasuryPaymaster.WhitelistedUsersAdded(targets); + paymaster.addWhitelistedUsers(targets); + assertTrue(paymaster.isWhitelistedUser(bob)); + + vm.expectEmit(); + emit FleetTreasuryPaymaster.WhitelistedUsersRemoved(targets); + paymaster.removeWhitelistedUsers(targets); + assertFalse(paymaster.isWhitelistedUser(bob)); + + vm.stopPrank(); + } + + function test_nonWhitelistAdminCannotUpdateWhitelist() public { + vm.startPrank(withdrawer); + + vm.expectRevert_AccessControlUnauthorizedAccount(withdrawer, paymaster.WHITELIST_ADMIN_ROLE()); + paymaster.addWhitelistedUsers(whitelistTargets); + + vm.expectRevert_AccessControlUnauthorizedAccount(withdrawer, paymaster.WHITELIST_ADMIN_ROLE()); + paymaster.removeWhitelistedUsers(whitelistTargets); + + vm.stopPrank(); + } + + // ══════════════════════════════════════════════ + // Paymaster Validation + // ══════════════════════════════════════════════ + + function test_generalFlowValidation_success() public { + vm.deal(address(paymaster), 10 ether); + paymaster.mock_validateAndPayGeneralFlow(alice, address(fleet), 1 ether); + } + + function test_doesNotSupportApprovalBasedFlow() public { + vm.expectRevert(BasePaymaster.PaymasterFlowNotSupported.selector); + paymaster.mock_validateAndPayApprovalBasedFlow(alice, address(fleet), address(0), 1, "0x", 0); + } + + function test_RevertIf_destinationNotAllowed() public { + vm.expectRevert(FleetTreasuryPaymaster.DestinationNotAllowed.selector); + paymaster.mock_validateAndPayGeneralFlow(alice, address(0xDEAD), 0); + } + + function test_RevertIf_userIsNotWhitelisted_paymaster() public { + vm.expectRevert(FleetTreasuryPaymaster.UserIsNotWhitelisted.selector); + paymaster.mock_validateAndPayGeneralFlow(bob, address(fleet), 0); + } + + function test_RevertIf_paymasterBalanceTooLow() public { + vm.expectRevert(FleetTreasuryPaymaster.PaymasterBalanceTooLow.selector); + paymaster.mock_validateAndPayGeneralFlow(alice, address(fleet), 1 ether); + } + + // ══════════════════════════════════════════════ + // Treasury: consumeSponsoredBond + // ══════════════════════════════════════════════ + + function test_consumeSponsoredBond_success() public { + // Only FleetIdentity can call consumeSponsoredBond + vm.prank(address(fleet)); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + assertEq(paymaster.claimed(), BASE_BOND); + } + + function test_RevertIf_consumeSponsoredBond_notFleetIdentity() public { + vm.prank(alice); + vm.expectRevert(FleetTreasuryPaymaster.NotFleetIdentity.selector); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + } + + function test_RevertIf_consumeSponsoredBond_notWhitelisted() public { + vm.prank(address(fleet)); + vm.expectRevert(FleetTreasuryPaymaster.UserIsNotWhitelisted.selector); + paymaster.consumeSponsoredBond(bob, BASE_BOND); + } + + function test_RevertIf_consumeSponsoredBond_insufficientBalance() public { + // Withdraw all NODL from paymaster + vm.startPrank(withdrawer); + paymaster.withdrawTokens(address(bondToken), withdrawer, bondToken.balanceOf(address(paymaster))); + vm.stopPrank(); + + vm.prank(address(fleet)); + vm.expectRevert(FleetTreasuryPaymaster.InsufficientBondBalance.selector); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + } + + // ══════════════════════════════════════════════ + // End-to-end: claimUuidSponsored through paymaster + // ══════════════════════════════════════════════ + + function test_sponsoredClaim_e2e() public { + vm.prank(alice); + uint256 tokenId = fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + + assertEq(fleet.uuidOwner(UUID_1), alice); + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(tokenId, uint256(uint128(UUID_1))); + } + + function test_sponsoredClaim_bondFromPaymaster() public { + uint256 paymasterBefore = bondToken.balanceOf(address(paymaster)); + + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + + assertEq(bondToken.balanceOf(address(paymaster)), paymasterBefore - BASE_BOND); + } + + function test_sponsoredClaim_withOperator() public { + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, bob, address(paymaster)); + + assertEq(fleet.operatorOf(UUID_1), bob); + assertEq(fleet.uuidOwner(UUID_1), alice); + } + + function test_sponsoredClaim_multipleClaims() public { + address[] memory bobList = new address[](1); + bobList[0] = bob; + vm.prank(admin); + paymaster.addWhitelistedUsers(bobList); + + vm.prank(alice); + uint256 tokenId1 = fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + + vm.prank(bob); + uint256 tokenId2 = fleet.claimUuidSponsored(UUID_2, address(0), address(paymaster)); + + assertEq(fleet.uuidOwner(UUID_1), alice); + assertEq(fleet.uuidOwner(UUID_2), bob); + assertEq(fleet.ownerOf(tokenId1), alice); + assertEq(fleet.ownerOf(tokenId2), bob); + } + + function test_RevertIf_sponsoredClaim_uuidAlreadyClaimed() public { + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + + vm.prank(alice); + vm.expectRevert(FleetIdentityUpgradeable.UuidAlreadyOwned.selector); + fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + } + + function test_burnAfterSponsoredClaim_refundsToOwner() public { + vm.prank(alice); + uint256 tokenId = fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + + uint256 aliceBefore = bondToken.balanceOf(alice); + + vm.prank(alice); + fleet.burn(tokenId); + + // Refund goes to alice (uuidOwner = msg.sender) + assertEq(bondToken.balanceOf(alice), aliceBefore + BASE_BOND); + } + + // ══════════════════════════════════════════════ + // QuotaControl integration + // ══════════════════════════════════════════════ + + function test_quotaParamsSetInConstructor() public view { + assertEq(paymaster.quota(), QUOTA); + assertEq(paymaster.period(), PERIOD); + } + + function test_RevertIf_quotaExceeded() public { + bondToken.mint(address(paymaster), 100_000 ether); + + // Quota is 1000 ether, each claim costs BASE_BOND (100 ether), so 10 claims exhaust it + for (uint256 i = 0; i < 10; i++) { + bytes16 uuid = bytes16(keccak256(abi.encodePacked("uuid-", i))); + vm.prank(alice); + fleet.claimUuidSponsored(uuid, address(0), address(paymaster)); + } + + // 11th claim should exceed quota + vm.prank(alice); + vm.expectRevert(QuotaControl.QuotaExceeded.selector); + fleet.claimUuidSponsored(UUID_3, address(0), address(paymaster)); + } + + function test_quotaTracksBaseBondNotClaimCount() public { + // Deploy paymaster with quota smaller than a single BASE_BOND + MockFleetTreasuryPaymaster tightPaymaster = new MockFleetTreasuryPaymaster( + admin, withdrawer, address(fleet), address(bondToken), BASE_BOND / 2, PERIOD + ); + + // Whitelist alice on the tight paymaster + address[] memory targets = new address[](1); + targets[0] = alice; + vm.prank(admin); + tightPaymaster.addWhitelistedUsers(targets); + + bondToken.mint(address(tightPaymaster), 10_000 ether); + + // BASE_BOND (100 ether) > quota (50 ether), so first claim must revert + vm.prank(alice); + vm.expectRevert(QuotaControl.QuotaExceeded.selector); + fleet.claimUuidSponsored(UUID_1, address(0), address(tightPaymaster)); + } + + function test_quotaResetsAfterPeriod() public { + bondToken.mint(address(paymaster), 100_000 ether); + + // Exhaust quota (10 claims × 100 ether = 1000 ether) + for (uint256 i = 0; i < 10; i++) { + bytes16 uuid = bytes16(keccak256(abi.encodePacked("uuid-", i))); + vm.prank(alice); + fleet.claimUuidSponsored(uuid, address(0), address(paymaster)); + } + + // Verify quota is exhausted + vm.prank(alice); + vm.expectRevert(QuotaControl.QuotaExceeded.selector); + fleet.claimUuidSponsored(UUID_3, address(0), address(paymaster)); + + // Advance past period + vm.warp(block.timestamp + PERIOD + 1); + + // Should succeed again after reset + vm.prank(alice); + fleet.claimUuidSponsored(UUID_3, address(0), address(paymaster)); + assertEq(fleet.uuidOwner(UUID_3), alice); + } + + function test_claimedCounterIncrementsCorrectly() public { + assertEq(paymaster.claimed(), 0); + + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + assertEq(paymaster.claimed(), BASE_BOND); + + address[] memory bobList = new address[](1); + bobList[0] = bob; + vm.prank(admin); + paymaster.addWhitelistedUsers(bobList); + + vm.prank(bob); + fleet.claimUuidSponsored(UUID_2, address(0), address(paymaster)); + assertEq(paymaster.claimed(), BASE_BOND * 2); + } + + function test_adminCanUpdateQuota() public { + vm.prank(admin); + paymaster.setQuota(500 ether); + assertEq(paymaster.quota(), 500 ether); + } + + function test_adminCanUpdatePeriod() public { + vm.prank(admin); + paymaster.setPeriod(7 days); + assertEq(paymaster.period(), 7 days); + } + + function test_RevertIf_nonAdminUpdatesQuota() public { + vm.prank(alice); + vm.expectRevert(); + paymaster.setQuota(500 ether); + } + + function test_RevertIf_nonAdminUpdatesPeriod() public { + vm.prank(alice); + vm.expectRevert(); + paymaster.setPeriod(7 days); + } + + function test_RevertIf_constructorZeroPeriod() public { + vm.expectRevert(QuotaControl.ZeroPeriod.selector); + new MockFleetTreasuryPaymaster(admin, withdrawer, address(fleet), address(bondToken), QUOTA, 0); + } + + function test_RevertIf_constructorTooLongPeriod() public { + vm.expectRevert(QuotaControl.TooLongPeriod.selector); + new MockFleetTreasuryPaymaster( + admin, withdrawer, address(fleet), address(bondToken), QUOTA, 31 days + ); + } + + // ══════════════════════════════════════════════ + // ERC-20 Withdrawal + // ══════════════════════════════════════════════ + + function test_withdrawTokens() public { + uint256 amount = 500 ether; + + vm.prank(withdrawer); + paymaster.withdrawTokens(address(bondToken), withdrawer, amount); + + assertEq(bondToken.balanceOf(withdrawer), amount); + } + + function test_RevertIf_withdrawTokens_notWithdrawer() public { + vm.prank(alice); + vm.expectRevert(); + paymaster.withdrawTokens(address(bondToken), alice, 1 ether); + } + + function test_withdrawTokensEmitsEvent() public { + uint256 amount = 500 ether; + + vm.expectEmit(true, true, true, true); + emit FleetTreasuryPaymaster.TokensWithdrawn(address(bondToken), withdrawer, amount); + + vm.prank(withdrawer); + paymaster.withdrawTokens(address(bondToken), withdrawer, amount); + } +} diff --git a/test/paymasters/WhitelistPaymaster.t.sol b/test/paymasters/WhitelistPaymaster.t.sol index cc88d0df..d7a32fca 100644 --- a/test/paymasters/WhitelistPaymaster.t.sol +++ b/test/paymasters/WhitelistPaymaster.t.sol @@ -122,4 +122,15 @@ contract WhitelistPaymasterTest is Test { vm.stopPrank(); } + + function test_RevertIf_paymasterBalanceTooLow() public { + vm.startPrank(alice); + paymaster.addWhitelistedContracts(whitelistTargets); + paymaster.addWhitelistedUsers(whitelistTargets); + vm.stopPrank(); + + // Paymaster has 0 balance, but requiredETH > 0 + vm.expectRevert(WhitelistPaymaster.PaymasterBalanceTooLow.selector); + paymaster.mock_validateAndPayGeneralFlow(charlie, charlie, 1 ether); + } }