From 538047dd994e0c276beb316ddf2791f813cbe537 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 6 Feb 2026 18:48:02 +1300 Subject: [PATCH 01/15] feat: implement BLE swarm registry system with dual-chain support - Add FleetIdentity (ERC721) for BLE fleet ownership via Proximity UUID - Add ServiceProvider (ERC721) for service endpoint URL ownership - Add SwarmRegistryL1 with SSTORE2 for Ethereum L1 optimization - Add SwarmRegistryUniversal with native bytes storage for ZkSync Era compatibility - Implement XOR filter-based tag membership verification - Add deterministic swarm IDs derived from (fleetId, providerId, filter) - Support orphan detection and permissionless purging of burned NFT swarms - Include comprehensive test suites (157 tests total) - Add Solady dependency for SSTORE2 functionality --- .agent/rules/solidity_zksync.md | 33 + .cspell.json | 11 +- .github/copilot-instructions.md | 33 + .gitmodules | 3 + .vscode/settings.json | 3 + foundry.lock | 20 + lib/solady | 1 + remappings.txt | 3 +- src/swarms/FleetIdentity.sol | 92 ++ src/swarms/ServiceProvider.sol | 53 ++ src/swarms/SwarmRegistryL1.sol | 368 ++++++++ src/swarms/SwarmRegistryUniversal.sol | 377 ++++++++ src/swarms/doc/assistant-guide.md | 205 ++++ src/swarms/doc/graph-architecture.md | 107 +++ src/swarms/doc/sequence-discovery.md | 76 ++ src/swarms/doc/sequence-lifecycle.md | 111 +++ src/swarms/doc/sequence-registration.md | 74 ++ test/FleetIdentity.t.sol | 262 ++++++ test/ServiceProvider.t.sol | 159 ++++ test/SwarmRegistryL1.t.sol | 1004 ++++++++++++++++++++ test/SwarmRegistryUniversal.t.sol | 1140 +++++++++++++++++++++++ 21 files changed, 4133 insertions(+), 2 deletions(-) create mode 100644 .agent/rules/solidity_zksync.md create mode 100644 .github/copilot-instructions.md create mode 100644 foundry.lock create mode 160000 lib/solady create mode 100644 src/swarms/FleetIdentity.sol create mode 100644 src/swarms/ServiceProvider.sol create mode 100644 src/swarms/SwarmRegistryL1.sol create mode 100644 src/swarms/SwarmRegistryUniversal.sol create mode 100644 src/swarms/doc/assistant-guide.md create mode 100644 src/swarms/doc/graph-architecture.md create mode 100644 src/swarms/doc/sequence-discovery.md create mode 100644 src/swarms/doc/sequence-lifecycle.md create mode 100644 src/swarms/doc/sequence-registration.md create mode 100644 test/FleetIdentity.t.sol create mode 100644 test/ServiceProvider.t.sol create mode 100644 test/SwarmRegistryL1.t.sol create mode 100644 test/SwarmRegistryUniversal.t.sol diff --git a/.agent/rules/solidity_zksync.md b/.agent/rules/solidity_zksync.md new file mode 100644 index 00000000..7cdccfc5 --- /dev/null +++ b/.agent/rules/solidity_zksync.md @@ -0,0 +1,33 @@ +# Solidity & ZkSync Development Standards + +## Toolchain & Environment +- **Primary Tool**: `forge` (ZkSync fork). Use for compilation, testing, and generic scripting. +- **Secondary Tool**: `hardhat`. Use only when `forge` encounters compatibility issues (e.g., complex deployments, specific plugin needs). +- **Network Target**: ZkSync Era (Layer 2). +- **Solidity Version**: `^0.8.20` (or `0.8.24` if strictly supported by the zk-compiler). + +## Modern Solidity Best Practices +- **Safety First**: + - **Checks-Effects-Interactions (CEI)** pattern must be strictly followed. + - Use `Ownable2Step` over `Ownable` for privileged access. + - Prefer `ReentrancyGuard` for external calls where appropriate. +- **Gas & Efficiency**: + - Use **Custom Errors** (`error MyError();`) instead of `require` strings. + - Use `mapping` over arrays for membership checks where possible. + - Minimize on-chain storage; use events for off-chain indexing. + +## Testing Standards +- **Framework**: Foundry (Forge). +- **Methodology**: + - **Unit Tests**: Comprehensive coverage for all functions. + - **Fuzz Testing**: Required for arithmetic and purely functional logic. + - **Invariant Testing**: Define invariants for stateful system properties. +- **Naming Convention**: + - `test_Description` + - `testFuzz_Description` + - `test_RevertIf_Condition` + +## ZkSync Specifics +- **System Contracts**: Be aware of ZkSync system contracts (e.g., `ContractDeployer`, `L2EthToken`) when interacting with low-level features. +- **Gas Model**: Account for ZkSync's different gas metering if performing low-level optimization. +- **Compiler Differences**: Be mindful of differences between `solc` and `zksolc` (e.g., `create2` address derivation). diff --git a/.cspell.json b/.cspell.json index c9909576..936fcf37 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 00000000..7cdccfc5 --- /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 9540dda6..c6c1a45d 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 4d04fd25..8ab6c216 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 00000000..7a3effd8 --- /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 00000000..acd959aa --- /dev/null +++ b/lib/solady @@ -0,0 +1 @@ +Subproject commit acd959aa4bd04720d640bf4e6a5c71037510cc4b diff --git a/remappings.txt b/remappings.txt index 1e950773..53468b38 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 00000000..9ab862d3 --- /dev/null +++ b/src/swarms/FleetIdentity.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; + +/** + * @title FleetIdentity + * @notice Permissionless ERC-721 representing ownership of a BLE fleet. + * @dev TokenID = uint256(uint128(uuid)), guaranteeing one owner per Proximity UUID. + */ +contract FleetIdentity is ERC721 { + error InvalidUUID(); + error InvalidPaginationParams(); + error NotTokenOwner(); + + // Array to enable enumeration of all registered fleets (for SDK scanning) + bytes16[] public registeredUUIDs; + + // Mapping to quickly check if a UUID is registered (redundant with ownerOf but cheaper for specific checks) + mapping(uint256 => bool) public activeFleets; + + event FleetRegistered(address indexed owner, bytes16 indexed uuid, uint256 indexed tokenId); + event FleetBurned(address indexed owner, uint256 indexed tokenId); + + constructor() ERC721("Swarm Fleet Identity", "SFID") {} + + /// @notice Mints a new fleet NFT for the given Proximity UUID. + /// @param uuid The 16-byte Proximity UUID. + /// @return tokenId The deterministic token ID derived from `uuid`. + function registerFleet(bytes16 uuid) external returns (uint256 tokenId) { + if (uuid == bytes16(0)) { + revert InvalidUUID(); + } + + tokenId = uint256(uint128(uuid)); + + _mint(msg.sender, tokenId); + + registeredUUIDs.push(uuid); + activeFleets[tokenId] = true; + + emit FleetRegistered(msg.sender, uuid, tokenId); + } + + /// @notice Burns the fleet NFT. Caller must be the token owner. + /// @param tokenId The fleet token ID to burn. + function burn(uint256 tokenId) external { + if (ownerOf(tokenId) != msg.sender) { + revert NotTokenOwner(); + } + + activeFleets[tokenId] = false; + + _burn(tokenId); + + emit FleetBurned(msg.sender, tokenId); + } + + /// @notice Returns a paginated slice of all registered UUIDs. + /// @param offset Starting index. + /// @param limit Maximum number of entries to return. + /// @return uuids The requested UUID slice. + function getRegisteredUUIDs(uint256 offset, uint256 limit) external view returns (bytes16[] memory uuids) { + if (limit == 0) { + revert InvalidPaginationParams(); + } + + if (offset >= registeredUUIDs.length) { + return new bytes16[](0); + } + + uint256 end = offset + limit; + if (end > registeredUUIDs.length) { + end = registeredUUIDs.length; + } + + uint256 resultLen = end - offset; + uuids = new bytes16[](resultLen); + + for (uint256 i = 0; i < resultLen;) { + uuids[i] = registeredUUIDs[offset + i]; + unchecked { + ++i; + } + } + } + + /// @notice Returns the total number of registered fleets (including burned). + function getTotalFleets() external view returns (uint256) { + return registeredUUIDs.length; + } +} diff --git a/src/swarms/ServiceProvider.sol b/src/swarms/ServiceProvider.sol new file mode 100644 index 00000000..80689b9e --- /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 00000000..5255c516 --- /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 00000000..3da81c08 --- /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 00000000..bbae881c --- /dev/null +++ b/src/swarms/doc/assistant-guide.md @@ -0,0 +1,205 @@ +# Swarm System Architecture & Implementation Guide + +> **Context for AI Agents**: This document outlines the architecture, constraints, and operational logic of the Swarm Smart Contract system. Use this context when modifying contracts, writing SDKs, or debugging verifiers. + +## 1. System Overview + +The Swarm System is a privacy-preserving registry for **BLE (Bluetooth Low Energy)** tag swarms. It allows Fleet Owners to manage large sets of tags (~10k-20k) and link them to Service Providers (Backend URLs) without revealing the individual identity of every tag on-chain. + +Two registry variants exist for different deployment targets: + +- **`SwarmRegistryL1`** — Ethereum L1, uses SSTORE2 (contract bytecode) for gas-efficient filter storage. Not compatible with ZkSync Era. +- **`SwarmRegistryUniversal`** — All EVM chains including ZkSync Era, uses native `bytes` storage. + +### Core Components + +| Contract | Role | Key Identity | Token | +| :--------------------------- | :------------------------- | :--------------------------------------- | :---- | +| **`FleetIdentity`** | Fleet Registry (ERC-721) | `uint256(uint128(uuid))` | SFID | +| **`ServiceProvider`** | Service Registry (ERC-721) | `keccak256(url)` | SSV | +| **`SwarmRegistryL1`** | Swarm Registry (L1) | `keccak256(fleetId, providerId, filter)` | — | +| **`SwarmRegistryUniversal`** | Swarm Registry (Universal) | `keccak256(fleetId, providerId, filter)` | — | + +All contracts are **fully permissionless** — access control is enforced through NFT ownership rather than admin roles. + +Both NFT contracts support **burning** — the token owner can call `burn(tokenId)` to destroy their NFT, which makes any swarms referencing that token _orphaned_. + +--- + +## 2. Operational Workflows + +### A. Provider & Fleet Setup (One-Time) + +1. **Service Provider**: Calls `ServiceProvider.registerProvider("https://cms.example.com")`. Receives `providerTokenId` (= `keccak256(url)`). +2. **Fleet Owner**: Calls `FleetIdentity.registerFleet(0xUUID...)`. Receives `fleetId` (= `uint256(uint128(uuid))`). + +### B. Swarm Registration (Per Batch of Tags) + +A Fleet Owner groups tags into a "Swarm" (chunk of ~10k-20k tags) and registers them. + +1. **Construct `TagID`s**: Generate the unique ID for every tag in the swarm (see "Tag Schemas" below). +2. **Build XOR Filter**: Create a binary XOR filter (Peeling Algorithm) containing the hashes of all `TagID`s. +3. **(Optional) Predict Swarm ID**: Call `computeSwarmId(fleetId, providerId, filterData)` off-chain to obtain the deterministic ID before submitting the transaction. +4. **Register**: + ```solidity + swarmRegistry.registerSwarm( + fleetId, + providerId, + filterData, + 16, // Fingerprint size in bits (1–16) + TagType.IBEACON_INCLUDES_MAC // or PAYLOAD_ONLY, VENDOR_ID, GENERIC + ); + // Returns the deterministic swarmId + ``` + +### C. Swarm Approval Flow + +After registration a swarm starts in `REGISTERED` status and requires provider approval: + +1. **Provider approves**: `swarmRegistry.acceptSwarm(swarmId)` → status becomes `ACCEPTED`. +2. **Provider rejects**: `swarmRegistry.rejectSwarm(swarmId)` → status becomes `REJECTED`. + +Only the owner of the provider NFT (`providerId`) can accept or reject. + +### D. Swarm Updates + +The fleet owner can modify a swarm at any time. Both operations reset status to `REGISTERED`, requiring fresh provider approval: + +- **Replace the XOR filter**: `swarmRegistry.updateSwarmFilter(swarmId, newFilterData)` +- **Change service provider**: `swarmRegistry.updateSwarmProvider(swarmId, newProviderId)` + +### E. Swarm Deletion + +The fleet owner can permanently remove a swarm: + +```solidity +swarmRegistry.deleteSwarm(swarmId); +``` + +### F. Orphan Detection & Cleanup + +When a fleet or provider NFT is burned, swarms referencing it become _orphaned_: + +- **Check validity**: `swarmRegistry.isSwarmValid(swarmId)` returns `(fleetValid, providerValid)`. +- **Purge**: Anyone can call `swarmRegistry.purgeOrphanedSwarm(swarmId)` to remove stale state. The caller receives the SSTORE gas refund as an incentive. +- **Guards**: `acceptSwarm`, `rejectSwarm`, and `checkMembership` all revert with `SwarmOrphaned()` if the swarm's NFTs have been burned. + +--- + +## 3. Off-Chain Logic: Filter & Tag Construction + +### Tag Schemas (`TagType`) + +The system supports different ways of constructing the unique `TagID` based on the hardware capabilities. + +**Enum: `TagType`** + +- **`0x00`: IBEACON_PAYLOAD_ONLY** + - **Format**: `UUID (16b) || Major (2b) || Minor (2b)` + - **Use Case**: When Major/Minor pairs are globally unique (standard iBeacon). +- **`0x01`: IBEACON_INCLUDES_MAC** + - **Format**: `UUID (16b) || Major (2b) || Minor (2b) || MAC (6b)` + - **Use Case**: Anti-spoofing logic or Shared Major/Minor fleets. + - **CRITICAL: MAC Normalization Rule**: + - If MAC is **Public/Static** (Address Type bits `00`): Use the **Real MAC Address**. + - If MAC is **Random/Private** (Address Type bits `01` or `11`): Replace with `FF:FF:FF:FF:FF:FF`. + - _Why?_ To support rotating privacy MACs while still validating "It's a privacy tag". +- **`0x02`: VENDOR_ID** + - **Format**: `companyID || hash(vendorBytes)` + - **Use Case**: Non-iBeacon BLE devices identified by Bluetooth SIG company ID. +- **`0x03`: GENERIC** + - **Use Case**: Catch-all for custom tag identity schemes. + +### Filter Construction (The Math) + +To verify membership on-chain, the contract uses **3-hash XOR logic**. + +1. **Input**: `h = keccak256(TagID)` (where TagID is constructed via schema above). +2. **Indices** (M = number of fingerprint slots = `filterLength * 8 / fingerprintSize`): + - `h1 = uint32(h) % M` + - `h2 = uint32(h >> 32) % M` + - `h3 = uint32(h >> 64) % M` +3. **Fingerprint**: `fp = (h >> 96) & ((1 << fingerprintSize) - 1)` +4. **Verification**: `Filter[h1] ^ Filter[h2] ^ Filter[h3] == fp` + +### Swarm ID Derivation + +Swarm IDs are **deterministic** — derived from the swarm's core identity: + +``` +swarmId = uint256(keccak256(abi.encode(fleetId, providerId, filterData))) +``` + +This means the same (fleet, provider, filter) triple always produces the same ID, and duplicate registrations revert with `SwarmAlreadyExists()`. The `computeSwarmId` function is `public pure`, so it can be called off-chain at zero cost via `eth_call`. + +--- + +## 4. Client Discovery Flow (The "Scanner" Perspective) + +A client (mobile phone or gateway) scans a BLE beacon and wants to find its owner and backend service. + +### Step 1: Scan & Detect + +- Scanner detects iBeacon: `UUID: E2C5...`, `Major: 1`, `Minor: 50`, `MAC: AA:BB...`. + +### Step 2: Identify Fleet + +- Scanner checks `FleetIdentity` contract. +- Calls `ownerOf(uint256(uint128(uuid)))` (or checks `activeFleets[tokenId]`). +- **Result**: "This beacon belongs to Fleet #42". + +### Step 3: Find Swarms + +- Scanner reads `swarmRegistry.fleetSwarms(42, index)` for each index (array of swarm IDs for that fleet). +- **Result**: List of `SwarmID`s: `[101, 102, 105]`. + +### Step 4: Membership Check (Find the specific Swarm) + +For each SwarmID in the list: + +1. **Check Schema**: Get `swarms[101].tagType`. +2. **Construct Candidate TagHash**: + - If `IBEACON_INCLUDES_MAC`: Check MAC byte. If Random, use `FF...FF`. + - Buffer = `UUID + Major + Minor + (NormalizedMAC)`. + - `hash = keccak256(Buffer)`. +3. **Verify**: + - Call `swarmRegistry.checkMembership(101, hash)`. + - Reverts with `SwarmOrphaned()` if the fleet or provider NFT has been burned. +4. **Result**: + - If `true`: **Found it!** This tag is in Swarm 101. + - If `false`: Try next swarm. + +### Step 5: Service Discovery + +Once Membership is confirmed (e.g., in Swarm 101): + +1. Get `swarms[101].providerId` (e.g., Provider #99). +2. Call `ServiceProvider.providerUrls(99)`. +3. **Result**: `"https://api.acme-tracking.com"`. +4. **Check Status**: `swarms[101].status`. + - If `ACCEPTED` (1): Safe to connect. + - If `REGISTERED` (0): Provider has not yet approved — use with caution. + - If `REJECTED` (2): Do not connect. + +--- + +## 5. Storage & Deletion Notes + +### SwarmRegistryL1 (SSTORE2) + +- Filter data is stored as **immutable contract bytecode** via SSTORE2. +- On `deleteSwarm` / `purgeOrphanedSwarm`, the struct is cleared but the deployed bytecode **cannot be erased** (accepted trade-off of the SSTORE2 pattern). + +### SwarmRegistryUniversal (native bytes) + +- Filter data is stored in a `mapping(uint256 => bytes)`. +- On `deleteSwarm` / `purgeOrphanedSwarm`, both the struct and the filter bytes are fully deleted (`delete filterData[swarmId]`), reclaiming storage. +- Exposes `getFilterData(swarmId)` for off-chain filter retrieval. + +### Deletion Performance + +Both registries use an **O(1) swap-and-pop** strategy for removing swarms from the `fleetSwarms` array, tracked via the `swarmIndexInFleet` mapping. + +--- + +**Note**: This architecture ensures that a scanner can go from **Raw Signal** → **Verified Service URL** entirely on-chain (data-wise), without a centralized indexer, while privacy of the 10,000 other tags in the swarm is preserved. diff --git a/src/swarms/doc/graph-architecture.md b/src/swarms/doc/graph-architecture.md new file mode 100644 index 00000000..fba222be --- /dev/null +++ b/src/swarms/doc/graph-architecture.md @@ -0,0 +1,107 @@ +# Swarm System — Contract Architecture + +```mermaid +graph TB + subgraph NFTs["Identity Layer (ERC-721)"] + FI["FleetIdentity
SFID
tokenId = uint128(uuid)"] + SP["ServiceProvider
SSV
tokenId = keccak256(url)"] + end + + subgraph Registries["Registry Layer"] + L1["SwarmRegistryL1
SSTORE2 filter storage
Ethereum L1 only"] + UNI["SwarmRegistryUniversal
native bytes storage
All EVM chains"] + end + + subgraph Actors + FO(("Fleet
Owner")) + PRV(("Service
Provider")) + ANY(("Anyone
(Scanner / Purger)")) + end + + FO -- "registerFleet(uuid)" --> FI + FO -- "registerSwarm / update / delete" --> L1 + FO -- "registerSwarm / update / delete" --> UNI + PRV -- "registerProvider(url)" --> SP + PRV -- "acceptSwarm / rejectSwarm" --> L1 + PRV -- "acceptSwarm / rejectSwarm" --> UNI + ANY -- "checkMembership / purgeOrphanedSwarm" --> L1 + ANY -- "checkMembership / purgeOrphanedSwarm" --> UNI + + L1 -. "ownerOf(fleetId)" .-> FI + L1 -. "ownerOf(providerId)" .-> SP + UNI -. "ownerOf(fleetId)" .-> FI + UNI -. "ownerOf(providerId)" .-> SP + + style FI fill:#4a9eff,color:#fff + style SP fill:#4a9eff,color:#fff + style L1 fill:#ff9f43,color:#fff + style UNI fill:#ff9f43,color:#fff + style FO fill:#2ecc71,color:#fff + style PRV fill:#2ecc71,color:#fff + style ANY fill:#95a5a6,color:#fff +``` + +## Swarm Data Model + +```mermaid +classDiagram + class FleetIdentity { + +bytes16[] registeredUUIDs + +mapping activeFleets + +registerFleet(uuid) tokenId + +burn(tokenId) + +getRegisteredUUIDs(offset, limit) + +getTotalFleets() + } + + class ServiceProvider { + +mapping providerUrls + +registerProvider(url) tokenId + +burn(tokenId) + } + + class SwarmRegistry { + +mapping swarms + +mapping fleetSwarms + +mapping swarmIndexInFleet + +computeSwarmId(fleetId, providerId, filter) swarmId + +registerSwarm(fleetId, providerId, filter, fpSize, tagType) swarmId + +acceptSwarm(swarmId) + +rejectSwarm(swarmId) + +updateSwarmFilter(swarmId, newFilter) + +updateSwarmProvider(swarmId, newProviderId) + +deleteSwarm(swarmId) + +isSwarmValid(swarmId) fleetValid, providerValid + +purgeOrphanedSwarm(swarmId) + +checkMembership(swarmId, tagHash) bool + } + + class Swarm { + uint256 fleetId + uint256 providerId + uint8 fingerprintSize + TagType tagType + SwarmStatus status + } + + class SwarmStatus { + <> + REGISTERED + ACCEPTED + REJECTED + } + + class TagType { + <> + IBEACON_PAYLOAD_ONLY + IBEACON_INCLUDES_MAC + VENDOR_ID + GENERIC + } + + SwarmRegistry --> FleetIdentity : validates ownership + SwarmRegistry --> ServiceProvider : validates ownership + SwarmRegistry *-- Swarm : stores + Swarm --> SwarmStatus + Swarm --> TagType +``` diff --git a/src/swarms/doc/sequence-discovery.md b/src/swarms/doc/sequence-discovery.md new file mode 100644 index 00000000..ac5e3691 --- /dev/null +++ b/src/swarms/doc/sequence-discovery.md @@ -0,0 +1,76 @@ +# Client Discovery Sequence + +## Full Discovery Flow: BLE Signal → Service URL + +```mermaid +sequenceDiagram + actor SC as Scanner (Client) + participant FI as FleetIdentity + participant SR as SwarmRegistry + participant SP as ServiceProvider + + Note over SC: Detects iBeacon:
UUID, Major, Minor, MAC + + rect rgb(240, 248, 255) + Note right of SC: Step 1 — Identify fleet + SC ->>+ FI: ownerOf(uint128(uuid)) + FI -->>- SC: fleet owner address (fleet exists ✓) + end + + rect rgb(255, 248, 240) + Note right of SC: Step 2 — Enumerate swarms + SC ->>+ SR: fleetSwarms(fleetId, 0) + SR -->>- SC: swarmId_0 + SC ->>+ SR: fleetSwarms(fleetId, 1) + SR -->>- SC: swarmId_1 + Note over SC: ... iterate until revert (end of array) + end + + rect rgb(240, 255, 240) + Note right of SC: Step 3 — Find matching swarm + Note over SC: Read swarms[swarmId_0].tagType + Note over SC: Construct tagId per schema:
UUID || Major || Minor [|| MAC] + Note over SC: tagHash = keccak256(tagId) + SC ->>+ SR: checkMembership(swarmId_0, tagHash) + SR -->>- SC: false (not in this swarm) + + SC ->>+ SR: checkMembership(swarmId_1, tagHash) + SR -->>- SC: true ✓ (tag found!) + end + + rect rgb(248, 240, 255) + Note right of SC: Step 4 — Resolve service URL + SC ->>+ SR: swarms(swarmId_1) + SR -->>- SC: { providerId, status: ACCEPTED, ... } + SC ->>+ SP: providerUrls(providerId) + SP -->>- SC: "https://api.acme-tracking.com" + end + + Note over SC: Connect to service URL ✓ +``` + +## Tag Hash Construction by TagType + +```mermaid +flowchart TD + A[Read swarm.tagType] --> B{TagType?} + + B -->|IBEACON_PAYLOAD_ONLY| C["tagId = UUID ∥ Major ∥ Minor
(20 bytes)"] + B -->|IBEACON_INCLUDES_MAC| D{MAC type?} + B -->|VENDOR_ID| E["tagId = companyID ∥ hash(vendorBytes)"] + B -->|GENERIC| F["tagId = custom scheme"] + + D -->|Public/Static| G["tagId = UUID ∥ Major ∥ Minor ∥ realMAC
(26 bytes)"] + D -->|Random/Private| H["tagId = UUID ∥ Major ∥ Minor ∥ FF:FF:FF:FF:FF:FF
(26 bytes)"] + + C --> I["tagHash = keccak256(tagId)"] + G --> I + H --> I + E --> I + F --> I + + I --> J["checkMembership(swarmId, tagHash)"] + + style I fill:#4a9eff,color:#fff + style J fill:#2ecc71,color:#fff +``` diff --git a/src/swarms/doc/sequence-lifecycle.md b/src/swarms/doc/sequence-lifecycle.md new file mode 100644 index 00000000..12758ec6 --- /dev/null +++ b/src/swarms/doc/sequence-lifecycle.md @@ -0,0 +1,111 @@ +# Swarm Lifecycle: Updates, Deletion & Orphan Cleanup + +## Swarm Status State Machine + +```mermaid +stateDiagram-v2 + [*] --> REGISTERED : registerSwarm() + + REGISTERED --> ACCEPTED : acceptSwarm()
(provider owner) + REGISTERED --> REJECTED : rejectSwarm()
(provider owner) + + ACCEPTED --> REGISTERED : updateSwarmFilter()
updateSwarmProvider()
(fleet owner) + REJECTED --> REGISTERED : updateSwarmFilter()
updateSwarmProvider()
(fleet owner) + + REGISTERED --> [*] : deleteSwarm() / purge + ACCEPTED --> [*] : deleteSwarm() / purge + REJECTED --> [*] : deleteSwarm() / purge +``` + +## Update Flow (Fleet Owner) + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant SR as SwarmRegistry + participant FI as FleetIdentity + + rect rgb(255, 248, 240) + Note right of FO: Update XOR filter + FO ->>+ SR: updateSwarmFilter(swarmId, newFilter) + SR ->>+ FI: ownerOf(fleetId) + FI -->>- SR: msg.sender ✓ + Note over SR: Write new filter data + Note over SR: status → REGISTERED + SR -->>- FO: ✓ (requires provider re-approval) + end + + rect rgb(240, 248, 255) + Note right of FO: Update service provider + FO ->>+ SR: updateSwarmProvider(swarmId, newProviderId) + SR ->>+ FI: ownerOf(fleetId) + FI -->>- SR: msg.sender ✓ + Note over SR: providerId → newProviderId + Note over SR: status → REGISTERED + SR -->>- FO: ✓ (requires new provider approval) + end +``` + +## Deletion (Fleet Owner) + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant SR as SwarmRegistry + participant FI as FleetIdentity + + FO ->>+ SR: deleteSwarm(swarmId) + SR ->>+ FI: ownerOf(fleetId) + FI -->>- SR: msg.sender ✓ + Note over SR: Remove from fleetSwarms[] (O(1) swap-and-pop) + Note over SR: delete swarms[swarmId] + Note over SR: delete filterData[swarmId] (Universal only) + SR -->>- FO: ✓ SwarmDeleted event +``` + +## Orphan Detection & Permissionless Cleanup + +```mermaid +sequenceDiagram + actor Owner as NFT Owner + actor Purger as Anyone + participant NFT as FleetIdentity / ServiceProvider + participant SR as SwarmRegistry + + rect rgb(255, 240, 240) + Note right of Owner: NFT owner burns their token + Owner ->>+ NFT: burn(tokenId) + NFT -->>- Owner: ✓ token destroyed + Note over SR: Swarms referencing this token
are now orphaned (lazy invalidation) + end + + rect rgb(255, 248, 240) + Note right of Purger: Anyone checks validity + Purger ->>+ SR: isSwarmValid(swarmId) + SR ->>+ NFT: ownerOf(fleetId) + NFT -->>- SR: ❌ reverts (burned) + SR -->>- Purger: (false, true) — fleet invalid + end + + rect rgb(240, 255, 240) + Note right of Purger: Anyone purges the orphan + Purger ->>+ SR: purgeOrphanedSwarm(swarmId) + Note over SR: Confirms at least one NFT is burned + Note over SR: Remove from fleetSwarms[] (O(1)) + Note over SR: delete swarms[swarmId] + Note over SR: Gas refund → Purger + SR -->>- Purger: ✓ SwarmPurged event + end +``` + +## Orphan Guards (Automatic Rejection) + +```mermaid +flowchart LR + A[acceptSwarm /
rejectSwarm /
checkMembership] --> B{isSwarmValid?} + B -->|Both NFTs exist| C[Proceed normally] + B -->|Fleet or Provider burned| D["❌ revert SwarmOrphaned()"] + + style D fill:#e74c3c,color:#fff + style C fill:#2ecc71,color:#fff +``` diff --git a/src/swarms/doc/sequence-registration.md b/src/swarms/doc/sequence-registration.md new file mode 100644 index 00000000..1058340c --- /dev/null +++ b/src/swarms/doc/sequence-registration.md @@ -0,0 +1,74 @@ +# Swarm Registration & Approval Sequence + +## One-Time Setup + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + actor PRV as Service Provider + participant FI as FleetIdentity + participant SP as ServiceProvider + + Note over FO, SP: One-time setup (independent, any order) + + FO ->>+ FI: registerFleet(uuid) + FI -->>- FO: fleetId = uint128(uuid) + + PRV ->>+ SP: registerProvider(url) + SP -->>- PRV: providerId = keccak256(url) +``` + +## Swarm Registration & Approval + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + actor PRV as Provider Owner + participant SR as SwarmRegistry + participant FI as FleetIdentity + participant SP as ServiceProvider + + Note over FO: Build XOR filter off-chain
from tag set (Peeling Algorithm) + + rect rgb(240, 248, 255) + Note right of FO: Registration (fleet owner) + FO ->>+ SR: registerSwarm(fleetId, providerId, filter, fpSize, tagType) + SR ->>+ FI: ownerOf(fleetId) + FI -->>- SR: msg.sender ✓ + SR ->>+ SP: ownerOf(providerId) + SP -->>- SR: address ✓ (exists) + Note over SR: swarmId = keccak256(fleetId, providerId, filter) + Note over SR: status = REGISTERED + SR -->>- FO: swarmId + end + + rect rgb(240, 255, 240) + Note right of PRV: Approval (provider owner) + alt Provider approves + PRV ->>+ SR: acceptSwarm(swarmId) + SR ->>+ SP: ownerOf(providerId) + SP -->>- SR: msg.sender ✓ + Note over SR: status = ACCEPTED + SR -->>- PRV: ✓ + else Provider rejects + PRV ->>+ SR: rejectSwarm(swarmId) + SR ->>+ SP: ownerOf(providerId) + SP -->>- SR: msg.sender ✓ + Note over SR: status = REJECTED + SR -->>- PRV: ✓ + end + end +``` + +## Duplicate Prevention + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant SR as SwarmRegistry + + FO ->>+ SR: registerSwarm(fleetId, providerId, sameFilter, ...) + Note over SR: swarmId = keccak256(fleetId, providerId, sameFilter) + Note over SR: swarms[swarmId] already exists + SR -->>- FO: ❌ revert SwarmAlreadyExists() +``` diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol new file mode 100644 index 00000000..b7122faa --- /dev/null +++ b/test/FleetIdentity.t.sol @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/swarms/FleetIdentity.sol"; + +contract FleetIdentityTest is Test { + FleetIdentity fleet; + + address alice = address(0xA); + address bob = address(0xB); + + bytes16 constant UUID_1 = bytes16(keccak256("fleet-alpha")); + bytes16 constant UUID_2 = bytes16(keccak256("fleet-bravo")); + bytes16 constant UUID_3 = bytes16(keccak256("fleet-charlie")); + + event FleetRegistered(address indexed owner, bytes16 indexed uuid, uint256 indexed tokenId); + event FleetBurned(address indexed owner, uint256 indexed tokenId); + + function setUp() public { + fleet = new FleetIdentity(); + } + + // ============================== + // registerFleet + // ============================== + + function test_registerFleet_mintsAndStoresUUID() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(tokenId, uint256(uint128(UUID_1))); + assertTrue(fleet.activeFleets(tokenId)); + assertEq(fleet.getTotalFleets(), 1); + assertEq(fleet.registeredUUIDs(0), UUID_1); + } + + function test_registerFleet_deterministicTokenId() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + assertEq(tokenId, uint256(uint128(UUID_1))); + } + + function test_registerFleet_emitsEvent() public { + uint256 expectedTokenId = uint256(uint128(UUID_1)); + + vm.expectEmit(true, true, true, true); + emit FleetRegistered(alice, UUID_1, expectedTokenId); + + vm.prank(alice); + fleet.registerFleet(UUID_1); + } + + function test_registerFleet_multipleFleetsDifferentOwners() public { + vm.prank(alice); + fleet.registerFleet(UUID_1); + + vm.prank(bob); + fleet.registerFleet(UUID_2); + + assertEq(fleet.getTotalFleets(), 2); + assertEq(fleet.ownerOf(uint256(uint128(UUID_1))), alice); + assertEq(fleet.ownerOf(uint256(uint128(UUID_2))), bob); + } + + function test_RevertIf_registerFleet_zeroUUID() public { + vm.prank(alice); + vm.expectRevert(FleetIdentity.InvalidUUID.selector); + fleet.registerFleet(bytes16(0)); + } + + function test_RevertIf_registerFleet_duplicateUUID() public { + vm.prank(alice); + fleet.registerFleet(UUID_1); + + vm.prank(bob); + vm.expectRevert(); // ERC721: token already minted + fleet.registerFleet(UUID_1); + } + + // ============================== + // getRegisteredUUIDs (pagination) + // ============================== + + function test_getRegisteredUUIDs_returnsCorrectPage() public { + vm.startPrank(alice); + fleet.registerFleet(UUID_1); + fleet.registerFleet(UUID_2); + fleet.registerFleet(UUID_3); + vm.stopPrank(); + + bytes16[] memory page = fleet.getRegisteredUUIDs(0, 2); + assertEq(page.length, 2); + assertEq(page[0], UUID_1); + assertEq(page[1], UUID_2); + } + + function test_getRegisteredUUIDs_lastPage() public { + vm.startPrank(alice); + fleet.registerFleet(UUID_1); + fleet.registerFleet(UUID_2); + fleet.registerFleet(UUID_3); + vm.stopPrank(); + + bytes16[] memory page = fleet.getRegisteredUUIDs(2, 10); + assertEq(page.length, 1); + assertEq(page[0], UUID_3); + } + + function test_getRegisteredUUIDs_offsetBeyondLength() public { + vm.prank(alice); + fleet.registerFleet(UUID_1); + + bytes16[] memory page = fleet.getRegisteredUUIDs(100, 5); + assertEq(page.length, 0); + } + + function test_RevertIf_getRegisteredUUIDs_zeroLimit() public { + vm.expectRevert(FleetIdentity.InvalidPaginationParams.selector); + fleet.getRegisteredUUIDs(0, 0); + } + + // ============================== + // getTotalFleets + // ============================== + + function test_getTotalFleets_empty() public view { + assertEq(fleet.getTotalFleets(), 0); + } + + function test_getTotalFleets_incrementsOnRegister() public { + vm.startPrank(alice); + fleet.registerFleet(UUID_1); + assertEq(fleet.getTotalFleets(), 1); + + fleet.registerFleet(UUID_2); + assertEq(fleet.getTotalFleets(), 2); + vm.stopPrank(); + } + + // ============================== + // activeFleets mapping + // ============================== + + function test_activeFleets_falseByDefault() public view { + assertFalse(fleet.activeFleets(12345)); + } + + function test_activeFleets_trueAfterRegister() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + assertTrue(fleet.activeFleets(tokenId)); + } + + // ============================== + // Fuzz Tests + // ============================== + + function testFuzz_registerFleet_anyValidUUID(bytes16 uuid) public { + vm.assume(uuid != bytes16(0)); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(uuid); + + assertEq(tokenId, uint256(uint128(uuid))); + assertEq(fleet.ownerOf(tokenId), alice); + assertTrue(fleet.activeFleets(tokenId)); + } + + function testFuzz_getRegisteredUUIDs_boundsHandling(uint256 offset, uint256 limit) public { + // Register 3 fleets + vm.startPrank(alice); + fleet.registerFleet(UUID_1); + fleet.registerFleet(UUID_2); + fleet.registerFleet(UUID_3); + vm.stopPrank(); + + // limit=0 always reverts + if (limit == 0) { + vm.expectRevert(FleetIdentity.InvalidPaginationParams.selector); + fleet.getRegisteredUUIDs(offset, limit); + return; + } + + bytes16[] memory result = fleet.getRegisteredUUIDs(offset, limit); + + if (offset >= 3) { + assertEq(result.length, 0); + } else { + uint256 expectedLen = offset + limit > 3 ? 3 - offset : limit; + assertEq(result.length, expectedLen); + } + } + + // ============================== + // burn + // ============================== + + function test_burn_setsActiveFleetsFalse() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + assertTrue(fleet.activeFleets(tokenId)); + + vm.prank(alice); + fleet.burn(tokenId); + assertFalse(fleet.activeFleets(tokenId)); + } + + function test_burn_emitsEvent() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + vm.expectEmit(true, true, true, true); + emit FleetBurned(alice, tokenId); + + vm.prank(alice); + fleet.burn(tokenId); + } + + function test_burn_ownerOfRevertsAfterBurn() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + vm.prank(alice); + fleet.burn(tokenId); + + vm.expectRevert(); + fleet.ownerOf(tokenId); + } + + function test_RevertIf_burn_notOwner() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.burn(tokenId); + } + + function test_RevertIf_burn_nonexistentToken() public { + vm.prank(alice); + vm.expectRevert(); // ownerOf reverts for nonexistent token + fleet.burn(12345); + } + + function testFuzz_burn_anyValidUUID(bytes16 uuid) public { + vm.assume(uuid != bytes16(0)); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(uuid); + + vm.prank(alice); + fleet.burn(tokenId); + + assertFalse(fleet.activeFleets(tokenId)); + vm.expectRevert(); + fleet.ownerOf(tokenId); + } +} diff --git a/test/ServiceProvider.t.sol b/test/ServiceProvider.t.sol new file mode 100644 index 00000000..9672dd10 --- /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 00000000..816186b9 --- /dev/null +++ b/test/SwarmRegistryL1.t.sol @@ -0,0 +1,1004 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/swarms/SwarmRegistryL1.sol"; +import "../src/swarms/FleetIdentity.sol"; +import "../src/swarms/ServiceProvider.sol"; + +contract SwarmRegistryL1Test is Test { + SwarmRegistryL1 swarmRegistry; + FleetIdentity fleetContract; + ServiceProvider providerContract; + + address fleetOwner = address(0x1); + address providerOwner = address(0x2); + address caller = address(0x3); + + event SwarmRegistered(uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner); + event SwarmStatusChanged(uint256 indexed swarmId, SwarmRegistryL1.SwarmStatus status); + event SwarmFilterUpdated(uint256 indexed swarmId, address indexed owner, uint32 filterSize); + event SwarmProviderUpdated(uint256 indexed swarmId, uint256 indexed oldProvider, uint256 indexed newProvider); + event SwarmDeleted(uint256 indexed swarmId, uint256 indexed fleetId, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, uint256 indexed fleetId, address indexed purgedBy); + + function setUp() public { + fleetContract = new FleetIdentity(); + providerContract = new ServiceProvider(); + swarmRegistry = new SwarmRegistryL1(address(fleetContract), address(providerContract)); + } + + // ============================== + // Helpers + // ============================== + + function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { + vm.prank(owner); + return fleetContract.registerFleet(bytes16(keccak256(seed))); + } + + function _registerProvider(address owner, string memory url) internal returns (uint256) { + vm.prank(owner); + return providerContract.registerProvider(url); + } + + function _registerSwarm( + address owner, + uint256 fleetId, + uint256 providerId, + bytes memory filter, + uint8 fpSize, + SwarmRegistryL1.TagType tagType + ) internal returns (uint256) { + vm.prank(owner); + return swarmRegistry.registerSwarm(fleetId, providerId, filter, fpSize, tagType); + } + + function getExpectedValues(bytes memory tagId, uint256 m, uint8 fpSize) + public + pure + returns (uint32 h1, uint32 h2, uint32 h3, uint256 fp) + { + bytes32 h = keccak256(tagId); + h1 = uint32(uint256(h)) % uint32(m); + h2 = uint32(uint256(h) >> 32) % uint32(m); + h3 = uint32(uint256(h) >> 64) % uint32(m); + uint256 fpMask = (1 << fpSize) - 1; + fp = (uint256(h) >> 96) & fpMask; + } + + function _write16Bit(bytes memory data, uint256 slotIndex, uint16 value) internal pure { + uint256 bitOffset = slotIndex * 16; + uint256 byteOffset = bitOffset / 8; + data[byteOffset] = bytes1(uint8(value >> 8)); + data[byteOffset + 1] = bytes1(uint8(value)); + } + + function _write8Bit(bytes memory data, uint256 slotIndex, uint8 value) internal pure { + data[slotIndex] = bytes1(value); + } + + // ============================== + // Constructor + // ============================== + + function test_constructor_setsImmutables() public view { + assertEq(address(swarmRegistry.FLEET_CONTRACT()), address(fleetContract)); + assertEq(address(swarmRegistry.PROVIDER_CONTRACT()), address(providerContract)); + } + + function test_RevertIf_constructor_zeroFleetAddress() public { + vm.expectRevert(SwarmRegistryL1.InvalidSwarmData.selector); + new SwarmRegistryL1(address(0), address(providerContract)); + } + + function test_RevertIf_constructor_zeroProviderAddress() public { + vm.expectRevert(SwarmRegistryL1.InvalidSwarmData.selector); + new SwarmRegistryL1(address(fleetContract), address(0)); + } + + function test_RevertIf_constructor_bothZero() public { + vm.expectRevert(SwarmRegistryL1.InvalidSwarmData.selector); + new SwarmRegistryL1(address(0), address(0)); + } + + // ============================== + // registerSwarm — happy path + // ============================== + + function test_registerSwarm_basicFlow() public { + uint256 fleetId = _registerFleet(fleetOwner, "my-fleet"); + uint256 providerId = _registerProvider(providerOwner, "https://api.example.com"); + + uint256 swarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(100), 16, SwarmRegistryL1.TagType.IBEACON_INCLUDES_MAC + ); + + // Swarm ID is deterministic hash of (fleetId, providerId, filter) + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, new bytes(100)); + assertEq(swarmId, expectedId); + } + + function test_registerSwarm_storesMetadataCorrectly() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.VENDOR_ID); + + ( + uint256 storedFleetId, + uint256 storedProviderId, + address filterPointer, + uint8 storedFpSize, + SwarmRegistryL1.TagType storedTagType, + SwarmRegistryL1.SwarmStatus storedStatus + ) = swarmRegistry.swarms(swarmId); + + assertEq(storedFleetId, fleetId); + assertEq(storedProviderId, providerId); + assertTrue(filterPointer != address(0)); + assertEq(storedFpSize, 8); + assertEq(uint8(storedTagType), uint8(SwarmRegistryL1.TagType.VENDOR_ID)); + assertEq(uint8(storedStatus), uint8(SwarmRegistryL1.SwarmStatus.REGISTERED)); + } + + function test_registerSwarm_deterministicId() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(32); + + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + + uint256 swarmId = _registerSwarm(fleetOwner, fleetId, providerId, filter, 8, SwarmRegistryL1.TagType.GENERIC); + assertEq(swarmId, expectedId); + } + + function test_RevertIf_registerSwarm_duplicateSwarm() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.SwarmAlreadyExists.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); + } + + function test_registerSwarm_emitsSwarmRegistered() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(50); + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + + vm.expectEmit(true, true, true, true); + emit SwarmRegistered(expectedId, fleetId, providerId, fleetOwner); + + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryL1.TagType.GENERIC); + } + + function test_registerSwarm_linksFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 swarmId2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarmId1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), swarmId2); + } + + function test_registerSwarm_allTagTypes() public { + uint256 fleetId1 = _registerFleet(fleetOwner, "f1"); + uint256 fleetId2 = _registerFleet(fleetOwner, "f2"); + uint256 fleetId3 = _registerFleet(fleetOwner, "f3"); + uint256 fleetId4 = _registerFleet(fleetOwner, "f4"); + uint256 providerId = _registerProvider(providerOwner, "url"); + + uint256 s1 = _registerSwarm( + fleetOwner, fleetId1, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.IBEACON_PAYLOAD_ONLY + ); + uint256 s2 = _registerSwarm( + fleetOwner, fleetId2, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.IBEACON_INCLUDES_MAC + ); + uint256 s3 = + _registerSwarm(fleetOwner, fleetId3, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.VENDOR_ID); + uint256 s4 = _registerSwarm(fleetOwner, fleetId4, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); + + (,,,, SwarmRegistryL1.TagType t1,) = swarmRegistry.swarms(s1); + (,,,, SwarmRegistryL1.TagType t2,) = swarmRegistry.swarms(s2); + (,,,, SwarmRegistryL1.TagType t3,) = swarmRegistry.swarms(s3); + (,,,, SwarmRegistryL1.TagType t4,) = swarmRegistry.swarms(s4); + + assertEq(uint8(t1), uint8(SwarmRegistryL1.TagType.IBEACON_PAYLOAD_ONLY)); + assertEq(uint8(t2), uint8(SwarmRegistryL1.TagType.IBEACON_INCLUDES_MAC)); + assertEq(uint8(t3), uint8(SwarmRegistryL1.TagType.VENDOR_ID)); + assertEq(uint8(t4), uint8(SwarmRegistryL1.TagType.GENERIC)); + } + + // ============================== + // registerSwarm — reverts + // ============================== + + function test_RevertIf_registerSwarm_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "my-fleet"); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + swarmRegistry.registerSwarm(fleetId, 1, new bytes(10), 16, SwarmRegistryL1.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_fingerprintSizeZero() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 0, SwarmRegistryL1.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_fingerprintSizeExceedsMax() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 17, SwarmRegistryL1.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_emptyFilter() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(0), 8, SwarmRegistryL1.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_filterTooLarge() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(24577), 8, SwarmRegistryL1.TagType.GENERIC); + } + + function test_registerSwarm_maxFingerprintSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + // fpSize=16 is MAX_FINGERPRINT_SIZE, should succeed + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(100), 16, SwarmRegistryL1.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + function test_registerSwarm_maxFilterSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + // Exactly 24576 bytes should succeed + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(24576), 8, SwarmRegistryL1.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + // ============================== + // acceptSwarm / rejectSwarm + // ============================== + + function test_acceptSwarm_setsStatusAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmStatusChanged(swarmId, SwarmRegistryL1.SwarmStatus.ACCEPTED); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + (,,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.ACCEPTED)); + } + + function test_rejectSwarm_setsStatusAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmStatusChanged(swarmId, SwarmRegistryL1.SwarmStatus.REJECTED); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + (,,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.REJECTED)); + } + + function test_RevertIf_acceptSwarm_notProviderOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotProviderOwner.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_RevertIf_rejectSwarm_notProviderOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); // fleet owner != provider owner + vm.expectRevert(SwarmRegistryL1.NotProviderOwner.selector); + swarmRegistry.rejectSwarm(swarmId); + } + + function test_acceptSwarm_afterReject() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + // Provider changes mind + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + (,,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.ACCEPTED)); + } + + // ============================== + // checkMembership — XOR logic + // ============================== + + function test_checkMembership_XORLogic16Bit() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + bytes memory tagId = hex"1122334455"; + uint8 fpSize = 16; + uint256 dataLen = 100; + uint256 m = (dataLen * 8) / fpSize; // 50 slots + + (uint32 h1, uint32 h2, uint32 h3, uint256 expectedFp) = getExpectedValues(tagId, m, fpSize); + + // Skip if collision (extremely unlikely with 50 slots) + if (h1 == h2 || h1 == h3 || h2 == h3) { + return; + } + + bytes memory filter = new bytes(dataLen); + _write16Bit(filter, h1, uint16(expectedFp)); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, fpSize, SwarmRegistryL1.TagType.GENERIC); + + // Positive check + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256(tagId)), "Valid tag should pass"); + + // Negative check + assertFalse(swarmRegistry.checkMembership(swarmId, keccak256(hex"999999")), "Invalid tag should fail"); + } + + function test_checkMembership_XORLogic8Bit() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + bytes memory tagId = hex"AABBCCDD"; + uint8 fpSize = 8; + // SSTORE2 prepends 0x00 STOP byte, so on-chain: + // extcodesize = rawLen + 1, dataLen = extcodesize - 1 = rawLen + // But SSTORE2.read offsets reads by +1 (skips STOP byte), so + // the data bytes read on-chain map 1:1 to the bytes we pass in. + // Therefore m = (rawLen * 8) / fpSize and slot indices match directly. + uint256 rawLen = 80; + uint256 m = (rawLen * 8) / fpSize; // 80 + + (uint32 h1, uint32 h2, uint32 h3, uint256 expectedFp) = getExpectedValues(tagId, m, fpSize); + + if (h1 == h2 || h1 == h3 || h2 == h3) { + return; + } + + bytes memory filter = new bytes(rawLen); + _write8Bit(filter, h1, uint8(expectedFp)); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, fpSize, SwarmRegistryL1.TagType.GENERIC); + + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256(tagId)), "8-bit valid tag should pass"); + assertFalse(swarmRegistry.checkMembership(swarmId, keccak256(hex"FFFFFF")), "8-bit invalid tag should fail"); + } + + function test_RevertIf_checkMembership_swarmNotFound() public { + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.checkMembership(999, keccak256("anything")); + } + + function test_checkMembership_allZeroFilter_returnsConsistent() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + // All-zero filter: f1^f2^f3 = 0^0^0 = 0 + // Only matches if expectedFp is also 0 + bytes memory filter = new bytes(64); + uint256 swarmId = _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryL1.TagType.GENERIC); + + // Some tags will match (those with expectedFp=0), most won't + // The point is it doesn't revert + swarmRegistry.checkMembership(swarmId, keccak256("test1")); + swarmRegistry.checkMembership(swarmId, keccak256("test2")); + } + + // ============================== + // Multiple swarms per fleet + // ============================== + + function test_multipleSwarms_sameFleet() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + uint256 providerId3 = _registerProvider(providerOwner, "url3"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 s2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(64), 16, SwarmRegistryL1.TagType.VENDOR_ID); + uint256 s3 = _registerSwarm( + fleetOwner, fleetId, providerId3, new bytes(50), 12, SwarmRegistryL1.TagType.IBEACON_PAYLOAD_ONLY + ); + + // IDs are distinct hashes + assertTrue(s1 != s2 && s2 != s3 && s1 != s3); + + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), s2); + assertEq(swarmRegistry.fleetSwarms(fleetId, 2), s3); + } + + // ============================== + // Constants + // ============================== + + function test_constants() public view { + assertEq(swarmRegistry.MAX_FINGERPRINT_SIZE(), 16); + } + + // ============================== + // Fuzz + // ============================== + + function testFuzz_registerSwarm_validFingerprintSizes(uint8 fpSize) public { + fpSize = uint8(bound(fpSize, 1, 16)); + + uint256 fleetId = _registerFleet(fleetOwner, abi.encodePacked("fleet-", fpSize)); + uint256 providerId = _registerProvider(providerOwner, string(abi.encodePacked("url-", fpSize))); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(64), fpSize, SwarmRegistryL1.TagType.GENERIC); + + (,,, uint8 storedFp,,) = swarmRegistry.swarms(swarmId); + assertEq(storedFp, fpSize); + } + + function testFuzz_registerSwarm_invalidFingerprintSizes(uint8 fpSize) public { + vm.assume(fpSize == 0 || fpSize > 16); + + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), fpSize, SwarmRegistryL1.TagType.GENERIC); + } + + // ============================== + // updateSwarmFilter + // ============================== + + function test_updateSwarmFilter_updatesFilterAndResetsStatus() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Provider accepts + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Fleet owner updates filter + bytes memory newFilter = new bytes(100); + vm.expectEmit(true, true, true, true); + emit SwarmFilterUpdated(swarmId, fleetOwner, 100); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, newFilter); + + // Status should be reset to REGISTERED + (,,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.REGISTERED)); + } + + function test_updateSwarmFilter_changesFilterPointer() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + (,, address oldPointer,,,) = swarmRegistry.swarms(swarmId); + + bytes memory newFilter = new bytes(100); + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, newFilter); + + (,, address newPointer,,,) = swarmRegistry.swarms(swarmId); + assertTrue(newPointer != oldPointer); + assertTrue(newPointer != address(0)); + } + + function test_RevertIf_updateSwarmFilter_swarmNotFound() public { + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.updateSwarmFilter(999, new bytes(50)); + } + + function test_RevertIf_updateSwarmFilter_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); + } + + function test_RevertIf_updateSwarmFilter_emptyFilter() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(0)); + } + + function test_RevertIf_updateSwarmFilter_filterTooLarge() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(24577)); + } + + // ============================== + // updateSwarmProvider + // ============================== + + function test_updateSwarmProvider_updatesProviderAndResetsStatus() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Provider accepts + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Fleet owner updates provider + vm.expectEmit(true, true, true, true); + emit SwarmProviderUpdated(swarmId, providerId1, providerId2); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + + // Check new provider and status reset + (, uint256 newProviderId,,,, SwarmRegistryL1.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(newProviderId, providerId2); + assertEq(uint8(status), uint8(SwarmRegistryL1.SwarmStatus.REGISTERED)); + } + + function test_RevertIf_updateSwarmProvider_swarmNotFound() public { + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.updateSwarmProvider(999, providerId); + } + + function test_RevertIf_updateSwarmProvider_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + } + + function test_RevertIf_updateSwarmProvider_providerDoesNotExist() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + // ERC721 reverts before our custom error is reached + vm.expectRevert(); + swarmRegistry.updateSwarmProvider(swarmId, 99999); + } + + // ============================== + // deleteSwarm + // ============================== + + function test_deleteSwarm_removesSwarmAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmDeleted(swarmId, fleetId, fleetOwner); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // Swarm should be zeroed + (,, address pointer,,,) = swarmRegistry.swarms(swarmId); + assertEq(pointer, address(0)); + } + + function test_deleteSwarm_removesFromFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarm1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 swarm2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Delete first swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarm1); + + // Only swarm2 should remain in fleetSwarms + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm2); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 1); // Should be out of bounds + } + + function test_deleteSwarm_swapAndPop() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + uint256 providerId3 = _registerProvider(providerOwner, "url3"); + + uint256 swarm1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 swarm2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 swarm3 = + _registerSwarm(fleetOwner, fleetId, providerId3, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Delete middle swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarm2); + + // swarm3 should be swapped to index 1 + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), swarm3); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 2); // Should be out of bounds + } + + function test_RevertIf_deleteSwarm_swarmNotFound() public { + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.deleteSwarm(999); + } + + function test_RevertIf_deleteSwarm_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + swarmRegistry.deleteSwarm(swarmId); + } + + function test_deleteSwarm_afterUpdate() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Update then delete + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + (,, address pointer,,,) = swarmRegistry.swarms(swarmId); + assertEq(pointer, address(0)); + } + + function test_deleteSwarm_updatesSwarmIndexInFleet() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 p1 = _registerProvider(providerOwner, "url1"); + uint256 p2 = _registerProvider(providerOwner, "url2"); + uint256 p3 = _registerProvider(providerOwner, "url3"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, p1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 s2 = _registerSwarm(fleetOwner, fleetId, p2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 s3 = _registerSwarm(fleetOwner, fleetId, p3, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Verify initial indices + assertEq(swarmRegistry.swarmIndexInFleet(s1), 0); + assertEq(swarmRegistry.swarmIndexInFleet(s2), 1); + assertEq(swarmRegistry.swarmIndexInFleet(s3), 2); + + // Delete s1 — s3 should be swapped to index 0 + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(s1); + + assertEq(swarmRegistry.swarmIndexInFleet(s3), 0); + assertEq(swarmRegistry.swarmIndexInFleet(s2), 1); + assertEq(swarmRegistry.swarmIndexInFleet(s1), 0); // deleted, reset to 0 + } + + // ============================== + // isSwarmValid + // ============================== + + function test_isSwarmValid_bothValid() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertTrue(providerValid); + } + + function test_isSwarmValid_providerBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider + vm.prank(providerOwner); + providerContract.burn(providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertFalse(providerValid); + } + + function test_isSwarmValid_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn fleet + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertFalse(fleetValid); + assertTrue(providerValid); + } + + function test_isSwarmValid_bothBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + vm.prank(providerOwner); + providerContract.burn(providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertFalse(fleetValid); + assertFalse(providerValid); + } + + function test_RevertIf_isSwarmValid_swarmNotFound() public { + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.isSwarmValid(999); + } + + // ============================== + // purgeOrphanedSwarm + // ============================== + + function test_purgeOrphanedSwarm_providerBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider + vm.prank(providerOwner); + providerContract.burn(providerId); + + // Anyone can purge + vm.expectEmit(true, true, true, true); + emit SwarmPurged(swarmId, fleetId, caller); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // Swarm should be zeroed + (,, address pointer,,,) = swarmRegistry.swarms(swarmId); + assertEq(pointer, address(0)); + } + + function test_purgeOrphanedSwarm_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn fleet + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + (,, address pointer,,,) = swarmRegistry.swarms(swarmId); + assertEq(pointer, address(0)); + } + + function test_purgeOrphanedSwarm_removesFromFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 p1 = _registerProvider(providerOwner, "url1"); + uint256 p2 = _registerProvider(providerOwner, "url2"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, p1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + uint256 s2 = _registerSwarm(fleetOwner, fleetId, p2, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider of s1 + vm.prank(providerOwner); + providerContract.burn(p1); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(s1); + + // s2 should be swapped to index 0 + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s2); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 1); + } + + function test_RevertIf_purgeOrphanedSwarm_swarmNotFound() public { + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.purgeOrphanedSwarm(999); + } + + function test_RevertIf_purgeOrphanedSwarm_swarmNotOrphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.expectRevert(SwarmRegistryL1.SwarmNotOrphaned.selector); + swarmRegistry.purgeOrphanedSwarm(swarmId); + } + + // ============================== + // Orphan guards on accept/reject/checkMembership + // ============================== + + function test_RevertIf_acceptSwarm_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryL1.SwarmOrphaned.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_RevertIf_rejectSwarm_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn fleet + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryL1.SwarmOrphaned.selector); + swarmRegistry.rejectSwarm(swarmId); + } + + function test_RevertIf_checkMembership_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + // Burn provider + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.expectRevert(SwarmRegistryL1.SwarmOrphaned.selector); + swarmRegistry.checkMembership(swarmId, keccak256("test")); + } + + function test_RevertIf_acceptSwarm_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryL1.SwarmOrphaned.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_purge_thenAcceptReverts() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // After purge, swarm no longer exists + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryL1.SwarmNotFound.selector); + swarmRegistry.acceptSwarm(swarmId); + } +} diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol new file mode 100644 index 00000000..3829348b --- /dev/null +++ b/test/SwarmRegistryUniversal.t.sol @@ -0,0 +1,1140 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/swarms/SwarmRegistryUniversal.sol"; +import "../src/swarms/FleetIdentity.sol"; +import "../src/swarms/ServiceProvider.sol"; + +contract SwarmRegistryUniversalTest is Test { + SwarmRegistryUniversal swarmRegistry; + FleetIdentity fleetContract; + ServiceProvider providerContract; + + address fleetOwner = address(0x1); + address providerOwner = address(0x2); + address caller = address(0x3); + + event SwarmRegistered( + uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner, uint32 filterSize + ); + event SwarmStatusChanged(uint256 indexed swarmId, SwarmRegistryUniversal.SwarmStatus status); + event SwarmFilterUpdated(uint256 indexed swarmId, address indexed owner, uint32 newFilterSize); + event SwarmProviderUpdated(uint256 indexed swarmId, uint256 indexed oldProviderId, uint256 indexed newProviderId); + event SwarmDeleted(uint256 indexed swarmId, uint256 indexed fleetId, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, uint256 indexed fleetId, address indexed purgedBy); + + function setUp() public { + fleetContract = new FleetIdentity(); + providerContract = new ServiceProvider(); + swarmRegistry = new SwarmRegistryUniversal(address(fleetContract), address(providerContract)); + } + + // ============================== + // Helpers + // ============================== + + function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { + vm.prank(owner); + return fleetContract.registerFleet(bytes16(keccak256(seed))); + } + + function _registerProvider(address owner, string memory url) internal returns (uint256) { + vm.prank(owner); + return providerContract.registerProvider(url); + } + + function _registerSwarm( + address owner, + uint256 fleetId, + uint256 providerId, + bytes memory filter, + uint8 fpSize, + SwarmRegistryUniversal.TagType tagType + ) internal returns (uint256) { + vm.prank(owner); + return swarmRegistry.registerSwarm(fleetId, providerId, filter, fpSize, tagType); + } + + function getExpectedValues(bytes memory tagId, uint256 m, uint8 fpSize) + public + pure + returns (uint32 h1, uint32 h2, uint32 h3, uint256 fp) + { + bytes32 h = keccak256(tagId); + h1 = uint32(uint256(h)) % uint32(m); + h2 = uint32(uint256(h) >> 32) % uint32(m); + h3 = uint32(uint256(h) >> 64) % uint32(m); + uint256 fpMask = (1 << fpSize) - 1; + fp = (uint256(h) >> 96) & fpMask; + } + + function _write16Bit(bytes memory data, uint256 slotIndex, uint16 value) internal pure { + uint256 byteOffset = (slotIndex * 16) / 8; + data[byteOffset] = bytes1(uint8(value >> 8)); + data[byteOffset + 1] = bytes1(uint8(value)); + } + + function _write8Bit(bytes memory data, uint256 slotIndex, uint8 value) internal pure { + data[slotIndex] = bytes1(value); + } + + // ============================== + // Constructor + // ============================== + + function test_constructor_setsImmutables() public view { + assertEq(address(swarmRegistry.FLEET_CONTRACT()), address(fleetContract)); + assertEq(address(swarmRegistry.PROVIDER_CONTRACT()), address(providerContract)); + } + + function test_RevertIf_constructor_zeroFleetAddress() public { + vm.expectRevert(SwarmRegistryUniversal.InvalidSwarmData.selector); + new SwarmRegistryUniversal(address(0), address(providerContract)); + } + + function test_RevertIf_constructor_zeroProviderAddress() public { + vm.expectRevert(SwarmRegistryUniversal.InvalidSwarmData.selector); + new SwarmRegistryUniversal(address(fleetContract), address(0)); + } + + function test_RevertIf_constructor_bothZero() public { + vm.expectRevert(SwarmRegistryUniversal.InvalidSwarmData.selector); + new SwarmRegistryUniversal(address(0), address(0)); + } + + // ============================== + // registerSwarm — happy path + // ============================== + + function test_registerSwarm_basicFlow() public { + uint256 fleetId = _registerFleet(fleetOwner, "my-fleet"); + uint256 providerId = _registerProvider(providerOwner, "https://api.example.com"); + + uint256 swarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(100), 16, SwarmRegistryUniversal.TagType.IBEACON_INCLUDES_MAC + ); + + // Swarm ID is deterministic hash of (fleetId, providerId, filter) + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, new bytes(100)); + assertEq(swarmId, expectedId); + } + + function test_registerSwarm_storesMetadataCorrectly() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 12, SwarmRegistryUniversal.TagType.VENDOR_ID); + + ( + uint256 storedFleetId, + uint256 storedProviderId, + uint32 storedFilterLen, + uint8 storedFpSize, + SwarmRegistryUniversal.TagType storedTagType, + SwarmRegistryUniversal.SwarmStatus storedStatus + ) = swarmRegistry.swarms(swarmId); + + assertEq(storedFleetId, fleetId); + assertEq(storedProviderId, providerId); + assertEq(storedFilterLen, 50); + assertEq(storedFpSize, 12); + assertEq(uint8(storedTagType), uint8(SwarmRegistryUniversal.TagType.VENDOR_ID)); + assertEq(uint8(storedStatus), uint8(SwarmRegistryUniversal.SwarmStatus.REGISTERED)); + } + + function test_registerSwarm_storesFilterData() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(100); + // Write some non-zero data + filter[0] = 0xAB; + filter[50] = 0xCD; + filter[99] = 0xEF; + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryUniversal.TagType.GENERIC); + + bytes memory storedFilter = swarmRegistry.getFilterData(swarmId); + assertEq(storedFilter.length, 100); + assertEq(uint8(storedFilter[0]), 0xAB); + assertEq(uint8(storedFilter[50]), 0xCD); + assertEq(uint8(storedFilter[99]), 0xEF); + } + + function test_registerSwarm_deterministicId() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(32); + + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 8, SwarmRegistryUniversal.TagType.GENERIC); + assertEq(swarmId, expectedId); + } + + function test_RevertIf_registerSwarm_duplicateSwarm() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmAlreadyExists.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_registerSwarm_emitsSwarmRegistered() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(50); + uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + + vm.expectEmit(true, true, true, true); + emit SwarmRegistered(expectedId, fleetId, providerId, fleetOwner, 50); + + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_registerSwarm_linksFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 s1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), s2); + } + + function test_registerSwarm_allTagTypes() public { + uint256 fleetId1 = _registerFleet(fleetOwner, "f1"); + uint256 fleetId2 = _registerFleet(fleetOwner, "f2"); + uint256 fleetId3 = _registerFleet(fleetOwner, "f3"); + uint256 fleetId4 = _registerFleet(fleetOwner, "f4"); + uint256 providerId = _registerProvider(providerOwner, "url"); + + uint256 s1 = _registerSwarm( + fleetOwner, fleetId1, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.IBEACON_PAYLOAD_ONLY + ); + uint256 s2 = _registerSwarm( + fleetOwner, fleetId2, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.IBEACON_INCLUDES_MAC + ); + uint256 s3 = + _registerSwarm(fleetOwner, fleetId3, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.VENDOR_ID); + uint256 s4 = + _registerSwarm(fleetOwner, fleetId4, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); + + (,,,, SwarmRegistryUniversal.TagType t1,) = swarmRegistry.swarms(s1); + (,,,, SwarmRegistryUniversal.TagType t2,) = swarmRegistry.swarms(s2); + (,,,, SwarmRegistryUniversal.TagType t3,) = swarmRegistry.swarms(s3); + (,,,, SwarmRegistryUniversal.TagType t4,) = swarmRegistry.swarms(s4); + + assertEq(uint8(t1), uint8(SwarmRegistryUniversal.TagType.IBEACON_PAYLOAD_ONLY)); + assertEq(uint8(t2), uint8(SwarmRegistryUniversal.TagType.IBEACON_INCLUDES_MAC)); + assertEq(uint8(t3), uint8(SwarmRegistryUniversal.TagType.VENDOR_ID)); + assertEq(uint8(t4), uint8(SwarmRegistryUniversal.TagType.GENERIC)); + } + + // ============================== + // registerSwarm — reverts + // ============================== + + function test_RevertIf_registerSwarm_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "my-fleet"); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + swarmRegistry.registerSwarm(fleetId, 1, new bytes(10), 16, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_fingerprintSizeZero() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 0, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_fingerprintSizeExceedsMax() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 17, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_emptyFilter() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFilterSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(0), 8, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_RevertIf_registerSwarm_filterTooLarge() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.FilterTooLarge.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(24577), 8, SwarmRegistryUniversal.TagType.GENERIC); + } + + function test_registerSwarm_maxFingerprintSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(100), 16, SwarmRegistryUniversal.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + function test_registerSwarm_maxFilterSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + // Exactly MAX_FILTER_SIZE (24576) should succeed + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(24576), 8, SwarmRegistryUniversal.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + function test_registerSwarm_minFilterSize() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + // 1 byte filter + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(1), 8, SwarmRegistryUniversal.TagType.GENERIC); + assertTrue(swarmId != 0); + } + + // ============================== + // acceptSwarm / rejectSwarm + // ============================== + + function test_acceptSwarm_setsStatusAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmStatusChanged(swarmId, SwarmRegistryUniversal.SwarmStatus.ACCEPTED); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + (,,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.ACCEPTED)); + } + + function test_rejectSwarm_setsStatusAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmStatusChanged(swarmId, SwarmRegistryUniversal.SwarmStatus.REJECTED); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + (,,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.REJECTED)); + } + + function test_RevertIf_acceptSwarm_notProviderOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotProviderOwner.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_RevertIf_rejectSwarm_notProviderOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); // fleet owner != provider owner + vm.expectRevert(SwarmRegistryUniversal.NotProviderOwner.selector); + swarmRegistry.rejectSwarm(swarmId); + } + + function test_RevertIf_acceptSwarm_fleetOwnerNotProvider() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.NotProviderOwner.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_acceptSwarm_afterReject() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + (,,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.ACCEPTED)); + } + + function test_rejectSwarm_afterAccept() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + vm.prank(providerOwner); + swarmRegistry.rejectSwarm(swarmId); + + (,,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.REJECTED)); + } + + // ============================== + // checkMembership — XOR logic + // ============================== + + function test_checkMembership_XORLogic16Bit() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + bytes memory tagId = hex"1122334455"; + uint8 fpSize = 16; + uint256 dataLen = 100; + uint256 m = (dataLen * 8) / fpSize; // 50 slots + + (uint32 h1, uint32 h2, uint32 h3, uint256 expectedFp) = getExpectedValues(tagId, m, fpSize); + + if (h1 == h2 || h1 == h3 || h2 == h3) { + return; + } + + bytes memory filter = new bytes(dataLen); + _write16Bit(filter, h1, uint16(expectedFp)); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, fpSize, SwarmRegistryUniversal.TagType.GENERIC); + + bytes32 tagHash = keccak256(tagId); + assertTrue(swarmRegistry.checkMembership(swarmId, tagHash), "Tag should be member"); + + bytes32 fakeHash = keccak256("not-a-tag"); + assertFalse(swarmRegistry.checkMembership(swarmId, fakeHash), "Fake tag should not be member"); + } + + function test_checkMembership_XORLogic8Bit() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + bytes memory tagId = hex"AABBCCDD"; + uint8 fpSize = 8; + uint256 dataLen = 80; + uint256 m = (dataLen * 8) / fpSize; // 80 slots + + (uint32 h1, uint32 h2, uint32 h3, uint256 expectedFp) = getExpectedValues(tagId, m, fpSize); + + if (h1 == h2 || h1 == h3 || h2 == h3) { + return; + } + + bytes memory filter = new bytes(dataLen); + _write8Bit(filter, h1, uint8(expectedFp)); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, fpSize, SwarmRegistryUniversal.TagType.GENERIC); + + assertTrue(swarmRegistry.checkMembership(swarmId, keccak256(tagId)), "8-bit valid tag should pass"); + assertFalse(swarmRegistry.checkMembership(swarmId, keccak256(hex"FFFFFF")), "8-bit invalid tag should fail"); + } + + function test_RevertIf_checkMembership_swarmNotFound() public { + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.checkMembership(999, keccak256("anything")); + } + + function test_checkMembership_allZeroFilter_returnsConsistent() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "u1"); + + // All-zero filter: f1^f2^f3 = 0^0^0 = 0 + bytes memory filter = new bytes(64); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryUniversal.TagType.GENERIC); + + // Should not revert regardless of result + swarmRegistry.checkMembership(swarmId, keccak256("test1")); + swarmRegistry.checkMembership(swarmId, keccak256("test2")); + } + + // ============================== + // getFilterData + // ============================== + + function test_getFilterData_returnsCorrectData() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(100); + filter[0] = 0xFF; + filter[99] = 0x01; + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryUniversal.TagType.GENERIC); + + bytes memory stored = swarmRegistry.getFilterData(swarmId); + assertEq(stored.length, 100); + assertEq(uint8(stored[0]), 0xFF); + assertEq(uint8(stored[99]), 0x01); + } + + function test_RevertIf_getFilterData_swarmNotFound() public { + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.getFilterData(999); + } + + // ============================== + // Multiple swarms per fleet + // ============================== + + function test_multipleSwarms_sameFleet() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + uint256 providerId3 = _registerProvider(providerOwner, "url3"); + + uint256 s1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s2 = _registerSwarm( + fleetOwner, fleetId, providerId2, new bytes(64), 16, SwarmRegistryUniversal.TagType.VENDOR_ID + ); + uint256 s3 = _registerSwarm( + fleetOwner, fleetId, providerId3, new bytes(50), 12, SwarmRegistryUniversal.TagType.IBEACON_PAYLOAD_ONLY + ); + + // IDs are distinct hashes + assertTrue(s1 != s2 && s2 != s3 && s1 != s3); + + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), s2); + assertEq(swarmRegistry.fleetSwarms(fleetId, 2), s3); + } + + // ============================== + // Constants + // ============================== + + function test_constants() public view { + assertEq(swarmRegistry.MAX_FINGERPRINT_SIZE(), 16); + assertEq(swarmRegistry.MAX_FILTER_SIZE(), 24576); + } + + // ============================== + // Fuzz + // ============================== + + function testFuzz_registerSwarm_validFingerprintSizes(uint8 fpSize) public { + fpSize = uint8(bound(fpSize, 1, 16)); + + uint256 fleetId = _registerFleet(fleetOwner, abi.encodePacked("fleet-", fpSize)); + uint256 providerId = _registerProvider(providerOwner, string(abi.encodePacked("url-", fpSize))); + + uint256 swarmId = _registerSwarm( + fleetOwner, fleetId, providerId, new bytes(64), fpSize, SwarmRegistryUniversal.TagType.GENERIC + ); + + (,,, uint8 storedFp,,) = swarmRegistry.swarms(swarmId); + assertEq(storedFp, fpSize); + } + + function testFuzz_registerSwarm_invalidFingerprintSizes(uint8 fpSize) public { + vm.assume(fpSize == 0 || fpSize > 16); + + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFingerprintSize.selector); + swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), fpSize, SwarmRegistryUniversal.TagType.GENERIC); + } + + function testFuzz_registerSwarm_filterSizeRange(uint256 size) public { + size = bound(size, 1, 24576); + + uint256 fleetId = _registerFleet(fleetOwner, abi.encodePacked("f-", size)); + uint256 providerId = _registerProvider(providerOwner, string(abi.encodePacked("url-", size))); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(size), 8, SwarmRegistryUniversal.TagType.GENERIC); + + (,, uint32 storedLen,,,) = swarmRegistry.swarms(swarmId); + assertEq(storedLen, uint32(size)); + } + + // ============================== + // updateSwarmFilter + // ============================== + + function test_updateSwarmFilter_updatesFilterAndResetsStatus() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Provider accepts + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Fleet owner updates filter + bytes memory newFilter = new bytes(100); + for (uint256 i = 0; i < 100; i++) { + newFilter[i] = bytes1(uint8(i % 256)); + } + + vm.expectEmit(true, true, true, true); + emit SwarmFilterUpdated(swarmId, fleetOwner, 100); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, newFilter); + + // Status should be reset to REGISTERED + (,, uint32 filterLength,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.REGISTERED)); + assertEq(filterLength, 100); + } + + function test_updateSwarmFilter_changesFilterLength() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + (,, uint32 oldLen,,,) = swarmRegistry.swarms(swarmId); + assertEq(oldLen, 50); + + bytes memory newFilter = new bytes(100); + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, newFilter); + + (,, uint32 newLen,,,) = swarmRegistry.swarms(swarmId); + assertEq(newLen, 100); + } + + function test_RevertIf_updateSwarmFilter_swarmNotFound() public { + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.updateSwarmFilter(999, new bytes(50)); + } + + function test_RevertIf_updateSwarmFilter_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); + } + + function test_RevertIf_updateSwarmFilter_emptyFilter() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.InvalidFilterSize.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(0)); + } + + function test_RevertIf_updateSwarmFilter_filterTooLarge() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.FilterTooLarge.selector); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(24577)); + } + + // ============================== + // updateSwarmProvider + // ============================== + + function test_updateSwarmProvider_updatesProviderAndResetsStatus() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Provider accepts + vm.prank(providerOwner); + swarmRegistry.acceptSwarm(swarmId); + + // Fleet owner updates provider + vm.expectEmit(true, true, true, true); + emit SwarmProviderUpdated(swarmId, providerId1, providerId2); + + vm.prank(fleetOwner); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + + // Check new provider and status reset + (, uint256 newProviderId,,,, SwarmRegistryUniversal.SwarmStatus status) = swarmRegistry.swarms(swarmId); + assertEq(newProviderId, providerId2); + assertEq(uint8(status), uint8(SwarmRegistryUniversal.SwarmStatus.REGISTERED)); + } + + function test_RevertIf_updateSwarmProvider_swarmNotFound() public { + uint256 providerId = _registerProvider(providerOwner, "url1"); + + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.updateSwarmProvider(999, providerId); + } + + function test_RevertIf_updateSwarmProvider_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + swarmRegistry.updateSwarmProvider(swarmId, providerId2); + } + + function test_RevertIf_updateSwarmProvider_providerDoesNotExist() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + // ERC721 reverts before our custom error is reached + vm.expectRevert(); + swarmRegistry.updateSwarmProvider(swarmId, 99999); + } + + // ============================== + // deleteSwarm + // ============================== + + function test_deleteSwarm_removesSwarmAndEmits() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.expectEmit(true, true, true, true); + emit SwarmDeleted(swarmId, fleetId, fleetOwner); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // Swarm should be zeroed + (uint256 fleetIdAfter,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(fleetIdAfter, 0); + assertEq(filterLength, 0); + } + + function test_deleteSwarm_removesFromFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + + uint256 swarm1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 swarm2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Delete first swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarm1); + + // Only swarm2 should remain in fleetSwarms + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm2); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 1); // Should be out of bounds + } + + function test_deleteSwarm_swapAndPop() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId1 = _registerProvider(providerOwner, "url1"); + uint256 providerId2 = _registerProvider(providerOwner, "url2"); + uint256 providerId3 = _registerProvider(providerOwner, "url3"); + + uint256 swarm1 = + _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 swarm2 = + _registerSwarm(fleetOwner, fleetId, providerId2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 swarm3 = + _registerSwarm(fleetOwner, fleetId, providerId3, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Delete middle swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarm2); + + // swarm3 should be swapped to index 1 + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm1); + assertEq(swarmRegistry.fleetSwarms(fleetId, 1), swarm3); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 2); // Should be out of bounds + } + + function test_deleteSwarm_clearsFilterData() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filterData = new bytes(50); + for (uint256 i = 0; i < 50; i++) { + filterData[i] = bytes1(uint8(i)); + } + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filterData, 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Delete swarm + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + // filterLength should be cleared + (,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(filterLength, 0); + } + + function test_RevertIf_deleteSwarm_swarmNotFound() public { + vm.prank(fleetOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.deleteSwarm(999); + } + + function test_RevertIf_deleteSwarm_notFleetOwner() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(caller); + vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + swarmRegistry.deleteSwarm(swarmId); + } + + function test_deleteSwarm_afterUpdate() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Update then delete + vm.prank(fleetOwner); + swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); + + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(swarmId); + + (uint256 fleetIdAfter,,,,,) = swarmRegistry.swarms(swarmId); + assertEq(fleetIdAfter, 0); + } + + function test_deleteSwarm_updatesSwarmIndexInFleet() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 p1 = _registerProvider(providerOwner, "url1"); + uint256 p2 = _registerProvider(providerOwner, "url2"); + uint256 p3 = _registerProvider(providerOwner, "url3"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, p1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s2 = _registerSwarm(fleetOwner, fleetId, p2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s3 = _registerSwarm(fleetOwner, fleetId, p3, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Verify initial indices + assertEq(swarmRegistry.swarmIndexInFleet(s1), 0); + assertEq(swarmRegistry.swarmIndexInFleet(s2), 1); + assertEq(swarmRegistry.swarmIndexInFleet(s3), 2); + + // Delete s1 — s3 should be swapped to index 0 + vm.prank(fleetOwner); + swarmRegistry.deleteSwarm(s1); + + assertEq(swarmRegistry.swarmIndexInFleet(s3), 0); + assertEq(swarmRegistry.swarmIndexInFleet(s2), 1); + assertEq(swarmRegistry.swarmIndexInFleet(s1), 0); // deleted, reset to 0 + } + + // ============================== + // isSwarmValid + // ============================== + + function test_isSwarmValid_bothValid() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertTrue(providerValid); + } + + function test_isSwarmValid_providerBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertTrue(fleetValid); + assertFalse(providerValid); + } + + function test_isSwarmValid_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertFalse(fleetValid); + assertTrue(providerValid); + } + + function test_isSwarmValid_bothBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + vm.prank(providerOwner); + providerContract.burn(providerId); + + (bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); + assertFalse(fleetValid); + assertFalse(providerValid); + } + + function test_RevertIf_isSwarmValid_swarmNotFound() public { + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.isSwarmValid(999); + } + + // ============================== + // purgeOrphanedSwarm + // ============================== + + function test_purgeOrphanedSwarm_providerBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.expectEmit(true, true, true, true); + emit SwarmPurged(swarmId, fleetId, caller); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + (,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(filterLength, 0); + } + + function test_purgeOrphanedSwarm_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + (uint256 fId,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(fId, 0); + assertEq(filterLength, 0); + } + + function test_purgeOrphanedSwarm_removesFromFleetSwarms() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 p1 = _registerProvider(providerOwner, "url1"); + uint256 p2 = _registerProvider(providerOwner, "url2"); + + uint256 s1 = _registerSwarm(fleetOwner, fleetId, p1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + uint256 s2 = _registerSwarm(fleetOwner, fleetId, p2, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + // Burn provider of s1 + vm.prank(providerOwner); + providerContract.burn(p1); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(s1); + + // s2 should be swapped to index 0 + assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s2); + vm.expectRevert(); + swarmRegistry.fleetSwarms(fleetId, 1); + } + + function test_purgeOrphanedSwarm_clearsFilterData() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + + bytes memory filter = new bytes(50); + for (uint256 i = 0; i < 50; i++) { + filter[i] = bytes1(uint8(i)); + } + + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, filter, 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // filterLength should be cleared + (,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(filterLength, 0); + } + + function test_RevertIf_purgeOrphanedSwarm_swarmNotFound() public { + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.purgeOrphanedSwarm(999); + } + + function test_RevertIf_purgeOrphanedSwarm_swarmNotOrphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.expectRevert(SwarmRegistryUniversal.SwarmNotOrphaned.selector); + swarmRegistry.purgeOrphanedSwarm(swarmId); + } + + // ============================== + // Orphan guards on accept/reject/checkMembership + // ============================== + + function test_RevertIf_acceptSwarm_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmOrphaned.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_RevertIf_rejectSwarm_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmOrphaned.selector); + swarmRegistry.rejectSwarm(swarmId); + } + + function test_RevertIf_checkMembership_orphaned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.expectRevert(SwarmRegistryUniversal.SwarmOrphaned.selector); + swarmRegistry.checkMembership(swarmId, keccak256("test")); + } + + function test_RevertIf_acceptSwarm_fleetBurned() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(fleetOwner); + fleetContract.burn(fleetId); + + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmOrphaned.selector); + swarmRegistry.acceptSwarm(swarmId); + } + + function test_purge_thenAcceptReverts() public { + uint256 fleetId = _registerFleet(fleetOwner, "f1"); + uint256 providerId = _registerProvider(providerOwner, "url1"); + uint256 swarmId = + _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); + + vm.prank(providerOwner); + providerContract.burn(providerId); + + vm.prank(caller); + swarmRegistry.purgeOrphanedSwarm(swarmId); + + // After purge, swarm no longer exists + vm.prank(providerOwner); + vm.expectRevert(SwarmRegistryUniversal.SwarmNotFound.selector); + swarmRegistry.acceptSwarm(swarmId); + } +} From 06e5a5f71ed6590cd93507eb6e07abf65e61a9a1 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 10 Feb 2026 15:49:39 +1300 Subject: [PATCH 02/15] chore: simplify architecture diagram --- src/swarms/doc/graph-architecture.md | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/swarms/doc/graph-architecture.md b/src/swarms/doc/graph-architecture.md index fba222be..3a3f1116 100644 --- a/src/swarms/doc/graph-architecture.md +++ b/src/swarms/doc/graph-architecture.md @@ -8,8 +8,7 @@ graph TB end subgraph Registries["Registry Layer"] - L1["SwarmRegistryL1
SSTORE2 filter storage
Ethereum L1 only"] - UNI["SwarmRegistryUniversal
native bytes storage
All EVM chains"] + REG["SwarmRegistry
L1 variant: SSTORE2 filter storage
Universal variant: native bytes storage"] end subgraph Actors @@ -19,23 +18,17 @@ graph TB end FO -- "registerFleet(uuid)" --> FI - FO -- "registerSwarm / update / delete" --> L1 - FO -- "registerSwarm / update / delete" --> UNI + FO -- "registerSwarm / update / delete" --> REG PRV -- "registerProvider(url)" --> SP - PRV -- "acceptSwarm / rejectSwarm" --> L1 - PRV -- "acceptSwarm / rejectSwarm" --> UNI - ANY -- "checkMembership / purgeOrphanedSwarm" --> L1 - ANY -- "checkMembership / purgeOrphanedSwarm" --> UNI + PRV -- "acceptSwarm / rejectSwarm" --> REG + ANY -- "checkMembership / purgeOrphanedSwarm" --> REG - L1 -. "ownerOf(fleetId)" .-> FI - L1 -. "ownerOf(providerId)" .-> SP - UNI -. "ownerOf(fleetId)" .-> FI - UNI -. "ownerOf(providerId)" .-> SP + REG -. "ownerOf(fleetId)" .-> FI + REG -. "ownerOf(providerId)" .-> SP style FI fill:#4a9eff,color:#fff style SP fill:#4a9eff,color:#fff - style L1 fill:#ff9f43,color:#fff - style UNI fill:#ff9f43,color:#fff + style REG fill:#ff9f43,color:#fff style FO fill:#2ecc71,color:#fff style PRV fill:#2ecc71,color:#fff style ANY fill:#95a5a6,color:#fff From 6d95f618704de6dc32fc43d840c17820ce761638 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 11 Feb 2026 10:20:20 +1300 Subject: [PATCH 03/15] feat(swarms): require bond on fleet id mints --- .agent/rules/solidity_zksync.md | 2 +- src/swarms/FleetIdentity.sol | 161 ++++-- src/swarms/doc/assistant-guide.md | 24 +- src/swarms/doc/graph-architecture.md | 16 +- src/swarms/doc/sequence-lifecycle.md | 3 +- src/swarms/doc/sequence-registration.md | 7 +- test/FleetIdentity.t.sol | 681 ++++++++++++++++++++---- test/SwarmRegistryL1.t.sol | 22 +- test/SwarmRegistryUniversal.t.sol | 22 +- 9 files changed, 745 insertions(+), 193 deletions(-) diff --git a/.agent/rules/solidity_zksync.md b/.agent/rules/solidity_zksync.md index 7cdccfc5..642f1082 100644 --- a/.agent/rules/solidity_zksync.md +++ b/.agent/rules/solidity_zksync.md @@ -9,7 +9,7 @@ ## Modern Solidity Best Practices - **Safety First**: - **Checks-Effects-Interactions (CEI)** pattern must be strictly followed. - - Use `Ownable2Step` over `Ownable` for privileged access. + - 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. diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 9ab862d3..4f802873 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -2,91 +2,144 @@ 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 Permissionless ERC-721 representing ownership of a BLE fleet. + * @notice ERC-721 with ERC721Enumerable representing ownership of a BLE fleet, + * secured by an ERC-20 bond that is locked on mint and refunded on burn. * @dev TokenID = uint256(uint128(uuid)), guaranteeing one owner per Proximity UUID. + * Bond amounts are increase-only and refunded in full when the NFT is burned. */ -contract FleetIdentity is ERC721 { +contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { + using SafeERC20 for IERC20; + + // ────────────────────────────────────────────── + // Errors + // ────────────────────────────────────────────── error InvalidUUID(); - error InvalidPaginationParams(); error NotTokenOwner(); + error ZeroBondAmount(); + error BondBelowMinimum(); - // Array to enable enumeration of all registered fleets (for SDK scanning) - bytes16[] public registeredUUIDs; + // ────────────────────────────────────────────── + // State + // ────────────────────────────────────────────── - // Mapping to quickly check if a UUID is registered (redundant with ownerOf but cheaper for specific checks) - mapping(uint256 => bool) public activeFleets; + /// @notice The ERC-20 token used for bonds (immutable, e.g. NODL). + IERC20 public immutable BOND_TOKEN; - event FleetRegistered(address indexed owner, bytes16 indexed uuid, uint256 indexed tokenId); - event FleetBurned(address indexed owner, uint256 indexed tokenId); + /// @notice Minimum bond required to register a fleet (set once at deploy). + uint256 public immutable MIN_BOND; - constructor() ERC721("Swarm Fleet Identity", "SFID") {} + /// @notice TokenID -> cumulative bond deposited. + mapping(uint256 => uint256) public bonds; - /// @notice Mints a new fleet NFT for the given Proximity UUID. - /// @param uuid The 16-byte Proximity UUID. - /// @return tokenId The deterministic token ID derived from `uuid`. - function registerFleet(bytes16 uuid) external returns (uint256 tokenId) { - if (uuid == bytes16(0)) { - revert InvalidUUID(); - } + // ────────────────────────────────────────────── + // Events + // ────────────────────────────────────────────── + + event FleetRegistered(address indexed owner, bytes16 indexed uuid, uint256 indexed tokenId, uint256 bondAmount); + event BondIncreased(uint256 indexed tokenId, address indexed depositor, uint256 amount, uint256 newTotal); + event FleetBurned(address indexed owner, uint256 indexed tokenId, uint256 bondRefund); + + // ────────────────────────────────────────────── + // Constructor + // ────────────────────────────────────────────── + + /// @param _bondToken Address of the ERC-20 token used for bonds. + /// @param _minBond Minimum bond required to register a fleet. + constructor(address _bondToken, uint256 _minBond) ERC721("Swarm Fleet Identity", "SFID") { + BOND_TOKEN = IERC20(_bondToken); + MIN_BOND = _minBond; + } + + // ────────────────────────────────────────────── + // Core + // ────────────────────────────────────────────── + + /// @notice Mints a new fleet NFT for the given Proximity UUID and locks a bond. + /// @param uuid The 16-byte Proximity UUID. + /// @param bondAmount Amount of BOND_TOKEN to lock (must be >= minBond). + /// @return tokenId The deterministic token ID derived from `uuid`. + function registerFleet(bytes16 uuid, uint256 bondAmount) external nonReentrant returns (uint256 tokenId) { + if (uuid == bytes16(0)) revert InvalidUUID(); + if (bondAmount < MIN_BOND) revert BondBelowMinimum(); tokenId = uint256(uint128(uuid)); + // CEI: effects before external call + bonds[tokenId] = bondAmount; _mint(msg.sender, tokenId); - registeredUUIDs.push(uuid); - activeFleets[tokenId] = true; + // Interaction: pull bond from caller + BOND_TOKEN.safeTransferFrom(msg.sender, address(this), bondAmount); - emit FleetRegistered(msg.sender, uuid, tokenId); + emit FleetRegistered(msg.sender, uuid, tokenId, bondAmount); } - /// @notice Burns the fleet NFT. Caller must be the token owner. - /// @param tokenId The fleet token ID to burn. - function burn(uint256 tokenId) external { - if (ownerOf(tokenId) != msg.sender) { - revert NotTokenOwner(); - } + /// @notice Increases the bond for an existing fleet. Anyone can top-up. + /// @param tokenId The fleet token ID. + /// @param amount Additional BOND_TOKEN to lock. + function increaseBond(uint256 tokenId, uint256 amount) external nonReentrant { + if (amount == 0) revert ZeroBondAmount(); - activeFleets[tokenId] = false; + // ownerOf reverts for nonexistent tokens — acts as existence check + ownerOf(tokenId); - _burn(tokenId); + // CEI: effects before external call + bonds[tokenId] += amount; + + // Interaction + BOND_TOKEN.safeTransferFrom(msg.sender, address(this), amount); - emit FleetBurned(msg.sender, tokenId); + emit BondIncreased(tokenId, msg.sender, amount, bonds[tokenId]); } - /// @notice Returns a paginated slice of all registered UUIDs. - /// @param offset Starting index. - /// @param limit Maximum number of entries to return. - /// @return uuids The requested UUID slice. - function getRegisteredUUIDs(uint256 offset, uint256 limit) external view returns (bytes16[] memory uuids) { - if (limit == 0) { - revert InvalidPaginationParams(); - } + /// @notice Burns the fleet NFT and refunds the entire bond to the token owner. + /// @param tokenId The fleet token ID to burn. + function burn(uint256 tokenId) external nonReentrant { + address tokenOwner = ownerOf(tokenId); + if (tokenOwner != msg.sender) revert NotTokenOwner(); - if (offset >= registeredUUIDs.length) { - return new bytes16[](0); - } + // CEI: effects before external call + uint256 refund = bonds[tokenId]; + delete bonds[tokenId]; + _burn(tokenId); - uint256 end = offset + limit; - if (end > registeredUUIDs.length) { - end = registeredUUIDs.length; + // Interaction: refund bond + if (refund > 0) { + BOND_TOKEN.safeTransfer(tokenOwner, refund); } - uint256 resultLen = end - offset; - uuids = new bytes16[](resultLen); + emit FleetBurned(tokenOwner, tokenId, refund); + } - for (uint256 i = 0; i < resultLen;) { - uuids[i] = registeredUUIDs[offset + i]; - unchecked { - ++i; - } - } + // ────────────────────────────────────────────── + // View helpers + // ────────────────────────────────────────────── + + /// @notice Convenience: returns the UUID for a given token ID. + function tokenUUID(uint256 tokenId) external pure returns (bytes16) { + return bytes16(uint128(tokenId)); + } + + // ────────────────────────────────────────────── + // 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); } - /// @notice Returns the total number of registered fleets (including burned). - function getTotalFleets() external view returns (uint256) { - return registeredUUIDs.length; + function supportsInterface(bytes4 interfaceId) public view override(ERC721Enumerable) returns (bool) { + return super.supportsInterface(interfaceId); } } diff --git a/src/swarms/doc/assistant-guide.md b/src/swarms/doc/assistant-guide.md index bbae881c..7001d734 100644 --- a/src/swarms/doc/assistant-guide.md +++ b/src/swarms/doc/assistant-guide.md @@ -13,16 +13,16 @@ Two registry variants exist for different deployment targets: ### Core Components -| Contract | Role | Key Identity | Token | -| :--------------------------- | :------------------------- | :--------------------------------------- | :---- | -| **`FleetIdentity`** | Fleet Registry (ERC-721) | `uint256(uint128(uuid))` | SFID | -| **`ServiceProvider`** | Service Registry (ERC-721) | `keccak256(url)` | SSV | -| **`SwarmRegistryL1`** | Swarm Registry (L1) | `keccak256(fleetId, providerId, filter)` | — | -| **`SwarmRegistryUniversal`** | Swarm Registry (Universal) | `keccak256(fleetId, providerId, filter)` | — | +| 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 **fully permissionless** — access control is enforced through NFT ownership rather than admin roles. +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, which makes any swarms referencing that token _orphaned_. +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_. --- @@ -31,7 +31,10 @@ Both NFT contracts support **burning** — the token owner can call `burn(tokenI ### A. Provider & Fleet Setup (One-Time) 1. **Service Provider**: Calls `ServiceProvider.registerProvider("https://cms.example.com")`. Receives `providerTokenId` (= `keccak256(url)`). -2. **Fleet Owner**: Calls `FleetIdentity.registerFleet(0xUUID...)`. Receives `fleetId` (= `uint256(uint128(uuid))`). +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) @@ -145,7 +148,8 @@ A client (mobile phone or gateway) scans a BLE beacon and wants to find its owne ### Step 2: Identify Fleet - Scanner checks `FleetIdentity` contract. -- Calls `ownerOf(uint256(uint128(uuid)))` (or checks `activeFleets[tokenId]`). +- 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 diff --git a/src/swarms/doc/graph-architecture.md b/src/swarms/doc/graph-architecture.md index 3a3f1116..5cc67b8c 100644 --- a/src/swarms/doc/graph-architecture.md +++ b/src/swarms/doc/graph-architecture.md @@ -17,7 +17,7 @@ graph TB ANY(("Anyone
(Scanner / Purger)")) end - FO -- "registerFleet(uuid)" --> FI + FO -- "registerFleet(uuid, bondAmount)" --> FI FO -- "registerSwarm / update / delete" --> REG PRV -- "registerProvider(url)" --> SP PRV -- "acceptSwarm / rejectSwarm" --> REG @@ -39,12 +39,16 @@ graph TB ```mermaid classDiagram class FleetIdentity { - +bytes16[] registeredUUIDs - +mapping activeFleets - +registerFleet(uuid) tokenId + +IERC20 BOND_TOKEN (immutable) + +uint256 MIN_BOND (immutable) + +mapping bonds + +registerFleet(uuid, bondAmount) tokenId + +increaseBond(tokenId, amount) +burn(tokenId) - +getRegisteredUUIDs(offset, limit) - +getTotalFleets() + +tokenUUID(tokenId) bytes16 + +totalSupply() uint256 + +tokenByIndex(index) uint256 + +tokenOfOwnerByIndex(owner, index) uint256 } class ServiceProvider { diff --git a/src/swarms/doc/sequence-lifecycle.md b/src/swarms/doc/sequence-lifecycle.md index 12758ec6..cdcedfae 100644 --- a/src/swarms/doc/sequence-lifecycle.md +++ b/src/swarms/doc/sequence-lifecycle.md @@ -75,7 +75,8 @@ sequenceDiagram rect rgb(255, 240, 240) Note right of Owner: NFT owner burns their token Owner ->>+ NFT: burn(tokenId) - NFT -->>- Owner: ✓ token destroyed + 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 diff --git a/src/swarms/doc/sequence-registration.md b/src/swarms/doc/sequence-registration.md index 1058340c..37c44833 100644 --- a/src/swarms/doc/sequence-registration.md +++ b/src/swarms/doc/sequence-registration.md @@ -11,7 +11,12 @@ sequenceDiagram Note over FO, SP: One-time setup (independent, any order) - FO ->>+ FI: registerFleet(uuid) + 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) diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index b7122faa..c3da2c46 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -3,43 +3,112 @@ 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")); - event FleetRegistered(address indexed owner, bytes16 indexed uuid, uint256 indexed tokenId); - event FleetBurned(address indexed owner, uint256 indexed tokenId); + uint256 constant MIN_BOND = 100 ether; + uint256 constant BOND_AMOUNT = 200 ether; + + event FleetRegistered(address indexed owner, bytes16 indexed uuid, uint256 indexed tokenId, uint256 bondAmount); + event BondIncreased(uint256 indexed tokenId, address indexed depositor, uint256 amount, uint256 newTotal); + event FleetBurned(address indexed owner, uint256 indexed tokenId, uint256 bondRefund); function setUp() public { - fleet = new FleetIdentity(); + bondToken = new MockERC20(); + fleet = new FleetIdentity(address(bondToken), MIN_BOND); + + // Fund test accounts + bondToken.mint(alice, 10_000 ether); + bondToken.mint(bob, 10_000 ether); + bondToken.mint(carol, 10_000 ether); + + // Approve fleet contract + 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); + } + + // ═══════════════════════════════════════════════ + // Constructor + // ═══════════════════════════════════════════════ + + function test_constructor_setsImmutables() public view { + assertEq(address(fleet.BOND_TOKEN()), address(bondToken)); + assertEq(fleet.MIN_BOND(), MIN_BOND); + assertEq(fleet.name(), "Swarm Fleet Identity"); + assertEq(fleet.symbol(), "SFID"); + } + + function test_constructor_zeroMinBond() public { + FleetIdentity f = new FleetIdentity(address(bondToken), 0); + assertEq(f.MIN_BOND(), 0); } - // ============================== + // ═══════════════════════════════════════════════ // registerFleet - // ============================== + // ═══════════════════════════════════════════════ - function test_registerFleet_mintsAndStoresUUID() public { + function test_registerFleet_mintsAndLocksBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); assertEq(fleet.ownerOf(tokenId), alice); assertEq(tokenId, uint256(uint128(UUID_1))); - assertTrue(fleet.activeFleets(tokenId)); - assertEq(fleet.getTotalFleets(), 1); - assertEq(fleet.registeredUUIDs(0), UUID_1); + assertEq(fleet.bonds(tokenId), BOND_AMOUNT); + assertEq(bondToken.balanceOf(address(fleet)), BOND_AMOUNT); } function test_registerFleet_deterministicTokenId() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1); - + uint256 tokenId = fleet.registerFleet(UUID_1, MIN_BOND); assertEq(tokenId, uint256(uint128(UUID_1))); } @@ -47,174 +116,200 @@ contract FleetIdentityTest is Test { uint256 expectedTokenId = uint256(uint128(UUID_1)); vm.expectEmit(true, true, true, true); - emit FleetRegistered(alice, UUID_1, expectedTokenId); + emit FleetRegistered(alice, UUID_1, expectedTokenId, BOND_AMOUNT); + + vm.prank(alice); + fleet.registerFleet(UUID_1, BOND_AMOUNT); + } + function test_registerFleet_exactMinBond() public { vm.prank(alice); - fleet.registerFleet(UUID_1); + uint256 tokenId = fleet.registerFleet(UUID_1, MIN_BOND); + assertEq(fleet.bonds(tokenId), MIN_BOND); } function test_registerFleet_multipleFleetsDifferentOwners() public { vm.prank(alice); - fleet.registerFleet(UUID_1); + fleet.registerFleet(UUID_1, BOND_AMOUNT); vm.prank(bob); - fleet.registerFleet(UUID_2); + fleet.registerFleet(UUID_2, BOND_AMOUNT); - assertEq(fleet.getTotalFleets(), 2); assertEq(fleet.ownerOf(uint256(uint128(UUID_1))), alice); assertEq(fleet.ownerOf(uint256(uint128(UUID_2))), bob); + assertEq(bondToken.balanceOf(address(fleet)), BOND_AMOUNT * 2); + } + + function test_registerFleet_zeroMinBondAllowsZeroBond() public { + // Deploy with minBond = 0 + FleetIdentity f = new FleetIdentity(address(bondToken), 0); + vm.prank(alice); + bondToken.approve(address(f), type(uint256).max); + + vm.prank(alice); + uint256 tokenId = f.registerFleet(UUID_1, 0); + assertEq(f.bonds(tokenId), 0); } function test_RevertIf_registerFleet_zeroUUID() public { vm.prank(alice); vm.expectRevert(FleetIdentity.InvalidUUID.selector); - fleet.registerFleet(bytes16(0)); + fleet.registerFleet(bytes16(0), BOND_AMOUNT); } function test_RevertIf_registerFleet_duplicateUUID() public { vm.prank(alice); - fleet.registerFleet(UUID_1); + fleet.registerFleet(UUID_1, BOND_AMOUNT); vm.prank(bob); vm.expectRevert(); // ERC721: token already minted - fleet.registerFleet(UUID_1); + fleet.registerFleet(UUID_1, BOND_AMOUNT); } - // ============================== - // getRegisteredUUIDs (pagination) - // ============================== + function test_RevertIf_registerFleet_bondBelowMinimum() public { + vm.prank(alice); + vm.expectRevert(FleetIdentity.BondBelowMinimum.selector); + fleet.registerFleet(UUID_1, MIN_BOND - 1); + } - function test_getRegisteredUUIDs_returnsCorrectPage() public { - vm.startPrank(alice); - fleet.registerFleet(UUID_1); - fleet.registerFleet(UUID_2); - fleet.registerFleet(UUID_3); - vm.stopPrank(); + function test_RevertIf_registerFleet_insufficientBalance() public { + address broke = address(0xDEAD); + vm.prank(broke); + bondToken.approve(address(fleet), type(uint256).max); - bytes16[] memory page = fleet.getRegisteredUUIDs(0, 2); - assertEq(page.length, 2); - assertEq(page[0], UUID_1); - assertEq(page[1], UUID_2); + vm.prank(broke); + vm.expectRevert(); // SafeERC20: transferFrom fails + fleet.registerFleet(UUID_1, BOND_AMOUNT); } - function test_getRegisteredUUIDs_lastPage() public { - vm.startPrank(alice); - fleet.registerFleet(UUID_1); - fleet.registerFleet(UUID_2); - fleet.registerFleet(UUID_3); - vm.stopPrank(); + function test_RevertIf_registerFleet_noApproval() public { + address noApproval = address(0xBEEF); + bondToken.mint(noApproval, BOND_AMOUNT); - bytes16[] memory page = fleet.getRegisteredUUIDs(2, 10); - assertEq(page.length, 1); - assertEq(page[0], UUID_3); + vm.prank(noApproval); + vm.expectRevert(); // SafeERC20: transferFrom fails + fleet.registerFleet(UUID_1, BOND_AMOUNT); } - function test_getRegisteredUUIDs_offsetBeyondLength() public { + // ═══════════════════════════════════════════════ + // increaseBond + // ═══════════════════════════════════════════════ + + function test_increaseBond_addsToExisting() public { vm.prank(alice); - fleet.registerFleet(UUID_1); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); - bytes16[] memory page = fleet.getRegisteredUUIDs(100, 5); - assertEq(page.length, 0); - } + vm.prank(alice); + fleet.increaseBond(tokenId, 50 ether); - function test_RevertIf_getRegisteredUUIDs_zeroLimit() public { - vm.expectRevert(FleetIdentity.InvalidPaginationParams.selector); - fleet.getRegisteredUUIDs(0, 0); + assertEq(fleet.bonds(tokenId), BOND_AMOUNT + 50 ether); + assertEq(bondToken.balanceOf(address(fleet)), BOND_AMOUNT + 50 ether); } - // ============================== - // getTotalFleets - // ============================== + function test_increaseBond_anyoneCanTopUp() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + // Bob tops up Alice's fleet + vm.prank(bob); + fleet.increaseBond(tokenId, 100 ether); - function test_getTotalFleets_empty() public view { - assertEq(fleet.getTotalFleets(), 0); + assertEq(fleet.bonds(tokenId), BOND_AMOUNT + 100 ether); } - function test_getTotalFleets_incrementsOnRegister() public { - vm.startPrank(alice); - fleet.registerFleet(UUID_1); - assertEq(fleet.getTotalFleets(), 1); + function test_increaseBond_emitsEvent() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); - fleet.registerFleet(UUID_2); - assertEq(fleet.getTotalFleets(), 2); - vm.stopPrank(); + uint256 expectedTotal = BOND_AMOUNT + 50 ether; + vm.expectEmit(true, true, true, true); + emit BondIncreased(tokenId, bob, 50 ether, expectedTotal); + + vm.prank(bob); + fleet.increaseBond(tokenId, 50 ether); } - // ============================== - // activeFleets mapping - // ============================== + function test_increaseBond_multipleTimes() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, MIN_BOND); + + vm.prank(alice); + fleet.increaseBond(tokenId, 10 ether); + vm.prank(alice); + fleet.increaseBond(tokenId, 20 ether); + vm.prank(alice); + fleet.increaseBond(tokenId, 30 ether); - function test_activeFleets_falseByDefault() public view { - assertFalse(fleet.activeFleets(12345)); + assertEq(fleet.bonds(tokenId), MIN_BOND + 60 ether); } - function test_activeFleets_trueAfterRegister() public { + function test_RevertIf_increaseBond_zeroAmount() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); - assertTrue(fleet.activeFleets(tokenId)); + vm.prank(alice); + vm.expectRevert(FleetIdentity.ZeroBondAmount.selector); + fleet.increaseBond(tokenId, 0); } - // ============================== - // Fuzz Tests - // ============================== + function test_RevertIf_increaseBond_nonexistentToken() public { + vm.prank(alice); + vm.expectRevert(); // ownerOf reverts + fleet.increaseBond(99999, 100 ether); + } - function testFuzz_registerFleet_anyValidUUID(bytes16 uuid) public { - vm.assume(uuid != bytes16(0)); + function test_RevertIf_increaseBond_burnedToken() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); vm.prank(alice); - uint256 tokenId = fleet.registerFleet(uuid); + fleet.burn(tokenId); - assertEq(tokenId, uint256(uint128(uuid))); - assertEq(fleet.ownerOf(tokenId), alice); - assertTrue(fleet.activeFleets(tokenId)); + vm.prank(bob); + vm.expectRevert(); // ownerOf reverts for burned tokens + fleet.increaseBond(tokenId, 100 ether); } - function testFuzz_getRegisteredUUIDs_boundsHandling(uint256 offset, uint256 limit) public { - // Register 3 fleets - vm.startPrank(alice); - fleet.registerFleet(UUID_1); - fleet.registerFleet(UUID_2); - fleet.registerFleet(UUID_3); - vm.stopPrank(); + // ═══════════════════════════════════════════════ + // burn + // ═══════════════════════════════════════════════ - // limit=0 always reverts - if (limit == 0) { - vm.expectRevert(FleetIdentity.InvalidPaginationParams.selector); - fleet.getRegisteredUUIDs(offset, limit); - return; - } + function test_burn_refundsBondToOwner() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 balBefore = bondToken.balanceOf(alice); - bytes16[] memory result = fleet.getRegisteredUUIDs(offset, limit); + vm.prank(alice); + fleet.burn(tokenId); - if (offset >= 3) { - assertEq(result.length, 0); - } else { - uint256 expectedLen = offset + limit > 3 ? 3 - offset : limit; - assertEq(result.length, expectedLen); - } + assertEq(bondToken.balanceOf(alice), balBefore + BOND_AMOUNT); + assertEq(bondToken.balanceOf(address(fleet)), 0); + assertEq(fleet.bonds(tokenId), 0); } - // ============================== - // burn - // ============================== - - function test_burn_setsActiveFleetsFalse() public { + function test_burn_refundsIncreasedBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1); - assertTrue(fleet.activeFleets(tokenId)); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + vm.prank(bob); + fleet.increaseBond(tokenId, 300 ether); + + uint256 totalBond = BOND_AMOUNT + 300 ether; + uint256 balBefore = bondToken.balanceOf(alice); vm.prank(alice); fleet.burn(tokenId); - assertFalse(fleet.activeFleets(tokenId)); + + // Full bond goes to the token owner (alice), not the depositor (bob) + assertEq(bondToken.balanceOf(alice), balBefore + totalBond); } function test_burn_emitsEvent() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); vm.expectEmit(true, true, true, true); - emit FleetBurned(alice, tokenId); + emit FleetBurned(alice, tokenId, BOND_AMOUNT); vm.prank(alice); fleet.burn(tokenId); @@ -222,7 +317,7 @@ contract FleetIdentityTest is Test { function test_burn_ownerOfRevertsAfterBurn() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); vm.prank(alice); fleet.burn(tokenId); @@ -231,9 +326,43 @@ contract FleetIdentityTest is Test { fleet.ownerOf(tokenId); } + function test_burn_allowsReregistration() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + vm.prank(alice); + fleet.burn(tokenId); + + // Same UUID can now be re-registered by someone else + vm.prank(bob); + uint256 newTokenId = fleet.registerFleet(UUID_1, MIN_BOND); + + assertEq(newTokenId, tokenId); // Same deterministic ID + assertEq(fleet.ownerOf(newTokenId), bob); + assertEq(fleet.bonds(newTokenId), MIN_BOND); + } + + function test_burn_zeroBondNoTransfer() public { + // Deploy with minBond = 0 + FleetIdentity f = new FleetIdentity(address(bondToken), 0); + vm.prank(alice); + bondToken.approve(address(f), type(uint256).max); + + vm.prank(alice); + uint256 tokenId = f.registerFleet(UUID_1, 0); + + uint256 balBefore = bondToken.balanceOf(alice); + + vm.prank(alice); + f.burn(tokenId); + + // No transfer should occur + assertEq(bondToken.balanceOf(alice), balBefore); + } + function test_RevertIf_burn_notOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -246,17 +375,337 @@ contract FleetIdentityTest is Test { fleet.burn(12345); } - function testFuzz_burn_anyValidUUID(bytes16 uuid) public { - vm.assume(uuid != bytes16(0)); + // ═══════════════════════════════════════════════ + // ERC721Enumerable + // ═══════════════════════════════════════════════ + + function test_enumerable_totalSupply() public { + assertEq(fleet.totalSupply(), 0); + + vm.prank(alice); + fleet.registerFleet(UUID_1, BOND_AMOUNT); + assertEq(fleet.totalSupply(), 1); + vm.prank(bob); + fleet.registerFleet(UUID_2, BOND_AMOUNT); + assertEq(fleet.totalSupply(), 2); + } + + function test_enumerable_totalSupplyDecrementsOnBurn() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(uuid); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + vm.prank(bob); + fleet.registerFleet(UUID_2, BOND_AMOUNT); + assertEq(fleet.totalSupply(), 2); vm.prank(alice); fleet.burn(tokenId); + assertEq(fleet.totalSupply(), 1); + } + + function test_enumerable_tokenByIndex() public { + vm.prank(alice); + uint256 id1 = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + vm.prank(bob); + uint256 id2 = fleet.registerFleet(UUID_2, BOND_AMOUNT); + + // Order depends on mint order + assertEq(fleet.tokenByIndex(0), id1); + assertEq(fleet.tokenByIndex(1), id2); + } + + function test_enumerable_tokenByIndex_afterBurn() public { + vm.prank(alice); + uint256 id1 = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + vm.prank(bob); + uint256 id2 = fleet.registerFleet(UUID_2, BOND_AMOUNT); + + vm.prank(carol); + uint256 id3 = fleet.registerFleet(UUID_3, BOND_AMOUNT); + + // Burn the middle token + vm.prank(bob); + fleet.burn(id2); + + assertEq(fleet.totalSupply(), 2); + // After burn, the last token fills the gap + assertEq(fleet.tokenByIndex(0), id1); + assertEq(fleet.tokenByIndex(1), id3); + } + + function test_RevertIf_tokenByIndex_outOfBounds() public { + vm.prank(alice); + fleet.registerFleet(UUID_1, BOND_AMOUNT); - assertFalse(fleet.activeFleets(tokenId)); vm.expectRevert(); - fleet.ownerOf(tokenId); + fleet.tokenByIndex(1); + } + + function test_enumerable_tokenOfOwnerByIndex() public { + vm.startPrank(alice); + uint256 id1 = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 id2 = fleet.registerFleet(UUID_2, BOND_AMOUNT); + vm.stopPrank(); + + assertEq(fleet.balanceOf(alice), 2); + assertEq(fleet.tokenOfOwnerByIndex(alice, 0), id1); + assertEq(fleet.tokenOfOwnerByIndex(alice, 1), id2); + } + + function test_enumerable_tokenOfOwnerByIndex_afterTransfer() public { + vm.prank(alice); + uint256 id1 = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + // Transfer to bob + vm.prank(alice); + fleet.transferFrom(alice, bob, id1); + + assertEq(fleet.balanceOf(alice), 0); + assertEq(fleet.balanceOf(bob), 1); + assertEq(fleet.tokenOfOwnerByIndex(bob, 0), id1); + } + + function test_RevertIf_tokenOfOwnerByIndex_outOfBounds() public { + vm.prank(alice); + fleet.registerFleet(UUID_1, BOND_AMOUNT); + + vm.expectRevert(); + fleet.tokenOfOwnerByIndex(alice, 1); + } + + function test_enumerable_supportsInterface() public view { + // ERC721Enumerable interfaceId = 0x780e9d63 + assertTrue(fleet.supportsInterface(0x780e9d63)); + // ERC721 interfaceId = 0x80ac58cd + assertTrue(fleet.supportsInterface(0x80ac58cd)); + // ERC165 interfaceId = 0x01ffc9a7 + assertTrue(fleet.supportsInterface(0x01ffc9a7)); + } + + // ═══════════════════════════════════════════════ + // tokenUUID view helper + // ═══════════════════════════════════════════════ + + function test_tokenUUID_roundTrip() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + bytes16 recovered = fleet.tokenUUID(tokenId); + assertEq(recovered, UUID_1); + } + + function test_tokenUUID_pureFunction() public view { + // tokenUUID is pure, works on any tokenId even nonexistent + bytes16 uuid = fleet.tokenUUID(42); + assertEq(uuid, bytes16(uint128(42))); + } + + // ═══════════════════════════════════════════════ + // Bond accounting integrity + // ═══════════════════════════════════════════════ + + function test_bondAccounting_multipleFleets() public { + vm.prank(alice); + fleet.registerFleet(UUID_1, 100 ether); + + vm.prank(bob); + fleet.registerFleet(UUID_2, 200 ether); + + vm.prank(carol); + fleet.registerFleet(UUID_3, 300 ether); + + assertEq(bondToken.balanceOf(address(fleet)), 600 ether); + + // Burn one + vm.prank(bob); + fleet.burn(uint256(uint128(UUID_2))); + + assertEq(bondToken.balanceOf(address(fleet)), 400 ether); + } + + function test_bondAccounting_burnAllFleets() public { + vm.prank(alice); + uint256 id1 = fleet.registerFleet(UUID_1, 150 ether); + + vm.prank(bob); + uint256 id2 = fleet.registerFleet(UUID_2, 250 ether); + + vm.prank(alice); + fleet.burn(id1); + vm.prank(bob); + fleet.burn(id2); + + assertEq(bondToken.balanceOf(address(fleet)), 0); + assertEq(fleet.totalSupply(), 0); + } + + // ═══════════════════════════════════════════════ + // ERC-20 edge cases (bad token) + // ═══════════════════════════════════════════════ + + function test_RevertIf_bondToken_transferFromReturnsFalse() public { + BadERC20 badToken = new BadERC20(); + FleetIdentity f = new FleetIdentity(address(badToken), MIN_BOND); + + badToken.mint(alice, BOND_AMOUNT); + vm.prank(alice); + badToken.approve(address(f), type(uint256).max); + + // Token works normally first + badToken.setFail(true); + + vm.prank(alice); + vm.expectRevert(); // SafeERC20 reverts on false return + f.registerFleet(UUID_1, BOND_AMOUNT); + } + + // ═══════════════════════════════════════════════ + // Transfer preserves bond + // ═══════════════════════════════════════════════ + + function test_transfer_bondStaysWithToken() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + vm.prank(alice); + fleet.transferFrom(alice, bob, tokenId); + + // Bond is still the same + assertEq(fleet.bonds(tokenId), BOND_AMOUNT); + + // Bob can burn and get the refund + uint256 bobBefore = bondToken.balanceOf(bob); + vm.prank(bob); + fleet.burn(tokenId); + assertEq(bondToken.balanceOf(bob), bobBefore + BOND_AMOUNT); + } + + // ═══════════════════════════════════════════════ + // Fuzz Tests + // ═══════════════════════════════════════════════ + + function testFuzz_registerFleet_anyValidUUID(bytes16 uuid) public { + vm.assume(uuid != bytes16(0)); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(uuid, BOND_AMOUNT); + + assertEq(tokenId, uint256(uint128(uuid))); + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(fleet.bonds(tokenId), BOND_AMOUNT); + assertEq(fleet.totalSupply(), 1); + } + + function testFuzz_registerFleet_anyBondAboveMin(uint256 bondAmount) public { + bondAmount = bound(bondAmount, MIN_BOND, 5_000 ether); + + bondToken.mint(alice, bondAmount); // ensure sufficient balance + + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, bondAmount); + + assertEq(fleet.bonds(tokenId), bondAmount); + } + + function testFuzz_increaseBond_anyPositiveAmount(uint256 amount) public { + amount = bound(amount, 1, 1_000_000 ether); + + bondToken.mint(bob, amount); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + vm.prank(bob); + fleet.increaseBond(tokenId, amount); + + assertEq(fleet.bonds(tokenId), BOND_AMOUNT + amount); + } + + function testFuzz_burn_refundsExactBond(uint256 bondAmount, uint256 increaseAmount) public { + bondAmount = bound(bondAmount, MIN_BOND, 5_000 ether); + increaseAmount = bound(increaseAmount, 0, 5_000 ether); + + bondToken.mint(alice, bondAmount + increaseAmount); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, bondAmount); + + if (increaseAmount > 0) { + vm.prank(alice); + fleet.increaseBond(tokenId, increaseAmount); + } + + uint256 expectedRefund = bondAmount + increaseAmount; + uint256 balBefore = bondToken.balanceOf(alice); + + vm.prank(alice); + fleet.burn(tokenId); + + assertEq(bondToken.balanceOf(alice), balBefore + expectedRefund); + assertEq(fleet.bonds(tokenId), 0); + } + + function testFuzz_burn_onlyOwner(address caller) public { + vm.assume(caller != alice); + vm.assume(caller != address(0)); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + + vm.prank(caller); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.burn(tokenId); + } + + function testFuzz_enumerable_totalSupplyMatchesMintBurnDelta(uint8 mintCount, uint8 burnCount) public { + mintCount = uint8(bound(mintCount, 1, 20)); + burnCount = uint8(bound(burnCount, 0, mintCount)); + + uint256[] memory tokenIds = new uint256[](mintCount); + + for (uint8 i = 0; i < mintCount; i++) { + bytes16 uuid = bytes16(keccak256(abi.encodePacked("fuzz-fleet-", i))); + bondToken.mint(alice, BOND_AMOUNT); + vm.prank(alice); + tokenIds[i] = fleet.registerFleet(uuid, BOND_AMOUNT); + } + + assertEq(fleet.totalSupply(), mintCount); + + for (uint8 i = 0; i < burnCount; i++) { + vm.prank(alice); + fleet.burn(tokenIds[i]); + } + + assertEq(fleet.totalSupply(), uint256(mintCount) - uint256(burnCount)); + } + + // ═══════════════════════════════════════════════ + // Invariant: contract token balance == sum of all bonds + // ═══════════════════════════════════════════════ + + function test_invariant_contractBalanceEqualsSumOfBonds() public { + vm.prank(alice); + uint256 id1 = fleet.registerFleet(UUID_1, 150 ether); + + vm.prank(bob); + uint256 id2 = fleet.registerFleet(UUID_2, 250 ether); + + vm.prank(carol); + fleet.increaseBond(id1, 50 ether); + + uint256 expectedSum = 150 ether + 250 ether + 50 ether; + assertEq(bondToken.balanceOf(address(fleet)), expectedSum); + assertEq(fleet.bonds(id1) + fleet.bonds(id2), expectedSum); + + // Burn one and verify + vm.prank(alice); + fleet.burn(id1); + + assertEq(bondToken.balanceOf(address(fleet)), 250 ether); + assertEq(fleet.bonds(id2), 250 ether); } } diff --git a/test/SwarmRegistryL1.t.sol b/test/SwarmRegistryL1.t.sol index 816186b9..fb18e47e 100644 --- a/test/SwarmRegistryL1.t.sol +++ b/test/SwarmRegistryL1.t.sol @@ -5,16 +5,28 @@ 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); @@ -23,9 +35,15 @@ contract SwarmRegistryL1Test is Test { event SwarmPurged(uint256 indexed swarmId, uint256 indexed fleetId, address indexed purgedBy); function setUp() public { - fleetContract = new FleetIdentity(); + 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); } // ============================== @@ -34,7 +52,7 @@ contract SwarmRegistryL1Test is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleet(bytes16(keccak256(seed))); + return fleetContract.registerFleet(bytes16(keccak256(seed)), FLEET_BOND); } function _registerProvider(address owner, string memory url) internal returns (uint256) { diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol index 3829348b..7e4c2cbb 100644 --- a/test/SwarmRegistryUniversal.t.sol +++ b/test/SwarmRegistryUniversal.t.sol @@ -5,16 +5,28 @@ 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 ); @@ -25,9 +37,15 @@ contract SwarmRegistryUniversalTest is Test { event SwarmPurged(uint256 indexed swarmId, uint256 indexed fleetId, address indexed purgedBy); function setUp() public { - fleetContract = new FleetIdentity(); + 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); } // ============================== @@ -36,7 +54,7 @@ contract SwarmRegistryUniversalTest is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleet(bytes16(keccak256(seed))); + return fleetContract.registerFleet(bytes16(keccak256(seed)), FLEET_BOND); } function _registerProvider(address owner, string memory url) internal returns (uint256) { From 2b0d4de9ac74a167be2ef3e2a4d4401963d9f864 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 13 Feb 2026 11:51:47 +1300 Subject: [PATCH 04/15] feat(swarms): add regional registery feature --- src/swarms/FleetIdentity.sol | 613 +++++++++++++-- test/FleetIdentity.t.sol | 1152 ++++++++++++++++++++--------- test/SwarmRegistryL1.t.sol | 4 +- test/SwarmRegistryUniversal.t.sol | 4 +- 4 files changed, 1378 insertions(+), 395 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 4f802873..8223d4b9 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -10,9 +10,31 @@ 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 that is locked on mint and refunded on burn. - * @dev TokenID = uint256(uint128(uuid)), guaranteeing one owner per Proximity UUID. - * Bond amounts are increase-only and refunded in full when the NFT is burned. + * secured by an ERC-20 bond organized into geometric shards. + * + * @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 shard namespace** — shard indices + * start at 0 for every region. The first fleet in any region always pays + * BASE_BOND regardless of how many shards exist in other regions. + * + * Shards hold up to SHARD_CAPACITY (20) members each. Shard K within a + * region requires bond = BASE_BOND * BOND_MULTIPLIER^K. + * + * Scanner 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 scanner enumeration without off-chain indexers. + * + * TokenID = uint256(uint128(uuid)), guaranteeing one owner per Proximity UUID. */ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { using SafeERC20 for IERC20; @@ -22,111 +44,592 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // ────────────────────────────────────────────── error InvalidUUID(); error NotTokenOwner(); - error ZeroBondAmount(); - error BondBelowMinimum(); + error MaxShardsReached(); + error ShardFull(); + error InsufficientBondForPromotion(); + error TargetShardNotHigher(); + error TargetShardNotLower(); + error InvalidCountryCode(); + error InvalidAdminCode(); // ────────────────────────────────────────────── - // State + // Constants & Immutables // ────────────────────────────────────────────── + /// @notice Maximum members per shard (matches iOS CLBeaconRegion limit). + uint256 public constant SHARD_CAPACITY = 20; + + /// @notice Hard cap on shard count per region to bound gas costs. + uint256 public constant MAX_SHARDS = 50; + + /// @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 Minimum bond required to register a fleet (set once at deploy). - uint256 public immutable MIN_BOND; + /// @notice Base bond for shard 0 in any region. Shard K requires BASE_BOND * BOND_MULTIPLIER^K. + uint256 public immutable BASE_BOND; + + /// @notice Geometric multiplier between shard tiers. + uint256 public immutable BOND_MULTIPLIER; + + // ────────────────────────────────────────────── + // Region-namespaced shard data + // ────────────────────────────────────────────── + + /// @notice regionKey -> number of shards opened in that region. + mapping(uint32 => uint256) public regionShardCount; + + /// @dev regionKey -> cached lower-bound hint for lowest open shard. + mapping(uint32 => uint256) internal _regionLowestHint; - /// @notice TokenID -> cumulative bond deposited. - mapping(uint256 => uint256) public bonds; + /// @notice regionKey -> shardIndex -> list of token IDs. + mapping(uint32 => mapping(uint256 => uint256[])) internal _regionShardMembers; + + /// @notice Token ID -> index within its shard's member array (for O(1) removal). + mapping(uint256 => uint256) internal _indexInShard; + + // ────────────────────────────────────────────── + // Fleet data + // ────────────────────────────────────────────── + + /// @notice Token ID -> region key the fleet is registered in. + mapping(uint256 => uint32) public fleetRegion; + + /// @notice Token ID -> shard index (within its region) the fleet belongs to. + mapping(uint256 => uint256) public fleetShard; + + // ────────────────────────────────────────────── + // 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, uint256 bondAmount); - event BondIncreased(uint256 indexed tokenId, address indexed depositor, uint256 amount, uint256 newTotal); - event FleetBurned(address indexed owner, uint256 indexed tokenId, uint256 bondRefund); + event FleetRegistered( + address indexed owner, + bytes16 indexed uuid, + uint256 indexed tokenId, + uint32 regionKey, + uint256 shardIndex, + uint256 bondAmount + ); + event FleetPromoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 additionalBond); + event FleetDemoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 bondRefund); + event FleetBurned(address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 shardIndex); // ────────────────────────────────────────────── // Constructor // ────────────────────────────────────────────── - /// @param _bondToken Address of the ERC-20 token used for bonds. - /// @param _minBond Minimum bond required to register a fleet. - constructor(address _bondToken, uint256 _minBond) ERC721("Swarm Fleet Identity", "SFID") { + /// @param _bondToken Address of the ERC-20 token used for bonds. + /// @param _baseBond Base bond for shard 0 in any region. + /// @param _bondMultiplier Multiplier between tiers (e.g. 2 = doubling). + constructor(address _bondToken, uint256 _baseBond, uint256 _bondMultiplier) + ERC721("Swarm Fleet Identity", "SFID") + { BOND_TOKEN = IERC20(_bondToken); - MIN_BOND = _minBond; + BASE_BOND = _baseBond; + BOND_MULTIPLIER = _bondMultiplier; } - // ────────────────────────────────────────────── - // Core - // ────────────────────────────────────────────── + // ══════════════════════════════════════════════ + // Registration: Global + // ══════════════════════════════════════════════ - /// @notice Mints a new fleet NFT for the given Proximity UUID and locks a bond. - /// @param uuid The 16-byte Proximity UUID. - /// @param bondAmount Amount of BOND_TOKEN to lock (must be >= minBond). - /// @return tokenId The deterministic token ID derived from `uuid`. - function registerFleet(bytes16 uuid, uint256 bondAmount) external nonReentrant returns (uint256 tokenId) { + /// @notice Register a fleet globally (auto-assign shard). + function registerFleetGlobal(bytes16 uuid) external nonReentrant returns (uint256 tokenId) { if (uuid == bytes16(0)) revert InvalidUUID(); - if (bondAmount < MIN_BOND) revert BondBelowMinimum(); + uint256 shard = _openShard(GLOBAL_REGION); + tokenId = _register(uuid, GLOBAL_REGION, shard); + } - tokenId = uint256(uint128(uuid)); + /// @notice Register a fleet globally into a specific shard. + function registerFleetGlobal(bytes16 uuid, uint256 targetShard) external nonReentrant returns (uint256 tokenId) { + if (uuid == bytes16(0)) revert InvalidUUID(); + _validateExplicitShard(GLOBAL_REGION, targetShard); + tokenId = _register(uuid, GLOBAL_REGION, targetShard); + } - // CEI: effects before external call - bonds[tokenId] = bondAmount; - _mint(msg.sender, tokenId); + // ══════════════════════════════════════════════ + // Registration: Country + // ══════════════════════════════════════════════ - // Interaction: pull bond from caller - BOND_TOKEN.safeTransferFrom(msg.sender, address(this), bondAmount); + /// @notice Register a fleet under a country (auto-assign shard). + /// @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 shard = _openShard(regionKey); + tokenId = _register(uuid, regionKey, shard); + } - emit FleetRegistered(msg.sender, uuid, tokenId, bondAmount); + /// @notice Register a fleet under a country into a specific shard. + function registerFleetCountry(bytes16 uuid, uint16 countryCode, uint256 targetShard) + external + nonReentrant + returns (uint256 tokenId) + { + if (uuid == bytes16(0)) revert InvalidUUID(); + if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); + uint32 regionKey = uint32(countryCode); + _validateExplicitShard(regionKey, targetShard); + tokenId = _register(uuid, regionKey, targetShard); } - /// @notice Increases the bond for an existing fleet. Anyone can top-up. - /// @param tokenId The fleet token ID. - /// @param amount Additional BOND_TOKEN to lock. - function increaseBond(uint256 tokenId, uint256 amount) external nonReentrant { - if (amount == 0) revert ZeroBondAmount(); + // ══════════════════════════════════════════════ + // Registration: Admin Area (local) + // ══════════════════════════════════════════════ + + /// @notice Register a fleet under a country + admin area (auto-assign shard). + /// @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 shard = _openShard(regionKey); + tokenId = _register(uuid, regionKey, shard); + } - // ownerOf reverts for nonexistent tokens — acts as existence check - ownerOf(tokenId); + /// @notice Register a fleet under a country + admin area into a specific shard. + function registerFleetLocal(bytes16 uuid, uint16 countryCode, uint16 adminCode, uint256 targetShard) + 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); + _validateExplicitShard(regionKey, targetShard); + tokenId = _register(uuid, regionKey, targetShard); + } - // CEI: effects before external call - bonds[tokenId] += amount; + // ══════════════════════════════════════════════ + // Promote / Demote (region-aware) + // ══════════════════════════════════════════════ + + /// @notice Promotes a fleet to the next shard within its region. + function promote(uint256 tokenId) external nonReentrant { + _promote(tokenId, fleetShard[tokenId] + 1); + } + + /// @notice Promotes a fleet to a specific higher shard within its region. + function promote(uint256 tokenId, uint256 targetShard) external nonReentrant { + _promote(tokenId, targetShard); + } + + /// @notice Demotes a fleet to a lower shard within its region. Refunds bond difference. + function demote(uint256 tokenId, uint256 targetShard) external nonReentrant { + address tokenOwner = ownerOf(tokenId); + if (tokenOwner != msg.sender) revert NotTokenOwner(); + + uint32 region = fleetRegion[tokenId]; + uint256 currentShard = fleetShard[tokenId]; + if (targetShard >= currentShard) revert TargetShardNotLower(); + if (_regionShardMembers[region][targetShard].length >= SHARD_CAPACITY) revert ShardFull(); + + uint256 currentBond = shardBond(currentShard); + uint256 targetBond = shardBond(targetShard); + uint256 refund = currentBond - targetBond; + + // Effects + _removeFromShard(tokenId, region, currentShard); + fleetShard[tokenId] = targetShard; + _regionShardMembers[region][targetShard].push(tokenId); + _indexInShard[tokenId] = _regionShardMembers[region][targetShard].length - 1; + + _trimShardCount(region); // Interaction - BOND_TOKEN.safeTransferFrom(msg.sender, address(this), amount); + if (refund > 0) { + BOND_TOKEN.safeTransfer(tokenOwner, refund); + } - emit BondIncreased(tokenId, msg.sender, amount, bonds[tokenId]); + emit FleetDemoted(tokenId, currentShard, targetShard, refund); } - /// @notice Burns the fleet NFT and refunds the entire bond to the token owner. - /// @param tokenId The fleet token ID to burn. + // ══════════════════════════════════════════════ + // Burn + // ══════════════════════════════════════════════ + + /// @notice Burns the fleet NFT and refunds the shard bond to the token owner. function burn(uint256 tokenId) external nonReentrant { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); - // CEI: effects before external call - uint256 refund = bonds[tokenId]; - delete bonds[tokenId]; + uint32 region = fleetRegion[tokenId]; + uint256 shard = fleetShard[tokenId]; + uint256 refund = shardBond(shard); + + // Effects + _removeFromShard(tokenId, region, shard); + delete fleetShard[tokenId]; + delete fleetRegion[tokenId]; + delete _indexInShard[tokenId]; _burn(tokenId); - // Interaction: refund bond + _trimShardCount(region); + _removeFromRegionIndex(region); + + // Interaction if (refund > 0) { BOND_TOKEN.safeTransfer(tokenOwner, refund); } - emit FleetBurned(tokenOwner, tokenId, refund); + emit FleetBurned(tokenOwner, tokenId, refund, region, shard); } - // ────────────────────────────────────────────── - // View helpers - // ────────────────────────────────────────────── + // ══════════════════════════════════════════════ + // Views: Bond & shard helpers + // ══════════════════════════════════════════════ + + /// @notice Bond required for shard K in any region = BASE_BOND * BOND_MULTIPLIER^K. + function shardBond(uint256 shard) public view returns (uint256) { + if (shard == 0) return BASE_BOND; + uint256 bond = BASE_BOND; + for (uint256 i = 0; i < shard; i++) { + bond *= BOND_MULTIPLIER; + } + return bond; + } + + /// @notice Returns the lowest open shard and its bond for a region. + function lowestOpenShard(uint32 regionKey) external view returns (uint256 shard, uint256 bond) { + shard = _findOpenShardView(regionKey); + bond = shardBond(shard); + } + + /// @notice Highest non-empty shard in a region, or 0 if none. + function highestActiveShard(uint32 regionKey) external view returns (uint256) { + uint256 sc = regionShardCount[regionKey]; + if (sc == 0) return 0; + return sc - 1; + } + + /// @notice Number of members in a specific shard of a region. + function shardMemberCount(uint32 regionKey, uint256 shard) external view returns (uint256) { + return _regionShardMembers[regionKey][shard].length; + } + + /// @notice All token IDs in a specific shard of a region. + function getShardMembers(uint32 regionKey, uint256 shard) external view returns (uint256[] memory) { + return _regionShardMembers[regionKey][shard]; + } + + /// @notice All UUIDs in a specific shard of a region. + function getShardUUIDs(uint32 regionKey, uint256 shard) external view returns (bytes16[] memory uuids) { + uint256[] storage members = _regionShardMembers[regionKey][shard]; + uuids = new bytes16[](members.length); + for (uint256 i = 0; i < members.length; i++) { + uuids[i] = bytes16(uint128(members[i])); + } + } - /// @notice Convenience: returns the UUID for a given token ID. + /// @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 shardBond(fleetShard[tokenId]); + } + + // ══════════════════════════════════════════════ + // Views: Scanner discovery + // ══════════════════════════════════════════════ + + /// @notice Returns the best shard for a scanner at a specific location. + /// Fallback order: admin area -> country -> global. + /// @return regionKey The region where fleets were found (0 = global). + /// @return shard The highest non-empty shard in that region. + /// @return members The token IDs in that shard. + function discoverBestShard(uint16 countryCode, uint16 adminCode) + external + view + returns (uint32 regionKey, uint256 shard, uint256[] memory members) + { + // 1. Try admin area + if (countryCode > 0 && adminCode > 0) { + regionKey = (uint32(countryCode) << 12) | uint32(adminCode); + uint256 sc = regionShardCount[regionKey]; + if (sc > 0) { + shard = sc - 1; + members = _regionShardMembers[regionKey][shard]; + return (regionKey, shard, members); + } + } + // 2. Try country + if (countryCode > 0) { + regionKey = uint32(countryCode); + uint256 sc = regionShardCount[regionKey]; + if (sc > 0) { + shard = sc - 1; + members = _regionShardMembers[regionKey][shard]; + return (regionKey, shard, members); + } + } + // 3. Global + regionKey = GLOBAL_REGION; + uint256 sc = regionShardCount[GLOBAL_REGION]; + if (sc > 0) { + shard = sc - 1; + members = _regionShardMembers[GLOBAL_REGION][shard]; + } + // else: all empty, returns (0, 0, []) + } + + /// @notice Returns active shard data at all three levels for a location. + function discoverAllLevels(uint16 countryCode, uint16 adminCode) + external + view + returns ( + uint256 globalShardCount, + uint256 countryShardCount, + uint256 adminShardCount, + uint32 adminRegionKey + ) + { + globalShardCount = regionShardCount[GLOBAL_REGION]; + if (countryCode > 0) { + countryShardCount = regionShardCount[uint32(countryCode)]; + } + if (countryCode > 0 && adminCode > 0) { + adminRegionKey = (uint32(countryCode) << 12) | uint32(adminCode); + adminShardCount = regionShardCount[adminRegionKey]; + } + } + + // ══════════════════════════════════════════════ + // 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 shard) internal returns (uint256 tokenId) { + uint256 bond = shardBond(shard); + tokenId = uint256(uint128(uuid)); + + // Effects + fleetRegion[tokenId] = region; + fleetShard[tokenId] = shard; + _regionShardMembers[region][shard].push(tokenId); + _indexInShard[tokenId] = _regionShardMembers[region][shard].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, shard, bond); + } + + /// @dev Shared promotion logic. + function _promote(uint256 tokenId, uint256 targetShard) internal { + address tokenOwner = ownerOf(tokenId); + if (tokenOwner != msg.sender) revert NotTokenOwner(); + + uint32 region = fleetRegion[tokenId]; + uint256 currentShard = fleetShard[tokenId]; + if (targetShard <= currentShard) revert TargetShardNotHigher(); + if (targetShard >= MAX_SHARDS) revert MaxShardsReached(); + if (_regionShardMembers[region][targetShard].length >= SHARD_CAPACITY) revert ShardFull(); + + uint256 currentBond = shardBond(currentShard); + uint256 targetBond = shardBond(targetShard); + uint256 additionalBond = targetBond - currentBond; + + // Effects + _removeFromShard(tokenId, region, currentShard); + fleetShard[tokenId] = targetShard; + _regionShardMembers[region][targetShard].push(tokenId); + _indexInShard[tokenId] = _regionShardMembers[region][targetShard].length - 1; + + if (targetShard >= regionShardCount[region]) { + regionShardCount[region] = targetShard + 1; + } + + // Interaction + if (additionalBond > 0) { + BOND_TOKEN.safeTransferFrom(tokenOwner, address(this), additionalBond); + } + + emit FleetPromoted(tokenId, currentShard, targetShard, additionalBond); + } + + /// @dev Validates and prepares an explicit shard for registration. + function _validateExplicitShard(uint32 region, uint256 targetShard) internal { + if (targetShard >= MAX_SHARDS) revert MaxShardsReached(); + if (_regionShardMembers[region][targetShard].length >= SHARD_CAPACITY) revert ShardFull(); + if (targetShard >= regionShardCount[region]) { + regionShardCount[region] = targetShard + 1; + } + } + + /// @dev Finds lowest open shard within a region, opening a new one if needed. + function _openShard(uint32 region) internal returns (uint256) { + uint256 sc = regionShardCount[region]; + uint256 start = _regionLowestHint[region]; + for (uint256 i = start; i < sc; i++) { + if (_regionShardMembers[region][i].length < SHARD_CAPACITY) { + _regionLowestHint[region] = i; + return i; + } + } + if (sc >= MAX_SHARDS) revert MaxShardsReached(); + regionShardCount[region] = sc + 1; + _regionLowestHint[region] = sc; + return sc; + } + + /// @dev View-only version of _openShard. + function _findOpenShardView(uint32 region) internal view returns (uint256) { + uint256 sc = regionShardCount[region]; + uint256 start = _regionLowestHint[region]; + for (uint256 i = start; i < sc; i++) { + if (_regionShardMembers[region][i].length < SHARD_CAPACITY) return i; + } + if (sc >= MAX_SHARDS) revert MaxShardsReached(); + return sc; + } + + /// @dev Swap-and-pop removal from a region's shard member array. + function _removeFromShard(uint256 tokenId, uint32 region, uint256 shard) internal { + uint256[] storage members = _regionShardMembers[region][shard]; + uint256 idx = _indexInShard[tokenId]; + uint256 lastIdx = members.length - 1; + + if (idx != lastIdx) { + uint256 lastTokenId = members[lastIdx]; + members[idx] = lastTokenId; + _indexInShard[lastTokenId] = idx; + } + members.pop(); + + if (shard < _regionLowestHint[region]) { + _regionLowestHint[region] = shard; + } + } + + /// @dev Shrinks regionShardCount so the top shard is always non-empty. + function _trimShardCount(uint32 region) internal { + uint256 sc = regionShardCount[region]; + while (sc > 0 && _regionShardMembers[region][sc - 1].length == 0) { + sc--; + } + regionShardCount[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 (regionShardCount[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 // ────────────────────────────────────────────── diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index c3da2c46..d9e63f16 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -51,23 +51,34 @@ contract FleetIdentityTest is Test { bytes16 constant UUID_2 = bytes16(keccak256("fleet-bravo")); bytes16 constant UUID_3 = bytes16(keccak256("fleet-charlie")); - uint256 constant MIN_BOND = 100 ether; - uint256 constant BOND_AMOUNT = 200 ether; - - event FleetRegistered(address indexed owner, bytes16 indexed uuid, uint256 indexed tokenId, uint256 bondAmount); - event BondIncreased(uint256 indexed tokenId, address indexed depositor, uint256 amount, uint256 newTotal); - event FleetBurned(address indexed owner, uint256 indexed tokenId, uint256 bondRefund); + uint256 constant BASE_BOND = 100 ether; + uint256 constant MULTIPLIER = 2; + + 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 shardIndex, + uint256 bondAmount + ); + event FleetPromoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 additionalBond); + event FleetDemoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 bondRefund); + event FleetBurned(address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 shardIndex); function setUp() public { bondToken = new MockERC20(); - fleet = new FleetIdentity(address(bondToken), MIN_BOND); + fleet = new FleetIdentity(address(bondToken), BASE_BOND, MULTIPLIER); - // Fund test accounts - bondToken.mint(alice, 10_000 ether); - bondToken.mint(bob, 10_000 ether); - bondToken.mint(carol, 10_000 ether); + bondToken.mint(alice, 100_000_000 ether); + bondToken.mint(bob, 100_000_000 ether); + bondToken.mint(carol, 100_000_000 ether); - // Approve fleet contract vm.prank(alice); bondToken.approve(address(fleet), type(uint256).max); vm.prank(bob); @@ -76,576 +87,990 @@ contract FleetIdentityTest is Test { bondToken.approve(address(fleet), type(uint256).max); } - // ═══════════════════════════════════════════════ - // Constructor - // ═══════════════════════════════════════════════ + // --- 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.MIN_BOND(), MIN_BOND); + assertEq(fleet.BASE_BOND(), BASE_BOND); + assertEq(fleet.BOND_MULTIPLIER(), MULTIPLIER); assertEq(fleet.name(), "Swarm Fleet Identity"); assertEq(fleet.symbol(), "SFID"); + assertEq(fleet.GLOBAL_REGION(), 0); } - function test_constructor_zeroMinBond() public { - FleetIdentity f = new FleetIdentity(address(bondToken), 0); - assertEq(f.MIN_BOND(), 0); + function test_constructor_constants() public view { + assertEq(fleet.SHARD_CAPACITY(), 20); + assertEq(fleet.MAX_SHARDS(), 50); } - // ═══════════════════════════════════════════════ - // registerFleet - // ═══════════════════════════════════════════════ + // --- shardBond --- - function test_registerFleet_mintsAndLocksBond() public { - vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + function test_shardBond_shard0() public view { + assertEq(fleet.shardBond(0), BASE_BOND); + } - assertEq(fleet.ownerOf(tokenId), alice); - assertEq(tokenId, uint256(uint128(UUID_1))); - assertEq(fleet.bonds(tokenId), BOND_AMOUNT); - assertEq(bondToken.balanceOf(address(fleet)), BOND_AMOUNT); + function test_shardBond_shard1() public view { + assertEq(fleet.shardBond(1), BASE_BOND * MULTIPLIER); + } + + function test_shardBond_shard2() public view { + assertEq(fleet.shardBond(2), BASE_BOND * MULTIPLIER * MULTIPLIER); } - function test_registerFleet_deterministicTokenId() public { + function test_shardBond_geometricProgression() public view { + for (uint256 i = 1; i <= 5; i++) { + assertEq(fleet.shardBond(i), fleet.shardBond(i - 1) * MULTIPLIER); + } + } + + // --- registerFleetGlobal auto --- + + function test_registerFleetGlobal_auto_mintsAndLocksBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, MIN_BOND); + 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.fleetShard(tokenId), 0); + assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND); } - function test_registerFleet_emitsEvent() public { + function test_registerFleetGlobal_auto_emitsEvent() public { uint256 expectedTokenId = uint256(uint128(UUID_1)); vm.expectEmit(true, true, true, true); - emit FleetRegistered(alice, UUID_1, expectedTokenId, BOND_AMOUNT); + emit FleetRegistered(alice, UUID_1, expectedTokenId, GLOBAL, 0, BASE_BOND); vm.prank(alice); - fleet.registerFleet(UUID_1, BOND_AMOUNT); + fleet.registerFleetGlobal(UUID_1); } - function test_registerFleet_exactMinBond() public { + function test_RevertIf_registerFleetGlobal_auto_zeroUUID() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, MIN_BOND); - assertEq(fleet.bonds(tokenId), MIN_BOND); + vm.expectRevert(FleetIdentity.InvalidUUID.selector); + fleet.registerFleetGlobal(bytes16(0)); } - function test_registerFleet_multipleFleetsDifferentOwners() public { + function test_RevertIf_registerFleetGlobal_auto_duplicateUUID() public { vm.prank(alice); - fleet.registerFleet(UUID_1, BOND_AMOUNT); + fleet.registerFleetGlobal(UUID_1); vm.prank(bob); - fleet.registerFleet(UUID_2, BOND_AMOUNT); - - assertEq(fleet.ownerOf(uint256(uint128(UUID_1))), alice); - assertEq(fleet.ownerOf(uint256(uint128(UUID_2))), bob); - assertEq(bondToken.balanceOf(address(fleet)), BOND_AMOUNT * 2); + vm.expectRevert(); + fleet.registerFleetGlobal(UUID_1); } - function test_registerFleet_zeroMinBondAllowsZeroBond() public { - // Deploy with minBond = 0 - FleetIdentity f = new FleetIdentity(address(bondToken), 0); + // --- registerFleetGlobal explicit shard --- + + function test_registerFleetGlobal_explicit_joinsSpecifiedShard() public { vm.prank(alice); - bondToken.approve(address(f), type(uint256).max); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); + assertEq(fleet.fleetShard(tokenId), 2); + assertEq(fleet.fleetRegion(tokenId), GLOBAL); + assertEq(fleet.bonds(tokenId), fleet.shardBond(2)); + assertEq(fleet.shardMemberCount(GLOBAL, 2), 1); + assertEq(fleet.regionShardCount(GLOBAL), 3); + } + + function test_RevertIf_registerFleetGlobal_explicit_exceedsMaxShards() public { vm.prank(alice); - uint256 tokenId = f.registerFleet(UUID_1, 0); - assertEq(f.bonds(tokenId), 0); + vm.expectRevert(FleetIdentity.MaxShardsReached.selector); + fleet.registerFleetGlobal(UUID_1, 50); } - function test_RevertIf_registerFleet_zeroUUID() public { + // --- registerFleetCountry --- + + function test_registerFleetCountry_auto_setsRegionAndShard() public { vm.prank(alice); - vm.expectRevert(FleetIdentity.InvalidUUID.selector); - fleet.registerFleet(bytes16(0), BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); + + assertEq(fleet.fleetRegion(tokenId), _regionUS()); + assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.bonds(tokenId), BASE_BOND); + assertEq(fleet.regionShardCount(_regionUS()), 1); } - function test_RevertIf_registerFleet_duplicateUUID() public { + function test_registerFleetCountry_explicit_shard() public { vm.prank(alice); - fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 3); - vm.prank(bob); - vm.expectRevert(); // ERC721: token already minted - fleet.registerFleet(UUID_1, BOND_AMOUNT); + assertEq(fleet.fleetShard(tokenId), 3); + assertEq(fleet.bonds(tokenId), fleet.shardBond(3)); + assertEq(fleet.regionShardCount(_regionUS()), 4); } - function test_RevertIf_registerFleet_bondBelowMinimum() public { + function test_RevertIf_registerFleetCountry_invalidCode_zero() public { vm.prank(alice); - vm.expectRevert(FleetIdentity.BondBelowMinimum.selector); - fleet.registerFleet(UUID_1, MIN_BOND - 1); + vm.expectRevert(FleetIdentity.InvalidCountryCode.selector); + fleet.registerFleetCountry(UUID_1, 0); } - function test_RevertIf_registerFleet_insufficientBalance() public { - address broke = address(0xDEAD); - vm.prank(broke); - bondToken.approve(address(fleet), type(uint256).max); + function test_RevertIf_registerFleetCountry_invalidCode_over999() public { + vm.prank(alice); + vm.expectRevert(FleetIdentity.InvalidCountryCode.selector); + fleet.registerFleetCountry(UUID_1, 1000); + } - vm.prank(broke); - vm.expectRevert(); // SafeERC20: transferFrom fails - fleet.registerFleet(UUID_1, BOND_AMOUNT); + // --- registerFleetLocal --- + + function test_registerFleetLocal_auto_setsRegionAndShard() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + assertEq(fleet.fleetRegion(tokenId), _regionUSCA()); + assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.bonds(tokenId), BASE_BOND); } - function test_RevertIf_registerFleet_noApproval() public { - address noApproval = address(0xBEEF); - bondToken.mint(noApproval, BOND_AMOUNT); + function test_registerFleetLocal_explicit_shard() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); - vm.prank(noApproval); - vm.expectRevert(); // SafeERC20: transferFrom fails - fleet.registerFleet(UUID_1, BOND_AMOUNT); + assertEq(fleet.fleetShard(tokenId), 2); + assertEq(fleet.bonds(tokenId), fleet.shardBond(2)); } - // ═══════════════════════════════════════════════ - // increaseBond - // ═══════════════════════════════════════════════ + function test_RevertIf_registerFleetLocal_invalidCountry() public { + vm.prank(alice); + vm.expectRevert(FleetIdentity.InvalidCountryCode.selector); + fleet.registerFleetLocal(UUID_1, 0, ADMIN_CA); + } - function test_increaseBond_addsToExisting() public { + function test_RevertIf_registerFleetLocal_invalidAdmin_zero() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + 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 shard indexing (KEY REQUIREMENT) --- + function test_perRegionShards_firstFleetInEveryRegionPaysSameBond() public { vm.prank(alice); - fleet.increaseBond(tokenId, 50 ether); + 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.fleetShard(g1), 0); + assertEq(fleet.fleetShard(c1), 0); + assertEq(fleet.fleetShard(l1), 0); + + assertEq(fleet.bonds(g1), BASE_BOND); + assertEq(fleet.bonds(c1), BASE_BOND); + assertEq(fleet.bonds(l1), BASE_BOND); + } + + function test_perRegionShards_fillOneRegionDoesNotAffectOthers() public { + _registerNGlobal(alice, 20); + assertEq(fleet.regionShardCount(GLOBAL), 1); + assertEq(fleet.shardMemberCount(GLOBAL, 0), 20); + + vm.prank(bob); + uint256 g21 = fleet.registerFleetGlobal(_uuid(100)); + assertEq(fleet.fleetShard(g21), 1); + assertEq(fleet.bonds(g21), BASE_BOND * MULTIPLIER); + + vm.prank(bob); + uint256 us1 = fleet.registerFleetCountry(_uuid(200), US); + assertEq(fleet.fleetShard(us1), 0); + assertEq(fleet.bonds(us1), BASE_BOND); + assertEq(fleet.regionShardCount(_regionUS()), 1); - assertEq(fleet.bonds(tokenId), BOND_AMOUNT + 50 ether); - assertEq(bondToken.balanceOf(address(fleet)), BOND_AMOUNT + 50 ether); + vm.prank(bob); + uint256 usca1 = fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA); + assertEq(fleet.fleetShard(usca1), 0); + assertEq(fleet.bonds(usca1), BASE_BOND); + } + + function test_perRegionShards_twoCountriesIndependent() public { + _registerNCountry(alice, US, 20, 0); + assertEq(fleet.shardMemberCount(_regionUS(), 0), 20); + + vm.prank(bob); + uint256 us21 = fleet.registerFleetCountry(_uuid(500), US); + assertEq(fleet.fleetShard(us21), 1); + assertEq(fleet.bonds(us21), BASE_BOND * MULTIPLIER); + + vm.prank(bob); + uint256 de1 = fleet.registerFleetCountry(_uuid(600), DE); + assertEq(fleet.fleetShard(de1), 0); + assertEq(fleet.bonds(de1), BASE_BOND); + } + + function test_perRegionShards_twoAdminAreasIndependent() public { + _registerNLocal(alice, US, ADMIN_CA, 20, 0); + assertEq(fleet.shardMemberCount(_regionUSCA(), 0), 20); + + vm.prank(bob); + uint256 ny1 = fleet.registerFleetLocal(_uuid(500), US, ADMIN_NY); + assertEq(fleet.fleetShard(ny1), 0); + assertEq(fleet.bonds(ny1), BASE_BOND); } - function test_increaseBond_anyoneCanTopUp() public { + // --- Auto-assign shard logic --- + + function test_autoAssign_fillsShard0BeforeOpeningShard1() public { + _registerNGlobal(alice, 20); + assertEq(fleet.regionShardCount(GLOBAL), 1); + + vm.prank(bob); + uint256 id21 = fleet.registerFleetGlobal(_uuid(20)); + assertEq(fleet.fleetShard(id21), 1); + assertEq(fleet.regionShardCount(GLOBAL), 2); + } + + function test_autoAssign_backfillsShard0WhenSlotOpens() public { + uint256[] memory ids = _registerNGlobal(alice, 20); + vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + fleet.burn(ids[5]); + assertEq(fleet.shardMemberCount(GLOBAL, 0), 19); - // Bob tops up Alice's fleet vm.prank(bob); - fleet.increaseBond(tokenId, 100 ether); + uint256 newId = fleet.registerFleetGlobal(_uuid(100)); + assertEq(fleet.fleetShard(newId), 0); + assertEq(fleet.shardMemberCount(GLOBAL, 0), 20); + } + + // --- promote --- + + function test_promote_next_movesToNextShardInRegion() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); - assertEq(fleet.bonds(tokenId), BOND_AMOUNT + 100 ether); + vm.prank(alice); + fleet.promote(tokenId); + + assertEq(fleet.fleetShard(tokenId), 1); + assertEq(fleet.fleetRegion(tokenId), _regionUS()); + assertEq(fleet.bonds(tokenId), fleet.shardBond(1)); } - function test_increaseBond_emitsEvent() public { + function test_promote_next_pullsBondDifference() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + + uint256 balBefore = bondToken.balanceOf(alice); + uint256 diff = fleet.shardBond(1) - fleet.shardBond(0); + vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + fleet.promote(tokenId); + + assertEq(bondToken.balanceOf(alice), balBefore - diff); + } + + function test_promote_specific_jumpsMultipleShards() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + vm.prank(alice); + fleet.promote(tokenId, 3); + + assertEq(fleet.fleetShard(tokenId), 3); + assertEq(fleet.bonds(tokenId), fleet.shardBond(3)); + assertEq(fleet.regionShardCount(_regionUSCA()), 4); + } + + function test_promote_emitsEvent() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 diff = fleet.shardBond(1) - fleet.shardBond(0); - uint256 expectedTotal = BOND_AMOUNT + 50 ether; vm.expectEmit(true, true, true, true); - emit BondIncreased(tokenId, bob, 50 ether, expectedTotal); + 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); - fleet.increaseBond(tokenId, 50 ether); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.promote(tokenId); } - function test_increaseBond_multipleTimes() public { + function test_RevertIf_promote_targetNotHigher() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, MIN_BOND); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); vm.prank(alice); - fleet.increaseBond(tokenId, 10 ether); + vm.expectRevert(FleetIdentity.TargetShardNotHigher.selector); + fleet.promote(tokenId, 1); + vm.prank(alice); - fleet.increaseBond(tokenId, 20 ether); + vm.expectRevert(FleetIdentity.TargetShardNotHigher.selector); + fleet.promote(tokenId, 2); + } + + function test_RevertIf_promote_targetShardFull() public { vm.prank(alice); - fleet.increaseBond(tokenId, 30 ether); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + + for (uint256 i = 0; i < 20; i++) { + vm.prank(bob); + fleet.registerFleetGlobal(_uuid(50 + i), 1); + } - assertEq(fleet.bonds(tokenId), MIN_BOND + 60 ether); + vm.prank(alice); + vm.expectRevert(FleetIdentity.ShardFull.selector); + fleet.promote(tokenId); } - function test_RevertIf_increaseBond_zeroAmount() public { + function test_RevertIf_promote_exceedsMaxShards() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); vm.prank(alice); - vm.expectRevert(FleetIdentity.ZeroBondAmount.selector); - fleet.increaseBond(tokenId, 0); + vm.expectRevert(FleetIdentity.MaxShardsReached.selector); + fleet.promote(tokenId, 50); } - function test_RevertIf_increaseBond_nonexistentToken() public { + // --- demote --- + + function test_demote_movesToLowerShard() public { vm.prank(alice); - vm.expectRevert(); // ownerOf reverts - fleet.increaseBond(99999, 100 ether); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, DE, 3); + + vm.prank(alice); + fleet.demote(tokenId, 1); + + assertEq(fleet.fleetShard(tokenId), 1); + assertEq(fleet.bonds(tokenId), fleet.shardBond(1)); } - function test_RevertIf_increaseBond_burnedToken() public { + function test_demote_refundsBondDifference() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); + + uint256 balBefore = bondToken.balanceOf(alice); + uint256 refund = fleet.shardBond(3) - fleet.shardBond(1); vm.prank(alice); - fleet.burn(tokenId); + fleet.demote(tokenId, 1); - vm.prank(bob); - vm.expectRevert(); // ownerOf reverts for burned tokens - fleet.increaseBond(tokenId, 100 ether); + assertEq(bondToken.balanceOf(alice), balBefore + refund); } - // ═══════════════════════════════════════════════ - // burn - // ═══════════════════════════════════════════════ + function test_demote_emitsEvent() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); + uint256 refund = fleet.shardBond(3) - fleet.shardBond(1); + + vm.expectEmit(true, true, true, true); + emit FleetDemoted(tokenId, 3, 1, refund); - function test_burn_refundsBondToOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); - uint256 balBefore = bondToken.balanceOf(alice); + fleet.demote(tokenId, 1); + } + function test_demote_trimsShardCountWhenTopEmpties() public { vm.prank(alice); - fleet.burn(tokenId); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); + assertEq(fleet.regionShardCount(GLOBAL), 4); - assertEq(bondToken.balanceOf(alice), balBefore + BOND_AMOUNT); - assertEq(bondToken.balanceOf(address(fleet)), 0); - assertEq(fleet.bonds(tokenId), 0); + vm.prank(alice); + fleet.demote(tokenId, 0); + assertEq(fleet.regionShardCount(GLOBAL), 1); } - function test_burn_refundsIncreasedBond() public { + function test_RevertIf_demote_notOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); vm.prank(bob); - fleet.increaseBond(tokenId, 300 ether); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.demote(tokenId, 0); + } - uint256 totalBond = BOND_AMOUNT + 300 ether; + function test_RevertIf_demote_targetNotLower() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); + + vm.prank(alice); + vm.expectRevert(FleetIdentity.TargetShardNotLower.selector); + fleet.demote(tokenId, 3); + + vm.prank(alice); + vm.expectRevert(FleetIdentity.TargetShardNotLower.selector); + fleet.demote(tokenId, 2); + } + + function test_RevertIf_demote_targetShardFull() public { + _registerNGlobal(alice, 20); + + vm.prank(bob); + uint256 tokenId = fleet.registerFleetGlobal(_uuid(100), 2); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.ShardFull.selector); + fleet.demote(tokenId, 0); + } + + // --- burn --- + + function test_burn_refundsShardBond() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); uint256 balBefore = bondToken.balanceOf(alice); vm.prank(alice); fleet.burn(tokenId); - // Full bond goes to the token owner (alice), not the depositor (bob) - assertEq(bondToken.balanceOf(alice), balBefore + totalBond); + 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.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); vm.expectEmit(true, true, true, true); - emit FleetBurned(alice, tokenId, BOND_AMOUNT); + emit FleetBurned(alice, tokenId, BASE_BOND, GLOBAL, 0); vm.prank(alice); fleet.burn(tokenId); } - function test_burn_ownerOfRevertsAfterBurn() public { + function test_burn_trimsShardCount() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 3); + assertEq(fleet.regionShardCount(_regionUS()), 4); vm.prank(alice); fleet.burn(tokenId); - - vm.expectRevert(); - fleet.ownerOf(tokenId); + assertEq(fleet.regionShardCount(_regionUS()), 0); } function test_burn_allowsReregistration() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); vm.prank(alice); fleet.burn(tokenId); - // Same UUID can now be re-registered by someone else vm.prank(bob); - uint256 newTokenId = fleet.registerFleet(UUID_1, MIN_BOND); - - assertEq(newTokenId, tokenId); // Same deterministic ID - assertEq(fleet.ownerOf(newTokenId), bob); - assertEq(fleet.bonds(newTokenId), MIN_BOND); + uint256 newId = fleet.registerFleetCountry(UUID_1, DE); + assertEq(newId, tokenId); + assertEq(fleet.fleetRegion(newId), _regionDE()); } - function test_burn_zeroBondNoTransfer() public { - // Deploy with minBond = 0 - FleetIdentity f = new FleetIdentity(address(bondToken), 0); + function test_RevertIf_burn_notOwner() public { vm.prank(alice); - bondToken.approve(address(f), type(uint256).max); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.burn(tokenId); + } + + // --- lowestOpenShard --- + + function test_lowestOpenShard_initiallyZeroForAnyRegion() public view { + (uint256 shard, uint256 bond) = fleet.lowestOpenShard(GLOBAL); + assertEq(shard, 0); + assertEq(bond, BASE_BOND); + + (shard, bond) = fleet.lowestOpenShard(_regionUS()); + assertEq(shard, 0); + assertEq(bond, BASE_BOND); + } + + function test_lowestOpenShard_perRegionAfterFilling() public { + _registerNGlobal(alice, 20); + + (uint256 gShard, uint256 gBond) = fleet.lowestOpenShard(GLOBAL); + assertEq(gShard, 1); + assertEq(gBond, BASE_BOND * MULTIPLIER); + + (uint256 usShard, uint256 usBond) = fleet.lowestOpenShard(_regionUS()); + assertEq(usShard, 0); + assertEq(usBond, BASE_BOND); + } + + // --- highestActiveShard --- + function test_highestActiveShard_noFleets() public view { + assertEq(fleet.highestActiveShard(GLOBAL), 0); + assertEq(fleet.highestActiveShard(_regionUS()), 0); + } + + function test_highestActiveShard_afterRegistrations() public { vm.prank(alice); - uint256 tokenId = f.registerFleet(UUID_1, 0); + fleet.registerFleetGlobal(UUID_1, 3); + assertEq(fleet.highestActiveShard(GLOBAL), 3); - uint256 balBefore = bondToken.balanceOf(alice); + assertEq(fleet.highestActiveShard(_regionUS()), 0); + } + + // --- Scanner helpers --- + + function test_shardMemberCount_perRegion() public { + _registerNGlobal(alice, 5); + _registerNCountry(bob, US, 3, 100); + assertEq(fleet.shardMemberCount(GLOBAL, 0), 5); + assertEq(fleet.shardMemberCount(_regionUS(), 0), 3); + } + + function test_getShardMembers_perRegion() public { vm.prank(alice); - f.burn(tokenId); + uint256 gId = fleet.registerFleetGlobal(UUID_1); + + vm.prank(bob); + uint256 usId = fleet.registerFleetCountry(UUID_2, US); - // No transfer should occur - assertEq(bondToken.balanceOf(alice), balBefore); + uint256[] memory gMembers = fleet.getShardMembers(GLOBAL, 0); + assertEq(gMembers.length, 1); + assertEq(gMembers[0], gId); + + uint256[] memory usMembers = fleet.getShardMembers(_regionUS(), 0); + assertEq(usMembers.length, 1); + assertEq(usMembers[0], usId); } - function test_RevertIf_burn_notOwner() public { + function test_getShardUUIDs_perRegion() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + fleet.registerFleetGlobal(UUID_1); vm.prank(bob); - vm.expectRevert(FleetIdentity.NotTokenOwner.selector); - fleet.burn(tokenId); + fleet.registerFleetCountry(UUID_2, US); + + bytes16[] memory gUUIDs = fleet.getShardUUIDs(GLOBAL, 0); + assertEq(gUUIDs.length, 1); + assertEq(gUUIDs[0], UUID_1); + + bytes16[] memory usUUIDs = fleet.getShardUUIDs(_regionUS(), 0); + assertEq(usUUIDs.length, 1); + assertEq(usUUIDs[0], UUID_2); } - function test_RevertIf_burn_nonexistentToken() public { + // --- discoverBestShard --- + + function test_discoverBestShard_prefersAdminArea() public { vm.prank(alice); - vm.expectRevert(); // ownerOf reverts for nonexistent token - fleet.burn(12345); + 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 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + assertEq(rk, _regionUSCA()); + assertEq(shard, 0); + assertEq(members.length, 1); } - // ═══════════════════════════════════════════════ - // ERC721Enumerable - // ═══════════════════════════════════════════════ + function test_discoverBestShard_fallsBackToCountry() public { + vm.prank(alice); + fleet.registerFleetGlobal(UUID_1); + vm.prank(bob); + fleet.registerFleetCountry(UUID_2, US); - function test_enumerable_totalSupply() public { - assertEq(fleet.totalSupply(), 0); + (uint32 rk, uint256 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + assertEq(rk, _regionUS()); + assertEq(shard, 0); + assertEq(members.length, 1); + } + function test_discoverBestShard_fallsBackToGlobal() public { vm.prank(alice); - fleet.registerFleet(UUID_1, BOND_AMOUNT); - assertEq(fleet.totalSupply(), 1); + fleet.registerFleetGlobal(UUID_1); + (uint32 rk, uint256 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + assertEq(rk, GLOBAL); + assertEq(shard, 0); + assertEq(members.length, 1); + } + + function test_discoverBestShard_allEmpty() public view { + (uint32 rk, uint256 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + assertEq(rk, GLOBAL); + assertEq(shard, 0); + assertEq(members.length, 0); + } + + function test_discoverBestShard_returnsHighestShard() public { + _registerNCountry(alice, US, 20, 0); vm.prank(bob); - fleet.registerFleet(UUID_2, BOND_AMOUNT); - assertEq(fleet.totalSupply(), 2); + fleet.registerFleetCountry(_uuid(500), US); + + (uint32 rk, uint256 shard,) = fleet.discoverBestShard(US, 0); + assertEq(rk, _regionUS()); + assertEq(shard, 1); } - function test_enumerable_totalSupplyDecrementsOnBurn() public { + // --- discoverAllLevels --- + + function test_discoverAllLevels_returnsAllCounts() public { + _registerNGlobal(alice, 20); vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + fleet.registerFleetGlobal(_uuid(999)); - vm.prank(bob); - fleet.registerFleet(UUID_2, BOND_AMOUNT); - assertEq(fleet.totalSupply(), 2); + _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, 5); + + (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); - assertEq(fleet.totalSupply(), 1); + assertFalse(fleet.globalActive()); } - function test_enumerable_tokenByIndex() public { + function test_activeCountries_addedOnRegistration() public { vm.prank(alice); - uint256 id1 = fleet.registerFleet(UUID_1, BOND_AMOUNT); - + fleet.registerFleetCountry(UUID_1, US); vm.prank(bob); - uint256 id2 = fleet.registerFleet(UUID_2, BOND_AMOUNT); + fleet.registerFleetCountry(UUID_2, DE); - // Order depends on mint order - assertEq(fleet.tokenByIndex(0), id1); - assertEq(fleet.tokenByIndex(1), id2); + uint16[] memory countries = fleet.getActiveCountries(); + assertEq(countries.length, 2); } - function test_enumerable_tokenByIndex_afterBurn() public { + function test_activeCountries_removedWhenAllBurned() public { vm.prank(alice); - uint256 id1 = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 id1 = fleet.registerFleetCountry(UUID_1, US); - vm.prank(bob); - uint256 id2 = fleet.registerFleet(UUID_2, BOND_AMOUNT); + uint16[] memory before_ = fleet.getActiveCountries(); + assertEq(before_.length, 1); - vm.prank(carol); - uint256 id3 = fleet.registerFleet(UUID_3, BOND_AMOUNT); - - // Burn the middle token - vm.prank(bob); - fleet.burn(id2); + vm.prank(alice); + fleet.burn(id1); - assertEq(fleet.totalSupply(), 2); - // After burn, the last token fills the gap - assertEq(fleet.tokenByIndex(0), id1); - assertEq(fleet.tokenByIndex(1), id3); + uint16[] memory after_ = fleet.getActiveCountries(); + assertEq(after_.length, 0); } - function test_RevertIf_tokenByIndex_outOfBounds() public { + function test_activeCountries_notDuplicated() public { vm.prank(alice); - fleet.registerFleet(UUID_1, BOND_AMOUNT); + fleet.registerFleetCountry(UUID_1, US); + vm.prank(bob); + fleet.registerFleetCountry(UUID_2, US); - vm.expectRevert(); - fleet.tokenByIndex(1); + uint16[] memory countries = fleet.getActiveCountries(); + assertEq(countries.length, 1); + assertEq(countries[0], US); } - function test_enumerable_tokenOfOwnerByIndex() public { - vm.startPrank(alice); - uint256 id1 = fleet.registerFleet(UUID_1, BOND_AMOUNT); - uint256 id2 = fleet.registerFleet(UUID_2, BOND_AMOUNT); - vm.stopPrank(); + 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); - assertEq(fleet.balanceOf(alice), 2); - assertEq(fleet.tokenOfOwnerByIndex(alice, 0), id1); - assertEq(fleet.tokenOfOwnerByIndex(alice, 1), id2); + uint32[] memory areas = fleet.getActiveAdminAreas(); + assertEq(areas.length, 2); } - function test_enumerable_tokenOfOwnerByIndex_afterTransfer() public { + function test_activeAdminAreas_removedWhenAllBurned() public { vm.prank(alice); - uint256 id1 = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + assertEq(fleet.getActiveAdminAreas().length, 1); - // Transfer to bob vm.prank(alice); - fleet.transferFrom(alice, bob, id1); + fleet.burn(id1); - assertEq(fleet.balanceOf(alice), 0); - assertEq(fleet.balanceOf(bob), 1); - assertEq(fleet.tokenOfOwnerByIndex(bob, 0), id1); + assertEq(fleet.getActiveAdminAreas().length, 0); } - function test_RevertIf_tokenOfOwnerByIndex_outOfBounds() public { - vm.prank(alice); - fleet.registerFleet(UUID_1, BOND_AMOUNT); + // --- Region key helpers --- - vm.expectRevert(); - fleet.tokenOfOwnerByIndex(alice, 1); + function test_countryRegionKey() public view { + assertEq(fleet.countryRegionKey(US), uint32(US)); + assertEq(fleet.countryRegionKey(DE), uint32(DE)); } - function test_enumerable_supportsInterface() public view { - // ERC721Enumerable interfaceId = 0x780e9d63 - assertTrue(fleet.supportsInterface(0x780e9d63)); - // ERC721 interfaceId = 0x80ac58cd - assertTrue(fleet.supportsInterface(0x80ac58cd)); - // ERC165 interfaceId = 0x01ffc9a7 - assertTrue(fleet.supportsInterface(0x01ffc9a7)); + 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 view helper - // ═══════════════════════════════════════════════ + // --- tokenUUID / bonds --- function test_tokenUUID_roundTrip() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + assertEq(fleet.tokenUUID(tokenId), UUID_1); + } - bytes16 recovered = fleet.tokenUUID(tokenId); - assertEq(recovered, UUID_1); + function test_bonds_returnsShardBond() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + assertEq(fleet.bonds(tokenId), BASE_BOND); } - function test_tokenUUID_pureFunction() public view { - // tokenUUID is pure, works on any tokenId even nonexistent - bytes16 uuid = fleet.tokenUUID(42); - assertEq(uuid, bytes16(uint128(42))); + function test_bonds_zeroForNonexistentToken() public view { + assertEq(fleet.bonds(99999), 0); } - // ═══════════════════════════════════════════════ - // Bond accounting integrity - // ═══════════════════════════════════════════════ + // --- ERC721Enumerable --- + + function test_enumerable_totalSupply() public { + assertEq(fleet.totalSupply(), 0); - function test_bondAccounting_multipleFleets() public { vm.prank(alice); - fleet.registerFleet(UUID_1, 100 ether); + fleet.registerFleetGlobal(UUID_1); + assertEq(fleet.totalSupply(), 1); vm.prank(bob); - fleet.registerFleet(UUID_2, 200 ether); + 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); - fleet.registerFleet(UUID_3, 300 ether); + uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - assertEq(bondToken.balanceOf(address(fleet)), 600 ether); + assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 3); - // Burn one vm.prank(bob); - fleet.burn(uint256(uint128(UUID_2))); + fleet.burn(c1); + assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 2); - assertEq(bondToken.balanceOf(address(fleet)), 400 ether); + vm.prank(alice); + fleet.burn(g1); + vm.prank(carol); + fleet.burn(l1); + assertEq(bondToken.balanceOf(address(fleet)), 0); } - function test_bondAccounting_burnAllFleets() public { + function test_bondAccounting_promoteAndDemoteRoundTrip() public { vm.prank(alice); - uint256 id1 = fleet.registerFleet(UUID_1, 150 ether); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); + uint256 balStart = bondToken.balanceOf(alice); - vm.prank(bob); - uint256 id2 = fleet.registerFleet(UUID_2, 250 ether); + vm.prank(alice); + fleet.promote(tokenId, 3); vm.prank(alice); - fleet.burn(id1); - vm.prank(bob); - fleet.burn(id2); + fleet.demote(tokenId, 0); - assertEq(bondToken.balanceOf(address(fleet)), 0); - assertEq(fleet.totalSupply(), 0); + assertEq(bondToken.balanceOf(alice), balStart); + assertEq(fleet.bonds(tokenId), BASE_BOND); } - // ═══════════════════════════════════════════════ - // ERC-20 edge cases (bad token) - // ═══════════════════════════════════════════════ + // --- ERC-20 edge case --- function test_RevertIf_bondToken_transferFromReturnsFalse() public { BadERC20 badToken = new BadERC20(); - FleetIdentity f = new FleetIdentity(address(badToken), MIN_BOND); + FleetIdentity f = new FleetIdentity(address(badToken), BASE_BOND, MULTIPLIER); - badToken.mint(alice, BOND_AMOUNT); + badToken.mint(alice, 1_000 ether); vm.prank(alice); badToken.approve(address(f), type(uint256).max); - // Token works normally first badToken.setFail(true); vm.prank(alice); - vm.expectRevert(); // SafeERC20 reverts on false return - f.registerFleet(UUID_1, BOND_AMOUNT); + vm.expectRevert(); + f.registerFleetGlobal(UUID_1); } - // ═══════════════════════════════════════════════ - // Transfer preserves bond - // ═══════════════════════════════════════════════ + // --- Transfer preserves region and shard --- - function test_transfer_bondStaysWithToken() public { + function test_transfer_regionAndShardStayWithToken() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 2); vm.prank(alice); fleet.transferFrom(alice, bob, tokenId); - // Bond is still the same - assertEq(fleet.bonds(tokenId), BOND_AMOUNT); + assertEq(fleet.fleetRegion(tokenId), _regionUS()); + assertEq(fleet.fleetShard(tokenId), 2); + assertEq(fleet.bonds(tokenId), fleet.shardBond(2)); - // Bob can burn and get the refund uint256 bobBefore = bondToken.balanceOf(bob); vm.prank(bob); fleet.burn(tokenId); - assertEq(bondToken.balanceOf(bob), bobBefore + BOND_AMOUNT); + assertEq(bondToken.balanceOf(bob), bobBefore + fleet.shardBond(2)); } - // ═══════════════════════════════════════════════ - // Fuzz Tests - // ═══════════════════════════════════════════════ + // --- Shard lifecycle --- - function testFuzz_registerFleet_anyValidUUID(bytes16 uuid) public { - vm.assume(uuid != bytes16(0)); + function test_shardLifecycle_fillBurnBackfillPerRegion() public { + uint256[] memory usIds = _registerNCountry(alice, US, 20, 0); + assertEq(fleet.shardMemberCount(_regionUS(), 0), 20); + + vm.prank(bob); + uint256 us21 = fleet.registerFleetCountry(_uuid(100), US); + assertEq(fleet.fleetShard(us21), 1); vm.prank(alice); - uint256 tokenId = fleet.registerFleet(uuid, BOND_AMOUNT); + fleet.burn(usIds[10]); - assertEq(tokenId, uint256(uint128(uuid))); - assertEq(fleet.ownerOf(tokenId), alice); - assertEq(fleet.bonds(tokenId), BOND_AMOUNT); - assertEq(fleet.totalSupply(), 1); + vm.prank(carol); + uint256 backfill = fleet.registerFleetCountry(_uuid(200), US); + assertEq(fleet.fleetShard(backfill), 0); + assertEq(fleet.shardMemberCount(_regionUS(), 0), 20); + + assertEq(fleet.regionShardCount(GLOBAL), 0); } - function testFuzz_registerFleet_anyBondAboveMin(uint256 bondAmount) public { - bondAmount = bound(bondAmount, MIN_BOND, 5_000 ether); + // --- Edge cases --- - bondToken.mint(alice, bondAmount); // ensure sufficient balance + function test_multiplier1_allShardsHaveSameBond() public { + FleetIdentity f = new FleetIdentity(address(bondToken), BASE_BOND, 1); + assertEq(f.shardBond(0), BASE_BOND); + assertEq(f.shardBond(5), BASE_BOND); + } + function test_zeroBaseBond_allowsRegistration() public { + FleetIdentity f = new FleetIdentity(address(bondToken), 0, MULTIPLIER); vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, bondAmount); + bondToken.approve(address(f), type(uint256).max); - assertEq(fleet.bonds(tokenId), bondAmount); + vm.prank(alice); + uint256 tokenId = f.registerFleetGlobal(UUID_1); + assertEq(f.bonds(tokenId), 0); + + vm.prank(alice); + f.burn(tokenId); } - function testFuzz_increaseBond_anyPositiveAmount(uint256 amount) public { - amount = bound(amount, 1, 1_000_000 ether); + // --- Fuzz Tests --- - bondToken.mint(bob, amount); + function testFuzz_registerFleetGlobal_anyValidUUID(bytes16 uuid) public { + vm.assume(uuid != bytes16(0)); vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetGlobal(uuid); - vm.prank(bob); - fleet.increaseBond(tokenId, amount); - - assertEq(fleet.bonds(tokenId), BOND_AMOUNT + amount); + assertEq(tokenId, uint256(uint128(uuid))); + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(fleet.bonds(tokenId), BASE_BOND); + assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.fleetRegion(tokenId), GLOBAL); } - function testFuzz_burn_refundsExactBond(uint256 bondAmount, uint256 increaseAmount) public { - bondAmount = bound(bondAmount, MIN_BOND, 5_000 ether); - increaseAmount = bound(increaseAmount, 0, 5_000 ether); + 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.fleetShard(tokenId), 0); + assertEq(fleet.bonds(tokenId), BASE_BOND); + } - bondToken.mint(alice, bondAmount + increaseAmount); + 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.registerFleet(UUID_1, bondAmount); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, cc, admin); - if (increaseAmount > 0) { - vm.prank(alice); - fleet.increaseBond(tokenId, increaseAmount); - } + uint32 expectedRegion = (uint32(cc) << 12) | uint32(admin); + assertEq(fleet.fleetRegion(tokenId), expectedRegion); + assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.bonds(tokenId), BASE_BOND); + } - uint256 expectedRefund = bondAmount + increaseAmount; - uint256 balBefore = bondToken.balanceOf(alice); + function testFuzz_promote_onlyOwner(address caller) public { + vm.assume(caller != alice); + vm.assume(caller != address(0)); vm.prank(alice); - fleet.burn(tokenId); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); - assertEq(bondToken.balanceOf(alice), balBefore + expectedRefund); - assertEq(fleet.bonds(tokenId), 0); + vm.prank(caller); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.promote(tokenId); } function testFuzz_burn_onlyOwner(address caller) public { @@ -653,59 +1078,114 @@ contract FleetIdentityTest is Test { vm.assume(caller != address(0)); vm.prank(alice); - uint256 tokenId = fleet.registerFleet(UUID_1, BOND_AMOUNT); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); vm.prank(caller); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); fleet.burn(tokenId); } - function testFuzz_enumerable_totalSupplyMatchesMintBurnDelta(uint8 mintCount, uint8 burnCount) public { - mintCount = uint8(bound(mintCount, 1, 20)); - burnCount = uint8(bound(burnCount, 0, mintCount)); + function testFuzz_shardBond_geometric(uint256 shard) public view { + shard = bound(shard, 0, 10); + uint256 expected = BASE_BOND; + for (uint256 i = 0; i < shard; i++) { + expected *= MULTIPLIER; + } + assertEq(fleet.shardBond(shard), expected); + } - uint256[] memory tokenIds = new uint256[](mintCount); + function testFuzz_perRegionShards_newRegionAlwaysStartsAtShard0(uint16 cc) public { + cc = uint16(bound(cc, 1, 999)); - for (uint8 i = 0; i < mintCount; i++) { - bytes16 uuid = bytes16(keccak256(abi.encodePacked("fuzz-fleet-", i))); - bondToken.mint(alice, BOND_AMOUNT); - vm.prank(alice); - tokenIds[i] = fleet.registerFleet(uuid, BOND_AMOUNT); - } + _registerNGlobal(alice, 40); + assertEq(fleet.regionShardCount(GLOBAL), 2); - assertEq(fleet.totalSupply(), mintCount); + vm.prank(bob); + uint256 tokenId = fleet.registerFleetCountry(_uuid(999), cc); + assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.bonds(tokenId), BASE_BOND); + } - for (uint8 i = 0; i < burnCount; i++) { + function testFuzz_shardAssignment_autoFillsSequentiallyPerRegion(uint8 count) public { + count = uint8(bound(count, 1, 40)); + + for (uint256 i = 0; i < count; i++) { vm.prank(alice); - fleet.burn(tokenIds[i]); + uint256 tokenId = fleet.registerFleetCountry(_uuid(i + 300), US); + + uint256 expectedShard = i / 20; + assertEq(fleet.fleetShard(tokenId), expectedShard); } - assertEq(fleet.totalSupply(), uint256(mintCount) - uint256(burnCount)); + uint256 expectedShards = (uint256(count) + 19) / 20; + assertEq(fleet.regionShardCount(_regionUS()), expectedShards); } - // ═══════════════════════════════════════════════ - // Invariant: contract token balance == sum of all bonds - // ═══════════════════════════════════════════════ + // --- Invariants --- function test_invariant_contractBalanceEqualsSumOfBonds() public { vm.prank(alice); - uint256 id1 = fleet.registerFleet(UUID_1, 150 ether); - + uint256 id1 = fleet.registerFleetGlobal(UUID_1); vm.prank(bob); - uint256 id2 = fleet.registerFleet(UUID_2, 250 ether); + 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_contractBalanceAfterPromoteDemoteBurn() 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); - fleet.increaseBond(id1, 50 ether); + uint256 id3 = fleet.registerFleetGlobal(UUID_3); - uint256 expectedSum = 150 ether + 250 ether + 50 ether; - assertEq(bondToken.balanceOf(address(fleet)), expectedSum); - assertEq(fleet.bonds(id1) + fleet.bonds(id2), expectedSum); + vm.prank(alice); + fleet.promote(id1, 3); + + vm.prank(alice); + fleet.demote(id1, 1); + + uint256 expected = fleet.bonds(id1) + fleet.bonds(id2) + fleet.bonds(id3); + assertEq(bondToken.balanceOf(address(fleet)), expected); - // Burn one and verify vm.prank(alice); fleet.burn(id1); + vm.prank(bob); + fleet.burn(id2); + vm.prank(carol); + fleet.burn(id3); + + assertEq(bondToken.balanceOf(address(fleet)), 0); + } + + // --- Scanner workflow --- + + function test_scannerWorkflow_multiRegionDiscovery() public { + _registerNGlobal(alice, 20); + for (uint256 i = 0; i < 5; i++) { + vm.prank(bob); + fleet.registerFleetGlobal(_uuid(20 + i)); + } + + _registerNLocal(carol, US, ADMIN_CA, 3, 200); + + (uint32 rk, uint256 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + assertEq(rk, _regionUSCA()); + assertEq(shard, 0); + assertEq(members.length, 3); - assertEq(bondToken.balanceOf(address(fleet)), 250 ether); - assertEq(fleet.bonds(id2), 250 ether); + (uint256 gsc, uint256 csc, uint256 asc,) = fleet.discoverAllLevels(US, ADMIN_CA); + assertEq(gsc, 2); + assertEq(csc, 0); + assertEq(asc, 1); } } diff --git a/test/SwarmRegistryL1.t.sol b/test/SwarmRegistryL1.t.sol index fb18e47e..4f3552ec 100644 --- a/test/SwarmRegistryL1.t.sol +++ b/test/SwarmRegistryL1.t.sol @@ -36,7 +36,7 @@ contract SwarmRegistryL1Test is Test { function setUp() public { bondToken = new MockBondTokenL1(); - fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND); + fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND, 2); providerContract = new ServiceProvider(); swarmRegistry = new SwarmRegistryL1(address(fleetContract), address(providerContract)); @@ -52,7 +52,7 @@ contract SwarmRegistryL1Test is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleet(bytes16(keccak256(seed)), FLEET_BOND); + return fleetContract.registerFleetGlobal(bytes16(keccak256(seed))); } function _registerProvider(address owner, string memory url) internal returns (uint256) { diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol index 7e4c2cbb..694da365 100644 --- a/test/SwarmRegistryUniversal.t.sol +++ b/test/SwarmRegistryUniversal.t.sol @@ -38,7 +38,7 @@ contract SwarmRegistryUniversalTest is Test { function setUp() public { bondToken = new MockBondTokenUniv(); - fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND); + fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND, 2); providerContract = new ServiceProvider(); swarmRegistry = new SwarmRegistryUniversal(address(fleetContract), address(providerContract)); @@ -54,7 +54,7 @@ contract SwarmRegistryUniversalTest is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleet(bytes16(keccak256(seed)), FLEET_BOND); + return fleetContract.registerFleetGlobal(bytes16(keccak256(seed))); } function _registerProvider(address owner, string memory url) internal returns (uint256) { From f07c7d7a6a5c1a461674247757833ba24e6ada9c Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 13 Feb 2026 12:23:44 +1300 Subject: [PATCH 05/15] feat(swarms): different shard size for different geo level --- src/swarms/FleetIdentity.sol | 146 ++++++++++++++-- test/FleetIdentity.t.sol | 325 ++++++++++++++++++++++++++++++----- 2 files changed, 413 insertions(+), 58 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 8223d4b9..bd94325f 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -23,8 +23,11 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * start at 0 for every region. The first fleet in any region always pays * BASE_BOND regardless of how many shards exist in other regions. * - * Shards hold up to SHARD_CAPACITY (20) members each. Shard K within a - * region requires bond = BASE_BOND * BOND_MULTIPLIER^K. + * Shard capacity varies by level: + * - Global: 4 members per shard + * - Country: 8 members per shard + * - Admin Area: 8 members per shard + * Shard K within a region requires bond = BASE_BOND * BOND_MULTIPLIER^K. * * Scanner discovery uses a 3-level fallback: * 1. Admin area (most specific) @@ -56,12 +59,21 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Constants & Immutables // ────────────────────────────────────────────── - /// @notice Maximum members per shard (matches iOS CLBeaconRegion limit). - uint256 public constant SHARD_CAPACITY = 20; + /// @notice Maximum members per global shard. + uint256 public constant GLOBAL_SHARD_CAPACITY = 4; + + /// @notice Maximum members per country-level shard. + uint256 public constant COUNTRY_SHARD_CAPACITY = 8; + + /// @notice Maximum members per admin-area (local) shard. + uint256 public constant LOCAL_SHARD_CAPACITY = 8; /// @notice Hard cap on shard count per region to bound gas costs. uint256 public constant MAX_SHARDS = 50; + /// @notice Maximum UUIDs returned by buildScannerBundle. + uint256 public constant SCANNER_BUNDLE_CAPACITY = 20; + /// @notice Region key for global registrations. uint32 public constant GLOBAL_REGION = 0; @@ -129,7 +141,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { ); event FleetPromoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 additionalBond); event FleetDemoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 bondRefund); - event FleetBurned(address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 shardIndex); + event FleetBurned( + address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 shardIndex + ); // ────────────────────────────────────────────── // Constructor @@ -247,7 +261,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint32 region = fleetRegion[tokenId]; uint256 currentShard = fleetShard[tokenId]; if (targetShard >= currentShard) revert TargetShardNotLower(); - if (_regionShardMembers[region][targetShard].length >= SHARD_CAPACITY) revert ShardFull(); + if (_regionShardMembers[region][targetShard].length >= shardCapacity(region)) revert ShardFull(); uint256 currentBond = shardBond(currentShard); uint256 targetBond = shardBond(targetShard); @@ -314,6 +328,14 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return bond; } + /// @notice Returns the shard capacity for a given region key. + /// Global = 4, Country = 8, Admin Area = 8. + function shardCapacity(uint32 regionKey) public pure returns (uint256) { + if (regionKey == GLOBAL_REGION) return GLOBAL_SHARD_CAPACITY; + if (regionKey <= 999) return COUNTRY_SHARD_CAPACITY; + return LOCAL_SHARD_CAPACITY; + } + /// @notice Returns the lowest open shard and its bond for a region. function lowestOpenShard(uint32 regionKey) external view returns (uint256 shard, uint256 bond) { shard = _findOpenShardView(regionKey); @@ -405,12 +427,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { function discoverAllLevels(uint16 countryCode, uint16 adminCode) external view - returns ( - uint256 globalShardCount, - uint256 countryShardCount, - uint256 adminShardCount, - uint32 adminRegionKey - ) + returns (uint256 globalShardCount, uint256 countryShardCount, uint256 adminShardCount, uint32 adminRegionKey) { globalShardCount = regionShardCount[GLOBAL_REGION]; if (countryCode > 0) { @@ -422,6 +439,101 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } + /// @notice Builds a priority-ordered bundle of up to SCANNER_BUNDLE_CAPACITY (20) + /// UUIDs for a scanner, merging the highest-bonded shards across admin-area, + /// country, and global levels. + /// + /// **Algorithm** + /// Maintains a cursor (highest remaining shard) for each of the three + /// levels. At each step: + /// 1. Compute the bond for each level's cursor shard. + /// 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 Scanner's country (0 to skip country + admin). + /// @param adminCode Scanner's admin area (0 to skip admin). + /// @return uuids The merged UUID bundle (up to 20). + /// @return count Actual number of UUIDs returned. + function buildScannerBundle(uint16 countryCode, uint16 adminCode) + external + view + returns (bytes16[] memory uuids, uint256 count) + { + uuids = new bytes16[](SCANNER_BUNDLE_CAPACITY); + + // Resolve region keys and shard 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 = regionShardCount[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 = regionShardCount[keys[1]]; + cursors[1] = sc > 0 ? int256(sc) - 1 : int256(-1); + } else { + cursors[1] = -1; + } + + // Level 2: global + { + keys[2] = GLOBAL_REGION; + uint256 sc = regionShardCount[GLOBAL_REGION]; + cursors[2] = sc > 0 ? int256(sc) - 1 : int256(-1); + } + + while (count < SCANNER_BUNDLE_CAPACITY) { + // 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 = shardBond(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 (shardBond(uint256(cursors[lvl])) != maxBond) continue; + + uint256[] storage members = _regionShardMembers[keys[lvl]][uint256(cursors[lvl])]; + uint256 mLen = members.length; + + for (uint256 m = 0; m < mLen && count < SCANNER_BUNDLE_CAPACITY; 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 // ══════════════════════════════════════════════ @@ -485,7 +597,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 currentShard = fleetShard[tokenId]; if (targetShard <= currentShard) revert TargetShardNotHigher(); if (targetShard >= MAX_SHARDS) revert MaxShardsReached(); - if (_regionShardMembers[region][targetShard].length >= SHARD_CAPACITY) revert ShardFull(); + if (_regionShardMembers[region][targetShard].length >= shardCapacity(region)) revert ShardFull(); uint256 currentBond = shardBond(currentShard); uint256 targetBond = shardBond(targetShard); @@ -512,7 +624,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Validates and prepares an explicit shard for registration. function _validateExplicitShard(uint32 region, uint256 targetShard) internal { if (targetShard >= MAX_SHARDS) revert MaxShardsReached(); - if (_regionShardMembers[region][targetShard].length >= SHARD_CAPACITY) revert ShardFull(); + if (_regionShardMembers[region][targetShard].length >= shardCapacity(region)) revert ShardFull(); if (targetShard >= regionShardCount[region]) { regionShardCount[region] = targetShard + 1; } @@ -521,9 +633,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Finds lowest open shard within a region, opening a new one if needed. function _openShard(uint32 region) internal returns (uint256) { uint256 sc = regionShardCount[region]; + uint256 cap = shardCapacity(region); uint256 start = _regionLowestHint[region]; for (uint256 i = start; i < sc; i++) { - if (_regionShardMembers[region][i].length < SHARD_CAPACITY) { + if (_regionShardMembers[region][i].length < cap) { _regionLowestHint[region] = i; return i; } @@ -537,9 +650,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev View-only version of _openShard. function _findOpenShardView(uint32 region) internal view returns (uint256) { uint256 sc = regionShardCount[region]; + uint256 cap = shardCapacity(region); uint256 start = _regionLowestHint[region]; for (uint256 i = start; i < sc; i++) { - if (_regionShardMembers[region][i].length < SHARD_CAPACITY) return i; + if (_regionShardMembers[region][i].length < cap) return i; } if (sc >= MAX_SHARDS) revert MaxShardsReached(); return sc; diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index d9e63f16..4911327a 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -69,7 +69,9 @@ contract FleetIdentityTest is Test { ); event FleetPromoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 additionalBond); event FleetDemoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 bondRefund); - event FleetBurned(address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 shardIndex); + event FleetBurned( + address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 shardIndex + ); function setUp() public { bondToken = new MockERC20(); @@ -95,10 +97,21 @@ contract FleetIdentityTest is Test { 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 _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); @@ -108,7 +121,10 @@ contract FleetIdentityTest is Test { } } - function _registerNCountry(address owner, uint16 cc, uint256 count, uint256 startSeed) internal returns (uint256[] memory ids) { + 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); @@ -116,7 +132,10 @@ contract FleetIdentityTest is Test { } } - function _registerNLocal(address owner, uint16 cc, uint16 admin, uint256 count, uint256 startSeed) internal returns (uint256[] memory ids) { + 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); @@ -136,8 +155,17 @@ contract FleetIdentityTest is Test { } function test_constructor_constants() public view { - assertEq(fleet.SHARD_CAPACITY(), 20); + assertEq(fleet.GLOBAL_SHARD_CAPACITY(), 4); + assertEq(fleet.COUNTRY_SHARD_CAPACITY(), 8); + assertEq(fleet.LOCAL_SHARD_CAPACITY(), 8); assertEq(fleet.MAX_SHARDS(), 50); + assertEq(fleet.SCANNER_BUNDLE_CAPACITY(), 20); + } + + function test_shardCapacity_perLevel() public view { + assertEq(fleet.shardCapacity(GLOBAL), 4); + assertEq(fleet.shardCapacity(_regionUS()), 8); + assertEq(fleet.shardCapacity(_regionUSCA()), 8); } // --- shardBond --- @@ -308,9 +336,9 @@ contract FleetIdentityTest is Test { } function test_perRegionShards_fillOneRegionDoesNotAffectOthers() public { - _registerNGlobal(alice, 20); + _registerNGlobal(alice, 4); assertEq(fleet.regionShardCount(GLOBAL), 1); - assertEq(fleet.shardMemberCount(GLOBAL, 0), 20); + assertEq(fleet.shardMemberCount(GLOBAL, 0), 4); vm.prank(bob); uint256 g21 = fleet.registerFleetGlobal(_uuid(100)); @@ -330,8 +358,8 @@ contract FleetIdentityTest is Test { } function test_perRegionShards_twoCountriesIndependent() public { - _registerNCountry(alice, US, 20, 0); - assertEq(fleet.shardMemberCount(_regionUS(), 0), 20); + _registerNCountry(alice, US, 8, 0); + assertEq(fleet.shardMemberCount(_regionUS(), 0), 8); vm.prank(bob); uint256 us21 = fleet.registerFleetCountry(_uuid(500), US); @@ -345,8 +373,8 @@ contract FleetIdentityTest is Test { } function test_perRegionShards_twoAdminAreasIndependent() public { - _registerNLocal(alice, US, ADMIN_CA, 20, 0); - assertEq(fleet.shardMemberCount(_regionUSCA(), 0), 20); + _registerNLocal(alice, US, ADMIN_CA, 8, 0); + assertEq(fleet.shardMemberCount(_regionUSCA(), 0), 8); vm.prank(bob); uint256 ny1 = fleet.registerFleetLocal(_uuid(500), US, ADMIN_NY); @@ -357,26 +385,26 @@ contract FleetIdentityTest is Test { // --- Auto-assign shard logic --- function test_autoAssign_fillsShard0BeforeOpeningShard1() public { - _registerNGlobal(alice, 20); + _registerNGlobal(alice, 4); assertEq(fleet.regionShardCount(GLOBAL), 1); vm.prank(bob); - uint256 id21 = fleet.registerFleetGlobal(_uuid(20)); - assertEq(fleet.fleetShard(id21), 1); + uint256 id5 = fleet.registerFleetGlobal(_uuid(20)); + assertEq(fleet.fleetShard(id5), 1); assertEq(fleet.regionShardCount(GLOBAL), 2); } function test_autoAssign_backfillsShard0WhenSlotOpens() public { - uint256[] memory ids = _registerNGlobal(alice, 20); + uint256[] memory ids = _registerNGlobal(alice, 4); vm.prank(alice); - fleet.burn(ids[5]); - assertEq(fleet.shardMemberCount(GLOBAL, 0), 19); + fleet.burn(ids[2]); + assertEq(fleet.shardMemberCount(GLOBAL, 0), 3); vm.prank(bob); uint256 newId = fleet.registerFleetGlobal(_uuid(100)); assertEq(fleet.fleetShard(newId), 0); - assertEq(fleet.shardMemberCount(GLOBAL, 0), 20); + assertEq(fleet.shardMemberCount(GLOBAL, 0), 4); } // --- promote --- @@ -456,7 +484,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1); - for (uint256 i = 0; i < 20; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(bob); fleet.registerFleetGlobal(_uuid(50 + i), 1); } @@ -546,7 +574,7 @@ contract FleetIdentityTest is Test { } function test_RevertIf_demote_targetShardFull() public { - _registerNGlobal(alice, 20); + _registerNGlobal(alice, 4); vm.prank(bob); uint256 tokenId = fleet.registerFleetGlobal(_uuid(100), 2); @@ -627,7 +655,7 @@ contract FleetIdentityTest is Test { } function test_lowestOpenShard_perRegionAfterFilling() public { - _registerNGlobal(alice, 20); + _registerNGlobal(alice, 4); (uint256 gShard, uint256 gBond) = fleet.lowestOpenShard(GLOBAL); assertEq(gShard, 1); @@ -656,11 +684,11 @@ contract FleetIdentityTest is Test { // --- Scanner helpers --- function test_shardMemberCount_perRegion() public { - _registerNGlobal(alice, 5); - _registerNCountry(bob, US, 3, 100); + _registerNGlobal(alice, 3); + _registerNCountry(bob, US, 5, 100); - assertEq(fleet.shardMemberCount(GLOBAL, 0), 5); - assertEq(fleet.shardMemberCount(_regionUS(), 0), 3); + assertEq(fleet.shardMemberCount(GLOBAL, 0), 3); + assertEq(fleet.shardMemberCount(_regionUS(), 0), 5); } function test_getShardMembers_perRegion() public { @@ -741,7 +769,7 @@ contract FleetIdentityTest is Test { } function test_discoverBestShard_returnsHighestShard() public { - _registerNCountry(alice, US, 20, 0); + _registerNCountry(alice, US, 8, 0); vm.prank(bob); fleet.registerFleetCountry(_uuid(500), US); @@ -753,7 +781,7 @@ contract FleetIdentityTest is Test { // --- discoverAllLevels --- function test_discoverAllLevels_returnsAllCounts() public { - _registerNGlobal(alice, 20); + _registerNGlobal(alice, 4); vm.prank(alice); fleet.registerFleetGlobal(_uuid(999)); @@ -768,7 +796,7 @@ contract FleetIdentityTest is Test { } function test_discoverAllLevels_zeroCountryAndAdmin() public { - _registerNGlobal(alice, 5); + _registerNGlobal(alice, 3); (uint256 gsc, uint256 csc, uint256 asc,) = fleet.discoverAllLevels(0, 0); assertEq(gsc, 1); @@ -983,20 +1011,20 @@ contract FleetIdentityTest is Test { // --- Shard lifecycle --- function test_shardLifecycle_fillBurnBackfillPerRegion() public { - uint256[] memory usIds = _registerNCountry(alice, US, 20, 0); - assertEq(fleet.shardMemberCount(_regionUS(), 0), 20); + uint256[] memory usIds = _registerNCountry(alice, US, 8, 0); + assertEq(fleet.shardMemberCount(_regionUS(), 0), 8); vm.prank(bob); - uint256 us21 = fleet.registerFleetCountry(_uuid(100), US); - assertEq(fleet.fleetShard(us21), 1); + uint256 us9 = fleet.registerFleetCountry(_uuid(100), US); + assertEq(fleet.fleetShard(us9), 1); vm.prank(alice); - fleet.burn(usIds[10]); + fleet.burn(usIds[3]); vm.prank(carol); uint256 backfill = fleet.registerFleetCountry(_uuid(200), US); assertEq(fleet.fleetShard(backfill), 0); - assertEq(fleet.shardMemberCount(_regionUS(), 0), 20); + assertEq(fleet.shardMemberCount(_regionUS(), 0), 8); assertEq(fleet.regionShardCount(GLOBAL), 0); } @@ -1097,7 +1125,7 @@ contract FleetIdentityTest is Test { function testFuzz_perRegionShards_newRegionAlwaysStartsAtShard0(uint16 cc) public { cc = uint16(bound(cc, 1, 999)); - _registerNGlobal(alice, 40); + _registerNGlobal(alice, 8); assertEq(fleet.regionShardCount(GLOBAL), 2); vm.prank(bob); @@ -1113,11 +1141,11 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(_uuid(i + 300), US); - uint256 expectedShard = i / 20; + uint256 expectedShard = i / 8; // country capacity = 8 assertEq(fleet.fleetShard(tokenId), expectedShard); } - uint256 expectedShards = (uint256(count) + 19) / 20; + uint256 expectedShards = (uint256(count) + 7) / 8; assertEq(fleet.regionShardCount(_regionUS()), expectedShards); } @@ -1170,8 +1198,8 @@ contract FleetIdentityTest is Test { // --- Scanner workflow --- function test_scannerWorkflow_multiRegionDiscovery() public { - _registerNGlobal(alice, 20); - for (uint256 i = 0; i < 5; i++) { + _registerNGlobal(alice, 4); + for (uint256 i = 0; i < 2; i++) { vm.prank(bob); fleet.registerFleetGlobal(_uuid(20 + i)); } @@ -1188,4 +1216,217 @@ contract FleetIdentityTest is Test { assertEq(csc, 0); assertEq(asc, 1); } + + // --- buildScannerBundle --- + + function test_buildBundle_emptyReturnsZero() public view { + (, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + assertEq(count, 0); + } + + function test_buildBundle_singleGlobal() public { + vm.prank(alice); + fleet.registerFleetGlobal(UUID_1); + + (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(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.buildScannerBundle(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.buildScannerBundle(US, ADMIN_CA); + assertEq(count, 1); + assertEq(uuids[0], UUID_1); + } + + function test_buildBundle_mergesAllLevelsAtSameBond() public { + // All at shard 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.buildScannerBundle(US, ADMIN_CA); + assertEq(count, 3); + } + + function test_buildBundle_higherBondFirstAcrossLevels() public { + // Global: shard 0 (bond=100) + vm.prank(alice); + fleet.registerFleetGlobal(UUID_1); + + // Country US: promote to shard 2 (bond=400) + vm.prank(alice); + uint256 usId = fleet.registerFleetCountry(UUID_2, US); + vm.prank(alice); + fleet.promote(usId, 2); + + // Admin US-CA: shard 0 (bond=100) + vm.prank(alice); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + + (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + assertEq(count, 3); + // First UUID should be from US country (shard 2, highest bond) + assertEq(uuids[0], UUID_2); + } + + function test_buildBundle_tiedBondsCollectedTogether() public { + // Global shard 0, Country shard 0, Admin shard 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.buildScannerBundle(US, ADMIN_CA); + // All at same bond → all 4 collected + assertEq(count, 4); + } + + function test_buildBundle_descendsShardsByBondPriority() public { + // Admin area: fill shard 0 (8 members, bond=100) + 1 in shard 1 (bond=200) + _registerNLocal(alice, US, ADMIN_CA, 8, 5000); + vm.prank(alice); + fleet.registerFleetLocal(_uuid(5099), US, ADMIN_CA); + + // Global: 1 member in shard 0 (bond=100) + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(6000)); + + (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + // Step 1: admin shard 1 (bond=200, 1 member) → count=1 + // Step 2: admin shard 0 (bond=100) + global shard 0 (bond=100) → tied → 8+1=9 + // Total: 10 + assertEq(count, 10); + // First UUID is from admin shard 1 (highest bond) + uint256[] memory adminShard1 = fleet.getShardMembers(_regionUSCA(), 1); + assertEq(uuids[0], bytes16(uint128(adminShard1[0]))); + } + + function test_buildBundle_capsAt20() public { + // Fill global: 4+4+4 = 12 in 3 shards + _registerNGlobal(alice, 12); + // Fill country US: 8+4 = 12 in 2 shards + _registerNCountry(bob, US, 12, 1000); + + // Total across levels: 24, but cap at 20 + (, uint256 count) = fleet.buildScannerBundle(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.buildScannerBundle(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.buildScannerBundle(US, 0); + assertEq(count, 1); // only country + } + + function test_buildBundle_multiShardMultiLevel_correctOrder() public { + // Admin: 2 shards (shard 0: 8 members bond=100, shard 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 shard 1 (bond=200) + vm.prank(alice); + uint256 countryId = fleet.registerFleetCountry(_uuid(8200), US); + vm.prank(alice); + fleet.promote(countryId, 1); + + // Global: promote to shard 2 (bond=400) + vm.prank(alice); + uint256 globalId = fleet.registerFleetGlobal(_uuid(8300)); + vm.prank(alice); + fleet.promote(globalId, 2); + + (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + // Step 1: global shard 2 (bond=400) → 1 member + // Step 2: admin shard 1 (bond=200) + country shard 1 (bond=200) → tied → 1+1=2 + // Step 3: admin shard 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.buildScannerBundle(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.buildScannerBundle(US, ADMIN_CA); + assertLe(count, 20); + + uint256 total = uint256(gCount) + uint256(cCount) + uint256(lCount); + if (total <= 20) { + assertEq(count, total); + } + } } From 9e52897bd9a6809a2fa82fb5dba23252e6f17c0a Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 13 Feb 2026 12:36:17 +1300 Subject: [PATCH 06/15] Consolidate shard reassignment API --- src/swarms/FleetIdentity.sol | 71 +++++++++++++++++------------- test/FleetIdentity.t.sol | 84 ++++++++++++++++-------------------- 2 files changed, 78 insertions(+), 77 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index bd94325f..c9ff69a5 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -52,6 +52,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { error InsufficientBondForPromotion(); error TargetShardNotHigher(); error TargetShardNotLower(); + error TargetShardSameAsCurrent(); error InvalidCountryCode(); error InvalidAdminCode(); @@ -248,39 +249,17 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { _promote(tokenId, fleetShard[tokenId] + 1); } - /// @notice Promotes a fleet to a specific higher shard within its region. - function promote(uint256 tokenId, uint256 targetShard) external nonReentrant { - _promote(tokenId, targetShard); - } - - /// @notice Demotes a fleet to a lower shard within its region. Refunds bond difference. - function demote(uint256 tokenId, uint256 targetShard) external nonReentrant { - address tokenOwner = ownerOf(tokenId); - if (tokenOwner != msg.sender) revert NotTokenOwner(); - - uint32 region = fleetRegion[tokenId]; + /// @notice Moves a fleet to a different shard within its region. + /// If targetShard > current shard, promotes (pulls additional bond). + /// If targetShard < current shard, demotes (refunds bond difference). + function reassignShard(uint256 tokenId, uint256 targetShard) external nonReentrant { uint256 currentShard = fleetShard[tokenId]; - if (targetShard >= currentShard) revert TargetShardNotLower(); - if (_regionShardMembers[region][targetShard].length >= shardCapacity(region)) revert ShardFull(); - - uint256 currentBond = shardBond(currentShard); - uint256 targetBond = shardBond(targetShard); - uint256 refund = currentBond - targetBond; - - // Effects - _removeFromShard(tokenId, region, currentShard); - fleetShard[tokenId] = targetShard; - _regionShardMembers[region][targetShard].push(tokenId); - _indexInShard[tokenId] = _regionShardMembers[region][targetShard].length - 1; - - _trimShardCount(region); - - // Interaction - if (refund > 0) { - BOND_TOKEN.safeTransfer(tokenOwner, refund); + if (targetShard == currentShard) revert TargetShardSameAsCurrent(); + if (targetShard > currentShard) { + _promote(tokenId, targetShard); + } else { + _demote(tokenId, targetShard); } - - emit FleetDemoted(tokenId, currentShard, targetShard, refund); } // ══════════════════════════════════════════════ @@ -621,6 +600,36 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { emit FleetPromoted(tokenId, currentShard, targetShard, additionalBond); } + /// @dev Shared demotion logic. Refunds bond difference. + function _demote(uint256 tokenId, uint256 targetShard) internal { + address tokenOwner = ownerOf(tokenId); + if (tokenOwner != msg.sender) revert NotTokenOwner(); + + uint32 region = fleetRegion[tokenId]; + uint256 currentShard = fleetShard[tokenId]; + if (targetShard >= currentShard) revert TargetShardNotLower(); + if (_regionShardMembers[region][targetShard].length >= shardCapacity(region)) revert ShardFull(); + + uint256 currentBond = shardBond(currentShard); + uint256 targetBond = shardBond(targetShard); + uint256 refund = currentBond - targetBond; + + // Effects + _removeFromShard(tokenId, region, currentShard); + fleetShard[tokenId] = targetShard; + _regionShardMembers[region][targetShard].push(tokenId); + _indexInShard[tokenId] = _regionShardMembers[region][targetShard].length - 1; + + _trimShardCount(region); + + // Interaction + if (refund > 0) { + BOND_TOKEN.safeTransfer(tokenOwner, refund); + } + + emit FleetDemoted(tokenId, currentShard, targetShard, refund); + } + /// @dev Validates and prepares an explicit shard for registration. function _validateExplicitShard(uint32 region, uint256 targetShard) internal { if (targetShard >= MAX_SHARDS) revert MaxShardsReached(); diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 4911327a..968dcc72 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -434,12 +434,12 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(alice), balBefore - diff); } - function test_promote_specific_jumpsMultipleShards() public { + function test_reassignShard_promotesWhenTargetHigher() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(alice); - fleet.promote(tokenId, 3); + fleet.reassignShard(tokenId, 3); assertEq(fleet.fleetShard(tokenId), 3); assertEq(fleet.bonds(tokenId), fleet.shardBond(3)); @@ -467,17 +467,13 @@ contract FleetIdentityTest is Test { fleet.promote(tokenId); } - function test_RevertIf_promote_targetNotHigher() public { + function test_RevertIf_reassignShard_targetSameAsCurrent() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); vm.prank(alice); - vm.expectRevert(FleetIdentity.TargetShardNotHigher.selector); - fleet.promote(tokenId, 1); - - vm.prank(alice); - vm.expectRevert(FleetIdentity.TargetShardNotHigher.selector); - fleet.promote(tokenId, 2); + vm.expectRevert(FleetIdentity.TargetShardSameAsCurrent.selector); + fleet.reassignShard(tokenId, 2); } function test_RevertIf_promote_targetShardFull() public { @@ -494,29 +490,29 @@ contract FleetIdentityTest is Test { fleet.promote(tokenId); } - function test_RevertIf_promote_exceedsMaxShards() public { + function test_RevertIf_reassignShard_exceedsMaxShards() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1); vm.prank(alice); vm.expectRevert(FleetIdentity.MaxShardsReached.selector); - fleet.promote(tokenId, 50); + fleet.reassignShard(tokenId, 50); } - // --- demote --- + // --- reassignShard (demote direction) --- - function test_demote_movesToLowerShard() public { + function test_reassignShard_demotesWhenTargetLower() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, DE, 3); vm.prank(alice); - fleet.demote(tokenId, 1); + fleet.reassignShard(tokenId, 1); assertEq(fleet.fleetShard(tokenId), 1); assertEq(fleet.bonds(tokenId), fleet.shardBond(1)); } - function test_demote_refundsBondDifference() public { + function test_reassignShard_demoteRefundsBondDifference() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); @@ -524,12 +520,12 @@ contract FleetIdentityTest is Test { uint256 refund = fleet.shardBond(3) - fleet.shardBond(1); vm.prank(alice); - fleet.demote(tokenId, 1); + fleet.reassignShard(tokenId, 1); assertEq(bondToken.balanceOf(alice), balBefore + refund); } - function test_demote_emitsEvent() public { + function test_reassignShard_demoteEmitsEvent() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); uint256 refund = fleet.shardBond(3) - fleet.shardBond(1); @@ -538,42 +534,29 @@ contract FleetIdentityTest is Test { emit FleetDemoted(tokenId, 3, 1, refund); vm.prank(alice); - fleet.demote(tokenId, 1); + fleet.reassignShard(tokenId, 1); } - function test_demote_trimsShardCountWhenTopEmpties() public { + function test_reassignShard_demoteTrimsShardCountWhenTopEmpties() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); assertEq(fleet.regionShardCount(GLOBAL), 4); vm.prank(alice); - fleet.demote(tokenId, 0); + fleet.reassignShard(tokenId, 0); assertEq(fleet.regionShardCount(GLOBAL), 1); } - function test_RevertIf_demote_notOwner() public { + function test_RevertIf_reassignShard_demoteNotOwner() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); - fleet.demote(tokenId, 0); + fleet.reassignShard(tokenId, 0); } - function test_RevertIf_demote_targetNotLower() public { - vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); - - vm.prank(alice); - vm.expectRevert(FleetIdentity.TargetShardNotLower.selector); - fleet.demote(tokenId, 3); - - vm.prank(alice); - vm.expectRevert(FleetIdentity.TargetShardNotLower.selector); - fleet.demote(tokenId, 2); - } - - function test_RevertIf_demote_targetShardFull() public { + function test_RevertIf_reassignShard_demoteTargetShardFull() public { _registerNGlobal(alice, 4); vm.prank(bob); @@ -581,7 +564,16 @@ contract FleetIdentityTest is Test { vm.prank(bob); vm.expectRevert(FleetIdentity.ShardFull.selector); - fleet.demote(tokenId, 0); + fleet.reassignShard(tokenId, 0); + } + + function test_RevertIf_reassignShard_promoteNotOwner() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.reassignShard(tokenId, 3); } // --- burn --- @@ -957,16 +949,16 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(address(fleet)), 0); } - function test_bondAccounting_promoteAndDemoteRoundTrip() public { + function test_bondAccounting_reassignShardRoundTrip() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); uint256 balStart = bondToken.balanceOf(alice); vm.prank(alice); - fleet.promote(tokenId, 3); + fleet.reassignShard(tokenId, 3); vm.prank(alice); - fleet.demote(tokenId, 0); + fleet.reassignShard(tokenId, 0); assertEq(bondToken.balanceOf(alice), balStart); assertEq(fleet.bonds(tokenId), BASE_BOND); @@ -1168,7 +1160,7 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(address(fleet)), fleet.bonds(id2) + fleet.bonds(id3)); } - function test_invariant_contractBalanceAfterPromoteDemoteBurn() public { + function test_invariant_contractBalanceAfterReassignShardBurn() public { vm.prank(alice); uint256 id1 = fleet.registerFleetCountry(UUID_1, US); vm.prank(bob); @@ -1177,10 +1169,10 @@ contract FleetIdentityTest is Test { uint256 id3 = fleet.registerFleetGlobal(UUID_3); vm.prank(alice); - fleet.promote(id1, 3); + fleet.reassignShard(id1, 3); vm.prank(alice); - fleet.demote(id1, 1); + fleet.reassignShard(id1, 1); uint256 expected = fleet.bonds(id1) + fleet.bonds(id2) + fleet.bonds(id3); assertEq(bondToken.balanceOf(address(fleet)), expected); @@ -1273,7 +1265,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 usId = fleet.registerFleetCountry(UUID_2, US); vm.prank(alice); - fleet.promote(usId, 2); + fleet.reassignShard(usId, 2); // Admin US-CA: shard 0 (bond=100) vm.prank(alice); @@ -1364,13 +1356,13 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 countryId = fleet.registerFleetCountry(_uuid(8200), US); vm.prank(alice); - fleet.promote(countryId, 1); + fleet.reassignShard(countryId, 1); // Global: promote to shard 2 (bond=400) vm.prank(alice); uint256 globalId = fleet.registerFleetGlobal(_uuid(8300)); vm.prank(alice); - fleet.promote(globalId, 2); + fleet.reassignShard(globalId, 2); (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); // Step 1: global shard 2 (bond=400) → 1 member From 55fc8ab6419ff3b83abfc524515da0f8b8700877 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 13 Feb 2026 12:59:21 +1300 Subject: [PATCH 07/15] Refactor FleetIdentity to tiers --- src/swarms/FleetIdentity.sol | 402 +++++++++++++++---------------- test/FleetIdentity.t.sol | 444 +++++++++++++++++------------------ 2 files changed, 423 insertions(+), 423 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index c9ff69a5..61ad7397 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -10,7 +10,7 @@ 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 shards. + * secured by an ERC-20 bond organized into geometric tiers. * * @dev **Three-level geographic registration** * @@ -19,15 +19,15 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * - Country — regionKey = countryCode (ISO 3166-1 numeric, 1-999) * - Admin Area — regionKey = (countryCode << 12) | adminCode (>= 4096) * - * Each regionKey has its **own independent shard namespace** — shard indices + * 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 shards exist in other regions. + * BASE_BOND regardless of how many tiers exist in other regions. * - * Shard capacity varies by level: - * - Global: 4 members per shard - * - Country: 8 members per shard - * - Admin Area: 8 members per shard - * Shard K within a region requires bond = BASE_BOND * BOND_MULTIPLIER^K. + * 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. * * Scanner discovery uses a 3-level fallback: * 1. Admin area (most specific) @@ -47,12 +47,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // ────────────────────────────────────────────── error InvalidUUID(); error NotTokenOwner(); - error MaxShardsReached(); - error ShardFull(); + error MaxTiersReached(); + error TierFull(); error InsufficientBondForPromotion(); - error TargetShardNotHigher(); - error TargetShardNotLower(); - error TargetShardSameAsCurrent(); + error TargetTierNotHigher(); + error TargetTierNotLower(); + error TargetTierSameAsCurrent(); error InvalidCountryCode(); error InvalidAdminCode(); @@ -60,17 +60,17 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Constants & Immutables // ────────────────────────────────────────────── - /// @notice Maximum members per global shard. - uint256 public constant GLOBAL_SHARD_CAPACITY = 4; + /// @notice Maximum members per global tier. + uint256 public constant GLOBAL_TIER_CAPACITY = 4; - /// @notice Maximum members per country-level shard. - uint256 public constant COUNTRY_SHARD_CAPACITY = 8; + /// @notice Maximum members per country-level tier. + uint256 public constant COUNTRY_TIER_CAPACITY = 8; - /// @notice Maximum members per admin-area (local) shard. - uint256 public constant LOCAL_SHARD_CAPACITY = 8; + /// @notice Maximum members per admin-area (local) tier. + uint256 public constant LOCAL_TIER_CAPACITY = 8; - /// @notice Hard cap on shard count per region to bound gas costs. - uint256 public constant MAX_SHARDS = 50; + /// @notice Hard cap on tier count per region to bound gas costs. + uint256 public constant MAX_TIERS = 50; /// @notice Maximum UUIDs returned by buildScannerBundle. uint256 public constant SCANNER_BUNDLE_CAPACITY = 20; @@ -81,27 +81,27 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice The ERC-20 token used for bonds (immutable, e.g. NODL). IERC20 public immutable BOND_TOKEN; - /// @notice Base bond for shard 0 in any region. Shard K requires BASE_BOND * BOND_MULTIPLIER^K. + /// @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 shard tiers. + /// @notice Geometric multiplier between tiers. uint256 public immutable BOND_MULTIPLIER; // ────────────────────────────────────────────── - // Region-namespaced shard data + // Region-namespaced tier data // ────────────────────────────────────────────── - /// @notice regionKey -> number of shards opened in that region. - mapping(uint32 => uint256) public regionShardCount; + /// @notice regionKey -> number of tiers opened in that region. + mapping(uint32 => uint256) public regionTierCount; - /// @dev regionKey -> cached lower-bound hint for lowest open shard. + /// @dev regionKey -> cached lower-bound hint for lowest open tier. mapping(uint32 => uint256) internal _regionLowestHint; - /// @notice regionKey -> shardIndex -> list of token IDs. - mapping(uint32 => mapping(uint256 => uint256[])) internal _regionShardMembers; + /// @notice regionKey -> tierIndex -> list of token IDs. + mapping(uint32 => mapping(uint256 => uint256[])) internal _regionTierMembers; - /// @notice Token ID -> index within its shard's member array (for O(1) removal). - mapping(uint256 => uint256) internal _indexInShard; + /// @notice Token ID -> index within its tier's member array (for O(1) removal). + mapping(uint256 => uint256) internal _indexInTier; // ────────────────────────────────────────────── // Fleet data @@ -110,8 +110,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Token ID -> region key the fleet is registered in. mapping(uint256 => uint32) public fleetRegion; - /// @notice Token ID -> shard index (within its region) the fleet belongs to. - mapping(uint256 => uint256) public fleetShard; + /// @notice Token ID -> tier index (within its region) the fleet belongs to. + mapping(uint256 => uint256) public fleetTier; // ────────────────────────────────────────────── // On-chain region indexes @@ -137,13 +137,13 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { bytes16 indexed uuid, uint256 indexed tokenId, uint32 regionKey, - uint256 shardIndex, + uint256 tierIndex, uint256 bondAmount ); - event FleetPromoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 additionalBond); - event FleetDemoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 bondRefund); + 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 shardIndex + address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 tierIndex ); // ────────────────────────────────────────────── @@ -151,7 +151,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // ────────────────────────────────────────────── /// @param _bondToken Address of the ERC-20 token used for bonds. - /// @param _baseBond Base bond for shard 0 in any region. + /// @param _baseBond Base bond for tier 0 in any region. /// @param _bondMultiplier Multiplier between tiers (e.g. 2 = doubling). constructor(address _bondToken, uint256 _baseBond, uint256 _bondMultiplier) ERC721("Swarm Fleet Identity", "SFID") @@ -165,36 +165,36 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Registration: Global // ══════════════════════════════════════════════ - /// @notice Register a fleet globally (auto-assign shard). + /// @notice Register a fleet globally (auto-assign tier). function registerFleetGlobal(bytes16 uuid) external nonReentrant returns (uint256 tokenId) { if (uuid == bytes16(0)) revert InvalidUUID(); - uint256 shard = _openShard(GLOBAL_REGION); - tokenId = _register(uuid, GLOBAL_REGION, shard); + uint256 tier = _openTier(GLOBAL_REGION); + tokenId = _register(uuid, GLOBAL_REGION, tier); } - /// @notice Register a fleet globally into a specific shard. - function registerFleetGlobal(bytes16 uuid, uint256 targetShard) external nonReentrant returns (uint256 tokenId) { + /// @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(); - _validateExplicitShard(GLOBAL_REGION, targetShard); - tokenId = _register(uuid, GLOBAL_REGION, targetShard); + _validateExplicitTier(GLOBAL_REGION, targetTier); + tokenId = _register(uuid, GLOBAL_REGION, targetTier); } // ══════════════════════════════════════════════ // Registration: Country // ══════════════════════════════════════════════ - /// @notice Register a fleet under a country (auto-assign shard). + /// @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 shard = _openShard(regionKey); - tokenId = _register(uuid, regionKey, shard); + uint256 tier = _openTier(regionKey); + tokenId = _register(uuid, regionKey, tier); } - /// @notice Register a fleet under a country into a specific shard. - function registerFleetCountry(bytes16 uuid, uint16 countryCode, uint256 targetShard) + /// @notice Register a fleet under a country into a specific tier. + function registerFleetCountry(bytes16 uuid, uint16 countryCode, uint256 targetTier) external nonReentrant returns (uint256 tokenId) @@ -202,15 +202,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (uuid == bytes16(0)) revert InvalidUUID(); if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); uint32 regionKey = uint32(countryCode); - _validateExplicitShard(regionKey, targetShard); - tokenId = _register(uuid, regionKey, targetShard); + _validateExplicitTier(regionKey, targetTier); + tokenId = _register(uuid, regionKey, targetTier); } // ══════════════════════════════════════════════ // Registration: Admin Area (local) // ══════════════════════════════════════════════ - /// @notice Register a fleet under a country + admin area (auto-assign shard). + /// @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) @@ -222,12 +222,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); if (adminCode == 0 || adminCode > 4095) revert InvalidAdminCode(); uint32 regionKey = (uint32(countryCode) << 12) | uint32(adminCode); - uint256 shard = _openShard(regionKey); - tokenId = _register(uuid, regionKey, shard); + uint256 tier = _openTier(regionKey); + tokenId = _register(uuid, regionKey, tier); } - /// @notice Register a fleet under a country + admin area into a specific shard. - function registerFleetLocal(bytes16 uuid, uint16 countryCode, uint16 adminCode, uint256 targetShard) + /// @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) @@ -236,29 +236,29 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); if (adminCode == 0 || adminCode > 4095) revert InvalidAdminCode(); uint32 regionKey = (uint32(countryCode) << 12) | uint32(adminCode); - _validateExplicitShard(regionKey, targetShard); - tokenId = _register(uuid, regionKey, targetShard); + _validateExplicitTier(regionKey, targetTier); + tokenId = _register(uuid, regionKey, targetTier); } // ══════════════════════════════════════════════ // Promote / Demote (region-aware) // ══════════════════════════════════════════════ - /// @notice Promotes a fleet to the next shard within its region. + /// @notice Promotes a fleet to the next tier within its region. function promote(uint256 tokenId) external nonReentrant { - _promote(tokenId, fleetShard[tokenId] + 1); + _promote(tokenId, fleetTier[tokenId] + 1); } - /// @notice Moves a fleet to a different shard within its region. - /// If targetShard > current shard, promotes (pulls additional bond). - /// If targetShard < current shard, demotes (refunds bond difference). - function reassignShard(uint256 tokenId, uint256 targetShard) external nonReentrant { - uint256 currentShard = fleetShard[tokenId]; - if (targetShard == currentShard) revert TargetShardSameAsCurrent(); - if (targetShard > currentShard) { - _promote(tokenId, targetShard); + /// @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, targetShard); + _demote(tokenId, targetTier); } } @@ -266,23 +266,23 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Burn // ══════════════════════════════════════════════ - /// @notice Burns the fleet NFT and refunds the shard bond to the token owner. + /// @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 shard = fleetShard[tokenId]; - uint256 refund = shardBond(shard); + uint256 tier = fleetTier[tokenId]; + uint256 refund = tierBond(tier); // Effects - _removeFromShard(tokenId, region, shard); - delete fleetShard[tokenId]; + _removeFromTier(tokenId, region, tier); + delete fleetTier[tokenId]; delete fleetRegion[tokenId]; - delete _indexInShard[tokenId]; + delete _indexInTier[tokenId]; _burn(tokenId); - _trimShardCount(region); + _trimTierCount(region); _removeFromRegionIndex(region); // Interaction @@ -290,57 +290,57 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { BOND_TOKEN.safeTransfer(tokenOwner, refund); } - emit FleetBurned(tokenOwner, tokenId, refund, region, shard); + emit FleetBurned(tokenOwner, tokenId, refund, region, tier); } // ══════════════════════════════════════════════ - // Views: Bond & shard helpers + // Views: Bond & tier helpers // ══════════════════════════════════════════════ - /// @notice Bond required for shard K in any region = BASE_BOND * BOND_MULTIPLIER^K. - function shardBond(uint256 shard) public view returns (uint256) { - if (shard == 0) return BASE_BOND; + /// @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 < shard; i++) { + for (uint256 i = 0; i < tier; i++) { bond *= BOND_MULTIPLIER; } return bond; } - /// @notice Returns the shard capacity for a given region key. + /// @notice Returns the tier capacity for a given region key. /// Global = 4, Country = 8, Admin Area = 8. - function shardCapacity(uint32 regionKey) public pure returns (uint256) { - if (regionKey == GLOBAL_REGION) return GLOBAL_SHARD_CAPACITY; - if (regionKey <= 999) return COUNTRY_SHARD_CAPACITY; - return LOCAL_SHARD_CAPACITY; + 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 shard and its bond for a region. - function lowestOpenShard(uint32 regionKey) external view returns (uint256 shard, uint256 bond) { - shard = _findOpenShardView(regionKey); - bond = shardBond(shard); + /// @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 shard in a region, or 0 if none. - function highestActiveShard(uint32 regionKey) external view returns (uint256) { - uint256 sc = regionShardCount[regionKey]; + /// @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 shard of a region. - function shardMemberCount(uint32 regionKey, uint256 shard) external view returns (uint256) { - return _regionShardMembers[regionKey][shard].length; + /// @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 shard of a region. - function getShardMembers(uint32 regionKey, uint256 shard) external view returns (uint256[] memory) { - return _regionShardMembers[regionKey][shard]; + /// @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 shard of a region. - function getShardUUIDs(uint32 regionKey, uint256 shard) external view returns (bytes16[] memory uuids) { - uint256[] storage members = _regionShardMembers[regionKey][shard]; + /// @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])); @@ -355,77 +355,77 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @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 shardBond(fleetShard[tokenId]); + return tierBond(fleetTier[tokenId]); } // ══════════════════════════════════════════════ // Views: Scanner discovery // ══════════════════════════════════════════════ - /// @notice Returns the best shard for a scanner at a specific location. + /// @notice Returns the best tier for a scanner at a specific location. /// Fallback order: admin area -> country -> global. /// @return regionKey The region where fleets were found (0 = global). - /// @return shard The highest non-empty shard in that region. - /// @return members The token IDs in that shard. - function discoverBestShard(uint16 countryCode, uint16 adminCode) + /// @return tier The highest non-empty tier in that region. + /// @return members The token IDs in that tier. + function discoverBestTier(uint16 countryCode, uint16 adminCode) external view - returns (uint32 regionKey, uint256 shard, uint256[] memory members) + 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 = regionShardCount[regionKey]; + uint256 sc = regionTierCount[regionKey]; if (sc > 0) { - shard = sc - 1; - members = _regionShardMembers[regionKey][shard]; - return (regionKey, shard, members); + tier = sc - 1; + members = _regionTierMembers[regionKey][tier]; + return (regionKey, tier, members); } } // 2. Try country if (countryCode > 0) { regionKey = uint32(countryCode); - uint256 sc = regionShardCount[regionKey]; + uint256 sc = regionTierCount[regionKey]; if (sc > 0) { - shard = sc - 1; - members = _regionShardMembers[regionKey][shard]; - return (regionKey, shard, members); + tier = sc - 1; + members = _regionTierMembers[regionKey][tier]; + return (regionKey, tier, members); } } // 3. Global regionKey = GLOBAL_REGION; - uint256 sc = regionShardCount[GLOBAL_REGION]; + uint256 sc = regionTierCount[GLOBAL_REGION]; if (sc > 0) { - shard = sc - 1; - members = _regionShardMembers[GLOBAL_REGION][shard]; + tier = sc - 1; + members = _regionTierMembers[GLOBAL_REGION][tier]; } // else: all empty, returns (0, 0, []) } - /// @notice Returns active shard data at all three levels for a location. + /// @notice Returns active tier data at all three levels for a location. function discoverAllLevels(uint16 countryCode, uint16 adminCode) external view - returns (uint256 globalShardCount, uint256 countryShardCount, uint256 adminShardCount, uint32 adminRegionKey) + returns (uint256 globalTierCount, uint256 countryTierCount, uint256 adminTierCount, uint32 adminRegion) { - globalShardCount = regionShardCount[GLOBAL_REGION]; + globalTierCount = regionTierCount[GLOBAL_REGION]; if (countryCode > 0) { - countryShardCount = regionShardCount[uint32(countryCode)]; + countryTierCount = regionTierCount[uint32(countryCode)]; } if (countryCode > 0 && adminCode > 0) { - adminRegionKey = (uint32(countryCode) << 12) | uint32(adminCode); - adminShardCount = regionShardCount[adminRegionKey]; + adminRegion = (uint32(countryCode) << 12) | uint32(adminCode); + adminTierCount = regionTierCount[adminRegion]; } } /// @notice Builds a priority-ordered bundle of up to SCANNER_BUNDLE_CAPACITY (20) - /// UUIDs for a scanner, merging the highest-bonded shards across admin-area, + /// UUIDs for a scanner, merging the highest-bonded tiers across admin-area, /// country, and global levels. /// /// **Algorithm** - /// Maintains a cursor (highest remaining shard) for each of the three + /// Maintains a cursor (highest remaining tier) for each of the three /// levels. At each step: - /// 1. Compute the bond for each level's cursor shard. + /// 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). @@ -443,7 +443,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { { uuids = new bytes16[](SCANNER_BUNDLE_CAPACITY); - // Resolve region keys and shard counts for each level. + // 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; @@ -451,7 +451,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Level 0: admin area if (countryCode > 0 && adminCode > 0) { keys[0] = (uint32(countryCode) << 12) | uint32(adminCode); - uint256 sc = regionShardCount[keys[0]]; + uint256 sc = regionTierCount[keys[0]]; cursors[0] = sc > 0 ? int256(sc) - 1 : int256(-1); } else { cursors[0] = -1; @@ -460,7 +460,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Level 1: country if (countryCode > 0) { keys[1] = uint32(countryCode); - uint256 sc = regionShardCount[keys[1]]; + uint256 sc = regionTierCount[keys[1]]; cursors[1] = sc > 0 ? int256(sc) - 1 : int256(-1); } else { cursors[1] = -1; @@ -469,7 +469,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Level 2: global { keys[2] = GLOBAL_REGION; - uint256 sc = regionShardCount[GLOBAL_REGION]; + uint256 sc = regionTierCount[GLOBAL_REGION]; cursors[2] = sc > 0 ? int256(sc) - 1 : int256(-1); } @@ -480,7 +480,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { for (uint256 lvl = 0; lvl < 3; lvl++) { if (cursors[lvl] < 0) continue; - uint256 b = shardBond(uint256(cursors[lvl])); + uint256 b = tierBond(uint256(cursors[lvl])); if (!anyActive || b > maxBond) { maxBond = b; anyActive = true; @@ -492,9 +492,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Collect members from every level whose cursor bond == maxBond. for (uint256 lvl = 0; lvl < 3; lvl++) { if (cursors[lvl] < 0) continue; - if (shardBond(uint256(cursors[lvl])) != maxBond) continue; + if (tierBond(uint256(cursors[lvl])) != maxBond) continue; - uint256[] storage members = _regionShardMembers[keys[lvl]][uint256(cursors[lvl])]; + uint256[] storage members = _regionTierMembers[keys[lvl]][uint256(cursors[lvl])]; uint256 mLen = members.length; for (uint256 m = 0; m < mLen && count < SCANNER_BUNDLE_CAPACITY; m++) { @@ -546,15 +546,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // ══════════════════════════════════════════════ /// @dev Shared registration logic. - function _register(bytes16 uuid, uint32 region, uint256 shard) internal returns (uint256 tokenId) { - uint256 bond = shardBond(shard); + function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { + uint256 bond = tierBond(tier); tokenId = uint256(uint128(uuid)); // Effects fleetRegion[tokenId] = region; - fleetShard[tokenId] = shard; - _regionShardMembers[region][shard].push(tokenId); - _indexInShard[tokenId] = _regionShardMembers[region][shard].length - 1; + fleetTier[tokenId] = tier; + _regionTierMembers[region][tier].push(tokenId); + _indexInTier[tokenId] = _regionTierMembers[region][tier].length - 1; _addToRegionIndex(region); _mint(msg.sender, tokenId); @@ -564,32 +564,32 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { BOND_TOKEN.safeTransferFrom(msg.sender, address(this), bond); } - emit FleetRegistered(msg.sender, uuid, tokenId, region, shard, bond); + emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, bond); } /// @dev Shared promotion logic. - function _promote(uint256 tokenId, uint256 targetShard) internal { + function _promote(uint256 tokenId, uint256 targetTier) internal { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); uint32 region = fleetRegion[tokenId]; - uint256 currentShard = fleetShard[tokenId]; - if (targetShard <= currentShard) revert TargetShardNotHigher(); - if (targetShard >= MAX_SHARDS) revert MaxShardsReached(); - if (_regionShardMembers[region][targetShard].length >= shardCapacity(region)) revert ShardFull(); + 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 = shardBond(currentShard); - uint256 targetBond = shardBond(targetShard); + uint256 currentBond = tierBond(currentTier); + uint256 targetBond = tierBond(targetTier); uint256 additionalBond = targetBond - currentBond; // Effects - _removeFromShard(tokenId, region, currentShard); - fleetShard[tokenId] = targetShard; - _regionShardMembers[region][targetShard].push(tokenId); - _indexInShard[tokenId] = _regionShardMembers[region][targetShard].length - 1; + _removeFromTier(tokenId, region, currentTier); + fleetTier[tokenId] = targetTier; + _regionTierMembers[region][targetTier].push(tokenId); + _indexInTier[tokenId] = _regionTierMembers[region][targetTier].length - 1; - if (targetShard >= regionShardCount[region]) { - regionShardCount[region] = targetShard + 1; + if (targetTier >= regionTierCount[region]) { + regionTierCount[region] = targetTier + 1; } // Interaction @@ -597,102 +597,102 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { BOND_TOKEN.safeTransferFrom(tokenOwner, address(this), additionalBond); } - emit FleetPromoted(tokenId, currentShard, targetShard, additionalBond); + emit FleetPromoted(tokenId, currentTier, targetTier, additionalBond); } /// @dev Shared demotion logic. Refunds bond difference. - function _demote(uint256 tokenId, uint256 targetShard) internal { + function _demote(uint256 tokenId, uint256 targetTier) internal { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); uint32 region = fleetRegion[tokenId]; - uint256 currentShard = fleetShard[tokenId]; - if (targetShard >= currentShard) revert TargetShardNotLower(); - if (_regionShardMembers[region][targetShard].length >= shardCapacity(region)) revert ShardFull(); + uint256 currentTier = fleetTier[tokenId]; + if (targetTier >= currentTier) revert TargetTierNotLower(); + if (_regionTierMembers[region][targetTier].length >= tierCapacity(region)) revert TierFull(); - uint256 currentBond = shardBond(currentShard); - uint256 targetBond = shardBond(targetShard); + uint256 currentBond = tierBond(currentTier); + uint256 targetBond = tierBond(targetTier); uint256 refund = currentBond - targetBond; // Effects - _removeFromShard(tokenId, region, currentShard); - fleetShard[tokenId] = targetShard; - _regionShardMembers[region][targetShard].push(tokenId); - _indexInShard[tokenId] = _regionShardMembers[region][targetShard].length - 1; + _removeFromTier(tokenId, region, currentTier); + fleetTier[tokenId] = targetTier; + _regionTierMembers[region][targetTier].push(tokenId); + _indexInTier[tokenId] = _regionTierMembers[region][targetTier].length - 1; - _trimShardCount(region); + _trimTierCount(region); // Interaction if (refund > 0) { BOND_TOKEN.safeTransfer(tokenOwner, refund); } - emit FleetDemoted(tokenId, currentShard, targetShard, refund); + emit FleetDemoted(tokenId, currentTier, targetTier, refund); } - /// @dev Validates and prepares an explicit shard for registration. - function _validateExplicitShard(uint32 region, uint256 targetShard) internal { - if (targetShard >= MAX_SHARDS) revert MaxShardsReached(); - if (_regionShardMembers[region][targetShard].length >= shardCapacity(region)) revert ShardFull(); - if (targetShard >= regionShardCount[region]) { - regionShardCount[region] = targetShard + 1; + /// @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 shard within a region, opening a new one if needed. - function _openShard(uint32 region) internal returns (uint256) { - uint256 sc = regionShardCount[region]; - uint256 cap = shardCapacity(region); + /// @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 (_regionShardMembers[region][i].length < cap) { + if (_regionTierMembers[region][i].length < cap) { _regionLowestHint[region] = i; return i; } } - if (sc >= MAX_SHARDS) revert MaxShardsReached(); - regionShardCount[region] = sc + 1; + if (sc >= MAX_TIERS) revert MaxTiersReached(); + regionTierCount[region] = sc + 1; _regionLowestHint[region] = sc; return sc; } - /// @dev View-only version of _openShard. - function _findOpenShardView(uint32 region) internal view returns (uint256) { - uint256 sc = regionShardCount[region]; - uint256 cap = shardCapacity(region); + /// @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 (_regionShardMembers[region][i].length < cap) return i; + if (_regionTierMembers[region][i].length < cap) return i; } - if (sc >= MAX_SHARDS) revert MaxShardsReached(); + if (sc >= MAX_TIERS) revert MaxTiersReached(); return sc; } - /// @dev Swap-and-pop removal from a region's shard member array. - function _removeFromShard(uint256 tokenId, uint32 region, uint256 shard) internal { - uint256[] storage members = _regionShardMembers[region][shard]; - uint256 idx = _indexInShard[tokenId]; + /// @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; - _indexInShard[lastTokenId] = idx; + _indexInTier[lastTokenId] = idx; } members.pop(); - if (shard < _regionLowestHint[region]) { - _regionLowestHint[region] = shard; + if (tier < _regionLowestHint[region]) { + _regionLowestHint[region] = tier; } } - /// @dev Shrinks regionShardCount so the top shard is always non-empty. - function _trimShardCount(uint32 region) internal { - uint256 sc = regionShardCount[region]; - while (sc > 0 && _regionShardMembers[region][sc - 1].length == 0) { + /// @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--; } - regionShardCount[region] = sc; + regionTierCount[region] = sc; } // -- Region index maintenance -- @@ -719,7 +719,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Removes a region from the index set if the region is now completely empty. function _removeFromRegionIndex(uint32 region) internal { - if (regionShardCount[region] > 0) return; // still has fleets + if (regionTierCount[region] > 0) return; // still has fleets if (region == GLOBAL_REGION) { globalActive = false; diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 968dcc72..c7a245b5 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -64,13 +64,13 @@ contract FleetIdentityTest is Test { bytes16 indexed uuid, uint256 indexed tokenId, uint32 regionKey, - uint256 shardIndex, + uint256 tierIndex, uint256 bondAmount ); - event FleetPromoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 additionalBond); - event FleetDemoted(uint256 indexed tokenId, uint256 fromShard, uint256 toShard, uint256 bondRefund); + 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 shardIndex + address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 tierIndex ); function setUp() public { @@ -155,36 +155,36 @@ contract FleetIdentityTest is Test { } function test_constructor_constants() public view { - assertEq(fleet.GLOBAL_SHARD_CAPACITY(), 4); - assertEq(fleet.COUNTRY_SHARD_CAPACITY(), 8); - assertEq(fleet.LOCAL_SHARD_CAPACITY(), 8); - assertEq(fleet.MAX_SHARDS(), 50); + assertEq(fleet.GLOBAL_TIER_CAPACITY(), 4); + assertEq(fleet.COUNTRY_TIER_CAPACITY(), 8); + assertEq(fleet.LOCAL_TIER_CAPACITY(), 8); + assertEq(fleet.MAX_TIERS(), 50); assertEq(fleet.SCANNER_BUNDLE_CAPACITY(), 20); } - function test_shardCapacity_perLevel() public view { - assertEq(fleet.shardCapacity(GLOBAL), 4); - assertEq(fleet.shardCapacity(_regionUS()), 8); - assertEq(fleet.shardCapacity(_regionUSCA()), 8); + function test_tierCapacity_perLevel() public view { + assertEq(fleet.tierCapacity(GLOBAL), 4); + assertEq(fleet.tierCapacity(_regionUS()), 8); + assertEq(fleet.tierCapacity(_regionUSCA()), 8); } - // --- shardBond --- + // --- tierBond --- - function test_shardBond_shard0() public view { - assertEq(fleet.shardBond(0), BASE_BOND); + function test_tierBond_tier0() public view { + assertEq(fleet.tierBond(0), BASE_BOND); } - function test_shardBond_shard1() public view { - assertEq(fleet.shardBond(1), BASE_BOND * MULTIPLIER); + function test_tierBond_tier1() public view { + assertEq(fleet.tierBond(1), BASE_BOND * MULTIPLIER); } - function test_shardBond_shard2() public view { - assertEq(fleet.shardBond(2), BASE_BOND * MULTIPLIER * MULTIPLIER); + function test_tierBond_tier2() public view { + assertEq(fleet.tierBond(2), BASE_BOND * MULTIPLIER * MULTIPLIER); } - function test_shardBond_geometricProgression() public view { + function test_tierBond_geometricProgression() public view { for (uint256 i = 1; i <= 5; i++) { - assertEq(fleet.shardBond(i), fleet.shardBond(i - 1) * MULTIPLIER); + assertEq(fleet.tierBond(i), fleet.tierBond(i - 1) * MULTIPLIER); } } @@ -198,7 +198,7 @@ contract FleetIdentityTest is Test { assertEq(tokenId, uint256(uint128(UUID_1))); assertEq(fleet.bonds(tokenId), BASE_BOND); assertEq(fleet.fleetRegion(tokenId), GLOBAL); - assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.fleetTier(tokenId), 0); assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND); } @@ -227,44 +227,44 @@ contract FleetIdentityTest is Test { fleet.registerFleetGlobal(UUID_1); } - // --- registerFleetGlobal explicit shard --- + // --- registerFleetGlobal explicit tier --- - function test_registerFleetGlobal_explicit_joinsSpecifiedShard() public { + function test_registerFleetGlobal_explicit_joinsSpecifiedTier() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); - assertEq(fleet.fleetShard(tokenId), 2); + assertEq(fleet.fleetTier(tokenId), 2); assertEq(fleet.fleetRegion(tokenId), GLOBAL); - assertEq(fleet.bonds(tokenId), fleet.shardBond(2)); - assertEq(fleet.shardMemberCount(GLOBAL, 2), 1); - assertEq(fleet.regionShardCount(GLOBAL), 3); + assertEq(fleet.bonds(tokenId), fleet.tierBond(2)); + assertEq(fleet.tierMemberCount(GLOBAL, 2), 1); + assertEq(fleet.regionTierCount(GLOBAL), 3); } - function test_RevertIf_registerFleetGlobal_explicit_exceedsMaxShards() public { + function test_RevertIf_registerFleetGlobal_explicit_exceedsMaxTiers() public { vm.prank(alice); - vm.expectRevert(FleetIdentity.MaxShardsReached.selector); + vm.expectRevert(FleetIdentity.MaxTiersReached.selector); fleet.registerFleetGlobal(UUID_1, 50); } // --- registerFleetCountry --- - function test_registerFleetCountry_auto_setsRegionAndShard() public { + function test_registerFleetCountry_auto_setsRegionAndTier() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); assertEq(fleet.fleetRegion(tokenId), _regionUS()); - assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND); - assertEq(fleet.regionShardCount(_regionUS()), 1); + assertEq(fleet.regionTierCount(_regionUS()), 1); } - function test_registerFleetCountry_explicit_shard() public { + function test_registerFleetCountry_explicit_tier() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 3); - assertEq(fleet.fleetShard(tokenId), 3); - assertEq(fleet.bonds(tokenId), fleet.shardBond(3)); - assertEq(fleet.regionShardCount(_regionUS()), 4); + assertEq(fleet.fleetTier(tokenId), 3); + assertEq(fleet.bonds(tokenId), fleet.tierBond(3)); + assertEq(fleet.regionTierCount(_regionUS()), 4); } function test_RevertIf_registerFleetCountry_invalidCode_zero() public { @@ -281,21 +281,21 @@ contract FleetIdentityTest is Test { // --- registerFleetLocal --- - function test_registerFleetLocal_auto_setsRegionAndShard() public { + 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.fleetShard(tokenId), 0); + assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND); } - function test_registerFleetLocal_explicit_shard() public { + function test_registerFleetLocal_explicit_tier() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); - assertEq(fleet.fleetShard(tokenId), 2); - assertEq(fleet.bonds(tokenId), fleet.shardBond(2)); + assertEq(fleet.fleetTier(tokenId), 2); + assertEq(fleet.bonds(tokenId), fleet.tierBond(2)); } function test_RevertIf_registerFleetLocal_invalidCountry() public { @@ -316,9 +316,9 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(UUID_1, US, 4096); } - // --- Per-region independent shard indexing (KEY REQUIREMENT) --- + // --- Per-region independent tier indexing (KEY REQUIREMENT) --- - function test_perRegionShards_firstFleetInEveryRegionPaysSameBond() public { + function test_perRegionTiers_firstFleetInEveryRegionPaysSameBond() public { vm.prank(alice); uint256 g1 = fleet.registerFleetGlobal(UUID_1); vm.prank(alice); @@ -326,99 +326,99 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - assertEq(fleet.fleetShard(g1), 0); - assertEq(fleet.fleetShard(c1), 0); - assertEq(fleet.fleetShard(l1), 0); + 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_perRegionShards_fillOneRegionDoesNotAffectOthers() public { + function test_perRegionTiers_fillOneRegionDoesNotAffectOthers() public { _registerNGlobal(alice, 4); - assertEq(fleet.regionShardCount(GLOBAL), 1); - assertEq(fleet.shardMemberCount(GLOBAL, 0), 4); + assertEq(fleet.regionTierCount(GLOBAL), 1); + assertEq(fleet.tierMemberCount(GLOBAL, 0), 4); vm.prank(bob); uint256 g21 = fleet.registerFleetGlobal(_uuid(100)); - assertEq(fleet.fleetShard(g21), 1); + assertEq(fleet.fleetTier(g21), 1); assertEq(fleet.bonds(g21), BASE_BOND * MULTIPLIER); vm.prank(bob); uint256 us1 = fleet.registerFleetCountry(_uuid(200), US); - assertEq(fleet.fleetShard(us1), 0); + assertEq(fleet.fleetTier(us1), 0); assertEq(fleet.bonds(us1), BASE_BOND); - assertEq(fleet.regionShardCount(_regionUS()), 1); + assertEq(fleet.regionTierCount(_regionUS()), 1); vm.prank(bob); uint256 usca1 = fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA); - assertEq(fleet.fleetShard(usca1), 0); + assertEq(fleet.fleetTier(usca1), 0); assertEq(fleet.bonds(usca1), BASE_BOND); } - function test_perRegionShards_twoCountriesIndependent() public { + function test_perRegionTiers_twoCountriesIndependent() public { _registerNCountry(alice, US, 8, 0); - assertEq(fleet.shardMemberCount(_regionUS(), 0), 8); + assertEq(fleet.tierMemberCount(_regionUS(), 0), 8); vm.prank(bob); uint256 us21 = fleet.registerFleetCountry(_uuid(500), US); - assertEq(fleet.fleetShard(us21), 1); + assertEq(fleet.fleetTier(us21), 1); assertEq(fleet.bonds(us21), BASE_BOND * MULTIPLIER); vm.prank(bob); uint256 de1 = fleet.registerFleetCountry(_uuid(600), DE); - assertEq(fleet.fleetShard(de1), 0); + assertEq(fleet.fleetTier(de1), 0); assertEq(fleet.bonds(de1), BASE_BOND); } - function test_perRegionShards_twoAdminAreasIndependent() public { + function test_perRegionTiers_twoAdminAreasIndependent() public { _registerNLocal(alice, US, ADMIN_CA, 8, 0); - assertEq(fleet.shardMemberCount(_regionUSCA(), 0), 8); + assertEq(fleet.tierMemberCount(_regionUSCA(), 0), 8); vm.prank(bob); uint256 ny1 = fleet.registerFleetLocal(_uuid(500), US, ADMIN_NY); - assertEq(fleet.fleetShard(ny1), 0); + assertEq(fleet.fleetTier(ny1), 0); assertEq(fleet.bonds(ny1), BASE_BOND); } - // --- Auto-assign shard logic --- + // --- Auto-assign tier logic --- - function test_autoAssign_fillsShard0BeforeOpeningShard1() public { + function test_autoAssign_fillsTier0BeforeOpeningTier1() public { _registerNGlobal(alice, 4); - assertEq(fleet.regionShardCount(GLOBAL), 1); + assertEq(fleet.regionTierCount(GLOBAL), 1); vm.prank(bob); uint256 id5 = fleet.registerFleetGlobal(_uuid(20)); - assertEq(fleet.fleetShard(id5), 1); - assertEq(fleet.regionShardCount(GLOBAL), 2); + assertEq(fleet.fleetTier(id5), 1); + assertEq(fleet.regionTierCount(GLOBAL), 2); } - function test_autoAssign_backfillsShard0WhenSlotOpens() public { + function test_autoAssign_backfillsTier0WhenSlotOpens() public { uint256[] memory ids = _registerNGlobal(alice, 4); vm.prank(alice); fleet.burn(ids[2]); - assertEq(fleet.shardMemberCount(GLOBAL, 0), 3); + assertEq(fleet.tierMemberCount(GLOBAL, 0), 3); vm.prank(bob); uint256 newId = fleet.registerFleetGlobal(_uuid(100)); - assertEq(fleet.fleetShard(newId), 0); - assertEq(fleet.shardMemberCount(GLOBAL, 0), 4); + assertEq(fleet.fleetTier(newId), 0); + assertEq(fleet.tierMemberCount(GLOBAL, 0), 4); } // --- promote --- - function test_promote_next_movesToNextShardInRegion() public { + function test_promote_next_movesToNextTierInRegion() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); vm.prank(alice); fleet.promote(tokenId); - assertEq(fleet.fleetShard(tokenId), 1); + assertEq(fleet.fleetTier(tokenId), 1); assertEq(fleet.fleetRegion(tokenId), _regionUS()); - assertEq(fleet.bonds(tokenId), fleet.shardBond(1)); + assertEq(fleet.bonds(tokenId), fleet.tierBond(1)); } function test_promote_next_pullsBondDifference() public { @@ -426,7 +426,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetGlobal(UUID_1); uint256 balBefore = bondToken.balanceOf(alice); - uint256 diff = fleet.shardBond(1) - fleet.shardBond(0); + uint256 diff = fleet.tierBond(1) - fleet.tierBond(0); vm.prank(alice); fleet.promote(tokenId); @@ -434,22 +434,22 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(alice), balBefore - diff); } - function test_reassignShard_promotesWhenTargetHigher() public { + function test_reassignTier_promotesWhenTargetHigher() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(alice); - fleet.reassignShard(tokenId, 3); + fleet.reassignTier(tokenId, 3); - assertEq(fleet.fleetShard(tokenId), 3); - assertEq(fleet.bonds(tokenId), fleet.shardBond(3)); - assertEq(fleet.regionShardCount(_regionUSCA()), 4); + 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.shardBond(1) - fleet.shardBond(0); + uint256 diff = fleet.tierBond(1) - fleet.tierBond(0); vm.expectEmit(true, true, true, true); emit FleetPromoted(tokenId, 0, 1, diff); @@ -467,16 +467,16 @@ contract FleetIdentityTest is Test { fleet.promote(tokenId); } - function test_RevertIf_reassignShard_targetSameAsCurrent() public { + function test_RevertIf_reassignTier_targetSameAsCurrent() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); vm.prank(alice); - vm.expectRevert(FleetIdentity.TargetShardSameAsCurrent.selector); - fleet.reassignShard(tokenId, 2); + vm.expectRevert(FleetIdentity.TargetTierSameAsCurrent.selector); + fleet.reassignTier(tokenId, 2); } - function test_RevertIf_promote_targetShardFull() public { + function test_RevertIf_promote_targetTierFull() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1); @@ -486,99 +486,99 @@ contract FleetIdentityTest is Test { } vm.prank(alice); - vm.expectRevert(FleetIdentity.ShardFull.selector); + vm.expectRevert(FleetIdentity.TierFull.selector); fleet.promote(tokenId); } - function test_RevertIf_reassignShard_exceedsMaxShards() public { + function test_RevertIf_reassignTier_exceedsMaxTiers() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1); vm.prank(alice); - vm.expectRevert(FleetIdentity.MaxShardsReached.selector); - fleet.reassignShard(tokenId, 50); + vm.expectRevert(FleetIdentity.MaxTiersReached.selector); + fleet.reassignTier(tokenId, 50); } - // --- reassignShard (demote direction) --- + // --- reassignTier (demote direction) --- - function test_reassignShard_demotesWhenTargetLower() public { + function test_reassignTier_demotesWhenTargetLower() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, DE, 3); vm.prank(alice); - fleet.reassignShard(tokenId, 1); + fleet.reassignTier(tokenId, 1); - assertEq(fleet.fleetShard(tokenId), 1); - assertEq(fleet.bonds(tokenId), fleet.shardBond(1)); + assertEq(fleet.fleetTier(tokenId), 1); + assertEq(fleet.bonds(tokenId), fleet.tierBond(1)); } - function test_reassignShard_demoteRefundsBondDifference() public { + function test_reassignTier_demoteRefundsBondDifference() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); uint256 balBefore = bondToken.balanceOf(alice); - uint256 refund = fleet.shardBond(3) - fleet.shardBond(1); + uint256 refund = fleet.tierBond(3) - fleet.tierBond(1); vm.prank(alice); - fleet.reassignShard(tokenId, 1); + fleet.reassignTier(tokenId, 1); assertEq(bondToken.balanceOf(alice), balBefore + refund); } - function test_reassignShard_demoteEmitsEvent() public { + function test_reassignTier_demoteEmitsEvent() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); - uint256 refund = fleet.shardBond(3) - fleet.shardBond(1); + uint256 refund = fleet.tierBond(3) - fleet.tierBond(1); vm.expectEmit(true, true, true, true); emit FleetDemoted(tokenId, 3, 1, refund); vm.prank(alice); - fleet.reassignShard(tokenId, 1); + fleet.reassignTier(tokenId, 1); } - function test_reassignShard_demoteTrimsShardCountWhenTopEmpties() public { + function test_reassignTier_demoteTrimsTierCountWhenTopEmpties() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); - assertEq(fleet.regionShardCount(GLOBAL), 4); + assertEq(fleet.regionTierCount(GLOBAL), 4); vm.prank(alice); - fleet.reassignShard(tokenId, 0); - assertEq(fleet.regionShardCount(GLOBAL), 1); + fleet.reassignTier(tokenId, 0); + assertEq(fleet.regionTierCount(GLOBAL), 1); } - function test_RevertIf_reassignShard_demoteNotOwner() public { + 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.reassignShard(tokenId, 0); + fleet.reassignTier(tokenId, 0); } - function test_RevertIf_reassignShard_demoteTargetShardFull() public { + 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.ShardFull.selector); - fleet.reassignShard(tokenId, 0); + vm.expectRevert(FleetIdentity.TierFull.selector); + fleet.reassignTier(tokenId, 0); } - function test_RevertIf_reassignShard_promoteNotOwner() public { + function test_RevertIf_reassignTier_promoteNotOwner() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); - fleet.reassignShard(tokenId, 3); + fleet.reassignTier(tokenId, 3); } // --- burn --- - function test_burn_refundsShardBond() public { + function test_burn_refundsTierBond() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1); uint256 balBefore = bondToken.balanceOf(alice); @@ -602,14 +602,14 @@ contract FleetIdentityTest is Test { fleet.burn(tokenId); } - function test_burn_trimsShardCount() public { + function test_burn_trimsTierCount() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 3); - assertEq(fleet.regionShardCount(_regionUS()), 4); + assertEq(fleet.regionTierCount(_regionUS()), 4); vm.prank(alice); fleet.burn(tokenId); - assertEq(fleet.regionShardCount(_regionUS()), 0); + assertEq(fleet.regionTierCount(_regionUS()), 0); } function test_burn_allowsReregistration() public { @@ -634,90 +634,90 @@ contract FleetIdentityTest is Test { fleet.burn(tokenId); } - // --- lowestOpenShard --- + // --- lowestOpenTier --- - function test_lowestOpenShard_initiallyZeroForAnyRegion() public view { - (uint256 shard, uint256 bond) = fleet.lowestOpenShard(GLOBAL); - assertEq(shard, 0); + function test_lowestOpenTier_initiallyZeroForAnyRegion() public view { + (uint256 tier, uint256 bond) = fleet.lowestOpenTier(GLOBAL); + assertEq(tier, 0); assertEq(bond, BASE_BOND); - (shard, bond) = fleet.lowestOpenShard(_regionUS()); - assertEq(shard, 0); + (tier, bond) = fleet.lowestOpenTier(_regionUS()); + assertEq(tier, 0); assertEq(bond, BASE_BOND); } - function test_lowestOpenShard_perRegionAfterFilling() public { + function test_lowestOpenTier_perRegionAfterFilling() public { _registerNGlobal(alice, 4); - (uint256 gShard, uint256 gBond) = fleet.lowestOpenShard(GLOBAL); - assertEq(gShard, 1); + (uint256 gTier, uint256 gBond) = fleet.lowestOpenTier(GLOBAL); + assertEq(gTier, 1); assertEq(gBond, BASE_BOND * MULTIPLIER); - (uint256 usShard, uint256 usBond) = fleet.lowestOpenShard(_regionUS()); - assertEq(usShard, 0); + (uint256 usTier, uint256 usBond) = fleet.lowestOpenTier(_regionUS()); + assertEq(usTier, 0); assertEq(usBond, BASE_BOND); } - // --- highestActiveShard --- + // --- highestActiveTier --- - function test_highestActiveShard_noFleets() public view { - assertEq(fleet.highestActiveShard(GLOBAL), 0); - assertEq(fleet.highestActiveShard(_regionUS()), 0); + function test_highestActiveTier_noFleets() public view { + assertEq(fleet.highestActiveTier(GLOBAL), 0); + assertEq(fleet.highestActiveTier(_regionUS()), 0); } - function test_highestActiveShard_afterRegistrations() public { + function test_highestActiveTier_afterRegistrations() public { vm.prank(alice); fleet.registerFleetGlobal(UUID_1, 3); - assertEq(fleet.highestActiveShard(GLOBAL), 3); + assertEq(fleet.highestActiveTier(GLOBAL), 3); - assertEq(fleet.highestActiveShard(_regionUS()), 0); + assertEq(fleet.highestActiveTier(_regionUS()), 0); } // --- Scanner helpers --- - function test_shardMemberCount_perRegion() public { + function test_tierMemberCount_perRegion() public { _registerNGlobal(alice, 3); _registerNCountry(bob, US, 5, 100); - assertEq(fleet.shardMemberCount(GLOBAL, 0), 3); - assertEq(fleet.shardMemberCount(_regionUS(), 0), 5); + assertEq(fleet.tierMemberCount(GLOBAL, 0), 3); + assertEq(fleet.tierMemberCount(_regionUS(), 0), 5); } - function test_getShardMembers_perRegion() public { + 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.getShardMembers(GLOBAL, 0); + uint256[] memory gMembers = fleet.getTierMembers(GLOBAL, 0); assertEq(gMembers.length, 1); assertEq(gMembers[0], gId); - uint256[] memory usMembers = fleet.getShardMembers(_regionUS(), 0); + uint256[] memory usMembers = fleet.getTierMembers(_regionUS(), 0); assertEq(usMembers.length, 1); assertEq(usMembers[0], usId); } - function test_getShardUUIDs_perRegion() public { + function test_getTierUUIDs_perRegion() public { vm.prank(alice); fleet.registerFleetGlobal(UUID_1); vm.prank(bob); fleet.registerFleetCountry(UUID_2, US); - bytes16[] memory gUUIDs = fleet.getShardUUIDs(GLOBAL, 0); + bytes16[] memory gUUIDs = fleet.getTierUUIDs(GLOBAL, 0); assertEq(gUUIDs.length, 1); assertEq(gUUIDs[0], UUID_1); - bytes16[] memory usUUIDs = fleet.getShardUUIDs(_regionUS(), 0); + bytes16[] memory usUUIDs = fleet.getTierUUIDs(_regionUS(), 0); assertEq(usUUIDs.length, 1); assertEq(usUUIDs[0], UUID_2); } - // --- discoverBestShard --- + // --- discoverBestTier --- - function test_discoverBestShard_prefersAdminArea() public { + function test_discoverBestTier_prefersAdminArea() public { vm.prank(alice); fleet.registerFleetGlobal(UUID_1); vm.prank(bob); @@ -725,49 +725,49 @@ contract FleetIdentityTest is Test { vm.prank(carol); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - (uint32 rk, uint256 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverBestTier(US, ADMIN_CA); assertEq(rk, _regionUSCA()); - assertEq(shard, 0); + assertEq(tier, 0); assertEq(members.length, 1); } - function test_discoverBestShard_fallsBackToCountry() public { + function test_discoverBestTier_fallsBackToCountry() public { vm.prank(alice); fleet.registerFleetGlobal(UUID_1); vm.prank(bob); fleet.registerFleetCountry(UUID_2, US); - (uint32 rk, uint256 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverBestTier(US, ADMIN_CA); assertEq(rk, _regionUS()); - assertEq(shard, 0); + assertEq(tier, 0); assertEq(members.length, 1); } - function test_discoverBestShard_fallsBackToGlobal() public { + function test_discoverBestTier_fallsBackToGlobal() public { vm.prank(alice); fleet.registerFleetGlobal(UUID_1); - (uint32 rk, uint256 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverBestTier(US, ADMIN_CA); assertEq(rk, GLOBAL); - assertEq(shard, 0); + assertEq(tier, 0); assertEq(members.length, 1); } - function test_discoverBestShard_allEmpty() public view { - (uint32 rk, uint256 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + function test_discoverBestTier_allEmpty() public view { + (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverBestTier(US, ADMIN_CA); assertEq(rk, GLOBAL); - assertEq(shard, 0); + assertEq(tier, 0); assertEq(members.length, 0); } - function test_discoverBestShard_returnsHighestShard() public { + function test_discoverBestTier_returnsHighestTier() public { _registerNCountry(alice, US, 8, 0); vm.prank(bob); fleet.registerFleetCountry(_uuid(500), US); - (uint32 rk, uint256 shard,) = fleet.discoverBestShard(US, 0); + (uint32 rk, uint256 tier,) = fleet.discoverBestTier(US, 0); assertEq(rk, _regionUS()); - assertEq(shard, 1); + assertEq(tier, 1); } // --- discoverAllLevels --- @@ -892,7 +892,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.tokenUUID(tokenId), UUID_1); } - function test_bonds_returnsShardBond() public { + function test_bonds_returnsTierBond() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1); assertEq(fleet.bonds(tokenId), BASE_BOND); @@ -949,16 +949,16 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(address(fleet)), 0); } - function test_bondAccounting_reassignShardRoundTrip() public { + function test_bondAccounting_reassignTierRoundTrip() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); uint256 balStart = bondToken.balanceOf(alice); vm.prank(alice); - fleet.reassignShard(tokenId, 3); + fleet.reassignTier(tokenId, 3); vm.prank(alice); - fleet.reassignShard(tokenId, 0); + fleet.reassignTier(tokenId, 0); assertEq(bondToken.balanceOf(alice), balStart); assertEq(fleet.bonds(tokenId), BASE_BOND); @@ -981,9 +981,9 @@ contract FleetIdentityTest is Test { f.registerFleetGlobal(UUID_1); } - // --- Transfer preserves region and shard --- + // --- Transfer preserves region and tier --- - function test_transfer_regionAndShardStayWithToken() public { + function test_transfer_regionAndTierStayWithToken() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 2); @@ -991,42 +991,42 @@ contract FleetIdentityTest is Test { fleet.transferFrom(alice, bob, tokenId); assertEq(fleet.fleetRegion(tokenId), _regionUS()); - assertEq(fleet.fleetShard(tokenId), 2); - assertEq(fleet.bonds(tokenId), fleet.shardBond(2)); + 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.shardBond(2)); + assertEq(bondToken.balanceOf(bob), bobBefore + fleet.tierBond(2)); } - // --- Shard lifecycle --- + // --- Tier lifecycle --- - function test_shardLifecycle_fillBurnBackfillPerRegion() public { + function test_tierLifecycle_fillBurnBackfillPerRegion() public { uint256[] memory usIds = _registerNCountry(alice, US, 8, 0); - assertEq(fleet.shardMemberCount(_regionUS(), 0), 8); + assertEq(fleet.tierMemberCount(_regionUS(), 0), 8); vm.prank(bob); uint256 us9 = fleet.registerFleetCountry(_uuid(100), US); - assertEq(fleet.fleetShard(us9), 1); + assertEq(fleet.fleetTier(us9), 1); vm.prank(alice); fleet.burn(usIds[3]); vm.prank(carol); uint256 backfill = fleet.registerFleetCountry(_uuid(200), US); - assertEq(fleet.fleetShard(backfill), 0); - assertEq(fleet.shardMemberCount(_regionUS(), 0), 8); + assertEq(fleet.fleetTier(backfill), 0); + assertEq(fleet.tierMemberCount(_regionUS(), 0), 8); - assertEq(fleet.regionShardCount(GLOBAL), 0); + assertEq(fleet.regionTierCount(GLOBAL), 0); } // --- Edge cases --- - function test_multiplier1_allShardsHaveSameBond() public { + function test_multiplier1_allTiersHaveSameBond() public { FleetIdentity f = new FleetIdentity(address(bondToken), BASE_BOND, 1); - assertEq(f.shardBond(0), BASE_BOND); - assertEq(f.shardBond(5), BASE_BOND); + assertEq(f.tierBond(0), BASE_BOND); + assertEq(f.tierBond(5), BASE_BOND); } function test_zeroBaseBond_allowsRegistration() public { @@ -1053,7 +1053,7 @@ contract FleetIdentityTest is Test { assertEq(tokenId, uint256(uint128(uuid))); assertEq(fleet.ownerOf(tokenId), alice); assertEq(fleet.bonds(tokenId), BASE_BOND); - assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.fleetRegion(tokenId), GLOBAL); } @@ -1064,7 +1064,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetCountry(UUID_1, cc); assertEq(fleet.fleetRegion(tokenId), uint32(cc)); - assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND); } @@ -1077,7 +1077,7 @@ contract FleetIdentityTest is Test { uint32 expectedRegion = (uint32(cc) << 12) | uint32(admin); assertEq(fleet.fleetRegion(tokenId), expectedRegion); - assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND); } @@ -1105,40 +1105,40 @@ contract FleetIdentityTest is Test { fleet.burn(tokenId); } - function testFuzz_shardBond_geometric(uint256 shard) public view { - shard = bound(shard, 0, 10); + function testFuzz_tierBond_geometric(uint256 tier) public view { + tier = bound(tier, 0, 10); uint256 expected = BASE_BOND; - for (uint256 i = 0; i < shard; i++) { + for (uint256 i = 0; i < tier; i++) { expected *= MULTIPLIER; } - assertEq(fleet.shardBond(shard), expected); + assertEq(fleet.tierBond(tier), expected); } - function testFuzz_perRegionShards_newRegionAlwaysStartsAtShard0(uint16 cc) public { + function testFuzz_perRegionTiers_newRegionAlwaysStartsAtTier0(uint16 cc) public { cc = uint16(bound(cc, 1, 999)); _registerNGlobal(alice, 8); - assertEq(fleet.regionShardCount(GLOBAL), 2); + assertEq(fleet.regionTierCount(GLOBAL), 2); vm.prank(bob); uint256 tokenId = fleet.registerFleetCountry(_uuid(999), cc); - assertEq(fleet.fleetShard(tokenId), 0); + assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND); } - function testFuzz_shardAssignment_autoFillsSequentiallyPerRegion(uint8 count) public { + 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 expectedShard = i / 8; // country capacity = 8 - assertEq(fleet.fleetShard(tokenId), expectedShard); + uint256 expectedTier = i / 8; // country capacity = 8 + assertEq(fleet.fleetTier(tokenId), expectedTier); } - uint256 expectedShards = (uint256(count) + 7) / 8; - assertEq(fleet.regionShardCount(_regionUS()), expectedShards); + uint256 expectedTiers = (uint256(count) + 7) / 8; + assertEq(fleet.regionTierCount(_regionUS()), expectedTiers); } // --- Invariants --- @@ -1160,7 +1160,7 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(address(fleet)), fleet.bonds(id2) + fleet.bonds(id3)); } - function test_invariant_contractBalanceAfterReassignShardBurn() public { + function test_invariant_contractBalanceAfterReassignTierBurn() public { vm.prank(alice); uint256 id1 = fleet.registerFleetCountry(UUID_1, US); vm.prank(bob); @@ -1169,10 +1169,10 @@ contract FleetIdentityTest is Test { uint256 id3 = fleet.registerFleetGlobal(UUID_3); vm.prank(alice); - fleet.reassignShard(id1, 3); + fleet.reassignTier(id1, 3); vm.prank(alice); - fleet.reassignShard(id1, 1); + fleet.reassignTier(id1, 1); uint256 expected = fleet.bonds(id1) + fleet.bonds(id2) + fleet.bonds(id3); assertEq(bondToken.balanceOf(address(fleet)), expected); @@ -1198,9 +1198,9 @@ contract FleetIdentityTest is Test { _registerNLocal(carol, US, ADMIN_CA, 3, 200); - (uint32 rk, uint256 shard, uint256[] memory members) = fleet.discoverBestShard(US, ADMIN_CA); + (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverBestTier(US, ADMIN_CA); assertEq(rk, _regionUSCA()); - assertEq(shard, 0); + assertEq(tier, 0); assertEq(members.length, 3); (uint256 gsc, uint256 csc, uint256 asc,) = fleet.discoverAllLevels(US, ADMIN_CA); @@ -1244,7 +1244,7 @@ contract FleetIdentityTest is Test { } function test_buildBundle_mergesAllLevelsAtSameBond() public { - // All at shard 0 → same bond → all collected together + // All at tier 0 → same bond → all collected together vm.prank(alice); fleet.registerFleetGlobal(UUID_1); vm.prank(alice); @@ -1257,28 +1257,28 @@ contract FleetIdentityTest is Test { } function test_buildBundle_higherBondFirstAcrossLevels() public { - // Global: shard 0 (bond=100) + // Global: tier 0 (bond=100) vm.prank(alice); fleet.registerFleetGlobal(UUID_1); - // Country US: promote to shard 2 (bond=400) + // Country US: promote to tier 2 (bond=400) vm.prank(alice); uint256 usId = fleet.registerFleetCountry(UUID_2, US); vm.prank(alice); - fleet.reassignShard(usId, 2); + fleet.reassignTier(usId, 2); - // Admin US-CA: shard 0 (bond=100) + // Admin US-CA: tier 0 (bond=100) vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); assertEq(count, 3); - // First UUID should be from US country (shard 2, highest bond) + // First UUID should be from US country (tier 2, highest bond) assertEq(uuids[0], UUID_2); } function test_buildBundle_tiedBondsCollectedTogether() public { - // Global shard 0, Country shard 0, Admin shard 0 — all bond=BASE_BOND + // Global tier 0, Country tier 0, Admin tier 0 — all bond=BASE_BOND vm.prank(alice); fleet.registerFleetGlobal(UUID_1); vm.prank(bob); @@ -1293,30 +1293,30 @@ contract FleetIdentityTest is Test { assertEq(count, 4); } - function test_buildBundle_descendsShardsByBondPriority() public { - // Admin area: fill shard 0 (8 members, bond=100) + 1 in shard 1 (bond=200) + 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 shard 0 (bond=100) + // Global: 1 member in tier 0 (bond=100) vm.prank(alice); fleet.registerFleetGlobal(_uuid(6000)); (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); - // Step 1: admin shard 1 (bond=200, 1 member) → count=1 - // Step 2: admin shard 0 (bond=100) + global shard 0 (bond=100) → tied → 8+1=9 + // 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 shard 1 (highest bond) - uint256[] memory adminShard1 = fleet.getShardMembers(_regionUSCA(), 1); - assertEq(uuids[0], bytes16(uint128(adminShard1[0]))); + // 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 shards + // Fill global: 4+4+4 = 12 in 3 tiers _registerNGlobal(alice, 12); - // Fill country US: 8+4 = 12 in 2 shards + // Fill country US: 8+4 = 12 in 2 tiers _registerNCountry(bob, US, 12, 1000); // Total across levels: 24, but cap at 20 @@ -1346,28 +1346,28 @@ contract FleetIdentityTest is Test { assertEq(count, 1); // only country } - function test_buildBundle_multiShardMultiLevel_correctOrder() public { - // Admin: 2 shards (shard 0: 8 members bond=100, shard 1: 1 member bond=200) + 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 shard 1 (bond=200) + // Country: promote to tier 1 (bond=200) vm.prank(alice); uint256 countryId = fleet.registerFleetCountry(_uuid(8200), US); vm.prank(alice); - fleet.reassignShard(countryId, 1); + fleet.reassignTier(countryId, 1); - // Global: promote to shard 2 (bond=400) + // Global: promote to tier 2 (bond=400) vm.prank(alice); uint256 globalId = fleet.registerFleetGlobal(_uuid(8300)); vm.prank(alice); - fleet.reassignShard(globalId, 2); + fleet.reassignTier(globalId, 2); (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); - // Step 1: global shard 2 (bond=400) → 1 member - // Step 2: admin shard 1 (bond=200) + country shard 1 (bond=200) → tied → 1+1=2 - // Step 3: admin shard 0 (bond=100) → 8 members + // 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)); From f16f2c647a210e51088a4ab275d69e8aefe8553c Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 13 Feb 2026 17:18:12 +1300 Subject: [PATCH 08/15] Fix FleetIdentity constants: MAX_TIERS=24, BOND_MULTIPLIER=2 (constant) --- src/swarms/FleetIdentity.sol | 22 ++++++++++++---------- test/FleetIdentity.t.sol | 31 ++++++++++++------------------- test/SwarmRegistryL1.t.sol | 2 +- test/SwarmRegistryUniversal.t.sol | 2 +- 4 files changed, 26 insertions(+), 31 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 61ad7397..3ebf6f90 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -69,8 +69,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Maximum members per admin-area (local) tier. uint256 public constant LOCAL_TIER_CAPACITY = 8; - /// @notice Hard cap on tier count per region to bound gas costs. - uint256 public constant MAX_TIERS = 50; + /// @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 buildScannerBundle. uint256 public constant SCANNER_BUNDLE_CAPACITY = 20; @@ -85,7 +89,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 public immutable BASE_BOND; /// @notice Geometric multiplier between tiers. - uint256 public immutable BOND_MULTIPLIER; + /// @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 @@ -150,15 +156,11 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Constructor // ────────────────────────────────────────────── - /// @param _bondToken Address of the ERC-20 token used for bonds. - /// @param _baseBond Base bond for tier 0 in any region. - /// @param _bondMultiplier Multiplier between tiers (e.g. 2 = doubling). - constructor(address _bondToken, uint256 _baseBond, uint256 _bondMultiplier) - ERC721("Swarm Fleet Identity", "SFID") - { + /// @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; - BOND_MULTIPLIER = _bondMultiplier; } // ══════════════════════════════════════════════ diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index c7a245b5..28f840ca 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -52,7 +52,6 @@ contract FleetIdentityTest is Test { bytes16 constant UUID_3 = bytes16(keccak256("fleet-charlie")); uint256 constant BASE_BOND = 100 ether; - uint256 constant MULTIPLIER = 2; uint16 constant US = 840; uint16 constant DE = 276; @@ -75,7 +74,7 @@ contract FleetIdentityTest is Test { function setUp() public { bondToken = new MockERC20(); - fleet = new FleetIdentity(address(bondToken), BASE_BOND, MULTIPLIER); + fleet = new FleetIdentity(address(bondToken), BASE_BOND); bondToken.mint(alice, 100_000_000 ether); bondToken.mint(bob, 100_000_000 ether); @@ -148,7 +147,7 @@ contract FleetIdentityTest is Test { function test_constructor_setsImmutables() public view { assertEq(address(fleet.BOND_TOKEN()), address(bondToken)); assertEq(fleet.BASE_BOND(), BASE_BOND); - assertEq(fleet.BOND_MULTIPLIER(), MULTIPLIER); + assertEq(fleet.BOND_MULTIPLIER(), 2); assertEq(fleet.name(), "Swarm Fleet Identity"); assertEq(fleet.symbol(), "SFID"); assertEq(fleet.GLOBAL_REGION(), 0); @@ -158,7 +157,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.GLOBAL_TIER_CAPACITY(), 4); assertEq(fleet.COUNTRY_TIER_CAPACITY(), 8); assertEq(fleet.LOCAL_TIER_CAPACITY(), 8); - assertEq(fleet.MAX_TIERS(), 50); + assertEq(fleet.MAX_TIERS(), 24); assertEq(fleet.SCANNER_BUNDLE_CAPACITY(), 20); } @@ -175,16 +174,16 @@ contract FleetIdentityTest is Test { } function test_tierBond_tier1() public view { - assertEq(fleet.tierBond(1), BASE_BOND * MULTIPLIER); + assertEq(fleet.tierBond(1), BASE_BOND * 2); } function test_tierBond_tier2() public view { - assertEq(fleet.tierBond(2), BASE_BOND * MULTIPLIER * MULTIPLIER); + 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) * MULTIPLIER); + assertEq(fleet.tierBond(i), fleet.tierBond(i - 1) * 2); } } @@ -343,7 +342,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); uint256 g21 = fleet.registerFleetGlobal(_uuid(100)); assertEq(fleet.fleetTier(g21), 1); - assertEq(fleet.bonds(g21), BASE_BOND * MULTIPLIER); + assertEq(fleet.bonds(g21), BASE_BOND * 2); vm.prank(bob); uint256 us1 = fleet.registerFleetCountry(_uuid(200), US); @@ -364,7 +363,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); uint256 us21 = fleet.registerFleetCountry(_uuid(500), US); assertEq(fleet.fleetTier(us21), 1); - assertEq(fleet.bonds(us21), BASE_BOND * MULTIPLIER); + assertEq(fleet.bonds(us21), BASE_BOND * 2); vm.prank(bob); uint256 de1 = fleet.registerFleetCountry(_uuid(600), DE); @@ -651,7 +650,7 @@ contract FleetIdentityTest is Test { (uint256 gTier, uint256 gBond) = fleet.lowestOpenTier(GLOBAL); assertEq(gTier, 1); - assertEq(gBond, BASE_BOND * MULTIPLIER); + assertEq(gBond, BASE_BOND * 2); (uint256 usTier, uint256 usBond) = fleet.lowestOpenTier(_regionUS()); assertEq(usTier, 0); @@ -968,7 +967,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_bondToken_transferFromReturnsFalse() public { BadERC20 badToken = new BadERC20(); - FleetIdentity f = new FleetIdentity(address(badToken), BASE_BOND, MULTIPLIER); + FleetIdentity f = new FleetIdentity(address(badToken), BASE_BOND); badToken.mint(alice, 1_000 ether); vm.prank(alice); @@ -1023,14 +1022,8 @@ contract FleetIdentityTest is Test { // --- Edge cases --- - function test_multiplier1_allTiersHaveSameBond() public { - FleetIdentity f = new FleetIdentity(address(bondToken), BASE_BOND, 1); - assertEq(f.tierBond(0), BASE_BOND); - assertEq(f.tierBond(5), BASE_BOND); - } - function test_zeroBaseBond_allowsRegistration() public { - FleetIdentity f = new FleetIdentity(address(bondToken), 0, MULTIPLIER); + FleetIdentity f = new FleetIdentity(address(bondToken), 0); vm.prank(alice); bondToken.approve(address(f), type(uint256).max); @@ -1109,7 +1102,7 @@ contract FleetIdentityTest is Test { tier = bound(tier, 0, 10); uint256 expected = BASE_BOND; for (uint256 i = 0; i < tier; i++) { - expected *= MULTIPLIER; + expected *= 2; } assertEq(fleet.tierBond(tier), expected); } diff --git a/test/SwarmRegistryL1.t.sol b/test/SwarmRegistryL1.t.sol index 4f3552ec..5624e4f3 100644 --- a/test/SwarmRegistryL1.t.sol +++ b/test/SwarmRegistryL1.t.sol @@ -36,7 +36,7 @@ contract SwarmRegistryL1Test is Test { function setUp() public { bondToken = new MockBondTokenL1(); - fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND, 2); + fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND); providerContract = new ServiceProvider(); swarmRegistry = new SwarmRegistryL1(address(fleetContract), address(providerContract)); diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol index 694da365..17f90880 100644 --- a/test/SwarmRegistryUniversal.t.sol +++ b/test/SwarmRegistryUniversal.t.sol @@ -38,7 +38,7 @@ contract SwarmRegistryUniversalTest is Test { function setUp() public { bondToken = new MockBondTokenUniv(); - fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND, 2); + fleetContract = new FleetIdentity(address(bondToken), FLEET_BOND); providerContract = new ServiceProvider(); swarmRegistry = new SwarmRegistryUniversal(address(fleetContract), address(providerContract)); From 1ba85149ec28452a0cf6b7c8dfb2216e351a6f65 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 13 Feb 2026 17:35:47 +1300 Subject: [PATCH 09/15] Rename scanner APIs to EdgeBeaconScanner and highest-bonded terminology --- src/swarms/FleetIdentity.sol | 30 +++++++------- src/swarms/doc/assistant-guide.md | 10 ++--- src/swarms/doc/graph-architecture.md | 2 +- src/swarms/doc/sequence-discovery.md | 52 +++++++++++------------ test/FleetIdentity.t.sol | 62 ++++++++++++++-------------- 5 files changed, 78 insertions(+), 78 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 3ebf6f90..bb373903 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -29,13 +29,13 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * - Admin Area: 8 members per tier * Tier K within a region requires bond = BASE_BOND * BOND_MULTIPLIER^K. * - * Scanner discovery uses a 3-level fallback: + * 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 scanner enumeration without off-chain indexers. + * enabling EdgeBeaconScanner enumeration without off-chain indexers. * * TokenID = uint256(uint128(uuid)), guaranteeing one owner per Proximity UUID. */ @@ -76,8 +76,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// 24 provides comfortable headroom. uint256 public constant MAX_TIERS = 24; - /// @notice Maximum UUIDs returned by buildScannerBundle. - uint256 public constant SCANNER_BUNDLE_CAPACITY = 20; + /// @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; @@ -361,15 +361,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } // ══════════════════════════════════════════════ - // Views: Scanner discovery + // Views: EdgeBeaconScanner discovery // ══════════════════════════════════════════════ - /// @notice Returns the best tier for a scanner at a specific location. + /// @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 discoverBestTier(uint16 countryCode, uint16 adminCode) + function discoverHighestBondedTier(uint16 countryCode, uint16 adminCode) external view returns (uint32 regionKey, uint256 tier, uint256[] memory members) @@ -420,8 +420,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } - /// @notice Builds a priority-ordered bundle of up to SCANNER_BUNDLE_CAPACITY (20) - /// UUIDs for a scanner, merging the highest-bonded tiers across admin-area, + /// @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** @@ -434,16 +434,16 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// 4. Advance those cursors downward. /// 5. Repeat until the bundle is full or all cursors exhausted. /// - /// @param countryCode Scanner's country (0 to skip country + admin). - /// @param adminCode Scanner's admin area (0 to skip admin). + /// @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 buildScannerBundle(uint16 countryCode, uint16 adminCode) + function buildHighestBondedUUIDBundle(uint16 countryCode, uint16 adminCode) external view returns (bytes16[] memory uuids, uint256 count) { - uuids = new bytes16[](SCANNER_BUNDLE_CAPACITY); + 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". @@ -475,7 +475,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { cursors[2] = sc > 0 ? int256(sc) - 1 : int256(-1); } - while (count < SCANNER_BUNDLE_CAPACITY) { + while (count < MAX_BONDED_UUID_BUNDLE_SIZE) { // Find the maximum bond across all active cursors. uint256 maxBond = 0; bool anyActive = false; @@ -499,7 +499,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256[] storage members = _regionTierMembers[keys[lvl]][uint256(cursors[lvl])]; uint256 mLen = members.length; - for (uint256 m = 0; m < mLen && count < SCANNER_BUNDLE_CAPACITY; m++) { + for (uint256 m = 0; m < mLen && count < MAX_BONDED_UUID_BUNDLE_SIZE; m++) { uuids[count] = bytes16(uint128(members[m])); count++; } diff --git a/src/swarms/doc/assistant-guide.md b/src/swarms/doc/assistant-guide.md index 7001d734..ffa3cb4b 100644 --- a/src/swarms/doc/assistant-guide.md +++ b/src/swarms/doc/assistant-guide.md @@ -137,24 +137,24 @@ This means the same (fleet, provider, filter) triple always produces the same ID --- -## 4. Client Discovery Flow (The "Scanner" Perspective) +## 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 -- Scanner detects iBeacon: `UUID: E2C5...`, `Major: 1`, `Minor: 50`, `MAC: AA:BB...`. +- EdgeBeaconScanner detects iBeacon: `UUID: E2C5...`, `Major: 1`, `Minor: 50`, `MAC: AA:BB...`. ### Step 2: Identify Fleet -- Scanner checks `FleetIdentity` contract. +- 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 -- Scanner reads `swarmRegistry.fleetSwarms(42, index)` for each index (array of swarm IDs for that fleet). +- 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) @@ -206,4 +206,4 @@ Both registries use an **O(1) swap-and-pop** strategy for removing swarms from t --- -**Note**: This architecture ensures that a scanner can go from **Raw Signal** → **Verified Service URL** entirely on-chain (data-wise), without a centralized indexer, while privacy of the 10,000 other tags in the swarm is preserved. +**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 index 5cc67b8c..59c173e8 100644 --- a/src/swarms/doc/graph-architecture.md +++ b/src/swarms/doc/graph-architecture.md @@ -14,7 +14,7 @@ graph TB subgraph Actors FO(("Fleet
Owner")) PRV(("Service
Provider")) - ANY(("Anyone
(Scanner / Purger)")) + ANY(("Anyone
(EdgeBeaconScanner / Purger)")) end FO -- "registerFleet(uuid, bondAmount)" --> FI diff --git a/src/swarms/doc/sequence-discovery.md b/src/swarms/doc/sequence-discovery.md index ac5e3691..ea8cf32a 100644 --- a/src/swarms/doc/sequence-discovery.md +++ b/src/swarms/doc/sequence-discovery.md @@ -4,49 +4,49 @@ ```mermaid sequenceDiagram - actor SC as Scanner (Client) + actor EBS as EdgeBeaconScanner (Client) participant FI as FleetIdentity participant SR as SwarmRegistry participant SP as ServiceProvider - Note over SC: Detects iBeacon:
UUID, Major, Minor, MAC + Note over EBS: Detects iBeacon:
UUID, Major, Minor, MAC rect rgb(240, 248, 255) - Note right of SC: Step 1 — Identify fleet - SC ->>+ FI: ownerOf(uint128(uuid)) - FI -->>- SC: fleet owner address (fleet exists ✓) + 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 SC: Step 2 — Enumerate swarms - SC ->>+ SR: fleetSwarms(fleetId, 0) - SR -->>- SC: swarmId_0 - SC ->>+ SR: fleetSwarms(fleetId, 1) - SR -->>- SC: swarmId_1 - Note over SC: ... iterate until revert (end of array) + 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 SC: Step 3 — Find matching swarm - Note over SC: Read swarms[swarmId_0].tagType - Note over SC: Construct tagId per schema:
UUID || Major || Minor [|| MAC] - Note over SC: tagHash = keccak256(tagId) - SC ->>+ SR: checkMembership(swarmId_0, tagHash) - SR -->>- SC: false (not in this swarm) - - SC ->>+ SR: checkMembership(swarmId_1, tagHash) - SR -->>- SC: true ✓ (tag found!) + 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 SC: Step 4 — Resolve service URL - SC ->>+ SR: swarms(swarmId_1) - SR -->>- SC: { providerId, status: ACCEPTED, ... } - SC ->>+ SP: providerUrls(providerId) - SP -->>- SC: "https://api.acme-tracking.com" + 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 SC: Connect to service URL ✓ + Note over EBS: Connect to service URL ✓ ``` ## Tag Hash Construction by TagType diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 28f840ca..5f0c1681 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -158,7 +158,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.COUNTRY_TIER_CAPACITY(), 8); assertEq(fleet.LOCAL_TIER_CAPACITY(), 8); assertEq(fleet.MAX_TIERS(), 24); - assertEq(fleet.SCANNER_BUNDLE_CAPACITY(), 20); + assertEq(fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), 20); } function test_tierCapacity_perLevel() public view { @@ -672,7 +672,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.highestActiveTier(_regionUS()), 0); } - // --- Scanner helpers --- + // --- EdgeBeaconScanner helpers --- function test_tierMemberCount_perRegion() public { _registerNGlobal(alice, 3); @@ -714,9 +714,9 @@ contract FleetIdentityTest is Test { assertEq(usUUIDs[0], UUID_2); } - // --- discoverBestTier --- + // --- discoverHighestBondedTier --- - function test_discoverBestTier_prefersAdminArea() public { + function test_discoverHighestBondedTier_prefersAdminArea() public { vm.prank(alice); fleet.registerFleetGlobal(UUID_1); vm.prank(bob); @@ -724,47 +724,47 @@ contract FleetIdentityTest is Test { vm.prank(carol); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverBestTier(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_discoverBestTier_fallsBackToCountry() public { + 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.discoverBestTier(US, ADMIN_CA); + (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); assertEq(rk, _regionUS()); assertEq(tier, 0); assertEq(members.length, 1); } - function test_discoverBestTier_fallsBackToGlobal() public { + function test_discoverHighestBondedTier_fallsBackToGlobal() public { vm.prank(alice); fleet.registerFleetGlobal(UUID_1); - (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverBestTier(US, ADMIN_CA); + (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); assertEq(rk, GLOBAL); assertEq(tier, 0); assertEq(members.length, 1); } - function test_discoverBestTier_allEmpty() public view { - (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverBestTier(US, ADMIN_CA); + 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_discoverBestTier_returnsHighestTier() public { + function test_discoverHighestBondedTier_returnsHighestTier() public { _registerNCountry(alice, US, 8, 0); vm.prank(bob); fleet.registerFleetCountry(_uuid(500), US); - (uint32 rk, uint256 tier,) = fleet.discoverBestTier(US, 0); + (uint32 rk, uint256 tier,) = fleet.discoverHighestBondedTier(US, 0); assertEq(rk, _regionUS()); assertEq(tier, 1); } @@ -1180,9 +1180,9 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(address(fleet)), 0); } - // --- Scanner workflow --- + // --- EdgeBeaconScanner workflow --- - function test_scannerWorkflow_multiRegionDiscovery() public { + function test_edgeBeaconScannerWorkflow_multiRegionDiscovery() public { _registerNGlobal(alice, 4); for (uint256 i = 0; i < 2; i++) { vm.prank(bob); @@ -1191,7 +1191,7 @@ contract FleetIdentityTest is Test { _registerNLocal(carol, US, ADMIN_CA, 3, 200); - (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverBestTier(US, ADMIN_CA); + (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); assertEq(rk, _regionUSCA()); assertEq(tier, 0); assertEq(members.length, 3); @@ -1202,10 +1202,10 @@ contract FleetIdentityTest is Test { assertEq(asc, 1); } - // --- buildScannerBundle --- + // --- buildHighestBondedUUIDBundle --- function test_buildBundle_emptyReturnsZero() public view { - (, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 0); } @@ -1213,7 +1213,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(UUID_1); - (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(0, 0); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(0, 0); assertEq(count, 1); assertEq(uuids[0], UUID_1); } @@ -1222,7 +1222,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetCountry(UUID_1, US); - (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, 0); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); assertEq(count, 1); assertEq(uuids[0], UUID_1); } @@ -1231,7 +1231,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); - (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 1); assertEq(uuids[0], UUID_1); } @@ -1245,7 +1245,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - (, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 3); } @@ -1264,7 +1264,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(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); @@ -1281,7 +1281,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - (, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); // All at same bond → all 4 collected assertEq(count, 4); } @@ -1296,7 +1296,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(_uuid(6000)); - (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + (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 @@ -1313,7 +1313,7 @@ contract FleetIdentityTest is Test { _registerNCountry(bob, US, 12, 1000); // Total across levels: 24, but cap at 20 - (, uint256 count) = fleet.buildScannerBundle(US, 0); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); assertEq(count, 20); } @@ -1324,7 +1324,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetCountry(UUID_2, US); // countryCode=0 → skip country and admin levels - (, uint256 count) = fleet.buildScannerBundle(0, 0); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(0, 0); assertEq(count, 1); // only global } @@ -1335,7 +1335,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); // adminCode=0 → skip admin level - (, uint256 count) = fleet.buildScannerBundle(US, 0); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); assertEq(count, 1); // only country } @@ -1357,7 +1357,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.reassignTier(globalId, 2); - (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + (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 @@ -1374,7 +1374,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - (bytes16[] memory uuids, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 3); bool found1; @@ -1406,7 +1406,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(_uuid(32_000 + i), US, ADMIN_CA); } - (, uint256 count) = fleet.buildScannerBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertLe(count, 20); uint256 total = uint256(gCount) + uint256(cCount) + uint256(lCount); From ba6ac569505b6ec3c5bee9fe1c073b8e4c4906ed Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 17 Feb 2026 11:22:48 +1300 Subject: [PATCH 10/15] Implement all-or-nothing tier collection in buildHighestBondedUUIDBundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Algorithm: Greedy All-or-Nothing with Level Priority. When bonds tie across admin/country/global levels, try each level in priority order (admin → country → global). Include the entire tier only if all members fit in the remaining room; otherwise skip that tier entirely. Cursors always advance regardless, preventing starvation of lower-priority levels when a larger tier is skipped. This guarantees every included tier is complete (no partial tiers), respects geographic priority, and avoids starving smaller levels. --- src/swarms/FleetIdentity.sol | 40 +++- test/FleetIdentity.t.sol | 429 ++++++++++++++++++++++++++++++++--- 2 files changed, 421 insertions(+), 48 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index bb373903..90da4d2d 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -424,16 +424,26 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// UUIDs for an EdgeBeaconScanner, merging the highest-bonded tiers across admin-area, /// country, and global levels. /// - /// **Algorithm** + /// **Algorithm – Greedy All-or-Nothing with Level Priority** + /// /// 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. + /// 3. For every level whose cursor bond equals that maximum, + /// try to include the **entire** tier (all members). If the + /// tier fits in the remaining room, include it; otherwise + /// **skip it entirely** (never take a partial tier). + /// Levels are tried in priority order: admin → country → global. + /// 4. Advance those cursors downward regardless of whether + /// the tier was included or skipped. /// 5. Repeat until the bundle is full or all cursors exhausted. /// + /// This guarantees that every included tier is complete (all-or-nothing), + /// respects geographic priority when bonds are tied, and never starves + /// lower-priority levels when a higher-priority level's tier is too + /// large to fit. + /// /// @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). @@ -450,7 +460,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint32[3] memory keys; int256[3] memory cursors; - // Level 0: admin area + // Level 0: admin area (highest priority) if (countryCode > 0 && adminCode > 0) { keys[0] = (uint32(countryCode) << 12) | uint32(adminCode); uint256 sc = regionTierCount[keys[0]]; @@ -468,7 +478,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { cursors[1] = -1; } - // Level 2: global + // Level 2: global (lowest priority) { keys[2] = GLOBAL_REGION; uint256 sc = regionTierCount[GLOBAL_REGION]; @@ -491,20 +501,26 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (!anyActive) break; - // Collect members from every level whose cursor bond == maxBond. + // Try each level whose cursor bond == maxBond in priority order. + // Include the entire tier only if ALL members fit; skip otherwise. 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++; + uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; + + // All-or-nothing: include only if the entire tier fits. + if (mLen <= room) { + for (uint256 m = 0; m < mLen; m++) { + uuids[count] = bytes16(uint128(members[m])); + count++; + } } + // else: skip this tier entirely (too large for remaining room). - // Advance this cursor downward. + // Always advance cursor downward regardless. cursors[lvl]--; } } diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 5f0c1681..6503f364 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1202,7 +1202,7 @@ contract FleetIdentityTest is Test { assertEq(asc, 1); } - // --- buildHighestBondedUUIDBundle --- + // --- buildHighestBondedUUIDBundle (all-or-nothing) --- function test_buildBundle_emptyReturnsZero() public view { (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); @@ -1236,20 +1236,7 @@ contract FleetIdentityTest is Test { 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 { + function test_buildBundle_higherBondFirst() public { // Global: tier 0 (bond=100) vm.prank(alice); fleet.registerFleetGlobal(UUID_1); @@ -1266,24 +1253,146 @@ contract FleetIdentityTest is Test { (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 3); - // First UUID should be from US country (tier 2, highest bond) + // Country tier 2 (bond=400) comes first assertEq(uuids[0], UUID_2); } - function test_buildBundle_tiedBondsCollectedTogether() public { - // Global tier 0, Country tier 0, Admin tier 0 — all bond=BASE_BOND + function test_buildBundle_allLevelsTied_levelPriorityOrder() public { + // All at tier 0 → same bond → level priority: admin(0), country(1), global(2) vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); - vm.prank(bob); - fleet.registerFleetGlobal(_uuid(11)); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); vm.prank(alice); fleet.registerFleetCountry(UUID_2, US); vm.prank(alice); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + fleet.registerFleetGlobal(UUID_1); + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 3); + // Admin first, then country, then global + assertEq(uuids[0], UUID_3); // admin + assertEq(uuids[1], UUID_2); // country + assertEq(uuids[2], UUID_1); // global + } + + function test_buildBundle_tierSkippedWhenDoesntFit() public { + // Fill 19 slots, then a tier with 2 members should be skipped (room=1). + // Admin: 8 in tier 0, 8 in tier 1 = 16 at two bond levels + _registerNLocal(alice, US, ADMIN_CA, 8, 5000); + _registerNLocal(alice, US, ADMIN_CA, 8, 5100); + // now admin has tier 0 (8 members, bond=100) and tier 1 (8 members, bond=200) + + // Country: 3 members at tier 0 (bond=100) + _registerNCountry(alice, US, 3, 6000); + + // Global: 1 member at tier 1 (bond=200) + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(7000), 1); + + // Step 1: maxBond = 200. Admin tier 1 = 8 members (room=20), fits → included. Global tier 1 = 1 member (room=12), fits → included. count=9. + // Step 2: maxBond = 100. Admin tier 0 = 8 members (room=11), fits → included. count=17. + // Country tier 0 = 3 members (room=3), fits → included. count=20. + // Total: 20 + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 20); + } + + function test_buildBundle_skipLargerTakeSmallerAtSameBond() public { + // Admin tier 0: fill with 8 members (bond=100) + _registerNLocal(alice, US, ADMIN_CA, 8, 5000); + + // Country tier 0: 3 members (bond=100) + _registerNCountry(alice, US, 3, 6000); + + // Global tier 0: 4 members (bond=100) + _registerNGlobal(alice, 4); + + // Fill admin tier 1 to eat up room: 5 members at tier 1 (bond=200) + for (uint256 i = 0; i < 5; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(7000 + i), US, ADMIN_CA, 1); + } + + // Step 1: maxBond = 200. Admin tier 1 = 5 (room=20), fits → count=5. + // Step 2: maxBond = 100. All levels at tier 0. + // admin tier 0 = 8 (room=15), fits → count=13. + // country tier 0 = 3 (room=7), fits → count=16. + // global tier 0 = 4 (room=4), fits → count=20. + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 20); + } + + function test_buildBundle_skipLargerTierTakeSmallerNextRound() public { + // Scenario: at same bond, admin tier is too big but country tier fits. + // Admin tier 0: 8 members (bond=100) + _registerNLocal(alice, US, ADMIN_CA, 8, 5000); + + // Country tier 0: 2 members (bond=100) + _registerNCountry(alice, US, 2, 6000); + + // Global tier 0: 2 members (bond=100) + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(7000)); + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(7001)); + + // Now fill room: register 11 at admin tier 1 to force only 9 remaining room for tier 0. + // Actually let's approach differently: set up admin tier 1 with enough to leave room < 8 for tier 0. + // Register 13 in admin tier 1 => room left = 7 for tier 0 round + // But admin tier capacity is 8, so we can register up to 8 per tier. + // Let's use a simpler approach: register at admin tier 1 to fill more room. + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(8000 + i), US, ADMIN_CA, 1); + } + // Admin tier 1: 8 members (bond=200) + + // Now also country tier 1: 5 members (bond=200) + for (uint256 i = 0; i < 5; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(9000 + i), US, 1); + } + // Step 1: maxBond=200. Admin tier 1 = 8 (room=20), fits → count=8. + // Country tier 1 = 5 (room=12), fits → count=13. + // Step 2: maxBond=100. All at tier 0. + // Admin tier 0 = 8 (room=7) → SKIP (8 > 7). + // Country tier 0 = 2 (room=7) → fits → count=15. + // Global tier 0 = 2 (room=5) → fits → count=17. + // Total: 17 (admin tier 0 skipped entirely) (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - // All at same bond → all 4 collected - assertEq(count, 4); + assertEq(count, 17); + } + + function test_buildBundle_noStarvation_smallerLevelIncludedWhenLargerSkipped() public { + // Test that global isn't starved when a larger admin tier is skipped. + // Admin tier 0: 8 members (bond=100). This will be too big. + _registerNLocal(alice, US, ADMIN_CA, 8, 5000); + + // Global tier 0: 2 members (bond=100) + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(7000)); + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(7001)); + + // Consume 15 slots at admin tier 1 (bond=200) – but capacity is 8 per tier. + // So 8 at tier 1 + 7 at tier 2 (need two tiers to eat 15 slots) + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(8000 + i), US, ADMIN_CA, 1); + } + for (uint256 i = 0; i < 7; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(9000 + i), US, ADMIN_CA, 2); + } + // Admin tier 2: 7 members (bond=400), tier 1: 8 members (bond=200), tier 0: 8 (bond=100) + + // Step 1: maxBond=400. Admin tier 2 = 7 (room=20), fits → count=7. + // Step 2: maxBond=200. Admin tier 1 = 8 (room=13), fits → count=15. + // Step 3: maxBond=100. Admin tier 0 = 8 (room=5) → SKIP. + // Global tier 0 = 2 (room=5) → fits → count=17. + // Total: 17 — global not starved. + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 17); } function test_buildBundle_descendsTiersByBondPriority() public { @@ -1298,8 +1407,7 @@ contract FleetIdentityTest is Test { (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 + // Step 2: admin tier 0 (bond=100) = 8 + global tier 0 (bond=100) = 1 → 9 fit → count=10 assertEq(count, 10); // First UUID is from admin tier 1 (highest bond) uint256[] memory adminTier1 = fleet.getTierMembers(_regionUSCA(), 1); @@ -1309,10 +1417,13 @@ contract FleetIdentityTest is Test { 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); + // Fill country US: 8+8 = 16 in 2 tiers + _registerNCountry(bob, US, 16, 1000); - // Total across levels: 24, but cap at 20 + // Global tiers: tier 0(4), tier 1(4), tier 2(4). Country tiers: tier 0(8), tier 1(8). + // Step 1: maxBond=400 → global tier 2 = 4 (room=20), fits → count=4. + // Step 2: maxBond=200 → country tier 1 = 8 (room=16), fits → count=12. global tier 1 = 4, fits → count=16. + // Step 3: maxBond=100 → country tier 0 = 8 (room=4) → SKIP. global tier 0 = 4 (room=4) → fits → count=20. (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); assertEq(count, 20); } @@ -1358,10 +1469,9 @@ contract FleetIdentityTest is Test { 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 + // Step 1: global tier 2 (bond=400) → 1 member → count=1 + // Step 2: admin tier 1 (bond=200) + country tier 1 (bond=200) → 1+1=2 → count=3 + // Step 3: admin tier 0 (bond=100) → 8 → count=11 assertEq(count, 11); assertEq(uuids[0], fleet.tokenUUID(globalId)); } @@ -1388,6 +1498,207 @@ contract FleetIdentityTest is Test { assertTrue(found1 && found2 && found3); } + function test_buildBundle_allOrNothing_partialTierNeverIncluded() public { + // Verify by filling room so a tier of 4 has only 3 remaining slots. + // Global tier 0: 4 members (bond=100) + _registerNGlobal(alice, 4); + + // Admin: 8 at tier 1 (bond=200) to consume space, 8 at tier 0 (bond=100) + _registerNLocal(alice, US, ADMIN_CA, 8, 5000); + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(5100 + i), US, ADMIN_CA, 1); + } + + // Country: 1 at tier 1 (bond=200) + vm.prank(alice); + fleet.registerFleetCountry(_uuid(6000), US, 1); + + // Step 1: maxBond=200. Admin tier 1 = 8 (room=20) fits → count=8. Country tier 1 = 1 (room=12) fits → count=9. + // Step 2: maxBond=100. Admin tier 0 = 8 (room=11) fits → count=17. + // Global tier 0 = 4 (room=3) → SKIP entirely. + // Total: 17 (global tier 0 skipped, no partial) + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 17); + + // Verify no global UUID is in the result + for (uint256 i = 0; i < count; i++) { + uint256 tokenId = uint256(uint128(uuids[i])); + assertTrue(fleet.fleetRegion(tokenId) != GLOBAL, "Global UUID should not appear"); + } + } + + function test_buildBundle_allOrNothing_tieBreaker_adminBeforeCountryBeforeGlobal() public { + // When room is exactly 8, admin tier 0 (8 members) is tried before country tier 0 (8 members). + // Only one of them can fit. Admin should win because of level priority. + + // Eat 12 room at higher bonds first. + for (uint256 i = 0; i < 4; i++) { + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(1000 + i), 1); + } + // global tier 1 = 4 (bond=200) + + // Admin tier 1: 8 members (bond=200) + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(2000 + i), US, ADMIN_CA, 1); + } + + // Now room after step 1 = 20 - 12 = 8. + // Admin tier 0: 8 members (bond=100) + _registerNLocal(alice, US, ADMIN_CA, 8, 3000); + // Country tier 0: 8 members (bond=100) + _registerNCountry(alice, US, 8, 4000); + + // Step 1: maxBond=200. admin tier 1 = 8 (room=20), fits → count=8. global tier 1 = 4 (room=12), fits → count=12. + // Step 2: maxBond=100. admin tier 0 = 8 (room=8) → fits → count=20. + // country tier 0 = 8 (room=0) → SKIP. Global tier 0 = 0 (no tier 0). + // Total: 20 — admin prioritized over country at same bond. + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 20); + + // Verify all admin+global, zero country UUIDs + uint256 adminCount; + uint256 globalCount; + uint256 countryCount; + for (uint256 i = 0; i < count; i++) { + uint256 tokenId = uint256(uint128(uuids[i])); + uint32 region = fleet.fleetRegion(tokenId); + if (region == GLOBAL) globalCount++; + else if (region == _regionUS()) countryCount++; + else if (region == _regionUSCA()) adminCount++; + } + assertEq(adminCount, 16); // tier 0 (8) + tier 1 (8) + assertEq(globalCount, 4); // tier 1 (4) + assertEq(countryCount, 0); // skipped + } + + function test_buildBundle_afterBurn_reflects() public { + // Register 3 global, build bundle, burn one, rebuild. + vm.prank(alice); + uint256 id1 = fleet.registerFleetGlobal(UUID_1); + vm.prank(bob); + fleet.registerFleetGlobal(UUID_2); + vm.prank(carol); + fleet.registerFleetGlobal(UUID_3); + + (, uint256 countBefore) = fleet.buildHighestBondedUUIDBundle(0, 0); + assertEq(countBefore, 3); + + vm.prank(alice); + fleet.burn(id1); + + (, uint256 countAfter) = fleet.buildHighestBondedUUIDBundle(0, 0); + assertEq(countAfter, 2); + } + + function test_buildBundle_singleLevelMultipleTiers() public { + // Only country, multiple tiers. + _registerNCountry(alice, US, 8, 1000); // tier 0: 8 members (bond=100) + _registerNCountry(alice, US, 8, 2000); // tier 1: 8 members (bond=200) + _registerNCountry(alice, US, 4, 3000); // tier 2: 4 members (bond=400) + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + // tier 2 (4) + tier 1 (8) + tier 0 (8) = 20, all fit + assertEq(count, 20); + + // Verify order: tier 2 first, then tier 1, then tier 0 + // First 4 UUIDs from tier 2 + uint256[] memory t2 = fleet.getTierMembers(_regionUS(), 2); + for (uint256 i = 0; i < 4; i++) { + assertEq(uuids[i], bytes16(uint128(t2[i]))); + } + } + + function test_buildBundle_emptyTiersInMiddle() public { + // Country: register at tier 0 and tier 2 (tier 1 is empty) + vm.prank(alice); + fleet.registerFleetCountry(UUID_1, US); + vm.prank(alice); + fleet.registerFleetCountry(UUID_2, US, 2); + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + // tier 2: 1 member (bond=400), tier 1: 0 members (skipped naturally), tier 0: 1 member (bond=100) + assertEq(count, 2); + assertEq(uuids[0], UUID_2); // higher bond first + assertEq(uuids[1], UUID_1); + } + + function test_buildBundle_exactlyFillsToCapacity() public { + // Create exactly 20 members across levels, all at same bond. + // Admin: 8 (tier 0), Country: 8 (tier 0), Global: 4 (tier 0) = 20 + _registerNLocal(alice, US, ADMIN_CA, 8, 1000); + _registerNCountry(alice, US, 8, 2000); + _registerNGlobal(alice, 4); + + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 20); + } + + function test_buildBundle_twentyOneOverflow_globalSkipped() public { + // 21 total: admin 8 + country 8 + global 4 + 1 extra country. + // Same bond for all in tier 0, but country spills to tier 1. + _registerNLocal(alice, US, ADMIN_CA, 8, 1000); + _registerNCountry(alice, US, 8, 2000); + _registerNGlobal(alice, 4); + // 1 more country → goes to tier 1 (bond=200) + vm.prank(alice); + fleet.registerFleetCountry(_uuid(3000), US); + + // Step 1: country tier 1 = 1 (bond=200, room=20), fits → count=1. + // Step 2: all tier 0 tied at bond=100. + // admin tier 0 = 8 (room=19), fits → count=9. + // country tier 0 = 8 (room=11), fits → count=17. + // global tier 0 = 4 (room=3) → SKIP. + // Total: 17 + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 17); + } + + function test_buildBundle_multipleAdminAreas_onlyRequestedIncluded() public { + // Register in two different admin areas, only the requested one appears. + _registerNLocal(alice, US, ADMIN_CA, 5, 1000); + _registerNLocal(alice, US, ADMIN_NY, 5, 2000); + + (, uint256 countCA) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(countCA, 5); + + (, uint256 countNY) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_NY); + assertEq(countNY, 5); + } + + function test_buildBundle_noNonExistentUUIDs() public { + // Ensure returned UUIDs are valid token IDs. + _registerNLocal(alice, US, ADMIN_CA, 3, 1000); + _registerNCountry(bob, US, 2, 2000); + vm.prank(carol); + fleet.registerFleetGlobal(UUID_1); + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 6); + + for (uint256 i = 0; i < count; i++) { + uint256 tokenId = uint256(uint128(uuids[i])); + // ownerOf reverts for nonexistent tokens + assertTrue(fleet.ownerOf(tokenId) != address(0)); + } + } + + function test_buildBundle_noDuplicateUUIDs() public { + _registerNLocal(alice, US, ADMIN_CA, 5, 1000); + _registerNCountry(bob, US, 4, 2000); + _registerNGlobal(carol, 3); + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + + for (uint256 i = 0; i < count; i++) { + for (uint256 j = i + 1; j < count; j++) { + assertTrue(uuids[i] != uuids[j], "Duplicate UUID found"); + } + } + } + function testFuzz_buildBundle_neverExceeds20(uint8 gCount, uint8 cCount, uint8 lCount) public { gCount = uint8(bound(gCount, 0, 8)); cCount = uint8(bound(cCount, 0, 10)); @@ -1408,10 +1719,56 @@ contract FleetIdentityTest is Test { (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertLe(count, 20); + } + + function testFuzz_buildBundle_noDuplicates(uint8 gCount, uint8 cCount, uint8 lCount) public { + gCount = uint8(bound(gCount, 0, 6)); + cCount = uint8(bound(cCount, 0, 8)); + lCount = uint8(bound(lCount, 0, 8)); + + for (uint256 i = 0; i < gCount; i++) { + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(40_000 + i)); + } + for (uint256 i = 0; i < cCount; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(41_000 + i), US); + } + for (uint256 i = 0; i < lCount; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(42_000 + i), US, ADMIN_CA); + } + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + for (uint256 i = 0; i < count; i++) { + for (uint256 j = i + 1; j < count; j++) { + assertTrue(uuids[i] != uuids[j], "Fuzz: duplicate UUID"); + } + } + } + + function testFuzz_buildBundle_allReturnedUUIDsExist(uint8 gCount, uint8 cCount, uint8 lCount) public { + gCount = uint8(bound(gCount, 0, 6)); + cCount = uint8(bound(cCount, 0, 8)); + lCount = uint8(bound(lCount, 0, 8)); - uint256 total = uint256(gCount) + uint256(cCount) + uint256(lCount); - if (total <= 20) { - assertEq(count, total); + for (uint256 i = 0; i < gCount; i++) { + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(50_000 + i)); + } + for (uint256 i = 0; i < cCount; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(51_000 + i), US); + } + for (uint256 i = 0; i < lCount; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(52_000 + i), US, ADMIN_CA); + } + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + for (uint256 i = 0; i < count; i++) { + uint256 tokenId = uint256(uint128(uuids[i])); + assertTrue(fleet.ownerOf(tokenId) != address(0), "Fuzz: UUID does not exist"); } } } From f0a658f93354c533d4c70f8f13815ec3d7f1bdd2 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 17 Feb 2026 11:39:18 +1300 Subject: [PATCH 11/15] =?UTF-8?q?Rename=20variable=20sc=20=E2=86=92=20tier?= =?UTF-8?q?Count=20for=20clarity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improved readability by replacing cryptic 'sc' abbreviation with descriptive 'tierCount' name across all 10 occurrences in: - highestActiveTier - discoverHighestBondedTier (3 level checks) - buildHighestBondedUUIDBundle (3 cursor inits) - _openTier - _findOpenTierView - _trimTierCount --- src/swarms/FleetIdentity.sol | 64 ++++++++++++++++++------------------ 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 90da4d2d..c0113468 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -325,9 +325,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @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; + uint256 tierCount = regionTierCount[regionKey]; + if (tierCount == 0) return 0; + return tierCount - 1; } /// @notice Number of members in a specific tier of a region. @@ -377,9 +377,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // 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; + uint256 tierCount = regionTierCount[regionKey]; + if (tierCount > 0) { + tier = tierCount - 1; members = _regionTierMembers[regionKey][tier]; return (regionKey, tier, members); } @@ -387,18 +387,18 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // 2. Try country if (countryCode > 0) { regionKey = uint32(countryCode); - uint256 sc = regionTierCount[regionKey]; - if (sc > 0) { - tier = sc - 1; + uint256 tierCount = regionTierCount[regionKey]; + if (tierCount > 0) { + tier = tierCount - 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; + uint256 tierCount = regionTierCount[GLOBAL_REGION]; + if (tierCount > 0) { + tier = tierCount - 1; members = _regionTierMembers[GLOBAL_REGION][tier]; } // else: all empty, returns (0, 0, []) @@ -463,8 +463,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Level 0: admin area (highest priority) 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); + uint256 tierCount = regionTierCount[keys[0]]; + cursors[0] = tierCount > 0 ? int256(tierCount) - 1 : int256(-1); } else { cursors[0] = -1; } @@ -472,8 +472,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Level 1: country if (countryCode > 0) { keys[1] = uint32(countryCode); - uint256 sc = regionTierCount[keys[1]]; - cursors[1] = sc > 0 ? int256(sc) - 1 : int256(-1); + uint256 tierCount = regionTierCount[keys[1]]; + cursors[1] = tierCount > 0 ? int256(tierCount) - 1 : int256(-1); } else { cursors[1] = -1; } @@ -481,8 +481,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Level 2: global (lowest priority) { keys[2] = GLOBAL_REGION; - uint256 sc = regionTierCount[GLOBAL_REGION]; - cursors[2] = sc > 0 ? int256(sc) - 1 : int256(-1); + uint256 tierCount = regionTierCount[GLOBAL_REGION]; + cursors[2] = tierCount > 0 ? int256(tierCount) - 1 : int256(-1); } while (count < MAX_BONDED_UUID_BUNDLE_SIZE) { @@ -659,31 +659,31 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @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 tierCount = regionTierCount[region]; uint256 cap = tierCapacity(region); uint256 start = _regionLowestHint[region]; - for (uint256 i = start; i < sc; i++) { + for (uint256 i = start; i < tierCount; 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; + if (tierCount >= MAX_TIERS) revert MaxTiersReached(); + regionTierCount[region] = tierCount + 1; + _regionLowestHint[region] = tierCount; + return tierCount; } /// @dev View-only version of _openTier. function _findOpenTierView(uint32 region) internal view returns (uint256) { - uint256 sc = regionTierCount[region]; + uint256 tierCount = regionTierCount[region]; uint256 cap = tierCapacity(region); uint256 start = _regionLowestHint[region]; - for (uint256 i = start; i < sc; i++) { + for (uint256 i = start; i < tierCount; i++) { if (_regionTierMembers[region][i].length < cap) return i; } - if (sc >= MAX_TIERS) revert MaxTiersReached(); - return sc; + if (tierCount >= MAX_TIERS) revert MaxTiersReached(); + return tierCount; } /// @dev Swap-and-pop removal from a region's tier member array. @@ -706,11 +706,11 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @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--; + uint256 tierCount = regionTierCount[region]; + while (tierCount > 0 && _regionTierMembers[region][tierCount - 1].length == 0) { + tierCount--; } - regionTierCount[region] = sc; + regionTierCount[region] = tierCount; } // -- Region index maintenance -- From 70391f5c8b6b6a9aa854b86c191c011a02f730d0 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 17 Feb 2026 11:48:08 +1300 Subject: [PATCH 12/15] Reorder FleetBurned event parameters for better clarity - Move regionKey to 3rd position (indexed) - Move bondRefund to last position - Update FleetPromoted and FleetDemoted event declarations to include indexed modifiers on fromTier and toTier Event signature now: FleetBurned(address indexed owner, uint256 indexed tokenId, uint32 indexed regionKey, uint256 tierIndex, uint256 bondRefund) This groups indexed params first and places monetary values last. --- src/swarms/FleetIdentity.sol | 14 ++++++++------ test/FleetIdentity.t.sol | 10 ++++++---- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index c0113468..0b763bbd 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -146,10 +146,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { 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 FleetPromoted( + uint256 indexed tokenId, uint256 indexed fromTier, uint256 indexed toTier, uint256 additionalBond + ); + event FleetDemoted(uint256 indexed tokenId, uint256 indexed fromTier, uint256 indexed toTier, uint256 bondRefund); event FleetBurned( - address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 tierIndex + address indexed owner, uint256 indexed tokenId, uint32 indexed regionKey, uint256 tierIndex, uint256 bondRefund ); // ────────────────────────────────────────────── @@ -292,7 +294,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { BOND_TOKEN.safeTransfer(tokenOwner, refund); } - emit FleetBurned(tokenOwner, tokenId, refund, region, tier); + emit FleetBurned(tokenOwner, tokenId, region, tier, refund); } // ══════════════════════════════════════════════ @@ -662,7 +664,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 tierCount = regionTierCount[region]; uint256 cap = tierCapacity(region); uint256 start = _regionLowestHint[region]; - for (uint256 i = start; i < tierCount; i++) { + for (uint256 i = start; i < tierCount; ++i) { if (_regionTierMembers[region][i].length < cap) { _regionLowestHint[region] = i; return i; @@ -679,7 +681,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 tierCount = regionTierCount[region]; uint256 cap = tierCapacity(region); uint256 start = _regionLowestHint[region]; - for (uint256 i = start; i < tierCount; i++) { + for (uint256 i = start; i < tierCount; ++i) { if (_regionTierMembers[region][i].length < cap) return i; } if (tierCount >= MAX_TIERS) revert MaxTiersReached(); diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 6503f364..6b868bc7 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -66,10 +66,12 @@ contract FleetIdentityTest is Test { 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 FleetPromoted( + uint256 indexed tokenId, uint256 indexed fromTier, uint256 indexed toTier, uint256 additionalBond + ); + event FleetDemoted(uint256 indexed tokenId, uint256 indexed fromTier, uint256 indexed toTier, uint256 bondRefund); event FleetBurned( - address indexed owner, uint256 indexed tokenId, uint256 bondRefund, uint32 regionKey, uint256 tierIndex + address indexed owner, uint256 indexed tokenId, uint32 indexed regionKey, uint256 tierIndex, uint256 bondRefund ); function setUp() public { @@ -595,7 +597,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetGlobal(UUID_1); vm.expectEmit(true, true, true, true); - emit FleetBurned(alice, tokenId, BASE_BOND, GLOBAL, 0); + emit FleetBurned(alice, tokenId, GLOBAL, 0, BASE_BOND); vm.prank(alice); fleet.burn(tokenId); From 024e814848119f4f17bd758a94051c861214b8f9 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 17 Feb 2026 14:26:24 +1300 Subject: [PATCH 13/15] =?UTF-8?q?feat(fleet):=20shared-cursor=20fair-stop?= =?UTF-8?q?=20algorithm=20for=20buildHighestBondedUUIDBundle\n\nReplace=20?= =?UTF-8?q?the=20greedy=20all-or-nothing=20algorithm=20with=20a=20shared-c?= =?UTF-8?q?ursor\nfair-stop=20approach:\n\n-=20Single=20cursor=20descends?= =?UTF-8?q?=20from=20the=20highest=20active=20tier=20across=20all=20levels?= =?UTF-8?q?\n-=20At=20each=20cursor:=20try=20admin=20=E2=86=92=20country?= =?UTF-8?q?=20=E2=86=92=20global,=20include=20entire=20tier=20or=20skip\n-?= =?UTF-8?q?=20If=20ANY=20tier=20is=20skipped=20at=20a=20cursor=20position,?= =?UTF-8?q?=20STOP=20after=20finishing=20that=20cursor\n-=20Prevents=20che?= =?UTF-8?q?aper=20tiers=20from=20being=20included=20when=20a=20same-priced?= =?UTF-8?q?=20peer=20was=20excluded\n\nTest=20suite=20rewritten:=2036=20te?= =?UTF-8?q?sts=20(32=20deterministic=20+=204=20fuzz)=20covering\nbond=20pr?= =?UTF-8?q?iority,=20all-or-nothing,=20fair-stop,=20level=20filtering,=20l?= =?UTF-8?q?ifecycle,\ncap=20enforcement,=20and=20integrity=20invariants."?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swarms/FleetIdentity.sol | 132 +++--- test/FleetIdentity.t.sol | 867 ++++++++++++++++++++++++----------- 2 files changed, 677 insertions(+), 322 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 0b763bbd..628966ad 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -426,25 +426,32 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// UUIDs for an EdgeBeaconScanner, merging the highest-bonded tiers across admin-area, /// country, and global levels. /// - /// **Algorithm – Greedy All-or-Nothing with Level Priority** + /// **Algorithm – Shared-Cursor All-or-Nothing with Fair Stop** /// - /// 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. For every level whose cursor bond equals that maximum, - /// try to include the **entire** tier (all members). If the - /// tier fits in the remaining room, include it; otherwise - /// **skip it entirely** (never take a partial tier). - /// Levels are tried in priority order: admin → country → global. - /// 4. Advance those cursors downward regardless of whether - /// the tier was included or skipped. - /// 5. Repeat until the bundle is full or all cursors exhausted. + /// Uses a single shared tier-index cursor that descends from the + /// highest active tier across all three levels. At each cursor + /// position: /// - /// This guarantees that every included tier is complete (all-or-nothing), - /// respects geographic priority when bonds are tied, and never starves - /// lower-priority levels when a higher-priority level's tier is too - /// large to fit. + /// 1. Try to include each level's FULL tier at the current cursor + /// in priority order: admin → country → global. + /// A tier is included only if **all** its members fit in the + /// remaining bundle capacity. If a tier does not fit it is + /// skipped and a `skipped` flag is set. + /// + /// 2. After processing all three levels at this cursor position: + /// - If any level was **skipped** (tier existed but didn't fit), + /// **STOP** — do not descend to lower tiers. Delivering a + /// smaller bundle is preferred over including lower-bonded + /// tiers from some categories while a higher-bonded tier + /// from another category was excluded. + /// - Otherwise, decrement the cursor and repeat. + /// + /// This guarantees: + /// - A tier is never partially collected (all-or-nothing). + /// - No category advances to cheaper tiers while a peer category's + /// more expensive tier was skipped for capacity reasons. + /// - Local is preferred (tried first), but fairness across levels + /// is maintained by the stop rule. /// /// @param countryCode EdgeBeaconScanner country (0 to skip country + admin). /// @param adminCode EdgeBeaconScanner admin area (0 to skip admin). @@ -457,74 +464,83 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { { 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". + // Resolve region keys for each level. uint32[3] memory keys; - int256[3] memory cursors; + bool[3] memory active; // Level 0: admin area (highest priority) if (countryCode > 0 && adminCode > 0) { keys[0] = (uint32(countryCode) << 12) | uint32(adminCode); - uint256 tierCount = regionTierCount[keys[0]]; - cursors[0] = tierCount > 0 ? int256(tierCount) - 1 : int256(-1); - } else { - cursors[0] = -1; + active[0] = true; } // Level 1: country if (countryCode > 0) { keys[1] = uint32(countryCode); - uint256 tierCount = regionTierCount[keys[1]]; - cursors[1] = tierCount > 0 ? int256(tierCount) - 1 : int256(-1); - } else { - cursors[1] = -1; + active[1] = true; } // Level 2: global (lowest priority) - { - keys[2] = GLOBAL_REGION; - uint256 tierCount = regionTierCount[GLOBAL_REGION]; - cursors[2] = tierCount > 0 ? int256(tierCount) - 1 : int256(-1); + keys[2] = GLOBAL_REGION; + active[2] = true; + + // Find the highest active tier index across all levels. + uint256 maxTierIndex = 0; + bool anyActive = false; + for (uint256 lvl = 0; lvl < 3; ++lvl) { + if (!active[lvl]) continue; + uint256 tierCount = regionTierCount[keys[lvl]]; + if (tierCount > 0) { + anyActive = true; + if (tierCount - 1 > maxTierIndex) { + maxTierIndex = tierCount - 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) { + assembly { + mstore(uuids, 0) } + return (uuids, 0); + } + + // Descend from the highest tier index using a shared cursor. + for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { + if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; - if (!anyActive) break; + bool skipped = false; - // Try each level whose cursor bond == maxBond in priority order. - // Include the entire tier only if ALL members fit; skip otherwise. - for (uint256 lvl = 0; lvl < 3; lvl++) { - if (cursors[lvl] < 0) continue; - if (tierBond(uint256(cursors[lvl])) != maxBond) continue; + // Try each level at this cursor position in priority order. + for (uint256 lvl = 0; lvl < 3; ++lvl) { + if (!active[lvl]) continue; - uint256[] storage members = _regionTierMembers[keys[lvl]][uint256(cursors[lvl])]; + // Skip if this level doesn't have a tier at this cursor index. + uint256 tierCount = regionTierCount[keys[lvl]]; + if (tierCount == 0 || uint256(cursor) >= tierCount) continue; + + uint256[] storage members = _regionTierMembers[keys[lvl]][uint256(cursor)]; uint256 mLen = members.length; + + if (mLen == 0) continue; + uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; - // All-or-nothing: include only if the entire tier fits. if (mLen <= room) { - for (uint256 m = 0; m < mLen; m++) { + // All members fit — include the entire tier. + for (uint256 m = 0; m < mLen; ++m) { uuids[count] = bytes16(uint128(members[m])); - count++; + ++count; } + } else { + // Tier exists with members but doesn't fit — mark as skipped. + skipped = true; } - // else: skip this tier entirely (too large for remaining room). - - // Always advance cursor downward regardless. - cursors[lvl]--; } + + // Fair-stop rule: if any tier was skipped at this cursor level, + // do NOT descend to cheaper tiers. Deliver the bundle as-is. + if (skipped) break; } // Trim the array to actual size. diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 6b868bc7..94133d40 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1204,7 +1204,9 @@ contract FleetIdentityTest is Test { assertEq(asc, 1); } - // --- buildHighestBondedUUIDBundle (all-or-nothing) --- + // --- buildHighestBondedUUIDBundle (shared-cursor fair-stop) --- + + // ── Empty / Single-level basics ── function test_buildBundle_emptyReturnsZero() public view { (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); @@ -1238,329 +1240,433 @@ contract FleetIdentityTest is Test { assertEq(uuids[0], UUID_1); } - function test_buildBundle_higherBondFirst() public { - // Global: tier 0 (bond=100) + // ── Same cursor, all levels at tier 0 ── + + function test_buildBundle_allLevelsTied_levelPriorityOrder() public { + // All at tier 0 → shared cursor 0 → level priority: admin, country, global + vm.prank(alice); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + vm.prank(alice); + fleet.registerFleetCountry(UUID_2, US); vm.prank(alice); fleet.registerFleetGlobal(UUID_1); - // Country US: promote to tier 2 (bond=400) + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 3); + assertEq(uuids[0], UUID_3); // admin first + assertEq(uuids[1], UUID_2); // country second + assertEq(uuids[2], UUID_1); // global last + } + + function test_buildBundle_allLevelsTier0_fullCapacity() public { + // 8 local + 8 country + 4 global = 20 = bundle cap + _registerNLocal(alice, US, ADMIN_CA, 8, 1000); + _registerNCountry(alice, US, 8, 2000); + _registerNGlobal(alice, 4); + + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 20); + } + + function test_buildBundle_allLevelsTier0_partialFill() public { + // 3 local + 2 country + 1 global = 6 + _registerNLocal(alice, US, ADMIN_CA, 3, 1000); + _registerNCountry(alice, US, 2, 2000); + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(3000)); + + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 6); + } + + // ── Bond priority: higher tier index = higher bond = comes first ── + + function test_buildBundle_higherBondFirst() public { + // Global: tier 0 (bond=BASE) + vm.prank(alice); + fleet.registerFleetGlobal(UUID_1); + // Country: promote to tier 2 (bond=4*BASE) vm.prank(alice); uint256 usId = fleet.registerFleetCountry(UUID_2, US); vm.prank(alice); fleet.reassignTier(usId, 2); - - // Admin US-CA: tier 0 (bond=100) + // Admin: tier 0 (bond=BASE) vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 3); - // Country tier 2 (bond=400) comes first - assertEq(uuids[0], UUID_2); + // Cursor=2: only country has tier 2 → include uuid2. Count=1. + // Cursor=1: all empty. Descend. + // Cursor=0: admin(uuid3) + global(uuid1). Count=3. + assertEq(uuids[0], UUID_2); // highest bond first } - function test_buildBundle_allLevelsTied_levelPriorityOrder() public { - // All at tier 0 → same bond → level priority: admin(0), country(1), global(2) + function test_buildBundle_multiTierDescendingBond() public { + // Local tier 2 (bond=4*BASE) vm.prank(alice); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(alice); - fleet.registerFleetCountry(UUID_2, US); + fleet.reassignTier(id1, 2); + + // Country tier 1 (bond=2*BASE) vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + uint256 id2 = fleet.registerFleetCountry(UUID_2, US); + vm.prank(alice); + fleet.reassignTier(id2, 1); + + // Global tier 0 (bond=BASE) + vm.prank(alice); + fleet.registerFleetGlobal(UUID_3); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 3); - // Admin first, then country, then global - assertEq(uuids[0], UUID_3); // admin - assertEq(uuids[1], UUID_2); // country - assertEq(uuids[2], UUID_1); // global + // Cursor=2: admin(1)→include. Count=1. + // Cursor=1: country(1)→include. Count=2. + // Cursor=0: global(1)→include. Count=3. + assertEq(uuids[0], UUID_1); // bond=4*BASE + assertEq(uuids[1], UUID_2); // bond=2*BASE + assertEq(uuids[2], UUID_3); // bond=BASE } - function test_buildBundle_tierSkippedWhenDoesntFit() public { - // Fill 19 slots, then a tier with 2 members should be skipped (room=1). - // Admin: 8 in tier 0, 8 in tier 1 = 16 at two bond levels - _registerNLocal(alice, US, ADMIN_CA, 8, 5000); - _registerNLocal(alice, US, ADMIN_CA, 8, 5100); - // now admin has tier 0 (8 members, bond=100) and tier 1 (8 members, bond=200) + function test_buildBundle_multiTierMultiLevel_correctOrder() public { + // Admin: tier 0 (8 members) + tier 1 (1 member) + _registerNLocal(alice, US, ADMIN_CA, 8, 8000); + vm.prank(alice); + fleet.registerFleetLocal(_uuid(8100), US, ADMIN_CA); - // Country: 3 members at tier 0 (bond=100) - _registerNCountry(alice, US, 3, 6000); + // 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: 1 member at tier 1 (bond=200) + // Global: promote to tier 2 (bond=400) vm.prank(alice); - fleet.registerFleetGlobal(_uuid(7000), 1); + uint256 globalId = fleet.registerFleetGlobal(_uuid(8300)); + vm.prank(alice); + fleet.reassignTier(globalId, 2); - // Step 1: maxBond = 200. Admin tier 1 = 8 members (room=20), fits → included. Global tier 1 = 1 member (room=12), fits → included. count=9. - // Step 2: maxBond = 100. Admin tier 0 = 8 members (room=11), fits → included. count=17. - // Country tier 0 = 3 members (room=3), fits → included. count=20. - // Total: 20 - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - assertEq(count, 20); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=2: global(1)→include. Count=1. + // Cursor=1: admin(1)+country(1)→include. Count=3. + // Cursor=0: admin(8)→include. Count=11. + assertEq(count, 11); + assertEq(uuids[0], fleet.tokenUUID(globalId)); // tier 2 first } - function test_buildBundle_skipLargerTakeSmallerAtSameBond() public { - // Admin tier 0: fill with 8 members (bond=100) - _registerNLocal(alice, US, ADMIN_CA, 8, 5000); - - // Country tier 0: 3 members (bond=100) - _registerNCountry(alice, US, 3, 6000); + // ── All-or-nothing ── - // Global tier 0: 4 members (bond=100) - _registerNGlobal(alice, 4); - - // Fill admin tier 1 to eat up room: 5 members at tier 1 (bond=200) - for (uint256 i = 0; i < 5; i++) { + function test_buildBundle_allOrNothing_tierSkippedWhenDoesntFit() public { + // Fill room so that at a cursor position a tier can't fit. + // Admin tier 1: 8 members (bond=200) + for (uint256 i = 0; i < 8; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(7000 + i), US, ADMIN_CA, 1); + fleet.registerFleetLocal(_uuid(5100 + i), US, ADMIN_CA, 1); } + // Country tier 1: 8 members (bond=200) + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(6100 + i), US, 1); + } + // Global tier 1: 1 member (bond=200) + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(7100), 1); - // Step 1: maxBond = 200. Admin tier 1 = 5 (room=20), fits → count=5. - // Step 2: maxBond = 100. All levels at tier 0. - // admin tier 0 = 8 (room=15), fits → count=13. - // country tier 0 = 3 (room=7), fits → count=16. - // global tier 0 = 4 (room=4), fits → count=20. - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - assertEq(count, 20); - } - - function test_buildBundle_skipLargerTierTakeSmallerNextRound() public { - // Scenario: at same bond, admin tier is too big but country tier fits. - // Admin tier 0: 8 members (bond=100) + // Tier 0: admin(8), country(3), global(2) _registerNLocal(alice, US, ADMIN_CA, 8, 5000); - - // Country tier 0: 2 members (bond=100) - _registerNCountry(alice, US, 2, 6000); - - // Global tier 0: 2 members (bond=100) + _registerNCountry(alice, US, 3, 6000); vm.prank(alice); fleet.registerFleetGlobal(_uuid(7000)); vm.prank(alice); fleet.registerFleetGlobal(_uuid(7001)); - // Now fill room: register 11 at admin tier 1 to force only 9 remaining room for tier 0. - // Actually let's approach differently: set up admin tier 1 with enough to leave room < 8 for tier 0. - // Register 13 in admin tier 1 => room left = 7 for tier 0 round - // But admin tier capacity is 8, so we can register up to 8 per tier. - // Let's use a simpler approach: register at admin tier 1 to fill more room. - for (uint256 i = 0; i < 8; i++) { + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=1: admin(8)+country(8)+global(1)=17. Count=17, room=3. + // Cursor=0: admin(8)>3→SKIP. country(3)≤3→include[count=20,room=0]. + // global(2)>0→SKIP. skipped=true→STOP. + assertEq(count, 20); + } + + function test_buildBundle_allOrNothing_noPartialCollection() public { + // Room=3, tier has 5 members → entire tier skipped. + // Global tier 1: 4 members (bond=200) + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(8000 + i), US, ADMIN_CA, 1); + fleet.registerFleetGlobal(_uuid(1000 + i), 1); } // Admin tier 1: 8 members (bond=200) - - // Now also country tier 1: 5 members (bond=200) + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(2000 + i), US, ADMIN_CA, 1); + } + // Country tier 1: 5 members (bond=200) for (uint256 i = 0; i < 5; i++) { vm.prank(alice); - fleet.registerFleetCountry(_uuid(9000 + i), US, 1); + fleet.registerFleetCountry(_uuid(3000 + i), US, 1); } - // Step 1: maxBond=200. Admin tier 1 = 8 (room=20), fits → count=8. - // Country tier 1 = 5 (room=12), fits → count=13. - // Step 2: maxBond=100. All at tier 0. - // Admin tier 0 = 8 (room=7) → SKIP (8 > 7). - // Country tier 0 = 2 (room=7) → fits → count=15. - // Global tier 0 = 2 (room=5) → fits → count=17. - // Total: 17 (admin tier 0 skipped entirely) (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=1: admin(8)+country(5)+global(4)=17. Count=17. Room=3. + // Cursor=0: all empty at tier 0. Done. assertEq(count, 17); } - function test_buildBundle_noStarvation_smallerLevelIncludedWhenLargerSkipped() public { - // Test that global isn't starved when a larger admin tier is skipped. - // Admin tier 0: 8 members (bond=100). This will be too big. + function test_buildBundle_allOrNothing_partialTierNeverIncluded() public { + // Verify by filling room so a tier of 4 has only 3 remaining slots. + // Global tier 0: 4 members (bond=100) + _registerNGlobal(alice, 4); + + // Admin: 8 at tier 1 (bond=200) + 8 at tier 0 (bond=100) _registerNLocal(alice, US, ADMIN_CA, 8, 5000); + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(5100 + i), US, ADMIN_CA, 1); + } - // Global tier 0: 2 members (bond=100) - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(7000)); + // Country: 1 at tier 1 (bond=200) vm.prank(alice); - fleet.registerFleetGlobal(_uuid(7001)); + fleet.registerFleetCountry(_uuid(6000), US, 1); - // Consume 15 slots at admin tier 1 (bond=200) – but capacity is 8 per tier. - // So 8 at tier 1 + 7 at tier 2 (need two tiers to eat 15 slots) - for (uint256 i = 0; i < 8; i++) { + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=1: admin(8)+country(1)=9. Count=9, room=11. + // Cursor=0: admin(8)≤11→include[count=17,room=3]. global(4)>3→SKIP→STOP. + assertEq(count, 17); + + // Verify no global UUID is in the result + for (uint256 i = 0; i < count; i++) { + uint256 tokenId = uint256(uint128(uuids[i])); + assertTrue(fleet.fleetRegion(tokenId) != GLOBAL, "Global UUID should not appear"); + } + } + + // ── Fair-stop rule ── + + function test_buildBundle_fairStop_stopsWhenAnyTierSkipped() public { + // At cursor=0: admin(8) fits, country(8) doesn't → STOP. + + // Consume 6 slots at tier 1. + for (uint256 i = 0; i < 3; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(8000 + i), US, ADMIN_CA, 1); + fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); } - for (uint256 i = 0; i < 7; i++) { + for (uint256 i = 0; i < 3; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(9000 + i), US, ADMIN_CA, 2); + fleet.registerFleetCountry(_uuid(2000 + i), US, 1); } - // Admin tier 2: 7 members (bond=400), tier 1: 8 members (bond=200), tier 0: 8 (bond=100) - // Step 1: maxBond=400. Admin tier 2 = 7 (room=20), fits → count=7. - // Step 2: maxBond=200. Admin tier 1 = 8 (room=13), fits → count=15. - // Step 3: maxBond=100. Admin tier 0 = 8 (room=5) → SKIP. - // Global tier 0 = 2 (room=5) → fits → count=17. - // Total: 17 — global not starved. + // Tier 0: full capacities. + _registerNLocal(alice, US, ADMIN_CA, 8, 3000); + _registerNCountry(alice, US, 8, 4000); + _registerNGlobal(alice, 4); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - assertEq(count, 17); + // Cursor=1: admin(3)+country(3)=6. Count=6, room=14. + // Cursor=0: admin(8)≤14→include[count=14,room=6]. + // country(8)>6→SKIP. + // global(4)≤6→include[count=18,room=2]. + // skipped=true→STOP. + assertEq(count, 18); } - 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)); + function test_buildBundle_fairStop_globalNotStarvedByLocalFill() public { + // Two local tiers consume 16 slots, leaving 4 for cursor=0. + // At cursor=0: local(8)>4→skip→STOP. + // Global tier 0 (4 members) would fit but is NOT included. + // This is fair: same-price local fleets couldn't fit, so no cheaper + // tier should be shown from any category. - (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) = 8 + global tier 0 (bond=100) = 1 → 9 fit → count=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]))); - } + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); + } + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(2000 + i), US, ADMIN_CA, 2); + } - function test_buildBundle_capsAt20() public { - // Fill global: 4+4+4 = 12 in 3 tiers - _registerNGlobal(alice, 12); - // Fill country US: 8+8 = 16 in 2 tiers - _registerNCountry(bob, US, 16, 1000); + // Tier 0: 8 local + 4 global + _registerNLocal(alice, US, ADMIN_CA, 8, 3000); + _registerNGlobal(alice, 4); - // Global tiers: tier 0(4), tier 1(4), tier 2(4). Country tiers: tier 0(8), tier 1(8). - // Step 1: maxBond=400 → global tier 2 = 4 (room=20), fits → count=4. - // Step 2: maxBond=200 → country tier 1 = 8 (room=16), fits → count=12. global tier 1 = 4, fits → count=16. - // Step 3: maxBond=100 → country tier 0 = 8 (room=4) → SKIP. global tier 0 = 4 (room=4) → fits → count=20. - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=2: admin(8)→include. Count=8. + // Cursor=1: admin(8)→include. Count=16, room=4. + // Cursor=0: admin(8)>4→SKIP. global(4)≤4→include[count=20,room=0]. + // skipped=true→STOP. assertEq(count, 20); } - function test_buildBundle_onlyGlobalWhenNoCountryCode() public { - vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); - vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US); + function test_buildBundle_fairStop_smallerLevelsIncludedBeforeStopFires() public { + // At cursor=0: admin(3) fits, country(8) doesn't, global(2) fits. + // All three are processed at this cursor level. The stop fires AFTER + // the inner loop, so admin and global at this cursor get included. - // countryCode=0 → skip country and admin levels - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(0, 0); - assertEq(count, 1); // only global - } + // Consume 10 slots at tier 1. + for (uint256 i = 0; i < 5; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); + } + for (uint256 i = 0; i < 5; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(2000 + i), US, 1); + } - function test_buildBundle_skipAdminWhenAdminCodeZero() public { + // Tier 0: admin=3, country=8, global=2 + _registerNLocal(alice, US, ADMIN_CA, 3, 3000); + _registerNCountry(alice, US, 8, 4000); vm.prank(alice); - fleet.registerFleetCountry(UUID_1, US); - vm.prank(bob); - fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + fleet.registerFleetGlobal(_uuid(5000)); + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(5001)); - // adminCode=0 → skip admin level - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); - assertEq(count, 1); // only country + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=1: admin(5)+country(5)=10. Count=10, room=10. + // Cursor=0: admin(3)≤10→include[count=13,room=7]. + // country(8)>7→SKIP(skipped=true). + // global(2)≤5→include[count=15]. + // skipped=true→STOP (no lower cursors anyway). + assertEq(count, 15); + + // Verify admin tier 0 is present + bool foundAdmin = false; + for (uint256 i = 0; i < count; i++) { + if (uuids[i] == _uuid(3000)) foundAdmin = true; + } + assertTrue(foundAdmin, "admin tier 0 should be included"); + + // Verify global tier 0 is present + bool foundGlobal = false; + for (uint256 i = 0; i < count; i++) { + if (uuids[i] == _uuid(5000)) foundGlobal = true; + } + assertTrue(foundGlobal, "global included at same cursor despite country skip"); } - 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); + function test_buildBundle_fairStop_doesNotDescendAfterSkip() public { + // After a skip at cursor=1, cursor=0 tiers are NOT included. - // Country: promote to tier 1 (bond=200) - vm.prank(alice); - uint256 countryId = fleet.registerFleetCountry(_uuid(8200), US); - vm.prank(alice); - fleet.reassignTier(countryId, 1); + // Tier 1: admin(8) + country(8) + global(4) = 20 → fills bundle. + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); + } + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(2000 + i), US, 1); + } + for (uint256 i = 0; i < 4; i++) { + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(3000 + i), 1); + } - // Global: promote to tier 2 (bond=400) + // Tier 0: extras that should NOT be included vm.prank(alice); - uint256 globalId = fleet.registerFleetGlobal(_uuid(8300)); + fleet.registerFleetLocal(_uuid(4000), US, ADMIN_CA); vm.prank(alice); - fleet.reassignTier(globalId, 2); + fleet.registerFleetCountry(_uuid(4001), US); + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(4002)); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - // Step 1: global tier 2 (bond=400) → 1 member → count=1 - // Step 2: admin tier 1 (bond=200) + country tier 1 (bond=200) → 1+1=2 → count=3 - // Step 3: admin tier 0 (bond=100) → 8 → count=11 - assertEq(count, 11); - assertEq(uuids[0], fleet.tokenUUID(globalId)); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=1: admin(8)+country(8)+global(4)=20. Bundle full. + assertEq(count, 20); } - function test_buildBundle_exhaustsAllLevels() public { + function test_buildBundle_fairStop_skipAtHigherTierStopsAll() public { + // Cursor=2: admin(3)→include. Count=3. + // Cursor=1: admin(8)≤17→include[count=11,room=9]. + // country(8)≤9→include[count=19,room=1]. + // global(4)>1→SKIP→STOP. + // Tier 0 is never visited. + + for (uint256 i = 0; i < 3; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 2); + } + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(2000 + i), US, ADMIN_CA, 1); + } + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(3000 + i), US, 1); + } + for (uint256 i = 0; i < 4; i++) { + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(4000 + i), 1); + } + + // Tier 0 extras (should NOT be included): vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetLocal(_uuid(5000), US, ADMIN_CA); vm.prank(alice); - fleet.registerFleetCountry(UUID_2, US); + fleet.registerFleetCountry(_uuid(5001), US); vm.prank(alice); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + fleet.registerFleetGlobal(_uuid(5002)); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - assertEq(count, 3); + assertEq(count, 19); - bool found1; - bool found2; - bool found3; + // Verify tier 0 members are NOT in the bundle. 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(uuids[i] != _uuid(5000), "local tier 0 should not be included"); + assertTrue(uuids[i] != _uuid(5001), "country tier 0 should not be included"); + assertTrue(uuids[i] != _uuid(5002), "global tier 0 should not be included"); } - assertTrue(found1 && found2 && found3); } - function test_buildBundle_allOrNothing_partialTierNeverIncluded() public { - // Verify by filling room so a tier of 4 has only 3 remaining slots. - // Global tier 0: 4 members (bond=100) - _registerNGlobal(alice, 4); + function test_buildBundle_fairStop_noDescentWhenPeerSkipped() public { + // At cursor=0, admin(8) tried first but doesn't fit → SKIP. + // Country and global NOT included even if they fit (fair-stop). - // Admin: 8 at tier 1 (bond=200) to consume space, 8 at tier 0 (bond=100) - _registerNLocal(alice, US, ADMIN_CA, 8, 5000); + // Admin tier 1: 8 (bond=200), Country tier 1: 5 (bond=200) for (uint256 i = 0; i < 8; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(5100 + i), US, ADMIN_CA, 1); + fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); + } + for (uint256 i = 0; i < 5; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(2000 + i), US, 1); } - // Country: 1 at tier 1 (bond=200) - vm.prank(alice); - fleet.registerFleetCountry(_uuid(6000), US, 1); - - // Step 1: maxBond=200. Admin tier 1 = 8 (room=20) fits → count=8. Country tier 1 = 1 (room=12) fits → count=9. - // Step 2: maxBond=100. Admin tier 0 = 8 (room=11) fits → count=17. - // Global tier 0 = 4 (room=3) → SKIP entirely. - // Total: 17 (global tier 0 skipped, no partial) - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - assertEq(count, 17); + // Tier 0: admin=8, country=8 + _registerNLocal(alice, US, ADMIN_CA, 8, 3000); + _registerNCountry(alice, US, 8, 4000); - // Verify no global UUID is in the result - for (uint256 i = 0; i < count; i++) { - uint256 tokenId = uint256(uint128(uuids[i])); - assertTrue(fleet.fleetRegion(tokenId) != GLOBAL, "Global UUID should not appear"); - } + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=1: admin(8)+country(5)=13. Count=13, room=7. + // Cursor=0: admin(8)>7→SKIP→STOP. + assertEq(count, 13); } - function test_buildBundle_allOrNothing_tieBreaker_adminBeforeCountryBeforeGlobal() public { - // When room is exactly 8, admin tier 0 (8 members) is tried before country tier 0 (8 members). - // Only one of them can fit. Admin should win because of level priority. + // ── Tie-breaker: admin before country before global at same cursor ── - // Eat 12 room at higher bonds first. + function test_buildBundle_tieBreaker_adminBeforeCountryBeforeGlobal() public { + // Room=8 after higher tiers. Admin tier 0 (8) tried before country tier 0 (8). + // Admin fits, then country doesn't → STOP. + + // Eat 12 room at tier 1. for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetGlobal(_uuid(1000 + i), 1); } - // global tier 1 = 4 (bond=200) - - // Admin tier 1: 8 members (bond=200) for (uint256 i = 0; i < 8; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(2000 + i), US, ADMIN_CA, 1); } - // Now room after step 1 = 20 - 12 = 8. - // Admin tier 0: 8 members (bond=100) + // Tier 0: admin=8, country=8 _registerNLocal(alice, US, ADMIN_CA, 8, 3000); - // Country tier 0: 8 members (bond=100) _registerNCountry(alice, US, 8, 4000); - // Step 1: maxBond=200. admin tier 1 = 8 (room=20), fits → count=8. global tier 1 = 4 (room=12), fits → count=12. - // Step 2: maxBond=100. admin tier 0 = 8 (room=8) → fits → count=20. - // country tier 0 = 8 (room=0) → SKIP. Global tier 0 = 0 (no tier 0). - // Total: 20 — admin prioritized over country at same bond. (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=1: admin(8)+global(4)=12. Count=12, room=8. + // Cursor=0: admin(8)≤8→include[count=20,room=0]. + // country(8)>0→SKIP→STOP. assertEq(count, 20); - // Verify all admin+global, zero country UUIDs + // Verify all admin+global, zero country uint256 adminCount; uint256 globalCount; uint256 countryCount; @@ -1576,8 +1682,149 @@ contract FleetIdentityTest is Test { assertEq(countryCount, 0); // skipped } + // ── Empty tiers and gaps ── + + function test_buildBundle_emptyTiersSkippedCleanly() public { + // Register at tier 0 then promote to tier 2, leaving tier 1 empty. + vm.prank(alice); + uint256 id = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + vm.prank(alice); + fleet.reassignTier(id, 2); + + vm.prank(alice); + fleet.registerFleetGlobal(UUID_2); + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=2: admin(1)→include. Count=1. + // Cursor=1: all empty. No skip. Descend. + // Cursor=0: global(1)→include. Count=2. + assertEq(count, 2); + assertEq(uuids[0], UUID_1); + assertEq(uuids[1], UUID_2); + } + + function test_buildBundle_multipleEmptyTiersInMiddle() public { + // Local at tier 5, global at tier 0. Tiers 1-4 empty. + vm.prank(alice); + uint256 id = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + vm.prank(alice); + fleet.reassignTier(id, 5); + vm.prank(alice); + fleet.registerFleetGlobal(UUID_2); + + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 2); + } + + function test_buildBundle_emptyTiersInMiddle_countryToo() public { + // Country: register at tier 0 and tier 2 (tier 1 empty) + vm.prank(alice); + fleet.registerFleetCountry(UUID_1, US); + vm.prank(alice); + fleet.registerFleetCountry(UUID_2, US, 2); + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + assertEq(count, 2); + assertEq(uuids[0], UUID_2); // higher bond first + assertEq(uuids[1], UUID_1); + } + + // ── Level filtering ── + + function test_buildBundle_onlyGlobalWhenNoCountryCode() public { + vm.prank(alice); + fleet.registerFleetGlobal(UUID_1); + vm.prank(bob); + fleet.registerFleetCountry(UUID_2, US); + + (, 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); + + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + assertEq(count, 1); // only country + } + + function test_buildBundle_multipleAdminAreas_isolated() public { + _registerNLocal(alice, US, ADMIN_CA, 5, 1000); + _registerNLocal(alice, US, ADMIN_NY, 5, 2000); + + (, uint256 countCA) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(countCA, 5); + (, uint256 countNY) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_NY); + assertEq(countNY, 5); + } + + // ── Single level, multiple tiers ── + + function test_buildBundle_singleLevelMultipleTiers() public { + // Only country, multiple tiers. + _registerNCountry(alice, US, 8, 1000); // tier 0: 8 members (bond=BASE) + _registerNCountry(alice, US, 8, 2000); // tier 1: 8 members (bond=2*BASE) + _registerNCountry(alice, US, 4, 3000); // tier 2: 4 members (bond=4*BASE) + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + assertEq(count, 20); + // Verify order: tier 2 first + uint256[] memory t2 = fleet.getTierMembers(_regionUS(), 2); + for (uint256 i = 0; i < 4; i++) { + assertEq(uuids[i], bytes16(uint128(t2[i]))); + } + } + + function test_buildBundle_singleLevelOnlyLocal() public { + _registerNLocal(alice, US, ADMIN_CA, 5, 1000); + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 5); + } + + function test_buildBundle_onlyCountryAndGlobal() public { + _registerNGlobal(alice, 4); + _registerNCountry(alice, US, 8, 1000); + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + assertEq(count, 12); + // Country first (level priority), then global. + assertEq(uuids[0], _uuid(1000)); + } + + // ── Shared cursor: different max tier indices per level ── + + function test_buildBundle_sharedCursor_levelsAtDifferentMaxTiers() public { + // Local at tier 3, Country at tier 1, Global at tier 0. + vm.prank(alice); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + vm.prank(alice); + fleet.reassignTier(id1, 3); + vm.prank(alice); + uint256 id2 = fleet.registerFleetCountry(UUID_2, US); + vm.prank(alice); + fleet.reassignTier(id2, 1); + vm.prank(alice); + fleet.registerFleetGlobal(UUID_3); + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 3); + assertEq(uuids[0], UUID_1); // tier 3 + assertEq(uuids[1], UUID_2); // tier 1 + assertEq(uuids[2], UUID_3); // tier 0 + } + + function test_buildBundle_sharedCursor_sameTierIndex_sameBond() public { + assertEq(fleet.tierBond(0), BASE_BOND); + assertEq(fleet.tierBond(1), BASE_BOND * 2); + assertEq(fleet.tierBond(2), BASE_BOND * 4); + } + + // ── Lifecycle ── + function test_buildBundle_afterBurn_reflects() public { - // Register 3 global, build bundle, burn one, rebuild. vm.prank(alice); uint256 id1 = fleet.registerFleetGlobal(UUID_1); vm.prank(bob); @@ -1595,41 +1842,82 @@ contract FleetIdentityTest is Test { assertEq(countAfter, 2); } - function test_buildBundle_singleLevelMultipleTiers() public { - // Only country, multiple tiers. - _registerNCountry(alice, US, 8, 1000); // tier 0: 8 members (bond=100) - _registerNCountry(alice, US, 8, 2000); // tier 1: 8 members (bond=200) - _registerNCountry(alice, US, 4, 3000); // tier 2: 4 members (bond=400) - - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); - // tier 2 (4) + tier 1 (8) + tier 0 (8) = 20, all fit - assertEq(count, 20); + 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); - // Verify order: tier 2 first, then tier 1, then tier 0 - // First 4 UUIDs from tier 2 - uint256[] memory t2 = fleet.getTierMembers(_regionUS(), 2); - for (uint256 i = 0; i < 4; i++) { - assertEq(uuids[i], bytes16(uint128(t2[i]))); + (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 test_buildBundle_emptyTiersInMiddle() public { - // Country: register at tier 0 and tier 2 (tier 1 is empty) + function test_buildBundle_lifecycle_promotionsAndBurns() public { vm.prank(alice); - fleet.registerFleetCountry(UUID_1, US); + uint256 g1 = fleet.registerFleetGlobal(_uuid(100)); vm.prank(alice); - fleet.registerFleetCountry(UUID_2, US, 2); + fleet.registerFleetGlobal(_uuid(101)); + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(102)); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); - // tier 2: 1 member (bond=400), tier 1: 0 members (skipped naturally), tier 0: 1 member (bond=100) - assertEq(count, 2); - assertEq(uuids[0], UUID_2); // higher bond first - assertEq(uuids[1], UUID_1); + vm.prank(alice); + uint256 c1 = fleet.registerFleetCountry(_uuid(200), US); + vm.prank(alice); + fleet.registerFleetCountry(_uuid(201), US); + + vm.prank(alice); + fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA); + + vm.prank(alice); + fleet.reassignTier(g1, 3); + vm.prank(alice); + fleet.reassignTier(c1, 1); + + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + // Cursor=3: global(1)→include. Count=1. + // Cursor=2: empty. Descend. + // Cursor=1: country(1)→include. Count=2. + // Cursor=0: admin(1)+country(1)+global(2)=4→include. Count=6. + assertEq(count, 6); + + vm.prank(alice); + fleet.burn(g1); + + (, count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 5); + } + + // ── Cap enforcement ── + + function test_buildBundle_capsAt20() public { + // Fill global: 4+4+4 = 12 in 3 tiers + _registerNGlobal(alice, 12); + // Fill country US: 8+8 = 16 in 2 tiers + _registerNCountry(bob, US, 16, 1000); + + // Cursor=2: global(4)→include. Count=4. + // Cursor=1: country(8)+global(4)=12. Count=16, room=4. + // Cursor=0: country(8)>4→SKIP→STOP. global(4)≤4 but skipped already fired. + // Actually global is processed after country at same cursor, so: + // country(8)>4→SKIP. global(4)≤4→include[count=20]. + // skipped=true→STOP. + (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + assertEq(count, 20); } function test_buildBundle_exactlyFillsToCapacity() public { - // Create exactly 20 members across levels, all at same bond. - // Admin: 8 (tier 0), Country: 8 (tier 0), Global: 4 (tier 0) = 20 + // 8 admin + 8 country + 4 global = 20 exactly, all tier 0. _registerNLocal(alice, US, ADMIN_CA, 8, 1000); _registerNCountry(alice, US, 8, 2000); _registerNGlobal(alice, 4); @@ -1638,40 +1926,38 @@ contract FleetIdentityTest is Test { assertEq(count, 20); } - function test_buildBundle_twentyOneOverflow_globalSkipped() public { - // 21 total: admin 8 + country 8 + global 4 + 1 extra country. - // Same bond for all in tier 0, but country spills to tier 1. + function test_buildBundle_twentyOneOverflow_fairStop() public { + // 21 total: admin 8 + country 8 + global 4 + 1 extra country at tier 1. _registerNLocal(alice, US, ADMIN_CA, 8, 1000); _registerNCountry(alice, US, 8, 2000); _registerNGlobal(alice, 4); - // 1 more country → goes to tier 1 (bond=200) vm.prank(alice); fleet.registerFleetCountry(_uuid(3000), US); - // Step 1: country tier 1 = 1 (bond=200, room=20), fits → count=1. - // Step 2: all tier 0 tied at bond=100. - // admin tier 0 = 8 (room=19), fits → count=9. - // country tier 0 = 8 (room=11), fits → count=17. - // global tier 0 = 4 (room=3) → SKIP. - // Total: 17 + // Cursor=1: country(1)→include. Count=1, room=19. + // Cursor=0: admin(8)≤19→include[count=9,room=11]. + // country(8)≤11→include[count=17,room=3]. + // global(4)>3→SKIP→STOP. (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 17); } - function test_buildBundle_multipleAdminAreas_onlyRequestedIncluded() public { - // Register in two different admin areas, only the requested one appears. - _registerNLocal(alice, US, ADMIN_CA, 5, 1000); - _registerNLocal(alice, US, ADMIN_NY, 5, 2000); + // ── Integrity ── - (, uint256 countCA) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - assertEq(countCA, 5); + function test_buildBundle_noDuplicateUUIDs() public { + _registerNLocal(alice, US, ADMIN_CA, 5, 1000); + _registerNCountry(bob, US, 4, 2000); + _registerNGlobal(carol, 3); - (, uint256 countNY) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_NY); - assertEq(countNY, 5); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + for (uint256 i = 0; i < count; i++) { + for (uint256 j = i + 1; j < count; j++) { + assertTrue(uuids[i] != uuids[j], "Duplicate UUID found"); + } + } } function test_buildBundle_noNonExistentUUIDs() public { - // Ensure returned UUIDs are valid token IDs. _registerNLocal(alice, US, ADMIN_CA, 3, 1000); _registerNCountry(bob, US, 2, 2000); vm.prank(carol); @@ -1679,28 +1965,41 @@ contract FleetIdentityTest is Test { (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 6); - for (uint256 i = 0; i < count; i++) { uint256 tokenId = uint256(uint128(uuids[i])); - // ownerOf reverts for nonexistent tokens assertTrue(fleet.ownerOf(tokenId) != address(0)); } } - function test_buildBundle_noDuplicateUUIDs() public { - _registerNLocal(alice, US, ADMIN_CA, 5, 1000); - _registerNCountry(bob, US, 4, 2000); - _registerNGlobal(carol, 3); + function test_buildBundle_allReturnedAreCompleteRegionTiers() public { + // Verify all-or-nothing: if any UUID from a region+tier appears, + // ALL members of that region+tier must be present. + _registerNLocal(alice, US, ADMIN_CA, 4, 1000); + _registerNCountry(alice, US, 3, 2000); + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(3000)); + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(3001)); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + uint256 localFound; + uint256 countryFound; + uint256 globalFound; for (uint256 i = 0; i < count; i++) { - for (uint256 j = i + 1; j < count; j++) { - assertTrue(uuids[i] != uuids[j], "Duplicate UUID found"); - } + uint256 tid = uint256(uint128(uuids[i])); + uint32 region = fleet.fleetRegion(tid); + if (region == _regionUSCA()) localFound++; + else if (region == _regionUS()) countryFound++; + else if (region == GLOBAL) globalFound++; } + assertTrue(localFound == 0 || localFound == 4, "partial local tier"); + assertTrue(countryFound == 0 || countryFound == 3, "partial country tier"); + assertTrue(globalFound == 0 || globalFound == 2, "partial global tier"); } + // ── Fuzz ── + function testFuzz_buildBundle_neverExceeds20(uint8 gCount, uint8 cCount, uint8 lCount) public { gCount = uint8(bound(gCount, 0, 8)); cCount = uint8(bound(cCount, 0, 10)); @@ -1773,4 +2072,44 @@ contract FleetIdentityTest is Test { assertTrue(fleet.ownerOf(tokenId) != address(0), "Fuzz: UUID does not exist"); } } + + function testFuzz_buildBundle_allOrNothingInvariant(uint8 gCount, uint8 cCount, uint8 lCount) public { + gCount = uint8(bound(gCount, 0, 6)); + cCount = uint8(bound(cCount, 0, 8)); + lCount = uint8(bound(lCount, 0, 8)); + + for (uint256 i = 0; i < gCount; i++) { + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(60_000 + i)); + } + for (uint256 i = 0; i < cCount; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(61_000 + i), US); + } + for (uint256 i = 0; i < lCount; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(62_000 + i), US, ADMIN_CA); + } + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + + // Group returned UUIDs by (region, tier). For each group, + // verify ALL members of that region+tier are present. + for (uint256 i = 0; i < count; i++) { + uint256 tid = uint256(uint128(uuids[i])); + uint32 region = fleet.fleetRegion(tid); + uint256 tier = fleet.fleetTier(tid); + + uint256 inBundle; + for (uint256 j = 0; j < count; j++) { + uint256 tjd = uint256(uint128(uuids[j])); + if (fleet.fleetRegion(tjd) == region && fleet.fleetTier(tjd) == tier) { + inBundle++; + } + } + + uint256 totalInTier = fleet.tierMemberCount(region, tier); + assertEq(inBundle, totalInTier, "Fuzz: partial tier detected"); + } + } } From 2cc01ce273c8160605c589f13a29f7423965e8f4 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 17 Feb 2026 15:57:18 +1300 Subject: [PATCH 14/15] feat(fleet): add competitive intelligence views (competitiveLandscape, globalCompetitiveHint, countryCompetitiveHint) --- src/swarms/FleetIdentity.sol | 151 +++++++++++++++++ test/FleetIdentity.t.sol | 318 +++++++++++++++++++++++++++++++++++ 2 files changed, 469 insertions(+) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 628966ad..81212bc2 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -362,6 +362,157 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return tierBond(fleetTier[tokenId]); } + /// @notice Returns competitive intelligence for a region so registrants + /// can decide which tier to target. + /// @param regionKey The region to inspect (0 = global, 1-999 = country, + /// ≥4096 = admin area). + /// @return topTier Highest active tier index (0 if no fleets). + /// @return topTierMembers Number of members in the top tier. + /// @return topTierCapacity Max members that tier can hold. + /// @return topTierBond Bond required to join the top tier. + /// @return nextTierBond Bond required to open the NEXT tier above + /// (0 if MAX_TIERS reached). + function competitiveLandscape(uint32 regionKey) + external + view + returns ( + uint256 topTier, + uint256 topTierMembers, + uint256 topTierCapacity, + uint256 topTierBond, + uint256 nextTierBond + ) + { + uint256 tc = regionTierCount[regionKey]; + if (tc == 0) { + // No fleets — first registrant gets tier 0. + topTierCapacity = tierCapacity(regionKey); + topTierBond = tierBond(0); + nextTierBond = tierBond(0); // joining tier 0 is "next" + return (0, 0, topTierCapacity, topTierBond, nextTierBond); + } + + topTier = tc - 1; + topTierMembers = _regionTierMembers[regionKey][topTier].length; + topTierCapacity = tierCapacity(regionKey); + topTierBond = tierBond(topTier); + nextTierBond = (topTier + 1 < MAX_TIERS) ? tierBond(topTier + 1) : 0; + } + + /// @notice Returns the minimum tier a **global** registrant must target to + /// guarantee their beacon is processed at the same cursor level as + /// the hottest local/country region — and therefore appears in + /// bundles for ANY location query before a fair-stop can fire. + /// + /// @dev Scans all active countries and admin areas to find the highest + /// tier index across every region. A global fleet registered at + /// this tier (or above) will be processed at the top cursor + /// position regardless of which (countryCode, adminCode) pair is + /// queried. + /// + /// @return hottestTier Highest active tier index across all regions. + /// @return hottestRegion The region key that holds it. + /// @return globalTopTier Current highest tier in the global region. + /// @return bondToMatch Bond required to register globally at hottestTier. + /// @return bondToOutcompete Bond to open the tier ABOVE hottestTier globally + /// (0 if MAX_TIERS reached). + function globalCompetitiveHint() + external + view + returns ( + uint256 hottestTier, + uint32 hottestRegion, + uint256 globalTopTier, + uint256 bondToMatch, + uint256 bondToOutcompete + ) + { + // Start with global's own tier count. + uint256 tc = regionTierCount[GLOBAL_REGION]; + if (tc > 0) { + hottestTier = tc - 1; + hottestRegion = GLOBAL_REGION; + } + globalTopTier = hottestTier; + + // Scan countries. + for (uint256 i = 0; i < _activeCountries.length; ++i) { + uint32 rk = uint32(_activeCountries[i]); + tc = regionTierCount[rk]; + if (tc > 0 && tc - 1 > hottestTier) { + hottestTier = tc - 1; + hottestRegion = rk; + } + } + + // Scan admin areas. + for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { + uint32 rk = _activeAdminAreas[i]; + tc = regionTierCount[rk]; + if (tc > 0 && tc - 1 > hottestTier) { + hottestTier = tc - 1; + hottestRegion = rk; + } + } + + bondToMatch = tierBond(hottestTier); + bondToOutcompete = (hottestTier + 1 < MAX_TIERS) ? tierBond(hottestTier + 1) : 0; + } + + /// @notice Returns the minimum tier a **country-level** registrant must + /// target so their beacon appears at the same cursor position as + /// the hottest admin area within the same country. + /// + /// @dev Scans `_activeAdminAreas`, filtering for entries whose country + /// code matches. Like `globalCompetitiveHint`, this is a view — + /// free off-chain — so no storage caching is needed. + /// + /// @param countryCode ISO 3166-1 numeric country code (1-999). + /// @return hottestTier Highest active tier index across the country + /// and its admin areas. + /// @return hottestRegion The region key that holds it. + /// @return countryTopTier Current highest tier in the country region itself. + /// @return bondToMatch Bond required to register at hottestTier. + /// @return bondToOutcompete Bond to open the tier ABOVE hottestTier + /// (0 if MAX_TIERS reached). + function countryCompetitiveHint(uint16 countryCode) + external + view + returns ( + uint256 hottestTier, + uint32 hottestRegion, + uint256 countryTopTier, + uint256 bondToMatch, + uint256 bondToOutcompete + ) + { + uint32 countryKey = uint32(countryCode); + + // Start with the country's own tier count. + uint256 tc = regionTierCount[countryKey]; + if (tc > 0) { + hottestTier = tc - 1; + hottestRegion = countryKey; + } + countryTopTier = hottestTier; + + // Scan admin areas belonging to this country. + // Admin region key = (countryCode << 12) | adminCode, so + // (regionKey >> 12) recovers the country code. + for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { + uint32 rk = _activeAdminAreas[i]; + if (uint16(rk >> 12) != countryCode) continue; + tc = regionTierCount[rk]; + if (tc > 0 && tc - 1 > hottestTier) { + hottestTier = tc - 1; + hottestRegion = rk; + } + } + + bondToMatch = tierBond(hottestTier); + bondToOutcompete = (hottestTier + 1 < MAX_TIERS) ? tierBond(hottestTier + 1) : 0; + } + // ══════════════════════════════════════════════ // Views: EdgeBeaconScanner discovery // ══════════════════════════════════════════════ diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 94133d40..aea364ea 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1204,6 +1204,324 @@ contract FleetIdentityTest is Test { assertEq(asc, 1); } + // --- competitiveLandscape --- + + function test_competitiveLandscape_emptyRegion() public view { + (uint256 topTier, uint256 members, uint256 cap, uint256 bond, uint256 nextBond) = + fleet.competitiveLandscape(GLOBAL); + assertEq(topTier, 0); + assertEq(members, 0); + assertEq(cap, 4); // GLOBAL_TIER_CAPACITY + assertEq(bond, BASE_BOND); + assertEq(nextBond, BASE_BOND); // first registrant gets tier 0 + } + + function test_competitiveLandscape_singleTier() public { + vm.prank(alice); + fleet.registerFleetGlobal(UUID_1); + + (uint256 topTier, uint256 members, uint256 cap, uint256 bond, uint256 nextBond) = + fleet.competitiveLandscape(GLOBAL); + assertEq(topTier, 0); + assertEq(members, 1); + assertEq(cap, 4); + assertEq(bond, BASE_BOND); + assertEq(nextBond, BASE_BOND * 2); + } + + function test_competitiveLandscape_multipleTiers() public { + _registerNGlobal(alice, 4); // fills tier 0 + vm.prank(bob); + fleet.registerFleetGlobal(_uuid(9000)); // auto → tier 1 + + (uint256 topTier, uint256 members, uint256 cap, uint256 bond, uint256 nextBond) = + fleet.competitiveLandscape(GLOBAL); + assertEq(topTier, 1); + assertEq(members, 1); + assertEq(cap, 4); + assertEq(bond, BASE_BOND * 2); + assertEq(nextBond, BASE_BOND * 4); + } + + function test_competitiveLandscape_countryRegion() public { + _registerNCountry(alice, US, 3, 1000); + + (uint256 topTier, uint256 members, uint256 cap, uint256 bond, uint256 nextBond) = + fleet.competitiveLandscape(_regionUS()); + assertEq(topTier, 0); + assertEq(members, 3); + assertEq(cap, 8); // COUNTRY_TIER_CAPACITY + assertEq(bond, BASE_BOND); + assertEq(nextBond, BASE_BOND * 2); + } + + function test_competitiveLandscape_adminRegion() public { + _registerNLocal(alice, US, ADMIN_CA, 8, 1000); // fills tier 0 + _registerNLocal(bob, US, ADMIN_CA, 5, 2000); // spill to tier 1 + + (uint256 topTier, uint256 members, uint256 cap, uint256 bond, uint256 nextBond) = + fleet.competitiveLandscape(_regionUSCA()); + assertEq(topTier, 1); + assertEq(members, 5); + assertEq(cap, 8); // LOCAL_TIER_CAPACITY + assertEq(bond, BASE_BOND * 2); + assertEq(nextBond, BASE_BOND * 4); + } + + function test_competitiveLandscape_afterBurn_reflects() public { + vm.prank(alice); + uint256 id = fleet.registerFleetGlobal(UUID_1); + + (, uint256 membersBefore,,,) = fleet.competitiveLandscape(GLOBAL); + assertEq(membersBefore, 1); + + vm.prank(alice); + fleet.burn(id); + + (uint256 topTier, uint256 membersAfter,,,) = fleet.competitiveLandscape(GLOBAL); + assertEq(topTier, 0); + assertEq(membersAfter, 0); + } + + function test_competitiveLandscape_isolatedRegions() public { + // US-CA and US-NY are independent + _registerNLocal(alice, US, ADMIN_CA, 5, 1000); + _registerNLocal(alice, US, ADMIN_NY, 2, 2000); + + (, uint256 caMembers,,,) = fleet.competitiveLandscape(_regionUSCA()); + (, uint256 nyMembers,,,) = fleet.competitiveLandscape(_regionUSNY()); + assertEq(caMembers, 5); + assertEq(nyMembers, 2); + } + + // --- globalCompetitiveHint --- + + function test_globalHint_emptyReturnsZeros() public view { + (uint256 hottestTier, uint32 hottestRegion, uint256 globalTop, uint256 matchBond, uint256 outcompeteBond) = + fleet.globalCompetitiveHint(); + assertEq(hottestTier, 0); + assertEq(hottestRegion, GLOBAL); + assertEq(globalTop, 0); + assertEq(matchBond, BASE_BOND); + assertEq(outcompeteBond, BASE_BOND * 2); + } + + function test_globalHint_onlyGlobalFleets() public { + _registerNGlobal(alice, 4); // fills tier 0 + vm.prank(bob); + fleet.registerFleetGlobal(_uuid(9000)); // spills to tier 1 + + (uint256 hottestTier,, uint256 globalTop,,) = fleet.globalCompetitiveHint(); + assertEq(hottestTier, 1); + assertEq(globalTop, 1); // global IS the hottest + } + + function test_globalHint_localAreaHotter() public { + // Global: tier 0 only + vm.prank(alice); + fleet.registerFleetGlobal(UUID_1); + + // US-CA: push to tier 3 via explicit registration + vm.prank(bob); + fleet.registerFleetLocal(_uuid(2000), US, ADMIN_CA, 3); + + (uint256 hottestTier, uint32 hottestRegion, uint256 globalTop, uint256 matchBond, uint256 outcompeteBond) = + fleet.globalCompetitiveHint(); + assertEq(hottestTier, 3); + assertEq(hottestRegion, _regionUSCA()); + assertEq(globalTop, 0); + // Bond to match tier 3 = BASE * 2^3 = 800 + assertEq(matchBond, BASE_BOND * 8); + // Bond to outcompete = tier 4 = BASE * 2^4 = 1600 + assertEq(outcompeteBond, BASE_BOND * 16); + } + + function test_globalHint_countryHotter() public { + // Country US: push to tier 2 + vm.prank(alice); + fleet.registerFleetCountry(_uuid(1000), US, 2); + + // Global: tier 0 + vm.prank(bob); + fleet.registerFleetGlobal(UUID_1); + + (uint256 hottestTier, uint32 hottestRegion, uint256 globalTop,,) = fleet.globalCompetitiveHint(); + assertEq(hottestTier, 2); + assertEq(hottestRegion, _regionUS()); + assertEq(globalTop, 0); + } + + function test_globalHint_multipleRegions_picksHighest() public { + // US-CA at tier 2, DE at tier 5, global at tier 1 + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 2); + vm.prank(alice); + fleet.registerFleetCountry(_uuid(2000), DE, 5); + _registerNGlobal(bob, 4); + vm.prank(bob); + fleet.registerFleetGlobal(_uuid(3000)); // tier 1 + + (uint256 hottestTier, uint32 hottestRegion, uint256 globalTop,,) = fleet.globalCompetitiveHint(); + assertEq(hottestTier, 5); + assertEq(hottestRegion, _regionDE()); + assertEq(globalTop, 1); + } + + function test_globalHint_afterBurn_updates() public { + // Push US-CA to tier 3, then burn so it drops + vm.prank(alice); + uint256 id = fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 3); + + (uint256 before_,,,,) = fleet.globalCompetitiveHint(); + assertEq(before_, 3); + + vm.prank(alice); + fleet.burn(id); + + (uint256 after_,,,,) = fleet.globalCompetitiveHint(); + assertEq(after_, 0); // no regions left above tier 0 + } + + function test_globalHint_registrantCanActOnHint() public { + // Scenario: admin area is at tier 2. A global registrant uses the hint + // to register at tier 2, ensuring they appear at the top cursor. + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 2); + + (uint256 hottestTier,,, uint256 matchBond,) = fleet.globalCompetitiveHint(); + assertEq(hottestTier, 2); + + // Bob registers globally at the hinted tier + vm.prank(bob); + fleet.registerFleetGlobal(_uuid(2000), hottestTier); + + // Verify Bob's fleet is at tier 2 with correct bond + uint256 tokenId = uint256(uint128(_uuid(2000))); + assertEq(fleet.fleetTier(tokenId), 2); + assertEq(fleet.bonds(tokenId), matchBond); + + // Bundle for US-CA includes both at cursor=2 + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 2); + // Both present at same cursor level + bool foundLocal; + bool foundGlobal; + for (uint256 i = 0; i < count; i++) { + if (uuids[i] == _uuid(1000)) foundLocal = true; + if (uuids[i] == _uuid(2000)) foundGlobal = true; + } + assertTrue(foundLocal && foundGlobal, "Both should appear at same cursor"); + } + + // --- countryCompetitiveHint --- + + function test_countryHint_emptyReturnsZeros() public view { + (uint256 hottestTier,, uint256 countryTop, uint256 matchBond, uint256 outcompeteBond) = + fleet.countryCompetitiveHint(US); + assertEq(hottestTier, 0); + assertEq(countryTop, 0); + assertEq(matchBond, BASE_BOND); + assertEq(outcompeteBond, BASE_BOND * 2); + } + + function test_countryHint_onlyCountryFleets() public { + _registerNCountry(alice, US, 8, 1000); // fills tier 0 + vm.prank(bob); + fleet.registerFleetCountry(_uuid(9000), US); // spills to tier 1 + + (uint256 hottestTier,, uint256 countryTop,,) = fleet.countryCompetitiveHint(US); + assertEq(hottestTier, 1); + assertEq(countryTop, 1); + } + + function test_countryHint_adminAreaHotter() public { + // Country US: tier 0 + vm.prank(alice); + fleet.registerFleetCountry(_uuid(1000), US); + + // US-CA: push to tier 3 + vm.prank(bob); + fleet.registerFleetLocal(_uuid(2000), US, ADMIN_CA, 3); + + (uint256 hottestTier, uint32 hottestRegion, uint256 countryTop, uint256 matchBond, uint256 outcompeteBond) = + fleet.countryCompetitiveHint(US); + assertEq(hottestTier, 3); + assertEq(hottestRegion, _regionUSCA()); + assertEq(countryTop, 0); + assertEq(matchBond, BASE_BOND * 8); // tier 3 = BASE * 2^3 + assertEq(outcompeteBond, BASE_BOND * 16); // tier 4 + } + + function test_countryHint_multipleAdminAreas_picksHighest() public { + // US-CA at tier 2, US-NY at tier 4 + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 2); + vm.prank(alice); + fleet.registerFleetLocal(_uuid(2000), US, ADMIN_NY, 4); + + (uint256 hottestTier, uint32 hottestRegion,,,) = fleet.countryCompetitiveHint(US); + assertEq(hottestTier, 4); + assertEq(hottestRegion, _regionUSNY()); + } + + function test_countryHint_ignoresOtherCountries() public { + // DE admin area at tier 5 — should NOT affect US hint + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000), DE, 1, 5); + + // US-CA at tier 1 + vm.prank(bob); + fleet.registerFleetLocal(_uuid(2000), US, ADMIN_CA, 1); + + (uint256 usHottest,,,,) = fleet.countryCompetitiveHint(US); + assertEq(usHottest, 1); // DE's tier 5 is not visible + + (uint256 deHottest,,,,) = fleet.countryCompetitiveHint(DE); + assertEq(deHottest, 5); + } + + function test_countryHint_afterBurn_updates() public { + vm.prank(alice); + uint256 id = fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 3); + + (uint256 before_,,,,) = fleet.countryCompetitiveHint(US); + assertEq(before_, 3); + + vm.prank(alice); + fleet.burn(id); + + (uint256 after_,,,,) = fleet.countryCompetitiveHint(US); + assertEq(after_, 0); + } + + function test_countryHint_registrantCanActOnHint() public { + // US-CA at tier 2 + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 2); + + (uint256 hottestTier,,, uint256 matchBond,) = fleet.countryCompetitiveHint(US); + assertEq(hottestTier, 2); + + // Bob registers at country level matching the hint + vm.prank(bob); + fleet.registerFleetCountry(_uuid(2000), US, hottestTier); + + uint256 tokenId = uint256(uint128(_uuid(2000))); + assertEq(fleet.fleetTier(tokenId), 2); + assertEq(fleet.bonds(tokenId), matchBond); + + // Bundle for US-CA includes both at cursor=2 + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + assertEq(count, 2); + bool foundLocal; + bool foundCountry; + for (uint256 i = 0; i < count; i++) { + if (uuids[i] == _uuid(1000)) foundLocal = true; + if (uuids[i] == _uuid(2000)) foundCountry = true; + } + assertTrue(foundLocal && foundCountry, "Both should appear at same cursor"); + } + // --- buildHighestBondedUUIDBundle (shared-cursor fair-stop) --- // ── Empty / Single-level basics ── From 9664314d798f44403611adafeadd5f12a6ffe2b5 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 17 Feb 2026 16:06:20 +1300 Subject: [PATCH 15/15] refactor(fleet): remove dead error, tierBond bit-shift, extract _addToTier, DRY competitive hints, remove BOND_MULTIPLIER --- src/swarms/FleetIdentity.sol | 109 ++++++++++++++++++----------------- test/FleetIdentity.t.sol | 1 - 2 files changed, 56 insertions(+), 54 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 81212bc2..865ddd1b 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -27,7 +27,7 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * - 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. + * Tier K within a region requires bond = BASE_BOND * 2^K. * * EdgeBeaconScanner discovery uses a 3-level fallback: * 1. Admin area (most specific) @@ -49,7 +49,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { error NotTokenOwner(); error MaxTiersReached(); error TierFull(); - error InsufficientBondForPromotion(); error TargetTierNotHigher(); error TargetTierNotLower(); error TargetTierSameAsCurrent(); @@ -70,8 +69,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { 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 + /// @dev Derived from anti-spam analysis: with a bond doubling per tier + /// and 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; @@ -85,14 +84,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @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. + /// @notice Base bond for tier 0 in any region. Tier K requires BASE_BOND * 2^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 // ────────────────────────────────────────────── @@ -301,14 +295,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Views: Bond & tier helpers // ══════════════════════════════════════════════ - /// @notice Bond required for tier K in any region = BASE_BOND * BOND_MULTIPLIER^K. + /// @notice Bond required for tier K in any region = BASE_BOND * 2^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; + return BASE_BOND << tier; } /// @notice Returns the tier capacity for a given region key. @@ -435,25 +424,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } globalTopTier = hottestTier; - // Scan countries. - for (uint256 i = 0; i < _activeCountries.length; ++i) { - uint32 rk = uint32(_activeCountries[i]); - tc = regionTierCount[rk]; - if (tc > 0 && tc - 1 > hottestTier) { - hottestTier = tc - 1; - hottestRegion = rk; - } - } - - // Scan admin areas. - for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { - uint32 rk = _activeAdminAreas[i]; - tc = regionTierCount[rk]; - if (tc > 0 && tc - 1 > hottestTier) { - hottestTier = tc - 1; - hottestRegion = rk; - } - } + // Scan all countries and admin areas. + (hottestTier, hottestRegion) = _scanHottestTier(_activeAdminAreas, hottestTier, hottestRegion, 0); + (hottestTier, hottestRegion) = _scanHottestCountry(hottestTier, hottestRegion); bondToMatch = tierBond(hottestTier); bondToOutcompete = (hottestTier + 1 < MAX_TIERS) ? tierBond(hottestTier + 1) : 0; @@ -497,17 +470,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { countryTopTier = hottestTier; // Scan admin areas belonging to this country. - // Admin region key = (countryCode << 12) | adminCode, so - // (regionKey >> 12) recovers the country code. - for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { - uint32 rk = _activeAdminAreas[i]; - if (uint16(rk >> 12) != countryCode) continue; - tc = regionTierCount[rk]; - if (tc > 0 && tc - 1 > hottestTier) { - hottestTier = tc - 1; - hottestRegion = rk; - } - } + (hottestTier, hottestRegion) = _scanHottestTier(_activeAdminAreas, hottestTier, hottestRegion, countryCode); bondToMatch = tierBond(hottestTier); bondToOutcompete = (hottestTier + 1 < MAX_TIERS) ? tierBond(hottestTier + 1) : 0; @@ -740,8 +703,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Effects fleetRegion[tokenId] = region; fleetTier[tokenId] = tier; - _regionTierMembers[region][tier].push(tokenId); - _indexInTier[tokenId] = _regionTierMembers[region][tier].length - 1; + _addToTier(tokenId, region, tier); _addToRegionIndex(region); _mint(msg.sender, tokenId); @@ -772,8 +734,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Effects _removeFromTier(tokenId, region, currentTier); fleetTier[tokenId] = targetTier; - _regionTierMembers[region][targetTier].push(tokenId); - _indexInTier[tokenId] = _regionTierMembers[region][targetTier].length - 1; + _addToTier(tokenId, region, targetTier); if (targetTier >= regionTierCount[region]) { regionTierCount[region] = targetTier + 1; @@ -804,8 +765,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Effects _removeFromTier(tokenId, region, currentTier); fleetTier[tokenId] = targetTier; - _regionTierMembers[region][targetTier].push(tokenId); - _indexInTier[tokenId] = _regionTierMembers[region][targetTier].length - 1; + _addToTier(tokenId, region, targetTier); _trimTierCount(region); @@ -855,6 +815,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return tierCount; } + /// @dev Appends a token to a region's tier member array and records its index. + function _addToTier(uint256 tokenId, uint32 region, uint256 tier) internal { + _regionTierMembers[region][tier].push(tokenId); + _indexInTier[tokenId] = _regionTierMembers[region][tier].length - 1; + } + /// @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]; @@ -882,6 +848,43 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { regionTierCount[region] = tierCount; } + /// @dev Scans `regions` for the highest active tier, optionally filtering by country. + /// Pass `filterCountry = 0` to include all entries. + function _scanHottestTier( + uint32[] storage regions, + uint256 currentHottest, + uint32 currentRegion, + uint16 filterCountry + ) internal view returns (uint256, uint32) { + for (uint256 i = 0; i < regions.length; ++i) { + uint32 rk = regions[i]; + if (filterCountry != 0 && uint16(rk >> 12) != filterCountry) continue; + uint256 tc = regionTierCount[rk]; + if (tc > 0 && tc - 1 > currentHottest) { + currentHottest = tc - 1; + currentRegion = rk; + } + } + return (currentHottest, currentRegion); + } + + /// @dev Scans `_activeCountries` for the highest active tier. + function _scanHottestCountry(uint256 currentHottest, uint32 currentRegion) + internal + view + returns (uint256, uint32) + { + for (uint256 i = 0; i < _activeCountries.length; ++i) { + uint32 rk = uint32(_activeCountries[i]); + uint256 tc = regionTierCount[rk]; + if (tc > 0 && tc - 1 > currentHottest) { + currentHottest = tc - 1; + currentRegion = rk; + } + } + return (currentHottest, currentRegion); + } + // -- Region index maintenance -- /// @dev Adds a region to the appropriate index set if not already present. diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index aea364ea..f62632ae 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -149,7 +149,6 @@ contract FleetIdentityTest is Test { 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);