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);
+ }
+}