diff --git a/.agent/rules/solidity_zksync.md b/.agent/rules/solidity_zksync.md new file mode 100644 index 0000000..7cdccfc --- /dev/null +++ b/.agent/rules/solidity_zksync.md @@ -0,0 +1,33 @@ +# Solidity & ZkSync Development Standards + +## Toolchain & Environment +- **Primary Tool**: `forge` (ZkSync fork). Use for compilation, testing, and generic scripting. +- **Secondary Tool**: `hardhat`. Use only when `forge` encounters compatibility issues (e.g., complex deployments, specific plugin needs). +- **Network Target**: ZkSync Era (Layer 2). +- **Solidity Version**: `^0.8.20` (or `0.8.24` if strictly supported by the zk-compiler). + +## Modern Solidity Best Practices +- **Safety First**: + - **Checks-Effects-Interactions (CEI)** pattern must be strictly followed. + - Use `Ownable2Step` over `Ownable` for privileged access. + - Prefer `ReentrancyGuard` for external calls where appropriate. +- **Gas & Efficiency**: + - Use **Custom Errors** (`error MyError();`) instead of `require` strings. + - Use `mapping` over arrays for membership checks where possible. + - Minimize on-chain storage; use events for off-chain indexing. + +## Testing Standards +- **Framework**: Foundry (Forge). +- **Methodology**: + - **Unit Tests**: Comprehensive coverage for all functions. + - **Fuzz Testing**: Required for arithmetic and purely functional logic. + - **Invariant Testing**: Define invariants for stateful system properties. +- **Naming Convention**: + - `test_Description` + - `testFuzz_Description` + - `test_RevertIf_Condition` + +## ZkSync Specifics +- **System Contracts**: Be aware of ZkSync system contracts (e.g., `ContractDeployer`, `L2EthToken`) when interacting with low-level features. +- **Gas Model**: Account for ZkSync's different gas metering if performing low-level optimization. +- **Compiler Differences**: Be mindful of differences between `solc` and `zksolc` (e.g., `create2` address derivation). diff --git a/.cspell.json b/.cspell.json index c990957..936fcf3 100644 --- a/.cspell.json +++ b/.cspell.json @@ -60,6 +60,15 @@ "Frontends", "testuser", "testhandle", - "douglasacost" + "douglasacost", + "IBEACON", + "AABBCCDD", + "SSTORE", + "Permissionless", + "Reentrancy", + "SFID", + "EXTCODECOPY", + "solady", + "SLOAD" ] } diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..7cdccfc --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,33 @@ +# Solidity & ZkSync Development Standards + +## Toolchain & Environment +- **Primary Tool**: `forge` (ZkSync fork). Use for compilation, testing, and generic scripting. +- **Secondary Tool**: `hardhat`. Use only when `forge` encounters compatibility issues (e.g., complex deployments, specific plugin needs). +- **Network Target**: ZkSync Era (Layer 2). +- **Solidity Version**: `^0.8.20` (or `0.8.24` if strictly supported by the zk-compiler). + +## Modern Solidity Best Practices +- **Safety First**: + - **Checks-Effects-Interactions (CEI)** pattern must be strictly followed. + - Use `Ownable2Step` over `Ownable` for privileged access. + - Prefer `ReentrancyGuard` for external calls where appropriate. +- **Gas & Efficiency**: + - Use **Custom Errors** (`error MyError();`) instead of `require` strings. + - Use `mapping` over arrays for membership checks where possible. + - Minimize on-chain storage; use events for off-chain indexing. + +## Testing Standards +- **Framework**: Foundry (Forge). +- **Methodology**: + - **Unit Tests**: Comprehensive coverage for all functions. + - **Fuzz Testing**: Required for arithmetic and purely functional logic. + - **Invariant Testing**: Define invariants for stateful system properties. +- **Naming Convention**: + - `test_Description` + - `testFuzz_Description` + - `test_RevertIf_Condition` + +## ZkSync Specifics +- **System Contracts**: Be aware of ZkSync system contracts (e.g., `ContractDeployer`, `L2EthToken`) when interacting with low-level features. +- **Gas Model**: Account for ZkSync's different gas metering if performing low-level optimization. +- **Compiler Differences**: Be mindful of differences between `solc` and `zksolc` (e.g., `create2` address derivation). diff --git a/.gitmodules b/.gitmodules index 9540dda..c6c1a45 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,6 @@ [submodule "lib/era-contracts"] path = lib/era-contracts url = https://github.com/matter-labs/era-contracts +[submodule "lib/solady"] + path = lib/solady + url = https://github.com/vectorized/solady diff --git a/.vscode/settings.json b/.vscode/settings.json index 4d04fd2..8ab6c21 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,8 @@ "editor.formatOnSave": true, "[solidity]": { "editor.defaultFormatter": "JuanBlanco.solidity" + }, + "chat.tools.terminal.autoApprove": { + "forge": true } } diff --git a/foundry.lock b/foundry.lock new file mode 100644 index 0000000..7a3effd --- /dev/null +++ b/foundry.lock @@ -0,0 +1,20 @@ +{ + "lib/zksync-storage-proofs": { + "rev": "4b20401ce44c1ec966a29d893694f65db885304b" + }, + "lib/openzeppelin-contracts": { + "rev": "e4f70216d759d8e6a64144a9e1f7bbeed78e7079" + }, + "lib/solady": { + "tag": { + "name": "v0.1.26", + "rev": "acd959aa4bd04720d640bf4e6a5c71037510cc4b" + } + }, + "lib/forge-std": { + "rev": "1eea5bae12ae557d589f9f0f0edae2faa47cb262" + }, + "lib/era-contracts": { + "rev": "84d5e3716f645909e8144c7d50af9dd6dd9ded62" + } +} \ No newline at end of file diff --git a/lib/solady b/lib/solady new file mode 160000 index 0000000..acd959a --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit acd959aa4bd04720d640bf4e6a5c71037510cc4b diff --git a/remappings.txt b/remappings.txt index 1e95077..53468b3 100644 --- a/remappings.txt +++ b/remappings.txt @@ -1 +1,2 @@ -@openzeppelin=lib/openzeppelin-contracts/ \ No newline at end of file +@openzeppelin=lib/openzeppelin-contracts/ +solady/=lib/solady/src/ \ No newline at end of file diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol new file mode 100644 index 0000000..9ab862d --- /dev/null +++ b/src/swarms/FleetIdentity.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +/** + * @title FleetIdentity + * @notice Permissionless ERC-721 representing ownership of a BLE fleet. + * @dev TokenID = uint256(uint128(uuid)), guaranteeing one owner per Proximity UUID. + */ +contract FleetIdentity is ERC721 { + error InvalidUUID(); + error InvalidPaginationParams(); + error NotTokenOwner(); + + // Array to enable enumeration of all registered fleets (for SDK scanning) + bytes16[] public registeredUUIDs; + + // Mapping to quickly check if a UUID is registered (redundant with ownerOf but cheaper for specific checks) + mapping(uint256 => bool) public activeFleets; + + event FleetRegistered(address indexed owner, bytes16 indexed uuid, uint256 indexed tokenId); + event FleetBurned(address indexed owner, uint256 indexed tokenId); + + constructor() ERC721("Swarm Fleet Identity", "SFID") {} + + /// @notice Mints a new fleet NFT for the given Proximity UUID. + /// @param uuid The 16-byte Proximity UUID. + /// @return tokenId The deterministic token ID derived from `uuid`. + function registerFleet(bytes16 uuid) external returns (uint256 tokenId) { + if (uuid == bytes16(0)) { + revert InvalidUUID(); + } + + tokenId = uint256(uint128(uuid)); + + _mint(msg.sender, tokenId); + + registeredUUIDs.push(uuid); + activeFleets[tokenId] = true; + + emit FleetRegistered(msg.sender, uuid, tokenId); + } + + /// @notice Burns the fleet NFT. Caller must be the token owner. + /// @param tokenId The fleet token ID to burn. + function burn(uint256 tokenId) external { + if (ownerOf(tokenId) != msg.sender) { + revert NotTokenOwner(); + } + + activeFleets[tokenId] = false; + + _burn(tokenId); + + emit FleetBurned(msg.sender, tokenId); + } + + /// @notice Returns a paginated slice of all registered UUIDs. + /// @param offset Starting index. + /// @param limit Maximum number of entries to return. + /// @return uuids The requested UUID slice. + function getRegisteredUUIDs(uint256 offset, uint256 limit) external view returns (bytes16[] memory uuids) { + if (limit == 0) { + revert InvalidPaginationParams(); + } + + if (offset >= registeredUUIDs.length) { + return new bytes16[](0); + } + + uint256 end = offset + limit; + if (end > registeredUUIDs.length) { + end = registeredUUIDs.length; + } + + uint256 resultLen = end - offset; + uuids = new bytes16[](resultLen); + + for (uint256 i = 0; i < resultLen;) { + uuids[i] = registeredUUIDs[offset + i]; + unchecked { + ++i; + } + } + } + + /// @notice Returns the total number of registered fleets (including burned). + function getTotalFleets() external view returns (uint256) { + return registeredUUIDs.length; + } +} diff --git a/src/swarms/ServiceProvider.sol b/src/swarms/ServiceProvider.sol new file mode 100644 index 0000000..80689b9 --- /dev/null +++ b/src/swarms/ServiceProvider.sol @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +/** + * @title ServiceProvider + * @notice Permissionless ERC-721 representing ownership of a service endpoint URL. + * @dev TokenID = keccak256(url), guaranteeing one owner per URL. + */ +contract ServiceProvider is ERC721 { + error EmptyURL(); + error NotTokenOwner(); + + // Maps TokenID -> Provider URL + mapping(uint256 => string) public providerUrls; + + event ProviderRegistered(address indexed owner, string url, uint256 indexed tokenId); + event ProviderBurned(address indexed owner, uint256 indexed tokenId); + + constructor() ERC721("Swarm Service Provider", "SSV") {} + + /// @notice Mints a new provider NFT for the given URL. + /// @param url The backend service URL (must be unique). + /// @return tokenId The deterministic token ID derived from `url`. + function registerProvider(string calldata url) external returns (uint256 tokenId) { + if (bytes(url).length == 0) { + revert EmptyURL(); + } + + tokenId = uint256(keccak256(bytes(url))); + + providerUrls[tokenId] = url; + + _mint(msg.sender, tokenId); + + emit ProviderRegistered(msg.sender, url, tokenId); + } + + /// @notice Burns the provider NFT. Caller must be the token owner. + /// @param tokenId The provider token ID to burn. + function burn(uint256 tokenId) external { + if (ownerOf(tokenId) != msg.sender) { + revert NotTokenOwner(); + } + + delete providerUrls[tokenId]; + + _burn(tokenId); + + emit ProviderBurned(msg.sender, tokenId); + } +} diff --git a/src/swarms/SwarmRegistryL1.sol b/src/swarms/SwarmRegistryL1.sol new file mode 100644 index 0000000..5255c51 --- /dev/null +++ b/src/swarms/SwarmRegistryL1.sol @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +// NOTE: SSTORE2 is not compatible with ZkSync Era due to EXTCODECOPY limitation. +// For ZkSync deployment, consider using chunked storage or calldata alternatives. +import {SSTORE2} from "solady/utils/SSTORE2.sol"; +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {FleetIdentity} from "./FleetIdentity.sol"; +import {ServiceProvider} from "./ServiceProvider.sol"; + +/** + * @title SwarmRegistryL1 + * @notice Permissionless BLE swarm registry optimized for Ethereum L1 (uses SSTORE2 for filter storage). + * @dev Not compatible with ZkSync Era — use SwarmRegistryUniversal instead. + */ +contract SwarmRegistryL1 is ReentrancyGuard { + error InvalidFingerprintSize(); + error InvalidFilterSize(); + error NotFleetOwner(); + error ProviderDoesNotExist(); + error NotProviderOwner(); + error SwarmNotFound(); + error InvalidSwarmData(); + error SwarmAlreadyExists(); + error SwarmNotOrphaned(); + error SwarmOrphaned(); + + enum SwarmStatus { + REGISTERED, + ACCEPTED, + REJECTED + } + + // Internal Schema version for Tag ID construction + enum TagType { + IBEACON_PAYLOAD_ONLY, // 0x00: proxUUID || major || minor + IBEACON_INCLUDES_MAC, // 0x01: proxUUID || major || minor || MAC (Normalized) + VENDOR_ID, // 0x02: companyID || hash(vendorBytes) + GENERIC // 0x03 + + } + + struct Swarm { + uint256 fleetId; // The Fleet UUID (as uint) + uint256 providerId; // The Service Provider TokenID + address filterPointer; // SSTORE2 pointer + uint8 fingerprintSize; + TagType tagType; + SwarmStatus status; + } + + uint8 public constant MAX_FINGERPRINT_SIZE = 16; + + FleetIdentity public immutable FLEET_CONTRACT; + + ServiceProvider public immutable PROVIDER_CONTRACT; + + // SwarmID -> Swarm + mapping(uint256 => Swarm) public swarms; + + // FleetID -> List of SwarmIDs + mapping(uint256 => uint256[]) public fleetSwarms; + + // SwarmID -> index in fleetSwarms[fleetId] (for O(1) removal) + mapping(uint256 => uint256) public swarmIndexInFleet; + + event SwarmRegistered(uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner); + event SwarmStatusChanged(uint256 indexed swarmId, SwarmStatus status); + event SwarmFilterUpdated(uint256 indexed swarmId, address indexed owner, uint32 filterSize); + event SwarmProviderUpdated(uint256 indexed swarmId, uint256 indexed oldProvider, uint256 indexed newProvider); + event SwarmDeleted(uint256 indexed swarmId, uint256 indexed fleetId, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, uint256 indexed fleetId, address indexed purgedBy); + + /// @notice Derives a deterministic swarm ID. Callable off-chain to predict IDs before registration. + /// @return swarmId keccak256(fleetId, providerId, filterData) + function computeSwarmId(uint256 fleetId, uint256 providerId, bytes calldata filterData) + public + pure + returns (uint256) + { + return uint256(keccak256(abi.encode(fleetId, providerId, filterData))); + } + + constructor(address _fleetContract, address _providerContract) { + if (_fleetContract == address(0) || _providerContract == address(0)) { + revert InvalidSwarmData(); + } + FLEET_CONTRACT = FleetIdentity(_fleetContract); + PROVIDER_CONTRACT = ServiceProvider(_providerContract); + } + + /// @notice Registers a new swarm. Caller must own the fleet NFT. + /// @param fleetId Fleet token ID. + /// @param providerId Service provider token ID. + /// @param filterData XOR filter blob (1–24 576 bytes). + /// @param fingerprintSize Fingerprint width in bits (1–16). + /// @param tagType Tag identity schema. + /// @return swarmId Deterministic ID for this swarm. + function registerSwarm( + uint256 fleetId, + uint256 providerId, + bytes calldata filterData, + uint8 fingerprintSize, + TagType tagType + ) external nonReentrant returns (uint256 swarmId) { + if (fingerprintSize == 0 || fingerprintSize > MAX_FINGERPRINT_SIZE) { + revert InvalidFingerprintSize(); + } + if (filterData.length == 0 || filterData.length > 24576) { + revert InvalidFilterSize(); + } + + if (FLEET_CONTRACT.ownerOf(fleetId) != msg.sender) { + revert NotFleetOwner(); + } + if (PROVIDER_CONTRACT.ownerOf(providerId) == address(0)) { + revert ProviderDoesNotExist(); + } + + swarmId = computeSwarmId(fleetId, providerId, filterData); + + if (swarms[swarmId].filterPointer != address(0)) { + revert SwarmAlreadyExists(); + } + + Swarm storage s = swarms[swarmId]; + s.fleetId = fleetId; + s.providerId = providerId; + s.fingerprintSize = fingerprintSize; + s.tagType = tagType; + s.status = SwarmStatus.REGISTERED; + + fleetSwarms[fleetId].push(swarmId); + swarmIndexInFleet[swarmId] = fleetSwarms[fleetId].length - 1; + + s.filterPointer = SSTORE2.write(filterData); + + emit SwarmRegistered(swarmId, fleetId, providerId, msg.sender); + } + + /// @notice Approves a swarm. Caller must own the provider NFT. + /// @param swarmId The swarm to accept. + function acceptSwarm(uint256 swarmId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterPointer == address(0)) revert SwarmNotFound(); + + (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); + if (!fleetValid || !providerValid) revert SwarmOrphaned(); + + if (PROVIDER_CONTRACT.ownerOf(s.providerId) != msg.sender) { + revert NotProviderOwner(); + } + s.status = SwarmStatus.ACCEPTED; + emit SwarmStatusChanged(swarmId, SwarmStatus.ACCEPTED); + } + + /// @notice Rejects a swarm. Caller must own the provider NFT. + /// @param swarmId The swarm to reject. + function rejectSwarm(uint256 swarmId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterPointer == address(0)) revert SwarmNotFound(); + + (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); + if (!fleetValid || !providerValid) revert SwarmOrphaned(); + + if (PROVIDER_CONTRACT.ownerOf(s.providerId) != msg.sender) { + revert NotProviderOwner(); + } + s.status = SwarmStatus.REJECTED; + emit SwarmStatusChanged(swarmId, SwarmStatus.REJECTED); + } + + /// @notice Replaces the XOR filter. Resets status to REGISTERED. Caller must own the fleet NFT. + /// @param swarmId The swarm to update. + /// @param newFilterData Replacement filter blob. + function updateSwarmFilter(uint256 swarmId, bytes calldata newFilterData) external nonReentrant { + Swarm storage s = swarms[swarmId]; + if (s.filterPointer == address(0)) { + revert SwarmNotFound(); + } + if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { + revert NotFleetOwner(); + } + if (newFilterData.length == 0 || newFilterData.length > 24576) { + revert InvalidFilterSize(); + } + + s.status = SwarmStatus.REGISTERED; + + s.filterPointer = SSTORE2.write(newFilterData); + + emit SwarmFilterUpdated(swarmId, msg.sender, uint32(newFilterData.length)); + } + + /// @notice Reassigns the service provider. Resets status to REGISTERED. Caller must own the fleet NFT. + /// @param swarmId The swarm to update. + /// @param newProviderId New provider token ID. + function updateSwarmProvider(uint256 swarmId, uint256 newProviderId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterPointer == address(0)) { + revert SwarmNotFound(); + } + if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { + revert NotFleetOwner(); + } + if (PROVIDER_CONTRACT.ownerOf(newProviderId) == address(0)) { + revert ProviderDoesNotExist(); + } + + uint256 oldProvider = s.providerId; + + s.providerId = newProviderId; + + s.status = SwarmStatus.REGISTERED; + + emit SwarmProviderUpdated(swarmId, oldProvider, newProviderId); + } + + /// @notice Permanently deletes a swarm. Caller must own the fleet NFT. + /// @param swarmId The swarm to delete. + function deleteSwarm(uint256 swarmId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterPointer == address(0)) { + revert SwarmNotFound(); + } + if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { + revert NotFleetOwner(); + } + + uint256 fleetId = s.fleetId; + + _removeFromFleetSwarms(fleetId, swarmId); + + delete swarms[swarmId]; + + emit SwarmDeleted(swarmId, fleetId, msg.sender); + } + + /// @notice Returns whether the swarm's fleet and provider NFTs still exist (i.e. have not been burned). + /// @param swarmId The swarm to check. + /// @return fleetValid True if the fleet NFT exists. + /// @return providerValid True if the provider NFT exists. + function isSwarmValid(uint256 swarmId) public view returns (bool fleetValid, bool providerValid) { + Swarm storage s = swarms[swarmId]; + if (s.filterPointer == address(0)) revert SwarmNotFound(); + + try FLEET_CONTRACT.ownerOf(s.fleetId) returns (address) { + fleetValid = true; + } catch { + fleetValid = false; + } + + try PROVIDER_CONTRACT.ownerOf(s.providerId) returns (address) { + providerValid = true; + } catch { + providerValid = false; + } + } + + /// @notice Permissionless-ly removes a swarm whose fleet or provider NFT has been burned. + /// @param swarmId The orphaned swarm to purge. + function purgeOrphanedSwarm(uint256 swarmId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterPointer == address(0)) revert SwarmNotFound(); + + (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); + if (fleetValid && providerValid) revert SwarmNotOrphaned(); + + uint256 fleetId = s.fleetId; + + _removeFromFleetSwarms(fleetId, swarmId); + + delete swarms[swarmId]; + + emit SwarmPurged(swarmId, fleetId, msg.sender); + } + + /// @notice Tests tag membership against the swarm's XOR filter. + /// @param swarmId The swarm to query. + /// @param tagHash keccak256 of the tag identity bytes (caller must pre-normalize per tagType). + /// @return isValid True if the tag passes the XOR filter check. + function checkMembership(uint256 swarmId, bytes32 tagHash) external view returns (bool isValid) { + Swarm storage s = swarms[swarmId]; + if (s.filterPointer == address(0)) { + revert SwarmNotFound(); + } + + // Reject queries against orphaned swarms + (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); + if (!fleetValid || !providerValid) revert SwarmOrphaned(); + + uint256 dataLen; + address pointer = s.filterPointer; + assembly { + dataLen := extcodesize(pointer) + } + + // SSTORE2 adds 1 byte overhead (0x00), So actual data length = codeSize - 1. + if (dataLen > 0) { + unchecked { + --dataLen; + } + } + + // 2. Calculate M (number of slots) + uint256 m = (dataLen * 8) / s.fingerprintSize; + if (m == 0) return false; + + bytes32 h = tagHash; + + uint32 h1 = uint32(uint256(h)) % uint32(m); + uint32 h2 = uint32(uint256(h) >> 32) % uint32(m); + uint32 h3 = uint32(uint256(h) >> 64) % uint32(m); + + uint256 fpMask = (1 << s.fingerprintSize) - 1; + uint256 expectedFp = (uint256(h) >> 96) & fpMask; + + uint256 f1 = _readFingerprint(pointer, h1, s.fingerprintSize); + uint256 f2 = _readFingerprint(pointer, h2, s.fingerprintSize); + uint256 f3 = _readFingerprint(pointer, h3, s.fingerprintSize); + + return (f1 ^ f2 ^ f3) == expectedFp; + } + + /** + * @dev O(1) removal of a swarm from its fleet's swarm list using index tracking. + */ + function _removeFromFleetSwarms(uint256 fleetId, uint256 swarmId) internal { + uint256[] storage arr = fleetSwarms[fleetId]; + uint256 index = swarmIndexInFleet[swarmId]; + uint256 lastId = arr[arr.length - 1]; + + arr[index] = lastId; + swarmIndexInFleet[lastId] = index; + arr.pop(); + delete swarmIndexInFleet[swarmId]; + } + + /** + * @dev Reads a packed fingerprint of arbitrary bit size from SSTORE2 blob. + * @param pointer The contract address storing data. + * @param index The slot index. + * @param bits The bit size of the fingerprint. + */ + function _readFingerprint(address pointer, uint256 index, uint8 bits) internal view returns (uint256) { + uint256 bitOffset = index * bits; + uint256 startByte = bitOffset / 8; + uint256 endByte = (bitOffset + bits - 1) / 8; + + // Read raw bytes. SSTORE2 uses 0-based index relative to data. + bytes memory chunk = SSTORE2.read(pointer, startByte, endByte + 1); + + // Convert chunk to uint256 + uint256 raw; + for (uint256 i = 0; i < chunk.length;) { + raw = (raw << 8) | uint8(chunk[i]); + unchecked { + ++i; + } + } + + uint256 totalBitsRead = chunk.length * 8; + uint256 localStart = bitOffset % 8; + uint256 shiftRight = totalBitsRead - (localStart + bits); + + return (raw >> shiftRight) & ((1 << bits) - 1); + } +} diff --git a/src/swarms/SwarmRegistryUniversal.sol b/src/swarms/SwarmRegistryUniversal.sol new file mode 100644 index 0000000..3da81c0 --- /dev/null +++ b/src/swarms/SwarmRegistryUniversal.sol @@ -0,0 +1,377 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import {FleetIdentity} from "./FleetIdentity.sol"; +import {ServiceProvider} from "./ServiceProvider.sol"; + +/** + * @title SwarmRegistryUniversal + * @notice Permissionless BLE swarm registry compatible with all EVM chains (including ZkSync Era). + * @dev Uses native `bytes` storage for cross-chain compatibility. + */ +contract SwarmRegistryUniversal is ReentrancyGuard { + error InvalidFingerprintSize(); + error InvalidFilterSize(); + error NotFleetOwner(); + error ProviderDoesNotExist(); + error NotProviderOwner(); + error SwarmNotFound(); + error InvalidSwarmData(); + error FilterTooLarge(); + error SwarmAlreadyExists(); + error SwarmNotOrphaned(); + error SwarmOrphaned(); + + enum SwarmStatus { + REGISTERED, + ACCEPTED, + REJECTED + } + + enum TagType { + IBEACON_PAYLOAD_ONLY, // 0x00: proxUUID || major || minor + IBEACON_INCLUDES_MAC, // 0x01: proxUUID || major || minor || MAC (Normalized) + VENDOR_ID, // 0x02: companyID || hash(vendorBytes) + GENERIC // 0x03 + + } + + struct Swarm { + uint256 fleetId; + uint256 providerId; + uint32 filterLength; // Length of filter in bytes (max ~4GB, practically limited) + uint8 fingerprintSize; + TagType tagType; + SwarmStatus status; + } + + uint8 public constant MAX_FINGERPRINT_SIZE = 16; + + /// @notice Maximum filter size per swarm (24KB - fits in ~15M gas on cold write) + uint32 public constant MAX_FILTER_SIZE = 24576; + + FleetIdentity public immutable FLEET_CONTRACT; + + ServiceProvider public immutable PROVIDER_CONTRACT; + + /// @notice SwarmID -> Swarm metadata + mapping(uint256 => Swarm) public swarms; + + /// @notice SwarmID -> XOR filter data (stored as bytes) + mapping(uint256 => bytes) internal filterData; + + /// @notice FleetID -> List of SwarmIDs + mapping(uint256 => uint256[]) public fleetSwarms; + + /// @notice SwarmID -> index in fleetSwarms[fleetId] (for O(1) removal) + mapping(uint256 => uint256) public swarmIndexInFleet; + + event SwarmRegistered( + uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner, uint32 filterSize + ); + + event SwarmStatusChanged(uint256 indexed swarmId, SwarmStatus status); + event SwarmFilterUpdated(uint256 indexed swarmId, address indexed owner, uint32 filterSize); + event SwarmProviderUpdated(uint256 indexed swarmId, uint256 indexed oldProvider, uint256 indexed newProvider); + event SwarmDeleted(uint256 indexed swarmId, uint256 indexed fleetId, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, uint256 indexed fleetId, address indexed purgedBy); + + /// @notice Derives a deterministic swarm ID. Callable off-chain to predict IDs before registration. + /// @return swarmId keccak256(fleetId, providerId, filter) + function computeSwarmId(uint256 fleetId, uint256 providerId, bytes calldata filter) public pure returns (uint256) { + return uint256(keccak256(abi.encode(fleetId, providerId, filter))); + } + + constructor(address _fleetContract, address _providerContract) { + if (_fleetContract == address(0) || _providerContract == address(0)) { + revert InvalidSwarmData(); + } + FLEET_CONTRACT = FleetIdentity(_fleetContract); + PROVIDER_CONTRACT = ServiceProvider(_providerContract); + } + + /// @notice Registers a new swarm. Caller must own the fleet NFT. + /// @param fleetId Fleet token ID. + /// @param providerId Service provider token ID. + /// @param filter XOR filter blob (1–24 576 bytes). + /// @param fingerprintSize Fingerprint width in bits (1–16). + /// @param tagType Tag identity schema. + /// @return swarmId Deterministic ID for this swarm. + function registerSwarm( + uint256 fleetId, + uint256 providerId, + bytes calldata filter, + uint8 fingerprintSize, + TagType tagType + ) external nonReentrant returns (uint256 swarmId) { + if (fingerprintSize == 0 || fingerprintSize > MAX_FINGERPRINT_SIZE) { + revert InvalidFingerprintSize(); + } + if (filter.length == 0) { + revert InvalidFilterSize(); + } + if (filter.length > MAX_FILTER_SIZE) { + revert FilterTooLarge(); + } + + if (FLEET_CONTRACT.ownerOf(fleetId) != msg.sender) { + revert NotFleetOwner(); + } + if (PROVIDER_CONTRACT.ownerOf(providerId) == address(0)) { + revert ProviderDoesNotExist(); + } + + swarmId = computeSwarmId(fleetId, providerId, filter); + + if (swarms[swarmId].filterLength != 0) { + revert SwarmAlreadyExists(); + } + + Swarm storage s = swarms[swarmId]; + s.fleetId = fleetId; + s.providerId = providerId; + s.filterLength = uint32(filter.length); + s.fingerprintSize = fingerprintSize; + s.tagType = tagType; + s.status = SwarmStatus.REGISTERED; + + filterData[swarmId] = filter; + + fleetSwarms[fleetId].push(swarmId); + swarmIndexInFleet[swarmId] = fleetSwarms[fleetId].length - 1; + + emit SwarmRegistered(swarmId, fleetId, providerId, msg.sender, uint32(filter.length)); + } + + /// @notice Approves a swarm. Caller must own the provider NFT. + /// @param swarmId The swarm to accept. + function acceptSwarm(uint256 swarmId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterLength == 0) revert SwarmNotFound(); + + (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); + if (!fleetValid || !providerValid) revert SwarmOrphaned(); + + if (PROVIDER_CONTRACT.ownerOf(s.providerId) != msg.sender) { + revert NotProviderOwner(); + } + s.status = SwarmStatus.ACCEPTED; + emit SwarmStatusChanged(swarmId, SwarmStatus.ACCEPTED); + } + + /// @notice Rejects a swarm. Caller must own the provider NFT. + /// @param swarmId The swarm to reject. + function rejectSwarm(uint256 swarmId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterLength == 0) revert SwarmNotFound(); + + (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); + if (!fleetValid || !providerValid) revert SwarmOrphaned(); + + if (PROVIDER_CONTRACT.ownerOf(s.providerId) != msg.sender) { + revert NotProviderOwner(); + } + s.status = SwarmStatus.REJECTED; + emit SwarmStatusChanged(swarmId, SwarmStatus.REJECTED); + } + + /// @notice Replaces the XOR filter. Resets status to REGISTERED. Caller must own the fleet NFT. + /// @param swarmId The swarm to update. + /// @param newFilterData Replacement filter blob. + function updateSwarmFilter(uint256 swarmId, bytes calldata newFilterData) external nonReentrant { + Swarm storage s = swarms[swarmId]; + if (s.filterLength == 0) { + revert SwarmNotFound(); + } + if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { + revert NotFleetOwner(); + } + if (newFilterData.length == 0) { + revert InvalidFilterSize(); + } + if (newFilterData.length > MAX_FILTER_SIZE) { + revert FilterTooLarge(); + } + + s.filterLength = uint32(newFilterData.length); + s.status = SwarmStatus.REGISTERED; + filterData[swarmId] = newFilterData; + + emit SwarmFilterUpdated(swarmId, msg.sender, uint32(newFilterData.length)); + } + + /// @notice Reassigns the service provider. Resets status to REGISTERED. Caller must own the fleet NFT. + /// @param swarmId The swarm to update. + /// @param newProviderId New provider token ID. + function updateSwarmProvider(uint256 swarmId, uint256 newProviderId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterLength == 0) { + revert SwarmNotFound(); + } + if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { + revert NotFleetOwner(); + } + if (PROVIDER_CONTRACT.ownerOf(newProviderId) == address(0)) { + revert ProviderDoesNotExist(); + } + + uint256 oldProvider = s.providerId; + + // Effects — update provider and reset status + s.providerId = newProviderId; + s.status = SwarmStatus.REGISTERED; + + emit SwarmProviderUpdated(swarmId, oldProvider, newProviderId); + } + + /// @notice Permanently deletes a swarm. Caller must own the fleet NFT. + /// @param swarmId The swarm to delete. + function deleteSwarm(uint256 swarmId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterLength == 0) { + revert SwarmNotFound(); + } + if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { + revert NotFleetOwner(); + } + + uint256 fleetId = s.fleetId; + + _removeFromFleetSwarms(fleetId, swarmId); + + delete swarms[swarmId]; + delete filterData[swarmId]; + + emit SwarmDeleted(swarmId, fleetId, msg.sender); + } + + /// @notice Returns whether the swarm's fleet and provider NFTs still exist (i.e. have not been burned). + /// @param swarmId The swarm to check. + /// @return fleetValid True if the fleet NFT exists. + /// @return providerValid True if the provider NFT exists. + function isSwarmValid(uint256 swarmId) public view returns (bool fleetValid, bool providerValid) { + Swarm storage s = swarms[swarmId]; + if (s.filterLength == 0) revert SwarmNotFound(); + + try FLEET_CONTRACT.ownerOf(s.fleetId) returns (address) { + fleetValid = true; + } catch { + fleetValid = false; + } + + try PROVIDER_CONTRACT.ownerOf(s.providerId) returns (address) { + providerValid = true; + } catch { + providerValid = false; + } + } + + /// @notice Permissionless-ly removes a swarm whose fleet or provider NFT has been burned. + /// @param swarmId The orphaned swarm to purge. + function purgeOrphanedSwarm(uint256 swarmId) external { + Swarm storage s = swarms[swarmId]; + if (s.filterLength == 0) revert SwarmNotFound(); + + (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); + if (fleetValid && providerValid) revert SwarmNotOrphaned(); + + uint256 fleetId = s.fleetId; + + _removeFromFleetSwarms(fleetId, swarmId); + + delete swarms[swarmId]; + delete filterData[swarmId]; + + emit SwarmPurged(swarmId, fleetId, msg.sender); + } + + /// @notice Tests tag membership against the swarm's XOR filter. + /// @param swarmId The swarm to query. + /// @param tagHash keccak256 of the tag identity bytes (caller must pre-normalize per tagType). + /// @return isValid True if the tag passes the XOR filter check. + function checkMembership(uint256 swarmId, bytes32 tagHash) external view returns (bool isValid) { + Swarm storage s = swarms[swarmId]; + if (s.filterLength == 0) { + revert SwarmNotFound(); + } + + // Reject queries against orphaned swarms + (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); + if (!fleetValid || !providerValid) revert SwarmOrphaned(); + + bytes storage filter = filterData[swarmId]; + uint256 dataLen = s.filterLength; + + // Calculate M (number of fingerprint slots) + uint256 m = (dataLen * 8) / s.fingerprintSize; + if (m == 0) return false; + + // Derive 3 indices and expected fingerprint from hash + uint32 h1 = uint32(uint256(tagHash)) % uint32(m); + uint32 h2 = uint32(uint256(tagHash) >> 32) % uint32(m); + uint32 h3 = uint32(uint256(tagHash) >> 64) % uint32(m); + + uint256 fpMask = (1 << s.fingerprintSize) - 1; + uint256 expectedFp = (uint256(tagHash) >> 96) & fpMask; + + // Read and XOR fingerprints + uint256 f1 = _readFingerprint(filter, h1, s.fingerprintSize); + uint256 f2 = _readFingerprint(filter, h2, s.fingerprintSize); + uint256 f3 = _readFingerprint(filter, h3, s.fingerprintSize); + + return (f1 ^ f2 ^ f3) == expectedFp; + } + + /// @notice Returns the raw XOR filter bytes for a swarm. + /// @param swarmId The swarm to query. + /// @return filter The XOR filter blob. + function getFilterData(uint256 swarmId) external view returns (bytes memory filter) { + if (swarms[swarmId].filterLength == 0) { + revert SwarmNotFound(); + } + return filterData[swarmId]; + } + + /** + * @dev O(1) removal of a swarm from its fleet's swarm list using index tracking. + */ + function _removeFromFleetSwarms(uint256 fleetId, uint256 swarmId) internal { + uint256[] storage arr = fleetSwarms[fleetId]; + uint256 index = swarmIndexInFleet[swarmId]; + uint256 lastId = arr[arr.length - 1]; + + arr[index] = lastId; + swarmIndexInFleet[lastId] = index; + arr.pop(); + delete swarmIndexInFleet[swarmId]; + } + + /** + * @dev Reads a packed fingerprint from storage bytes. + * @param filter The filter bytes in storage. + * @param index The fingerprint slot index. + * @param bits The fingerprint size in bits. + */ + function _readFingerprint(bytes storage filter, uint256 index, uint8 bits) internal view returns (uint256) { + uint256 bitOffset = index * bits; + uint256 startByte = bitOffset / 8; + uint256 endByte = (bitOffset + bits - 1) / 8; + + // Read bytes and assemble into uint256 + uint256 raw; + for (uint256 i = startByte; i <= endByte;) { + raw = (raw << 8) | uint8(filter[i]); + unchecked { + ++i; + } + } + + // Extract the fingerprint bits + uint256 totalBitsRead = (endByte - startByte + 1) * 8; + uint256 localStart = bitOffset % 8; + uint256 shiftRight = totalBitsRead - (localStart + bits); + + return (raw >> shiftRight) & ((1 << bits) - 1); + } +} diff --git a/src/swarms/doc/assistant-guide.md b/src/swarms/doc/assistant-guide.md new file mode 100644 index 0000000..bbae881 --- /dev/null +++ b/src/swarms/doc/assistant-guide.md @@ -0,0 +1,205 @@ +# Swarm System Architecture & Implementation Guide + +> **Context for AI Agents**: This document outlines the architecture, constraints, and operational logic of the Swarm Smart Contract system. Use this context when modifying contracts, writing SDKs, or debugging verifiers. + +## 1. System Overview + +The Swarm System is a privacy-preserving registry for **BLE (Bluetooth Low Energy)** tag swarms. It allows Fleet Owners to manage large sets of tags (~10k-20k) and link them to Service Providers (Backend URLs) without revealing the individual identity of every tag on-chain. + +Two registry variants exist for different deployment targets: + +- **`SwarmRegistryL1`** — Ethereum L1, uses SSTORE2 (contract bytecode) for gas-efficient filter storage. Not compatible with ZkSync Era. +- **`SwarmRegistryUniversal`** — All EVM chains including ZkSync Era, uses native `bytes` storage. + +### Core Components + +| Contract | Role | Key Identity | Token | +| :--------------------------- | :------------------------- | :--------------------------------------- | :---- | +| **`FleetIdentity`** | Fleet Registry (ERC-721) | `uint256(uint128(uuid))` | SFID | +| **`ServiceProvider`** | Service Registry (ERC-721) | `keccak256(url)` | SSV | +| **`SwarmRegistryL1`** | Swarm Registry (L1) | `keccak256(fleetId, providerId, filter)` | — | +| **`SwarmRegistryUniversal`** | Swarm Registry (Universal) | `keccak256(fleetId, providerId, filter)` | — | + +All contracts are **fully permissionless** — access control is enforced through NFT ownership rather than admin roles. + +Both NFT contracts support **burning** — the token owner can call `burn(tokenId)` to destroy their NFT, which makes any swarms referencing that token _orphaned_. + +--- + +## 2. Operational Workflows + +### A. Provider & Fleet Setup (One-Time) + +1. **Service Provider**: Calls `ServiceProvider.registerProvider("https://cms.example.com")`. Receives `providerTokenId` (= `keccak256(url)`). +2. **Fleet Owner**: Calls `FleetIdentity.registerFleet(0xUUID...)`. Receives `fleetId` (= `uint256(uint128(uuid))`). + +### B. Swarm Registration (Per Batch of Tags) + +A Fleet Owner groups tags into a "Swarm" (chunk of ~10k-20k tags) and registers them. + +1. **Construct `TagID`s**: Generate the unique ID for every tag in the swarm (see "Tag Schemas" below). +2. **Build XOR Filter**: Create a binary XOR filter (Peeling Algorithm) containing the hashes of all `TagID`s. +3. **(Optional) Predict Swarm ID**: Call `computeSwarmId(fleetId, providerId, filterData)` off-chain to obtain the deterministic ID before submitting the transaction. +4. **Register**: + ```solidity + swarmRegistry.registerSwarm( + fleetId, + providerId, + filterData, + 16, // Fingerprint size in bits (1–16) + TagType.IBEACON_INCLUDES_MAC // or PAYLOAD_ONLY, VENDOR_ID, GENERIC + ); + // Returns the deterministic swarmId + ``` + +### C. Swarm Approval Flow + +After registration a swarm starts in `REGISTERED` status and requires provider approval: + +1. **Provider approves**: `swarmRegistry.acceptSwarm(swarmId)` → status becomes `ACCEPTED`. +2. **Provider rejects**: `swarmRegistry.rejectSwarm(swarmId)` → status becomes `REJECTED`. + +Only the owner of the provider NFT (`providerId`) can accept or reject. + +### D. Swarm Updates + +The fleet owner can modify a swarm at any time. Both operations reset status to `REGISTERED`, requiring fresh provider approval: + +- **Replace the XOR filter**: `swarmRegistry.updateSwarmFilter(swarmId, newFilterData)` +- **Change service provider**: `swarmRegistry.updateSwarmProvider(swarmId, newProviderId)` + +### E. Swarm Deletion + +The fleet owner can permanently remove a swarm: + +```solidity +swarmRegistry.deleteSwarm(swarmId); +``` + +### F. Orphan Detection & Cleanup + +When a fleet or provider NFT is burned, swarms referencing it become _orphaned_: + +- **Check validity**: `swarmRegistry.isSwarmValid(swarmId)` returns `(fleetValid, providerValid)`. +- **Purge**: Anyone can call `swarmRegistry.purgeOrphanedSwarm(swarmId)` to remove stale state. The caller receives the SSTORE gas refund as an incentive. +- **Guards**: `acceptSwarm`, `rejectSwarm`, and `checkMembership` all revert with `SwarmOrphaned()` if the swarm's NFTs have been burned. + +--- + +## 3. Off-Chain Logic: Filter & Tag Construction + +### Tag Schemas (`TagType`) + +The system supports different ways of constructing the unique `TagID` based on the hardware capabilities. + +**Enum: `TagType`** + +- **`0x00`: IBEACON_PAYLOAD_ONLY** + - **Format**: `UUID (16b) || Major (2b) || Minor (2b)` + - **Use Case**: When Major/Minor pairs are globally unique (standard iBeacon). +- **`0x01`: IBEACON_INCLUDES_MAC** + - **Format**: `UUID (16b) || Major (2b) || Minor (2b) || MAC (6b)` + - **Use Case**: Anti-spoofing logic or Shared Major/Minor fleets. + - **CRITICAL: MAC Normalization Rule**: + - If MAC is **Public/Static** (Address Type bits `00`): Use the **Real MAC Address**. + - If MAC is **Random/Private** (Address Type bits `01` or `11`): Replace with `FF:FF:FF:FF:FF:FF`. + - _Why?_ To support rotating privacy MACs while still validating "It's a privacy tag". +- **`0x02`: VENDOR_ID** + - **Format**: `companyID || hash(vendorBytes)` + - **Use Case**: Non-iBeacon BLE devices identified by Bluetooth SIG company ID. +- **`0x03`: GENERIC** + - **Use Case**: Catch-all for custom tag identity schemes. + +### Filter Construction (The Math) + +To verify membership on-chain, the contract uses **3-hash XOR logic**. + +1. **Input**: `h = keccak256(TagID)` (where TagID is constructed via schema above). +2. **Indices** (M = number of fingerprint slots = `filterLength * 8 / fingerprintSize`): + - `h1 = uint32(h) % M` + - `h2 = uint32(h >> 32) % M` + - `h3 = uint32(h >> 64) % M` +3. **Fingerprint**: `fp = (h >> 96) & ((1 << fingerprintSize) - 1)` +4. **Verification**: `Filter[h1] ^ Filter[h2] ^ Filter[h3] == fp` + +### Swarm ID Derivation + +Swarm IDs are **deterministic** — derived from the swarm's core identity: + +``` +swarmId = uint256(keccak256(abi.encode(fleetId, providerId, filterData))) +``` + +This means the same (fleet, provider, filter) triple always produces the same ID, and duplicate registrations revert with `SwarmAlreadyExists()`. The `computeSwarmId` function is `public pure`, so it can be called off-chain at zero cost via `eth_call`. + +--- + +## 4. Client Discovery Flow (The "Scanner" Perspective) + +A client (mobile phone or gateway) scans a BLE beacon and wants to find its owner and backend service. + +### Step 1: Scan & Detect + +- Scanner detects iBeacon: `UUID: E2C5...`, `Major: 1`, `Minor: 50`, `MAC: AA:BB...`. + +### Step 2: Identify Fleet + +- Scanner checks `FleetIdentity` contract. +- Calls `ownerOf(uint256(uint128(uuid)))` (or checks `activeFleets[tokenId]`). +- **Result**: "This beacon belongs to Fleet #42". + +### Step 3: Find Swarms + +- Scanner reads `swarmRegistry.fleetSwarms(42, index)` for each index (array of swarm IDs for that fleet). +- **Result**: List of `SwarmID`s: `[101, 102, 105]`. + +### Step 4: Membership Check (Find the specific Swarm) + +For each SwarmID in the list: + +1. **Check Schema**: Get `swarms[101].tagType`. +2. **Construct Candidate TagHash**: + - If `IBEACON_INCLUDES_MAC`: Check MAC byte. If Random, use `FF...FF`. + - Buffer = `UUID + Major + Minor + (NormalizedMAC)`. + - `hash = keccak256(Buffer)`. +3. **Verify**: + - Call `swarmRegistry.checkMembership(101, hash)`. + - Reverts with `SwarmOrphaned()` if the fleet or provider NFT has been burned. +4. **Result**: + - If `true`: **Found it!** This tag is in Swarm 101. + - If `false`: Try next swarm. + +### Step 5: Service Discovery + +Once Membership is confirmed (e.g., in Swarm 101): + +1. Get `swarms[101].providerId` (e.g., Provider #99). +2. Call `ServiceProvider.providerUrls(99)`. +3. **Result**: `"https://api.acme-tracking.com"`. +4. **Check Status**: `swarms[101].status`. + - If `ACCEPTED` (1): Safe to connect. + - If `REGISTERED` (0): Provider has not yet approved — use with caution. + - If `REJECTED` (2): Do not connect. + +--- + +## 5. Storage & Deletion Notes + +### SwarmRegistryL1 (SSTORE2) + +- Filter data is stored as **immutable contract bytecode** via SSTORE2. +- On `deleteSwarm` / `purgeOrphanedSwarm`, the struct is cleared but the deployed bytecode **cannot be erased** (accepted trade-off of the SSTORE2 pattern). + +### SwarmRegistryUniversal (native bytes) + +- Filter data is stored in a `mapping(uint256 => bytes)`. +- On `deleteSwarm` / `purgeOrphanedSwarm`, both the struct and the filter bytes are fully deleted (`delete filterData[swarmId]`), reclaiming storage. +- Exposes `getFilterData(swarmId)` for off-chain filter retrieval. + +### Deletion Performance + +Both registries use an **O(1) swap-and-pop** strategy for removing swarms from the `fleetSwarms` array, tracked via the `swarmIndexInFleet` mapping. + +--- + +**Note**: This architecture ensures that a scanner can go from **Raw Signal** → **Verified Service URL** entirely on-chain (data-wise), without a centralized indexer, while privacy of the 10,000 other tags in the swarm is preserved. diff --git a/src/swarms/doc/graph-architecture.md b/src/swarms/doc/graph-architecture.md new file mode 100644 index 0000000..fba222b --- /dev/null +++ b/src/swarms/doc/graph-architecture.md @@ -0,0 +1,107 @@ +# Swarm System — Contract Architecture + +```mermaid +graph TB + subgraph NFTs["Identity Layer (ERC-721)"] + FI["FleetIdentity
SFID
tokenId = uint128(uuid)"] + SP["ServiceProvider
SSV
tokenId = keccak256(url)"] + end + + subgraph Registries["Registry Layer"] + L1["SwarmRegistryL1
SSTORE2 filter storage
Ethereum L1 only"] + UNI["SwarmRegistryUniversal
native bytes storage
All EVM chains"] + end + + subgraph Actors + FO(("Fleet
Owner")) + PRV(("Service
Provider")) + ANY(("Anyone
(Scanner / Purger)")) + end + + FO -- "registerFleet(uuid)" --> FI + FO -- "registerSwarm / update / delete" --> L1 + FO -- "registerSwarm / update / delete" --> UNI + PRV -- "registerProvider(url)" --> SP + PRV -- "acceptSwarm / rejectSwarm" --> L1 + PRV -- "acceptSwarm / rejectSwarm" --> UNI + ANY -- "checkMembership / purgeOrphanedSwarm" --> L1 + ANY -- "checkMembership / purgeOrphanedSwarm" --> UNI + + L1 -. "ownerOf(fleetId)" .-> FI + L1 -. "ownerOf(providerId)" .-> SP + UNI -. "ownerOf(fleetId)" .-> FI + UNI -. "ownerOf(providerId)" .-> SP + + style FI fill:#4a9eff,color:#fff + style SP fill:#4a9eff,color:#fff + style L1 fill:#ff9f43,color:#fff + style UNI fill:#ff9f43,color:#fff + style FO fill:#2ecc71,color:#fff + style PRV fill:#2ecc71,color:#fff + style ANY fill:#95a5a6,color:#fff +``` + +## Swarm Data Model + +```mermaid +classDiagram + class FleetIdentity { + +bytes16[] registeredUUIDs + +mapping activeFleets + +registerFleet(uuid) tokenId + +burn(tokenId) + +getRegisteredUUIDs(offset, limit) + +getTotalFleets() + } + + class ServiceProvider { + +mapping providerUrls + +registerProvider(url) tokenId + +burn(tokenId) + } + + class SwarmRegistry { + +mapping swarms + +mapping fleetSwarms + +mapping swarmIndexInFleet + +computeSwarmId(fleetId, providerId, filter) swarmId + +registerSwarm(fleetId, providerId, filter, fpSize, tagType) swarmId + +acceptSwarm(swarmId) + +rejectSwarm(swarmId) + +updateSwarmFilter(swarmId, newFilter) + +updateSwarmProvider(swarmId, newProviderId) + +deleteSwarm(swarmId) + +isSwarmValid(swarmId) fleetValid, providerValid + +purgeOrphanedSwarm(swarmId) + +checkMembership(swarmId, tagHash) bool + } + + class Swarm { + uint256 fleetId + uint256 providerId + uint8 fingerprintSize + TagType tagType + SwarmStatus status + } + + class SwarmStatus { + <> + REGISTERED + ACCEPTED + REJECTED + } + + class TagType { + <> + IBEACON_PAYLOAD_ONLY + IBEACON_INCLUDES_MAC + VENDOR_ID + GENERIC + } + + SwarmRegistry --> FleetIdentity : validates ownership + SwarmRegistry --> ServiceProvider : validates ownership + SwarmRegistry *-- Swarm : stores + Swarm --> SwarmStatus + Swarm --> TagType +``` diff --git a/src/swarms/doc/sequence-discovery.md b/src/swarms/doc/sequence-discovery.md new file mode 100644 index 0000000..ac5e369 --- /dev/null +++ b/src/swarms/doc/sequence-discovery.md @@ -0,0 +1,76 @@ +# Client Discovery Sequence + +## Full Discovery Flow: BLE Signal → Service URL + +```mermaid +sequenceDiagram + actor SC as Scanner (Client) + participant FI as FleetIdentity + participant SR as SwarmRegistry + participant SP as ServiceProvider + + Note over SC: Detects iBeacon:
UUID, Major, Minor, MAC + + rect rgb(240, 248, 255) + Note right of SC: Step 1 — Identify fleet + SC ->>+ FI: ownerOf(uint128(uuid)) + FI -->>- SC: fleet owner address (fleet exists ✓) + end + + rect rgb(255, 248, 240) + Note right of SC: Step 2 — Enumerate swarms + SC ->>+ SR: fleetSwarms(fleetId, 0) + SR -->>- SC: swarmId_0 + SC ->>+ SR: fleetSwarms(fleetId, 1) + SR -->>- SC: swarmId_1 + Note over SC: ... iterate until revert (end of array) + end + + rect rgb(240, 255, 240) + Note right of SC: Step 3 — Find matching swarm + Note over SC: Read swarms[swarmId_0].tagType + Note over SC: Construct tagId per schema:
UUID || Major || Minor [|| MAC] + Note over SC: tagHash = keccak256(tagId) + SC ->>+ SR: checkMembership(swarmId_0, tagHash) + SR -->>- SC: false (not in this swarm) + + SC ->>+ SR: checkMembership(swarmId_1, tagHash) + SR -->>- SC: true ✓ (tag found!) + end + + rect rgb(248, 240, 255) + Note right of SC: Step 4 — Resolve service URL + SC ->>+ SR: swarms(swarmId_1) + SR -->>- SC: { providerId, status: ACCEPTED, ... } + SC ->>+ SP: providerUrls(providerId) + SP -->>- SC: "https://api.acme-tracking.com" + end + + Note over SC: Connect to service URL ✓ +``` + +## Tag Hash Construction by TagType + +```mermaid +flowchart TD + A[Read swarm.tagType] --> B{TagType?} + + B -->|IBEACON_PAYLOAD_ONLY| C["tagId = UUID ∥ Major ∥ Minor
(20 bytes)"] + B -->|IBEACON_INCLUDES_MAC| D{MAC type?} + B -->|VENDOR_ID| E["tagId = companyID ∥ hash(vendorBytes)"] + B -->|GENERIC| F["tagId = custom scheme"] + + D -->|Public/Static| G["tagId = UUID ∥ Major ∥ Minor ∥ realMAC
(26 bytes)"] + D -->|Random/Private| H["tagId = UUID ∥ Major ∥ Minor ∥ FF:FF:FF:FF:FF:FF
(26 bytes)"] + + C --> I["tagHash = keccak256(tagId)"] + G --> I + H --> I + E --> I + F --> I + + I --> J["checkMembership(swarmId, tagHash)"] + + style I fill:#4a9eff,color:#fff + style J fill:#2ecc71,color:#fff +``` diff --git a/src/swarms/doc/sequence-lifecycle.md b/src/swarms/doc/sequence-lifecycle.md new file mode 100644 index 0000000..12758ec --- /dev/null +++ b/src/swarms/doc/sequence-lifecycle.md @@ -0,0 +1,111 @@ +# Swarm Lifecycle: Updates, Deletion & Orphan Cleanup + +## Swarm Status State Machine + +```mermaid +stateDiagram-v2 + [*] --> REGISTERED : registerSwarm() + + REGISTERED --> ACCEPTED : acceptSwarm()
(provider owner) + REGISTERED --> REJECTED : rejectSwarm()
(provider owner) + + ACCEPTED --> REGISTERED : updateSwarmFilter()
updateSwarmProvider()
(fleet owner) + REJECTED --> REGISTERED : updateSwarmFilter()
updateSwarmProvider()
(fleet owner) + + REGISTERED --> [*] : deleteSwarm() / purge + ACCEPTED --> [*] : deleteSwarm() / purge + REJECTED --> [*] : deleteSwarm() / purge +``` + +## Update Flow (Fleet Owner) + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant SR as SwarmRegistry + participant FI as FleetIdentity + + rect rgb(255, 248, 240) + Note right of FO: Update XOR filter + FO ->>+ SR: updateSwarmFilter(swarmId, newFilter) + SR ->>+ FI: ownerOf(fleetId) + FI -->>- SR: msg.sender ✓ + Note over SR: Write new filter data + Note over SR: status → REGISTERED + SR -->>- FO: ✓ (requires provider re-approval) + end + + rect rgb(240, 248, 255) + Note right of FO: Update service provider + FO ->>+ SR: updateSwarmProvider(swarmId, newProviderId) + SR ->>+ FI: ownerOf(fleetId) + FI -->>- SR: msg.sender ✓ + Note over SR: providerId → newProviderId + Note over SR: status → REGISTERED + SR -->>- FO: ✓ (requires new provider approval) + end +``` + +## Deletion (Fleet Owner) + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant SR as SwarmRegistry + participant FI as FleetIdentity + + FO ->>+ SR: deleteSwarm(swarmId) + SR ->>+ FI: ownerOf(fleetId) + FI -->>- SR: msg.sender ✓ + Note over SR: Remove from fleetSwarms[] (O(1) swap-and-pop) + Note over SR: delete swarms[swarmId] + Note over SR: delete filterData[swarmId] (Universal only) + SR -->>- FO: ✓ SwarmDeleted event +``` + +## Orphan Detection & Permissionless Cleanup + +```mermaid +sequenceDiagram + actor Owner as NFT Owner + actor Purger as Anyone + participant NFT as FleetIdentity / ServiceProvider + participant SR as SwarmRegistry + + rect rgb(255, 240, 240) + Note right of Owner: NFT owner burns their token + Owner ->>+ NFT: burn(tokenId) + NFT -->>- Owner: ✓ token destroyed + Note over SR: Swarms referencing this token
are now orphaned (lazy invalidation) + end + + rect rgb(255, 248, 240) + Note right of Purger: Anyone checks validity + Purger ->>+ SR: isSwarmValid(swarmId) + SR ->>+ NFT: ownerOf(fleetId) + NFT -->>- SR: ❌ reverts (burned) + SR -->>- Purger: (false, true) — fleet invalid + end + + rect rgb(240, 255, 240) + Note right of Purger: Anyone purges the orphan + Purger ->>+ SR: purgeOrphanedSwarm(swarmId) + Note over SR: Confirms at least one NFT is burned + Note over SR: Remove from fleetSwarms[] (O(1)) + Note over SR: delete swarms[swarmId] + Note over SR: Gas refund → Purger + SR -->>- Purger: ✓ SwarmPurged event + end +``` + +## Orphan Guards (Automatic Rejection) + +```mermaid +flowchart LR + A[acceptSwarm /
rejectSwarm /
checkMembership] --> B{isSwarmValid?} + B -->|Both NFTs exist| C[Proceed normally] + B -->|Fleet or Provider burned| D["❌ revert SwarmOrphaned()"] + + style D fill:#e74c3c,color:#fff + style C fill:#2ecc71,color:#fff +``` diff --git a/src/swarms/doc/sequence-registration.md b/src/swarms/doc/sequence-registration.md new file mode 100644 index 0000000..1058340 --- /dev/null +++ b/src/swarms/doc/sequence-registration.md @@ -0,0 +1,74 @@ +# Swarm Registration & Approval Sequence + +## One-Time Setup + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + actor PRV as Service Provider + participant FI as FleetIdentity + participant SP as ServiceProvider + + Note over FO, SP: One-time setup (independent, any order) + + FO ->>+ FI: registerFleet(uuid) + FI -->>- FO: fleetId = uint128(uuid) + + PRV ->>+ SP: registerProvider(url) + SP -->>- PRV: providerId = keccak256(url) +``` + +## Swarm Registration & Approval + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + actor PRV as Provider Owner + participant SR as SwarmRegistry + participant FI as FleetIdentity + participant SP as ServiceProvider + + Note over FO: Build XOR filter off-chain
from tag set (Peeling Algorithm) + + rect rgb(240, 248, 255) + Note right of FO: Registration (fleet owner) + FO ->>+ SR: registerSwarm(fleetId, providerId, filter, fpSize, tagType) + SR ->>+ FI: ownerOf(fleetId) + FI -->>- SR: msg.sender ✓ + SR ->>+ SP: ownerOf(providerId) + SP -->>- SR: address ✓ (exists) + Note over SR: swarmId = keccak256(fleetId, providerId, filter) + Note over SR: status = REGISTERED + SR -->>- FO: swarmId + end + + rect rgb(240, 255, 240) + Note right of PRV: Approval (provider owner) + alt Provider approves + PRV ->>+ SR: acceptSwarm(swarmId) + SR ->>+ SP: ownerOf(providerId) + SP -->>- SR: msg.sender ✓ + Note over SR: status = ACCEPTED + SR -->>- PRV: ✓ + else Provider rejects + PRV ->>+ SR: rejectSwarm(swarmId) + SR ->>+ SP: ownerOf(providerId) + SP -->>- SR: msg.sender ✓ + Note over SR: status = REJECTED + SR -->>- PRV: ✓ + end + end +``` + +## Duplicate Prevention + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant SR as SwarmRegistry + + FO ->>+ SR: registerSwarm(fleetId, providerId, sameFilter, ...) + Note over SR: swarmId = keccak256(fleetId, providerId, sameFilter) + Note over SR: swarms[swarmId] already exists + SR -->>- FO: ❌ revert SwarmAlreadyExists() +``` diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol new file mode 100644 index 0000000..b7122fa --- /dev/null +++ b/test/FleetIdentity.t.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/swarms/FleetIdentity.sol"; + +contract FleetIdentityTest is Test { + FleetIdentity fleet; + + address alice = address(0xA); + address 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")); + + event FleetRegistered(address indexed owner, bytes16 indexed uuid, uint256 indexed tokenId); + event FleetBurned(address indexed owner, uint256 indexed tokenId); + + function setUp() public { + fleet = new FleetIdentity(); + } + + // ============================== + // registerFleet + // ============================== + + function test_registerFleet_mintsAndStoresUUID() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(tokenId, uint256(uint128(UUID_1))); + assertTrue(fleet.activeFleets(tokenId)); + assertEq(fleet.getTotalFleets(), 1); + assertEq(fleet.registeredUUIDs(0), UUID_1); + } + + function test_registerFleet_deterministicTokenId() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + assertEq(tokenId, uint256(uint128(UUID_1))); + } + + function test_registerFleet_emitsEvent() public { + uint256 expectedTokenId = uint256(uint128(UUID_1)); + + vm.expectEmit(true, true, true, true); + emit FleetRegistered(alice, UUID_1, expectedTokenId); + + vm.prank(alice); + fleet.registerFleet(UUID_1); + } + + function test_registerFleet_multipleFleetsDifferentOwners() public { + vm.prank(alice); + fleet.registerFleet(UUID_1); + + vm.prank(bob); + fleet.registerFleet(UUID_2); + + assertEq(fleet.getTotalFleets(), 2); + assertEq(fleet.ownerOf(uint256(uint128(UUID_1))), alice); + assertEq(fleet.ownerOf(uint256(uint128(UUID_2))), bob); + } + + function test_RevertIf_registerFleet_zeroUUID() public { + vm.prank(alice); + vm.expectRevert(FleetIdentity.InvalidUUID.selector); + fleet.registerFleet(bytes16(0)); + } + + function test_RevertIf_registerFleet_duplicateUUID() public { + vm.prank(alice); + fleet.registerFleet(UUID_1); + + vm.prank(bob); + vm.expectRevert(); // ERC721: token already minted + fleet.registerFleet(UUID_1); + } + + // ============================== + // getRegisteredUUIDs (pagination) + // ============================== + + function test_getRegisteredUUIDs_returnsCorrectPage() public { + vm.startPrank(alice); + fleet.registerFleet(UUID_1); + fleet.registerFleet(UUID_2); + fleet.registerFleet(UUID_3); + vm.stopPrank(); + + bytes16[] memory page = fleet.getRegisteredUUIDs(0, 2); + assertEq(page.length, 2); + assertEq(page[0], UUID_1); + assertEq(page[1], UUID_2); + } + + function test_getRegisteredUUIDs_lastPage() public { + vm.startPrank(alice); + fleet.registerFleet(UUID_1); + fleet.registerFleet(UUID_2); + fleet.registerFleet(UUID_3); + vm.stopPrank(); + + bytes16[] memory page = fleet.getRegisteredUUIDs(2, 10); + assertEq(page.length, 1); + assertEq(page[0], UUID_3); + } + + function test_getRegisteredUUIDs_offsetBeyondLength() public { + vm.prank(alice); + fleet.registerFleet(UUID_1); + + bytes16[] memory page = fleet.getRegisteredUUIDs(100, 5); + assertEq(page.length, 0); + } + + function test_RevertIf_getRegisteredUUIDs_zeroLimit() public { + vm.expectRevert(FleetIdentity.InvalidPaginationParams.selector); + fleet.getRegisteredUUIDs(0, 0); + } + + // ============================== + // getTotalFleets + // ============================== + + function test_getTotalFleets_empty() public view { + assertEq(fleet.getTotalFleets(), 0); + } + + function test_getTotalFleets_incrementsOnRegister() public { + vm.startPrank(alice); + fleet.registerFleet(UUID_1); + assertEq(fleet.getTotalFleets(), 1); + + fleet.registerFleet(UUID_2); + assertEq(fleet.getTotalFleets(), 2); + vm.stopPrank(); + } + + // ============================== + // activeFleets mapping + // ============================== + + function test_activeFleets_falseByDefault() public view { + assertFalse(fleet.activeFleets(12345)); + } + + function test_activeFleets_trueAfterRegister() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + assertTrue(fleet.activeFleets(tokenId)); + } + + // ============================== + // Fuzz Tests + // ============================== + + function testFuzz_registerFleet_anyValidUUID(bytes16 uuid) public { + vm.assume(uuid != bytes16(0)); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(uuid); + + assertEq(tokenId, uint256(uint128(uuid))); + assertEq(fleet.ownerOf(tokenId), alice); + assertTrue(fleet.activeFleets(tokenId)); + } + + function testFuzz_getRegisteredUUIDs_boundsHandling(uint256 offset, uint256 limit) public { + // Register 3 fleets + vm.startPrank(alice); + fleet.registerFleet(UUID_1); + fleet.registerFleet(UUID_2); + fleet.registerFleet(UUID_3); + vm.stopPrank(); + + // limit=0 always reverts + if (limit == 0) { + vm.expectRevert(FleetIdentity.InvalidPaginationParams.selector); + fleet.getRegisteredUUIDs(offset, limit); + return; + } + + bytes16[] memory result = fleet.getRegisteredUUIDs(offset, limit); + + if (offset >= 3) { + assertEq(result.length, 0); + } else { + uint256 expectedLen = offset + limit > 3 ? 3 - offset : limit; + assertEq(result.length, expectedLen); + } + } + + // ============================== + // burn + // ============================== + + function test_burn_setsActiveFleetsFalse() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + assertTrue(fleet.activeFleets(tokenId)); + + vm.prank(alice); + fleet.burn(tokenId); + assertFalse(fleet.activeFleets(tokenId)); + } + + function test_burn_emitsEvent() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + vm.expectEmit(true, true, true, true); + emit FleetBurned(alice, tokenId); + + vm.prank(alice); + fleet.burn(tokenId); + } + + function test_burn_ownerOfRevertsAfterBurn() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + vm.prank(alice); + fleet.burn(tokenId); + + vm.expectRevert(); + fleet.ownerOf(tokenId); + } + + function test_RevertIf_burn_notOwner() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.burn(tokenId); + } + + function test_RevertIf_burn_nonexistentToken() public { + vm.prank(alice); + vm.expectRevert(); // ownerOf reverts for nonexistent token + fleet.burn(12345); + } + + function testFuzz_burn_anyValidUUID(bytes16 uuid) public { + vm.assume(uuid != bytes16(0)); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(uuid); + + vm.prank(alice); + fleet.burn(tokenId); + + assertFalse(fleet.activeFleets(tokenId)); + vm.expectRevert(); + fleet.ownerOf(tokenId); + } +} diff --git a/test/ServiceProvider.t.sol b/test/ServiceProvider.t.sol new file mode 100644 index 0000000..9672dd1 --- /dev/null +++ b/test/ServiceProvider.t.sol @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/swarms/ServiceProvider.sol"; + +contract ServiceProviderTest is Test { + ServiceProvider provider; + + address alice = address(0xA); + address bob = address(0xB); + + string constant URL_1 = "https://backend.swarm.example.com/api/v1"; + string constant URL_2 = "https://relay.nodle.network:8443"; + string constant URL_3 = "https://provider.third.io"; + + event ProviderRegistered(address indexed owner, string url, uint256 indexed tokenId); + event ProviderBurned(address indexed owner, uint256 indexed tokenId); + + function setUp() public { + provider = new ServiceProvider(); + } + + // ============================== + // registerProvider + // ============================== + + function test_registerProvider_mintsAndStoresURL() public { + vm.prank(alice); + uint256 tokenId = provider.registerProvider(URL_1); + + assertEq(provider.ownerOf(tokenId), alice); + assertEq(keccak256(bytes(provider.providerUrls(tokenId))), keccak256(bytes(URL_1))); + } + + function test_registerProvider_deterministicTokenId() public { + vm.prank(alice); + uint256 tokenId = provider.registerProvider(URL_1); + + assertEq(tokenId, uint256(keccak256(bytes(URL_1)))); + } + + function test_registerProvider_emitsEvent() public { + uint256 expectedTokenId = uint256(keccak256(bytes(URL_1))); + + vm.expectEmit(true, true, true, true); + emit ProviderRegistered(alice, URL_1, expectedTokenId); + + vm.prank(alice); + provider.registerProvider(URL_1); + } + + function test_registerProvider_multipleProviders() public { + vm.prank(alice); + uint256 id1 = provider.registerProvider(URL_1); + + vm.prank(bob); + uint256 id2 = provider.registerProvider(URL_2); + + assertEq(provider.ownerOf(id1), alice); + assertEq(provider.ownerOf(id2), bob); + assertTrue(id1 != id2); + } + + function test_RevertIf_registerProvider_emptyURL() public { + vm.prank(alice); + vm.expectRevert(ServiceProvider.EmptyURL.selector); + provider.registerProvider(""); + } + + function test_RevertIf_registerProvider_duplicateURL() public { + vm.prank(alice); + provider.registerProvider(URL_1); + + vm.prank(bob); + vm.expectRevert(); // ERC721: token already minted + provider.registerProvider(URL_1); + } + + // ============================== + // burn + // ============================== + + function test_burn_deletesURLAndToken() public { + vm.prank(alice); + uint256 tokenId = provider.registerProvider(URL_1); + + vm.prank(alice); + provider.burn(tokenId); + + // URL mapping cleared + assertEq(bytes(provider.providerUrls(tokenId)).length, 0); + + // Token no longer exists + vm.expectRevert(); // ownerOf reverts for non-existent token + provider.ownerOf(tokenId); + } + + function test_burn_emitsEvent() public { + vm.prank(alice); + uint256 tokenId = provider.registerProvider(URL_1); + + vm.expectEmit(true, true, true, true); + emit ProviderBurned(alice, tokenId); + + vm.prank(alice); + provider.burn(tokenId); + } + + function test_RevertIf_burn_notOwner() public { + vm.prank(alice); + uint256 tokenId = provider.registerProvider(URL_1); + + vm.prank(bob); + vm.expectRevert(ServiceProvider.NotTokenOwner.selector); + provider.burn(tokenId); + } + + function test_burn_allowsReregistration() public { + vm.prank(alice); + uint256 tokenId = provider.registerProvider(URL_1); + + vm.prank(alice); + provider.burn(tokenId); + + // Same URL can now be registered by someone else + vm.prank(bob); + uint256 newTokenId = provider.registerProvider(URL_1); + + assertEq(newTokenId, tokenId); // Same deterministic ID + assertEq(provider.ownerOf(newTokenId), bob); + } + + // ============================== + // Fuzz Tests + // ============================== + + function testFuzz_registerProvider_anyValidURL(string calldata url) public { + vm.assume(bytes(url).length > 0); + + vm.prank(alice); + uint256 tokenId = provider.registerProvider(url); + + assertEq(tokenId, uint256(keccak256(bytes(url)))); + assertEq(provider.ownerOf(tokenId), alice); + } + + function testFuzz_burn_onlyOwner(address caller) public { + vm.assume(caller != alice); + vm.assume(caller != address(0)); + + vm.prank(alice); + uint256 tokenId = provider.registerProvider(URL_1); + + vm.prank(caller); + vm.expectRevert(ServiceProvider.NotTokenOwner.selector); + provider.burn(tokenId); + } +} diff --git a/test/SwarmRegistryL1.t.sol b/test/SwarmRegistryL1.t.sol new file mode 100644 index 0000000..816186b --- /dev/null +++ b/test/SwarmRegistryL1.t.sol @@ -0,0 +1,1004 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/swarms/SwarmRegistryL1.sol"; +import "../src/swarms/FleetIdentity.sol"; +import "../src/swarms/ServiceProvider.sol"; + +contract SwarmRegistryL1Test is Test { + SwarmRegistryL1 swarmRegistry; + FleetIdentity fleetContract; + ServiceProvider providerContract; + + address fleetOwner = address(0x1); + address providerOwner = address(0x2); + address caller = address(0x3); + + event SwarmRegistered(uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner); + event SwarmStatusChanged(uint256 indexed swarmId, SwarmRegistryL1.SwarmStatus status); + event SwarmFilterUpdated(uint256 indexed swarmId, address indexed owner, uint32 filterSize); + event SwarmProviderUpdated(uint256 indexed swarmId, uint256 indexed oldProvider, uint256 indexed newProvider); + event SwarmDeleted(uint256 indexed swarmId, uint256 indexed fleetId, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, uint256 indexed fleetId, address indexed purgedBy); + + function setUp() public { + fleetContract = new FleetIdentity(); + providerContract = new ServiceProvider(); + swarmRegistry = new SwarmRegistryL1(address(fleetContract), address(providerContract)); + } + + // ============================== + // Helpers + // ============================== + + function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { + vm.prank(owner); + return fleetContract.registerFleet(bytes16(keccak256(seed))); + } + + function _registerProvider(address owner, string memory url) internal returns (uint256) { + vm.prank(owner); + return providerContract.registerProvider(url); + } + + function _registerSwarm( + address owner, + uint256 fleetId, + uint256 providerId, + bytes memory filter, + uint8 fpSize, + SwarmRegistryL1.TagType tagType + ) internal returns (uint256) { + vm.prank(owner); + return swarmRegistry.registerSwarm(fleetId, providerId, filter, fpSize, tagType); + } + + function getExpectedValues(bytes memory tagId, uint256 m, uint8 fpSize) + public + pure + returns (uint32 h1, uint32 h2, uint32 h3, uint256 fp) + { + bytes32 h = keccak256(tagId); + h1 = uint32(uint256(h)) % uint32(m); + h2 = uint32(uint256(h) >> 32) % uint32(m); + h3 = uint32(uint256(h) >> 64) % uint32(m); + uint256 fpMask = (1 << fpSize) - 1; + fp = (uint256(h) >> 96) & fpMask; + } + + function _write16Bit(bytes memory data, uint256 slotIndex, uint16 value) internal pure { + uint256 bitOffset = slotIndex * 16; + uint256 byteOffset = bitOffset / 8; + data[byteOffset] = bytes1(uint8(value >> 8)); + data[byteOffset + 1] = bytes1(uint8(value)); + } + + function _write8Bit(bytes memory data, uint256 slotIndex, uint8 value) internal pure { + data[slotIndex] = bytes1(value); + } + + // ============================== + // Constructor + // ============================== + + function test_constructor_setsImmutables() public view { + assertEq(address(swarmRegistry.FLEET_CONTRACT()), address(fleetContract)); + assertEq(address(swarmRegistry.PROVIDER_CONTRACT()), address(providerContract)); + } + + function test_RevertIf_constructor_zeroFleetAddress() public { + vm.expectRevert(SwarmRegistryL1.InvalidSwarmData.selector); + new SwarmRegistryL1(address(0), address(providerContract)); + } + + function test_RevertIf_constructor_zeroProviderAddress() public { + vm.expectRevert(SwarmRegistryL1.InvalidSwarmData.selector); + new SwarmRegistryL1(address(fleetContract), address(0)); + } + + function test_RevertIf_constructor_bothZero() public { + vm.expectRevert(SwarmRegistryL1.InvalidSwarmData.selector); + new SwarmRegistryL1(address(0), address(0)); + } + + // ============================== + // registerSwarm — happy path + // ============================== + + function test_registerSwarm_basicFlow() public { + uint256 fleetId = _registerFleet(fleetOwner, "my-fleet"); + uint256 providerId = _registerProvider(providerOwner, "https://api.example.com"); + + uint256 swarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(100), 16, SwarmRegistryL1.TagType.IBEACON_INCLUDES_MAC + ); + + // Swarm ID is deterministic hash of (fleetId, providerId, filter) + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, new bytes(100)); + assertEq(swarmId, expectedId); + } + + function test_registerSwarm_storesMetadataCorrectly() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.VENDOR_ID); + + ( + uint256 storedFleetId, + uint256 storedProviderId, + address filterPointer, + uint8 storedFpSize, + SwarmRegistryL1.TagType storedTagType, + SwarmRegistryL1.SwarmStatus storedStatus + ) = swarmRegistry.swarms(swarmId); + + assertEq(storedFleetId, fleetId); + assertEq(storedProviderId, providerId); + assertTrue(filterPointer != address(0)); + assertEq(storedFpSize, 8); + assertEq(uint8(storedTagType), uint8(SwarmRegistryL1.TagType.VENDOR_ID)); + assertEq(uint8(storedStatus), uint8(SwarmRegistryL1.SwarmStatus.REGISTERED)); + } + + function test_registerSwarm_deterministicId() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(32); + + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + + uint256 swarmId = _registerSwarm(fleetOwner, fleetId, providerId, filter, 8, SwarmRegistryL1.TagType.GENERIC); + assertEq(swarmId, expectedId); + } + + function test_RevertIf_registerSwarm_duplicateSwarm() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.SwarmAlreadyExists.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); + } + + function test_registerSwarm_emitsSwarmRegistered() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(50); + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + + vm.expectEmit(true, true, true, true); + emit SwarmRegistered(expectedId, fleetId, providerId, fleetOwner); + + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryL1.TagType.GENERIC); + } + + function test_registerSwarm_linksFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 swarmId2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarmId1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), swarmId2); + } + + function test_registerSwarm_allTagTypes() public { + uint256 fleetId1 = _registerFleet(fleetOwner, "f1"); + uint256 fleetId2 = _registerFleet(fleetOwner, "f2"); + uint256 fleetId3 = _registerFleet(fleetOwner, "f3"); + uint256 fleetId4 = _registerFleet(fleetOwner, "f4"); + uint256 providerId = _registerProvider(providerOwner, "url"); + + uint256 s1 = _registerSwarm( + fleetOwner, fleetId1, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.IBEACON_PAYLOAD_ONLY + ); + uint256 s2 = _registerSwarm( + fleetOwner, fleetId2, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.IBEACON_INCLUDES_MAC + ); + uint256 s3 = + _registerSwarm(fleetOwner, fleetId3, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.VENDOR_ID); + uint256 s4 = _registerSwarm(fleetOwner, fleetId4, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); + + (,,,, SwarmRegistryL1.TagType t1,) = swarmRegistry.swarms(s1); + (,,,, SwarmRegistryL1.TagType t2,) = swarmRegistry.swarms(s2); + (,,,, SwarmRegistryL1.TagType t3,) = swarmRegistry.swarms(s3); + (,,,, SwarmRegistryL1.TagType t4,) = swarmRegistry.swarms(s4); + + assertEq(uint8(t1), uint8(SwarmRegistryL1.TagType.IBEACON_PAYLOAD_ONLY)); + assertEq(uint8(t2), uint8(SwarmRegistryL1.TagType.IBEACON_INCLUDES_MAC)); + assertEq(uint8(t3), uint8(SwarmRegistryL1.TagType.VENDOR_ID)); + assertEq(uint8(t4), uint8(SwarmRegistryL1.TagType.GENERIC)); + } + + // ============================== + // registerSwarm — reverts + // ============================== + + function test_RevertIf_registerSwarm_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "my-fleet"); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + swarmRegistry.registerSwarm(fleetId, 1, new bytes(10), 16, SwarmRegistryL1.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_fingerprintSizeZero() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 0, SwarmRegistryL1.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_fingerprintSizeExceedsMax() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 17, SwarmRegistryL1.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_emptyFilter() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(0), 8, SwarmRegistryL1.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_filterTooLarge() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(24577), 8, SwarmRegistryL1.TagType.GENERIC); + } + + function test_registerSwarm_maxFingerprintSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + // fpSize=16 is MAX_FINGERPRINT_SIZE, should succeed + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(100), 16, SwarmRegistryL1.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + function test_registerSwarm_maxFilterSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + // Exactly 24576 bytes should succeed + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(24576), 8, SwarmRegistryL1.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + // ============================== + // acceptSwarm / rejectSwarm + // ============================== + + function test_acceptSwarm_setsStatusAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmStatusChanged(swarmId, SwarmRegistryL1.SwarmStatus.ACCEPTED); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + (,,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.ACCEPTED)); + } + + function test_rejectSwarm_setsStatusAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmStatusChanged(swarmId, SwarmRegistryL1.SwarmStatus.REJECTED); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + (,,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.REJECTED)); + } + + function test_RevertIf_acceptSwarm_notProviderOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotProviderOwner.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_RevertIf_rejectSwarm_notProviderOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); // fleet owner != provider owner + vm.expectRevert(SwarmRegistryL1.NotProviderOwner.selector); + swarmRegistry.rejectSwarm(swarmId); + } + + function test_acceptSwarm_afterReject() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + // Provider changes mind + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + (,,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.ACCEPTED)); + } + + // ============================== + // checkMembership — XOR logic + // ============================== + + function test_checkMembership_XORLogic16Bit() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + bytes memory tagId = hex"1122334455"; + uint8 fpSize = 16; + uint256 dataLen = 100; + uint256 m = (dataLen * 8) / fpSize; // 50 slots + + (uint32 h1, uint32 h2, uint32 h3, uint256 expectedFp) = getExpectedValues(tagId, m, fpSize); + + // Skip if collision (extremely unlikely with 50 slots) + if (h1 == h2 || h1 == h3 || h2 == h3) { + return; + } + + bytes memory filter = new bytes(dataLen); + _write16Bit(filter, h1, uint16(expectedFp)); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, fpSize, SwarmRegistryL1.TagType.GENERIC); + + // Positive check + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256(tagId)), "Valid tag should pass"); + + // Negative check + assertFalse(swarmRegistry.checkMembership(swarmId, keccak256(hex"999999")), "Invalid tag should fail"); + } + + function test_checkMembership_XORLogic8Bit() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + bytes memory tagId = hex"AABBCCDD"; + uint8 fpSize = 8; + // SSTORE2 prepends 0x00 STOP byte, so on-chain: + // extcodesize = rawLen + 1, dataLen = extcodesize - 1 = rawLen + // But SSTORE2.read offsets reads by +1 (skips STOP byte), so + // the data bytes read on-chain map 1:1 to the bytes we pass in. + // Therefore m = (rawLen * 8) / fpSize and slot indices match directly. + uint256 rawLen = 80; + uint256 m = (rawLen * 8) / fpSize; // 80 + + (uint32 h1, uint32 h2, uint32 h3, uint256 expectedFp) = getExpectedValues(tagId, m, fpSize); + + if (h1 == h2 || h1 == h3 || h2 == h3) { + return; + } + + bytes memory filter = new bytes(rawLen); + _write8Bit(filter, h1, uint8(expectedFp)); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, fpSize, SwarmRegistryL1.TagType.GENERIC); + + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256(tagId)), "8-bit valid tag should pass"); + assertFalse(swarmRegistry.checkMembership(swarmId, keccak256(hex"FFFFFF")), "8-bit invalid tag should fail"); + } + + function test_RevertIf_checkMembership_swarmNotFound() public { + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.checkMembership(999, keccak256("anything")); + } + + function test_checkMembership_allZeroFilter_returnsConsistent() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + // All-zero filter: f1^f2^f3 = 0^0^0 = 0 + // Only matches if expectedFp is also 0 + bytes memory filter = new bytes(64); + uint256 swarmId = _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryL1.TagType.GENERIC); + + // Some tags will match (those with expectedFp=0), most won't + // The point is it doesn't revert + swarmRegistry.checkMembership(swarmId, keccak256("test1")); + swarmRegistry.checkMembership(swarmId, keccak256("test2")); + } + + // ============================== + // Multiple swarms per fleet + // ============================== + + function test_multipleSwarms_sameFleet() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + uint256 providerId3 = _registerProvider(providerOwner, "url3"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 s2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(64), 16, SwarmRegistryL1.TagType.VENDOR_ID); + uint256 s3 = _registerSwarm( + fleetOwner, fleetId, providerId3, new bytes(50), 12, SwarmRegistryL1.TagType.IBEACON_PAYLOAD_ONLY + ); + + // IDs are distinct hashes + assertTrue(s1 != s2 && s2 != s3 && s1 != s3); + + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), s2); + assertEq(swarmRegistry.fleetSwarms(fleetId, 2), s3); + } + + // ============================== + // Constants + // ============================== + + function test_constants() public view { + assertEq(swarmRegistry.MAX_FINGERPRINT_SIZE(), 16); + } + + // ============================== + // Fuzz + // ============================== + + function testFuzz_registerSwarm_validFingerprintSizes(uint8 fpSize) public { + fpSize = uint8(bound(fpSize, 1, 16)); + + uint256 fleetId = _registerFleet(fleetOwner, abi.encodePacked("fleet-", fpSize)); + uint256 providerId = _registerProvider(providerOwner, string(abi.encodePacked("url-", fpSize))); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(64), fpSize, SwarmRegistryL1.TagType.GENERIC); + + (,,, uint8 storedFp,,) = swarmRegistry.swarms(swarmId); + assertEq(storedFp, fpSize); + } + + function testFuzz_registerSwarm_invalidFingerprintSizes(uint8 fpSize) public { + vm.assume(fpSize == 0 || fpSize > 16); + + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), fpSize, SwarmRegistryL1.TagType.GENERIC); + } + + // ============================== + // updateSwarmFilter + // ============================== + + function test_updateSwarmFilter_updatesFilterAndResetsStatus() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Provider accepts + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Fleet owner updates filter + bytes memory newFilter = new bytes(100); + vm.expectEmit(true, true, true, true); + emit SwarmFilterUpdated(swarmId, fleetOwner, 100); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, newFilter); + + // Status should be reset to REGISTERED + (,,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.REGISTERED)); + } + + function test_updateSwarmFilter_changesFilterPointer() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + (,, address oldPointer,,,) = swarmRegistry.swarms(swarmId); + + bytes memory newFilter = new bytes(100); + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, newFilter); + + (,, address newPointer,,,) = swarmRegistry.swarms(swarmId); + assertTrue(newPointer != oldPointer); + assertTrue(newPointer != address(0)); + } + + function test_RevertIf_updateSwarmFilter_swarmNotFound() public { + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.updateSwarmFilter(999, new bytes(50)); + } + + function test_RevertIf_updateSwarmFilter_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); + } + + function test_RevertIf_updateSwarmFilter_emptyFilter() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(0)); + } + + function test_RevertIf_updateSwarmFilter_filterTooLarge() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(24577)); + } + + // ============================== + // updateSwarmProvider + // ============================== + + function test_updateSwarmProvider_updatesProviderAndResetsStatus() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Provider accepts + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Fleet owner updates provider + vm.expectEmit(true, true, true, true); + emit SwarmProviderUpdated(swarmId, providerId1, providerId2); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + + // Check new provider and status reset + (, uint256 newProviderId,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(newProviderId, providerId2); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.REGISTERED)); + } + + function test_RevertIf_updateSwarmProvider_swarmNotFound() public { + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.updateSwarmProvider(999, providerId); + } + + function test_RevertIf_updateSwarmProvider_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + } + + function test_RevertIf_updateSwarmProvider_providerDoesNotExist() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + // ERC721 reverts before our custom error is reached + vm.expectRevert(); + swarmRegistry.updateSwarmProvider(swarmId, 99999); + } + + // ============================== + // deleteSwarm + // ============================== + + function test_deleteSwarm_removesSwarmAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmDeleted(swarmId, fleetId, fleetOwner); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // Swarm should be zeroed + (,, address pointer,,,) = swarmRegistry.swarms(swarmId); + assertEq(pointer, address(0)); + } + + function test_deleteSwarm_removesFromFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarm1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 swarm2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Delete first swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarm1); + + // Only swarm2 should remain in fleetSwarms + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm2); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 1); // Should be out of bounds + } + + function test_deleteSwarm_swapAndPop() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + uint256 providerId3 = _registerProvider(providerOwner, "url3"); + + uint256 swarm1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 swarm2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 swarm3 = + _registerSwarm(fleetOwner, fleetId, providerId3, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Delete middle swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarm2); + + // swarm3 should be swapped to index 1 + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), swarm3); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 2); // Should be out of bounds + } + + function test_RevertIf_deleteSwarm_swarmNotFound() public { + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.deleteSwarm(999); + } + + function test_RevertIf_deleteSwarm_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + swarmRegistry.deleteSwarm(swarmId); + } + + function test_deleteSwarm_afterUpdate() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Update then delete + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + (,, address pointer,,,) = swarmRegistry.swarms(swarmId); + assertEq(pointer, address(0)); + } + + function test_deleteSwarm_updatesSwarmIndexInFleet() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 p1 = _registerProvider(providerOwner, "url1"); + uint256 p2 = _registerProvider(providerOwner, "url2"); + uint256 p3 = _registerProvider(providerOwner, "url3"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, p1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 s2 = _registerSwarm(fleetOwner, fleetId, p2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 s3 = _registerSwarm(fleetOwner, fleetId, p3, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Verify initial indices + assertEq(swarmRegistry.swarmIndexInFleet(s1), 0); + assertEq(swarmRegistry.swarmIndexInFleet(s2), 1); + assertEq(swarmRegistry.swarmIndexInFleet(s3), 2); + + // Delete s1 — s3 should be swapped to index 0 + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(s1); + + assertEq(swarmRegistry.swarmIndexInFleet(s3), 0); + assertEq(swarmRegistry.swarmIndexInFleet(s2), 1); + assertEq(swarmRegistry.swarmIndexInFleet(s1), 0); // deleted, reset to 0 + } + + // ============================== + // isSwarmValid + // ============================== + + function test_isSwarmValid_bothValid() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertTrue(providerValid); + } + + function test_isSwarmValid_providerBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider + vm.prank(providerOwner); + providerContract.burn(providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertFalse(providerValid); + } + + function test_isSwarmValid_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn fleet + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertFalse(fleetValid); + assertTrue(providerValid); + } + + function test_isSwarmValid_bothBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + vm.prank(providerOwner); + providerContract.burn(providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertFalse(fleetValid); + assertFalse(providerValid); + } + + function test_RevertIf_isSwarmValid_swarmNotFound() public { + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.isSwarmValid(999); + } + + // ============================== + // purgeOrphanedSwarm + // ============================== + + function test_purgeOrphanedSwarm_providerBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider + vm.prank(providerOwner); + providerContract.burn(providerId); + + // Anyone can purge + vm.expectEmit(true, true, true, true); + emit SwarmPurged(swarmId, fleetId, caller); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // Swarm should be zeroed + (,, address pointer,,,) = swarmRegistry.swarms(swarmId); + assertEq(pointer, address(0)); + } + + function test_purgeOrphanedSwarm_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn fleet + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + (,, address pointer,,,) = swarmRegistry.swarms(swarmId); + assertEq(pointer, address(0)); + } + + function test_purgeOrphanedSwarm_removesFromFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 p1 = _registerProvider(providerOwner, "url1"); + uint256 p2 = _registerProvider(providerOwner, "url2"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, p1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 s2 = _registerSwarm(fleetOwner, fleetId, p2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider of s1 + vm.prank(providerOwner); + providerContract.burn(p1); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(s1); + + // s2 should be swapped to index 0 + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s2); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 1); + } + + function test_RevertIf_purgeOrphanedSwarm_swarmNotFound() public { + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.purgeOrphanedSwarm(999); + } + + function test_RevertIf_purgeOrphanedSwarm_swarmNotOrphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.expectRevert(SwarmRegistryL1.SwarmNotOrphaned.selector); + swarmRegistry.purgeOrphanedSwarm(swarmId); + } + + // ============================== + // Orphan guards on accept/reject/checkMembership + // ============================== + + function test_RevertIf_acceptSwarm_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryL1.SwarmOrphaned.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_RevertIf_rejectSwarm_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn fleet + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryL1.SwarmOrphaned.selector); + swarmRegistry.rejectSwarm(swarmId); + } + + function test_RevertIf_checkMembership_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.expectRevert(SwarmRegistryL1.SwarmOrphaned.selector); + swarmRegistry.checkMembership(swarmId, keccak256("test")); + } + + function test_RevertIf_acceptSwarm_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryL1.SwarmOrphaned.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_purge_thenAcceptReverts() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // After purge, swarm no longer exists + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.acceptSwarm(swarmId); + } +} diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol new file mode 100644 index 0000000..3829348 --- /dev/null +++ b/test/SwarmRegistryUniversal.t.sol @@ -0,0 +1,1140 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/swarms/SwarmRegistryUniversal.sol"; +import "../src/swarms/FleetIdentity.sol"; +import "../src/swarms/ServiceProvider.sol"; + +contract SwarmRegistryUniversalTest is Test { + SwarmRegistryUniversal swarmRegistry; + FleetIdentity fleetContract; + ServiceProvider providerContract; + + address fleetOwner = address(0x1); + address providerOwner = address(0x2); + address caller = address(0x3); + + event SwarmRegistered( + uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner, uint32 filterSize + ); + event SwarmStatusChanged(uint256 indexed swarmId, SwarmRegistryUniversal.SwarmStatus status); + event SwarmFilterUpdated(uint256 indexed swarmId, address indexed owner, uint32 newFilterSize); + event SwarmProviderUpdated(uint256 indexed swarmId, uint256 indexed oldProviderId, uint256 indexed newProviderId); + event SwarmDeleted(uint256 indexed swarmId, uint256 indexed fleetId, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, uint256 indexed fleetId, address indexed purgedBy); + + function setUp() public { + fleetContract = new FleetIdentity(); + providerContract = new ServiceProvider(); + swarmRegistry = new SwarmRegistryUniversal(address(fleetContract), address(providerContract)); + } + + // ============================== + // Helpers + // ============================== + + function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { + vm.prank(owner); + return fleetContract.registerFleet(bytes16(keccak256(seed))); + } + + function _registerProvider(address owner, string memory url) internal returns (uint256) { + vm.prank(owner); + return providerContract.registerProvider(url); + } + + function _registerSwarm( + address owner, + uint256 fleetId, + uint256 providerId, + bytes memory filter, + uint8 fpSize, + SwarmRegistryUniversal.TagType tagType + ) internal returns (uint256) { + vm.prank(owner); + return swarmRegistry.registerSwarm(fleetId, providerId, filter, fpSize, tagType); + } + + function getExpectedValues(bytes memory tagId, uint256 m, uint8 fpSize) + public + pure + returns (uint32 h1, uint32 h2, uint32 h3, uint256 fp) + { + bytes32 h = keccak256(tagId); + h1 = uint32(uint256(h)) % uint32(m); + h2 = uint32(uint256(h) >> 32) % uint32(m); + h3 = uint32(uint256(h) >> 64) % uint32(m); + uint256 fpMask = (1 << fpSize) - 1; + fp = (uint256(h) >> 96) & fpMask; + } + + function _write16Bit(bytes memory data, uint256 slotIndex, uint16 value) internal pure { + uint256 byteOffset = (slotIndex * 16) / 8; + data[byteOffset] = bytes1(uint8(value >> 8)); + data[byteOffset + 1] = bytes1(uint8(value)); + } + + function _write8Bit(bytes memory data, uint256 slotIndex, uint8 value) internal pure { + data[slotIndex] = bytes1(value); + } + + // ============================== + // Constructor + // ============================== + + function test_constructor_setsImmutables() public view { + assertEq(address(swarmRegistry.FLEET_CONTRACT()), address(fleetContract)); + assertEq(address(swarmRegistry.PROVIDER_CONTRACT()), address(providerContract)); + } + + function test_RevertIf_constructor_zeroFleetAddress() public { + vm.expectRevert(SwarmRegistryUniversal.InvalidSwarmData.selector); + new SwarmRegistryUniversal(address(0), address(providerContract)); + } + + function test_RevertIf_constructor_zeroProviderAddress() public { + vm.expectRevert(SwarmRegistryUniversal.InvalidSwarmData.selector); + new SwarmRegistryUniversal(address(fleetContract), address(0)); + } + + function test_RevertIf_constructor_bothZero() public { + vm.expectRevert(SwarmRegistryUniversal.InvalidSwarmData.selector); + new SwarmRegistryUniversal(address(0), address(0)); + } + + // ============================== + // registerSwarm — happy path + // ============================== + + function test_registerSwarm_basicFlow() public { + uint256 fleetId = _registerFleet(fleetOwner, "my-fleet"); + uint256 providerId = _registerProvider(providerOwner, "https://api.example.com"); + + uint256 swarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(100), 16, SwarmRegistryUniversal.TagType.IBEACON_INCLUDES_MAC + ); + + // Swarm ID is deterministic hash of (fleetId, providerId, filter) + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, new bytes(100)); + assertEq(swarmId, expectedId); + } + + function test_registerSwarm_storesMetadataCorrectly() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 12, SwarmRegistryUniversal.TagType.VENDOR_ID); + + ( + uint256 storedFleetId, + uint256 storedProviderId, + uint32 storedFilterLen, + uint8 storedFpSize, + SwarmRegistryUniversal.TagType storedTagType, + SwarmRegistryUniversal.SwarmStatus storedStatus + ) = swarmRegistry.swarms(swarmId); + + assertEq(storedFleetId, fleetId); + assertEq(storedProviderId, providerId); + assertEq(storedFilterLen, 50); + assertEq(storedFpSize, 12); + assertEq(uint8(storedTagType), uint8(SwarmRegistryUniversal.TagType.VENDOR_ID)); + assertEq(uint8(storedStatus), uint8(SwarmRegistryUniversal.SwarmStatus.REGISTERED)); + } + + function test_registerSwarm_storesFilterData() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(100); + // Write some non-zero data + filter[0] = 0xAB; + filter[50] = 0xCD; + filter[99] = 0xEF; + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryUniversal.TagType.GENERIC); + + bytes memory storedFilter = swarmRegistry.getFilterData(swarmId); + assertEq(storedFilter.length, 100); + assertEq(uint8(storedFilter[0]), 0xAB); + assertEq(uint8(storedFilter[50]), 0xCD); + assertEq(uint8(storedFilter[99]), 0xEF); + } + + function test_registerSwarm_deterministicId() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(32); + + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 8, SwarmRegistryUniversal.TagType.GENERIC); + assertEq(swarmId, expectedId); + } + + function test_RevertIf_registerSwarm_duplicateSwarm() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmAlreadyExists.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_registerSwarm_emitsSwarmRegistered() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(50); + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + + vm.expectEmit(true, true, true, true); + emit SwarmRegistered(expectedId, fleetId, providerId, fleetOwner, 50); + + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_registerSwarm_linksFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 s1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), s2); + } + + function test_registerSwarm_allTagTypes() public { + uint256 fleetId1 = _registerFleet(fleetOwner, "f1"); + uint256 fleetId2 = _registerFleet(fleetOwner, "f2"); + uint256 fleetId3 = _registerFleet(fleetOwner, "f3"); + uint256 fleetId4 = _registerFleet(fleetOwner, "f4"); + uint256 providerId = _registerProvider(providerOwner, "url"); + + uint256 s1 = _registerSwarm( + fleetOwner, fleetId1, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.IBEACON_PAYLOAD_ONLY + ); + uint256 s2 = _registerSwarm( + fleetOwner, fleetId2, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.IBEACON_INCLUDES_MAC + ); + uint256 s3 = + _registerSwarm(fleetOwner, fleetId3, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.VENDOR_ID); + uint256 s4 = + _registerSwarm(fleetOwner, fleetId4, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); + + (,,,, SwarmRegistryUniversal.TagType t1,) = swarmRegistry.swarms(s1); + (,,,, SwarmRegistryUniversal.TagType t2,) = swarmRegistry.swarms(s2); + (,,,, SwarmRegistryUniversal.TagType t3,) = swarmRegistry.swarms(s3); + (,,,, SwarmRegistryUniversal.TagType t4,) = swarmRegistry.swarms(s4); + + assertEq(uint8(t1), uint8(SwarmRegistryUniversal.TagType.IBEACON_PAYLOAD_ONLY)); + assertEq(uint8(t2), uint8(SwarmRegistryUniversal.TagType.IBEACON_INCLUDES_MAC)); + assertEq(uint8(t3), uint8(SwarmRegistryUniversal.TagType.VENDOR_ID)); + assertEq(uint8(t4), uint8(SwarmRegistryUniversal.TagType.GENERIC)); + } + + // ============================== + // registerSwarm — reverts + // ============================== + + function test_RevertIf_registerSwarm_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "my-fleet"); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + swarmRegistry.registerSwarm(fleetId, 1, new bytes(10), 16, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_fingerprintSizeZero() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 0, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_fingerprintSizeExceedsMax() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 17, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_emptyFilter() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFilterSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(0), 8, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_filterTooLarge() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.FilterTooLarge.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(24577), 8, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_registerSwarm_maxFingerprintSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(100), 16, SwarmRegistryUniversal.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + function test_registerSwarm_maxFilterSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + // Exactly MAX_FILTER_SIZE (24576) should succeed + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(24576), 8, SwarmRegistryUniversal.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + function test_registerSwarm_minFilterSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + // 1 byte filter + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(1), 8, SwarmRegistryUniversal.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + // ============================== + // acceptSwarm / rejectSwarm + // ============================== + + function test_acceptSwarm_setsStatusAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmStatusChanged(swarmId, SwarmRegistryUniversal.SwarmStatus.ACCEPTED); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + (,,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.ACCEPTED)); + } + + function test_rejectSwarm_setsStatusAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmStatusChanged(swarmId, SwarmRegistryUniversal.SwarmStatus.REJECTED); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + (,,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.REJECTED)); + } + + function test_RevertIf_acceptSwarm_notProviderOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotProviderOwner.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_RevertIf_rejectSwarm_notProviderOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); // fleet owner != provider owner + vm.expectRevert(SwarmRegistryUniversal.NotProviderOwner.selector); + swarmRegistry.rejectSwarm(swarmId); + } + + function test_RevertIf_acceptSwarm_fleetOwnerNotProvider() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.NotProviderOwner.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_acceptSwarm_afterReject() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + (,,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.ACCEPTED)); + } + + function test_rejectSwarm_afterAccept() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + (,,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.REJECTED)); + } + + // ============================== + // checkMembership — XOR logic + // ============================== + + function test_checkMembership_XORLogic16Bit() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + bytes memory tagId = hex"1122334455"; + uint8 fpSize = 16; + uint256 dataLen = 100; + uint256 m = (dataLen * 8) / fpSize; // 50 slots + + (uint32 h1, uint32 h2, uint32 h3, uint256 expectedFp) = getExpectedValues(tagId, m, fpSize); + + if (h1 == h2 || h1 == h3 || h2 == h3) { + return; + } + + bytes memory filter = new bytes(dataLen); + _write16Bit(filter, h1, uint16(expectedFp)); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, fpSize, SwarmRegistryUniversal.TagType.GENERIC); + + bytes32 tagHash = keccak256(tagId); + assertTrue(swarmRegistry.checkMembership(swarmId, tagHash), "Tag should be member"); + + bytes32 fakeHash = keccak256("not-a-tag"); + assertFalse(swarmRegistry.checkMembership(swarmId, fakeHash), "Fake tag should not be member"); + } + + function test_checkMembership_XORLogic8Bit() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + bytes memory tagId = hex"AABBCCDD"; + uint8 fpSize = 8; + uint256 dataLen = 80; + uint256 m = (dataLen * 8) / fpSize; // 80 slots + + (uint32 h1, uint32 h2, uint32 h3, uint256 expectedFp) = getExpectedValues(tagId, m, fpSize); + + if (h1 == h2 || h1 == h3 || h2 == h3) { + return; + } + + bytes memory filter = new bytes(dataLen); + _write8Bit(filter, h1, uint8(expectedFp)); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, fpSize, SwarmRegistryUniversal.TagType.GENERIC); + + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256(tagId)), "8-bit valid tag should pass"); + assertFalse(swarmRegistry.checkMembership(swarmId, keccak256(hex"FFFFFF")), "8-bit invalid tag should fail"); + } + + function test_RevertIf_checkMembership_swarmNotFound() public { + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.checkMembership(999, keccak256("anything")); + } + + function test_checkMembership_allZeroFilter_returnsConsistent() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + // All-zero filter: f1^f2^f3 = 0^0^0 = 0 + bytes memory filter = new bytes(64); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryUniversal.TagType.GENERIC); + + // Should not revert regardless of result + swarmRegistry.checkMembership(swarmId, keccak256("test1")); + swarmRegistry.checkMembership(swarmId, keccak256("test2")); + } + + // ============================== + // getFilterData + // ============================== + + function test_getFilterData_returnsCorrectData() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(100); + filter[0] = 0xFF; + filter[99] = 0x01; + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryUniversal.TagType.GENERIC); + + bytes memory stored = swarmRegistry.getFilterData(swarmId); + assertEq(stored.length, 100); + assertEq(uint8(stored[0]), 0xFF); + assertEq(uint8(stored[99]), 0x01); + } + + function test_RevertIf_getFilterData_swarmNotFound() public { + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.getFilterData(999); + } + + // ============================== + // Multiple swarms per fleet + // ============================== + + function test_multipleSwarms_sameFleet() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + uint256 providerId3 = _registerProvider(providerOwner, "url3"); + + uint256 s1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s2 = _registerSwarm( + fleetOwner, fleetId, providerId2, new bytes(64), 16, SwarmRegistryUniversal.TagType.VENDOR_ID + ); + uint256 s3 = _registerSwarm( + fleetOwner, fleetId, providerId3, new bytes(50), 12, SwarmRegistryUniversal.TagType.IBEACON_PAYLOAD_ONLY + ); + + // IDs are distinct hashes + assertTrue(s1 != s2 && s2 != s3 && s1 != s3); + + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), s2); + assertEq(swarmRegistry.fleetSwarms(fleetId, 2), s3); + } + + // ============================== + // Constants + // ============================== + + function test_constants() public view { + assertEq(swarmRegistry.MAX_FINGERPRINT_SIZE(), 16); + assertEq(swarmRegistry.MAX_FILTER_SIZE(), 24576); + } + + // ============================== + // Fuzz + // ============================== + + function testFuzz_registerSwarm_validFingerprintSizes(uint8 fpSize) public { + fpSize = uint8(bound(fpSize, 1, 16)); + + uint256 fleetId = _registerFleet(fleetOwner, abi.encodePacked("fleet-", fpSize)); + uint256 providerId = _registerProvider(providerOwner, string(abi.encodePacked("url-", fpSize))); + + uint256 swarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(64), fpSize, SwarmRegistryUniversal.TagType.GENERIC + ); + + (,,, uint8 storedFp,,) = swarmRegistry.swarms(swarmId); + assertEq(storedFp, fpSize); + } + + function testFuzz_registerSwarm_invalidFingerprintSizes(uint8 fpSize) public { + vm.assume(fpSize == 0 || fpSize > 16); + + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), fpSize, SwarmRegistryUniversal.TagType.GENERIC); + } + + function testFuzz_registerSwarm_filterSizeRange(uint256 size) public { + size = bound(size, 1, 24576); + + uint256 fleetId = _registerFleet(fleetOwner, abi.encodePacked("f-", size)); + uint256 providerId = _registerProvider(providerOwner, string(abi.encodePacked("url-", size))); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(size), 8, SwarmRegistryUniversal.TagType.GENERIC); + + (,, uint32 storedLen,,,) = swarmRegistry.swarms(swarmId); + assertEq(storedLen, uint32(size)); + } + + // ============================== + // updateSwarmFilter + // ============================== + + function test_updateSwarmFilter_updatesFilterAndResetsStatus() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Provider accepts + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Fleet owner updates filter + bytes memory newFilter = new bytes(100); + for (uint256 i = 0; i < 100; i++) { + newFilter[i] = bytes1(uint8(i % 256)); + } + + vm.expectEmit(true, true, true, true); + emit SwarmFilterUpdated(swarmId, fleetOwner, 100); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, newFilter); + + // Status should be reset to REGISTERED + (,, uint32 filterLength,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.REGISTERED)); + assertEq(filterLength, 100); + } + + function test_updateSwarmFilter_changesFilterLength() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + (,, uint32 oldLen,,,) = swarmRegistry.swarms(swarmId); + assertEq(oldLen, 50); + + bytes memory newFilter = new bytes(100); + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, newFilter); + + (,, uint32 newLen,,,) = swarmRegistry.swarms(swarmId); + assertEq(newLen, 100); + } + + function test_RevertIf_updateSwarmFilter_swarmNotFound() public { + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.updateSwarmFilter(999, new bytes(50)); + } + + function test_RevertIf_updateSwarmFilter_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); + } + + function test_RevertIf_updateSwarmFilter_emptyFilter() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFilterSize.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(0)); + } + + function test_RevertIf_updateSwarmFilter_filterTooLarge() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.FilterTooLarge.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(24577)); + } + + // ============================== + // updateSwarmProvider + // ============================== + + function test_updateSwarmProvider_updatesProviderAndResetsStatus() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Provider accepts + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Fleet owner updates provider + vm.expectEmit(true, true, true, true); + emit SwarmProviderUpdated(swarmId, providerId1, providerId2); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + + // Check new provider and status reset + (, uint256 newProviderId,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(newProviderId, providerId2); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.REGISTERED)); + } + + function test_RevertIf_updateSwarmProvider_swarmNotFound() public { + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.updateSwarmProvider(999, providerId); + } + + function test_RevertIf_updateSwarmProvider_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + } + + function test_RevertIf_updateSwarmProvider_providerDoesNotExist() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + // ERC721 reverts before our custom error is reached + vm.expectRevert(); + swarmRegistry.updateSwarmProvider(swarmId, 99999); + } + + // ============================== + // deleteSwarm + // ============================== + + function test_deleteSwarm_removesSwarmAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmDeleted(swarmId, fleetId, fleetOwner); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // Swarm should be zeroed + (uint256 fleetIdAfter,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(fleetIdAfter, 0); + assertEq(filterLength, 0); + } + + function test_deleteSwarm_removesFromFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarm1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 swarm2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Delete first swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarm1); + + // Only swarm2 should remain in fleetSwarms + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm2); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 1); // Should be out of bounds + } + + function test_deleteSwarm_swapAndPop() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + uint256 providerId3 = _registerProvider(providerOwner, "url3"); + + uint256 swarm1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 swarm2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 swarm3 = + _registerSwarm(fleetOwner, fleetId, providerId3, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Delete middle swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarm2); + + // swarm3 should be swapped to index 1 + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), swarm3); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 2); // Should be out of bounds + } + + function test_deleteSwarm_clearsFilterData() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filterData = new bytes(50); + for (uint256 i = 0; i < 50; i++) { + filterData[i] = bytes1(uint8(i)); + } + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filterData, 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Delete swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // filterLength should be cleared + (,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(filterLength, 0); + } + + function test_RevertIf_deleteSwarm_swarmNotFound() public { + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.deleteSwarm(999); + } + + function test_RevertIf_deleteSwarm_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + swarmRegistry.deleteSwarm(swarmId); + } + + function test_deleteSwarm_afterUpdate() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Update then delete + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + (uint256 fleetIdAfter,,,,,) = swarmRegistry.swarms(swarmId); + assertEq(fleetIdAfter, 0); + } + + function test_deleteSwarm_updatesSwarmIndexInFleet() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 p1 = _registerProvider(providerOwner, "url1"); + uint256 p2 = _registerProvider(providerOwner, "url2"); + uint256 p3 = _registerProvider(providerOwner, "url3"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, p1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s2 = _registerSwarm(fleetOwner, fleetId, p2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s3 = _registerSwarm(fleetOwner, fleetId, p3, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Verify initial indices + assertEq(swarmRegistry.swarmIndexInFleet(s1), 0); + assertEq(swarmRegistry.swarmIndexInFleet(s2), 1); + assertEq(swarmRegistry.swarmIndexInFleet(s3), 2); + + // Delete s1 — s3 should be swapped to index 0 + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(s1); + + assertEq(swarmRegistry.swarmIndexInFleet(s3), 0); + assertEq(swarmRegistry.swarmIndexInFleet(s2), 1); + assertEq(swarmRegistry.swarmIndexInFleet(s1), 0); // deleted, reset to 0 + } + + // ============================== + // isSwarmValid + // ============================== + + function test_isSwarmValid_bothValid() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertTrue(providerValid); + } + + function test_isSwarmValid_providerBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertFalse(providerValid); + } + + function test_isSwarmValid_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertFalse(fleetValid); + assertTrue(providerValid); + } + + function test_isSwarmValid_bothBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + vm.prank(providerOwner); + providerContract.burn(providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertFalse(fleetValid); + assertFalse(providerValid); + } + + function test_RevertIf_isSwarmValid_swarmNotFound() public { + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.isSwarmValid(999); + } + + // ============================== + // purgeOrphanedSwarm + // ============================== + + function test_purgeOrphanedSwarm_providerBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.expectEmit(true, true, true, true); + emit SwarmPurged(swarmId, fleetId, caller); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + (,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(filterLength, 0); + } + + function test_purgeOrphanedSwarm_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + (uint256 fId,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(fId, 0); + assertEq(filterLength, 0); + } + + function test_purgeOrphanedSwarm_removesFromFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 p1 = _registerProvider(providerOwner, "url1"); + uint256 p2 = _registerProvider(providerOwner, "url2"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, p1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s2 = _registerSwarm(fleetOwner, fleetId, p2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Burn provider of s1 + vm.prank(providerOwner); + providerContract.burn(p1); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(s1); + + // s2 should be swapped to index 0 + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s2); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 1); + } + + function test_purgeOrphanedSwarm_clearsFilterData() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(50); + for (uint256 i = 0; i < 50; i++) { + filter[i] = bytes1(uint8(i)); + } + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // filterLength should be cleared + (,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(filterLength, 0); + } + + function test_RevertIf_purgeOrphanedSwarm_swarmNotFound() public { + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.purgeOrphanedSwarm(999); + } + + function test_RevertIf_purgeOrphanedSwarm_swarmNotOrphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.expectRevert(SwarmRegistryUniversal.SwarmNotOrphaned.selector); + swarmRegistry.purgeOrphanedSwarm(swarmId); + } + + // ============================== + // Orphan guards on accept/reject/checkMembership + // ============================== + + function test_RevertIf_acceptSwarm_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmOrphaned.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_RevertIf_rejectSwarm_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmOrphaned.selector); + swarmRegistry.rejectSwarm(swarmId); + } + + function test_RevertIf_checkMembership_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.expectRevert(SwarmRegistryUniversal.SwarmOrphaned.selector); + swarmRegistry.checkMembership(swarmId, keccak256("test")); + } + + function test_RevertIf_acceptSwarm_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmOrphaned.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_purge_thenAcceptReverts() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // After purge, swarm no longer exists + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.acceptSwarm(swarmId); + } +}