diff --git a/.agent/rules/solidity_zksync.md b/.agent/rules/solidity_zksync.md
new file mode 100644
index 0000000..642f108
--- /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.
+ - When a contract requires an owner (e.g., admin-configurable parameters), prefer `Ownable2Step` over `Ownable`. Do **not** add ownership to contracts that don't need it — many contracts are fully permissionless by design.
+ - 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..bb37390
--- /dev/null
+++ b/src/swarms/FleetIdentity.sol
@@ -0,0 +1,773 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
+import {ERC721Enumerable} from "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
+import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
+import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
+import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
+
+/**
+ * @title FleetIdentity
+ * @notice ERC-721 with ERC721Enumerable representing ownership of a BLE fleet,
+ * secured by an ERC-20 bond organized into geometric tiers.
+ *
+ * @dev **Three-level geographic registration**
+ *
+ * Fleets register at exactly one level:
+ * - Global — regionKey = 0
+ * - Country — regionKey = countryCode (ISO 3166-1 numeric, 1-999)
+ * - Admin Area — regionKey = (countryCode << 12) | adminCode (>= 4096)
+ *
+ * Each regionKey has its **own independent tier namespace** — tier indices
+ * start at 0 for every region. The first fleet in any region always pays
+ * BASE_BOND regardless of how many tiers exist in other regions.
+ *
+ * Tier capacity varies by level:
+ * - Global: 4 members per tier
+ * - Country: 8 members per tier
+ * - Admin Area: 8 members per tier
+ * Tier K within a region requires bond = BASE_BOND * BOND_MULTIPLIER^K.
+ *
+ * EdgeBeaconScanner discovery uses a 3-level fallback:
+ * 1. Admin area (most specific)
+ * 2. Country
+ * 3. Global
+ *
+ * On-chain indexes track which countries and admin areas have active fleets,
+ * enabling EdgeBeaconScanner enumeration without off-chain indexers.
+ *
+ * TokenID = uint256(uint128(uuid)), guaranteeing one owner per Proximity UUID.
+ */
+contract FleetIdentity is ERC721Enumerable, ReentrancyGuard {
+ using SafeERC20 for IERC20;
+
+ // ──────────────────────────────────────────────
+ // Errors
+ // ──────────────────────────────────────────────
+ error InvalidUUID();
+ error NotTokenOwner();
+ error MaxTiersReached();
+ error TierFull();
+ error InsufficientBondForPromotion();
+ error TargetTierNotHigher();
+ error TargetTierNotLower();
+ error TargetTierSameAsCurrent();
+ error InvalidCountryCode();
+ error InvalidAdminCode();
+
+ // ──────────────────────────────────────────────
+ // Constants & Immutables
+ // ──────────────────────────────────────────────
+
+ /// @notice Maximum members per global tier.
+ uint256 public constant GLOBAL_TIER_CAPACITY = 4;
+
+ /// @notice Maximum members per country-level tier.
+ uint256 public constant COUNTRY_TIER_CAPACITY = 8;
+
+ /// @notice Maximum members per admin-area (local) tier.
+ uint256 public constant LOCAL_TIER_CAPACITY = 8;
+
+ /// @notice Hard cap on tier count per region.
+ /// @dev Derived from anti-spam analysis: with BOND_MULTIPLIER = 2 and
+ /// tier capacity 8, a spammer spending half the total token supply
+ /// against a BASE_BOND set 10 000× too low fills ~20 tiers.
+ /// 24 provides comfortable headroom.
+ uint256 public constant MAX_TIERS = 24;
+
+ /// @notice Maximum UUIDs returned by buildHighestBondedUUIDBundle.
+ uint256 public constant MAX_BONDED_UUID_BUNDLE_SIZE = 20;
+
+ /// @notice Region key for global registrations.
+ uint32 public constant GLOBAL_REGION = 0;
+
+ /// @notice The ERC-20 token used for bonds (immutable, e.g. NODL).
+ IERC20 public immutable BOND_TOKEN;
+
+ /// @notice Base bond for tier 0 in any region. Tier K requires BASE_BOND * BOND_MULTIPLIER^K.
+ uint256 public immutable BASE_BOND;
+
+ /// @notice Geometric multiplier between tiers.
+ /// @dev Fixed at 2 (doubling). Each tier costs 2× the previous one,
+ /// making spam 4× more expensive per tier (capacity / (M-1)).
+ uint256 public constant BOND_MULTIPLIER = 2;
+
+ // ──────────────────────────────────────────────
+ // Region-namespaced tier data
+ // ──────────────────────────────────────────────
+
+ /// @notice regionKey -> number of tiers opened in that region.
+ mapping(uint32 => uint256) public regionTierCount;
+
+ /// @dev regionKey -> cached lower-bound hint for lowest open tier.
+ mapping(uint32 => uint256) internal _regionLowestHint;
+
+ /// @notice regionKey -> tierIndex -> list of token IDs.
+ mapping(uint32 => mapping(uint256 => uint256[])) internal _regionTierMembers;
+
+ /// @notice Token ID -> index within its tier's member array (for O(1) removal).
+ mapping(uint256 => uint256) internal _indexInTier;
+
+ // ──────────────────────────────────────────────
+ // Fleet data
+ // ──────────────────────────────────────────────
+
+ /// @notice Token ID -> region key the fleet is registered in.
+ mapping(uint256 => uint32) public fleetRegion;
+
+ /// @notice Token ID -> tier index (within its region) the fleet belongs to.
+ mapping(uint256 => uint256) public fleetTier;
+
+ // ──────────────────────────────────────────────
+ // On-chain region indexes
+ // ──────────────────────────────────────────────
+
+ /// @notice Whether the global region has any active fleets.
+ bool public globalActive;
+
+ /// @dev Set of country codes with at least one active fleet.
+ uint16[] internal _activeCountries;
+ mapping(uint16 => uint256) internal _activeCountryIndex; // value = index+1 (0 = not present)
+
+ /// @dev Set of admin-area region keys with at least one active fleet.
+ uint32[] internal _activeAdminAreas;
+ mapping(uint32 => uint256) internal _activeAdminAreaIndex; // value = index+1 (0 = not present)
+
+ // ──────────────────────────────────────────────
+ // Events
+ // ──────────────────────────────────────────────
+
+ event FleetRegistered(
+ address indexed owner,
+ bytes16 indexed uuid,
+ uint256 indexed tokenId,
+ uint32 regionKey,
+ uint256 tierIndex,
+ uint256 bondAmount
+ );
+ event FleetPromoted(uint256 indexed tokenId, uint256 fromTier, uint256 toTier, uint256 additionalBond);
+ event FleetDemoted(uint256 indexed tokenId, uint256 fromTier, uint256 toTier, uint256 bondRefund);
+ event FleetBurned(
+ address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 tierIndex
+ );
+
+ // ──────────────────────────────────────────────
+ // Constructor
+ // ──────────────────────────────────────────────
+
+ /// @param _bondToken Address of the ERC-20 token used for bonds.
+ /// @param _baseBond Base bond for tier 0 in any region.
+ constructor(address _bondToken, uint256 _baseBond) ERC721("Swarm Fleet Identity", "SFID") {
+ BOND_TOKEN = IERC20(_bondToken);
+ BASE_BOND = _baseBond;
+ }
+
+ // ══════════════════════════════════════════════
+ // Registration: Global
+ // ══════════════════════════════════════════════
+
+ /// @notice Register a fleet globally (auto-assign tier).
+ function registerFleetGlobal(bytes16 uuid) external nonReentrant returns (uint256 tokenId) {
+ if (uuid == bytes16(0)) revert InvalidUUID();
+ uint256 tier = _openTier(GLOBAL_REGION);
+ tokenId = _register(uuid, GLOBAL_REGION, tier);
+ }
+
+ /// @notice Register a fleet globally into a specific tier.
+ function registerFleetGlobal(bytes16 uuid, uint256 targetTier) external nonReentrant returns (uint256 tokenId) {
+ if (uuid == bytes16(0)) revert InvalidUUID();
+ _validateExplicitTier(GLOBAL_REGION, targetTier);
+ tokenId = _register(uuid, GLOBAL_REGION, targetTier);
+ }
+
+ // ══════════════════════════════════════════════
+ // Registration: Country
+ // ══════════════════════════════════════════════
+
+ /// @notice Register a fleet under a country (auto-assign tier).
+ /// @param countryCode ISO 3166-1 numeric country code (1-999).
+ function registerFleetCountry(bytes16 uuid, uint16 countryCode) external nonReentrant returns (uint256 tokenId) {
+ if (uuid == bytes16(0)) revert InvalidUUID();
+ if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode();
+ uint32 regionKey = uint32(countryCode);
+ uint256 tier = _openTier(regionKey);
+ tokenId = _register(uuid, regionKey, tier);
+ }
+
+ /// @notice Register a fleet under a country into a specific tier.
+ function registerFleetCountry(bytes16 uuid, uint16 countryCode, uint256 targetTier)
+ external
+ nonReentrant
+ returns (uint256 tokenId)
+ {
+ if (uuid == bytes16(0)) revert InvalidUUID();
+ if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode();
+ uint32 regionKey = uint32(countryCode);
+ _validateExplicitTier(regionKey, targetTier);
+ tokenId = _register(uuid, regionKey, targetTier);
+ }
+
+ // ══════════════════════════════════════════════
+ // Registration: Admin Area (local)
+ // ══════════════════════════════════════════════
+
+ /// @notice Register a fleet under a country + admin area (auto-assign tier).
+ /// @param countryCode ISO 3166-1 numeric country code (1-999).
+ /// @param adminCode Admin area code within the country (1-4095).
+ function registerFleetLocal(bytes16 uuid, uint16 countryCode, uint16 adminCode)
+ external
+ nonReentrant
+ returns (uint256 tokenId)
+ {
+ if (uuid == bytes16(0)) revert InvalidUUID();
+ if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode();
+ if (adminCode == 0 || adminCode > 4095) revert InvalidAdminCode();
+ uint32 regionKey = (uint32(countryCode) << 12) | uint32(adminCode);
+ uint256 tier = _openTier(regionKey);
+ tokenId = _register(uuid, regionKey, tier);
+ }
+
+ /// @notice Register a fleet under a country + admin area into a specific tier.
+ function registerFleetLocal(bytes16 uuid, uint16 countryCode, uint16 adminCode, uint256 targetTier)
+ external
+ nonReentrant
+ returns (uint256 tokenId)
+ {
+ if (uuid == bytes16(0)) revert InvalidUUID();
+ if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode();
+ if (adminCode == 0 || adminCode > 4095) revert InvalidAdminCode();
+ uint32 regionKey = (uint32(countryCode) << 12) | uint32(adminCode);
+ _validateExplicitTier(regionKey, targetTier);
+ tokenId = _register(uuid, regionKey, targetTier);
+ }
+
+ // ══════════════════════════════════════════════
+ // Promote / Demote (region-aware)
+ // ══════════════════════════════════════════════
+
+ /// @notice Promotes a fleet to the next tier within its region.
+ function promote(uint256 tokenId) external nonReentrant {
+ _promote(tokenId, fleetTier[tokenId] + 1);
+ }
+
+ /// @notice Moves a fleet to a different tier within its region.
+ /// If targetTier > current tier, promotes (pulls additional bond).
+ /// If targetTier < current tier, demotes (refunds bond difference).
+ function reassignTier(uint256 tokenId, uint256 targetTier) external nonReentrant {
+ uint256 currentTier = fleetTier[tokenId];
+ if (targetTier == currentTier) revert TargetTierSameAsCurrent();
+ if (targetTier > currentTier) {
+ _promote(tokenId, targetTier);
+ } else {
+ _demote(tokenId, targetTier);
+ }
+ }
+
+ // ══════════════════════════════════════════════
+ // Burn
+ // ══════════════════════════════════════════════
+
+ /// @notice Burns the fleet NFT and refunds the tier bond to the token owner.
+ function burn(uint256 tokenId) external nonReentrant {
+ address tokenOwner = ownerOf(tokenId);
+ if (tokenOwner != msg.sender) revert NotTokenOwner();
+
+ uint32 region = fleetRegion[tokenId];
+ uint256 tier = fleetTier[tokenId];
+ uint256 refund = tierBond(tier);
+
+ // Effects
+ _removeFromTier(tokenId, region, tier);
+ delete fleetTier[tokenId];
+ delete fleetRegion[tokenId];
+ delete _indexInTier[tokenId];
+ _burn(tokenId);
+
+ _trimTierCount(region);
+ _removeFromRegionIndex(region);
+
+ // Interaction
+ if (refund > 0) {
+ BOND_TOKEN.safeTransfer(tokenOwner, refund);
+ }
+
+ emit FleetBurned(tokenOwner, tokenId, refund, region, tier);
+ }
+
+ // ══════════════════════════════════════════════
+ // Views: Bond & tier helpers
+ // ══════════════════════════════════════════════
+
+ /// @notice Bond required for tier K in any region = BASE_BOND * BOND_MULTIPLIER^K.
+ function tierBond(uint256 tier) public view returns (uint256) {
+ if (tier == 0) return BASE_BOND;
+ uint256 bond = BASE_BOND;
+ for (uint256 i = 0; i < tier; i++) {
+ bond *= BOND_MULTIPLIER;
+ }
+ return bond;
+ }
+
+ /// @notice Returns the tier capacity for a given region key.
+ /// Global = 4, Country = 8, Admin Area = 8.
+ function tierCapacity(uint32 regionKey) public pure returns (uint256) {
+ if (regionKey == GLOBAL_REGION) return GLOBAL_TIER_CAPACITY;
+ if (regionKey <= 999) return COUNTRY_TIER_CAPACITY;
+ return LOCAL_TIER_CAPACITY;
+ }
+
+ /// @notice Returns the lowest open tier and its bond for a region.
+ function lowestOpenTier(uint32 regionKey) external view returns (uint256 tier, uint256 bond) {
+ tier = _findOpenTierView(regionKey);
+ bond = tierBond(tier);
+ }
+
+ /// @notice Highest non-empty tier in a region, or 0 if none.
+ function highestActiveTier(uint32 regionKey) external view returns (uint256) {
+ uint256 sc = regionTierCount[regionKey];
+ if (sc == 0) return 0;
+ return sc - 1;
+ }
+
+ /// @notice Number of members in a specific tier of a region.
+ function tierMemberCount(uint32 regionKey, uint256 tier) external view returns (uint256) {
+ return _regionTierMembers[regionKey][tier].length;
+ }
+
+ /// @notice All token IDs in a specific tier of a region.
+ function getTierMembers(uint32 regionKey, uint256 tier) external view returns (uint256[] memory) {
+ return _regionTierMembers[regionKey][tier];
+ }
+
+ /// @notice All UUIDs in a specific tier of a region.
+ function getTierUUIDs(uint32 regionKey, uint256 tier) external view returns (bytes16[] memory uuids) {
+ uint256[] storage members = _regionTierMembers[regionKey][tier];
+ uuids = new bytes16[](members.length);
+ for (uint256 i = 0; i < members.length; i++) {
+ uuids[i] = bytes16(uint128(members[i]));
+ }
+ }
+
+ /// @notice UUID for a token ID.
+ function tokenUUID(uint256 tokenId) external pure returns (bytes16) {
+ return bytes16(uint128(tokenId));
+ }
+
+ /// @notice Bond amount for a token. Returns 0 for nonexistent tokens.
+ function bonds(uint256 tokenId) external view returns (uint256) {
+ if (_ownerOf(tokenId) == address(0)) return 0;
+ return tierBond(fleetTier[tokenId]);
+ }
+
+ // ══════════════════════════════════════════════
+ // Views: EdgeBeaconScanner discovery
+ // ══════════════════════════════════════════════
+
+ /// @notice Returns the highest-bonded active tier for an EdgeBeaconScanner at a specific location.
+ /// Fallback order: admin area -> country -> global.
+ /// @return regionKey The region where fleets were found (0 = global).
+ /// @return tier The highest non-empty tier in that region.
+ /// @return members The token IDs in that tier.
+ function discoverHighestBondedTier(uint16 countryCode, uint16 adminCode)
+ external
+ view
+ returns (uint32 regionKey, uint256 tier, uint256[] memory members)
+ {
+ // 1. Try admin area
+ if (countryCode > 0 && adminCode > 0) {
+ regionKey = (uint32(countryCode) << 12) | uint32(adminCode);
+ uint256 sc = regionTierCount[regionKey];
+ if (sc > 0) {
+ tier = sc - 1;
+ members = _regionTierMembers[regionKey][tier];
+ return (regionKey, tier, members);
+ }
+ }
+ // 2. Try country
+ if (countryCode > 0) {
+ regionKey = uint32(countryCode);
+ uint256 sc = regionTierCount[regionKey];
+ if (sc > 0) {
+ tier = sc - 1;
+ members = _regionTierMembers[regionKey][tier];
+ return (regionKey, tier, members);
+ }
+ }
+ // 3. Global
+ regionKey = GLOBAL_REGION;
+ uint256 sc = regionTierCount[GLOBAL_REGION];
+ if (sc > 0) {
+ tier = sc - 1;
+ members = _regionTierMembers[GLOBAL_REGION][tier];
+ }
+ // else: all empty, returns (0, 0, [])
+ }
+
+ /// @notice Returns active tier data at all three levels for a location.
+ function discoverAllLevels(uint16 countryCode, uint16 adminCode)
+ external
+ view
+ returns (uint256 globalTierCount, uint256 countryTierCount, uint256 adminTierCount, uint32 adminRegion)
+ {
+ globalTierCount = regionTierCount[GLOBAL_REGION];
+ if (countryCode > 0) {
+ countryTierCount = regionTierCount[uint32(countryCode)];
+ }
+ if (countryCode > 0 && adminCode > 0) {
+ adminRegion = (uint32(countryCode) << 12) | uint32(adminCode);
+ adminTierCount = regionTierCount[adminRegion];
+ }
+ }
+
+ /// @notice Builds a priority-ordered bundle of up to MAX_BONDED_UUID_BUNDLE_SIZE (20)
+ /// UUIDs for an EdgeBeaconScanner, merging the highest-bonded tiers across admin-area,
+ /// country, and global levels.
+ ///
+ /// **Algorithm**
+ /// Maintains a cursor (highest remaining tier) for each of the three
+ /// levels. At each step:
+ /// 1. Compute the bond for each level's cursor tier.
+ /// 2. Find the maximum bond across all levels.
+ /// 3. Take ALL members from every level whose cursor bond equals
+ /// that maximum (ties are included together).
+ /// 4. Advance those cursors downward.
+ /// 5. Repeat until the bundle is full or all cursors exhausted.
+ ///
+ /// @param countryCode EdgeBeaconScanner country (0 to skip country + admin).
+ /// @param adminCode EdgeBeaconScanner admin area (0 to skip admin).
+ /// @return uuids The merged UUID bundle (up to 20).
+ /// @return count Actual number of UUIDs returned.
+ function buildHighestBondedUUIDBundle(uint16 countryCode, uint16 adminCode)
+ external
+ view
+ returns (bytes16[] memory uuids, uint256 count)
+ {
+ uuids = new bytes16[](MAX_BONDED_UUID_BUNDLE_SIZE);
+
+ // Resolve region keys and tier counts for each level.
+ // We use int256 cursors so we can go to -1 to signal "exhausted".
+ uint32[3] memory keys;
+ int256[3] memory cursors;
+
+ // Level 0: admin area
+ if (countryCode > 0 && adminCode > 0) {
+ keys[0] = (uint32(countryCode) << 12) | uint32(adminCode);
+ uint256 sc = regionTierCount[keys[0]];
+ cursors[0] = sc > 0 ? int256(sc) - 1 : int256(-1);
+ } else {
+ cursors[0] = -1;
+ }
+
+ // Level 1: country
+ if (countryCode > 0) {
+ keys[1] = uint32(countryCode);
+ uint256 sc = regionTierCount[keys[1]];
+ cursors[1] = sc > 0 ? int256(sc) - 1 : int256(-1);
+ } else {
+ cursors[1] = -1;
+ }
+
+ // Level 2: global
+ {
+ keys[2] = GLOBAL_REGION;
+ uint256 sc = regionTierCount[GLOBAL_REGION];
+ cursors[2] = sc > 0 ? int256(sc) - 1 : int256(-1);
+ }
+
+ while (count < MAX_BONDED_UUID_BUNDLE_SIZE) {
+ // Find the maximum bond across all active cursors.
+ uint256 maxBond = 0;
+ bool anyActive = false;
+
+ for (uint256 lvl = 0; lvl < 3; lvl++) {
+ if (cursors[lvl] < 0) continue;
+ uint256 b = tierBond(uint256(cursors[lvl]));
+ if (!anyActive || b > maxBond) {
+ maxBond = b;
+ anyActive = true;
+ }
+ }
+
+ if (!anyActive) break;
+
+ // Collect members from every level whose cursor bond == maxBond.
+ for (uint256 lvl = 0; lvl < 3; lvl++) {
+ if (cursors[lvl] < 0) continue;
+ if (tierBond(uint256(cursors[lvl])) != maxBond) continue;
+
+ uint256[] storage members = _regionTierMembers[keys[lvl]][uint256(cursors[lvl])];
+ uint256 mLen = members.length;
+
+ for (uint256 m = 0; m < mLen && count < MAX_BONDED_UUID_BUNDLE_SIZE; m++) {
+ uuids[count] = bytes16(uint128(members[m]));
+ count++;
+ }
+
+ // Advance this cursor downward.
+ cursors[lvl]--;
+ }
+ }
+
+ // Trim the array to actual size.
+ assembly {
+ mstore(uuids, count)
+ }
+ }
+
+ // ══════════════════════════════════════════════
+ // Views: Region indexes
+ // ══════════════════════════════════════════════
+
+ /// @notice Returns all country codes with at least one active fleet.
+ function getActiveCountries() external view returns (uint16[] memory) {
+ return _activeCountries;
+ }
+
+ /// @notice Returns all admin-area region keys with at least one active fleet.
+ function getActiveAdminAreas() external view returns (uint32[] memory) {
+ return _activeAdminAreas;
+ }
+
+ // ══════════════════════════════════════════════
+ // Region key helpers (pure)
+ // ══════════════════════════════════════════════
+
+ /// @notice Builds a country region key from a country code.
+ function countryRegionKey(uint16 countryCode) external pure returns (uint32) {
+ return uint32(countryCode);
+ }
+
+ /// @notice Builds an admin-area region key from country + admin codes.
+ function adminRegionKey(uint16 countryCode, uint16 adminCode) external pure returns (uint32) {
+ return (uint32(countryCode) << 12) | uint32(adminCode);
+ }
+
+ // ══════════════════════════════════════════════
+ // Internals
+ // ══════════════════════════════════════════════
+
+ /// @dev Shared registration logic.
+ function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) {
+ uint256 bond = tierBond(tier);
+ tokenId = uint256(uint128(uuid));
+
+ // Effects
+ fleetRegion[tokenId] = region;
+ fleetTier[tokenId] = tier;
+ _regionTierMembers[region][tier].push(tokenId);
+ _indexInTier[tokenId] = _regionTierMembers[region][tier].length - 1;
+
+ _addToRegionIndex(region);
+ _mint(msg.sender, tokenId);
+
+ // Interaction
+ if (bond > 0) {
+ BOND_TOKEN.safeTransferFrom(msg.sender, address(this), bond);
+ }
+
+ emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, bond);
+ }
+
+ /// @dev Shared promotion logic.
+ function _promote(uint256 tokenId, uint256 targetTier) internal {
+ address tokenOwner = ownerOf(tokenId);
+ if (tokenOwner != msg.sender) revert NotTokenOwner();
+
+ uint32 region = fleetRegion[tokenId];
+ uint256 currentTier = fleetTier[tokenId];
+ if (targetTier <= currentTier) revert TargetTierNotHigher();
+ if (targetTier >= MAX_TIERS) revert MaxTiersReached();
+ if (_regionTierMembers[region][targetTier].length >= tierCapacity(region)) revert TierFull();
+
+ uint256 currentBond = tierBond(currentTier);
+ uint256 targetBond = tierBond(targetTier);
+ uint256 additionalBond = targetBond - currentBond;
+
+ // Effects
+ _removeFromTier(tokenId, region, currentTier);
+ fleetTier[tokenId] = targetTier;
+ _regionTierMembers[region][targetTier].push(tokenId);
+ _indexInTier[tokenId] = _regionTierMembers[region][targetTier].length - 1;
+
+ if (targetTier >= regionTierCount[region]) {
+ regionTierCount[region] = targetTier + 1;
+ }
+
+ // Interaction
+ if (additionalBond > 0) {
+ BOND_TOKEN.safeTransferFrom(tokenOwner, address(this), additionalBond);
+ }
+
+ emit FleetPromoted(tokenId, currentTier, targetTier, additionalBond);
+ }
+
+ /// @dev Shared demotion logic. Refunds bond difference.
+ function _demote(uint256 tokenId, uint256 targetTier) internal {
+ address tokenOwner = ownerOf(tokenId);
+ if (tokenOwner != msg.sender) revert NotTokenOwner();
+
+ uint32 region = fleetRegion[tokenId];
+ uint256 currentTier = fleetTier[tokenId];
+ if (targetTier >= currentTier) revert TargetTierNotLower();
+ if (_regionTierMembers[region][targetTier].length >= tierCapacity(region)) revert TierFull();
+
+ uint256 currentBond = tierBond(currentTier);
+ uint256 targetBond = tierBond(targetTier);
+ uint256 refund = currentBond - targetBond;
+
+ // Effects
+ _removeFromTier(tokenId, region, currentTier);
+ fleetTier[tokenId] = targetTier;
+ _regionTierMembers[region][targetTier].push(tokenId);
+ _indexInTier[tokenId] = _regionTierMembers[region][targetTier].length - 1;
+
+ _trimTierCount(region);
+
+ // Interaction
+ if (refund > 0) {
+ BOND_TOKEN.safeTransfer(tokenOwner, refund);
+ }
+
+ emit FleetDemoted(tokenId, currentTier, targetTier, refund);
+ }
+
+ /// @dev Validates and prepares an explicit tier for registration.
+ function _validateExplicitTier(uint32 region, uint256 targetTier) internal {
+ if (targetTier >= MAX_TIERS) revert MaxTiersReached();
+ if (_regionTierMembers[region][targetTier].length >= tierCapacity(region)) revert TierFull();
+ if (targetTier >= regionTierCount[region]) {
+ regionTierCount[region] = targetTier + 1;
+ }
+ }
+
+ /// @dev Finds lowest open tier within a region, opening a new one if needed.
+ function _openTier(uint32 region) internal returns (uint256) {
+ uint256 sc = regionTierCount[region];
+ uint256 cap = tierCapacity(region);
+ uint256 start = _regionLowestHint[region];
+ for (uint256 i = start; i < sc; i++) {
+ if (_regionTierMembers[region][i].length < cap) {
+ _regionLowestHint[region] = i;
+ return i;
+ }
+ }
+ if (sc >= MAX_TIERS) revert MaxTiersReached();
+ regionTierCount[region] = sc + 1;
+ _regionLowestHint[region] = sc;
+ return sc;
+ }
+
+ /// @dev View-only version of _openTier.
+ function _findOpenTierView(uint32 region) internal view returns (uint256) {
+ uint256 sc = regionTierCount[region];
+ uint256 cap = tierCapacity(region);
+ uint256 start = _regionLowestHint[region];
+ for (uint256 i = start; i < sc; i++) {
+ if (_regionTierMembers[region][i].length < cap) return i;
+ }
+ if (sc >= MAX_TIERS) revert MaxTiersReached();
+ return sc;
+ }
+
+ /// @dev Swap-and-pop removal from a region's tier member array.
+ function _removeFromTier(uint256 tokenId, uint32 region, uint256 tier) internal {
+ uint256[] storage members = _regionTierMembers[region][tier];
+ uint256 idx = _indexInTier[tokenId];
+ uint256 lastIdx = members.length - 1;
+
+ if (idx != lastIdx) {
+ uint256 lastTokenId = members[lastIdx];
+ members[idx] = lastTokenId;
+ _indexInTier[lastTokenId] = idx;
+ }
+ members.pop();
+
+ if (tier < _regionLowestHint[region]) {
+ _regionLowestHint[region] = tier;
+ }
+ }
+
+ /// @dev Shrinks regionTierCount so the top tier is always non-empty.
+ function _trimTierCount(uint32 region) internal {
+ uint256 sc = regionTierCount[region];
+ while (sc > 0 && _regionTierMembers[region][sc - 1].length == 0) {
+ sc--;
+ }
+ regionTierCount[region] = sc;
+ }
+
+ // -- Region index maintenance --
+
+ /// @dev Adds a region to the appropriate index set if not already present.
+ function _addToRegionIndex(uint32 region) internal {
+ if (region == GLOBAL_REGION) {
+ globalActive = true;
+ } else if (region <= 999) {
+ // Country
+ uint16 cc = uint16(region);
+ if (_activeCountryIndex[cc] == 0) {
+ _activeCountries.push(cc);
+ _activeCountryIndex[cc] = _activeCountries.length; // 1-indexed
+ }
+ } else {
+ // Admin area
+ if (_activeAdminAreaIndex[region] == 0) {
+ _activeAdminAreas.push(region);
+ _activeAdminAreaIndex[region] = _activeAdminAreas.length;
+ }
+ }
+ }
+
+ /// @dev Removes a region from the index set if the region is now completely empty.
+ function _removeFromRegionIndex(uint32 region) internal {
+ if (regionTierCount[region] > 0) return; // still has fleets
+
+ if (region == GLOBAL_REGION) {
+ globalActive = false;
+ } else if (region <= 999) {
+ uint16 cc = uint16(region);
+ uint256 oneIdx = _activeCountryIndex[cc];
+ if (oneIdx > 0) {
+ uint256 lastIdx = _activeCountries.length - 1;
+ uint256 removeIdx = oneIdx - 1;
+ if (removeIdx != lastIdx) {
+ uint16 lastCC = _activeCountries[lastIdx];
+ _activeCountries[removeIdx] = lastCC;
+ _activeCountryIndex[lastCC] = oneIdx;
+ }
+ _activeCountries.pop();
+ delete _activeCountryIndex[cc];
+ }
+ } else {
+ uint256 oneIdx = _activeAdminAreaIndex[region];
+ if (oneIdx > 0) {
+ uint256 lastIdx = _activeAdminAreas.length - 1;
+ uint256 removeIdx = oneIdx - 1;
+ if (removeIdx != lastIdx) {
+ uint32 lastAA = _activeAdminAreas[lastIdx];
+ _activeAdminAreas[removeIdx] = lastAA;
+ _activeAdminAreaIndex[lastAA] = oneIdx;
+ }
+ _activeAdminAreas.pop();
+ delete _activeAdminAreaIndex[region];
+ }
+ }
+ }
+
+ // ──────────────────────────────────────────────
+ // Overrides required by ERC721Enumerable
+ // ──────────────────────────────────────────────
+
+ function _update(address to, uint256 tokenId, address auth) internal override(ERC721Enumerable) returns (address) {
+ return super._update(to, tokenId, auth);
+ }
+
+ function _increaseBalance(address account, uint128 value) internal override(ERC721Enumerable) {
+ super._increaseBalance(account, value);
+ }
+
+ function supportsInterface(bytes4 interfaceId) public view override(ERC721Enumerable) returns (bool) {
+ return super.supportsInterface(interfaceId);
+ }
+}
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..ffa3cb4
--- /dev/null
+++ b/src/swarms/doc/assistant-guide.md
@@ -0,0 +1,209 @@
+# 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 Enumerable) | `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 **permissionless** — access control is enforced through NFT ownership rather than admin roles. `FleetIdentity` additionally requires an ERC-20 bond (e.g. NODL) to register a fleet, acting as an anti-spam / anti-abuse mechanism.
+
+Both NFT contracts support **burning** — the token owner can call `burn(tokenId)` to destroy their NFT. Burning a `FleetIdentity` token refunds the full bond to the owner. Burning either NFT 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**:
+ 1. Approves the bond token: `NODL.approve(fleetIdentityAddress, bondAmount)`.
+ 2. Calls `FleetIdentity.registerFleet(0xUUID..., bondAmount)`. Receives `fleetId` (= `uint256(uint128(uuid))`). The `bondAmount` must be ≥ `MIN_BOND` (set at deploy).
+ 3. _(Optional)_ Calls `FleetIdentity.increaseBond(fleetId, additionalAmount)` to top-up later. Anyone can top-up any fleet's bond.
+
+### 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 "EdgeBeaconScanner" Perspective)
+
+A client (mobile phone or gateway) scans a BLE beacon and wants to find its owner and backend service.
+
+### Step 1: Scan & Detect
+
+- EdgeBeaconScanner detects iBeacon: `UUID: E2C5...`, `Major: 1`, `Minor: 50`, `MAC: AA:BB...`.
+
+### Step 2: Identify Fleet
+
+- EdgeBeaconScanner checks `FleetIdentity` contract.
+- Calls `ownerOf(uint256(uint128(uuid)))` — reverts if the fleet does not exist.
+- _(Optional)_ Reads `bonds(tokenId)` to assess fleet credibility.
+- **Result**: "This beacon belongs to Fleet #42".
+
+### Step 3: Find Swarms
+
+- EdgeBeaconScanner 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 an EdgeBeaconScanner 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..59c173e
--- /dev/null
+++ b/src/swarms/doc/graph-architecture.md
@@ -0,0 +1,104 @@
+# 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"]
+ REG["SwarmRegistry
L1 variant: SSTORE2 filter storage
Universal variant: native bytes storage"]
+ end
+
+ subgraph Actors
+ FO(("Fleet
Owner"))
+ PRV(("Service
Provider"))
+ ANY(("Anyone
(EdgeBeaconScanner / Purger)"))
+ end
+
+ FO -- "registerFleet(uuid, bondAmount)" --> FI
+ FO -- "registerSwarm / update / delete" --> REG
+ PRV -- "registerProvider(url)" --> SP
+ PRV -- "acceptSwarm / rejectSwarm" --> REG
+ ANY -- "checkMembership / purgeOrphanedSwarm" --> REG
+
+ REG -. "ownerOf(fleetId)" .-> FI
+ REG -. "ownerOf(providerId)" .-> SP
+
+ style FI fill:#4a9eff,color:#fff
+ style SP fill:#4a9eff,color:#fff
+ style REG 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 {
+ +IERC20 BOND_TOKEN (immutable)
+ +uint256 MIN_BOND (immutable)
+ +mapping bonds
+ +registerFleet(uuid, bondAmount) tokenId
+ +increaseBond(tokenId, amount)
+ +burn(tokenId)
+ +tokenUUID(tokenId) bytes16
+ +totalSupply() uint256
+ +tokenByIndex(index) uint256
+ +tokenOfOwnerByIndex(owner, index) uint256
+ }
+
+ 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..ea8cf32
--- /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 EBS as EdgeBeaconScanner (Client)
+ participant FI as FleetIdentity
+ participant SR as SwarmRegistry
+ participant SP as ServiceProvider
+
+ Note over EBS: Detects iBeacon:
UUID, Major, Minor, MAC
+
+ rect rgb(240, 248, 255)
+ Note right of EBS: Step 1 — Identify fleet
+ EBS ->>+ FI: ownerOf(uint128(uuid))
+ FI -->>- EBS: fleet owner address (fleet exists ✓)
+ end
+
+ rect rgb(255, 248, 240)
+ Note right of EBS: Step 2 — Enumerate swarms
+ EBS ->>+ SR: fleetSwarms(fleetId, 0)
+ SR -->>- EBS: swarmId_0
+ EBS ->>+ SR: fleetSwarms(fleetId, 1)
+ SR -->>- EBS: swarmId_1
+ Note over EBS: ... iterate until revert (end of array)
+ end
+
+ rect rgb(240, 255, 240)
+ Note right of EBS: Step 3 — Find matching swarm
+ Note over EBS: Read swarms[swarmId_0].tagType
+ Note over EBS: Construct tagId per schema:
UUID || Major || Minor [|| MAC]
+ Note over EBS: tagHash = keccak256(tagId)
+ EBS ->>+ SR: checkMembership(swarmId_0, tagHash)
+ SR -->>- EBS: false (not in this swarm)
+
+ EBS ->>+ SR: checkMembership(swarmId_1, tagHash)
+ SR -->>- EBS: true ✓ (tag found!)
+ end
+
+ rect rgb(248, 240, 255)
+ Note right of EBS: Step 4 — Resolve service URL
+ EBS ->>+ SR: swarms(swarmId_1)
+ SR -->>- EBS: { providerId, status: ACCEPTED, ... }
+ EBS ->>+ SP: providerUrls(providerId)
+ SP -->>- EBS: "https://api.acme-tracking.com"
+ end
+
+ Note over EBS: 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..cdcedfa
--- /dev/null
+++ b/src/swarms/doc/sequence-lifecycle.md
@@ -0,0 +1,112 @@
+# 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)
+ Note over NFT: If FleetIdentity: refunds full bond
to token owner via BOND_TOKEN.safeTransfer
+ NFT -->>- Owner: ✓ token destroyed + bond refunded
+ 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..37c4483
--- /dev/null
+++ b/src/swarms/doc/sequence-registration.md
@@ -0,0 +1,79 @@
+# 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)
+
+ Note over FO: Approve bond token first:
+ Note over FO: NODL.approve(FleetIdentity, bondAmount)
+
+ FO ->>+ FI: registerFleet(uuid, bondAmount)
+ Note over FI: Requires bondAmount ≥ MIN_BOND
+ Note over FI: Locks bondAmount of BOND_TOKEN
+ 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..5f0c168
--- /dev/null
+++ b/test/FleetIdentity.t.sol
@@ -0,0 +1,1417 @@
+// SPDX-License-Identifier: MIT
+pragma solidity ^0.8.24;
+
+import "forge-std/Test.sol";
+import "../src/swarms/FleetIdentity.sol";
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+
+/// @dev Minimal ERC-20 mock with public mint for testing.
+contract MockERC20 is ERC20 {
+ constructor() ERC20("Mock Bond Token", "MBOND") {}
+
+ function mint(address to, uint256 amount) external {
+ _mint(to, amount);
+ }
+}
+
+/// @dev ERC-20 that returns false on transfer instead of reverting.
+contract BadERC20 is ERC20 {
+ bool public shouldFail;
+
+ constructor() ERC20("Bad Token", "BAD") {}
+
+ function mint(address to, uint256 amount) external {
+ _mint(to, amount);
+ }
+
+ function setFail(bool _fail) external {
+ shouldFail = _fail;
+ }
+
+ function transfer(address to, uint256 amount) public override returns (bool) {
+ if (shouldFail) return false;
+ return super.transfer(to, amount);
+ }
+
+ function transferFrom(address from, address to, uint256 amount) public override returns (bool) {
+ if (shouldFail) return false;
+ return super.transferFrom(from, to, amount);
+ }
+}
+
+contract FleetIdentityTest is Test {
+ FleetIdentity fleet;
+ MockERC20 bondToken;
+
+ address alice = address(0xA);
+ address bob = address(0xB);
+ address carol = address(0xC);
+
+ bytes16 constant UUID_1 = bytes16(keccak256("fleet-alpha"));
+ bytes16 constant UUID_2 = bytes16(keccak256("fleet-bravo"));
+ bytes16 constant UUID_3 = bytes16(keccak256("fleet-charlie"));
+
+ uint256 constant BASE_BOND = 100 ether;
+
+ uint16 constant US = 840;
+ uint16 constant DE = 276;
+ uint16 constant ADMIN_CA = 1;
+ uint16 constant ADMIN_NY = 2;
+
+ event FleetRegistered(
+ address indexed owner,
+ bytes16 indexed uuid,
+ uint256 indexed tokenId,
+ uint32 regionKey,
+ uint256 tierIndex,
+ uint256 bondAmount
+ );
+ event FleetPromoted(uint256 indexed tokenId, uint256 fromTier, uint256 toTier, uint256 additionalBond);
+ event FleetDemoted(uint256 indexed tokenId, uint256 fromTier, uint256 toTier, uint256 bondRefund);
+ event FleetBurned(
+ address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 tierIndex
+ );
+
+ function setUp() public {
+ bondToken = new MockERC20();
+ fleet = new FleetIdentity(address(bondToken), BASE_BOND);
+
+ bondToken.mint(alice, 100_000_000 ether);
+ bondToken.mint(bob, 100_000_000 ether);
+ bondToken.mint(carol, 100_000_000 ether);
+
+ vm.prank(alice);
+ bondToken.approve(address(fleet), type(uint256).max);
+ vm.prank(bob);
+ bondToken.approve(address(fleet), type(uint256).max);
+ vm.prank(carol);
+ bondToken.approve(address(fleet), type(uint256).max);
+ }
+
+ // --- Helpers ---
+
+ function _uuid(uint256 i) internal pure returns (bytes16) {
+ return bytes16(keccak256(abi.encodePacked("fleet-", i)));
+ }
+
+ uint32 constant GLOBAL = 0;
+
+ function _regionUS() internal pure returns (uint32) {
+ return uint32(US);
+ }
+
+ function _regionDE() internal pure returns (uint32) {
+ return uint32(DE);
+ }
+
+ function _regionUSCA() internal pure returns (uint32) {
+ return (uint32(US) << 12) | uint32(ADMIN_CA);
+ }
+
+ function _regionUSNY() internal pure returns (uint32) {
+ return (uint32(US) << 12) | uint32(ADMIN_NY);
+ }
+
+ function _registerNGlobal(address owner, uint256 count) internal returns (uint256[] memory ids) {
+ ids = new uint256[](count);
+ for (uint256 i = 0; i < count; i++) {
+ vm.prank(owner);
+ ids[i] = fleet.registerFleetGlobal(_uuid(i));
+ }
+ }
+
+ function _registerNCountry(address owner, uint16 cc, uint256 count, uint256 startSeed)
+ internal
+ returns (uint256[] memory ids)
+ {
+ ids = new uint256[](count);
+ for (uint256 i = 0; i < count; i++) {
+ vm.prank(owner);
+ ids[i] = fleet.registerFleetCountry(_uuid(startSeed + i), cc);
+ }
+ }
+
+ function _registerNLocal(address owner, uint16 cc, uint16 admin, uint256 count, uint256 startSeed)
+ internal
+ returns (uint256[] memory ids)
+ {
+ ids = new uint256[](count);
+ for (uint256 i = 0; i < count; i++) {
+ vm.prank(owner);
+ ids[i] = fleet.registerFleetLocal(_uuid(startSeed + i), cc, admin);
+ }
+ }
+
+ // --- Constructor ---
+
+ function test_constructor_setsImmutables() public view {
+ assertEq(address(fleet.BOND_TOKEN()), address(bondToken));
+ assertEq(fleet.BASE_BOND(), BASE_BOND);
+ assertEq(fleet.BOND_MULTIPLIER(), 2);
+ assertEq(fleet.name(), "Swarm Fleet Identity");
+ assertEq(fleet.symbol(), "SFID");
+ assertEq(fleet.GLOBAL_REGION(), 0);
+ }
+
+ function test_constructor_constants() public view {
+ assertEq(fleet.GLOBAL_TIER_CAPACITY(), 4);
+ assertEq(fleet.COUNTRY_TIER_CAPACITY(), 8);
+ assertEq(fleet.LOCAL_TIER_CAPACITY(), 8);
+ assertEq(fleet.MAX_TIERS(), 24);
+ assertEq(fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), 20);
+ }
+
+ function test_tierCapacity_perLevel() public view {
+ assertEq(fleet.tierCapacity(GLOBAL), 4);
+ assertEq(fleet.tierCapacity(_regionUS()), 8);
+ assertEq(fleet.tierCapacity(_regionUSCA()), 8);
+ }
+
+ // --- tierBond ---
+
+ function test_tierBond_tier0() public view {
+ assertEq(fleet.tierBond(0), BASE_BOND);
+ }
+
+ function test_tierBond_tier1() public view {
+ assertEq(fleet.tierBond(1), BASE_BOND * 2);
+ }
+
+ function test_tierBond_tier2() public view {
+ assertEq(fleet.tierBond(2), BASE_BOND * 2 * 2);
+ }
+
+ function test_tierBond_geometricProgression() public view {
+ for (uint256 i = 1; i <= 5; i++) {
+ assertEq(fleet.tierBond(i), fleet.tierBond(i - 1) * 2);
+ }
+ }
+
+ // --- registerFleetGlobal auto ---
+
+ function test_registerFleetGlobal_auto_mintsAndLocksBond() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ assertEq(fleet.ownerOf(tokenId), alice);
+ assertEq(tokenId, uint256(uint128(UUID_1)));
+ assertEq(fleet.bonds(tokenId), BASE_BOND);
+ assertEq(fleet.fleetRegion(tokenId), GLOBAL);
+ assertEq(fleet.fleetTier(tokenId), 0);
+ assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND);
+ }
+
+ function test_registerFleetGlobal_auto_emitsEvent() public {
+ uint256 expectedTokenId = uint256(uint128(UUID_1));
+
+ vm.expectEmit(true, true, true, true);
+ emit FleetRegistered(alice, UUID_1, expectedTokenId, GLOBAL, 0, BASE_BOND);
+
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+ }
+
+ function test_RevertIf_registerFleetGlobal_auto_zeroUUID() public {
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.InvalidUUID.selector);
+ fleet.registerFleetGlobal(bytes16(0));
+ }
+
+ function test_RevertIf_registerFleetGlobal_auto_duplicateUUID() public {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(bob);
+ vm.expectRevert();
+ fleet.registerFleetGlobal(UUID_1);
+ }
+
+ // --- registerFleetGlobal explicit tier ---
+
+ function test_registerFleetGlobal_explicit_joinsSpecifiedTier() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2);
+
+ assertEq(fleet.fleetTier(tokenId), 2);
+ assertEq(fleet.fleetRegion(tokenId), GLOBAL);
+ assertEq(fleet.bonds(tokenId), fleet.tierBond(2));
+ assertEq(fleet.tierMemberCount(GLOBAL, 2), 1);
+ assertEq(fleet.regionTierCount(GLOBAL), 3);
+ }
+
+ function test_RevertIf_registerFleetGlobal_explicit_exceedsMaxTiers() public {
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.MaxTiersReached.selector);
+ fleet.registerFleetGlobal(UUID_1, 50);
+ }
+
+ // --- registerFleetCountry ---
+
+ function test_registerFleetCountry_auto_setsRegionAndTier() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetCountry(UUID_1, US);
+
+ assertEq(fleet.fleetRegion(tokenId), _regionUS());
+ assertEq(fleet.fleetTier(tokenId), 0);
+ assertEq(fleet.bonds(tokenId), BASE_BOND);
+ assertEq(fleet.regionTierCount(_regionUS()), 1);
+ }
+
+ function test_registerFleetCountry_explicit_tier() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 3);
+
+ assertEq(fleet.fleetTier(tokenId), 3);
+ assertEq(fleet.bonds(tokenId), fleet.tierBond(3));
+ assertEq(fleet.regionTierCount(_regionUS()), 4);
+ }
+
+ function test_RevertIf_registerFleetCountry_invalidCode_zero() public {
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.InvalidCountryCode.selector);
+ fleet.registerFleetCountry(UUID_1, 0);
+ }
+
+ function test_RevertIf_registerFleetCountry_invalidCode_over999() public {
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.InvalidCountryCode.selector);
+ fleet.registerFleetCountry(UUID_1, 1000);
+ }
+
+ // --- registerFleetLocal ---
+
+ function test_registerFleetLocal_auto_setsRegionAndTier() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA);
+
+ assertEq(fleet.fleetRegion(tokenId), _regionUSCA());
+ assertEq(fleet.fleetTier(tokenId), 0);
+ assertEq(fleet.bonds(tokenId), BASE_BOND);
+ }
+
+ function test_registerFleetLocal_explicit_tier() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2);
+
+ assertEq(fleet.fleetTier(tokenId), 2);
+ assertEq(fleet.bonds(tokenId), fleet.tierBond(2));
+ }
+
+ function test_RevertIf_registerFleetLocal_invalidCountry() public {
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.InvalidCountryCode.selector);
+ fleet.registerFleetLocal(UUID_1, 0, ADMIN_CA);
+ }
+
+ function test_RevertIf_registerFleetLocal_invalidAdmin_zero() public {
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.InvalidAdminCode.selector);
+ fleet.registerFleetLocal(UUID_1, US, 0);
+ }
+
+ function test_RevertIf_registerFleetLocal_invalidAdmin_over4095() public {
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.InvalidAdminCode.selector);
+ fleet.registerFleetLocal(UUID_1, US, 4096);
+ }
+
+ // --- Per-region independent tier indexing (KEY REQUIREMENT) ---
+
+ function test_perRegionTiers_firstFleetInEveryRegionPaysSameBond() public {
+ vm.prank(alice);
+ uint256 g1 = fleet.registerFleetGlobal(UUID_1);
+ vm.prank(alice);
+ uint256 c1 = fleet.registerFleetCountry(UUID_2, US);
+ vm.prank(alice);
+ uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA);
+
+ assertEq(fleet.fleetTier(g1), 0);
+ assertEq(fleet.fleetTier(c1), 0);
+ assertEq(fleet.fleetTier(l1), 0);
+
+ assertEq(fleet.bonds(g1), BASE_BOND);
+ assertEq(fleet.bonds(c1), BASE_BOND);
+ assertEq(fleet.bonds(l1), BASE_BOND);
+ }
+
+ function test_perRegionTiers_fillOneRegionDoesNotAffectOthers() public {
+ _registerNGlobal(alice, 4);
+ assertEq(fleet.regionTierCount(GLOBAL), 1);
+ assertEq(fleet.tierMemberCount(GLOBAL, 0), 4);
+
+ vm.prank(bob);
+ uint256 g21 = fleet.registerFleetGlobal(_uuid(100));
+ assertEq(fleet.fleetTier(g21), 1);
+ assertEq(fleet.bonds(g21), BASE_BOND * 2);
+
+ vm.prank(bob);
+ uint256 us1 = fleet.registerFleetCountry(_uuid(200), US);
+ assertEq(fleet.fleetTier(us1), 0);
+ assertEq(fleet.bonds(us1), BASE_BOND);
+ assertEq(fleet.regionTierCount(_regionUS()), 1);
+
+ vm.prank(bob);
+ uint256 usca1 = fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA);
+ assertEq(fleet.fleetTier(usca1), 0);
+ assertEq(fleet.bonds(usca1), BASE_BOND);
+ }
+
+ function test_perRegionTiers_twoCountriesIndependent() public {
+ _registerNCountry(alice, US, 8, 0);
+ assertEq(fleet.tierMemberCount(_regionUS(), 0), 8);
+
+ vm.prank(bob);
+ uint256 us21 = fleet.registerFleetCountry(_uuid(500), US);
+ assertEq(fleet.fleetTier(us21), 1);
+ assertEq(fleet.bonds(us21), BASE_BOND * 2);
+
+ vm.prank(bob);
+ uint256 de1 = fleet.registerFleetCountry(_uuid(600), DE);
+ assertEq(fleet.fleetTier(de1), 0);
+ assertEq(fleet.bonds(de1), BASE_BOND);
+ }
+
+ function test_perRegionTiers_twoAdminAreasIndependent() public {
+ _registerNLocal(alice, US, ADMIN_CA, 8, 0);
+ assertEq(fleet.tierMemberCount(_regionUSCA(), 0), 8);
+
+ vm.prank(bob);
+ uint256 ny1 = fleet.registerFleetLocal(_uuid(500), US, ADMIN_NY);
+ assertEq(fleet.fleetTier(ny1), 0);
+ assertEq(fleet.bonds(ny1), BASE_BOND);
+ }
+
+ // --- Auto-assign tier logic ---
+
+ function test_autoAssign_fillsTier0BeforeOpeningTier1() public {
+ _registerNGlobal(alice, 4);
+ assertEq(fleet.regionTierCount(GLOBAL), 1);
+
+ vm.prank(bob);
+ uint256 id5 = fleet.registerFleetGlobal(_uuid(20));
+ assertEq(fleet.fleetTier(id5), 1);
+ assertEq(fleet.regionTierCount(GLOBAL), 2);
+ }
+
+ function test_autoAssign_backfillsTier0WhenSlotOpens() public {
+ uint256[] memory ids = _registerNGlobal(alice, 4);
+
+ vm.prank(alice);
+ fleet.burn(ids[2]);
+ assertEq(fleet.tierMemberCount(GLOBAL, 0), 3);
+
+ vm.prank(bob);
+ uint256 newId = fleet.registerFleetGlobal(_uuid(100));
+ assertEq(fleet.fleetTier(newId), 0);
+ assertEq(fleet.tierMemberCount(GLOBAL, 0), 4);
+ }
+
+ // --- promote ---
+
+ function test_promote_next_movesToNextTierInRegion() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetCountry(UUID_1, US);
+
+ vm.prank(alice);
+ fleet.promote(tokenId);
+
+ assertEq(fleet.fleetTier(tokenId), 1);
+ assertEq(fleet.fleetRegion(tokenId), _regionUS());
+ assertEq(fleet.bonds(tokenId), fleet.tierBond(1));
+ }
+
+ function test_promote_next_pullsBondDifference() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ uint256 balBefore = bondToken.balanceOf(alice);
+ uint256 diff = fleet.tierBond(1) - fleet.tierBond(0);
+
+ vm.prank(alice);
+ fleet.promote(tokenId);
+
+ assertEq(bondToken.balanceOf(alice), balBefore - diff);
+ }
+
+ function test_reassignTier_promotesWhenTargetHigher() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA);
+
+ vm.prank(alice);
+ fleet.reassignTier(tokenId, 3);
+
+ assertEq(fleet.fleetTier(tokenId), 3);
+ assertEq(fleet.bonds(tokenId), fleet.tierBond(3));
+ assertEq(fleet.regionTierCount(_regionUSCA()), 4);
+ }
+
+ function test_promote_emitsEvent() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+ uint256 diff = fleet.tierBond(1) - fleet.tierBond(0);
+
+ vm.expectEmit(true, true, true, true);
+ emit FleetPromoted(tokenId, 0, 1, diff);
+
+ vm.prank(alice);
+ fleet.promote(tokenId);
+ }
+
+ function test_RevertIf_promote_notOwner() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(bob);
+ vm.expectRevert(FleetIdentity.NotTokenOwner.selector);
+ fleet.promote(tokenId);
+ }
+
+ function test_RevertIf_reassignTier_targetSameAsCurrent() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2);
+
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.TargetTierSameAsCurrent.selector);
+ fleet.reassignTier(tokenId, 2);
+ }
+
+ function test_RevertIf_promote_targetTierFull() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ for (uint256 i = 0; i < 4; i++) {
+ vm.prank(bob);
+ fleet.registerFleetGlobal(_uuid(50 + i), 1);
+ }
+
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.TierFull.selector);
+ fleet.promote(tokenId);
+ }
+
+ function test_RevertIf_reassignTier_exceedsMaxTiers() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(alice);
+ vm.expectRevert(FleetIdentity.MaxTiersReached.selector);
+ fleet.reassignTier(tokenId, 50);
+ }
+
+ // --- reassignTier (demote direction) ---
+
+ function test_reassignTier_demotesWhenTargetLower() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetCountry(UUID_1, DE, 3);
+
+ vm.prank(alice);
+ fleet.reassignTier(tokenId, 1);
+
+ assertEq(fleet.fleetTier(tokenId), 1);
+ assertEq(fleet.bonds(tokenId), fleet.tierBond(1));
+ }
+
+ function test_reassignTier_demoteRefundsBondDifference() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3);
+
+ uint256 balBefore = bondToken.balanceOf(alice);
+ uint256 refund = fleet.tierBond(3) - fleet.tierBond(1);
+
+ vm.prank(alice);
+ fleet.reassignTier(tokenId, 1);
+
+ assertEq(bondToken.balanceOf(alice), balBefore + refund);
+ }
+
+ function test_reassignTier_demoteEmitsEvent() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3);
+ uint256 refund = fleet.tierBond(3) - fleet.tierBond(1);
+
+ vm.expectEmit(true, true, true, true);
+ emit FleetDemoted(tokenId, 3, 1, refund);
+
+ vm.prank(alice);
+ fleet.reassignTier(tokenId, 1);
+ }
+
+ function test_reassignTier_demoteTrimsTierCountWhenTopEmpties() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3);
+ assertEq(fleet.regionTierCount(GLOBAL), 4);
+
+ vm.prank(alice);
+ fleet.reassignTier(tokenId, 0);
+ assertEq(fleet.regionTierCount(GLOBAL), 1);
+ }
+
+ function test_RevertIf_reassignTier_demoteNotOwner() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2);
+
+ vm.prank(bob);
+ vm.expectRevert(FleetIdentity.NotTokenOwner.selector);
+ fleet.reassignTier(tokenId, 0);
+ }
+
+ function test_RevertIf_reassignTier_demoteTargetTierFull() public {
+ _registerNGlobal(alice, 4);
+
+ vm.prank(bob);
+ uint256 tokenId = fleet.registerFleetGlobal(_uuid(100), 2);
+
+ vm.prank(bob);
+ vm.expectRevert(FleetIdentity.TierFull.selector);
+ fleet.reassignTier(tokenId, 0);
+ }
+
+ function test_RevertIf_reassignTier_promoteNotOwner() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(bob);
+ vm.expectRevert(FleetIdentity.NotTokenOwner.selector);
+ fleet.reassignTier(tokenId, 3);
+ }
+
+ // --- burn ---
+
+ function test_burn_refundsTierBond() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+ uint256 balBefore = bondToken.balanceOf(alice);
+
+ vm.prank(alice);
+ fleet.burn(tokenId);
+
+ assertEq(bondToken.balanceOf(alice), balBefore + BASE_BOND);
+ assertEq(bondToken.balanceOf(address(fleet)), 0);
+ assertEq(fleet.bonds(tokenId), 0);
+ }
+
+ function test_burn_emitsEvent() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ vm.expectEmit(true, true, true, true);
+ emit FleetBurned(alice, tokenId, BASE_BOND, GLOBAL, 0);
+
+ vm.prank(alice);
+ fleet.burn(tokenId);
+ }
+
+ function test_burn_trimsTierCount() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 3);
+ assertEq(fleet.regionTierCount(_regionUS()), 4);
+
+ vm.prank(alice);
+ fleet.burn(tokenId);
+ assertEq(fleet.regionTierCount(_regionUS()), 0);
+ }
+
+ function test_burn_allowsReregistration() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(alice);
+ fleet.burn(tokenId);
+
+ vm.prank(bob);
+ uint256 newId = fleet.registerFleetCountry(UUID_1, DE);
+ assertEq(newId, tokenId);
+ assertEq(fleet.fleetRegion(newId), _regionDE());
+ }
+
+ function test_RevertIf_burn_notOwner() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(bob);
+ vm.expectRevert(FleetIdentity.NotTokenOwner.selector);
+ fleet.burn(tokenId);
+ }
+
+ // --- lowestOpenTier ---
+
+ function test_lowestOpenTier_initiallyZeroForAnyRegion() public view {
+ (uint256 tier, uint256 bond) = fleet.lowestOpenTier(GLOBAL);
+ assertEq(tier, 0);
+ assertEq(bond, BASE_BOND);
+
+ (tier, bond) = fleet.lowestOpenTier(_regionUS());
+ assertEq(tier, 0);
+ assertEq(bond, BASE_BOND);
+ }
+
+ function test_lowestOpenTier_perRegionAfterFilling() public {
+ _registerNGlobal(alice, 4);
+
+ (uint256 gTier, uint256 gBond) = fleet.lowestOpenTier(GLOBAL);
+ assertEq(gTier, 1);
+ assertEq(gBond, BASE_BOND * 2);
+
+ (uint256 usTier, uint256 usBond) = fleet.lowestOpenTier(_regionUS());
+ assertEq(usTier, 0);
+ assertEq(usBond, BASE_BOND);
+ }
+
+ // --- highestActiveTier ---
+
+ function test_highestActiveTier_noFleets() public view {
+ assertEq(fleet.highestActiveTier(GLOBAL), 0);
+ assertEq(fleet.highestActiveTier(_regionUS()), 0);
+ }
+
+ function test_highestActiveTier_afterRegistrations() public {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1, 3);
+ assertEq(fleet.highestActiveTier(GLOBAL), 3);
+
+ assertEq(fleet.highestActiveTier(_regionUS()), 0);
+ }
+
+ // --- EdgeBeaconScanner helpers ---
+
+ function test_tierMemberCount_perRegion() public {
+ _registerNGlobal(alice, 3);
+ _registerNCountry(bob, US, 5, 100);
+
+ assertEq(fleet.tierMemberCount(GLOBAL, 0), 3);
+ assertEq(fleet.tierMemberCount(_regionUS(), 0), 5);
+ }
+
+ function test_getTierMembers_perRegion() public {
+ vm.prank(alice);
+ uint256 gId = fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(bob);
+ uint256 usId = fleet.registerFleetCountry(UUID_2, US);
+
+ uint256[] memory gMembers = fleet.getTierMembers(GLOBAL, 0);
+ assertEq(gMembers.length, 1);
+ assertEq(gMembers[0], gId);
+
+ uint256[] memory usMembers = fleet.getTierMembers(_regionUS(), 0);
+ assertEq(usMembers.length, 1);
+ assertEq(usMembers[0], usId);
+ }
+
+ function test_getTierUUIDs_perRegion() public {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(bob);
+ fleet.registerFleetCountry(UUID_2, US);
+
+ bytes16[] memory gUUIDs = fleet.getTierUUIDs(GLOBAL, 0);
+ assertEq(gUUIDs.length, 1);
+ assertEq(gUUIDs[0], UUID_1);
+
+ bytes16[] memory usUUIDs = fleet.getTierUUIDs(_regionUS(), 0);
+ assertEq(usUUIDs.length, 1);
+ assertEq(usUUIDs[0], UUID_2);
+ }
+
+ // --- discoverHighestBondedTier ---
+
+ function test_discoverHighestBondedTier_prefersAdminArea() public {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+ vm.prank(bob);
+ fleet.registerFleetCountry(UUID_2, US);
+ vm.prank(carol);
+ fleet.registerFleetLocal(UUID_3, US, ADMIN_CA);
+
+ (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA);
+ assertEq(rk, _regionUSCA());
+ assertEq(tier, 0);
+ assertEq(members.length, 1);
+ }
+
+ function test_discoverHighestBondedTier_fallsBackToCountry() public {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+ vm.prank(bob);
+ fleet.registerFleetCountry(UUID_2, US);
+
+ (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA);
+ assertEq(rk, _regionUS());
+ assertEq(tier, 0);
+ assertEq(members.length, 1);
+ }
+
+ function test_discoverHighestBondedTier_fallsBackToGlobal() public {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+
+ (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA);
+ assertEq(rk, GLOBAL);
+ assertEq(tier, 0);
+ assertEq(members.length, 1);
+ }
+
+ function test_discoverHighestBondedTier_allEmpty() public view {
+ (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA);
+ assertEq(rk, GLOBAL);
+ assertEq(tier, 0);
+ assertEq(members.length, 0);
+ }
+
+ function test_discoverHighestBondedTier_returnsHighestTier() public {
+ _registerNCountry(alice, US, 8, 0);
+ vm.prank(bob);
+ fleet.registerFleetCountry(_uuid(500), US);
+
+ (uint32 rk, uint256 tier,) = fleet.discoverHighestBondedTier(US, 0);
+ assertEq(rk, _regionUS());
+ assertEq(tier, 1);
+ }
+
+ // --- discoverAllLevels ---
+
+ function test_discoverAllLevels_returnsAllCounts() public {
+ _registerNGlobal(alice, 4);
+ vm.prank(alice);
+ fleet.registerFleetGlobal(_uuid(999));
+
+ _registerNCountry(bob, US, 5, 100);
+ _registerNLocal(carol, US, ADMIN_CA, 3, 200);
+
+ (uint256 gsc, uint256 csc, uint256 asc, uint32 ark) = fleet.discoverAllLevels(US, ADMIN_CA);
+ assertEq(gsc, 2);
+ assertEq(csc, 1);
+ assertEq(asc, 1);
+ assertEq(ark, _regionUSCA());
+ }
+
+ function test_discoverAllLevels_zeroCountryAndAdmin() public {
+ _registerNGlobal(alice, 3);
+
+ (uint256 gsc, uint256 csc, uint256 asc,) = fleet.discoverAllLevels(0, 0);
+ assertEq(gsc, 1);
+ assertEq(csc, 0);
+ assertEq(asc, 0);
+ }
+
+ // --- Region indexes ---
+
+ function test_globalActive_trackedCorrectly() public {
+ assertFalse(fleet.globalActive());
+
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+ assertTrue(fleet.globalActive());
+
+ vm.prank(alice);
+ fleet.burn(tokenId);
+ assertFalse(fleet.globalActive());
+ }
+
+ function test_activeCountries_addedOnRegistration() public {
+ vm.prank(alice);
+ fleet.registerFleetCountry(UUID_1, US);
+ vm.prank(bob);
+ fleet.registerFleetCountry(UUID_2, DE);
+
+ uint16[] memory countries = fleet.getActiveCountries();
+ assertEq(countries.length, 2);
+ }
+
+ function test_activeCountries_removedWhenAllBurned() public {
+ vm.prank(alice);
+ uint256 id1 = fleet.registerFleetCountry(UUID_1, US);
+
+ uint16[] memory before_ = fleet.getActiveCountries();
+ assertEq(before_.length, 1);
+
+ vm.prank(alice);
+ fleet.burn(id1);
+
+ uint16[] memory after_ = fleet.getActiveCountries();
+ assertEq(after_.length, 0);
+ }
+
+ function test_activeCountries_notDuplicated() public {
+ vm.prank(alice);
+ fleet.registerFleetCountry(UUID_1, US);
+ vm.prank(bob);
+ fleet.registerFleetCountry(UUID_2, US);
+
+ uint16[] memory countries = fleet.getActiveCountries();
+ assertEq(countries.length, 1);
+ assertEq(countries[0], US);
+ }
+
+ function test_activeAdminAreas_trackedCorrectly() public {
+ vm.prank(alice);
+ fleet.registerFleetLocal(UUID_1, US, ADMIN_CA);
+ vm.prank(bob);
+ fleet.registerFleetLocal(UUID_2, US, ADMIN_NY);
+
+ uint32[] memory areas = fleet.getActiveAdminAreas();
+ assertEq(areas.length, 2);
+ }
+
+ function test_activeAdminAreas_removedWhenAllBurned() public {
+ vm.prank(alice);
+ uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA);
+
+ assertEq(fleet.getActiveAdminAreas().length, 1);
+
+ vm.prank(alice);
+ fleet.burn(id1);
+
+ assertEq(fleet.getActiveAdminAreas().length, 0);
+ }
+
+ // --- Region key helpers ---
+
+ function test_countryRegionKey() public view {
+ assertEq(fleet.countryRegionKey(US), uint32(US));
+ assertEq(fleet.countryRegionKey(DE), uint32(DE));
+ }
+
+ function test_adminRegionKey() public view {
+ assertEq(fleet.adminRegionKey(US, ADMIN_CA), (uint32(US) << 12) | uint32(ADMIN_CA));
+ }
+
+ function test_regionKeyNoOverlap_countryVsAdmin() public pure {
+ uint32 maxCountry = 999;
+ uint32 minAdmin = (uint32(1) << 12) | uint32(1);
+ assertTrue(minAdmin > maxCountry);
+ }
+
+ // --- tokenUUID / bonds ---
+
+ function test_tokenUUID_roundTrip() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+ assertEq(fleet.tokenUUID(tokenId), UUID_1);
+ }
+
+ function test_bonds_returnsTierBond() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+ assertEq(fleet.bonds(tokenId), BASE_BOND);
+ }
+
+ function test_bonds_zeroForNonexistentToken() public view {
+ assertEq(fleet.bonds(99999), 0);
+ }
+
+ // --- ERC721Enumerable ---
+
+ function test_enumerable_totalSupply() public {
+ assertEq(fleet.totalSupply(), 0);
+
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+ assertEq(fleet.totalSupply(), 1);
+
+ vm.prank(bob);
+ fleet.registerFleetCountry(UUID_2, US);
+ assertEq(fleet.totalSupply(), 2);
+
+ vm.prank(carol);
+ fleet.registerFleetLocal(UUID_3, US, ADMIN_CA);
+ assertEq(fleet.totalSupply(), 3);
+ }
+
+ function test_enumerable_supportsInterface() public view {
+ assertTrue(fleet.supportsInterface(0x780e9d63));
+ assertTrue(fleet.supportsInterface(0x80ac58cd));
+ assertTrue(fleet.supportsInterface(0x01ffc9a7));
+ }
+
+ // --- Bond accounting ---
+
+ function test_bondAccounting_acrossRegions() public {
+ vm.prank(alice);
+ uint256 g1 = fleet.registerFleetGlobal(UUID_1);
+ vm.prank(bob);
+ uint256 c1 = fleet.registerFleetCountry(UUID_2, US);
+ vm.prank(carol);
+ uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA);
+
+ assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 3);
+
+ vm.prank(bob);
+ fleet.burn(c1);
+ assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 2);
+
+ vm.prank(alice);
+ fleet.burn(g1);
+ vm.prank(carol);
+ fleet.burn(l1);
+ assertEq(bondToken.balanceOf(address(fleet)), 0);
+ }
+
+ function test_bondAccounting_reassignTierRoundTrip() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetCountry(UUID_1, US);
+ uint256 balStart = bondToken.balanceOf(alice);
+
+ vm.prank(alice);
+ fleet.reassignTier(tokenId, 3);
+
+ vm.prank(alice);
+ fleet.reassignTier(tokenId, 0);
+
+ assertEq(bondToken.balanceOf(alice), balStart);
+ assertEq(fleet.bonds(tokenId), BASE_BOND);
+ }
+
+ // --- ERC-20 edge case ---
+
+ function test_RevertIf_bondToken_transferFromReturnsFalse() public {
+ BadERC20 badToken = new BadERC20();
+ FleetIdentity f = new FleetIdentity(address(badToken), BASE_BOND);
+
+ badToken.mint(alice, 1_000 ether);
+ vm.prank(alice);
+ badToken.approve(address(f), type(uint256).max);
+
+ badToken.setFail(true);
+
+ vm.prank(alice);
+ vm.expectRevert();
+ f.registerFleetGlobal(UUID_1);
+ }
+
+ // --- Transfer preserves region and tier ---
+
+ function test_transfer_regionAndTierStayWithToken() public {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 2);
+
+ vm.prank(alice);
+ fleet.transferFrom(alice, bob, tokenId);
+
+ assertEq(fleet.fleetRegion(tokenId), _regionUS());
+ assertEq(fleet.fleetTier(tokenId), 2);
+ assertEq(fleet.bonds(tokenId), fleet.tierBond(2));
+
+ uint256 bobBefore = bondToken.balanceOf(bob);
+ vm.prank(bob);
+ fleet.burn(tokenId);
+ assertEq(bondToken.balanceOf(bob), bobBefore + fleet.tierBond(2));
+ }
+
+ // --- Tier lifecycle ---
+
+ function test_tierLifecycle_fillBurnBackfillPerRegion() public {
+ uint256[] memory usIds = _registerNCountry(alice, US, 8, 0);
+ assertEq(fleet.tierMemberCount(_regionUS(), 0), 8);
+
+ vm.prank(bob);
+ uint256 us9 = fleet.registerFleetCountry(_uuid(100), US);
+ assertEq(fleet.fleetTier(us9), 1);
+
+ vm.prank(alice);
+ fleet.burn(usIds[3]);
+
+ vm.prank(carol);
+ uint256 backfill = fleet.registerFleetCountry(_uuid(200), US);
+ assertEq(fleet.fleetTier(backfill), 0);
+ assertEq(fleet.tierMemberCount(_regionUS(), 0), 8);
+
+ assertEq(fleet.regionTierCount(GLOBAL), 0);
+ }
+
+ // --- Edge cases ---
+
+ function test_zeroBaseBond_allowsRegistration() public {
+ FleetIdentity f = new FleetIdentity(address(bondToken), 0);
+ vm.prank(alice);
+ bondToken.approve(address(f), type(uint256).max);
+
+ vm.prank(alice);
+ uint256 tokenId = f.registerFleetGlobal(UUID_1);
+ assertEq(f.bonds(tokenId), 0);
+
+ vm.prank(alice);
+ f.burn(tokenId);
+ }
+
+ // --- Fuzz Tests ---
+
+ function testFuzz_registerFleetGlobal_anyValidUUID(bytes16 uuid) public {
+ vm.assume(uuid != bytes16(0));
+
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(uuid);
+
+ assertEq(tokenId, uint256(uint128(uuid)));
+ assertEq(fleet.ownerOf(tokenId), alice);
+ assertEq(fleet.bonds(tokenId), BASE_BOND);
+ assertEq(fleet.fleetTier(tokenId), 0);
+ assertEq(fleet.fleetRegion(tokenId), GLOBAL);
+ }
+
+ function testFuzz_registerFleetCountry_validCountryCodes(uint16 cc) public {
+ cc = uint16(bound(cc, 1, 999));
+
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetCountry(UUID_1, cc);
+
+ assertEq(fleet.fleetRegion(tokenId), uint32(cc));
+ assertEq(fleet.fleetTier(tokenId), 0);
+ assertEq(fleet.bonds(tokenId), BASE_BOND);
+ }
+
+ function testFuzz_registerFleetLocal_validCodes(uint16 cc, uint16 admin) public {
+ cc = uint16(bound(cc, 1, 999));
+ admin = uint16(bound(admin, 1, 4095));
+
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetLocal(UUID_1, cc, admin);
+
+ uint32 expectedRegion = (uint32(cc) << 12) | uint32(admin);
+ assertEq(fleet.fleetRegion(tokenId), expectedRegion);
+ assertEq(fleet.fleetTier(tokenId), 0);
+ assertEq(fleet.bonds(tokenId), BASE_BOND);
+ }
+
+ function testFuzz_promote_onlyOwner(address caller) public {
+ vm.assume(caller != alice);
+ vm.assume(caller != address(0));
+
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(caller);
+ vm.expectRevert(FleetIdentity.NotTokenOwner.selector);
+ fleet.promote(tokenId);
+ }
+
+ function testFuzz_burn_onlyOwner(address caller) public {
+ vm.assume(caller != alice);
+ vm.assume(caller != address(0));
+
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetGlobal(UUID_1);
+
+ vm.prank(caller);
+ vm.expectRevert(FleetIdentity.NotTokenOwner.selector);
+ fleet.burn(tokenId);
+ }
+
+ function testFuzz_tierBond_geometric(uint256 tier) public view {
+ tier = bound(tier, 0, 10);
+ uint256 expected = BASE_BOND;
+ for (uint256 i = 0; i < tier; i++) {
+ expected *= 2;
+ }
+ assertEq(fleet.tierBond(tier), expected);
+ }
+
+ function testFuzz_perRegionTiers_newRegionAlwaysStartsAtTier0(uint16 cc) public {
+ cc = uint16(bound(cc, 1, 999));
+
+ _registerNGlobal(alice, 8);
+ assertEq(fleet.regionTierCount(GLOBAL), 2);
+
+ vm.prank(bob);
+ uint256 tokenId = fleet.registerFleetCountry(_uuid(999), cc);
+ assertEq(fleet.fleetTier(tokenId), 0);
+ assertEq(fleet.bonds(tokenId), BASE_BOND);
+ }
+
+ function testFuzz_tierAssignment_autoFillsSequentiallyPerRegion(uint8 count) public {
+ count = uint8(bound(count, 1, 40));
+
+ for (uint256 i = 0; i < count; i++) {
+ vm.prank(alice);
+ uint256 tokenId = fleet.registerFleetCountry(_uuid(i + 300), US);
+
+ uint256 expectedTier = i / 8; // country capacity = 8
+ assertEq(fleet.fleetTier(tokenId), expectedTier);
+ }
+
+ uint256 expectedTiers = (uint256(count) + 7) / 8;
+ assertEq(fleet.regionTierCount(_regionUS()), expectedTiers);
+ }
+
+ // --- Invariants ---
+
+ function test_invariant_contractBalanceEqualsSumOfBonds() public {
+ vm.prank(alice);
+ uint256 id1 = fleet.registerFleetGlobal(UUID_1);
+ vm.prank(bob);
+ uint256 id2 = fleet.registerFleetCountry(UUID_2, US);
+ vm.prank(carol);
+ uint256 id3 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA);
+
+ uint256 sumBonds = fleet.bonds(id1) + fleet.bonds(id2) + fleet.bonds(id3);
+ assertEq(bondToken.balanceOf(address(fleet)), sumBonds);
+
+ vm.prank(alice);
+ fleet.burn(id1);
+
+ assertEq(bondToken.balanceOf(address(fleet)), fleet.bonds(id2) + fleet.bonds(id3));
+ }
+
+ function test_invariant_contractBalanceAfterReassignTierBurn() public {
+ vm.prank(alice);
+ uint256 id1 = fleet.registerFleetCountry(UUID_1, US);
+ vm.prank(bob);
+ uint256 id2 = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA);
+ vm.prank(carol);
+ uint256 id3 = fleet.registerFleetGlobal(UUID_3);
+
+ vm.prank(alice);
+ fleet.reassignTier(id1, 3);
+
+ vm.prank(alice);
+ fleet.reassignTier(id1, 1);
+
+ uint256 expected = fleet.bonds(id1) + fleet.bonds(id2) + fleet.bonds(id3);
+ assertEq(bondToken.balanceOf(address(fleet)), expected);
+
+ vm.prank(alice);
+ fleet.burn(id1);
+ vm.prank(bob);
+ fleet.burn(id2);
+ vm.prank(carol);
+ fleet.burn(id3);
+
+ assertEq(bondToken.balanceOf(address(fleet)), 0);
+ }
+
+ // --- EdgeBeaconScanner workflow ---
+
+ function test_edgeBeaconScannerWorkflow_multiRegionDiscovery() public {
+ _registerNGlobal(alice, 4);
+ for (uint256 i = 0; i < 2; i++) {
+ vm.prank(bob);
+ fleet.registerFleetGlobal(_uuid(20 + i));
+ }
+
+ _registerNLocal(carol, US, ADMIN_CA, 3, 200);
+
+ (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA);
+ assertEq(rk, _regionUSCA());
+ assertEq(tier, 0);
+ assertEq(members.length, 3);
+
+ (uint256 gsc, uint256 csc, uint256 asc,) = fleet.discoverAllLevels(US, ADMIN_CA);
+ assertEq(gsc, 2);
+ assertEq(csc, 0);
+ assertEq(asc, 1);
+ }
+
+ // --- buildHighestBondedUUIDBundle ---
+
+ function test_buildBundle_emptyReturnsZero() public view {
+ (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA);
+ assertEq(count, 0);
+ }
+
+ function test_buildBundle_singleGlobal() public {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+
+ (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(0, 0);
+ assertEq(count, 1);
+ assertEq(uuids[0], UUID_1);
+ }
+
+ function test_buildBundle_singleCountry() public {
+ vm.prank(alice);
+ fleet.registerFleetCountry(UUID_1, US);
+
+ (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0);
+ assertEq(count, 1);
+ assertEq(uuids[0], UUID_1);
+ }
+
+ function test_buildBundle_singleLocal() public {
+ vm.prank(alice);
+ fleet.registerFleetLocal(UUID_1, US, ADMIN_CA);
+
+ (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA);
+ assertEq(count, 1);
+ assertEq(uuids[0], UUID_1);
+ }
+
+ function test_buildBundle_mergesAllLevelsAtSameBond() public {
+ // All at tier 0 → same bond → all collected together
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+ vm.prank(alice);
+ fleet.registerFleetCountry(UUID_2, US);
+ vm.prank(alice);
+ fleet.registerFleetLocal(UUID_3, US, ADMIN_CA);
+
+ (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA);
+ assertEq(count, 3);
+ }
+
+ function test_buildBundle_higherBondFirstAcrossLevels() public {
+ // Global: tier 0 (bond=100)
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+
+ // Country US: promote to tier 2 (bond=400)
+ vm.prank(alice);
+ uint256 usId = fleet.registerFleetCountry(UUID_2, US);
+ vm.prank(alice);
+ fleet.reassignTier(usId, 2);
+
+ // Admin US-CA: tier 0 (bond=100)
+ vm.prank(alice);
+ fleet.registerFleetLocal(UUID_3, US, ADMIN_CA);
+
+ (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA);
+ assertEq(count, 3);
+ // First UUID should be from US country (tier 2, highest bond)
+ assertEq(uuids[0], UUID_2);
+ }
+
+ function test_buildBundle_tiedBondsCollectedTogether() public {
+ // Global tier 0, Country tier 0, Admin tier 0 — all bond=BASE_BOND
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+ vm.prank(bob);
+ fleet.registerFleetGlobal(_uuid(11));
+ vm.prank(alice);
+ fleet.registerFleetCountry(UUID_2, US);
+ vm.prank(alice);
+ fleet.registerFleetLocal(UUID_3, US, ADMIN_CA);
+
+ (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA);
+ // All at same bond → all 4 collected
+ assertEq(count, 4);
+ }
+
+ function test_buildBundle_descendsTiersByBondPriority() public {
+ // Admin area: fill tier 0 (8 members, bond=100) + 1 in tier 1 (bond=200)
+ _registerNLocal(alice, US, ADMIN_CA, 8, 5000);
+ vm.prank(alice);
+ fleet.registerFleetLocal(_uuid(5099), US, ADMIN_CA);
+
+ // Global: 1 member in tier 0 (bond=100)
+ vm.prank(alice);
+ fleet.registerFleetGlobal(_uuid(6000));
+
+ (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA);
+ // Step 1: admin tier 1 (bond=200, 1 member) → count=1
+ // Step 2: admin tier 0 (bond=100) + global tier 0 (bond=100) → tied → 8+1=9
+ // Total: 10
+ assertEq(count, 10);
+ // First UUID is from admin tier 1 (highest bond)
+ uint256[] memory adminTier1 = fleet.getTierMembers(_regionUSCA(), 1);
+ assertEq(uuids[0], bytes16(uint128(adminTier1[0])));
+ }
+
+ function test_buildBundle_capsAt20() public {
+ // Fill global: 4+4+4 = 12 in 3 tiers
+ _registerNGlobal(alice, 12);
+ // Fill country US: 8+4 = 12 in 2 tiers
+ _registerNCountry(bob, US, 12, 1000);
+
+ // Total across levels: 24, but cap at 20
+ (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0);
+ assertEq(count, 20);
+ }
+
+ function test_buildBundle_onlyGlobalWhenNoCountryCode() public {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+ vm.prank(bob);
+ fleet.registerFleetCountry(UUID_2, US);
+
+ // countryCode=0 → skip country and admin levels
+ (, uint256 count) = fleet.buildHighestBondedUUIDBundle(0, 0);
+ assertEq(count, 1); // only global
+ }
+
+ function test_buildBundle_skipAdminWhenAdminCodeZero() public {
+ vm.prank(alice);
+ fleet.registerFleetCountry(UUID_1, US);
+ vm.prank(bob);
+ fleet.registerFleetLocal(UUID_2, US, ADMIN_CA);
+
+ // adminCode=0 → skip admin level
+ (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0);
+ assertEq(count, 1); // only country
+ }
+
+ function test_buildBundle_multiTierMultiLevel_correctOrder() public {
+ // Admin: 2 tiers (tier 0: 8 members bond=100, tier 1: 1 member bond=200)
+ _registerNLocal(alice, US, ADMIN_CA, 8, 8000);
+ vm.prank(alice);
+ fleet.registerFleetLocal(_uuid(8100), US, ADMIN_CA);
+
+ // Country: promote to tier 1 (bond=200)
+ vm.prank(alice);
+ uint256 countryId = fleet.registerFleetCountry(_uuid(8200), US);
+ vm.prank(alice);
+ fleet.reassignTier(countryId, 1);
+
+ // Global: promote to tier 2 (bond=400)
+ vm.prank(alice);
+ uint256 globalId = fleet.registerFleetGlobal(_uuid(8300));
+ vm.prank(alice);
+ fleet.reassignTier(globalId, 2);
+
+ (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA);
+ // Step 1: global tier 2 (bond=400) → 1 member
+ // Step 2: admin tier 1 (bond=200) + country tier 1 (bond=200) → tied → 1+1=2
+ // Step 3: admin tier 0 (bond=100) → 8 members
+ // Total: 11
+ assertEq(count, 11);
+ assertEq(uuids[0], fleet.tokenUUID(globalId));
+ }
+
+ function test_buildBundle_exhaustsAllLevels() public {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(UUID_1);
+ vm.prank(alice);
+ fleet.registerFleetCountry(UUID_2, US);
+ vm.prank(alice);
+ fleet.registerFleetLocal(UUID_3, US, ADMIN_CA);
+
+ (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA);
+ assertEq(count, 3);
+
+ bool found1;
+ bool found2;
+ bool found3;
+ for (uint256 i = 0; i < count; i++) {
+ if (uuids[i] == UUID_1) found1 = true;
+ if (uuids[i] == UUID_2) found2 = true;
+ if (uuids[i] == UUID_3) found3 = true;
+ }
+ assertTrue(found1 && found2 && found3);
+ }
+
+ function testFuzz_buildBundle_neverExceeds20(uint8 gCount, uint8 cCount, uint8 lCount) public {
+ gCount = uint8(bound(gCount, 0, 8));
+ cCount = uint8(bound(cCount, 0, 10));
+ lCount = uint8(bound(lCount, 0, 10));
+
+ for (uint256 i = 0; i < gCount; i++) {
+ vm.prank(alice);
+ fleet.registerFleetGlobal(_uuid(30_000 + i));
+ }
+ for (uint256 i = 0; i < cCount; i++) {
+ vm.prank(alice);
+ fleet.registerFleetCountry(_uuid(31_000 + i), US);
+ }
+ for (uint256 i = 0; i < lCount; i++) {
+ vm.prank(alice);
+ fleet.registerFleetLocal(_uuid(32_000 + i), US, ADMIN_CA);
+ }
+
+ (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA);
+ assertLe(count, 20);
+
+ uint256 total = uint256(gCount) + uint256(cCount) + uint256(lCount);
+ if (total <= 20) {
+ assertEq(count, total);
+ }
+ }
+}
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..5624e4f
--- /dev/null
+++ b/test/SwarmRegistryL1.t.sol
@@ -0,0 +1,1022 @@
+// 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";
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+
+contract MockBondTokenL1 is ERC20 {
+ constructor() ERC20("Mock Bond", "MBOND") {}
+
+ function mint(address to, uint256 amount) external {
+ _mint(to, amount);
+ }
+}
+
+contract SwarmRegistryL1Test is Test {
+ SwarmRegistryL1 swarmRegistry;
+ FleetIdentity fleetContract;
+ ServiceProvider providerContract;
+ MockBondTokenL1 bondToken;
+
+ address fleetOwner = address(0x1);
+ address providerOwner = address(0x2);
+ address caller = address(0x3);
+
+ uint256 constant FLEET_BOND = 100 ether;
+
+ 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 {
+ bondToken = new MockBondTokenL1();
+ fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND);
+ providerContract = new ServiceProvider();
+ swarmRegistry = new SwarmRegistryL1(address(fleetContract), address(providerContract));
+
+ // Fund fleet owner and approve
+ bondToken.mint(fleetOwner, 1_000_000 ether);
+ vm.prank(fleetOwner);
+ bondToken.approve(address(fleetContract), type(uint256).max);
+ }
+
+ // ==============================
+ // Helpers
+ // ==============================
+
+ function _registerFleet(address owner, bytes memory seed) internal returns (uint256) {
+ vm.prank(owner);
+ return fleetContract.registerFleetGlobal(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..17f9088
--- /dev/null
+++ b/test/SwarmRegistryUniversal.t.sol
@@ -0,0 +1,1158 @@
+// 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";
+import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
+
+contract MockBondTokenUniv is ERC20 {
+ constructor() ERC20("Mock Bond", "MBOND") {}
+
+ function mint(address to, uint256 amount) external {
+ _mint(to, amount);
+ }
+}
+
+contract SwarmRegistryUniversalTest is Test {
+ SwarmRegistryUniversal swarmRegistry;
+ FleetIdentity fleetContract;
+ ServiceProvider providerContract;
+ MockBondTokenUniv bondToken;
+
+ address fleetOwner = address(0x1);
+ address providerOwner = address(0x2);
+ address caller = address(0x3);
+
+ uint256 constant FLEET_BOND = 100 ether;
+
+ 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 {
+ bondToken = new MockBondTokenUniv();
+ fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND);
+ providerContract = new ServiceProvider();
+ swarmRegistry = new SwarmRegistryUniversal(address(fleetContract), address(providerContract));
+
+ // Fund fleet owner and approve
+ bondToken.mint(fleetOwner, 1_000_000 ether);
+ vm.prank(fleetOwner);
+ bondToken.approve(address(fleetContract), type(uint256).max);
+ }
+
+ // ==============================
+ // Helpers
+ // ==============================
+
+ function _registerFleet(address owner, bytes memory seed) internal returns (uint256) {
+ vm.prank(owner);
+ return fleetContract.registerFleetGlobal(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);
+ }
+}