From 538047dd994e0c276beb316ddf2791f813cbe537 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 6 Feb 2026 18:48:02 +1300 Subject: [PATCH 01/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] 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/63] =?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/63] 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/63] =?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/63] 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/63] 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); From 6cff00d3d5a8ad3a1dde4d01798a6df16139d8dd Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 18 Feb 2026 10:37:10 +1300 Subject: [PATCH 16/63] refactor: replace competitive hints with cheapest-inclusion hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FleetIdentity.sol: - Remove auto-assign overloads for registerFleetGlobal (1-arg) and registerFleetCountry (2-arg); callers must now pass an explicit tier. - Keep local auto-assign via _cheapestInclusionTierLocal which simulates the bundle algorithm to find the cheapest tier guaranteeing inclusion. - Replace bestOpenTier with localInclusionHint (bounded O(MAX_TIERS)). - Replace globalCompetitiveHint with globalInclusionHint (view-only, unbounded — iterates all active countries and admin areas). - Replace countryCompetitiveHint with countryInclusionHint (view-only, unbounded — iterates all active admin areas for a country). - Add _findCheapestInclusionTier: two-phase simulation that (1) builds the bundle without the candidate, tracking cutoff cursor and countBefore[], then (2) finds the cheapest tier T where the candidate region has room and the bundle would include it. - Remove _openTier, _findOpenTierView, _scanHottestTier, _scanHottestCountry. - Fix shadowed variable warnings in discoverHighestBondedTier. Tests (158 pass): - Rewrite auto-assign tests for local-only auto-assign. - Replace bestOpenTier tests with localInclusionHint tests. - Replace globalCompetitiveHint/countryCompetitiveHint tests with globalInclusionHint/countryInclusionHint tests covering pressure, burn updates, multiple locations, and registrant-can-act-on-hint. - Update _registerNGlobal/_registerNCountry helpers to compute tiers dynamically (i / capacity). - Fix all call sites for explicit tier arguments. - Update fuzz tests for dynamic tier computation. - Fix SwarmRegistryL1/Universal tests for 2-arg registerFleetGlobal."} --- src/swarms/FleetIdentity.sol | 406 ++++++++++--------- test/FleetIdentity.t.sol | 631 +++++++++++++++--------------- test/SwarmRegistryL1.t.sol | 2 +- test/SwarmRegistryUniversal.t.sol | 2 +- 4 files changed, 549 insertions(+), 492 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 865ddd1b..a92a56b9 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -94,9 +94,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice regionKey -> number of tiers opened in that region. mapping(uint32 => uint256) public regionTierCount; - /// @dev regionKey -> cached lower-bound hint for lowest open tier. - mapping(uint32 => uint256) internal _regionLowestHint; - /// @notice regionKey -> tierIndex -> list of token IDs. mapping(uint32 => mapping(uint256 => uint256[])) internal _regionTierMembers; @@ -160,17 +157,13 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } // ══════════════════════════════════════════════ - // Registration: Global + // Registration: Global (explicit tier only — use globalInclusionHint) // ══════════════════════════════════════════════ - /// @notice Register a fleet globally (auto-assign tier). - function registerFleetGlobal(bytes16 uuid) external nonReentrant returns (uint256 tokenId) { - if (uuid == bytes16(0)) revert InvalidUUID(); - uint256 tier = _openTier(GLOBAL_REGION); - tokenId = _register(uuid, GLOBAL_REGION, tier); - } - /// @notice Register a fleet globally into a specific tier. + /// @dev No auto-assign: the cheapest-inclusion tier depends on all active + /// regions (unbounded scan), so callers must query `globalInclusionHint()` + /// off-chain and supply the tier explicitly. function registerFleetGlobal(bytes16 uuid, uint256 targetTier) external nonReentrant returns (uint256 tokenId) { if (uuid == bytes16(0)) revert InvalidUUID(); _validateExplicitTier(GLOBAL_REGION, targetTier); @@ -178,20 +171,14 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } // ══════════════════════════════════════════════ - // Registration: Country + // Registration: Country (explicit tier only — use countryInclusionHint) // ══════════════════════════════════════════════ - /// @notice Register a fleet under a country (auto-assign tier). - /// @param countryCode ISO 3166-1 numeric country code (1-999). - function registerFleetCountry(bytes16 uuid, uint16 countryCode) external nonReentrant returns (uint256 tokenId) { - if (uuid == bytes16(0)) revert InvalidUUID(); - if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); - uint32 regionKey = uint32(countryCode); - uint256 tier = _openTier(regionKey); - tokenId = _register(uuid, regionKey, tier); - } - /// @notice Register a fleet under a country into a specific tier. + /// @dev No auto-assign: the cheapest-inclusion tier requires scanning all + /// admin areas in the country (unbounded), so callers must query + /// `countryInclusionHint(countryCode)` off-chain and supply the tier. + /// @param countryCode ISO 3166-1 numeric country code (1-999). function registerFleetCountry(bytes16 uuid, uint16 countryCode, uint256 targetTier) external nonReentrant @@ -209,6 +196,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // ══════════════════════════════════════════════ /// @notice Register a fleet under a country + admin area (auto-assign tier). + /// @dev Auto-assign picks the cheapest tier that guarantees the fleet + /// appears in `buildHighestBondedUUIDBundle` for this location. + /// Bounded: O(MAX_TIERS) — only 3 regions participate. /// @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) @@ -220,7 +210,7 @@ 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 tier = _openTier(regionKey); + uint256 tier = _cheapestInclusionTierLocal(regionKey, countryCode); tokenId = _register(uuid, regionKey, tier); } @@ -308,10 +298,79 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return LOCAL_TIER_CAPACITY; } - /// @notice Returns the lowest open tier and its bond for a region. - function lowestOpenTier(uint32 regionKey) external view returns (uint256 tier, uint256 bond) { - tier = _findOpenTierView(regionKey); - bond = tierBond(tier); + /// @notice Returns the cheapest tier that guarantees a **local** fleet + /// appears in `buildHighestBondedUUIDBundle` for (countryCode, adminCode). + /// Bounded: O(MAX_TIERS). + function localInclusionHint(uint16 countryCode, uint16 adminCode) + external + view + returns (uint256 inclusionTier, uint256 bond) + { + if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); + if (adminCode == 0 || adminCode > 4095) revert InvalidAdminCode(); + uint32 adminRegion = (uint32(countryCode) << 12) | uint32(adminCode); + inclusionTier = _findCheapestInclusionTier(countryCode, adminCode, adminRegion); + bond = tierBond(inclusionTier); + } + + /// @notice Returns the cheapest tier that guarantees a **country** fleet + /// appears in every `buildHighestBondedUUIDBundle` query within + /// the country (across all active admin areas). + /// @dev Unbounded view — iterates over all active admin areas in the + /// country. Free off-chain; callers pass the result to + /// `registerFleetCountry(uuid, cc, tier)`. + function countryInclusionHint(uint16 countryCode) + external + view + returns (uint256 inclusionTier, uint256 bond) + { + if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); + uint32 countryRegion = uint32(countryCode); + + // Check the country-only location (no admin area active). + inclusionTier = _findCheapestInclusionTier(countryCode, 0, countryRegion); + + // Scan all active admin areas belonging to this country. + for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { + uint32 rk = _activeAdminAreas[i]; + if (uint16(rk >> 12) != countryCode) continue; + uint16 admin = uint16(rk & 0xFFF); + uint256 t = _findCheapestInclusionTier(countryCode, admin, countryRegion); + if (t > inclusionTier) inclusionTier = t; + } + bond = tierBond(inclusionTier); + } + + /// @notice Returns the cheapest tier that guarantees a **global** fleet + /// appears in every `buildHighestBondedUUIDBundle` query for any + /// (countryCode, adminCode) pair. + /// @dev Unbounded view — iterates over all active countries and admin + /// areas. Free off-chain; callers pass the result to + /// `registerFleetGlobal(uuid, tier)`. + function globalInclusionHint() + external + view + returns (uint256 inclusionTier, uint256 bond) + { + // Global-only location (no country, no admin). + inclusionTier = _findCheapestInclusionTier(0, 0, GLOBAL_REGION); + + // Check each active country (country-only location). + for (uint256 i = 0; i < _activeCountries.length; ++i) { + uint16 cc = _activeCountries[i]; + uint256 t = _findCheapestInclusionTier(cc, 0, GLOBAL_REGION); + if (t > inclusionTier) inclusionTier = t; + } + + // Check each active admin area. + for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { + uint32 rk = _activeAdminAreas[i]; + uint16 cc = uint16(rk >> 12); + uint16 admin = uint16(rk & 0xFFF); + uint256 t = _findCheapestInclusionTier(cc, admin, GLOBAL_REGION); + if (t > inclusionTier) inclusionTier = t; + } + bond = tierBond(inclusionTier); } /// @notice Highest non-empty tier in a region, or 0 if none. @@ -388,93 +447,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { 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 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; - } - - /// @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. - (hottestTier, hottestRegion) = _scanHottestTier(_activeAdminAreas, hottestTier, hottestRegion, countryCode); - bondToMatch = tierBond(hottestTier); - bondToOutcompete = (hottestTier + 1 < MAX_TIERS) ? tierBond(hottestTier + 1) : 0; - } // ══════════════════════════════════════════════ // Views: EdgeBeaconScanner discovery @@ -493,9 +466,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // 1. Try admin area if (countryCode > 0 && adminCode > 0) { regionKey = (uint32(countryCode) << 12) | uint32(adminCode); - uint256 tierCount = regionTierCount[regionKey]; - if (tierCount > 0) { - tier = tierCount - 1; + uint256 tc = regionTierCount[regionKey]; + if (tc > 0) { + tier = tc - 1; members = _regionTierMembers[regionKey][tier]; return (regionKey, tier, members); } @@ -503,9 +476,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // 2. Try country if (countryCode > 0) { regionKey = uint32(countryCode); - uint256 tierCount = regionTierCount[regionKey]; - if (tierCount > 0) { - tier = tierCount - 1; + uint256 tc2 = regionTierCount[regionKey]; + if (tc2 > 0) { + tier = tc2 - 1; members = _regionTierMembers[regionKey][tier]; return (regionKey, tier, members); } @@ -786,33 +759,143 @@ 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 tierCount = regionTierCount[region]; - uint256 cap = tierCapacity(region); - uint256 start = _regionLowestHint[region]; - for (uint256 i = start; i < tierCount; ++i) { - if (_regionTierMembers[region][i].length < cap) { - _regionLowestHint[region] = i; - return i; - } + /// @dev State-changing wrapper around the inclusion simulation for local + /// auto-assign. Opens a new tier if the result exceeds regionTierCount. + function _cheapestInclusionTierLocal(uint32 adminRegion, uint16 countryCode) internal returns (uint256) { + uint16 adminCode = uint16(adminRegion & 0xFFF); + uint256 tier = _findCheapestInclusionTier(countryCode, adminCode, adminRegion); + if (tier >= regionTierCount[adminRegion]) { + regionTierCount[adminRegion] = tier + 1; } - if (tierCount >= MAX_TIERS) revert MaxTiersReached(); - regionTierCount[region] = tierCount + 1; - _regionLowestHint[region] = tierCount; - return tierCount; + return tier; } - /// @dev View-only version of _openTier. - function _findOpenTierView(uint32 region) internal view returns (uint256) { - uint256 tierCount = regionTierCount[region]; - uint256 cap = tierCapacity(region); - uint256 start = _regionLowestHint[region]; - for (uint256 i = start; i < tierCount; ++i) { - if (_regionTierMembers[region][i].length < cap) return i; + /// @dev Simulates `buildHighestBondedUUIDBundle(countryCode, adminCode)` and + /// returns the cheapest tier at `candidateRegion` that guarantees bundle + /// inclusion. Bounded: O(MAX_TIERS). Works for any candidate level. + /// + /// Phase 1 — run the bundle simulation WITHOUT the new fleet, recording + /// `countBefore[c]` (UUIDs accumulated from cursors above c) + /// and a `cutoffCursor` (cursor where a fair-stop fired). + /// + /// Phase 2 — scan candidate tiers from 0 upward. For each T: + /// (a) T must have capacity in the candidate region. + /// (b) The cursor must reach T (T >= cutoffCursor). + /// (c) At cursor T, after levels with higher priority than the + /// candidate consume room, the candidate's members + 1 must + /// still fit in MAX_BONDED_UUID_BUNDLE_SIZE. + function _findCheapestInclusionTier( + uint16 countryCode, + uint16 adminCode, + uint32 candidateRegion + ) internal view returns (uint256) { + // --- Set up the three bundle levels (same logic as buildHighestBondedUUIDBundle). --- + uint32[3] memory keys; + bool[3] memory active; + uint256 candidateLevel = 3; // sentinel — updated below + + if (countryCode > 0 && adminCode > 0) { + keys[0] = (uint32(countryCode) << 12) | uint32(adminCode); + active[0] = true; + if (keys[0] == candidateRegion) candidateLevel = 0; + } + if (countryCode > 0) { + keys[1] = uint32(countryCode); + active[1] = true; + if (keys[1] == candidateRegion) candidateLevel = 1; } - if (tierCount >= MAX_TIERS) revert MaxTiersReached(); - return tierCount; + keys[2] = GLOBAL_REGION; + active[2] = true; + if (keys[2] == candidateRegion) candidateLevel = 2; + + uint256 cap = tierCapacity(candidateRegion); + + // Collect tier-counts for the three levels. + uint256[3] memory tc; + uint256 maxTI = 0; + bool anyActive = false; + for (uint256 lvl = 0; lvl < 3; ++lvl) { + if (!active[lvl]) continue; + tc[lvl] = regionTierCount[keys[lvl]]; + if (tc[lvl] > 0) { + anyActive = true; + if (tc[lvl] - 1 > maxTI) maxTI = tc[lvl] - 1; + } + } + + // If nothing is active yet, tier 0 always works. + if (!anyActive) return 0; + + // --- Phase 1: simulate bundle WITHOUT the new fleet. --- + uint256[24] memory countBefore; // countBefore[c] = UUIDs from cursors above c + uint256 count = 0; + int256 cutoffCursor = -1; // -1 ⇒ cursor reached 0 without stopping + + for (int256 cursor = int256(maxTI); cursor >= 0; --cursor) { + countBefore[uint256(cursor)] = count; + if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) { + cutoffCursor = cursor; + break; + } + + bool skipped = false; + for (uint256 lvl = 0; lvl < 3; ++lvl) { + if (!active[lvl]) continue; + if (tc[lvl] == 0 || uint256(cursor) >= tc[lvl]) continue; + uint256 m = _regionTierMembers[keys[lvl]][uint256(cursor)].length; + if (m == 0) continue; + if (m <= MAX_BONDED_UUID_BUNDLE_SIZE - count) { + count += m; + } else { + skipped = true; + } + } + if (skipped) { + cutoffCursor = cursor; + break; + } + } + + // --- Phase 2: find cheapest tier T at candidateRegion. --- + uint256 candidateTC = (candidateLevel < 3) ? tc[candidateLevel] : 0; + + for (uint256 T = 0; T < MAX_TIERS; ++T) { + // (a) Room in the tier? + uint256 members = (T < candidateTC) + ? _regionTierMembers[candidateRegion][T].length + : 0; + if (members >= cap) continue; + + // (b) Cursor reaches T? + if (cutoffCursor >= 0 && int256(T) < cutoffCursor) continue; + + // (c) At cursor T, compute room after higher-priority levels. + uint256 countAtT = (T <= maxTI) ? countBefore[T] : 0; + + // Process levels before the candidate at cursor T. + bool earlySkip = false; + for (uint256 lvl = 0; lvl < 3; ++lvl) { + if (!active[lvl]) continue; + if (keys[lvl] == candidateRegion) break; // reached candidate level + uint256 ltc = tc[lvl]; + if (ltc == 0 || T >= ltc) continue; + uint256 m = _regionTierMembers[keys[lvl]][T].length; + if (m == 0) continue; + if (m <= MAX_BONDED_UUID_BUNDLE_SIZE - countAtT) { + countAtT += m; + } + // If a prior level doesn't fit it is skipped (count unchanged); + // the candidate is still tried (bundle processes ALL levels at + // each cursor position before checking the skip flag). + } + + // Does the candidate (with +1) fit? + if (members + 1 <= MAX_BONDED_UUID_BUNDLE_SIZE - countAtT) { + return T; + } + } + + revert MaxTiersReached(); } /// @dev Appends a token to a region's tier member array and records its index. @@ -833,10 +916,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { _indexInTier[lastTokenId] = idx; } members.pop(); - - if (tier < _regionLowestHint[region]) { - _regionLowestHint[region] = tier; - } } /// @dev Shrinks regionTierCount so the top tier is always non-empty. @@ -848,42 +927,7 @@ 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 -- diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index f62632ae..70870839 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -118,7 +118,18 @@ contract FleetIdentityTest is Test { ids = new uint256[](count); for (uint256 i = 0; i < count; i++) { vm.prank(owner); - ids[i] = fleet.registerFleetGlobal(_uuid(i)); + ids[i] = fleet.registerFleetGlobal(_uuid(i), i / 4); // GLOBAL_TIER_CAPACITY = 4 + } + } + + function _registerNGlobalAt(address owner, uint256 count, uint256 tier, uint256 startSeed) + internal + returns (uint256[] memory ids) + { + ids = new uint256[](count); + for (uint256 i = 0; i < count; i++) { + vm.prank(owner); + ids[i] = fleet.registerFleetGlobal(_uuid(startSeed + i), tier); } } @@ -129,7 +140,18 @@ contract FleetIdentityTest is Test { ids = new uint256[](count); for (uint256 i = 0; i < count; i++) { vm.prank(owner); - ids[i] = fleet.registerFleetCountry(_uuid(startSeed + i), cc); + ids[i] = fleet.registerFleetCountry(_uuid(startSeed + i), cc, i / 8); // COUNTRY_TIER_CAPACITY = 8 + } + } + + function _registerNCountryAt(address owner, uint16 cc, uint256 count, uint256 startSeed, uint256 tier) + 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, tier); } } @@ -144,6 +166,17 @@ contract FleetIdentityTest is Test { } } + function _registerNLocalAt(address owner, uint16 cc, uint16 admin, uint256 count, uint256 startSeed, uint256 tier) + 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, tier); + } + } + // --- Constructor --- function test_constructor_setsImmutables() public view { @@ -192,7 +225,7 @@ contract FleetIdentityTest is Test { function test_registerFleetGlobal_auto_mintsAndLocksBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); assertEq(fleet.ownerOf(tokenId), alice); assertEq(tokenId, uint256(uint128(UUID_1))); @@ -209,22 +242,22 @@ contract FleetIdentityTest is Test { emit FleetRegistered(alice, UUID_1, expectedTokenId, GLOBAL, 0, BASE_BOND); vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); } function test_RevertIf_registerFleetGlobal_auto_zeroUUID() public { vm.prank(alice); vm.expectRevert(FleetIdentity.InvalidUUID.selector); - fleet.registerFleetGlobal(bytes16(0)); + fleet.registerFleetGlobal(bytes16(0), 0); } function test_RevertIf_registerFleetGlobal_auto_duplicateUUID() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); vm.expectRevert(); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); } // --- registerFleetGlobal explicit tier --- @@ -250,7 +283,7 @@ contract FleetIdentityTest is Test { function test_registerFleetCountry_auto_setsRegionAndTier() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); assertEq(fleet.fleetRegion(tokenId), _regionUS()); assertEq(fleet.fleetTier(tokenId), 0); @@ -270,13 +303,13 @@ contract FleetIdentityTest is Test { function test_RevertIf_registerFleetCountry_invalidCode_zero() public { vm.prank(alice); vm.expectRevert(FleetIdentity.InvalidCountryCode.selector); - fleet.registerFleetCountry(UUID_1, 0); + fleet.registerFleetCountry(UUID_1, 0, 0); } function test_RevertIf_registerFleetCountry_invalidCode_over999() public { vm.prank(alice); vm.expectRevert(FleetIdentity.InvalidCountryCode.selector); - fleet.registerFleetCountry(UUID_1, 1000); + fleet.registerFleetCountry(UUID_1, 1000, 0); } // --- registerFleetLocal --- @@ -320,9 +353,9 @@ contract FleetIdentityTest is Test { function test_perRegionTiers_firstFleetInEveryRegionPaysSameBond() public { vm.prank(alice); - uint256 g1 = fleet.registerFleetGlobal(UUID_1); + uint256 g1 = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(alice); - uint256 c1 = fleet.registerFleetCountry(UUID_2, US); + uint256 c1 = fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(alice); uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); @@ -341,12 +374,12 @@ contract FleetIdentityTest is Test { assertEq(fleet.tierMemberCount(GLOBAL, 0), 4); vm.prank(bob); - uint256 g21 = fleet.registerFleetGlobal(_uuid(100)); + uint256 g21 = fleet.registerFleetGlobal(_uuid(100), 1); assertEq(fleet.fleetTier(g21), 1); assertEq(fleet.bonds(g21), BASE_BOND * 2); vm.prank(bob); - uint256 us1 = fleet.registerFleetCountry(_uuid(200), US); + uint256 us1 = fleet.registerFleetCountry(_uuid(200), US, 0); assertEq(fleet.fleetTier(us1), 0); assertEq(fleet.bonds(us1), BASE_BOND); assertEq(fleet.regionTierCount(_regionUS()), 1); @@ -362,12 +395,12 @@ contract FleetIdentityTest is Test { assertEq(fleet.tierMemberCount(_regionUS(), 0), 8); vm.prank(bob); - uint256 us21 = fleet.registerFleetCountry(_uuid(500), US); + uint256 us21 = fleet.registerFleetCountry(_uuid(500), US, 1); assertEq(fleet.fleetTier(us21), 1); assertEq(fleet.bonds(us21), BASE_BOND * 2); vm.prank(bob); - uint256 de1 = fleet.registerFleetCountry(_uuid(600), DE); + uint256 de1 = fleet.registerFleetCountry(_uuid(600), DE, 0); assertEq(fleet.fleetTier(de1), 0); assertEq(fleet.bonds(de1), BASE_BOND); } @@ -382,36 +415,32 @@ contract FleetIdentityTest is Test { assertEq(fleet.bonds(ny1), BASE_BOND); } - // --- Auto-assign tier logic --- - - function test_autoAssign_fillsTier0BeforeOpeningTier1() public { - _registerNGlobal(alice, 4); - assertEq(fleet.regionTierCount(GLOBAL), 1); + // --- Auto-assign tier logic (local only) --- - vm.prank(bob); - uint256 id5 = fleet.registerFleetGlobal(_uuid(20)); - assertEq(fleet.fleetTier(id5), 1); - assertEq(fleet.regionTierCount(GLOBAL), 2); + function test_autoAssign_local_emptyRegionGetsTier0() public { + // No fleets anywhere — local auto-assign picks tier 0. + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + assertEq(fleet.fleetTier(tokenId), 0); + assertEq(fleet.regionTierCount(_regionUSCA()), 1); } - function test_autoAssign_backfillsTier0WhenSlotOpens() public { - uint256[] memory ids = _registerNGlobal(alice, 4); - - vm.prank(alice); - fleet.burn(ids[2]); - assertEq(fleet.tierMemberCount(GLOBAL, 0), 3); + function test_autoAssign_local_cheapestInclusionTier() public { + // Fill admin-area tier 0 (8 members) so tier 0 is full. + _registerNLocal(alice, US, ADMIN_CA, 8, 0); + // Auto-assign should pick tier 1 (cheapest tier with capacity). vm.prank(bob); - uint256 newId = fleet.registerFleetGlobal(_uuid(100)); - assertEq(fleet.fleetTier(newId), 0); - assertEq(fleet.tierMemberCount(GLOBAL, 0), 4); + uint256 tokenId = fleet.registerFleetLocal(_uuid(100), US, ADMIN_CA); + assertEq(fleet.fleetTier(tokenId), 1); + assertEq(fleet.regionTierCount(_regionUSCA()), 2); } // --- promote --- function test_promote_next_movesToNextTierInRegion() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(alice); fleet.promote(tokenId); @@ -423,7 +452,7 @@ contract FleetIdentityTest is Test { function test_promote_next_pullsBondDifference() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); uint256 balBefore = bondToken.balanceOf(alice); uint256 diff = fleet.tierBond(1) - fleet.tierBond(0); @@ -448,7 +477,7 @@ contract FleetIdentityTest is Test { function test_promote_emitsEvent() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); uint256 diff = fleet.tierBond(1) - fleet.tierBond(0); vm.expectEmit(true, true, true, true); @@ -460,7 +489,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_promote_notOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -478,7 +507,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_promote_targetTierFull() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); for (uint256 i = 0; i < 4; i++) { vm.prank(bob); @@ -492,7 +521,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_reassignTier_exceedsMaxTiers() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(alice); vm.expectRevert(FleetIdentity.MaxTiersReached.selector); @@ -569,7 +598,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_reassignTier_promoteNotOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -580,7 +609,7 @@ contract FleetIdentityTest is Test { function test_burn_refundsTierBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); uint256 balBefore = bondToken.balanceOf(alice); vm.prank(alice); @@ -593,7 +622,7 @@ contract FleetIdentityTest is Test { function test_burn_emitsEvent() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); vm.expectEmit(true, true, true, true); emit FleetBurned(alice, tokenId, GLOBAL, 0, BASE_BOND); @@ -614,48 +643,41 @@ contract FleetIdentityTest is Test { function test_burn_allowsReregistration() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(alice); fleet.burn(tokenId); vm.prank(bob); - uint256 newId = fleet.registerFleetCountry(UUID_1, DE); + uint256 newId = fleet.registerFleetCountry(UUID_1, DE, 0); assertEq(newId, tokenId); assertEq(fleet.fleetRegion(newId), _regionDE()); } function test_RevertIf_burn_notOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); fleet.burn(tokenId); } - // --- lowestOpenTier --- - - function test_lowestOpenTier_initiallyZeroForAnyRegion() public view { - (uint256 tier, uint256 bond) = fleet.lowestOpenTier(GLOBAL); - assertEq(tier, 0); - assertEq(bond, BASE_BOND); + // --- localInclusionHint --- - (tier, bond) = fleet.lowestOpenTier(_regionUS()); + function test_localInclusionHint_emptyRegion() public view { + (uint256 tier, uint256 bond) = fleet.localInclusionHint(US, ADMIN_CA); assertEq(tier, 0); assertEq(bond, BASE_BOND); } - function test_lowestOpenTier_perRegionAfterFilling() public { - _registerNGlobal(alice, 4); - - (uint256 gTier, uint256 gBond) = fleet.lowestOpenTier(GLOBAL); - assertEq(gTier, 1); - assertEq(gBond, BASE_BOND * 2); + function test_localInclusionHint_afterFillingAdminTier0() public { + _registerNLocal(alice, US, ADMIN_CA, 8, 0); - (uint256 usTier, uint256 usBond) = fleet.lowestOpenTier(_regionUS()); - assertEq(usTier, 0); - assertEq(usBond, BASE_BOND); + // Admin tier 0 full → cheapest inclusion is tier 1. + (uint256 tier, uint256 bond) = fleet.localInclusionHint(US, ADMIN_CA); + assertEq(tier, 1); + assertEq(bond, BASE_BOND * 2); } // --- highestActiveTier --- @@ -685,10 +707,10 @@ contract FleetIdentityTest is Test { function test_getTierMembers_perRegion() public { vm.prank(alice); - uint256 gId = fleet.registerFleetGlobal(UUID_1); + uint256 gId = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); - uint256 usId = fleet.registerFleetCountry(UUID_2, US); + uint256 usId = fleet.registerFleetCountry(UUID_2, US, 0); uint256[] memory gMembers = fleet.getTierMembers(GLOBAL, 0); assertEq(gMembers.length, 1); @@ -701,10 +723,10 @@ contract FleetIdentityTest is Test { function test_getTierUUIDs_perRegion() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US); + fleet.registerFleetCountry(UUID_2, US, 0); bytes16[] memory gUUIDs = fleet.getTierUUIDs(GLOBAL, 0); assertEq(gUUIDs.length, 1); @@ -719,9 +741,9 @@ contract FleetIdentityTest is Test { function test_discoverHighestBondedTier_prefersAdminArea() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US); + fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(carol); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); @@ -733,9 +755,9 @@ contract FleetIdentityTest is Test { function test_discoverHighestBondedTier_fallsBackToCountry() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US); + fleet.registerFleetCountry(UUID_2, US, 0); (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); assertEq(rk, _regionUS()); @@ -745,7 +767,7 @@ contract FleetIdentityTest is Test { function test_discoverHighestBondedTier_fallsBackToGlobal() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); assertEq(rk, GLOBAL); @@ -763,7 +785,7 @@ contract FleetIdentityTest is Test { function test_discoverHighestBondedTier_returnsHighestTier() public { _registerNCountry(alice, US, 8, 0); vm.prank(bob); - fleet.registerFleetCountry(_uuid(500), US); + fleet.registerFleetCountry(_uuid(500), US, 1); (uint32 rk, uint256 tier,) = fleet.discoverHighestBondedTier(US, 0); assertEq(rk, _regionUS()); @@ -775,7 +797,7 @@ contract FleetIdentityTest is Test { function test_discoverAllLevels_returnsAllCounts() public { _registerNGlobal(alice, 4); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(999)); + fleet.registerFleetGlobal(_uuid(999), 1); _registerNCountry(bob, US, 5, 100); _registerNLocal(carol, US, ADMIN_CA, 3, 200); @@ -802,7 +824,7 @@ contract FleetIdentityTest is Test { assertFalse(fleet.globalActive()); vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); assertTrue(fleet.globalActive()); vm.prank(alice); @@ -812,9 +834,9 @@ contract FleetIdentityTest is Test { function test_activeCountries_addedOnRegistration() public { vm.prank(alice); - fleet.registerFleetCountry(UUID_1, US); + fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); - fleet.registerFleetCountry(UUID_2, DE); + fleet.registerFleetCountry(UUID_2, DE, 0); uint16[] memory countries = fleet.getActiveCountries(); assertEq(countries.length, 2); @@ -822,7 +844,7 @@ contract FleetIdentityTest is Test { function test_activeCountries_removedWhenAllBurned() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetCountry(UUID_1, US); + uint256 id1 = fleet.registerFleetCountry(UUID_1, US, 0); uint16[] memory before_ = fleet.getActiveCountries(); assertEq(before_.length, 1); @@ -836,9 +858,9 @@ contract FleetIdentityTest is Test { function test_activeCountries_notDuplicated() public { vm.prank(alice); - fleet.registerFleetCountry(UUID_1, US); + fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US); + fleet.registerFleetCountry(UUID_2, US, 0); uint16[] memory countries = fleet.getActiveCountries(); assertEq(countries.length, 1); @@ -888,13 +910,13 @@ contract FleetIdentityTest is Test { function test_tokenUUID_roundTrip() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); assertEq(fleet.tokenUUID(tokenId), UUID_1); } function test_bonds_returnsTierBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); assertEq(fleet.bonds(tokenId), BASE_BOND); } @@ -908,11 +930,11 @@ contract FleetIdentityTest is Test { assertEq(fleet.totalSupply(), 0); vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); assertEq(fleet.totalSupply(), 1); vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US); + fleet.registerFleetCountry(UUID_2, US, 0); assertEq(fleet.totalSupply(), 2); vm.prank(carol); @@ -930,9 +952,9 @@ contract FleetIdentityTest is Test { function test_bondAccounting_acrossRegions() public { vm.prank(alice); - uint256 g1 = fleet.registerFleetGlobal(UUID_1); + uint256 g1 = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); - uint256 c1 = fleet.registerFleetCountry(UUID_2, US); + uint256 c1 = fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(carol); uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); @@ -951,7 +973,7 @@ contract FleetIdentityTest is Test { function test_bondAccounting_reassignTierRoundTrip() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetCountry(UUID_1, US); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); uint256 balStart = bondToken.balanceOf(alice); vm.prank(alice); @@ -978,7 +1000,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); vm.expectRevert(); - f.registerFleetGlobal(UUID_1); + f.registerFleetGlobal(UUID_1, 0); } // --- Transfer preserves region and tier --- @@ -1007,16 +1029,18 @@ contract FleetIdentityTest is Test { assertEq(fleet.tierMemberCount(_regionUS(), 0), 8); vm.prank(bob); - uint256 us9 = fleet.registerFleetCountry(_uuid(100), US); + uint256 us9 = fleet.registerFleetCountry(_uuid(100), US, 1); assertEq(fleet.fleetTier(us9), 1); + // Burn from tier 0 — now tier 0 has 7, tier 1 has 1. vm.prank(alice); fleet.burn(usIds[3]); + // Backfill into tier 0 (now has room) or tier 1. vm.prank(carol); - uint256 backfill = fleet.registerFleetCountry(_uuid(200), US); - assertEq(fleet.fleetTier(backfill), 0); - assertEq(fleet.tierMemberCount(_regionUS(), 0), 8); + uint256 backfill = fleet.registerFleetCountry(_uuid(200), US, 1); + assertEq(fleet.fleetTier(backfill), 1); + assertEq(fleet.tierMemberCount(_regionUS(), 1), 2); assertEq(fleet.regionTierCount(GLOBAL), 0); } @@ -1029,7 +1053,7 @@ contract FleetIdentityTest is Test { bondToken.approve(address(f), type(uint256).max); vm.prank(alice); - uint256 tokenId = f.registerFleetGlobal(UUID_1); + uint256 tokenId = f.registerFleetGlobal(UUID_1, 0); assertEq(f.bonds(tokenId), 0); vm.prank(alice); @@ -1042,7 +1066,7 @@ contract FleetIdentityTest is Test { vm.assume(uuid != bytes16(0)); vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(uuid); + uint256 tokenId = fleet.registerFleetGlobal(uuid, 0); assertEq(tokenId, uint256(uint128(uuid))); assertEq(fleet.ownerOf(tokenId), alice); @@ -1055,7 +1079,7 @@ contract FleetIdentityTest is Test { cc = uint16(bound(cc, 1, 999)); vm.prank(alice); - uint256 tokenId = fleet.registerFleetCountry(UUID_1, cc); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, cc, 0); assertEq(fleet.fleetRegion(tokenId), uint32(cc)); assertEq(fleet.fleetTier(tokenId), 0); @@ -1080,7 +1104,7 @@ contract FleetIdentityTest is Test { vm.assume(caller != address(0)); vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(caller); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -1092,7 +1116,7 @@ contract FleetIdentityTest is Test { vm.assume(caller != address(0)); vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1); + uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(caller); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -1115,7 +1139,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.regionTierCount(GLOBAL), 2); vm.prank(bob); - uint256 tokenId = fleet.registerFleetCountry(_uuid(999), cc); + uint256 tokenId = fleet.registerFleetCountry(_uuid(999), cc, 0); assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND); } @@ -1124,10 +1148,10 @@ contract FleetIdentityTest is Test { count = uint8(bound(count, 1, 40)); for (uint256 i = 0; i < count; i++) { + uint256 expectedTier = i / 8; // country capacity = 8 vm.prank(alice); - uint256 tokenId = fleet.registerFleetCountry(_uuid(i + 300), US); + uint256 tokenId = fleet.registerFleetCountry(_uuid(i + 300), US, expectedTier); - uint256 expectedTier = i / 8; // country capacity = 8 assertEq(fleet.fleetTier(tokenId), expectedTier); } @@ -1139,9 +1163,9 @@ contract FleetIdentityTest is Test { function test_invariant_contractBalanceEqualsSumOfBonds() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetGlobal(UUID_1); + uint256 id1 = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); - uint256 id2 = fleet.registerFleetCountry(UUID_2, US); + uint256 id2 = fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(carol); uint256 id3 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); @@ -1156,11 +1180,11 @@ contract FleetIdentityTest is Test { function test_invariant_contractBalanceAfterReassignTierBurn() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetCountry(UUID_1, US); + uint256 id1 = fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); uint256 id2 = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); vm.prank(carol); - uint256 id3 = fleet.registerFleetGlobal(UUID_3); + uint256 id3 = fleet.registerFleetGlobal(UUID_3, 0); vm.prank(alice); fleet.reassignTier(id1, 3); @@ -1187,7 +1211,7 @@ contract FleetIdentityTest is Test { _registerNGlobal(alice, 4); for (uint256 i = 0; i < 2; i++) { vm.prank(bob); - fleet.registerFleetGlobal(_uuid(20 + i)); + fleet.registerFleetGlobal(_uuid(20 + i), 1); } _registerNLocal(carol, US, ADMIN_CA, 3, 200); @@ -1217,7 +1241,7 @@ contract FleetIdentityTest is Test { function test_competitiveLandscape_singleTier() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); (uint256 topTier, uint256 members, uint256 cap, uint256 bond, uint256 nextBond) = fleet.competitiveLandscape(GLOBAL); @@ -1231,7 +1255,7 @@ contract FleetIdentityTest is Test { function test_competitiveLandscape_multipleTiers() public { _registerNGlobal(alice, 4); // fills tier 0 vm.prank(bob); - fleet.registerFleetGlobal(_uuid(9000)); // auto → tier 1 + fleet.registerFleetGlobal(_uuid(9000), 1); // explicit tier 1 (uint256 topTier, uint256 members, uint256 cap, uint256 bond, uint256 nextBond) = fleet.competitiveLandscape(GLOBAL); @@ -1269,7 +1293,7 @@ contract FleetIdentityTest is Test { function test_competitiveLandscape_afterBurn_reflects() public { vm.prank(alice); - uint256 id = fleet.registerFleetGlobal(UUID_1); + uint256 id = fleet.registerFleetGlobal(UUID_1, 0); (, uint256 membersBefore,,,) = fleet.competitiveLandscape(GLOBAL); assertEq(membersBefore, 1); @@ -1293,177 +1317,173 @@ contract FleetIdentityTest is Test { assertEq(nyMembers, 2); } - // --- globalCompetitiveHint --- + // --- globalInclusionHint --- - 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_globalInclusionHint_emptyReturnsZero() public view { + (uint256 tier, uint256 bond) = fleet.globalInclusionHint(); + assertEq(tier, 0); + assertEq(bond, BASE_BOND); } - function test_globalHint_onlyGlobalFleets() public { + function test_globalInclusionHint_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 + fleet.registerFleetGlobal(_uuid(9000), 1); // tier 1 + + // Global fleet at tier 0 is the cheapest inclusion tier for + // a new global fleet (if tier 0 has room, it fits in bundle). + // Tier 0 is full (4/4). Tier 1 has 1 member but also full + // isn't relevant — we need tier where a new fleet fits. + // Tier 0 is full, tier 1 has room → cheapest = 1. + // But wait: with 5 globals, a 6th global at tier 0 can't fit (full). + // At tier 1 (1 member + 1 = 2), bundle has 4+2=6, fits in 20. + (uint256 tier, uint256 bond) = fleet.globalInclusionHint(); + assertEq(tier, 1); // tier 0 full, cheapest with room + assertEq(bond, BASE_BOND * 2); } - function test_globalHint_localAreaHotter() public { + function test_globalInclusionHint_localAreaPresent() public { // Global: tier 0 only vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); // 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); + // A new global fleet needs to be included in bundle(US, ADMIN_CA). + // Bundle simulation: cursor starts at tier 3. + // cursor=3: admin has 1 member (fits), global has 0 → no skip + // cursor=2..1: empty everywhere + // cursor=0: admin 0, global has 1 member (fits) + // A global fleet at tier 0: at cursor=0, global would have 1+1=2 members. + // 2 <= 20 - 1 (from admin at cursor 3) = 19. Fits. + // So cheapest = 0 (tier 0 has room for global: 1 member, cap 4). + (uint256 tier,) = fleet.globalInclusionHint(); + assertEq(tier, 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 + function test_globalInclusionHint_bundlePressure() public { + // Fill admin tier 0 with 8 members, country tier 0 with 8 members, + // global tier 0 with 4 members. Total = 20 = MAX_BONDED_UUID_BUNDLE_SIZE. + // A new global fleet at tier 0 would make global=5 members at cursor 0. + // At cursor 0: admin=8 (fits), country=8 (fits, count=16), global=5 + // 5 <= 20-16 = 4? No! 5 > 4 → global tier 0 is skipped → fair-stop. + // So tier 0 doesn't guarantee inclusion. Need tier 1. + // At cursor 1: no admin, no country, global has 1 member. Fits. + _registerNLocal(alice, US, ADMIN_CA, 8, 500); + _registerNCountry(alice, US, 8, 600); + _registerNGlobalAt(alice, 4, 0, 700); - (uint256 hottestTier, uint32 hottestRegion, uint256 globalTop,,) = fleet.globalCompetitiveHint(); - assertEq(hottestTier, 5); - assertEq(hottestRegion, _regionDE()); - assertEq(globalTop, 1); + (uint256 tier,) = fleet.globalInclusionHint(); + assertEq(tier, 1); } - function test_globalHint_afterBurn_updates() public { - // Push US-CA to tier 3, then burn so it drops + function test_globalInclusionHint_afterBurn_updates() public { vm.prank(alice); uint256 id = fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 3); - (uint256 before_,,,,) = fleet.globalCompetitiveHint(); - assertEq(before_, 3); + (uint256 before_,) = fleet.globalInclusionHint(); vm.prank(alice); fleet.burn(id); - (uint256 after_,,,,) = fleet.globalCompetitiveHint(); - assertEq(after_, 0); // no regions left above tier 0 + (uint256 after_,) = fleet.globalInclusionHint(); + assertEq(after_, 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. + function test_globalInclusionHint_registrantCanActOnHint() public { + // Admin area is at tier 2. A global registrant uses the hint. vm.prank(alice); fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 2); - (uint256 hottestTier,,, uint256 matchBond,) = fleet.globalCompetitiveHint(); - assertEq(hottestTier, 2); + (uint256 inclusionTier, uint256 hintBond) = fleet.globalInclusionHint(); // Bob registers globally at the hinted tier vm.prank(bob); - fleet.registerFleetGlobal(_uuid(2000), hottestTier); + fleet.registerFleetGlobal(_uuid(2000), inclusionTier); - // 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); + assertEq(fleet.fleetTier(tokenId), inclusionTier); + assertEq(fleet.bonds(tokenId), hintBond); - // Bundle for US-CA includes both at cursor=2 + // Bundle for US-CA includes both (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - assertEq(count, 2); - // Both present at same cursor level - bool foundLocal; + assertGt(count, 0); 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"); + assertTrue(foundGlobal, "Global fleet should appear in bundle"); + } + + function test_globalInclusionHint_multipleLocations_takesMax() public { + // US-CA: heavy competition — fill admin + country tiers + _registerNLocal(alice, US, ADMIN_CA, 8, 500); + _registerNCountry(alice, US, 8, 600); + // DE with admin area at tier 0 + _registerNLocal(alice, DE, 1, 3, 700); + + // Global needs to fit in BOTH locations. + // US-CA: 8 admin + 8 country = 16 at tier 0 → global tier 0 needs 4+1=5, room = 4. Doesn't fit → tier 1. + // DE-1: 3 admin at tier 0 → global tier 0 needs 4+1=5, room = 20-3 = 17. Fits at tier 0. + // Max across both = tier 1. + _registerNGlobalAt(alice, 4, 0, 800); + (uint256 tier,) = fleet.globalInclusionHint(); + assertEq(tier, 1); } - // --- countryCompetitiveHint --- + // --- countryInclusionHint --- - 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_countryInclusionHint_emptyReturnsZero() public view { + (uint256 tier, uint256 bond) = fleet.countryInclusionHint(US); + assertEq(tier, 0); + assertEq(bond, BASE_BOND); } - function test_countryHint_onlyCountryFleets() public { + function test_countryInclusionHint_onlyCountryFleets() public { _registerNCountry(alice, US, 8, 1000); // fills tier 0 vm.prank(bob); - fleet.registerFleetCountry(_uuid(9000), US); // spills to tier 1 + fleet.registerFleetCountry(_uuid(9000), US, 1); // tier 1 - (uint256 hottestTier,, uint256 countryTop,,) = fleet.countryCompetitiveHint(US); - assertEq(hottestTier, 1); - assertEq(countryTop, 1); + // Tier 0 is full → cheapest inclusion = tier 1. + (uint256 tier, uint256 bond) = fleet.countryInclusionHint(US); + assertEq(tier, 1); + assertEq(bond, BASE_BOND * 2); } - function test_countryHint_adminAreaHotter() public { - // Country US: tier 0 + function test_countryInclusionHint_adminAreaCreatesPresure() public { + // Country US: tier 0 with 1 member vm.prank(alice); - fleet.registerFleetCountry(_uuid(1000), US); + fleet.registerFleetCountry(_uuid(1000), US, 0); - // US-CA: push to tier 3 + // US-CA: push to tier 3 (1 member at 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 + // Country fleet needs to be included in bundle(US, ADMIN_CA). + // Simulation: cursor 3→0. At cursor 3: admin=1 (fits). At cursor 0: admin=0, country=1+1=2 (fits). + // Country tier 0 with 2 members: 2 <= 20-1 = 19. Fits. + // So cheapest = 0 (tier 0 has room: 1/8). + (uint256 tier,) = fleet.countryInclusionHint(US); + assertEq(tier, 0); } - 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_countryInclusionHint_multipleAdminAreas_takesMax() public { + // US-CA: fill admin tier 0 (8) + fill country tier 0 (8) = 16 + _registerNLocal(alice, US, ADMIN_CA, 8, 0); + _registerNCountryAt(alice, US, 8, 100, 0); + // US-NY: light (3 admin) + _registerNLocal(alice, US, ADMIN_NY, 3, 200); + + // Country tier 0 has 8/8 members → tier 0 is full. + // Even though the bundle has room, the tier capacity is exhausted. + // So cheapest inclusion tier for a country fleet = 1. + (uint256 tier,) = fleet.countryInclusionHint(US); + assertEq(tier, 1); } - function test_countryHint_ignoresOtherCountries() public { + function test_countryInclusionHint_ignoresOtherCountries() public { // DE admin area at tier 5 — should NOT affect US hint vm.prank(alice); fleet.registerFleetLocal(_uuid(1000), DE, 1, 5); @@ -1472,53 +1492,46 @@ contract FleetIdentityTest is Test { 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); + (uint256 usTier,) = fleet.countryInclusionHint(US); + // US country fleet needs inclusion in bundle(US, ADMIN_CA). + // Admin has 1 at tier 1. Country at tier 0: +1=1, fits. + assertEq(usTier, 0); } - function test_countryHint_afterBurn_updates() public { + function test_countryInclusionHint_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); + (uint256 after_,) = fleet.countryInclusionHint(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); + function test_countryInclusionHint_registrantCanActOnHint() public { + // Fill up to create pressure + _registerNLocal(alice, US, ADMIN_CA, 8, 0); + _registerNCountry(alice, US, 8, 100); - (uint256 hottestTier,,, uint256 matchBond,) = fleet.countryCompetitiveHint(US); - assertEq(hottestTier, 2); + (uint256 inclusionTier, uint256 hintBond) = fleet.countryInclusionHint(US); - // Bob registers at country level matching the hint + // Bob registers at country level at the hinted tier vm.prank(bob); - fleet.registerFleetCountry(_uuid(2000), US, hottestTier); + fleet.registerFleetCountry(_uuid(2000), US, inclusionTier); uint256 tokenId = uint256(uint128(_uuid(2000))); - assertEq(fleet.fleetTier(tokenId), 2); - assertEq(fleet.bonds(tokenId), matchBond); + assertEq(fleet.fleetTier(tokenId), inclusionTier); + assertEq(fleet.bonds(tokenId), hintBond); - // Bundle for US-CA includes both at cursor=2 + // Bundle for US-CA includes Bob's fleet (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); - assertEq(count, 2); - bool foundLocal; + assertGt(count, 0); 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"); + assertTrue(foundCountry, "Country fleet should appear in bundle"); } // --- buildHighestBondedUUIDBundle (shared-cursor fair-stop) --- @@ -1532,7 +1545,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_singleGlobal() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(0, 0); assertEq(count, 1); @@ -1541,7 +1554,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_singleCountry() public { vm.prank(alice); - fleet.registerFleetCountry(UUID_1, US); + fleet.registerFleetCountry(UUID_1, US, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); assertEq(count, 1); @@ -1564,9 +1577,9 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); vm.prank(alice); - fleet.registerFleetCountry(UUID_2, US); + fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 3); @@ -1590,7 +1603,7 @@ contract FleetIdentityTest is Test { _registerNLocal(alice, US, ADMIN_CA, 3, 1000); _registerNCountry(alice, US, 2, 2000); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(3000)); + fleet.registerFleetGlobal(_uuid(3000), 0); (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 6); @@ -1601,10 +1614,10 @@ contract FleetIdentityTest is Test { function test_buildBundle_higherBondFirst() public { // Global: tier 0 (bond=BASE) vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); // Country: promote to tier 2 (bond=4*BASE) vm.prank(alice); - uint256 usId = fleet.registerFleetCountry(UUID_2, US); + uint256 usId = fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(alice); fleet.reassignTier(usId, 2); // Admin: tier 0 (bond=BASE) @@ -1628,13 +1641,13 @@ contract FleetIdentityTest is Test { // Country tier 1 (bond=2*BASE) vm.prank(alice); - uint256 id2 = fleet.registerFleetCountry(UUID_2, US); + uint256 id2 = fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(alice); fleet.reassignTier(id2, 1); // Global tier 0 (bond=BASE) vm.prank(alice); - fleet.registerFleetGlobal(UUID_3); + fleet.registerFleetGlobal(UUID_3, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 3); @@ -1654,13 +1667,13 @@ contract FleetIdentityTest is Test { // Country: promote to tier 1 (bond=200) vm.prank(alice); - uint256 countryId = fleet.registerFleetCountry(_uuid(8200), US); + uint256 countryId = fleet.registerFleetCountry(_uuid(8200), US, 0); vm.prank(alice); fleet.reassignTier(countryId, 1); // Global: promote to tier 2 (bond=400) vm.prank(alice); - uint256 globalId = fleet.registerFleetGlobal(_uuid(8300)); + uint256 globalId = fleet.registerFleetGlobal(_uuid(8300), 0); vm.prank(alice); fleet.reassignTier(globalId, 2); @@ -1691,12 +1704,12 @@ contract FleetIdentityTest is Test { fleet.registerFleetGlobal(_uuid(7100), 1); // Tier 0: admin(8), country(3), global(2) - _registerNLocal(alice, US, ADMIN_CA, 8, 5000); - _registerNCountry(alice, US, 3, 6000); + _registerNLocalAt(alice, US, ADMIN_CA, 8, 5000, 0); + _registerNCountryAt(alice, US, 3, 6000, 0); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(7000)); + fleet.registerFleetGlobal(_uuid(7000), 0); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(7001)); + fleet.registerFleetGlobal(_uuid(7001), 0); (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); // Cursor=1: admin(8)+country(8)+global(1)=17. Count=17, room=3. @@ -1773,9 +1786,9 @@ contract FleetIdentityTest is Test { } // Tier 0: full capacities. - _registerNLocal(alice, US, ADMIN_CA, 8, 3000); - _registerNCountry(alice, US, 8, 4000); - _registerNGlobal(alice, 4); + _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); + _registerNCountryAt(alice, US, 8, 4000, 0); + _registerNGlobalAt(alice, 4, 0, 0); (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); // Cursor=1: admin(3)+country(3)=6. Count=6, room=14. @@ -1803,8 +1816,8 @@ contract FleetIdentityTest is Test { } // Tier 0: 8 local + 4 global - _registerNLocal(alice, US, ADMIN_CA, 8, 3000); - _registerNGlobal(alice, 4); + _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); + _registerNGlobalAt(alice, 4, 0, 0); (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); // Cursor=2: admin(8)→include. Count=8. @@ -1830,12 +1843,12 @@ contract FleetIdentityTest is Test { } // Tier 0: admin=3, country=8, global=2 - _registerNLocal(alice, US, ADMIN_CA, 3, 3000); - _registerNCountry(alice, US, 8, 4000); + _registerNLocalAt(alice, US, ADMIN_CA, 3, 3000, 0); + _registerNCountryAt(alice, US, 8, 4000, 0); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(5000)); + fleet.registerFleetGlobal(_uuid(5000), 0); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(5001)); + fleet.registerFleetGlobal(_uuid(5001), 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); // Cursor=1: admin(5)+country(5)=10. Count=10, room=10. @@ -1879,11 +1892,11 @@ contract FleetIdentityTest is Test { // Tier 0: extras that should NOT be included vm.prank(alice); - fleet.registerFleetLocal(_uuid(4000), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(4000), US, ADMIN_CA, 0); vm.prank(alice); - fleet.registerFleetCountry(_uuid(4001), US); + fleet.registerFleetCountry(_uuid(4001), US, 0); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(4002)); + fleet.registerFleetGlobal(_uuid(4002), 0); (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); // Cursor=1: admin(8)+country(8)+global(4)=20. Bundle full. @@ -1916,11 +1929,11 @@ contract FleetIdentityTest is Test { // Tier 0 extras (should NOT be included): vm.prank(alice); - fleet.registerFleetLocal(_uuid(5000), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(5000), US, ADMIN_CA, 0); vm.prank(alice); - fleet.registerFleetCountry(_uuid(5001), US); + fleet.registerFleetCountry(_uuid(5001), US, 0); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(5002)); + fleet.registerFleetGlobal(_uuid(5002), 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 19); @@ -1948,8 +1961,8 @@ contract FleetIdentityTest is Test { } // Tier 0: admin=8, country=8 - _registerNLocal(alice, US, ADMIN_CA, 8, 3000); - _registerNCountry(alice, US, 8, 4000); + _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); + _registerNCountryAt(alice, US, 8, 4000, 0); (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); // Cursor=1: admin(8)+country(5)=13. Count=13, room=7. @@ -1974,8 +1987,8 @@ contract FleetIdentityTest is Test { } // Tier 0: admin=8, country=8 - _registerNLocal(alice, US, ADMIN_CA, 8, 3000); - _registerNCountry(alice, US, 8, 4000); + _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); + _registerNCountryAt(alice, US, 8, 4000, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); // Cursor=1: admin(8)+global(4)=12. Count=12, room=8. @@ -2009,7 +2022,7 @@ contract FleetIdentityTest is Test { fleet.reassignTier(id, 2); vm.prank(alice); - fleet.registerFleetGlobal(UUID_2); + fleet.registerFleetGlobal(UUID_2, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); // Cursor=2: admin(1)→include. Count=1. @@ -2027,7 +2040,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.reassignTier(id, 5); vm.prank(alice); - fleet.registerFleetGlobal(UUID_2); + fleet.registerFleetGlobal(UUID_2, 0); (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 2); @@ -2036,7 +2049,7 @@ contract FleetIdentityTest is Test { 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); + fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(alice); fleet.registerFleetCountry(UUID_2, US, 2); @@ -2050,9 +2063,9 @@ contract FleetIdentityTest is Test { function test_buildBundle_onlyGlobalWhenNoCountryCode() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US); + fleet.registerFleetCountry(UUID_2, US, 0); (, uint256 count) = fleet.buildHighestBondedUUIDBundle(0, 0); assertEq(count, 1); // only global @@ -2060,7 +2073,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_skipAdminWhenAdminCodeZero() public { vm.prank(alice); - fleet.registerFleetCountry(UUID_1, US); + fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); @@ -2082,9 +2095,9 @@ contract FleetIdentityTest is Test { 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) + _registerNCountryAt(alice, US, 8, 1000, 0); // tier 0: 8 members (bond=BASE) + _registerNCountryAt(alice, US, 8, 2000, 1); // tier 1: 8 members (bond=2*BASE) + _registerNCountryAt(alice, US, 4, 3000, 2); // tier 2: 4 members (bond=4*BASE) (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); assertEq(count, 20); @@ -2120,11 +2133,11 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.reassignTier(id1, 3); vm.prank(alice); - uint256 id2 = fleet.registerFleetCountry(UUID_2, US); + uint256 id2 = fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(alice); fleet.reassignTier(id2, 1); vm.prank(alice); - fleet.registerFleetGlobal(UUID_3); + fleet.registerFleetGlobal(UUID_3, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 3); @@ -2143,11 +2156,11 @@ contract FleetIdentityTest is Test { function test_buildBundle_afterBurn_reflects() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetGlobal(UUID_1); + uint256 id1 = fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); - fleet.registerFleetGlobal(UUID_2); + fleet.registerFleetGlobal(UUID_2, 0); vm.prank(carol); - fleet.registerFleetGlobal(UUID_3); + fleet.registerFleetGlobal(UUID_3, 0); (, uint256 countBefore) = fleet.buildHighestBondedUUIDBundle(0, 0); assertEq(countBefore, 3); @@ -2161,9 +2174,9 @@ contract FleetIdentityTest is Test { function test_buildBundle_exhaustsAllLevels() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); vm.prank(alice); - fleet.registerFleetCountry(UUID_2, US); + fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); @@ -2182,16 +2195,16 @@ contract FleetIdentityTest is Test { function test_buildBundle_lifecycle_promotionsAndBurns() public { vm.prank(alice); - uint256 g1 = fleet.registerFleetGlobal(_uuid(100)); + uint256 g1 = fleet.registerFleetGlobal(_uuid(100), 0); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(101)); + fleet.registerFleetGlobal(_uuid(101), 0); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(102)); + fleet.registerFleetGlobal(_uuid(102), 0); vm.prank(alice); - uint256 c1 = fleet.registerFleetCountry(_uuid(200), US); + uint256 c1 = fleet.registerFleetCountry(_uuid(200), US, 0); vm.prank(alice); - fleet.registerFleetCountry(_uuid(201), US); + fleet.registerFleetCountry(_uuid(201), US, 0); vm.prank(alice); fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA); @@ -2249,7 +2262,7 @@ contract FleetIdentityTest is Test { _registerNCountry(alice, US, 8, 2000); _registerNGlobal(alice, 4); vm.prank(alice); - fleet.registerFleetCountry(_uuid(3000), US); + fleet.registerFleetCountry(_uuid(3000), US, 1); // Cursor=1: country(1)→include. Count=1, room=19. // Cursor=0: admin(8)≤19→include[count=9,room=11]. @@ -2278,7 +2291,7 @@ contract FleetIdentityTest is Test { _registerNLocal(alice, US, ADMIN_CA, 3, 1000); _registerNCountry(bob, US, 2, 2000); vm.prank(carol); - fleet.registerFleetGlobal(UUID_1); + fleet.registerFleetGlobal(UUID_1, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); assertEq(count, 6); @@ -2294,9 +2307,9 @@ contract FleetIdentityTest is Test { _registerNLocal(alice, US, ADMIN_CA, 4, 1000); _registerNCountry(alice, US, 3, 2000); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(3000)); + fleet.registerFleetGlobal(_uuid(3000), 0); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(3001)); + fleet.registerFleetGlobal(_uuid(3001), 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); @@ -2324,11 +2337,11 @@ contract FleetIdentityTest is Test { for (uint256 i = 0; i < gCount; i++) { vm.prank(alice); - fleet.registerFleetGlobal(_uuid(30_000 + i)); + fleet.registerFleetGlobal(_uuid(30_000 + i), i / 4); } for (uint256 i = 0; i < cCount; i++) { vm.prank(alice); - fleet.registerFleetCountry(_uuid(31_000 + i), US); + fleet.registerFleetCountry(_uuid(31_000 + i), US, i / 8); } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); @@ -2346,11 +2359,11 @@ contract FleetIdentityTest is Test { for (uint256 i = 0; i < gCount; i++) { vm.prank(alice); - fleet.registerFleetGlobal(_uuid(40_000 + i)); + fleet.registerFleetGlobal(_uuid(40_000 + i), i / 4); } for (uint256 i = 0; i < cCount; i++) { vm.prank(alice); - fleet.registerFleetCountry(_uuid(41_000 + i), US); + fleet.registerFleetCountry(_uuid(41_000 + i), US, i / 8); } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); @@ -2372,11 +2385,11 @@ contract FleetIdentityTest is Test { for (uint256 i = 0; i < gCount; i++) { vm.prank(alice); - fleet.registerFleetGlobal(_uuid(50_000 + i)); + fleet.registerFleetGlobal(_uuid(50_000 + i), i / 4); } for (uint256 i = 0; i < cCount; i++) { vm.prank(alice); - fleet.registerFleetCountry(_uuid(51_000 + i), US); + fleet.registerFleetCountry(_uuid(51_000 + i), US, i / 8); } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); @@ -2397,11 +2410,11 @@ contract FleetIdentityTest is Test { for (uint256 i = 0; i < gCount; i++) { vm.prank(alice); - fleet.registerFleetGlobal(_uuid(60_000 + i)); + fleet.registerFleetGlobal(_uuid(60_000 + i), i / 4); } for (uint256 i = 0; i < cCount; i++) { vm.prank(alice); - fleet.registerFleetCountry(_uuid(61_000 + i), US); + fleet.registerFleetCountry(_uuid(61_000 + i), US, i / 8); } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); diff --git a/test/SwarmRegistryL1.t.sol b/test/SwarmRegistryL1.t.sol index 5624e4f3..ed26ff3b 100644 --- a/test/SwarmRegistryL1.t.sol +++ b/test/SwarmRegistryL1.t.sol @@ -52,7 +52,7 @@ contract SwarmRegistryL1Test is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleetGlobal(bytes16(keccak256(seed))); + return fleetContract.registerFleetGlobal(bytes16(keccak256(seed)), 0); } function _registerProvider(address owner, string memory url) internal returns (uint256) { diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol index 17f90880..9c544e2a 100644 --- a/test/SwarmRegistryUniversal.t.sol +++ b/test/SwarmRegistryUniversal.t.sol @@ -54,7 +54,7 @@ contract SwarmRegistryUniversalTest is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleetGlobal(bytes16(keccak256(seed))); + return fleetContract.registerFleetGlobal(bytes16(keccak256(seed)), 0); } function _registerProvider(address owner, string memory url) internal returns (uint256) { From 31e1512e7a74a7091167eced75d65a096a0a393c Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 18 Feb 2026 11:30:10 +1300 Subject: [PATCH 17/63] refactor: eliminate magic numbers, extract bundle helpers, split _findCheapestInclusionTier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constants: - MAX_COUNTRY_CODE (999), MAX_ADMIN_CODE (4095) replace bare literals in all validation guards and tierCapacity / region-index branches. - LEVEL_ADMIN / LEVEL_COUNTRY / LEVEL_GLOBAL (0/1/2) name the bundle priority levels. - ADMIN_SHIFT (12), ADMIN_CODE_MASK (0xFFF) centralise the bit-packing scheme. Internal region-key helpers: - _makeAdminRegion(cc, admin): replaces 7 inline `(uint32(cc) << 12) | uint32(admin)`. - _countryFromRegion(rk), _adminFromRegion(rk): replace inline `uint16(rk >> 12)` / `uint16(rk & 0xFFF)`. Bundle-level helpers (shared by buildHighestBondedUUIDBundle and inclusion hints): - _resolveBundleLevels(cc, admin): pure, returns keys[3] + active[3]. - _findMaxTierIndex(keys, active): view, returns (maxTI, anyActive). - _simulateBundleCounts(keys, active, maxTI): view, Phase 1 of the inclusion simulation — returns countBefore[24] + cutoffCursor. _findCheapestInclusionTier now takes (countryCode, adminCode, candidateLevel) instead of a raw candidateRegion uint32. The region key is derived internally from keys[candidateLevel]. Dead `earlySkip` variable removed. buildHighestBondedUUIDBundle delegates setup to _resolveBundleLevels and _findMaxTierIndex, keeping only the UUID-collecting cursor loop. All 158 FleetIdentity tests + 135 SwarmRegistry tests pass." --- src/swarms/FleetIdentity.sol | 274 +++++++++++++++++++---------------- 1 file changed, 148 insertions(+), 126 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index a92a56b9..a39e82f5 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -81,6 +81,24 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Region key for global registrations. uint32 public constant GLOBAL_REGION = 0; + /// @notice ISO 3166-1 numeric upper bound for country codes. + uint16 internal constant MAX_COUNTRY_CODE = 999; + + /// @notice Upper bound for admin-area codes within a country. + uint16 internal constant MAX_ADMIN_CODE = 4095; + + /// @dev Bundle level index: admin area (highest priority). + uint256 internal constant LEVEL_ADMIN = 0; + /// @dev Bundle level index: country. + uint256 internal constant LEVEL_COUNTRY = 1; + /// @dev Bundle level index: global (lowest priority). + uint256 internal constant LEVEL_GLOBAL = 2; + + /// @dev Bit shift for packing countryCode into an admin-area region key. + uint256 private constant ADMIN_SHIFT = 12; + /// @dev Bitmask for extracting adminCode from an admin-area region key. + uint32 private constant ADMIN_CODE_MASK = 0xFFF; + /// @notice The ERC-20 token used for bonds (immutable, e.g. NODL). IERC20 public immutable BOND_TOKEN; @@ -185,7 +203,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { returns (uint256 tokenId) { if (uuid == bytes16(0)) revert InvalidUUID(); - if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); + if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); uint32 regionKey = uint32(countryCode); _validateExplicitTier(regionKey, targetTier); tokenId = _register(uuid, regionKey, targetTier); @@ -207,9 +225,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { 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); + if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); + if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); + uint32 regionKey = _makeAdminRegion(countryCode, adminCode); uint256 tier = _cheapestInclusionTierLocal(regionKey, countryCode); tokenId = _register(uuid, regionKey, tier); } @@ -221,9 +239,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { 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); + if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); + if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); + uint32 regionKey = _makeAdminRegion(countryCode, adminCode); _validateExplicitTier(regionKey, targetTier); tokenId = _register(uuid, regionKey, targetTier); } @@ -294,7 +312,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// Global = 4, Country = 8, Admin Area = 8. function tierCapacity(uint32 regionKey) public pure returns (uint256) { if (regionKey == GLOBAL_REGION) return GLOBAL_TIER_CAPACITY; - if (regionKey <= 999) return COUNTRY_TIER_CAPACITY; + if (regionKey <= MAX_COUNTRY_CODE) return COUNTRY_TIER_CAPACITY; return LOCAL_TIER_CAPACITY; } @@ -306,10 +324,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { view returns (uint256 inclusionTier, uint256 bond) { - if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); - if (adminCode == 0 || adminCode > 4095) revert InvalidAdminCode(); - uint32 adminRegion = (uint32(countryCode) << 12) | uint32(adminCode); - inclusionTier = _findCheapestInclusionTier(countryCode, adminCode, adminRegion); + if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); + if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); + inclusionTier = _findCheapestInclusionTier(countryCode, adminCode, LEVEL_ADMIN); bond = tierBond(inclusionTier); } @@ -324,18 +341,17 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { view returns (uint256 inclusionTier, uint256 bond) { - if (countryCode == 0 || countryCode > 999) revert InvalidCountryCode(); - uint32 countryRegion = uint32(countryCode); + if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); // Check the country-only location (no admin area active). - inclusionTier = _findCheapestInclusionTier(countryCode, 0, countryRegion); + inclusionTier = _findCheapestInclusionTier(countryCode, 0, LEVEL_COUNTRY); // Scan all active admin areas belonging to this country. for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { uint32 rk = _activeAdminAreas[i]; - if (uint16(rk >> 12) != countryCode) continue; - uint16 admin = uint16(rk & 0xFFF); - uint256 t = _findCheapestInclusionTier(countryCode, admin, countryRegion); + if (_countryFromRegion(rk) != countryCode) continue; + uint16 admin = _adminFromRegion(rk); + uint256 t = _findCheapestInclusionTier(countryCode, admin, LEVEL_COUNTRY); if (t > inclusionTier) inclusionTier = t; } bond = tierBond(inclusionTier); @@ -353,21 +369,21 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { returns (uint256 inclusionTier, uint256 bond) { // Global-only location (no country, no admin). - inclusionTier = _findCheapestInclusionTier(0, 0, GLOBAL_REGION); + inclusionTier = _findCheapestInclusionTier(0, 0, LEVEL_GLOBAL); // Check each active country (country-only location). for (uint256 i = 0; i < _activeCountries.length; ++i) { uint16 cc = _activeCountries[i]; - uint256 t = _findCheapestInclusionTier(cc, 0, GLOBAL_REGION); + uint256 t = _findCheapestInclusionTier(cc, 0, LEVEL_GLOBAL); if (t > inclusionTier) inclusionTier = t; } // Check each active admin area. for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { uint32 rk = _activeAdminAreas[i]; - uint16 cc = uint16(rk >> 12); - uint16 admin = uint16(rk & 0xFFF); - uint256 t = _findCheapestInclusionTier(cc, admin, GLOBAL_REGION); + uint16 cc = _countryFromRegion(rk); + uint16 admin = _adminFromRegion(rk); + uint256 t = _findCheapestInclusionTier(cc, admin, LEVEL_GLOBAL); if (t > inclusionTier) inclusionTier = t; } bond = tierBond(inclusionTier); @@ -465,7 +481,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { { // 1. Try admin area if (countryCode > 0 && adminCode > 0) { - regionKey = (uint32(countryCode) << 12) | uint32(adminCode); + regionKey = _makeAdminRegion(countryCode, adminCode); uint256 tc = regionTierCount[regionKey]; if (tc > 0) { tier = tc - 1; @@ -504,7 +520,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { countryTierCount = regionTierCount[uint32(countryCode)]; } if (countryCode > 0 && adminCode > 0) { - adminRegion = (uint32(countryCode) << 12) | uint32(adminCode); + adminRegion = _makeAdminRegion(countryCode, adminCode); adminTierCount = regionTierCount[adminRegion]; } } @@ -551,39 +567,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { { uuids = new bytes16[](MAX_BONDED_UUID_BUNDLE_SIZE); - // Resolve region keys for each level. - uint32[3] memory keys; - bool[3] memory active; - - // Level 0: admin area (highest priority) - if (countryCode > 0 && adminCode > 0) { - keys[0] = (uint32(countryCode) << 12) | uint32(adminCode); - active[0] = true; - } - - // Level 1: country - if (countryCode > 0) { - keys[1] = uint32(countryCode); - active[1] = true; - } - - // Level 2: global (lowest priority) - 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; - } - } - } + (uint32[3] memory keys, bool[3] memory active) = _resolveBundleLevels(countryCode, adminCode); + (uint256 maxTierIndex, bool anyActive) = _findMaxTierIndex(keys, active); if (!anyActive) { assembly { @@ -661,13 +646,30 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @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); + return _makeAdminRegion(countryCode, adminCode); } // ══════════════════════════════════════════════ // Internals // ══════════════════════════════════════════════ + // -- Region key encoding -- + + /// @dev Packs a country code and admin code into an admin-area region key. + function _makeAdminRegion(uint16 countryCode, uint16 adminCode) internal pure returns (uint32) { + return (uint32(countryCode) << uint32(ADMIN_SHIFT)) | uint32(adminCode); + } + + /// @dev Extracts the country code from an admin-area region key. + function _countryFromRegion(uint32 adminRegion) internal pure returns (uint16) { + return uint16(adminRegion >> uint32(ADMIN_SHIFT)); + } + + /// @dev Extracts the admin code from an admin-area region key. + function _adminFromRegion(uint32 adminRegion) internal pure returns (uint16) { + return uint16(adminRegion & ADMIN_CODE_MASK); + } + /// @dev Shared registration logic. function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { uint256 bond = tierBond(tier); @@ -759,77 +761,55 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } - /// @dev State-changing wrapper around the inclusion simulation for local - /// auto-assign. Opens a new tier if the result exceeds regionTierCount. - function _cheapestInclusionTierLocal(uint32 adminRegion, uint16 countryCode) internal returns (uint256) { - uint16 adminCode = uint16(adminRegion & 0xFFF); - uint256 tier = _findCheapestInclusionTier(countryCode, adminCode, adminRegion); - if (tier >= regionTierCount[adminRegion]) { - regionTierCount[adminRegion] = tier + 1; - } - return tier; - } - - /// @dev Simulates `buildHighestBondedUUIDBundle(countryCode, adminCode)` and - /// returns the cheapest tier at `candidateRegion` that guarantees bundle - /// inclusion. Bounded: O(MAX_TIERS). Works for any candidate level. - /// - /// Phase 1 — run the bundle simulation WITHOUT the new fleet, recording - /// `countBefore[c]` (UUIDs accumulated from cursors above c) - /// and a `cutoffCursor` (cursor where a fair-stop fired). - /// - /// Phase 2 — scan candidate tiers from 0 upward. For each T: - /// (a) T must have capacity in the candidate region. - /// (b) The cursor must reach T (T >= cutoffCursor). - /// (c) At cursor T, after levels with higher priority than the - /// candidate consume room, the candidate's members + 1 must - /// still fit in MAX_BONDED_UUID_BUNDLE_SIZE. - function _findCheapestInclusionTier( - uint16 countryCode, - uint16 adminCode, - uint32 candidateRegion - ) internal view returns (uint256) { - // --- Set up the three bundle levels (same logic as buildHighestBondedUUIDBundle). --- - uint32[3] memory keys; - bool[3] memory active; - uint256 candidateLevel = 3; // sentinel — updated below + // -- Bundle-level helpers (shared by buildHighestBondedUUIDBundle & inclusion hints) -- + /// @dev Resolves the three bundle levels from a location. + /// Level 0 = admin area, Level 1 = country, Level 2 = global. + function _resolveBundleLevels(uint16 countryCode, uint16 adminCode) + internal + pure + returns (uint32[3] memory keys, bool[3] memory active) + { if (countryCode > 0 && adminCode > 0) { - keys[0] = (uint32(countryCode) << 12) | uint32(adminCode); - active[0] = true; - if (keys[0] == candidateRegion) candidateLevel = 0; + keys[LEVEL_ADMIN] = _makeAdminRegion(countryCode, adminCode); + active[LEVEL_ADMIN] = true; } if (countryCode > 0) { - keys[1] = uint32(countryCode); - active[1] = true; - if (keys[1] == candidateRegion) candidateLevel = 1; + keys[LEVEL_COUNTRY] = uint32(countryCode); + active[LEVEL_COUNTRY] = true; } - keys[2] = GLOBAL_REGION; - active[2] = true; - if (keys[2] == candidateRegion) candidateLevel = 2; - - uint256 cap = tierCapacity(candidateRegion); + keys[LEVEL_GLOBAL] = GLOBAL_REGION; + active[LEVEL_GLOBAL] = true; + } - // Collect tier-counts for the three levels. - uint256[3] memory tc; - uint256 maxTI = 0; - bool anyActive = false; + /// @dev Finds the highest active tier index across all bundle levels. + function _findMaxTierIndex(uint32[3] memory keys, bool[3] memory active) + internal + view + returns (uint256 maxTI, bool anyActive) + { for (uint256 lvl = 0; lvl < 3; ++lvl) { if (!active[lvl]) continue; - tc[lvl] = regionTierCount[keys[lvl]]; - if (tc[lvl] > 0) { + uint256 tc = regionTierCount[keys[lvl]]; + if (tc > 0) { anyActive = true; - if (tc[lvl] - 1 > maxTI) maxTI = tc[lvl] - 1; + if (tc - 1 > maxTI) maxTI = tc - 1; } } + } - // If nothing is active yet, tier 0 always works. - if (!anyActive) return 0; - - // --- Phase 1: simulate bundle WITHOUT the new fleet. --- - uint256[24] memory countBefore; // countBefore[c] = UUIDs from cursors above c + /// @dev Phase 1 of the inclusion simulation: runs the bundle's shared-cursor + /// descent WITHOUT the candidate fleet, recording how many UUIDs are + /// accumulated before each cursor position (`countBefore[c]`) and the + /// cursor level at which a fair-stop fired (`cutoffCursor`, or -1 if + /// the cursor reached 0 without stopping). + function _simulateBundleCounts( + uint32[3] memory keys, + bool[3] memory active, + uint256 maxTI + ) internal view returns (uint256[24] memory countBefore, int256 cutoffCursor) { uint256 count = 0; - int256 cutoffCursor = -1; // -1 ⇒ cursor reached 0 without stopping + cutoffCursor = -1; for (int256 cursor = int256(maxTI); cursor >= 0; --cursor) { countBefore[uint256(cursor)] = count; @@ -841,7 +821,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { bool skipped = false; for (uint256 lvl = 0; lvl < 3; ++lvl) { if (!active[lvl]) continue; - if (tc[lvl] == 0 || uint256(cursor) >= tc[lvl]) continue; + uint256 tc = regionTierCount[keys[lvl]]; + if (tc == 0 || uint256(cursor) >= tc) continue; uint256 m = _regionTierMembers[keys[lvl]][uint256(cursor)].length; if (m == 0) continue; if (m <= MAX_BONDED_UUID_BUNDLE_SIZE - count) { @@ -855,9 +836,52 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { break; } } + } + + // -- Inclusion-tier logic -- + + /// @dev State-changing wrapper around the inclusion simulation for local + /// auto-assign. Opens a new tier if the result exceeds regionTierCount. + function _cheapestInclusionTierLocal(uint32 adminRegion, uint16 countryCode) internal returns (uint256) { + uint16 adminCode = _adminFromRegion(adminRegion); + uint256 tier = _findCheapestInclusionTier(countryCode, adminCode, LEVEL_ADMIN); + if (tier >= regionTierCount[adminRegion]) { + regionTierCount[adminRegion] = tier + 1; + } + return tier; + } + + /// @dev Simulates `buildHighestBondedUUIDBundle(countryCode, adminCode)` and + /// returns the cheapest tier at `candidateLevel` that guarantees bundle + /// inclusion. Bounded: O(MAX_TIERS). Works for any candidate level. + /// + /// Phase 1 (`_simulateBundleCounts`) — run the bundle simulation WITHOUT + /// the new fleet, recording `countBefore[c]` and `cutoffCursor`. + /// + /// Phase 2 — scan candidate tiers from 0 upward. For each T: + /// (a) T must have capacity in the candidate region. + /// (b) The cursor must reach T (T >= cutoffCursor). + /// (c) At cursor T, after levels with higher priority than the + /// candidate consume room, the candidate's members + 1 must + /// still fit in MAX_BONDED_UUID_BUNDLE_SIZE. + function _findCheapestInclusionTier( + uint16 countryCode, + uint16 adminCode, + uint256 candidateLevel + ) internal view returns (uint256) { + (uint32[3] memory keys, bool[3] memory active) = _resolveBundleLevels(countryCode, adminCode); + (uint256 maxTI, bool anyActive) = _findMaxTierIndex(keys, active); + + if (!anyActive) return 0; + + uint32 candidateRegion = keys[candidateLevel]; + uint256 cap = tierCapacity(candidateRegion); + + // Phase 1: simulate bundle without the new fleet. + (uint256[24] memory countBefore, int256 cutoffCursor) = _simulateBundleCounts(keys, active, maxTI); - // --- Phase 2: find cheapest tier T at candidateRegion. --- - uint256 candidateTC = (candidateLevel < 3) ? tc[candidateLevel] : 0; + // Phase 2: find cheapest tier T at candidateRegion. + uint256 candidateTC = regionTierCount[candidateRegion]; for (uint256 T = 0; T < MAX_TIERS; ++T) { // (a) Room in the tier? @@ -872,12 +896,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // (c) At cursor T, compute room after higher-priority levels. uint256 countAtT = (T <= maxTI) ? countBefore[T] : 0; - // Process levels before the candidate at cursor T. - bool earlySkip = false; for (uint256 lvl = 0; lvl < 3; ++lvl) { if (!active[lvl]) continue; - if (keys[lvl] == candidateRegion) break; // reached candidate level - uint256 ltc = tc[lvl]; + if (lvl == candidateLevel) break; // reached candidate level + uint256 ltc = regionTierCount[keys[lvl]]; if (ltc == 0 || T >= ltc) continue; uint256 m = _regionTierMembers[keys[lvl]][T].length; if (m == 0) continue; @@ -935,7 +957,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { function _addToRegionIndex(uint32 region) internal { if (region == GLOBAL_REGION) { globalActive = true; - } else if (region <= 999) { + } else if (region <= MAX_COUNTRY_CODE) { // Country uint16 cc = uint16(region); if (_activeCountryIndex[cc] == 0) { @@ -957,7 +979,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (region == GLOBAL_REGION) { globalActive = false; - } else if (region <= 999) { + } else if (region <= MAX_COUNTRY_CODE) { uint16 cc = uint16(region); uint256 oneIdx = _activeCountryIndex[cc]; if (oneIdx > 0) { From de7197785ab35d2a41a8de4f5f629e2f8610a676 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 18 Feb 2026 11:45:14 +1300 Subject: [PATCH 18/63] remove anyActive --- src/swarms/FleetIdentity.sol | 64 ++++++++++-------------------------- test/FleetIdentity.t.sol | 2 +- 2 files changed, 19 insertions(+), 47 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index a39e82f5..1c5cc7b2 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -336,11 +336,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Unbounded view — iterates over all active admin areas in the /// country. Free off-chain; callers pass the result to /// `registerFleetCountry(uuid, cc, tier)`. - function countryInclusionHint(uint16 countryCode) - external - view - returns (uint256 inclusionTier, uint256 bond) - { + function countryInclusionHint(uint16 countryCode) external view returns (uint256 inclusionTier, uint256 bond) { if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); // Check the country-only location (no admin area active). @@ -363,11 +359,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Unbounded view — iterates over all active countries and admin /// areas. Free off-chain; callers pass the result to /// `registerFleetGlobal(uuid, tier)`. - function globalInclusionHint() - external - view - returns (uint256 inclusionTier, uint256 bond) - { + function globalInclusionHint() external view returns (uint256 inclusionTier, uint256 bond) { // Global-only location (no country, no admin). inclusionTier = _findCheapestInclusionTier(0, 0, LEVEL_GLOBAL); @@ -463,8 +455,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { nextTierBond = (topTier + 1 < MAX_TIERS) ? tierBond(topTier + 1) : 0; } - - // ══════════════════════════════════════════════ // Views: EdgeBeaconScanner discovery // ══════════════════════════════════════════════ @@ -568,14 +558,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uuids = new bytes16[](MAX_BONDED_UUID_BUNDLE_SIZE); (uint32[3] memory keys, bool[3] memory active) = _resolveBundleLevels(countryCode, adminCode); - (uint256 maxTierIndex, bool anyActive) = _findMaxTierIndex(keys, active); - - if (!anyActive) { - assembly { - mstore(uuids, 0) - } - return (uuids, 0); - } + uint256 maxTierIndex = _findMaxTierIndex(keys, active); // Descend from the highest tier index using a shared cursor. for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { @@ -783,17 +766,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @dev Finds the highest active tier index across all bundle levels. - function _findMaxTierIndex(uint32[3] memory keys, bool[3] memory active) - internal - view - returns (uint256 maxTI, bool anyActive) - { + function _findMaxTierIndex(uint32[3] memory keys, bool[3] memory active) internal view returns (uint256 maxTI) { for (uint256 lvl = 0; lvl < 3; ++lvl) { if (!active[lvl]) continue; uint256 tc = regionTierCount[keys[lvl]]; - if (tc > 0) { - anyActive = true; - if (tc - 1 > maxTI) maxTI = tc - 1; + if (tc > 0 && tc - 1 > maxTI) { + maxTI = tc - 1; } } } @@ -803,11 +781,11 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// accumulated before each cursor position (`countBefore[c]`) and the /// cursor level at which a fair-stop fired (`cutoffCursor`, or -1 if /// the cursor reached 0 without stopping). - function _simulateBundleCounts( - uint32[3] memory keys, - bool[3] memory active, - uint256 maxTI - ) internal view returns (uint256[24] memory countBefore, int256 cutoffCursor) { + function _simulateBundleCounts(uint32[3] memory keys, bool[3] memory active, uint256 maxTI) + internal + view + returns (uint256[24] memory countBefore, int256 cutoffCursor) + { uint256 count = 0; cutoffCursor = -1; @@ -864,15 +842,13 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// (c) At cursor T, after levels with higher priority than the /// candidate consume room, the candidate's members + 1 must /// still fit in MAX_BONDED_UUID_BUNDLE_SIZE. - function _findCheapestInclusionTier( - uint16 countryCode, - uint16 adminCode, - uint256 candidateLevel - ) internal view returns (uint256) { + function _findCheapestInclusionTier(uint16 countryCode, uint16 adminCode, uint256 candidateLevel) + internal + view + returns (uint256) + { (uint32[3] memory keys, bool[3] memory active) = _resolveBundleLevels(countryCode, adminCode); - (uint256 maxTI, bool anyActive) = _findMaxTierIndex(keys, active); - - if (!anyActive) return 0; + uint256 maxTI = _findMaxTierIndex(keys, active); uint32 candidateRegion = keys[candidateLevel]; uint256 cap = tierCapacity(candidateRegion); @@ -885,9 +861,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { for (uint256 T = 0; T < MAX_TIERS; ++T) { // (a) Room in the tier? - uint256 members = (T < candidateTC) - ? _regionTierMembers[candidateRegion][T].length - : 0; + uint256 members = (T < candidateTC) ? _regionTierMembers[candidateRegion][T].length : 0; if (members >= cap) continue; // (b) Cursor reaches T? @@ -949,8 +923,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { regionTierCount[region] = tierCount; } - - // -- 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 70870839..1d1992c8 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1493,7 +1493,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(_uuid(2000), US, ADMIN_CA, 1); (uint256 usTier,) = fleet.countryInclusionHint(US); - // US country fleet needs inclusion in bundle(US, ADMIN_CA). + // US country fleet needs inclusion in bundle(US, ADMIN_CA). // Admin has 1 at tier 1. Country at tier 0: +1=1, fits. assertEq(usTier, 0); } From d27c48d8fd22d70e61ffd4bee4dc508d6c73f724 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 18 Feb 2026 14:00:46 +1300 Subject: [PATCH 19/63] fix lints --- src/swarms/FleetIdentity.sol | 76 +++++++++--------- src/swarms/doc/graph-architecture.md | 2 +- test/FleetIdentity.t.sol | 112 +++++++++++++-------------- 3 files changed, 92 insertions(+), 98 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 1c5cc7b2..e76871ec 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -75,7 +75,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// 24 provides comfortable headroom. uint256 public constant MAX_TIERS = 24; - /// @notice Maximum UUIDs returned by buildHighestBondedUUIDBundle. + /// @notice Maximum UUIDs returned by buildHighestBondedUuidBundle. uint256 public constant MAX_BONDED_UUID_BUNDLE_SIZE = 20; /// @notice Region key for global registrations. @@ -215,7 +215,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Register a fleet under a country + admin area (auto-assign tier). /// @dev Auto-assign picks the cheapest tier that guarantees the fleet - /// appears in `buildHighestBondedUUIDBundle` for this location. + /// appears in `buildHighestBondedUuidBundle` for this location. /// Bounded: O(MAX_TIERS) — only 3 regions participate. /// @param countryCode ISO 3166-1 numeric country code (1-999). /// @param adminCode Admin area code within the country (1-4095). @@ -227,9 +227,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (uuid == bytes16(0)) revert InvalidUUID(); if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); - uint32 regionKey = _makeAdminRegion(countryCode, adminCode); - uint256 tier = _cheapestInclusionTierLocal(regionKey, countryCode); - tokenId = _register(uuid, regionKey, tier); + uint256 targetTier = _findCheapestInclusionTier(countryCode, adminCode, LEVEL_ADMIN); + uint32 region = _makeAdminRegion(countryCode, adminCode); + _validateExplicitTier(region, targetTier); + tokenId = _register(uuid, region, targetTier); } /// @notice Register a fleet under a country + admin area into a specific tier. @@ -317,7 +318,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @notice Returns the cheapest tier that guarantees a **local** fleet - /// appears in `buildHighestBondedUUIDBundle` for (countryCode, adminCode). + /// appears in `buildHighestBondedUuidBundle` for (countryCode, adminCode). /// Bounded: O(MAX_TIERS). function localInclusionHint(uint16 countryCode, uint16 adminCode) external @@ -331,7 +332,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @notice Returns the cheapest tier that guarantees a **country** fleet - /// appears in every `buildHighestBondedUUIDBundle` query within + /// appears in every `buildHighestBondedUuidBundle` query within /// the country (across all active admin areas). /// @dev Unbounded view — iterates over all active admin areas in the /// country. Free off-chain; callers pass the result to @@ -354,7 +355,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @notice Returns the cheapest tier that guarantees a **global** fleet - /// appears in every `buildHighestBondedUUIDBundle` query for any + /// appears in every `buildHighestBondedUuidBundle` query for any /// (countryCode, adminCode) pair. /// @dev Unbounded view — iterates over all active countries and admin /// areas. Free off-chain; callers pass the result to @@ -399,7 +400,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @notice All UUIDs in a specific tier of a region. - function getTierUUIDs(uint32 regionKey, uint256 tier) external view returns (bytes16[] memory uuids) { + 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++) { @@ -408,7 +409,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @notice UUID for a token ID. - function tokenUUID(uint256 tokenId) external pure returns (bytes16) { + function tokenUuid(uint256 tokenId) external pure returns (bytes16) { return bytes16(uint128(tokenId)); } @@ -550,7 +551,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @param adminCode EdgeBeaconScanner admin area (0 to skip admin). /// @return uuids The merged UUID bundle (up to 20). /// @return count Actual number of UUIDs returned. - function buildHighestBondedUUIDBundle(uint16 countryCode, uint16 adminCode) + function buildHighestBondedUuidBundle(uint16 countryCode, uint16 adminCode) external view returns (bytes16[] memory uuids, uint256 count) @@ -744,7 +745,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } - // -- Bundle-level helpers (shared by buildHighestBondedUUIDBundle & inclusion hints) -- + // -- Bundle-level helpers (shared by buildHighestBondedUuidBundle & inclusion hints) -- /// @dev Resolves the three bundle levels from a location. /// Level 0 = admin area, Level 1 = country, Level 2 = global. @@ -766,12 +767,16 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @dev Finds the highest active tier index across all bundle levels. - function _findMaxTierIndex(uint32[3] memory keys, bool[3] memory active) internal view returns (uint256 maxTI) { + function _findMaxTierIndex(uint32[3] memory keys, bool[3] memory active) + internal + view + returns (uint256 maxTierIndex) + { for (uint256 lvl = 0; lvl < 3; ++lvl) { if (!active[lvl]) continue; uint256 tc = regionTierCount[keys[lvl]]; - if (tc > 0 && tc - 1 > maxTI) { - maxTI = tc - 1; + if (tc > 0 && tc - 1 > maxTierIndex) { + maxTierIndex = tc - 1; } } } @@ -781,15 +786,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// accumulated before each cursor position (`countBefore[c]`) and the /// cursor level at which a fair-stop fired (`cutoffCursor`, or -1 if /// the cursor reached 0 without stopping). - function _simulateBundleCounts(uint32[3] memory keys, bool[3] memory active, uint256 maxTI) + function _simulateBundleCounts(uint32[3] memory keys, bool[3] memory active, uint256 maxTierIndex) internal view - returns (uint256[24] memory countBefore, int256 cutoffCursor) + returns (uint256[MAX_TIERS] memory countBefore, int256 cutoffCursor) { uint256 count = 0; cutoffCursor = -1; - for (int256 cursor = int256(maxTI); cursor >= 0; --cursor) { + for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { countBefore[uint256(cursor)] = count; if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) { cutoffCursor = cursor; @@ -818,18 +823,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // -- Inclusion-tier logic -- - /// @dev State-changing wrapper around the inclusion simulation for local - /// auto-assign. Opens a new tier if the result exceeds regionTierCount. - function _cheapestInclusionTierLocal(uint32 adminRegion, uint16 countryCode) internal returns (uint256) { - uint16 adminCode = _adminFromRegion(adminRegion); - uint256 tier = _findCheapestInclusionTier(countryCode, adminCode, LEVEL_ADMIN); - if (tier >= regionTierCount[adminRegion]) { - regionTierCount[adminRegion] = tier + 1; - } - return tier; - } - - /// @dev Simulates `buildHighestBondedUUIDBundle(countryCode, adminCode)` and + /// @dev Simulates `buildHighestBondedUuidBundle(countryCode, adminCode)` and /// returns the cheapest tier at `candidateLevel` that guarantees bundle /// inclusion. Bounded: O(MAX_TIERS). Works for any candidate level. /// @@ -848,27 +842,27 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { returns (uint256) { (uint32[3] memory keys, bool[3] memory active) = _resolveBundleLevels(countryCode, adminCode); - uint256 maxTI = _findMaxTierIndex(keys, active); + uint256 maxTierIndex = _findMaxTierIndex(keys, active); uint32 candidateRegion = keys[candidateLevel]; uint256 cap = tierCapacity(candidateRegion); // Phase 1: simulate bundle without the new fleet. - (uint256[24] memory countBefore, int256 cutoffCursor) = _simulateBundleCounts(keys, active, maxTI); + (uint256[MAX_TIERS] memory countBefore, int256 cutoffCursor) = _simulateBundleCounts(keys, active, maxTierIndex); // Phase 2: find cheapest tier T at candidateRegion. - uint256 candidateTC = regionTierCount[candidateRegion]; + uint256 candidateTierCount = regionTierCount[candidateRegion]; for (uint256 T = 0; T < MAX_TIERS; ++T) { // (a) Room in the tier? - uint256 members = (T < candidateTC) ? _regionTierMembers[candidateRegion][T].length : 0; + uint256 members = (T < candidateTierCount) ? _regionTierMembers[candidateRegion][T].length : 0; if (members >= cap) continue; // (b) Cursor reaches T? if (cutoffCursor >= 0 && int256(T) < cutoffCursor) continue; // (c) At cursor T, compute room after higher-priority levels. - uint256 countAtT = (T <= maxTI) ? countBefore[T] : 0; + uint256 countAtT = (T <= maxTierIndex) ? countBefore[T] : 0; for (uint256 lvl = 0; lvl < 3; ++lvl) { if (!active[lvl]) continue; @@ -958,9 +952,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 lastIdx = _activeCountries.length - 1; uint256 removeIdx = oneIdx - 1; if (removeIdx != lastIdx) { - uint16 lastCC = _activeCountries[lastIdx]; - _activeCountries[removeIdx] = lastCC; - _activeCountryIndex[lastCC] = oneIdx; + uint16 lastCountryCode = _activeCountries[lastIdx]; + _activeCountries[removeIdx] = lastCountryCode; + _activeCountryIndex[lastCountryCode] = oneIdx; } _activeCountries.pop(); delete _activeCountryIndex[cc]; @@ -971,9 +965,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 lastIdx = _activeAdminAreas.length - 1; uint256 removeIdx = oneIdx - 1; if (removeIdx != lastIdx) { - uint32 lastAA = _activeAdminAreas[lastIdx]; - _activeAdminAreas[removeIdx] = lastAA; - _activeAdminAreaIndex[lastAA] = oneIdx; + uint32 lastAdminArea = _activeAdminAreas[lastIdx]; + _activeAdminAreas[removeIdx] = lastAdminArea; + _activeAdminAreaIndex[lastAdminArea] = oneIdx; } _activeAdminAreas.pop(); delete _activeAdminAreaIndex[region]; diff --git a/src/swarms/doc/graph-architecture.md b/src/swarms/doc/graph-architecture.md index 59c173e8..8bf78f4d 100644 --- a/src/swarms/doc/graph-architecture.md +++ b/src/swarms/doc/graph-architecture.md @@ -45,7 +45,7 @@ classDiagram +registerFleet(uuid, bondAmount) tokenId +increaseBond(tokenId, amount) +burn(tokenId) - +tokenUUID(tokenId) bytes16 + +tokenUuid(tokenId) bytes16 +totalSupply() uint256 +tokenByIndex(index) uint256 +tokenOfOwnerByIndex(owner, index) uint256 diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 1d1992c8..9bf44ad7 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -721,18 +721,18 @@ contract FleetIdentityTest is Test { assertEq(usMembers[0], usId); } - function test_getTierUUIDs_perRegion() public { + function test_getTierUuids_perRegion() public { vm.prank(alice); fleet.registerFleetGlobal(UUID_1, 0); vm.prank(bob); fleet.registerFleetCountry(UUID_2, US, 0); - bytes16[] memory gUUIDs = fleet.getTierUUIDs(GLOBAL, 0); + bytes16[] memory gUUIDs = fleet.getTierUuids(GLOBAL, 0); assertEq(gUUIDs.length, 1); assertEq(gUUIDs[0], UUID_1); - bytes16[] memory usUUIDs = fleet.getTierUUIDs(_regionUS(), 0); + bytes16[] memory usUUIDs = fleet.getTierUuids(_regionUS(), 0); assertEq(usUUIDs.length, 1); assertEq(usUUIDs[0], UUID_2); } @@ -906,12 +906,12 @@ contract FleetIdentityTest is Test { assertTrue(minAdmin > maxCountry); } - // --- tokenUUID / bonds --- + // --- tokenUuid / bonds --- - function test_tokenUUID_roundTrip() public { + function test_tokenUuid_roundTrip() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); - assertEq(fleet.tokenUUID(tokenId), UUID_1); + assertEq(fleet.tokenUuid(tokenId), UUID_1); } function test_bonds_returnsTierBond() public { @@ -1408,7 +1408,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.bonds(tokenId), hintBond); // Bundle for US-CA includes both - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertGt(count, 0); bool foundGlobal; for (uint256 i = 0; i < count; i++) { @@ -1525,7 +1525,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.bonds(tokenId), hintBond); // Bundle for US-CA includes Bob's fleet - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertGt(count, 0); bool foundCountry; for (uint256 i = 0; i < count; i++) { @@ -1534,12 +1534,12 @@ contract FleetIdentityTest is Test { assertTrue(foundCountry, "Country fleet should appear in bundle"); } - // --- buildHighestBondedUUIDBundle (shared-cursor fair-stop) --- + // --- buildHighestBondedUuidBundle (shared-cursor fair-stop) --- // ── Empty / Single-level basics ── function test_buildBundle_emptyReturnsZero() public view { - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 0); } @@ -1547,7 +1547,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(UUID_1, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(0, 0); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(0, 0); assertEq(count, 1); assertEq(uuids[0], UUID_1); } @@ -1556,7 +1556,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetCountry(UUID_1, US, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); assertEq(count, 1); assertEq(uuids[0], UUID_1); } @@ -1565,7 +1565,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 1); assertEq(uuids[0], UUID_1); } @@ -1581,7 +1581,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(UUID_1, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (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 @@ -1594,7 +1594,7 @@ contract FleetIdentityTest is Test { _registerNCountry(alice, US, 8, 2000); _registerNGlobal(alice, 4); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 20); } @@ -1605,7 +1605,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(_uuid(3000), 0); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 6); } @@ -1624,7 +1624,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 3); // Cursor=2: only country has tier 2 → include uuid2. Count=1. // Cursor=1: all empty. Descend. @@ -1649,7 +1649,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(UUID_3, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 3); // Cursor=2: admin(1)→include. Count=1. // Cursor=1: country(1)→include. Count=2. @@ -1677,12 +1677,12 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.reassignTier(globalId, 2); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (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 + assertEq(uuids[0], fleet.tokenUuid(globalId)); // tier 2 first } // ── All-or-nothing ── @@ -1711,7 +1711,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(_uuid(7001), 0); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, 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. @@ -1736,7 +1736,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetCountry(_uuid(3000 + i), US, 1); } - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, 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); @@ -1758,7 +1758,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetCountry(_uuid(6000), US, 1); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (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); @@ -1790,7 +1790,7 @@ contract FleetIdentityTest is Test { _registerNCountryAt(alice, US, 8, 4000, 0); _registerNGlobalAt(alice, 4, 0, 0); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); // 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. @@ -1819,7 +1819,7 @@ contract FleetIdentityTest is Test { _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); _registerNGlobalAt(alice, 4, 0, 0); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, 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]. @@ -1850,7 +1850,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(_uuid(5001), 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (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). @@ -1898,7 +1898,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(_uuid(4002), 0); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); // Cursor=1: admin(8)+country(8)+global(4)=20. Bundle full. assertEq(count, 20); } @@ -1935,7 +1935,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(_uuid(5002), 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 19); // Verify tier 0 members are NOT in the bundle. @@ -1964,7 +1964,7 @@ contract FleetIdentityTest is Test { _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); _registerNCountryAt(alice, US, 8, 4000, 0); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, 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); @@ -1990,7 +1990,7 @@ contract FleetIdentityTest is Test { _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); _registerNCountryAt(alice, US, 8, 4000, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (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. @@ -2024,7 +2024,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(UUID_2, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (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. @@ -2042,7 +2042,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(UUID_2, 0); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 2); } @@ -2053,7 +2053,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetCountry(UUID_2, US, 2); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + (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); @@ -2067,7 +2067,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); fleet.registerFleetCountry(UUID_2, US, 0); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(0, 0); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(0, 0); assertEq(count, 1); // only global } @@ -2077,7 +2077,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); assertEq(count, 1); // only country } @@ -2085,9 +2085,9 @@ contract FleetIdentityTest is Test { _registerNLocal(alice, US, ADMIN_CA, 5, 1000); _registerNLocal(alice, US, ADMIN_NY, 5, 2000); - (, uint256 countCA) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 countCA) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(countCA, 5); - (, uint256 countNY) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_NY); + (, uint256 countNY) = fleet.buildHighestBondedUuidBundle(US, ADMIN_NY); assertEq(countNY, 5); } @@ -2099,7 +2099,7 @@ contract FleetIdentityTest is Test { _registerNCountryAt(alice, US, 8, 2000, 1); // tier 1: 8 members (bond=2*BASE) _registerNCountryAt(alice, US, 4, 3000, 2); // tier 2: 4 members (bond=4*BASE) - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); assertEq(count, 20); // Verify order: tier 2 first uint256[] memory t2 = fleet.getTierMembers(_regionUS(), 2); @@ -2110,7 +2110,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_singleLevelOnlyLocal() public { _registerNLocal(alice, US, ADMIN_CA, 5, 1000); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 5); } @@ -2118,7 +2118,7 @@ contract FleetIdentityTest is Test { _registerNGlobal(alice, 4); _registerNCountry(alice, US, 8, 1000); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, 0); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); assertEq(count, 12); // Country first (level priority), then global. assertEq(uuids[0], _uuid(1000)); @@ -2139,7 +2139,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(UUID_3, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (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 @@ -2162,13 +2162,13 @@ contract FleetIdentityTest is Test { vm.prank(carol); fleet.registerFleetGlobal(UUID_3, 0); - (, uint256 countBefore) = fleet.buildHighestBondedUUIDBundle(0, 0); + (, uint256 countBefore) = fleet.buildHighestBondedUuidBundle(0, 0); assertEq(countBefore, 3); vm.prank(alice); fleet.burn(id1); - (, uint256 countAfter) = fleet.buildHighestBondedUUIDBundle(0, 0); + (, uint256 countAfter) = fleet.buildHighestBondedUuidBundle(0, 0); assertEq(countAfter, 2); } @@ -2180,7 +2180,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 3); bool found1; bool found2; @@ -2214,7 +2214,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.reassignTier(c1, 1); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, 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. @@ -2224,7 +2224,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.burn(g1); - (, count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 5); } @@ -2242,7 +2242,7 @@ contract FleetIdentityTest is Test { // 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); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); assertEq(count, 20); } @@ -2252,7 +2252,7 @@ contract FleetIdentityTest is Test { _registerNCountry(alice, US, 8, 2000); _registerNGlobal(alice, 4); - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 20); } @@ -2268,7 +2268,7 @@ contract FleetIdentityTest is Test { // 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); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 17); } @@ -2279,7 +2279,7 @@ contract FleetIdentityTest is Test { _registerNCountry(bob, US, 4, 2000); _registerNGlobal(carol, 3); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(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], "Duplicate UUID found"); @@ -2293,7 +2293,7 @@ contract FleetIdentityTest is Test { vm.prank(carol); fleet.registerFleetGlobal(UUID_1, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (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])); @@ -2311,7 +2311,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetGlobal(_uuid(3001), 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); uint256 localFound; uint256 countryFound; @@ -2348,7 +2348,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(_uuid(32_000 + i), US, ADMIN_CA); } - (, uint256 count) = fleet.buildHighestBondedUUIDBundle(US, ADMIN_CA); + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertLe(count, 20); } @@ -2370,7 +2370,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(_uuid(42_000 + i), US, ADMIN_CA); } - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(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"); @@ -2396,7 +2396,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(_uuid(52_000 + i), US, ADMIN_CA); } - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(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"); @@ -2421,7 +2421,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(_uuid(62_000 + i), US, ADMIN_CA); } - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUUIDBundle(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. From e63b9a3908606f355d7d8dc456cff7a4cff378b9 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 18 Feb 2026 14:06:11 +1300 Subject: [PATCH 20/63] Fix spellcheck typos and dictionary entries --- .cspell.json | 8 +++++++- test/FleetIdentity.t.sol | 4 ++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.cspell.json b/.cspell.json index 936fcf37..541973b0 100644 --- a/.cspell.json +++ b/.cspell.json @@ -69,6 +69,12 @@ "SFID", "EXTCODECOPY", "solady", - "SLOAD" + "SLOAD", + "Bitmask", + "mstore", + "MBOND", + "USCA", + "USNY", + "usca" ] } diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 9bf44ad7..243eb4f4 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1452,7 +1452,7 @@ contract FleetIdentityTest is Test { assertEq(bond, BASE_BOND * 2); } - function test_countryInclusionHint_adminAreaCreatesPresure() public { + function test_countryInclusionHint_adminAreaCreatesPressure() public { // Country US: tier 0 with 1 member vm.prank(alice); fleet.registerFleetCountry(_uuid(1000), US, 0); @@ -1687,7 +1687,7 @@ contract FleetIdentityTest is Test { // ── All-or-nothing ── - function test_buildBundle_allOrNothing_tierSkippedWhenDoesntFit() public { + function test_buildBundle_allOrNothing_tierSkippedWhenDoesNotFit() 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++) { From 534cfc7446327a15a66b688d6cfb05015d1f92e0 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 18 Feb 2026 15:39:09 +1300 Subject: [PATCH 21/63] Simplify CI: remove zksync flag, fix lint warnings --- .github/workflows/checks.yml | 2 +- test/FleetIdentity.t.sol | 9 +++++---- test/contentsign/BaseContentSign.t.sol | 2 +- test/contentsign/PaymentMiddleware.t.sol | 3 +-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 64ac1373..1ceaaf35 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -34,4 +34,4 @@ jobs: run: yarn lint - name: Run tests - run: forge test --zksync + run: forge test diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 243eb4f4..719ef99b 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1383,13 +1383,14 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 id = fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 3); - (uint256 before_,) = fleet.globalInclusionHint(); + (uint256 beforeTier,) = fleet.globalInclusionHint(); vm.prank(alice); fleet.burn(id); - (uint256 after_,) = fleet.globalInclusionHint(); - assertEq(after_, 0); + (uint256 afterTier,) = fleet.globalInclusionHint(); + assertLe(afterTier, beforeTier); + assertEq(afterTier, 0); } function test_globalInclusionHint_registrantCanActOnHint() public { @@ -2146,7 +2147,7 @@ contract FleetIdentityTest is Test { assertEq(uuids[2], UUID_3); // tier 0 } - function test_buildBundle_sharedCursor_sameTierIndex_sameBond() public { + function test_buildBundle_sharedCursor_sameTierIndex_sameBond() public view { assertEq(fleet.tierBond(0), BASE_BOND); assertEq(fleet.tierBond(1), BASE_BOND * 2); assertEq(fleet.tierBond(2), BASE_BOND * 4); diff --git a/test/contentsign/BaseContentSign.t.sol b/test/contentsign/BaseContentSign.t.sol index b52438b3..ef5a5389 100644 --- a/test/contentsign/BaseContentSign.t.sol +++ b/test/contentsign/BaseContentSign.t.sol @@ -2,7 +2,7 @@ pragma solidity ^0.8.20; -import {Test, console} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {BaseContentSign} from "../../src/contentsign/BaseContentSign.sol"; contract MockContentSign is BaseContentSign { diff --git a/test/contentsign/PaymentMiddleware.t.sol b/test/contentsign/PaymentMiddleware.t.sol index 3d5c24b0..5c7bb6e6 100644 --- a/test/contentsign/PaymentMiddleware.t.sol +++ b/test/contentsign/PaymentMiddleware.t.sol @@ -2,14 +2,13 @@ pragma solidity ^0.8.20; -import {Test, console} from "forge-std/Test.sol"; +import {Test} from "forge-std/Test.sol"; import {BaseContentSign} from "../../src/contentsign/BaseContentSign.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol"; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; import {PaymentMiddleware} from "../../src/contentsign/PaymentMiddleware.sol"; -import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; contract MockToken is ERC20 { constructor() ERC20("Mock Token", "MTK") {} From 5a19bd44cc19e16037b82f1f2095195d5a68280d Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 11:15:57 +1300 Subject: [PATCH 22/63] Add edge case tests for _findCheapestInclusionTier --- test/FleetIdentity.t.sol | 207 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 204 insertions(+), 3 deletions(-) diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 719ef99b..af5ba927 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -78,9 +78,11 @@ contract FleetIdentityTest is Test { bondToken = new MockERC20(); fleet = new FleetIdentity(address(bondToken), BASE_BOND); - bondToken.mint(alice, 100_000_000 ether); - bondToken.mint(bob, 100_000_000 ether); - bondToken.mint(carol, 100_000_000 ether); + // Mint enough for all 24 tiers (tier 23 bond = BASE_BOND * 2^23 ≈ 838M ether) + // Total for 8 members across 24 tiers ≈ 13.4 billion ether + bondToken.mint(alice, 100_000_000_000_000 ether); + bondToken.mint(bob, 100_000_000_000_000 ether); + bondToken.mint(carol, 100_000_000_000_000 ether); vm.prank(alice); bondToken.approve(address(fleet), type(uint256).max); @@ -2443,4 +2445,203 @@ contract FleetIdentityTest is Test { assertEq(inBundle, totalInTier, "Fuzz: partial tier detected"); } } + + // ══════════════════════════════════════════════════════════════════════════════════ + // Edge Cases: _findCheapestInclusionTier & MaxTiersReached + // ══════════════════════════════════════════════════════════════════════════════════ + + /// @notice When all 24 tiers of a region are full, localInclusionHint should revert. + function test_RevertIf_localInclusionHint_allTiersFull() public { + // Fill all 24 tiers of US/ADMIN_CA (8 members each = 192 fleets) + // LOCAL_TIER_CAPACITY = 8, MAX_TIERS = 24 + for (uint256 tier = 0; tier < 24; tier++) { + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(tier * 100 + i), US, ADMIN_CA, tier); + } + } + + // Verify all tiers are full + for (uint256 tier = 0; tier < 24; tier++) { + assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), tier), 8); + } + + // localInclusionHint should revert + vm.expectRevert(FleetIdentity.MaxTiersReached.selector); + fleet.localInclusionHint(US, ADMIN_CA); + } + + /// @notice When all tiers are full, auto-assign registerFleetLocal should revert. + function test_RevertIf_registerFleetLocal_autoAssign_allTiersFull() public { + // Fill all 24 tiers + for (uint256 tier = 0; tier < 24; tier++) { + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(tier * 100 + i), US, ADMIN_CA, tier); + } + } + + // Auto-assign registration should revert + vm.prank(bob); + vm.expectRevert(FleetIdentity.MaxTiersReached.selector); + fleet.registerFleetLocal(_uuid(99999), US, ADMIN_CA); + } + + /// @notice countryInclusionHint reverts when all tiers in the country region are full. + function test_RevertIf_countryInclusionHint_allTiersFull() public { + // Fill all 24 tiers of country US (8 members each) + for (uint256 tier = 0; tier < 24; tier++) { + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(tier * 100 + i), US, tier); + } + } + + vm.expectRevert(FleetIdentity.MaxTiersReached.selector); + fleet.countryInclusionHint(US); + } + + /// @notice globalInclusionHint reverts when all tiers in global are full. + function test_RevertIf_globalInclusionHint_allTiersFull() public { + // Fill all 24 tiers of global (4 members each = 96 fleets) + for (uint256 tier = 0; tier < 24; tier++) { + for (uint256 i = 0; i < 4; i++) { + vm.prank(alice); + fleet.registerFleetGlobal(_uuid(tier * 100 + i), tier); + } + } + + vm.expectRevert(FleetIdentity.MaxTiersReached.selector); + fleet.globalInclusionHint(); + } + + /// @notice Proves cheapest inclusion tier can be ABOVE maxTierIndex when bundle is + /// constrained by higher-priority levels at existing tiers. + /// + /// Scenario: + /// - Global has 4 fleets at tier 2 (the maxTierIndex) + /// - Country US has 8 fleets at tier 2 + /// - Admin US/CA has 8 fleets at tier 2 + /// - Total at tier 2: 4 + 8 + 8 = 20 (fills bundle completely) + /// - Admin tier 2 is FULL (8 members = capacity), so a new fleet cannot join tier 2. + /// - Cheapest inclusion should be tier 3 (above maxTierIndex=2). + function test_cheapestInclusionTier_aboveMaxTierIndex() public { + // Fill bundle at tier 2: global=4, country=8, admin=8 = 20 + _registerNGlobalAt(alice, 4, 2, 5000); + _registerNCountryAt(alice, US, 8, 6000, 2); + _registerNLocalAt(alice, US, ADMIN_CA, 8, 7000, 2); + + // Verify tier 2 is maxTierIndex (all regions have tierCount = 3, highest = tier 2) + assertEq(fleet.regionTierCount(GLOBAL), 3); + assertEq(fleet.regionTierCount(fleet.countryRegionKey(US)), 3); + assertEq(fleet.regionTierCount(fleet.adminRegionKey(US, ADMIN_CA)), 3); + + // Admin tier 2 is full (8 members = LOCAL_TIER_CAPACITY) + assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 2), 8); + + // At tier 2: bundle has 4+8+8=20 members → full, fair-stop triggers. + // Admin tier 2 is full, so candidate cannot join tier 2. + // Tiers 0-1 are unreachable (cutoffCursor=2). + // The new fleet must go to tier 3 (above maxTierIndex) to be included. + (uint256 inclusionTier, uint256 bond) = fleet.localInclusionHint(US, ADMIN_CA); + assertEq(inclusionTier, 3, "Should recommend tier 3 (above maxTierIndex=2)"); + assertEq(bond, BASE_BOND * 8); // tier 3 bond = BASE_BOND * 2^3 + + // Verify registration at tier 3 works + vm.prank(bob); + uint256 tokenId = fleet.registerFleetLocal(_uuid(9999), US, ADMIN_CA, 3); + assertEq(fleet.fleetTier(tokenId), 3); + + // Confirm new fleet appears in bundle at the TOP (first position) + // Bundle now: tier 3 (1) + tier 2: admin(8) + country(8) = 17 + // Global tier 2 (4) doesn't fit (room=3), fair-stop triggers + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + assertEq(count, 17, "Tier 3 (1) + tier 2 admin (8) + tier 2 country (8) = 17"); + assertEq(uuids[0], _uuid(9999), "Tier 3 fleet should be first in bundle"); + } + + /// @notice Edge case: bundle is full from tier maxTierIndex, and all tiers 0..maxTierIndex + /// at the candidate region are also full. The cheapest tier is above maxTierIndex. + function test_cheapestInclusionTier_aboveMaxTierIndex_candidateTiersFull() public { + // Global tier 0 has 4 fleets + _registerNGlobalAt(alice, 4, 0, 1000); + + // Country tier 0 has 8 fleets → bundle at tier 0 = 4+8+8=20 once admin fills + _registerNCountryAt(alice, US, 8, 2000, 0); + + // Admin tier 0 has 8 fleets (full) + _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); + + // Verify admin tier 0 is full + assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 0), 8); + + // At tier 0: 4+8+8=20 → bundle full at cursor 0 + // Admin tier 0 is full (8 members), so candidate must go elsewhere. + // Cheapest inclusion tier should be 1 (above maxTierIndex=0). + (uint256 inclusionTier,) = fleet.localInclusionHint(US, ADMIN_CA); + assertEq(inclusionTier, 1, "Should recommend tier 1 since tier 0 is full"); + } + + /// @notice When going above maxTierIndex would require tier >= MAX_TIERS, revert. + /// + /// Scenario: Fill global tiers 0-23 with 4 members each (96 global fleets). + /// A new LOCAL fleet cannot fit in any tier because: + /// - The bundle simulation runs through tiers 23→0 + /// - At each tier, global's 4 members + potential admin members need to fit + /// - With global filling 4 slots at every tier, and country/admin potentially + /// competing, we design a scenario where no tier works. + /// + /// Simpler approach: Fill all 24 admin tiers AND make bundle full at every tier. + function test_RevertIf_cheapestInclusionTier_exceedsMaxTiers() public { + // Fill all 24 tiers of admin area US/CA with 8 members each + for (uint256 tier = 0; tier < 24; tier++) { + for (uint256 i = 0; i < 8; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(tier * 100 + i), US, ADMIN_CA, tier); + } + } + + // Now all admin tiers 0-23 are full. A new admin fleet must go to tier 24, + // which exceeds MAX_TIERS=24 (valid tiers are 0-23). + vm.expectRevert(FleetIdentity.MaxTiersReached.selector); + fleet.localInclusionHint(US, ADMIN_CA); + } + + /// @notice Verify that when bundle is full due to higher-tier members preventing + /// lower-tier inclusion, the hint correctly identifies the cheapest viable tier. + function test_cheapestInclusionTier_bundleFullFromHigherTiers() public { + // Create a scenario where: + // - Global tier 5 has 4 members + // - Country tier 5 has 8 members + // - Admin tier 5 has 8 members + // Total = 20 at tier 5 → bundle full immediately + + _registerNGlobalAt(alice, 4, 5, 10000); + _registerNCountryAt(alice, US, 8, 11000, 5); + _registerNLocalAt(alice, US, ADMIN_CA, 8, 12000, 5); + + // maxTierIndex = 5 + // Bundle fills at tier 5 with 20 members, fair-stop triggers. + // New admin fleet cannot fit at tier 5 (full), tiers 0-4 unreachable (cursor stops at 5). + // Must go to tier 6. + (uint256 inclusionTier,) = fleet.localInclusionHint(US, ADMIN_CA); + assertEq(inclusionTier, 6, "Must go above maxTierIndex=5 to tier 6"); + } + + /// @notice Verifies the bundle correctly includes a fleet registered above maxTierIndex. + function test_buildBundle_includesFleetAboveMaxTierIndex() public { + // Only global tier 0 has fleets (maxTierIndex = 0) + _registerNGlobalAt(alice, 4, 0, 20000); + + // New admin registers at tier 2 (above maxTierIndex) + vm.prank(bob); + uint256 adminToken = fleet.registerFleetLocal(_uuid(21000), US, ADMIN_CA, 2); + + // Bundle should include admin tier 2 first (highest), then global tier 0 + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + assertEq(count, 5, "Admin tier 2 (1) + Global tier 0 (4) = 5"); + + // First should be admin tier 2 + assertEq(uint256(uint128(uuids[0])), adminToken, "Admin tier 2 fleet should be first"); + } } From 4f2ef7d300a61c4f15f390f453c38ed314565be7 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 12:12:07 +1300 Subject: [PATCH 23/63] feat(FleetIdentity): implement partial tier inclusion for bundle building Replace all-or-nothing + fair-stop algorithm with partial inclusion: - Members are included in array order (registration order) until bundle is full - A tier that doesn't fully fit now includes as many members as possible - Prevents economic attack where 1 high-tier fleet could displace 4 lower-tier fleets Changes: - buildHighestBondedUuidBundle: include partial tiers by array position - _findCheapestInclusionTier: fix countBefore calculation, add _getCountFromTiersAbove helper - _simulateBundleCounts: simplified to return count array only - Updated tests to reflect partial inclusion behavior --- src/swarms/FleetIdentity.sol | 170 ++++++++++++++-------------- test/FleetIdentity.t.sol | 210 +++++++++++++++++++++++++---------- 2 files changed, 242 insertions(+), 138 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index e76871ec..393773d4 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -520,32 +520,24 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// UUIDs for an EdgeBeaconScanner, merging the highest-bonded tiers across admin-area, /// country, and global levels. /// - /// **Algorithm – Shared-Cursor All-or-Nothing with Fair Stop** + /// **Algorithm – Shared-Cursor with Partial Inclusion** /// /// Uses a single shared tier-index cursor that descends from the /// highest active tier across all three levels. At each cursor /// position: /// - /// 1. Try to include each level's FULL tier at the current cursor + /// 1. Try to include members from each level's 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. + /// 2. Include as many members as fit in the remaining bundle capacity. + /// Members are included in array order (registration order within tier). + /// 3. Decrement cursor and repeat until bundle is full or cursor < 0. /// /// 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. + /// - Higher-bonded fleets always take priority over lower-bonded ones. + /// - Within same bond tier, higher-priority levels (admin > country > global) + /// take precedence. + /// - Within same tier and level, earlier registrations take precedence. + /// - Bundle is always maximally filled (up to 20) if enough fleets exist. /// /// @param countryCode EdgeBeaconScanner country (0 to skip country + admin). /// @param adminCode EdgeBeaconScanner admin area (0 to skip admin). @@ -565,11 +557,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; - bool skipped = false; - // Try each level at this cursor position in priority order. for (uint256 lvl = 0; lvl < 3; ++lvl) { if (!active[lvl]) continue; + if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; // Skip if this level doesn't have a tier at this cursor index. uint256 tierCount = regionTierCount[keys[lvl]]; @@ -581,22 +572,14 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (mLen == 0) continue; uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; + uint256 toInclude = mLen < room ? mLen : room; - if (mLen <= room) { - // All members fit — include the entire tier. - for (uint256 m = 0; m < mLen; ++m) { - uuids[count] = bytes16(uint128(members[m])); - ++count; - } - } else { - // Tier exists with members but doesn't fit — mark as skipped. - skipped = true; + // Include members in array order (registration order) + for (uint256 m = 0; m < toInclude; ++m) { + uuids[count] = bytes16(uint128(members[m])); + ++count; } } - - // 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. @@ -783,40 +766,39 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Phase 1 of the inclusion simulation: runs the bundle's shared-cursor /// descent WITHOUT the candidate fleet, recording how many UUIDs are - /// accumulated before each cursor position (`countBefore[c]`) and the - /// cursor level at which a fair-stop fired (`cutoffCursor`, or -1 if - /// the cursor reached 0 without stopping). + /// accumulated at each cursor position (`countAt[c]`). + /// Uses partial inclusion (as many members as fit per tier). function _simulateBundleCounts(uint32[3] memory keys, bool[3] memory active, uint256 maxTierIndex) internal view - returns (uint256[MAX_TIERS] memory countBefore, int256 cutoffCursor) + returns (uint256[MAX_TIERS] memory countAt) { uint256 count = 0; - cutoffCursor = -1; for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { - countBefore[uint256(cursor)] = count; if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) { - cutoffCursor = cursor; + // Bundle already full, record count for remaining cursors + for (int256 c = cursor; c >= 0; --c) { + countAt[uint256(c)] = count; + } break; } - bool skipped = false; + countAt[uint256(cursor)] = count; + for (uint256 lvl = 0; lvl < 3; ++lvl) { if (!active[lvl]) continue; + if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; + uint256 tc = regionTierCount[keys[lvl]]; if (tc == 0 || uint256(cursor) >= tc) continue; + uint256 m = _regionTierMembers[keys[lvl]][uint256(cursor)].length; if (m == 0) continue; - if (m <= MAX_BONDED_UUID_BUNDLE_SIZE - count) { - count += m; - } else { - skipped = true; - } - } - if (skipped) { - cutoffCursor = cursor; - break; + + uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; + uint256 toInclude = m < room ? m : room; + count += toInclude; } } } @@ -827,15 +809,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// returns the cheapest tier at `candidateLevel` that guarantees bundle /// inclusion. Bounded: O(MAX_TIERS). Works for any candidate level. /// - /// Phase 1 (`_simulateBundleCounts`) — run the bundle simulation WITHOUT - /// the new fleet, recording `countBefore[c]` and `cutoffCursor`. - /// - /// Phase 2 — scan candidate tiers from 0 upward. For each T: - /// (a) T must have capacity in the candidate region. - /// (b) The cursor must reach T (T >= cutoffCursor). - /// (c) At cursor T, after levels with higher priority than the - /// candidate consume room, the candidate's members + 1 must - /// still fit in MAX_BONDED_UUID_BUNDLE_SIZE. + /// With partial inclusion, a new fleet is included if: + /// (a) The tier has capacity for the new fleet. + /// (b) At that tier position, after higher-priority levels consume slots, + /// there is room for at least one member from the candidate tier. function _findCheapestInclusionTier(uint16 countryCode, uint16 adminCode, uint256 candidateLevel) internal view @@ -847,10 +824,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint32 candidateRegion = keys[candidateLevel]; uint256 cap = tierCapacity(candidateRegion); - // Phase 1: simulate bundle without the new fleet. - (uint256[MAX_TIERS] memory countBefore, int256 cutoffCursor) = _simulateBundleCounts(keys, active, maxTierIndex); - - // Phase 2: find cheapest tier T at candidateRegion. + // Find cheapest tier T at candidateRegion. uint256 candidateTierCount = regionTierCount[candidateRegion]; for (uint256 T = 0; T < MAX_TIERS; ++T) { @@ -858,29 +832,35 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 members = (T < candidateTierCount) ? _regionTierMembers[candidateRegion][T].length : 0; if (members >= cap) continue; - // (b) Cursor reaches T? - if (cutoffCursor >= 0 && int256(T) < cutoffCursor) continue; - - // (c) At cursor T, compute room after higher-priority levels. - uint256 countAtT = (T <= maxTierIndex) ? countBefore[T] : 0; - - for (uint256 lvl = 0; lvl < 3; ++lvl) { - if (!active[lvl]) continue; - if (lvl == candidateLevel) break; // reached candidate level - uint256 ltc = regionTierCount[keys[lvl]]; - if (ltc == 0 || T >= ltc) continue; - uint256 m = _regionTierMembers[keys[lvl]][T].length; - if (m == 0) continue; - if (m <= MAX_BONDED_UUID_BUNDLE_SIZE - countAtT) { - countAtT += m; + // (b) Compute how many slots are consumed BEFORE reaching (candidateRegion, T). + // This is: slots from tiers > T + slots from higher-priority levels AT T. + uint256 countBefore; + if (T > maxTierIndex) { + // Tier above current max: if we join here, we become the new max. + // Bundle starts from T, so countBefore = 0. + countBefore = 0; + } else { + // Count from tiers strictly above T. + countBefore = _getCountFromTiersAbove(keys, active, maxTierIndex, T); + + // Add higher-priority levels at tier T itself. + for (uint256 lvl = 0; lvl < candidateLevel; ++lvl) { + if (!active[lvl]) continue; + if (countBefore >= MAX_BONDED_UUID_BUNDLE_SIZE) break; + + uint256 ltc = regionTierCount[keys[lvl]]; + if (ltc == 0 || T >= ltc) continue; + + uint256 m = _regionTierMembers[keys[lvl]][T].length; + if (m == 0) continue; + + uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - countBefore; + countBefore += (m < room ? m : room); } - // If a prior level doesn't fit it is skipped (count unchanged); - // the candidate is still tried (bundle processes ALL levels at - // each cursor position before checking the skip flag). } // Does the candidate (with +1) fit? - if (members + 1 <= MAX_BONDED_UUID_BUNDLE_SIZE - countAtT) { + if (countBefore < MAX_BONDED_UUID_BUNDLE_SIZE) { return T; } } @@ -888,6 +868,32 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { revert MaxTiersReached(); } + /// @dev Helper to compute count from tiers STRICTLY above T (i.e., tiers > T). + function _getCountFromTiersAbove(uint32[3] memory keys, bool[3] memory active, uint256 maxTierIndex, uint256 T) + internal + view + returns (uint256 count) + { + // Process tiers from maxTierIndex down to T+1 (exclusive of T). + for (int256 cursor = int256(maxTierIndex); cursor > int256(T); --cursor) { + if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; + + for (uint256 lvl = 0; lvl < 3; ++lvl) { + if (!active[lvl]) continue; + if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; + + uint256 tc = regionTierCount[keys[lvl]]; + if (tc == 0 || uint256(cursor) >= tc) continue; + + uint256 m = _regionTierMembers[keys[lvl]][uint256(cursor)].length; + if (m == 0) continue; + + uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; + count += (m < room ? m : room); + } + } + } + /// @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); diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index af5ba927..e4eeaae8 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1745,8 +1745,9 @@ contract FleetIdentityTest is Test { assertEq(count, 17); } - function test_buildBundle_allOrNothing_partialTierNeverIncluded() public { - // Verify by filling room so a tier of 4 has only 3 remaining slots. + function test_buildBundle_partialInclusion_fillsRemainingSlots() public { + // With partial inclusion: if only 3 slots remain for 4 global members, + // we include 3 of them (first 3 by array position). // Global tier 0: 4 members (bond=100) _registerNGlobal(alice, 4); @@ -1763,20 +1764,24 @@ contract FleetIdentityTest is Test { (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); + // Cursor=0: admin(8)≤11→include 8[count=17,room=3]. global(4)>3→include 3. + // Final count=20 (bundle full). + assertEq(count, 20); - // Verify no global UUID is in the result + // Verify 3 global UUIDs ARE in the result (partial inclusion) + uint256 globalCount; for (uint256 i = 0; i < count; i++) { uint256 tokenId = uint256(uint128(uuids[i])); - assertTrue(fleet.fleetRegion(tokenId) != GLOBAL, "Global UUID should not appear"); + if (fleet.fleetRegion(tokenId) == GLOBAL) globalCount++; } + assertEq(globalCount, 3, "3 of 4 global members included due to partial inclusion"); } - // ── Fair-stop rule ── + // ── Partial inclusion (replaces all-or-nothing + fair-stop) ── - function test_buildBundle_fairStop_stopsWhenAnyTierSkipped() public { - // At cursor=0: admin(8) fits, country(8) doesn't → STOP. + function test_buildBundle_partialInclusion_fillsBundleCompletely() public { + // With partial inclusion, we fill the bundle completely by including + // as many members as fit, in array order. // Consume 6 slots at tier 1. for (uint256 i = 0; i < 3; i++) { @@ -1795,11 +1800,9 @@ contract FleetIdentityTest is Test { (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); // 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); + // Cursor=0: admin(8)≤14→include 8[count=14,room=6]. + // country(8)>6→include 6 of 8[count=20,room=0]. Bundle full. + assertEq(count, 20); } function test_buildBundle_fairStop_globalNotStarvedByLocalFill() public { @@ -1830,10 +1833,9 @@ contract FleetIdentityTest is Test { assertEq(count, 20); } - 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. + function test_buildBundle_partialInclusion_allLevelsPartiallyIncluded() public { + // With partial inclusion, all levels get included partially if needed. + // At cursor=0: admin(3) fits fully, country(8) partially (7 fit), global(2) - no room. // Consume 10 slots at tier 1. for (uint256 i = 0; i < 5; i++) { @@ -1855,11 +1857,9 @@ contract FleetIdentityTest is Test { (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); + // Cursor=0: admin(3)≤10→include 3[count=13,room=7]. + // country(8)>7→include 7 of 8[count=20]. Bundle full. + assertEq(count, 20); // Verify admin tier 0 is present bool foundAdmin = false; @@ -1868,12 +1868,13 @@ contract FleetIdentityTest is Test { } assertTrue(foundAdmin, "admin tier 0 should be included"); - // Verify global tier 0 is present - bool foundGlobal = false; + // Count how many country tier 0 members are included + uint256 countryT0Count; for (uint256 i = 0; i < count; i++) { - if (uuids[i] == _uuid(5000)) foundGlobal = true; + uint256 tokenId = uint256(uint128(uuids[i])); + if (fleet.fleetRegion(tokenId) == _regionUS() && fleet.fleetTier(tokenId) == 0) countryT0Count++; } - assertTrue(foundGlobal, "global included at same cursor despite country skip"); + assertEq(countryT0Count, 7, "7 of 8 country tier 0 members included"); } function test_buildBundle_fairStop_doesNotDescendAfterSkip() public { @@ -1906,12 +1907,11 @@ contract FleetIdentityTest is Test { assertEq(count, 20); } - function test_buildBundle_fairStop_skipAtHigherTierStopsAll() public { + function test_buildBundle_partialInclusion_fillsAtHighTier() 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. + // Cursor=1: admin(8)≤17→include 8[count=11,room=9]. + // country(8)≤9→include 8[count=19,room=1]. + // global(4)>1→include 1 of 4[count=20]. Bundle full. for (uint256 i = 0; i < 3; i++) { vm.prank(alice); @@ -1930,7 +1930,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetGlobal(_uuid(4000 + i), 1); } - // Tier 0 extras (should NOT be included): + // Tier 0 extras (would be included with more room): vm.prank(alice); fleet.registerFleetLocal(_uuid(5000), US, ADMIN_CA, 0); vm.prank(alice); @@ -1939,9 +1939,10 @@ contract FleetIdentityTest is Test { fleet.registerFleetGlobal(_uuid(5002), 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 19); + // Bundle fills at tier 1, partial global inclusion. + assertEq(count, 20); - // Verify tier 0 members are NOT in the bundle. + // Tier 0 members NOT included (bundle full before reaching cursor=0) for (uint256 i = 0; i < count; i++) { assertTrue(uuids[i] != _uuid(5000), "local tier 0 should not be included"); assertTrue(uuids[i] != _uuid(5001), "country tier 0 should not be included"); @@ -1949,9 +1950,9 @@ contract FleetIdentityTest is Test { } } - 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). + function test_buildBundle_partialInclusion_higherPriorityFirst() public { + // Partial inclusion fills higher-priority levels first at each tier. + // Admin gets slots before country, which gets slots before global. // Admin tier 1: 8 (bond=200), Country tier 1: 5 (bond=200) for (uint256 i = 0; i < 8; i++) { @@ -1967,10 +1968,19 @@ contract FleetIdentityTest is Test { _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); _registerNCountryAt(alice, US, 8, 4000, 0); - (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + (bytes16[] memory uuids, 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); + // Cursor=0: admin(8)>7→include 7 of 8[count=20]. Bundle full. + // Country tier 0 not included (bundle full). + assertEq(count, 20); + + // Verify admin tier 0 partial inclusion (7 of 8) + uint256 adminT0Count; + for (uint256 i = 0; i < count; i++) { + uint256 tokenId = uint256(uint128(uuids[i])); + if (fleet.fleetRegion(tokenId) == _regionUSCA() && fleet.fleetTier(tokenId) == 0) adminT0Count++; + } + assertEq(adminT0Count, 7, "7 of 8 admin tier 0 included"); } // ── Tie-breaker: admin before country before global at same cursor ── @@ -2259,8 +2269,9 @@ contract FleetIdentityTest is Test { assertEq(count, 20); } - function test_buildBundle_twentyOneOverflow_fairStop() public { + function test_buildBundle_twentyOneMembers_partialInclusion() public { // 21 total: admin 8 + country 8 + global 4 + 1 extra country at tier 1. + // With partial inclusion, bundle fills to 20. _registerNLocal(alice, US, ADMIN_CA, 8, 1000); _registerNCountry(alice, US, 8, 2000); _registerNGlobal(alice, 4); @@ -2268,11 +2279,11 @@ contract FleetIdentityTest is Test { fleet.registerFleetCountry(_uuid(3000), US, 1); // 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. + // Cursor=0: admin(8)≤19→include 8[count=9,room=11]. + // country(8)≤11→include 8[count=17,room=3]. + // global(4)>3→include 3 of 4[count=20]. Bundle full. (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 17); + assertEq(count, 20); } // ── Integrity ── @@ -2539,10 +2550,10 @@ contract FleetIdentityTest is Test { // Admin tier 2 is full (8 members = LOCAL_TIER_CAPACITY) assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 2), 8); - // At tier 2: bundle has 4+8+8=20 members → full, fair-stop triggers. - // Admin tier 2 is full, so candidate cannot join tier 2. - // Tiers 0-1 are unreachable (cutoffCursor=2). - // The new fleet must go to tier 3 (above maxTierIndex) to be included. + // With partial inclusion: + // - At tier 0,1: countBefore = 20 (bundle full from tier 2), no room. + // - At tier 2: tier is full (8 members = cap), cannot join. + // - At tier 3: above maxTierIndex, countBefore = 0, has room. (uint256 inclusionTier, uint256 bond) = fleet.localInclusionHint(US, ADMIN_CA); assertEq(inclusionTier, 3, "Should recommend tier 3 (above maxTierIndex=2)"); assertEq(bond, BASE_BOND * 8); // tier 3 bond = BASE_BOND * 2^3 @@ -2553,10 +2564,12 @@ contract FleetIdentityTest is Test { assertEq(fleet.fleetTier(tokenId), 3); // Confirm new fleet appears in bundle at the TOP (first position) - // Bundle now: tier 3 (1) + tier 2: admin(8) + country(8) = 17 - // Global tier 2 (4) doesn't fit (room=3), fair-stop triggers + // With partial inclusion: + // - Cursor=3: admin(1). count=1. + // - Cursor=2: admin(8)+country(8)=16. count=17, room=3. global(4)>3 → include 3. + // - count=20, bundle full. (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 17, "Tier 3 (1) + tier 2 admin (8) + tier 2 country (8) = 17"); + assertEq(count, 20, "Partial inclusion: tier 3 (1) + tier 2 admin (8) + country (8) + 3 global = 20"); assertEq(uuids[0], _uuid(9999), "Tier 3 fleet should be first in bundle"); } @@ -2621,9 +2634,10 @@ contract FleetIdentityTest is Test { _registerNLocalAt(alice, US, ADMIN_CA, 8, 12000, 5); // maxTierIndex = 5 - // Bundle fills at tier 5 with 20 members, fair-stop triggers. - // New admin fleet cannot fit at tier 5 (full), tiers 0-4 unreachable (cursor stops at 5). - // Must go to tier 6. + // With partial inclusion: + // - At tiers 0-4: countBefore = 20 (from tier 5). No room. + // - At tier 5: tier full (8 = capacity). Cannot join. + // - At tier 6: above maxTierIndex, countBefore = 0. Has room. (uint256 inclusionTier,) = fleet.localInclusionHint(US, ADMIN_CA); assertEq(inclusionTier, 6, "Must go above maxTierIndex=5 to tier 6"); } @@ -2644,4 +2658,88 @@ contract FleetIdentityTest is Test { // First should be admin tier 2 assertEq(uint256(uint128(uuids[0])), adminToken, "Admin tier 2 fleet should be first"); } + + // ══════════════════════════════════════════════════════════════════════════════════ + // Demonstration: Partial inclusion prevents total tier displacement + // ══════════════════════════════════════════════════════════════════════════════════ + + /// @notice DEMONSTRATES that partial inclusion prevents the scenario where a single + /// fleet registration could push an entire tier out of the bundle. + /// + /// Scenario: + /// BEFORE: + /// - Admin tier 0: 8 members + /// - Country tier 0: 8 members + /// - Global tier 0: 4 members + /// - Bundle: all 20 members included (8+8+4=20) + /// + /// AFTER (single admin tier 1 registration): + /// - Admin tier 1: 1 member (NEW - above previous maxTierIndex) + /// - With PARTIAL INCLUSION: + /// - Tier 1: admin(1) → count=1 + /// - Tier 0: admin(8) fits (room=19) → count=9 + /// country(8) fits (room=11) → count=17 + /// global(4) > room(3) → include 3 of 4, count=20 + /// - Final bundle: 20 members (full) + /// - 3 of 4 global tier 0 members STILL INCLUDED! + /// + /// Result: Only 1 global fleet displaced (the last by array position), not all 4. + function test_DEMO_partialInclusionPreventsFullDisplacement() public { + // === BEFORE STATE === + // Fill bundle exactly: admin(8) + country(8) + global(4) = 20 + _registerNLocalAt(alice, US, ADMIN_CA, 8, 30000, 0); // Admin tier 0: 8 + _registerNCountryAt(alice, US, 8, 31000, 0); // Country tier 0: 8 + uint256[] memory globalIds = _registerNGlobalAt(alice, 4, 0, 32000); // Global tier 0: 4 + + // Verify BEFORE: all 20 members in bundle + (bytes16[] memory uuidsBefore, uint256 countBefore) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + assertEq(countBefore, 20, "BEFORE: All 20 members should be in bundle"); + + // Verify all 4 global fleets are included BEFORE + uint256 globalCountBefore; + for (uint256 i = 0; i < countBefore; i++) { + uint256 tokenId = uint256(uint128(uuidsBefore[i])); + if (fleet.fleetRegion(tokenId) == GLOBAL) globalCountBefore++; + } + assertEq(globalCountBefore, 4, "BEFORE: All 4 global fleets in bundle"); + + // === SINGLE REGISTRATION === + // Bob registers just ONE fleet at admin tier 1 + vm.prank(bob); + fleet.registerFleetLocal(_uuid(99999), US, ADMIN_CA, 1); + + // === AFTER STATE === + (bytes16[] memory uuidsAfter, uint256 countAfter) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + + // Bundle STILL has 20 members (partial inclusion keeps bundle full) + assertEq(countAfter, 20, "AFTER: Bundle should still have 20 members"); + + // Count how many global fleets are included AFTER + uint256 globalCountAfter; + for (uint256 i = 0; i < countAfter; i++) { + uint256 tokenId = uint256(uint128(uuidsAfter[i])); + if (fleet.fleetRegion(tokenId) == GLOBAL) globalCountAfter++; + } + assertEq(globalCountAfter, 3, "AFTER: 3 of 4 global fleets still in bundle"); + + // Verify the FIRST 3 global fleets (by array position) are included + bool[] memory globalIncluded = new bool[](4); + for (uint256 i = 0; i < countAfter; i++) { + uint256 tokenId = uint256(uint128(uuidsAfter[i])); + for (uint256 g = 0; g < 4; g++) { + if (tokenId == globalIds[g]) globalIncluded[g] = true; + } + } + // First 3 should be included, 4th excluded (array position ordering) + assertTrue(globalIncluded[0], "First global fleet included"); + assertTrue(globalIncluded[1], "Second global fleet included"); + assertTrue(globalIncluded[2], "Third global fleet included"); + assertFalse(globalIncluded[3], "Fourth global fleet excluded (partial inclusion limit)"); + + // === IMPROVEMENT SUMMARY === + emit log_string("=== PARTIAL INCLUSION FIX DEMONSTRATED ==="); + emit log_string("A single tier-1 registration now displaces only 1 global fleet, not 4"); + emit log_named_uint("Global fleets displaced", 1); + emit log_named_uint("Global fleets still included", 3); + } } From f58f57cba696bc3b32043ecdd9b35cb1eda8092d Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 12:35:16 +1300 Subject: [PATCH 24/63] refactor(FleetIdentity): remove redundant discovery functions Remove discoverHighestBondedTier, competitiveLandscape, and discoverAllLevels. These functions are superseded by: - buildHighestBondedUuidBundle: provides complete bundle for EdgeBeaconScanners - localInclusionHint/countryInclusionHint/globalInclusionHint: provide actionable tier recommendations for registrants The inclusion hints compute the exact tier needed for bundle inclusion, making raw competitive data functions unnecessary. --- src/swarms/FleetIdentity.sol | 93 ----------------- test/FleetIdentity.t.sol | 193 ----------------------------------- 2 files changed, 286 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 393773d4..24f80788 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -419,103 +419,10 @@ 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; - } - // ══════════════════════════════════════════════ // Views: EdgeBeaconScanner discovery // ══════════════════════════════════════════════ - /// @notice Returns the highest-bonded active tier for an EdgeBeaconScanner at a specific location. - /// Fallback order: admin area -> country -> global. - /// @return regionKey The region where fleets were found (0 = global). - /// @return tier The highest non-empty tier in that region. - /// @return members The token IDs in that tier. - function discoverHighestBondedTier(uint16 countryCode, uint16 adminCode) - external - view - returns (uint32 regionKey, uint256 tier, uint256[] memory members) - { - // 1. Try admin area - if (countryCode > 0 && adminCode > 0) { - regionKey = _makeAdminRegion(countryCode, adminCode); - uint256 tc = regionTierCount[regionKey]; - if (tc > 0) { - tier = tc - 1; - members = _regionTierMembers[regionKey][tier]; - return (regionKey, tier, members); - } - } - // 2. Try country - if (countryCode > 0) { - regionKey = uint32(countryCode); - uint256 tc2 = regionTierCount[regionKey]; - if (tc2 > 0) { - tier = tc2 - 1; - members = _regionTierMembers[regionKey][tier]; - return (regionKey, tier, members); - } - } - // 3. Global - regionKey = GLOBAL_REGION; - uint256 tierCount = regionTierCount[GLOBAL_REGION]; - if (tierCount > 0) { - tier = tierCount - 1; - members = _regionTierMembers[GLOBAL_REGION][tier]; - } - // else: all empty, returns (0, 0, []) - } - - /// @notice Returns active tier data at all three levels for a location. - function discoverAllLevels(uint16 countryCode, uint16 adminCode) - external - view - returns (uint256 globalTierCount, uint256 countryTierCount, uint256 adminTierCount, uint32 adminRegion) - { - globalTierCount = regionTierCount[GLOBAL_REGION]; - if (countryCode > 0) { - countryTierCount = regionTierCount[uint32(countryCode)]; - } - if (countryCode > 0 && adminCode > 0) { - adminRegion = _makeAdminRegion(countryCode, adminCode); - adminTierCount = regionTierCount[adminRegion]; - } - } - /// @notice Builds a priority-ordered bundle of up to MAX_BONDED_UUID_BUNDLE_SIZE (20) /// UUIDs for an EdgeBeaconScanner, merging the highest-bonded tiers across admin-area, /// country, and global levels. diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index e4eeaae8..11c26570 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -739,87 +739,6 @@ contract FleetIdentityTest is Test { assertEq(usUUIDs[0], UUID_2); } - // --- discoverHighestBondedTier --- - - function test_discoverHighestBondedTier_prefersAdminArea() public { - vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); - vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US, 0); - vm.prank(carol); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - - (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); - assertEq(rk, _regionUSCA()); - assertEq(tier, 0); - assertEq(members.length, 1); - } - - function test_discoverHighestBondedTier_fallsBackToCountry() public { - vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); - vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US, 0); - - (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); - assertEq(rk, _regionUS()); - assertEq(tier, 0); - assertEq(members.length, 1); - } - - function test_discoverHighestBondedTier_fallsBackToGlobal() public { - vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); - - (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); - assertEq(rk, GLOBAL); - assertEq(tier, 0); - assertEq(members.length, 1); - } - - function test_discoverHighestBondedTier_allEmpty() public view { - (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); - assertEq(rk, GLOBAL); - assertEq(tier, 0); - assertEq(members.length, 0); - } - - function test_discoverHighestBondedTier_returnsHighestTier() public { - _registerNCountry(alice, US, 8, 0); - vm.prank(bob); - fleet.registerFleetCountry(_uuid(500), US, 1); - - (uint32 rk, uint256 tier,) = fleet.discoverHighestBondedTier(US, 0); - assertEq(rk, _regionUS()); - assertEq(tier, 1); - } - - // --- discoverAllLevels --- - - function test_discoverAllLevels_returnsAllCounts() public { - _registerNGlobal(alice, 4); - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(999), 1); - - _registerNCountry(bob, US, 5, 100); - _registerNLocal(carol, US, ADMIN_CA, 3, 200); - - (uint256 gsc, uint256 csc, uint256 asc, uint32 ark) = fleet.discoverAllLevels(US, ADMIN_CA); - assertEq(gsc, 2); - assertEq(csc, 1); - assertEq(asc, 1); - assertEq(ark, _regionUSCA()); - } - - function test_discoverAllLevels_zeroCountryAndAdmin() public { - _registerNGlobal(alice, 3); - - (uint256 gsc, uint256 csc, uint256 asc,) = fleet.discoverAllLevels(0, 0); - assertEq(gsc, 1); - assertEq(csc, 0); - assertEq(asc, 0); - } - // --- Region indexes --- function test_globalActive_trackedCorrectly() public { @@ -1207,118 +1126,6 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(address(fleet)), 0); } - // --- EdgeBeaconScanner workflow --- - - function test_edgeBeaconScannerWorkflow_multiRegionDiscovery() public { - _registerNGlobal(alice, 4); - for (uint256 i = 0; i < 2; i++) { - vm.prank(bob); - fleet.registerFleetGlobal(_uuid(20 + i), 1); - } - - _registerNLocal(carol, US, ADMIN_CA, 3, 200); - - (uint32 rk, uint256 tier, uint256[] memory members) = fleet.discoverHighestBondedTier(US, ADMIN_CA); - assertEq(rk, _regionUSCA()); - assertEq(tier, 0); - assertEq(members.length, 3); - - (uint256 gsc, uint256 csc, uint256 asc,) = fleet.discoverAllLevels(US, ADMIN_CA); - assertEq(gsc, 2); - assertEq(csc, 0); - assertEq(asc, 1); - } - - // --- 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, 0); - - (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), 1); // explicit 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, 0); - - (, 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); - } - // --- globalInclusionHint --- function test_globalInclusionHint_emptyReturnsZero() public view { From 22fa52a3d799cd29eb7a9112a810def0d830c0f1 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 12:59:47 +1300 Subject: [PATCH 25/63] add more test --- test/FleetIdentity.t.sol | 78 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 73 insertions(+), 5 deletions(-) diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 11c26570..8a7611c7 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -55,6 +55,8 @@ contract FleetIdentityTest is Test { uint16 constant US = 840; uint16 constant DE = 276; + uint16 constant FR = 250; + uint16 constant JP = 392; uint16 constant ADMIN_CA = 1; uint16 constant ADMIN_NY = 2; @@ -1243,6 +1245,53 @@ contract FleetIdentityTest is Test { assertEq(tier, 1); } + /// @notice Verifies that globalInclusionHint returns a tier that guarantees + /// the registrant appears in ALL active regional bundles. + function test_globalInclusionHint_guaranteesInclusionInAllRegionalBundles() public { + // --- Setup: Multiple regions at vastly different bond levels --- + // US-CA: heavy local competition at tier 0 + _registerNLocal(alice, US, ADMIN_CA, 8, 100); // fills admin tier 0 + _registerNCountry(alice, US, 8, 200); // fills country tier 0 + // FR-75: light competition at tier 0 + _registerNLocal(alice, FR, 75, 2, 300); // 2 admins + // DE-BE: moderate at tier 1 + _registerNLocal(alice, DE, 1, 4, 400); // 4 admins at tier 0 + vm.prank(alice); + fleet.registerFleetLocal(_uuid(410), DE, 1, 1); // 1 admin at tier 1 + // JP country-only at tier 0 + _registerNCountry(alice, JP, 3, 500); + // Global tier 0 is also present + _registerNGlobalAt(alice, 4, 0, 600); + + // --- Get the global hint --- + (uint256 hintTier,) = fleet.globalInclusionHint(); + + // --- Bob registers a global fleet at the hinted tier --- + vm.prank(bob); + fleet.registerFleetGlobal(_uuid(9000), hintTier); + bytes16 bobUuid = _uuid(9000); + + // --- Verify inclusion in ALL active location bundles --- + _assertUuidInBundle(US, ADMIN_CA, bobUuid, "US-CA"); + _assertUuidInBundle(FR, 75, bobUuid, "FR-75"); + _assertUuidInBundle(DE, 1, bobUuid, "DE-BE"); + _assertUuidInBundle(JP, 0, bobUuid, "JP (country-only)"); + _assertUuidInBundle(0, 0, bobUuid, "Global-only"); + } + + /// @dev Helper to assert a UUID is present in a regional bundle. + function _assertUuidInBundle(uint16 cc, uint16 admin, bytes16 uuid, string memory location) internal { + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(cc, admin); + bool found; + for (uint256 i = 0; i < count; i++) { + if (uuids[i] == uuid) { + found = true; + break; + } + } + assertTrue(found, string.concat("Global fleet missing from bundle: ", location)); + } + // --- countryInclusionHint --- function test_countryInclusionHint_emptyReturnsZero() public view { @@ -2224,7 +2273,7 @@ contract FleetIdentityTest is Test { } } - function testFuzz_buildBundle_allOrNothingInvariant(uint8 gCount, uint8 cCount, uint8 lCount) public { + function testFuzz_buildBundle_partialInclusionInvariant(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)); @@ -2244,13 +2293,15 @@ contract FleetIdentityTest is Test { (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. + // With partial inclusion: for each (region, tier) group in the bundle, + // the included members should be a PREFIX of the full tier (registration order). + // We verify this by checking that included members are the first N in the tier's array. for (uint256 i = 0; i < count; i++) { uint256 tid = uint256(uint128(uuids[i])); uint32 region = fleet.fleetRegion(tid); uint256 tier = fleet.fleetTier(tid); + // Count how many from this (region, tier) are in the bundle uint256 inBundle; for (uint256 j = 0; j < count; j++) { uint256 tjd = uint256(uint128(uuids[j])); @@ -2259,8 +2310,25 @@ contract FleetIdentityTest is Test { } } - uint256 totalInTier = fleet.tierMemberCount(region, tier); - assertEq(inBundle, totalInTier, "Fuzz: partial tier detected"); + // Get the full tier members + uint256[] memory tierMembers = fleet.getTierMembers(region, tier); + + // The included count should be <= total tier members + assertLe(inBundle, tierMembers.length, "Fuzz: more included than exist"); + + // Verify the included members are exactly the first `inBundle` members of the tier + // (prefix property for partial inclusion) + uint256 found; + for (uint256 m = 0; m < inBundle && m < tierMembers.length; m++) { + bytes16 expectedUuid = bytes16(uint128(tierMembers[m])); + for (uint256 j = 0; j < count; j++) { + if (uuids[j] == expectedUuid) { + found++; + break; + } + } + } + assertEq(found, inBundle, "Fuzz: included members not a prefix of tier"); } } From 662394a310ca94e3d8dc385d547855fefe842ed1 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 16:43:36 +1300 Subject: [PATCH 26/63] refactor(FleetIdentity): remove global level, 2-level system (Country + Local) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed registerFleetGlobal, globalInclusionHint, GLOBAL_REGION - TIER_CAPACITY = 4 (unified for all levels) - COUNTRY_BOND_MULTIPLIER = 8 (country pays 8× local bond) - MAX_COUNTRY_IN_BUNDLE = 8 (cap country fleets in bundle) - tierBond(tier, regionKey) determines multiplier based on region - AdminAreaRequired error when buildHighestBondedUuidBundle called with adminCode=0 - Updated all tests to match new contract behavior --- src/swarms/FleetIdentity.sol | 327 +++---- test/FleetIdentity.t.sol | 1435 +++++++++++------------------ test/SwarmRegistryL1.t.sol | 6 +- test/SwarmRegistryUniversal.t.sol | 6 +- 4 files changed, 680 insertions(+), 1094 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 24f80788..0072d844 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -12,27 +12,30 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * @notice ERC-721 with ERC721Enumerable representing ownership of a BLE fleet, * secured by an ERC-20 bond organized into geometric tiers. * - * @dev **Three-level geographic registration** + * @dev **Two-level geographic registration** * * Fleets register at exactly one level: - * - Global — regionKey = 0 * - Country — regionKey = countryCode (ISO 3166-1 numeric, 1-999) * - Admin Area — regionKey = (countryCode << 12) | adminCode (>= 4096) * * Each regionKey has its **own independent tier namespace** — tier indices * start at 0 for every region. The first fleet in any region always pays - * BASE_BOND regardless of how many tiers exist in other regions. + * the level-appropriate bond (LOCAL: BASE_BOND, COUNTRY: BASE_BOND * 8). * - * 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 * 2^K. + * **Economic Model** * - * EdgeBeaconScanner discovery uses a 3-level fallback: - * 1. Admin area (most specific) - * 2. Country - * 3. Global + * - Tier capacity: 4 members per tier (unified across levels) + * - Local bond: BASE_BOND * 2^tier + * - Country bond: BASE_BOND * COUNTRY_BOND_MULTIPLIER * 2^tier (8× local) + * + * Country fleets pay more but appear in all admin-area bundles within + * their country. Bundle caps (MAX_COUNTRY_IN_BUNDLE = 8) ensure locals + * retain majority presence. Caps are flexible: if no country fleets exist, + * locals fill all 20 slots. + * + * EdgeBeaconScanner discovery uses 2-level fallback: + * 1. Admin area (highest priority) + * 2. Country (lower priority) * * On-chain indexes track which countries and admin areas have active fleets, * enabling EdgeBeaconScanner enumeration without off-chain indexers. @@ -54,23 +57,24 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { error TargetTierSameAsCurrent(); error InvalidCountryCode(); error InvalidAdminCode(); + error AdminAreaRequired(); // ────────────────────────────────────────────── // Constants & Immutables // ────────────────────────────────────────────── - /// @notice Maximum members per global tier. - uint256 public constant GLOBAL_TIER_CAPACITY = 4; + /// @notice Unified tier capacity for all levels. + uint256 public constant TIER_CAPACITY = 4; - /// @notice Maximum members per country-level tier. - uint256 public constant COUNTRY_TIER_CAPACITY = 8; + /// @notice Bond multiplier for country-level registration (8× local). + uint256 public constant COUNTRY_BOND_MULTIPLIER = 8; - /// @notice Maximum members per admin-area (local) tier. - uint256 public constant LOCAL_TIER_CAPACITY = 8; + /// @notice Maximum country fleets in any bundle (flexible cap). + uint256 public constant MAX_COUNTRY_IN_BUNDLE = 8; /// @notice Hard cap on tier count per region. /// @dev Derived from anti-spam analysis: with a bond doubling per tier - /// and capacity 8, a spammer spending half the total token supply + /// and capacity 4, 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; @@ -78,9 +82,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Maximum UUIDs returned by buildHighestBondedUuidBundle. uint256 public constant MAX_BONDED_UUID_BUNDLE_SIZE = 20; - /// @notice Region key for global registrations. - uint32 public constant GLOBAL_REGION = 0; - /// @notice ISO 3166-1 numeric upper bound for country codes. uint16 internal constant MAX_COUNTRY_CODE = 999; @@ -91,8 +92,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 internal constant LEVEL_ADMIN = 0; /// @dev Bundle level index: country. uint256 internal constant LEVEL_COUNTRY = 1; - /// @dev Bundle level index: global (lowest priority). - uint256 internal constant LEVEL_GLOBAL = 2; /// @dev Bit shift for packing countryCode into an admin-area region key. uint256 private constant ADMIN_SHIFT = 12; @@ -132,9 +131,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // 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) @@ -174,20 +170,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { BASE_BOND = _baseBond; } - // ══════════════════════════════════════════════ - // Registration: Global (explicit tier only — use globalInclusionHint) - // ══════════════════════════════════════════════ - - /// @notice Register a fleet globally into a specific tier. - /// @dev No auto-assign: the cheapest-inclusion tier depends on all active - /// regions (unbounded scan), so callers must query `globalInclusionHint()` - /// off-chain and supply the tier explicitly. - function registerFleetGlobal(bytes16 uuid, uint256 targetTier) external nonReentrant returns (uint256 tokenId) { - if (uuid == bytes16(0)) revert InvalidUUID(); - _validateExplicitTier(GLOBAL_REGION, targetTier); - tokenId = _register(uuid, GLOBAL_REGION, targetTier); - } - // ══════════════════════════════════════════════ // Registration: Country (explicit tier only — use countryInclusionHint) // ══════════════════════════════════════════════ @@ -280,7 +262,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint32 region = fleetRegion[tokenId]; uint256 tier = fleetTier[tokenId]; - uint256 refund = tierBond(tier); + uint256 refund = tierBond(tier, region); // Effects _removeFromTier(tokenId, region, tier); @@ -304,17 +286,20 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Views: Bond & tier helpers // ══════════════════════════════════════════════ - /// @notice Bond required for tier K in any region = BASE_BOND * 2^K. - function tierBond(uint256 tier) public view returns (uint256) { - return BASE_BOND << tier; + /// @notice Bond required for tier K at a given region. + /// Local (admin area): BASE_BOND * 2^K + /// Country: BASE_BOND * COUNTRY_BOND_MULTIPLIER * 2^K (8× local) + function tierBond(uint256 tier, uint32 regionKey) public view returns (uint256) { + uint256 base = BASE_BOND << tier; + if (regionKey <= MAX_COUNTRY_CODE) { + return base * COUNTRY_BOND_MULTIPLIER; + } + return base; // Local (admin area) } - /// @notice Returns the tier capacity for a given region key. - /// Global = 4, Country = 8, Admin Area = 8. - function tierCapacity(uint32 regionKey) public pure returns (uint256) { - if (regionKey == GLOBAL_REGION) return GLOBAL_TIER_CAPACITY; - if (regionKey <= MAX_COUNTRY_CODE) return COUNTRY_TIER_CAPACITY; - return LOCAL_TIER_CAPACITY; + /// @notice Returns the unified tier capacity (4 for all levels). + function tierCapacity() public pure returns (uint256) { + return TIER_CAPACITY; } /// @notice Returns the cheapest tier that guarantees a **local** fleet @@ -328,7 +313,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); inclusionTier = _findCheapestInclusionTier(countryCode, adminCode, LEVEL_ADMIN); - bond = tierBond(inclusionTier); + bond = tierBond(inclusionTier, _makeAdminRegion(countryCode, adminCode)); } /// @notice Returns the cheapest tier that guarantees a **country** fleet @@ -351,35 +336,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 t = _findCheapestInclusionTier(countryCode, admin, LEVEL_COUNTRY); if (t > inclusionTier) inclusionTier = t; } - bond = tierBond(inclusionTier); - } - - /// @notice Returns the cheapest tier that guarantees a **global** fleet - /// appears in every `buildHighestBondedUuidBundle` query for any - /// (countryCode, adminCode) pair. - /// @dev Unbounded view — iterates over all active countries and admin - /// areas. Free off-chain; callers pass the result to - /// `registerFleetGlobal(uuid, tier)`. - function globalInclusionHint() external view returns (uint256 inclusionTier, uint256 bond) { - // Global-only location (no country, no admin). - inclusionTier = _findCheapestInclusionTier(0, 0, LEVEL_GLOBAL); - - // Check each active country (country-only location). - for (uint256 i = 0; i < _activeCountries.length; ++i) { - uint16 cc = _activeCountries[i]; - uint256 t = _findCheapestInclusionTier(cc, 0, LEVEL_GLOBAL); - if (t > inclusionTier) inclusionTier = t; - } - - // Check each active admin area. - for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { - uint32 rk = _activeAdminAreas[i]; - uint16 cc = _countryFromRegion(rk); - uint16 admin = _adminFromRegion(rk); - uint256 t = _findCheapestInclusionTier(cc, admin, LEVEL_GLOBAL); - if (t > inclusionTier) inclusionTier = t; - } - bond = tierBond(inclusionTier); + bond = tierBond(inclusionTier, uint32(countryCode)); } /// @notice Highest non-empty tier in a region, or 0 if none. @@ -416,7 +373,7 @@ 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 tierBond(fleetTier[tokenId]); + return tierBond(fleetTier[tokenId], fleetRegion[tokenId]); } // ══════════════════════════════════════════════ @@ -424,30 +381,32 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // ══════════════════════════════════════════════ /// @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. + /// UUIDs for an EdgeBeaconScanner, merging the highest-bonded tiers across + /// admin-area and country levels. /// - /// **Algorithm – Shared-Cursor with Partial Inclusion** + /// **Algorithm – Shared-Cursor with Partial Inclusion + Country Cap** /// /// Uses a single shared tier-index cursor that descends from the - /// highest active tier across all three levels. At each cursor - /// position: + /// highest active tier across both levels. At each cursor position: /// - /// 1. Try to include members from each level's tier at the current cursor - /// in priority order: admin → country → global. - /// 2. Include as many members as fit in the remaining bundle capacity. + /// 1. Try to include members from admin area tier first (higher priority). + /// 2. Then include country fleets up to MAX_COUNTRY_IN_BUNDLE cap. + /// 3. Include as many members as fit in remaining bundle capacity. /// Members are included in array order (registration order within tier). - /// 3. Decrement cursor and repeat until bundle is full or cursor < 0. + /// 4. Decrement cursor and repeat until bundle is full or cursor < 0. + /// + /// The country cap (MAX_COUNTRY_IN_BUNDLE = 8) is flexible: if fewer country + /// fleets exist, locals fill the remaining slots. /// /// This guarantees: /// - Higher-bonded fleets always take priority over lower-bonded ones. - /// - Within same bond tier, higher-priority levels (admin > country > global) - /// take precedence. + /// - Within same bond tier, admin area takes precedence over country. /// - Within same tier and level, earlier registrations take precedence. /// - Bundle is always maximally filled (up to 20) if enough fleets exist. + /// - Locals get majority presence (at least 12 slots when country cap reached). /// - /// @param countryCode EdgeBeaconScanner country (0 to skip country + admin). - /// @param adminCode EdgeBeaconScanner admin area (0 to skip admin). + /// @param countryCode EdgeBeaconScanner country (must be > 0). + /// @param adminCode EdgeBeaconScanner admin area (must be > 0). /// @return uuids The merged UUID bundle (up to 20). /// @return count Actual number of UUIDs returned. function buildHighestBondedUuidBundle(uint16 countryCode, uint16 adminCode) @@ -455,23 +414,27 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { view returns (bytes16[] memory uuids, uint256 count) { + if (adminCode == 0) revert AdminAreaRequired(); + if (countryCode == 0) revert InvalidCountryCode(); + uuids = new bytes16[](MAX_BONDED_UUID_BUNDLE_SIZE); + uint256 countryIncluded = 0; - (uint32[3] memory keys, bool[3] memory active) = _resolveBundleLevels(countryCode, adminCode); + (uint32[2] memory keys, bool[2] memory active) = _resolveBundleLevels(countryCode, adminCode); uint256 maxTierIndex = _findMaxTierIndex(keys, active); // 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; - // Try each level at this cursor position in priority order. - for (uint256 lvl = 0; lvl < 3; ++lvl) { + // Try each level at this cursor position in priority order: admin (0), country (1). + for (uint256 lvl = 0; lvl < 2; ++lvl) { if (!active[lvl]) continue; if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; // 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 tc = regionTierCount[keys[lvl]]; + if (tc == 0 || uint256(cursor) >= tc) continue; uint256[] storage members = _regionTierMembers[keys[lvl]][uint256(cursor)]; uint256 mLen = members.length; @@ -479,6 +442,14 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (mLen == 0) continue; uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; + + // Apply country cap for level 1 (LEVEL_COUNTRY) + if (lvl == LEVEL_COUNTRY) { + uint256 countryRoom = + MAX_COUNTRY_IN_BUNDLE > countryIncluded ? MAX_COUNTRY_IN_BUNDLE - countryIncluded : 0; + room = room < countryRoom ? room : countryRoom; + } + uint256 toInclude = mLen < room ? mLen : room; // Include members in array order (registration order) @@ -486,6 +457,11 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uuids[count] = bytes16(uint128(members[m])); ++count; } + + // Track country inclusions + if (lvl == LEVEL_COUNTRY) { + countryIncluded += toInclude; + } } } @@ -546,7 +522,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Shared registration logic. function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { - uint256 bond = tierBond(tier); + uint256 bond = tierBond(tier, region); tokenId = uint256(uint128(uuid)); // Effects @@ -574,10 +550,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 currentTier = fleetTier[tokenId]; if (targetTier <= currentTier) revert TargetTierNotHigher(); if (targetTier >= MAX_TIERS) revert MaxTiersReached(); - if (_regionTierMembers[region][targetTier].length >= tierCapacity(region)) revert TierFull(); + if (_regionTierMembers[region][targetTier].length >= tierCapacity()) revert TierFull(); - uint256 currentBond = tierBond(currentTier); - uint256 targetBond = tierBond(targetTier); + uint256 currentBond = tierBond(currentTier, region); + uint256 targetBond = tierBond(targetTier, region); uint256 additionalBond = targetBond - currentBond; // Effects @@ -605,10 +581,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint32 region = fleetRegion[tokenId]; uint256 currentTier = fleetTier[tokenId]; if (targetTier >= currentTier) revert TargetTierNotLower(); - if (_regionTierMembers[region][targetTier].length >= tierCapacity(region)) revert TierFull(); + if (_regionTierMembers[region][targetTier].length >= tierCapacity()) revert TierFull(); - uint256 currentBond = tierBond(currentTier); - uint256 targetBond = tierBond(targetTier); + uint256 currentBond = tierBond(currentTier, region); + uint256 targetBond = tierBond(targetTier, region); uint256 refund = currentBond - targetBond; // Effects @@ -629,7 +605,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @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 (_regionTierMembers[region][targetTier].length >= tierCapacity()) revert TierFull(); if (targetTier >= regionTierCount[region]) { regionTierCount[region] = targetTier + 1; } @@ -637,12 +613,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // -- Bundle-level helpers (shared by buildHighestBondedUuidBundle & inclusion hints) -- - /// @dev Resolves the three bundle levels from a location. - /// Level 0 = admin area, Level 1 = country, Level 2 = global. + /// @dev Resolves the two bundle levels from a location. + /// Level 0 = admin area (highest priority), Level 1 = country. function _resolveBundleLevels(uint16 countryCode, uint16 adminCode) internal pure - returns (uint32[3] memory keys, bool[3] memory active) + returns (uint32[2] memory keys, bool[2] memory active) { if (countryCode > 0 && adminCode > 0) { keys[LEVEL_ADMIN] = _makeAdminRegion(countryCode, adminCode); @@ -652,17 +628,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { keys[LEVEL_COUNTRY] = uint32(countryCode); active[LEVEL_COUNTRY] = true; } - keys[LEVEL_GLOBAL] = GLOBAL_REGION; - active[LEVEL_GLOBAL] = true; } - /// @dev Finds the highest active tier index across all bundle levels. - function _findMaxTierIndex(uint32[3] memory keys, bool[3] memory active) + /// @dev Finds the highest active tier index across both bundle levels. + function _findMaxTierIndex(uint32[2] memory keys, bool[2] memory active) internal view returns (uint256 maxTierIndex) { - for (uint256 lvl = 0; lvl < 3; ++lvl) { + for (uint256 lvl = 0; lvl < 2; ++lvl) { if (!active[lvl]) continue; uint256 tc = regionTierCount[keys[lvl]]; if (tc > 0 && tc - 1 > maxTierIndex) { @@ -671,65 +645,27 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } - /// @dev Phase 1 of the inclusion simulation: runs the bundle's shared-cursor - /// descent WITHOUT the candidate fleet, recording how many UUIDs are - /// accumulated at each cursor position (`countAt[c]`). - /// Uses partial inclusion (as many members as fit per tier). - function _simulateBundleCounts(uint32[3] memory keys, bool[3] memory active, uint256 maxTierIndex) - internal - view - returns (uint256[MAX_TIERS] memory countAt) - { - uint256 count = 0; - - for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { - if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) { - // Bundle already full, record count for remaining cursors - for (int256 c = cursor; c >= 0; --c) { - countAt[uint256(c)] = count; - } - break; - } - - countAt[uint256(cursor)] = count; - - for (uint256 lvl = 0; lvl < 3; ++lvl) { - if (!active[lvl]) continue; - if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; - - uint256 tc = regionTierCount[keys[lvl]]; - if (tc == 0 || uint256(cursor) >= tc) continue; - - uint256 m = _regionTierMembers[keys[lvl]][uint256(cursor)].length; - if (m == 0) continue; - - uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; - uint256 toInclude = m < room ? m : room; - count += toInclude; - } - } - } - // -- Inclusion-tier logic -- /// @dev Simulates `buildHighestBondedUuidBundle(countryCode, adminCode)` and /// returns the cheapest tier at `candidateLevel` that guarantees bundle - /// inclusion. Bounded: O(MAX_TIERS). Works for any candidate level. + /// inclusion. Bounded: O(MAX_TIERS). Works for admin or country level. /// - /// With partial inclusion, a new fleet is included if: + /// With partial inclusion + country cap, a new fleet is included if: /// (a) The tier has capacity for the new fleet. - /// (b) At that tier position, after higher-priority levels consume slots, + /// (b) At that tier position, after higher-priority levels consume slots + /// (respecting MAX_COUNTRY_IN_BUNDLE for country level), /// there is room for at least one member from the candidate tier. function _findCheapestInclusionTier(uint16 countryCode, uint16 adminCode, uint256 candidateLevel) internal view returns (uint256) { - (uint32[3] memory keys, bool[3] memory active) = _resolveBundleLevels(countryCode, adminCode); + (uint32[2] memory keys, bool[2] memory active) = _resolveBundleLevels(countryCode, adminCode); uint256 maxTierIndex = _findMaxTierIndex(keys, active); uint32 candidateRegion = keys[candidateLevel]; - uint256 cap = tierCapacity(candidateRegion); + uint256 cap = tierCapacity(); // Find cheapest tier T at candidateRegion. uint256 candidateTierCount = regionTierCount[candidateRegion]; @@ -740,33 +676,40 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (members >= cap) continue; // (b) Compute how many slots are consumed BEFORE reaching (candidateRegion, T). - // This is: slots from tiers > T + slots from higher-priority levels AT T. + // This includes the country cap for country-level candidates. uint256 countBefore; + uint256 countryIncludedBefore; + if (T > maxTierIndex) { // Tier above current max: if we join here, we become the new max. // Bundle starts from T, so countBefore = 0. countBefore = 0; + countryIncludedBefore = 0; } else { // Count from tiers strictly above T. - countBefore = _getCountFromTiersAbove(keys, active, maxTierIndex, T); - - // Add higher-priority levels at tier T itself. - for (uint256 lvl = 0; lvl < candidateLevel; ++lvl) { - if (!active[lvl]) continue; - if (countBefore >= MAX_BONDED_UUID_BUNDLE_SIZE) break; - - uint256 ltc = regionTierCount[keys[lvl]]; - if (ltc == 0 || T >= ltc) continue; - - uint256 m = _regionTierMembers[keys[lvl]][T].length; - if (m == 0) continue; - - uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - countBefore; - countBefore += (m < room ? m : room); + (countBefore, countryIncludedBefore) = _getCountFromTiersAbove(keys, active, maxTierIndex, T); + + // Add higher-priority levels at tier T itself (only admin area for country candidates). + if (candidateLevel == LEVEL_COUNTRY && active[LEVEL_ADMIN]) { + if (countBefore < MAX_BONDED_UUID_BUNDLE_SIZE) { + uint256 ltc = regionTierCount[keys[LEVEL_ADMIN]]; + if (ltc > 0 && T < ltc) { + uint256 m = _regionTierMembers[keys[LEVEL_ADMIN]][T].length; + if (m > 0) { + uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - countBefore; + countBefore += (m < room ? m : room); + } + } + } } } - // Does the candidate (with +1) fit? + // For country candidates, check against country cap + if (candidateLevel == LEVEL_COUNTRY) { + if (countryIncludedBefore >= MAX_COUNTRY_IN_BUNDLE) continue; + } + + // Does the candidate (with +1) fit in bundle? if (countBefore < MAX_BONDED_UUID_BUNDLE_SIZE) { return T; } @@ -776,16 +719,17 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @dev Helper to compute count from tiers STRICTLY above T (i.e., tiers > T). - function _getCountFromTiersAbove(uint32[3] memory keys, bool[3] memory active, uint256 maxTierIndex, uint256 T) + /// Also returns how many country fleets were included (for cap tracking). + function _getCountFromTiersAbove(uint32[2] memory keys, bool[2] memory active, uint256 maxTierIndex, uint256 T) internal view - returns (uint256 count) + returns (uint256 count, uint256 countryIncluded) { // Process tiers from maxTierIndex down to T+1 (exclusive of T). for (int256 cursor = int256(maxTierIndex); cursor > int256(T); --cursor) { if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; - for (uint256 lvl = 0; lvl < 3; ++lvl) { + for (uint256 lvl = 0; lvl < 2; ++lvl) { if (!active[lvl]) continue; if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; @@ -796,7 +740,20 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (m == 0) continue; uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; - count += (m < room ? m : room); + + // Apply country cap for LEVEL_COUNTRY + if (lvl == LEVEL_COUNTRY) { + uint256 countryRoom = + MAX_COUNTRY_IN_BUNDLE > countryIncluded ? MAX_COUNTRY_IN_BUNDLE - countryIncluded : 0; + room = room < countryRoom ? room : countryRoom; + } + + uint256 toInclude = m < room ? m : room; + count += toInclude; + + if (lvl == LEVEL_COUNTRY) { + countryIncluded += toInclude; + } } } } @@ -834,9 +791,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @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 <= MAX_COUNTRY_CODE) { + if (region <= MAX_COUNTRY_CODE) { // Country uint16 cc = uint16(region); if (_activeCountryIndex[cc] == 0) { @@ -856,9 +811,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { function _removeFromRegionIndex(uint32 region) internal { if (regionTierCount[region] > 0) return; // still has fleets - if (region == GLOBAL_REGION) { - globalActive = false; - } else if (region <= MAX_COUNTRY_CODE) { + if (region <= MAX_COUNTRY_CODE) { uint16 cc = uint16(region); uint256 oneIdx = _activeCountryIndex[cc]; if (oneIdx > 0) { diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 8a7611c7..08e83714 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -100,8 +100,6 @@ contract FleetIdentityTest is Test { return bytes16(keccak256(abi.encodePacked("fleet-", i))); } - uint32 constant GLOBAL = 0; - function _regionUS() internal pure returns (uint32) { return uint32(US); } @@ -118,25 +116,6 @@ contract FleetIdentityTest is Test { 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), i / 4); // GLOBAL_TIER_CAPACITY = 4 - } - } - - function _registerNGlobalAt(address owner, uint256 count, uint256 tier, uint256 startSeed) - internal - returns (uint256[] memory ids) - { - ids = new uint256[](count); - for (uint256 i = 0; i < count; i++) { - vm.prank(owner); - ids[i] = fleet.registerFleetGlobal(_uuid(startSeed + i), tier); - } - } - function _registerNCountry(address owner, uint16 cc, uint256 count, uint256 startSeed) internal returns (uint256[] memory ids) @@ -144,7 +123,7 @@ contract FleetIdentityTest is Test { ids = new uint256[](count); for (uint256 i = 0; i < count; i++) { vm.prank(owner); - ids[i] = fleet.registerFleetCountry(_uuid(startSeed + i), cc, i / 8); // COUNTRY_TIER_CAPACITY = 8 + ids[i] = fleet.registerFleetCountry(_uuid(startSeed + i), cc, i / 4); // TIER_CAPACITY = 4 } } @@ -166,7 +145,7 @@ contract FleetIdentityTest is Test { ids = new uint256[](count); for (uint256 i = 0; i < count; i++) { vm.prank(owner); - ids[i] = fleet.registerFleetLocal(_uuid(startSeed + i), cc, admin); + ids[i] = fleet.registerFleetLocal(_uuid(startSeed + i), cc, admin, i / 4); // TIER_CAPACITY = 4 } } @@ -188,101 +167,47 @@ contract FleetIdentityTest is Test { assertEq(fleet.BASE_BOND(), BASE_BOND); assertEq(fleet.name(), "Swarm Fleet Identity"); assertEq(fleet.symbol(), "SFID"); - assertEq(fleet.GLOBAL_REGION(), 0); } function test_constructor_constants() public view { - assertEq(fleet.GLOBAL_TIER_CAPACITY(), 4); - assertEq(fleet.COUNTRY_TIER_CAPACITY(), 8); - assertEq(fleet.LOCAL_TIER_CAPACITY(), 8); + assertEq(fleet.TIER_CAPACITY(), 4); assertEq(fleet.MAX_TIERS(), 24); assertEq(fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), 20); + assertEq(fleet.COUNTRY_BOND_MULTIPLIER(), 8); + assertEq(fleet.MAX_COUNTRY_IN_BUNDLE(), 8); } - function test_tierCapacity_perLevel() public view { - assertEq(fleet.tierCapacity(GLOBAL), 4); - assertEq(fleet.tierCapacity(_regionUS()), 8); - assertEq(fleet.tierCapacity(_regionUSCA()), 8); + function test_tierCapacity() public view { + assertEq(fleet.tierCapacity(), 4); } // --- tierBond --- - function test_tierBond_tier0() public view { - assertEq(fleet.tierBond(0), BASE_BOND); + function test_tierBond_local_tier0() public view { + // Local regions get 1× multiplier + assertEq(fleet.tierBond(0, _regionUSCA()), BASE_BOND); + } + + function test_tierBond_country_tier0() public view { + // Country regions get 8× multiplier + assertEq(fleet.tierBond(0, _regionUS()), BASE_BOND * 8); } - function test_tierBond_tier1() public view { - assertEq(fleet.tierBond(1), BASE_BOND * 2); + function test_tierBond_local_tier1() public view { + assertEq(fleet.tierBond(1, _regionUSCA()), BASE_BOND * 2); } - function test_tierBond_tier2() public view { - assertEq(fleet.tierBond(2), BASE_BOND * 2 * 2); + function test_tierBond_country_tier1() public view { + assertEq(fleet.tierBond(1, _regionUS()), BASE_BOND * 8 * 2); } function test_tierBond_geometricProgression() public view { for (uint256 i = 1; i <= 5; i++) { - assertEq(fleet.tierBond(i), fleet.tierBond(i - 1) * 2); + assertEq(fleet.tierBond(i, _regionUSCA()), fleet.tierBond(i - 1, _regionUSCA()) * 2); + assertEq(fleet.tierBond(i, _regionUS()), fleet.tierBond(i - 1, _regionUS()) * 2); } } - // --- registerFleetGlobal auto --- - - function test_registerFleetGlobal_auto_mintsAndLocksBond() public { - vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); - - assertEq(fleet.ownerOf(tokenId), alice); - assertEq(tokenId, uint256(uint128(UUID_1))); - assertEq(fleet.bonds(tokenId), BASE_BOND); - assertEq(fleet.fleetRegion(tokenId), GLOBAL); - assertEq(fleet.fleetTier(tokenId), 0); - assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND); - } - - function test_registerFleetGlobal_auto_emitsEvent() public { - uint256 expectedTokenId = uint256(uint128(UUID_1)); - - vm.expectEmit(true, true, true, true); - emit FleetRegistered(alice, UUID_1, expectedTokenId, GLOBAL, 0, BASE_BOND); - - vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); - } - - function test_RevertIf_registerFleetGlobal_auto_zeroUUID() public { - vm.prank(alice); - vm.expectRevert(FleetIdentity.InvalidUUID.selector); - fleet.registerFleetGlobal(bytes16(0), 0); - } - - function test_RevertIf_registerFleetGlobal_auto_duplicateUUID() public { - vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); - - vm.prank(bob); - vm.expectRevert(); - fleet.registerFleetGlobal(UUID_1, 0); - } - - // --- registerFleetGlobal explicit tier --- - - function test_registerFleetGlobal_explicit_joinsSpecifiedTier() public { - vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); - - assertEq(fleet.fleetTier(tokenId), 2); - assertEq(fleet.fleetRegion(tokenId), GLOBAL); - assertEq(fleet.bonds(tokenId), fleet.tierBond(2)); - assertEq(fleet.tierMemberCount(GLOBAL, 2), 1); - assertEq(fleet.regionTierCount(GLOBAL), 3); - } - - function test_RevertIf_registerFleetGlobal_explicit_exceedsMaxTiers() public { - vm.prank(alice); - vm.expectRevert(FleetIdentity.MaxTiersReached.selector); - fleet.registerFleetGlobal(UUID_1, 50); - } - // --- registerFleetCountry --- function test_registerFleetCountry_auto_setsRegionAndTier() public { @@ -291,7 +216,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.fleetRegion(tokenId), _regionUS()); assertEq(fleet.fleetTier(tokenId), 0); - assertEq(fleet.bonds(tokenId), BASE_BOND); + assertEq(fleet.bonds(tokenId), BASE_BOND * 8); // Country gets 8× multiplier assertEq(fleet.regionTierCount(_regionUS()), 1); } @@ -300,7 +225,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 3); assertEq(fleet.fleetTier(tokenId), 3); - assertEq(fleet.bonds(tokenId), fleet.tierBond(3)); + assertEq(fleet.bonds(tokenId), fleet.tierBond(3, _regionUS())); assertEq(fleet.regionTierCount(_regionUS()), 4); } @@ -332,7 +257,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); assertEq(fleet.fleetTier(tokenId), 2); - assertEq(fleet.bonds(tokenId), fleet.tierBond(2)); + assertEq(fleet.bonds(tokenId), fleet.tierBond(2, fleet.adminRegionKey(US, ADMIN_CA))); } function test_RevertIf_registerFleetLocal_invalidCountry() public { @@ -355,39 +280,41 @@ contract FleetIdentityTest is Test { // --- Per-region independent tier indexing (KEY REQUIREMENT) --- - function test_perRegionTiers_firstFleetInEveryRegionPaysSameBond() public { - vm.prank(alice); - uint256 g1 = fleet.registerFleetGlobal(UUID_1, 0); + function test_perRegionTiers_firstFleetInEachLevelPaysBondWithMultiplier() public { + // Country level pays 8× multiplier vm.prank(alice); - uint256 c1 = fleet.registerFleetCountry(UUID_2, US, 0); + uint256 c1 = fleet.registerFleetCountry(UUID_1, US, 0); + // Local level pays 1× multiplier vm.prank(alice); - uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + uint256 l1 = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); - assertEq(fleet.fleetTier(g1), 0); assertEq(fleet.fleetTier(c1), 0); assertEq(fleet.fleetTier(l1), 0); - assertEq(fleet.bonds(g1), BASE_BOND); - assertEq(fleet.bonds(c1), BASE_BOND); - assertEq(fleet.bonds(l1), BASE_BOND); + assertEq(fleet.bonds(c1), BASE_BOND * 8); // Country gets 8× multiplier + assertEq(fleet.bonds(l1), BASE_BOND); // Local gets 1× multiplier } function test_perRegionTiers_fillOneRegionDoesNotAffectOthers() public { - _registerNGlobal(alice, 4); - assertEq(fleet.regionTierCount(GLOBAL), 1); - assertEq(fleet.tierMemberCount(GLOBAL, 0), 4); + // Fill US country tier 0 with 4 fleets + _registerNCountryAt(alice, US, 4, 0, 0); + assertEq(fleet.regionTierCount(_regionUS()), 1); + assertEq(fleet.tierMemberCount(_regionUS(), 0), 4); + // Next US country fleet goes to tier 1 vm.prank(bob); - uint256 g21 = fleet.registerFleetGlobal(_uuid(100), 1); - assertEq(fleet.fleetTier(g21), 1); - assertEq(fleet.bonds(g21), BASE_BOND * 2); + uint256 us21 = fleet.registerFleetCountry(_uuid(100), US, 1); + assertEq(fleet.fleetTier(us21), 1); + assertEq(fleet.bonds(us21), BASE_BOND * 8 * 2); // Country tier 1: 8× * 2^1 + // DE country is independent - can still join tier 0 vm.prank(bob); - uint256 us1 = fleet.registerFleetCountry(_uuid(200), US, 0); - assertEq(fleet.fleetTier(us1), 0); - assertEq(fleet.bonds(us1), BASE_BOND); - assertEq(fleet.regionTierCount(_regionUS()), 1); + uint256 de1 = fleet.registerFleetCountry(_uuid(200), DE, 0); + assertEq(fleet.fleetTier(de1), 0); + assertEq(fleet.bonds(de1), BASE_BOND * 8); + assertEq(fleet.regionTierCount(_regionDE()), 1); + // US local is independent - can still join tier 0 vm.prank(bob); uint256 usca1 = fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA); assertEq(fleet.fleetTier(usca1), 0); @@ -395,24 +322,29 @@ contract FleetIdentityTest is Test { } function test_perRegionTiers_twoCountriesIndependent() public { - _registerNCountry(alice, US, 8, 0); - assertEq(fleet.tierMemberCount(_regionUS(), 0), 8); + // Register 4 US country fleets at tier 0 + _registerNCountryAt(alice, US, 4, 0, 0); + assertEq(fleet.tierMemberCount(_regionUS(), 0), 4); + // Next US country fleet explicitly goes to tier 1 vm.prank(bob); uint256 us21 = fleet.registerFleetCountry(_uuid(500), US, 1); assertEq(fleet.fleetTier(us21), 1); - assertEq(fleet.bonds(us21), BASE_BOND * 2); + assertEq(fleet.bonds(us21), BASE_BOND * 8 * 2); // Country tier 1: 8× * 2^1 + // DE country is independent - can still join tier 0 vm.prank(bob); uint256 de1 = fleet.registerFleetCountry(_uuid(600), DE, 0); assertEq(fleet.fleetTier(de1), 0); - assertEq(fleet.bonds(de1), BASE_BOND); + assertEq(fleet.bonds(de1), BASE_BOND * 8); // Country tier 0: 8× * 2^0 } function test_perRegionTiers_twoAdminAreasIndependent() public { - _registerNLocal(alice, US, ADMIN_CA, 8, 0); - assertEq(fleet.tierMemberCount(_regionUSCA(), 0), 8); + // Register 4 local fleets at tier 0 in US/CA + _registerNLocalAt(alice, US, ADMIN_CA, 4, 0, 0); + assertEq(fleet.tierMemberCount(_regionUSCA(), 0), 4); + // NY is independent - can still join tier 0 vm.prank(bob); uint256 ny1 = fleet.registerFleetLocal(_uuid(500), US, ADMIN_NY); assertEq(fleet.fleetTier(ny1), 0); @@ -430,8 +362,8 @@ contract FleetIdentityTest is Test { } function test_autoAssign_local_cheapestInclusionTier() public { - // Fill admin-area tier 0 (8 members) so tier 0 is full. - _registerNLocal(alice, US, ADMIN_CA, 8, 0); + // Fill admin-area tier 0 (4 members) so tier 0 is full. + _registerNLocalAt(alice, US, ADMIN_CA, 4, 0, 0); // Auto-assign should pick tier 1 (cheapest tier with capacity). vm.prank(bob); @@ -451,15 +383,15 @@ contract FleetIdentityTest is Test { assertEq(fleet.fleetTier(tokenId), 1); assertEq(fleet.fleetRegion(tokenId), _regionUS()); - assertEq(fleet.bonds(tokenId), fleet.tierBond(1)); + assertEq(fleet.bonds(tokenId), fleet.tierBond(1, _regionUS())); } function test_promote_next_pullsBondDifference() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); uint256 balBefore = bondToken.balanceOf(alice); - uint256 diff = fleet.tierBond(1) - fleet.tierBond(0); + uint256 diff = fleet.tierBond(1, _regionUSCA()) - fleet.tierBond(0, _regionUSCA()); vm.prank(alice); fleet.promote(tokenId); @@ -475,14 +407,14 @@ contract FleetIdentityTest is Test { fleet.reassignTier(tokenId, 3); assertEq(fleet.fleetTier(tokenId), 3); - assertEq(fleet.bonds(tokenId), fleet.tierBond(3)); + assertEq(fleet.bonds(tokenId), fleet.tierBond(3, _regionUSCA())); assertEq(fleet.regionTierCount(_regionUSCA()), 4); } function test_promote_emitsEvent() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); - uint256 diff = fleet.tierBond(1) - fleet.tierBond(0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 diff = fleet.tierBond(1, _regionUSCA()) - fleet.tierBond(0, _regionUSCA()); vm.expectEmit(true, true, true, true); emit FleetPromoted(tokenId, 0, 1, diff); @@ -493,7 +425,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_promote_notOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -502,7 +434,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_reassignTier_targetSameAsCurrent() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); vm.prank(alice); vm.expectRevert(FleetIdentity.TargetTierSameAsCurrent.selector); @@ -511,11 +443,12 @@ contract FleetIdentityTest is Test { function test_RevertIf_promote_targetTierFull() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + // Fill tier 1 with 4 members for (uint256 i = 0; i < 4; i++) { vm.prank(bob); - fleet.registerFleetGlobal(_uuid(50 + i), 1); + fleet.registerFleetLocal(_uuid(50 + i), US, ADMIN_CA, 1); } vm.prank(alice); @@ -525,7 +458,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_reassignTier_exceedsMaxTiers() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(alice); vm.expectRevert(FleetIdentity.MaxTiersReached.selector); @@ -542,15 +475,15 @@ contract FleetIdentityTest is Test { fleet.reassignTier(tokenId, 1); assertEq(fleet.fleetTier(tokenId), 1); - assertEq(fleet.bonds(tokenId), fleet.tierBond(1)); + assertEq(fleet.bonds(tokenId), fleet.tierBond(1, _regionDE())); } function test_reassignTier_demoteRefundsBondDifference() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 3); uint256 balBefore = bondToken.balanceOf(alice); - uint256 refund = fleet.tierBond(3) - fleet.tierBond(1); + uint256 refund = fleet.tierBond(3, _regionUSCA()) - fleet.tierBond(1, _regionUSCA()); vm.prank(alice); fleet.reassignTier(tokenId, 1); @@ -560,8 +493,8 @@ contract FleetIdentityTest is Test { function test_reassignTier_demoteEmitsEvent() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); - uint256 refund = fleet.tierBond(3) - fleet.tierBond(1); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 3); + uint256 refund = fleet.tierBond(3, _regionUSCA()) - fleet.tierBond(1, _regionUSCA()); vm.expectEmit(true, true, true, true); emit FleetDemoted(tokenId, 3, 1, refund); @@ -572,17 +505,17 @@ contract FleetIdentityTest is Test { function test_reassignTier_demoteTrimsTierCountWhenTopEmpties() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 3); - assertEq(fleet.regionTierCount(GLOBAL), 4); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 3); + assertEq(fleet.regionTierCount(_regionUSCA()), 4); vm.prank(alice); fleet.reassignTier(tokenId, 0); - assertEq(fleet.regionTierCount(GLOBAL), 1); + assertEq(fleet.regionTierCount(_regionUSCA()), 1); } function test_RevertIf_reassignTier_demoteNotOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 2); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -590,10 +523,10 @@ contract FleetIdentityTest is Test { } function test_RevertIf_reassignTier_demoteTargetTierFull() public { - _registerNGlobal(alice, 4); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 0, 0); vm.prank(bob); - uint256 tokenId = fleet.registerFleetGlobal(_uuid(100), 2); + uint256 tokenId = fleet.registerFleetLocal(_uuid(100), US, ADMIN_CA, 2); vm.prank(bob); vm.expectRevert(FleetIdentity.TierFull.selector); @@ -602,7 +535,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_reassignTier_promoteNotOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -613,7 +546,7 @@ contract FleetIdentityTest is Test { function test_burn_refundsTierBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); uint256 balBefore = bondToken.balanceOf(alice); vm.prank(alice); @@ -626,10 +559,10 @@ contract FleetIdentityTest is Test { function test_burn_emitsEvent() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.expectEmit(true, true, true, true); - emit FleetBurned(alice, tokenId, GLOBAL, 0, BASE_BOND); + emit FleetBurned(alice, tokenId, _regionUSCA(), 0, BASE_BOND); vm.prank(alice); fleet.burn(tokenId); @@ -647,7 +580,7 @@ contract FleetIdentityTest is Test { function test_burn_allowsReregistration() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(alice); fleet.burn(tokenId); @@ -660,7 +593,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_burn_notOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -676,7 +609,7 @@ contract FleetIdentityTest is Test { } function test_localInclusionHint_afterFillingAdminTier0() public { - _registerNLocal(alice, US, ADMIN_CA, 8, 0); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 0, 0); // Admin tier 0 full → cheapest inclusion is tier 1. (uint256 tier, uint256 bond) = fleet.localInclusionHint(US, ADMIN_CA); @@ -687,74 +620,63 @@ contract FleetIdentityTest is Test { // --- highestActiveTier --- function test_highestActiveTier_noFleets() public view { - assertEq(fleet.highestActiveTier(GLOBAL), 0); assertEq(fleet.highestActiveTier(_regionUS()), 0); + assertEq(fleet.highestActiveTier(_regionUSCA()), 0); } function test_highestActiveTier_afterRegistrations() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 3); - assertEq(fleet.highestActiveTier(GLOBAL), 3); + fleet.registerFleetCountry(UUID_1, US, 3); + assertEq(fleet.highestActiveTier(_regionUS()), 3); - assertEq(fleet.highestActiveTier(_regionUS()), 0); + // Different region still at 0 + assertEq(fleet.highestActiveTier(_regionDE()), 0); } // --- EdgeBeaconScanner helpers --- function test_tierMemberCount_perRegion() public { - _registerNGlobal(alice, 3); - _registerNCountry(bob, US, 5, 100); + _registerNLocalAt(alice, US, ADMIN_CA, 3, 0, 0); + _registerNCountryAt(bob, US, 4, 100, 0); - assertEq(fleet.tierMemberCount(GLOBAL, 0), 3); - assertEq(fleet.tierMemberCount(_regionUS(), 0), 5); + assertEq(fleet.tierMemberCount(_regionUSCA(), 0), 3); + assertEq(fleet.tierMemberCount(_regionUS(), 0), 4); } function test_getTierMembers_perRegion() public { vm.prank(alice); - uint256 gId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 usId = fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); - uint256 usId = fleet.registerFleetCountry(UUID_2, US, 0); - - uint256[] memory gMembers = fleet.getTierMembers(GLOBAL, 0); - assertEq(gMembers.length, 1); - assertEq(gMembers[0], gId); + uint256 uscaId = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); uint256[] memory usMembers = fleet.getTierMembers(_regionUS(), 0); assertEq(usMembers.length, 1); assertEq(usMembers[0], usId); + + uint256[] memory uscaMembers = fleet.getTierMembers(_regionUSCA(), 0); + assertEq(uscaMembers.length, 1); + assertEq(uscaMembers[0], uscaId); } function test_getTierUuids_perRegion() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); + fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US, 0); - - bytes16[] memory gUUIDs = fleet.getTierUuids(GLOBAL, 0); - assertEq(gUUIDs.length, 1); - assertEq(gUUIDs[0], UUID_1); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); bytes16[] memory usUUIDs = fleet.getTierUuids(_regionUS(), 0); assertEq(usUUIDs.length, 1); - assertEq(usUUIDs[0], UUID_2); + assertEq(usUUIDs[0], UUID_1); + + bytes16[] memory uscaUUIDs = fleet.getTierUuids(_regionUSCA(), 0); + assertEq(uscaUUIDs.length, 1); + assertEq(uscaUUIDs[0], UUID_2); } // --- Region indexes --- - function test_globalActive_trackedCorrectly() public { - assertFalse(fleet.globalActive()); - - vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); - assertTrue(fleet.globalActive()); - - vm.prank(alice); - fleet.burn(tokenId); - assertFalse(fleet.globalActive()); - } - function test_activeCountries_addedOnRegistration() public { vm.prank(alice); fleet.registerFleetCountry(UUID_1, US, 0); @@ -833,13 +755,13 @@ contract FleetIdentityTest is Test { function test_tokenUuid_roundTrip() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); assertEq(fleet.tokenUuid(tokenId), UUID_1); } function test_bonds_returnsTierBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); assertEq(fleet.bonds(tokenId), BASE_BOND); } @@ -853,11 +775,11 @@ contract FleetIdentityTest is Test { assertEq(fleet.totalSupply(), 0); vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); + fleet.registerFleetCountry(UUID_1, US, 0); assertEq(fleet.totalSupply(), 1); vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US, 0); + fleet.registerFleetCountry(UUID_2, DE, 0); assertEq(fleet.totalSupply(), 2); vm.prank(carol); @@ -875,20 +797,21 @@ contract FleetIdentityTest is Test { function test_bondAccounting_acrossRegions() public { vm.prank(alice); - uint256 g1 = fleet.registerFleetGlobal(UUID_1, 0); + uint256 c1 = fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); - uint256 c1 = fleet.registerFleetCountry(UUID_2, US, 0); + uint256 c2 = fleet.registerFleetCountry(UUID_2, DE, 0); vm.prank(carol); uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 3); + // c1 and c2 are country (8× multiplier), l1 is local (1× multiplier) + assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 8 + BASE_BOND * 8 + BASE_BOND); vm.prank(bob); - fleet.burn(c1); - assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 2); + fleet.burn(c2); + assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 8 + BASE_BOND); vm.prank(alice); - fleet.burn(g1); + fleet.burn(c1); vm.prank(carol); fleet.burn(l1); assertEq(bondToken.balanceOf(address(fleet)), 0); @@ -896,7 +819,7 @@ contract FleetIdentityTest is Test { function test_bondAccounting_reassignTierRoundTrip() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); uint256 balStart = bondToken.balanceOf(alice); vm.prank(alice); @@ -923,7 +846,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); vm.expectRevert(); - f.registerFleetGlobal(UUID_1, 0); + f.registerFleetLocal(UUID_1, US, ADMIN_CA); } // --- Transfer preserves region and tier --- @@ -937,35 +860,35 @@ contract FleetIdentityTest is Test { assertEq(fleet.fleetRegion(tokenId), _regionUS()); assertEq(fleet.fleetTier(tokenId), 2); - assertEq(fleet.bonds(tokenId), fleet.tierBond(2)); + assertEq(fleet.bonds(tokenId), fleet.tierBond(2, _regionUS())); uint256 bobBefore = bondToken.balanceOf(bob); vm.prank(bob); fleet.burn(tokenId); - assertEq(bondToken.balanceOf(bob), bobBefore + fleet.tierBond(2)); + assertEq(bondToken.balanceOf(bob), bobBefore + fleet.tierBond(2, _regionUS())); } // --- Tier lifecycle --- function test_tierLifecycle_fillBurnBackfillPerRegion() public { - uint256[] memory usIds = _registerNCountry(alice, US, 8, 0); - assertEq(fleet.tierMemberCount(_regionUS(), 0), 8); + // Register 4 US country fleets at tier 0 (fills capacity) + uint256[] memory usIds = _registerNCountryAt(alice, US, 4, 0, 0); + assertEq(fleet.tierMemberCount(_regionUS(), 0), 4); + // Next country fleet goes to tier 1 vm.prank(bob); - uint256 us9 = fleet.registerFleetCountry(_uuid(100), US, 1); - assertEq(fleet.fleetTier(us9), 1); + uint256 us5 = fleet.registerFleetCountry(_uuid(100), US, 1); + assertEq(fleet.fleetTier(us5), 1); - // Burn from tier 0 — now tier 0 has 7, tier 1 has 1. + // Burn from tier 0 — now tier 0 has 3, tier 1 has 1. vm.prank(alice); fleet.burn(usIds[3]); - // Backfill into tier 0 (now has room) or tier 1. + // Explicitly register into tier 1. vm.prank(carol); uint256 backfill = fleet.registerFleetCountry(_uuid(200), US, 1); assertEq(fleet.fleetTier(backfill), 1); assertEq(fleet.tierMemberCount(_regionUS(), 1), 2); - - assertEq(fleet.regionTierCount(GLOBAL), 0); } // --- Edge cases --- @@ -976,7 +899,7 @@ contract FleetIdentityTest is Test { bondToken.approve(address(f), type(uint256).max); vm.prank(alice); - uint256 tokenId = f.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = f.registerFleetLocal(UUID_1, US, ADMIN_CA); assertEq(f.bonds(tokenId), 0); vm.prank(alice); @@ -985,19 +908,6 @@ contract FleetIdentityTest is Test { // --- Fuzz Tests --- - function testFuzz_registerFleetGlobal_anyValidUUID(bytes16 uuid) public { - vm.assume(uuid != bytes16(0)); - - vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(uuid, 0); - - assertEq(tokenId, uint256(uint128(uuid))); - assertEq(fleet.ownerOf(tokenId), alice); - assertEq(fleet.bonds(tokenId), BASE_BOND); - assertEq(fleet.fleetTier(tokenId), 0); - assertEq(fleet.fleetRegion(tokenId), GLOBAL); - } - function testFuzz_registerFleetCountry_validCountryCodes(uint16 cc) public { cc = uint16(bound(cc, 1, 999)); @@ -1006,7 +916,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.fleetRegion(tokenId), uint32(cc)); assertEq(fleet.fleetTier(tokenId), 0); - assertEq(fleet.bonds(tokenId), BASE_BOND); + assertEq(fleet.bonds(tokenId), BASE_BOND * 8); // Country gets 8× multiplier } function testFuzz_registerFleetLocal_validCodes(uint16 cc, uint16 admin) public { @@ -1027,7 +937,7 @@ contract FleetIdentityTest is Test { vm.assume(caller != address(0)); vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(caller); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -1039,7 +949,7 @@ contract FleetIdentityTest is Test { vm.assume(caller != address(0)); vm.prank(alice); - uint256 tokenId = fleet.registerFleetGlobal(UUID_1, 0); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(caller); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -1052,43 +962,48 @@ contract FleetIdentityTest is Test { for (uint256 i = 0; i < tier; i++) { expected *= 2; } - assertEq(fleet.tierBond(tier), expected); + // Local regions get 1× multiplier + assertEq(fleet.tierBond(tier, _regionUSCA()), expected); + // Country regions get 8× multiplier + assertEq(fleet.tierBond(tier, _regionUS()), expected * 8); } function testFuzz_perRegionTiers_newRegionAlwaysStartsAtTier0(uint16 cc) public { cc = uint16(bound(cc, 1, 999)); - _registerNGlobal(alice, 8); - assertEq(fleet.regionTierCount(GLOBAL), 2); + // Fill one country with 8 fleets + _registerNCountry(alice, US, 8, 0); + assertEq(fleet.regionTierCount(_regionUS()), 2); + // New country should start at tier 0 regardless of other regions vm.prank(bob); uint256 tokenId = fleet.registerFleetCountry(_uuid(999), cc, 0); assertEq(fleet.fleetTier(tokenId), 0); - assertEq(fleet.bonds(tokenId), BASE_BOND); + assertEq(fleet.bonds(tokenId), BASE_BOND * 8); // Country gets 8× multiplier } function testFuzz_tierAssignment_autoFillsSequentiallyPerRegion(uint8 count) public { count = uint8(bound(count, 1, 40)); for (uint256 i = 0; i < count; i++) { - uint256 expectedTier = i / 8; // country capacity = 8 + uint256 expectedTier = i / 4; // TIER_CAPACITY = 4 vm.prank(alice); - uint256 tokenId = fleet.registerFleetCountry(_uuid(i + 300), US, expectedTier); + uint256 tokenId = fleet.registerFleetLocal(_uuid(i + 300), US, ADMIN_CA, expectedTier); assertEq(fleet.fleetTier(tokenId), expectedTier); } - uint256 expectedTiers = (uint256(count) + 7) / 8; - assertEq(fleet.regionTierCount(_regionUS()), expectedTiers); + uint256 expectedTiers = (uint256(count) + 3) / 4; // TIER_CAPACITY = 4 + assertEq(fleet.regionTierCount(_regionUSCA()), expectedTiers); } // --- Invariants --- function test_invariant_contractBalanceEqualsSumOfBonds() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetGlobal(UUID_1, 0); + uint256 id1 = fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); - uint256 id2 = fleet.registerFleetCountry(UUID_2, US, 0); + uint256 id2 = fleet.registerFleetCountry(UUID_2, DE, 0); vm.prank(carol); uint256 id3 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); @@ -1107,7 +1022,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); uint256 id2 = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); vm.prank(carol); - uint256 id3 = fleet.registerFleetGlobal(UUID_3, 0); + uint256 id3 = fleet.registerFleetLocal(UUID_3, DE, ADMIN_NY); vm.prank(alice); fleet.reassignTier(id1, 3); @@ -1128,187 +1043,23 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(address(fleet)), 0); } - // --- globalInclusionHint --- - - function test_globalInclusionHint_emptyReturnsZero() public view { - (uint256 tier, uint256 bond) = fleet.globalInclusionHint(); - assertEq(tier, 0); - assertEq(bond, BASE_BOND); - } - - function test_globalInclusionHint_onlyGlobalFleets() public { - _registerNGlobal(alice, 4); // fills tier 0 - vm.prank(bob); - fleet.registerFleetGlobal(_uuid(9000), 1); // tier 1 - - // Global fleet at tier 0 is the cheapest inclusion tier for - // a new global fleet (if tier 0 has room, it fits in bundle). - // Tier 0 is full (4/4). Tier 1 has 1 member but also full - // isn't relevant — we need tier where a new fleet fits. - // Tier 0 is full, tier 1 has room → cheapest = 1. - // But wait: with 5 globals, a 6th global at tier 0 can't fit (full). - // At tier 1 (1 member + 1 = 2), bundle has 4+2=6, fits in 20. - (uint256 tier, uint256 bond) = fleet.globalInclusionHint(); - assertEq(tier, 1); // tier 0 full, cheapest with room - assertEq(bond, BASE_BOND * 2); - } - - function test_globalInclusionHint_localAreaPresent() public { - // Global: tier 0 only - vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); - - // US-CA: push to tier 3 via explicit registration - vm.prank(bob); - fleet.registerFleetLocal(_uuid(2000), US, ADMIN_CA, 3); - - // A new global fleet needs to be included in bundle(US, ADMIN_CA). - // Bundle simulation: cursor starts at tier 3. - // cursor=3: admin has 1 member (fits), global has 0 → no skip - // cursor=2..1: empty everywhere - // cursor=0: admin 0, global has 1 member (fits) - // A global fleet at tier 0: at cursor=0, global would have 1+1=2 members. - // 2 <= 20 - 1 (from admin at cursor 3) = 19. Fits. - // So cheapest = 0 (tier 0 has room for global: 1 member, cap 4). - (uint256 tier,) = fleet.globalInclusionHint(); - assertEq(tier, 0); - } - - function test_globalInclusionHint_bundlePressure() public { - // Fill admin tier 0 with 8 members, country tier 0 with 8 members, - // global tier 0 with 4 members. Total = 20 = MAX_BONDED_UUID_BUNDLE_SIZE. - // A new global fleet at tier 0 would make global=5 members at cursor 0. - // At cursor 0: admin=8 (fits), country=8 (fits, count=16), global=5 - // 5 <= 20-16 = 4? No! 5 > 4 → global tier 0 is skipped → fair-stop. - // So tier 0 doesn't guarantee inclusion. Need tier 1. - // At cursor 1: no admin, no country, global has 1 member. Fits. - _registerNLocal(alice, US, ADMIN_CA, 8, 500); - _registerNCountry(alice, US, 8, 600); - _registerNGlobalAt(alice, 4, 0, 700); - - (uint256 tier,) = fleet.globalInclusionHint(); - assertEq(tier, 1); - } - - function test_globalInclusionHint_afterBurn_updates() public { - vm.prank(alice); - uint256 id = fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 3); - - (uint256 beforeTier,) = fleet.globalInclusionHint(); - - vm.prank(alice); - fleet.burn(id); - - (uint256 afterTier,) = fleet.globalInclusionHint(); - assertLe(afterTier, beforeTier); - assertEq(afterTier, 0); - } - - function test_globalInclusionHint_registrantCanActOnHint() public { - // Admin area is at tier 2. A global registrant uses the hint. - vm.prank(alice); - fleet.registerFleetLocal(_uuid(1000), US, ADMIN_CA, 2); - - (uint256 inclusionTier, uint256 hintBond) = fleet.globalInclusionHint(); - - // Bob registers globally at the hinted tier - vm.prank(bob); - fleet.registerFleetGlobal(_uuid(2000), inclusionTier); - - uint256 tokenId = uint256(uint128(_uuid(2000))); - assertEq(fleet.fleetTier(tokenId), inclusionTier); - assertEq(fleet.bonds(tokenId), hintBond); - - // Bundle for US-CA includes both - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertGt(count, 0); - bool foundGlobal; - for (uint256 i = 0; i < count; i++) { - if (uuids[i] == _uuid(2000)) foundGlobal = true; - } - assertTrue(foundGlobal, "Global fleet should appear in bundle"); - } - - function test_globalInclusionHint_multipleLocations_takesMax() public { - // US-CA: heavy competition — fill admin + country tiers - _registerNLocal(alice, US, ADMIN_CA, 8, 500); - _registerNCountry(alice, US, 8, 600); - // DE with admin area at tier 0 - _registerNLocal(alice, DE, 1, 3, 700); - - // Global needs to fit in BOTH locations. - // US-CA: 8 admin + 8 country = 16 at tier 0 → global tier 0 needs 4+1=5, room = 4. Doesn't fit → tier 1. - // DE-1: 3 admin at tier 0 → global tier 0 needs 4+1=5, room = 20-3 = 17. Fits at tier 0. - // Max across both = tier 1. - _registerNGlobalAt(alice, 4, 0, 800); - (uint256 tier,) = fleet.globalInclusionHint(); - assertEq(tier, 1); - } - - /// @notice Verifies that globalInclusionHint returns a tier that guarantees - /// the registrant appears in ALL active regional bundles. - function test_globalInclusionHint_guaranteesInclusionInAllRegionalBundles() public { - // --- Setup: Multiple regions at vastly different bond levels --- - // US-CA: heavy local competition at tier 0 - _registerNLocal(alice, US, ADMIN_CA, 8, 100); // fills admin tier 0 - _registerNCountry(alice, US, 8, 200); // fills country tier 0 - // FR-75: light competition at tier 0 - _registerNLocal(alice, FR, 75, 2, 300); // 2 admins - // DE-BE: moderate at tier 1 - _registerNLocal(alice, DE, 1, 4, 400); // 4 admins at tier 0 - vm.prank(alice); - fleet.registerFleetLocal(_uuid(410), DE, 1, 1); // 1 admin at tier 1 - // JP country-only at tier 0 - _registerNCountry(alice, JP, 3, 500); - // Global tier 0 is also present - _registerNGlobalAt(alice, 4, 0, 600); - - // --- Get the global hint --- - (uint256 hintTier,) = fleet.globalInclusionHint(); - - // --- Bob registers a global fleet at the hinted tier --- - vm.prank(bob); - fleet.registerFleetGlobal(_uuid(9000), hintTier); - bytes16 bobUuid = _uuid(9000); - - // --- Verify inclusion in ALL active location bundles --- - _assertUuidInBundle(US, ADMIN_CA, bobUuid, "US-CA"); - _assertUuidInBundle(FR, 75, bobUuid, "FR-75"); - _assertUuidInBundle(DE, 1, bobUuid, "DE-BE"); - _assertUuidInBundle(JP, 0, bobUuid, "JP (country-only)"); - _assertUuidInBundle(0, 0, bobUuid, "Global-only"); - } - - /// @dev Helper to assert a UUID is present in a regional bundle. - function _assertUuidInBundle(uint16 cc, uint16 admin, bytes16 uuid, string memory location) internal { - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(cc, admin); - bool found; - for (uint256 i = 0; i < count; i++) { - if (uuids[i] == uuid) { - found = true; - break; - } - } - assertTrue(found, string.concat("Global fleet missing from bundle: ", location)); - } - // --- countryInclusionHint --- function test_countryInclusionHint_emptyReturnsZero() public view { (uint256 tier, uint256 bond) = fleet.countryInclusionHint(US); assertEq(tier, 0); - assertEq(bond, BASE_BOND); + assertEq(bond, BASE_BOND * 8); // Country pays 8× multiplier } function test_countryInclusionHint_onlyCountryFleets() public { - _registerNCountry(alice, US, 8, 1000); // fills tier 0 + _registerNCountryAt(alice, US, 4, 1000, 0); // fills tier 0 (TIER_CAPACITY=4) vm.prank(bob); fleet.registerFleetCountry(_uuid(9000), US, 1); // tier 1 // Tier 0 is full → cheapest inclusion = tier 1. (uint256 tier, uint256 bond) = fleet.countryInclusionHint(US); assertEq(tier, 1); - assertEq(bond, BASE_BOND * 2); + assertEq(bond, BASE_BOND * 8 * 2); // Country pays 8× multiplier, tier 1 = 2× base } function test_countryInclusionHint_adminAreaCreatesPressure() public { @@ -1323,19 +1074,19 @@ contract FleetIdentityTest is Test { // Country fleet needs to be included in bundle(US, ADMIN_CA). // Simulation: cursor 3→0. At cursor 3: admin=1 (fits). At cursor 0: admin=0, country=1+1=2 (fits). // Country tier 0 with 2 members: 2 <= 20-1 = 19. Fits. - // So cheapest = 0 (tier 0 has room: 1/8). + // So cheapest = 0 (tier 0 has room: 1/4). (uint256 tier,) = fleet.countryInclusionHint(US); assertEq(tier, 0); } function test_countryInclusionHint_multipleAdminAreas_takesMax() public { - // US-CA: fill admin tier 0 (8) + fill country tier 0 (8) = 16 - _registerNLocal(alice, US, ADMIN_CA, 8, 0); - _registerNCountryAt(alice, US, 8, 100, 0); + // US-CA: fill admin tier 0 (4) + fill country tier 0 (4) = 8 + _registerNLocalAt(alice, US, ADMIN_CA, 4, 0, 0); + _registerNCountryAt(alice, US, 4, 100, 0); // US-NY: light (3 admin) _registerNLocal(alice, US, ADMIN_NY, 3, 200); - // Country tier 0 has 8/8 members → tier 0 is full. + // Country tier 0 has 4/4 members → tier 0 is full. // Even though the bundle has room, the tier capacity is exhausted. // So cheapest inclusion tier for a country fleet = 1. (uint256 tier,) = fleet.countryInclusionHint(US); @@ -1402,20 +1153,19 @@ contract FleetIdentityTest is Test { assertEq(count, 0); } - function test_buildBundle_singleGlobal() public { + function test_RevertIf_buildBundle_adminCodeZero() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); + fleet.registerFleetCountry(UUID_1, US, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(0, 0); - assertEq(count, 1); - assertEq(uuids[0], UUID_1); + vm.expectRevert(FleetIdentity.AdminAreaRequired.selector); + fleet.buildHighestBondedUuidBundle(US, 0); } function test_buildBundle_singleCountry() public { vm.prank(alice); fleet.registerFleetCountry(UUID_1, US, 0); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 1); assertEq(uuids[0], UUID_1); } @@ -1429,66 +1179,56 @@ contract FleetIdentityTest is Test { assertEq(uuids[0], UUID_1); } - // ── Same cursor, all levels at tier 0 ── + // ── Same cursor, both levels at tier 0 ── - function test_buildBundle_allLevelsTied_levelPriorityOrder() public { - // All at tier 0 → shared cursor 0 → level priority: admin, country, global + function test_buildBundle_bothLevelsTied_levelPriorityOrder() public { + // Both at tier 0 → shared cursor 0 → level priority: local, country vm.prank(alice); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - vm.prank(alice); - fleet.registerFleetCountry(UUID_2, US, 0); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); + fleet.registerFleetCountry(UUID_1, US, 0); (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 + assertEq(count, 2); + assertEq(uuids[0], UUID_2); // local first + assertEq(uuids[1], UUID_1); // country second } - 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); + function test_buildBundle_2LevelsTier0_fullCapacity() public { + // 4 local + 4 country at tier 0 = 8 + // Bundle fits all since max is 20 + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1000, 0); + _registerNCountryAt(alice, US, 4, 2000, 0); (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 20); + assertEq(count, 8); } - 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), 0); + function test_buildBundle_2LevelsTier0_partialFill() public { + // 3 local + 2 country = 5 + _registerNLocalAt(alice, US, ADMIN_CA, 3, 1000, 0); + _registerNCountryAt(alice, US, 2, 2000, 0); (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 6); + assertEq(count, 5); } // ── 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, 0); - // Country: promote to tier 2 (bond=4*BASE) + // Country: promote to tier 2 (bond=8*4*BASE) vm.prank(alice); - uint256 usId = fleet.registerFleetCountry(UUID_2, US, 0); + uint256 usId = fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(alice); fleet.reassignTier(usId, 2); - // Admin: tier 0 (bond=BASE) + // Local: tier 0 (bond=BASE) vm.prank(alice); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 3); - // 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 + assertEq(count, 2); + assertEq(uuids[0], UUID_1); // highest bond first (country tier 2) + assertEq(uuids[1], UUID_2); // local tier 0 } function test_buildBundle_multiTierDescendingBond() public { @@ -1498,139 +1238,119 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.reassignTier(id1, 2); - // Country tier 1 (bond=2*BASE) + // Country tier 1 (bond=8*2*BASE) vm.prank(alice); uint256 id2 = fleet.registerFleetCountry(UUID_2, US, 0); vm.prank(alice); fleet.reassignTier(id2, 1); - // Global tier 0 (bond=BASE) + // Local tier 0 (bond=BASE) vm.prank(alice); - fleet.registerFleetGlobal(UUID_3, 0); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 3); - // 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 + assertEq(uuids[0], UUID_1); // local tier 2: bond=4*BASE + assertEq(uuids[1], UUID_2); // country tier 1: bond=16*BASE (but added after local at cursor) } function test_buildBundle_multiTierMultiLevel_correctOrder() public { - // Admin: tier 0 (8 members) + tier 1 (1 member) - _registerNLocal(alice, US, ADMIN_CA, 8, 8000); + // Admin: tier 0 (4 members) + tier 1 (1 member) + _registerNLocalAt(alice, US, ADMIN_CA, 4, 8000, 0); vm.prank(alice); - fleet.registerFleetLocal(_uuid(8100), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(8100), US, ADMIN_CA, 1); - // Country: promote to tier 1 (bond=200) + // Country: promote to tier 1 (bond=8*2*BASE) vm.prank(alice); uint256 countryId = fleet.registerFleetCountry(_uuid(8200), US, 0); vm.prank(alice); fleet.reassignTier(countryId, 1); - // Global: promote to tier 2 (bond=400) + // Country: promote to tier 2 (bond=8*4*BASE) vm.prank(alice); - uint256 globalId = fleet.registerFleetGlobal(_uuid(8300), 0); + uint256 country2Id = fleet.registerFleetCountry(_uuid(8300), US, 0); vm.prank(alice); - fleet.reassignTier(globalId, 2); + fleet.reassignTier(country2Id, 2); (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 + // Cursor=2: country(1)→include. Count=1. + // Cursor=1: local(1)+country(1)→include. Count=3. + // Cursor=0: local(4)→include. Count=7. + assertEq(count, 7); + assertEq(uuids[0], fleet.tokenUuid(country2Id)); // tier 2 first } // ── All-or-nothing ── function test_buildBundle_allOrNothing_tierSkippedWhenDoesNotFit() 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++) { + // Admin tier 1: 4 members + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(5100 + i), US, ADMIN_CA, 1); } - // Country tier 1: 8 members (bond=200) - for (uint256 i = 0; i < 8; i++) { + // Country tier 1: 4 members + for (uint256 i = 0; i < 4; 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); - // Tier 0: admin(8), country(3), global(2) - _registerNLocalAt(alice, US, ADMIN_CA, 8, 5000, 0); + // Tier 0: local(4), country(3) + _registerNLocalAt(alice, US, ADMIN_CA, 4, 5000, 0); _registerNCountryAt(alice, US, 3, 6000, 0); - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(7000), 0); - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(7001), 0); (, 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); + // Cursor=1: local(4)+country(4)=8. Count=8, room=12. + // Cursor=0: local(4)≤12→include[count=12,room=8]. country(3)≤8→include[count=15,room=5]. + assertEq(count, 15); } function test_buildBundle_allOrNothing_noPartialCollection() public { - // Room=3, tier has 5 members → entire tier skipped. - // Global tier 1: 4 members (bond=200) + // Room=3, tier has 5 members → some members skipped. + // Local tier 1: 4 members for (uint256 i = 0; i < 4; i++) { - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(1000 + i), 1); - } - // 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); } - // Country tier 1: 5 members (bond=200) - for (uint256 i = 0; i < 5; i++) { + // Country tier 1: 4 members (limited by MAX_COUNTRY_IN_BUNDLE) + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetCountry(_uuid(3000 + i), US, 1); } (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - // Cursor=1: admin(8)+country(5)+global(4)=17. Count=17. Room=3. + // Cursor=1: local(4)+country(4)=8. Count=8. // Cursor=0: all empty at tier 0. Done. - assertEq(count, 17); + assertEq(count, 8); } function test_buildBundle_partialInclusion_fillsRemainingSlots() public { - // With partial inclusion: if only 3 slots remain for 4 global members, + // With partial inclusion: if only 3 slots remain for 4 country members, // we include 3 of them (first 3 by array position). - // Global tier 0: 4 members (bond=100) - _registerNGlobal(alice, 4); + // Country tier 0: 4 members (will be capped by MAX_COUNTRY_IN_BUNDLE) + _registerNCountryAt(alice, US, 4, 0, 0); - // 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++) { + // Local: 4 at tier 0 + 4 at tier 1 (TIER_CAPACITY = 4) + _registerNLocalAt(alice, US, ADMIN_CA, 4, 5000, 0); + for (uint256 i = 0; i < 4; 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); - (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 8[count=17,room=3]. global(4)>3→include 3. - // Final count=20 (bundle full). - assertEq(count, 20); + // Cursor=1: local(4)=4. Count=4, room=16. + // Cursor=0: local(4)≤16→include 4[count=8,room=12]. country(4)≤12→include 4. + // Final count=12. + assertEq(count, 12); - // Verify 3 global UUIDs ARE in the result (partial inclusion) - uint256 globalCount; + // Verify country UUIDs ARE in the result + uint256 countryCount; for (uint256 i = 0; i < count; i++) { uint256 tokenId = uint256(uint128(uuids[i])); - if (fleet.fleetRegion(tokenId) == GLOBAL) globalCount++; + uint32 region = fleet.fleetRegion(tokenId); + if (region == _regionUS()) countryCount++; } - assertEq(globalCount, 3, "3 of 4 global members included due to partial inclusion"); + assertEq(countryCount, 4, "4 country members included"); } // ── Partial inclusion (replaces all-or-nothing + fair-stop) ── @@ -1649,80 +1369,70 @@ contract FleetIdentityTest is Test { fleet.registerFleetCountry(_uuid(2000 + i), US, 1); } - // Tier 0: full capacities. - _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); - _registerNCountryAt(alice, US, 8, 4000, 0); - _registerNGlobalAt(alice, 4, 0, 0); + // Tier 0: full capacities (TIER_CAPACITY = 4). + _registerNLocalAt(alice, US, ADMIN_CA, 4, 3000, 0); + _registerNCountryAt(alice, US, 4, 4000, 0); (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - // Cursor=1: admin(3)+country(3)=6. Count=6, room=14. - // Cursor=0: admin(8)≤14→include 8[count=14,room=6]. - // country(8)>6→include 6 of 8[count=20,room=0]. Bundle full. - assertEq(count, 20); + // Cursor=1: local(3)+country(3)=6. Count=6, room=14. + // Cursor=0: local(4)≤14→include 4[count=10,room=10]. + // country(4)≤10→include 4[count=14,room=6]. + assertEq(count, 14); } - 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. + function test_buildBundle_partialFill_localAndCountry() public { + // Two local tiers consume 8 slots, leaving 12 for cursor=0. + // At cursor=0: local(4) fits. country(4) included. - for (uint256 i = 0; i < 8; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); } - for (uint256 i = 0; i < 8; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(2000 + i), US, ADMIN_CA, 2); } - // Tier 0: 8 local + 4 global - _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); - _registerNGlobalAt(alice, 4, 0, 0); + // Tier 0: 4 local + 4 country (TIER_CAPACITY = 4) + _registerNLocalAt(alice, US, ADMIN_CA, 4, 3000, 0); + _registerNCountryAt(alice, US, 4, 4000, 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); + // Cursor=2: local(4)→include. Count=4. + // Cursor=1: local(4)→include. Count=8, room=12. + // Cursor=0: local(4)≤12→include[count=12,room=8]. country(4)≤8→include[count=16,room=4]. + assertEq(count, 16); } function test_buildBundle_partialInclusion_allLevelsPartiallyIncluded() public { - // With partial inclusion, all levels get included partially if needed. - // At cursor=0: admin(3) fits fully, country(8) partially (7 fit), global(2) - no room. + // With partial inclusion, both levels get included partially if needed. - // Consume 10 slots at tier 1. - for (uint256 i = 0; i < 5; i++) { + // Consume 8 slots at tier 1. + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); } - for (uint256 i = 0; i < 5; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetCountry(_uuid(2000 + i), US, 1); } - // Tier 0: admin=3, country=8, global=2 - _registerNLocalAt(alice, US, ADMIN_CA, 3, 3000, 0); - _registerNCountryAt(alice, US, 8, 4000, 0); - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(5000), 0); - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(5001), 0); + // Tier 0: local=4, country=4 (TIER_CAPACITY = 4) + _registerNLocalAt(alice, US, ADMIN_CA, 4, 3000, 0); + _registerNCountryAt(alice, US, 4, 4000, 0); (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 3[count=13,room=7]. - // country(8)>7→include 7 of 8[count=20]. Bundle full. - assertEq(count, 20); + // Cursor=1: local(4)+country(4)=8. Count=8, room=12. + // Cursor=0: local(4)≤12→include 4[count=12,room=8]. + // country(4)≤8→include 4[count=16]. + assertEq(count, 16); - // Verify admin tier 0 is present - bool foundAdmin = false; + // Verify local tier 0 is present + bool foundLocal = false; for (uint256 i = 0; i < count; i++) { - if (uuids[i] == _uuid(3000)) foundAdmin = true; + if (uuids[i] == _uuid(3000)) foundLocal = true; } - assertTrue(foundAdmin, "admin tier 0 should be included"); + assertTrue(foundLocal, "local tier 0 should be included"); // Count how many country tier 0 members are included uint256 countryT0Count; @@ -1730,33 +1440,29 @@ contract FleetIdentityTest is Test { uint256 tokenId = uint256(uint128(uuids[i])); if (fleet.fleetRegion(tokenId) == _regionUS() && fleet.fleetTier(tokenId) == 0) countryT0Count++; } - assertEq(countryT0Count, 7, "7 of 8 country tier 0 members included"); + assertEq(countryT0Count, 4, "4 country tier 0 members included"); } - function test_buildBundle_fairStop_doesNotDescendAfterSkip() public { - // After a skip at cursor=1, cursor=0 tiers are NOT included. + function test_buildBundle_doesNotDescendAfterBundleFull() public { + // When cursor=1 fills bundle, cursor=0 tiers are NOT included. - // Tier 1: admin(8) + country(8) + global(4) = 20 → fills bundle. - for (uint256 i = 0; i < 8; i++) { + // Tier 1: local(4) + country(4) + more local(4) + more country(4) = 16 + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); } - for (uint256 i = 0; i < 8; i++) { + for (uint256 i = 0; i < 4; 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); + fleet.registerFleetLocal(_uuid(3000 + i), US, ADMIN_CA, 2); } - // Tier 0: extras that should NOT be included - vm.prank(alice); - fleet.registerFleetLocal(_uuid(4000), US, ADMIN_CA, 0); - vm.prank(alice); - fleet.registerFleetCountry(_uuid(4001), US, 0); - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(4002), 0); + // Tier 0: extras that might not all fit + _registerNLocalAt(alice, US, ADMIN_CA, 4, 4000, 0); + _registerNCountryAt(alice, US, 4, 5000, 0); (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); // Cursor=1: admin(8)+country(8)+global(4)=20. Bundle full. @@ -1764,26 +1470,22 @@ contract FleetIdentityTest is Test { } function test_buildBundle_partialInclusion_fillsAtHighTier() public { - // Cursor=2: admin(3)→include. Count=3. - // Cursor=1: admin(8)≤17→include 8[count=11,room=9]. - // country(8)≤9→include 8[count=19,room=1]. - // global(4)>1→include 1 of 4[count=20]. Bundle full. + // With TIER_CAPACITY = 4: + // Cursor=2: local(3)→include. Count=3. + // Cursor=1: local(4)+country(4)=8→include. Count=11, room=9. + // Cursor=0: local(1)≤9→include[count=12,room=8]. country(1)≤8→include[count=13,room=7]. 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++) { + for (uint256 i = 0; i < 4; 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); + fleet.registerFleetCountry(_uuid(3000 + i), US, 1); } // Tier 0 extras (would be included with more room): @@ -1791,94 +1493,87 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(_uuid(5000), US, ADMIN_CA, 0); vm.prank(alice); fleet.registerFleetCountry(_uuid(5001), US, 0); - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(5002), 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - // Bundle fills at tier 1, partial global inclusion. - assertEq(count, 20); - - // Tier 0 members NOT included (bundle full before reaching cursor=0) - for (uint256 i = 0; i < count; i++) { - 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"); - } + // Cursor=2: local(3)→include. Count=3, room=17. + // Cursor=1: local(4)+country(4)→include. Count=11, room=9. + // Cursor=0: local(1)≤9→include[count=12,room=8]. country(1)≤8→include[count=13,room=7]. + assertEq(count, 13); } function test_buildBundle_partialInclusion_higherPriorityFirst() public { // Partial inclusion fills higher-priority levels first at each tier. - // Admin gets slots before country, which gets slots before global. + // Local gets slots before country. - // Admin tier 1: 8 (bond=200), Country tier 1: 5 (bond=200) - for (uint256 i = 0; i < 8; i++) { + // Local tier 1: 4, Country tier 1: 4 + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); } - for (uint256 i = 0; i < 5; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetCountry(_uuid(2000 + i), US, 1); } - // Tier 0: admin=8, country=8 - _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); - _registerNCountryAt(alice, US, 8, 4000, 0); + // Tier 0: local=4, country=4 (TIER_CAPACITY = 4) + _registerNLocalAt(alice, US, ADMIN_CA, 4, 3000, 0); + _registerNCountryAt(alice, US, 4, 4000, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - // Cursor=1: admin(8)+country(5)=13. Count=13, room=7. - // Cursor=0: admin(8)>7→include 7 of 8[count=20]. Bundle full. - // Country tier 0 not included (bundle full). - assertEq(count, 20); + // Cursor=1: local(4)+country(4)=8. Count=8, room=12. + // Cursor=0: local(4)≤12→include 4[count=12,room=8]. country(4)≤8→include 4[count=16]. + assertEq(count, 16); - // Verify admin tier 0 partial inclusion (7 of 8) - uint256 adminT0Count; + // Verify local tier 0 full inclusion (4 of 4) + uint256 localT0Count; for (uint256 i = 0; i < count; i++) { uint256 tokenId = uint256(uint128(uuids[i])); - if (fleet.fleetRegion(tokenId) == _regionUSCA() && fleet.fleetTier(tokenId) == 0) adminT0Count++; + if (fleet.fleetRegion(tokenId) == _regionUSCA() && fleet.fleetTier(tokenId) == 0) localT0Count++; } - assertEq(adminT0Count, 7, "7 of 8 admin tier 0 included"); + assertEq(localT0Count, 4, "4 local tier 0 included"); } - // ── Tie-breaker: admin before country before global at same cursor ── + // ── Tie-breaker: local before country at same cursor ── - 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. + function test_buildBundle_tieBreaker_localBeforeCountry() public { + // Room=8 after higher tiers. Local tier 0 (4) tried before country tier 0 (4). + // Local fits (4), then country (4). - // Eat 12 room at tier 1. + // Eat 12 room at tier 1 and 2. + for (uint256 i = 0; i < 4; i++) { + vm.prank(alice); + fleet.registerFleetLocal(_uuid(1000 + i), US, ADMIN_CA, 1); + } for (uint256 i = 0; i < 4; i++) { vm.prank(alice); - fleet.registerFleetGlobal(_uuid(1000 + i), 1); + fleet.registerFleetCountry(_uuid(2000 + i), US, 1); } - for (uint256 i = 0; i < 8; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(2000 + i), US, ADMIN_CA, 1); + fleet.registerFleetLocal(_uuid(3000 + i), US, ADMIN_CA, 2); } - // Tier 0: admin=8, country=8 - _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); - _registerNCountryAt(alice, US, 8, 4000, 0); + // Tier 0: local=4, country=4 (TIER_CAPACITY = 4) + _registerNLocalAt(alice, US, ADMIN_CA, 4, 4000, 0); + _registerNCountryAt(alice, US, 4, 5000, 0); (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. + // Cursor=2: local(4)→include. Count=4, room=16. + // Cursor=1: local(4)+country(4)=8→include. Count=12, room=8. + // Cursor=0: local(4)≤8→include[count=16,room=4]. country(4)≤4→include 4[count=20,room=0]. assertEq(count, 20); - // Verify all admin+global, zero country - uint256 adminCount; - uint256 globalCount; + // Verify: local(12) + country(8) + uint256 localCount; 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++; + if (region == _regionUS()) countryCount++; + else if (region == _regionUSCA()) localCount++; } - assertEq(adminCount, 16); // tier 0 (8) + tier 1 (8) - assertEq(globalCount, 4); // tier 1 (4) - assertEq(countryCount, 0); // skipped + assertEq(localCount, 12); // tier 0 (4) + tier 1 (4) + tier 2 (4) + assertEq(countryCount, 8); // tier 1 (4) + tier 0 (4) } // ── Empty tiers and gaps ── @@ -1891,25 +1586,25 @@ contract FleetIdentityTest is Test { fleet.reassignTier(id, 2); vm.prank(alice); - fleet.registerFleetGlobal(UUID_2, 0); + fleet.registerFleetCountry(UUID_2, US, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - // Cursor=2: admin(1)→include. Count=1. + // Cursor=2: local(1)→include. Count=1. // Cursor=1: all empty. No skip. Descend. - // Cursor=0: global(1)→include. Count=2. + // Cursor=0: country(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. + // Local at tier 5, country 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, 0); + fleet.registerFleetCountry(UUID_2, US, 0); (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 2); @@ -1922,55 +1617,38 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetCountry(UUID_2, US, 2); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); 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, 0); - vm.prank(bob); - fleet.registerFleetCountry(UUID_2, US, 0); - - (, uint256 count) = fleet.buildHighestBondedUuidBundle(0, 0); - assertEq(count, 1); // only global - } - - function test_buildBundle_skipAdminWhenAdminCodeZero() public { - vm.prank(alice); - fleet.registerFleetCountry(UUID_1, US, 0); - vm.prank(bob); - fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); - - (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); - assertEq(count, 1); // only country - } + // ── Local isolation ── function test_buildBundle_multipleAdminAreas_isolated() public { - _registerNLocal(alice, US, ADMIN_CA, 5, 1000); - _registerNLocal(alice, US, ADMIN_NY, 5, 2000); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1000, 0); + _registerNLocalAt(alice, US, ADMIN_NY, 4, 2000, 0); (, uint256 countCA) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(countCA, 5); + // CA locals + any country + assertEq(countCA, 4); (, uint256 countNY) = fleet.buildHighestBondedUuidBundle(US, ADMIN_NY); - assertEq(countNY, 5); + // NY locals + any country (same country) + assertEq(countNY, 4); } // ── Single level, multiple tiers ── function test_buildBundle_singleLevelMultipleTiers() public { - // Only country, multiple tiers. - _registerNCountryAt(alice, US, 8, 1000, 0); // tier 0: 8 members (bond=BASE) - _registerNCountryAt(alice, US, 8, 2000, 1); // tier 1: 8 members (bond=2*BASE) - _registerNCountryAt(alice, US, 4, 3000, 2); // tier 2: 4 members (bond=4*BASE) + // Only country, multiple tiers. MAX_COUNTRY_IN_BUNDLE = 8, + // so only 8 of the 12 country members can be included. + _registerNCountryAt(alice, US, 4, 1000, 0); // tier 0: 4 members + _registerNCountryAt(alice, US, 4, 2000, 1); // tier 1: 4 members + _registerNCountryAt(alice, US, 4, 3000, 2); // tier 2: 4 members - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); - assertEq(count, 20); - // Verify order: tier 2 first + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + assertEq(count, 8); // capped at MAX_COUNTRY_IN_BUNDLE + // Verify order: tier 2 first (highest bond) uint256[] memory t2 = fleet.getTierMembers(_regionUS(), 2); for (uint256 i = 0; i < 4; i++) { assertEq(uuids[i], bytes16(uint128(t2[i]))); @@ -1978,25 +1656,25 @@ contract FleetIdentityTest is Test { } function test_buildBundle_singleLevelOnlyLocal() public { - _registerNLocal(alice, US, ADMIN_CA, 5, 1000); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1000, 0); (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 5); + assertEq(count, 4); } - function test_buildBundle_onlyCountryAndGlobal() public { - _registerNGlobal(alice, 4); - _registerNCountry(alice, US, 8, 1000); + function test_buildBundle_onlyCountry() public { + // TIER_CAPACITY = 4, so split across two tiers + _registerNCountryAt(alice, US, 4, 1000, 0); + _registerNCountryAt(alice, US, 4, 1100, 1); - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, 0); - assertEq(count, 12); - // Country first (level priority), then global. - assertEq(uuids[0], _uuid(1000)); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + assertEq(count, 8); + assertEq(uuids[0], _uuid(1100)); // tier 1 comes first (higher bond) } // ── 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. + // Local at tier 3, Country at tier 1. vm.prank(alice); uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(alice); @@ -2006,7 +1684,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.reassignTier(id2, 1); vm.prank(alice); - fleet.registerFleetGlobal(UUID_3, 0); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 3); @@ -2015,60 +1693,58 @@ contract FleetIdentityTest is Test { assertEq(uuids[2], UUID_3); // tier 0 } - function test_buildBundle_sharedCursor_sameTierIndex_sameBond() public view { - assertEq(fleet.tierBond(0), BASE_BOND); - assertEq(fleet.tierBond(1), BASE_BOND * 2); - assertEq(fleet.tierBond(2), BASE_BOND * 4); + function test_buildBundle_sharedCursor_sameTierIndex_differentBondByRegion() public view { + // Local tier 0 = BASE_BOND, Country tier 0 = BASE_BOND * 8 (multiplier) + assertEq(fleet.tierBond(0, _regionUSCA()), BASE_BOND); + assertEq(fleet.tierBond(0, _regionUS()), BASE_BOND * 8); + assertEq(fleet.tierBond(1, _regionUSCA()), BASE_BOND * 2); + assertEq(fleet.tierBond(1, _regionUS()), BASE_BOND * 8 * 2); } // ── Lifecycle ── function test_buildBundle_afterBurn_reflects() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetGlobal(UUID_1, 0); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(bob); - fleet.registerFleetGlobal(UUID_2, 0); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); vm.prank(carol); - fleet.registerFleetGlobal(UUID_3, 0); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); - (, uint256 countBefore) = fleet.buildHighestBondedUuidBundle(0, 0); + (, uint256 countBefore) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(countBefore, 3); vm.prank(alice); fleet.burn(id1); - (, uint256 countAfter) = fleet.buildHighestBondedUuidBundle(0, 0); + (, uint256 countAfter) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(countAfter, 2); } - function test_buildBundle_exhaustsAllLevels() public { + function test_buildBundle_exhaustsBothLevels() public { vm.prank(alice); - fleet.registerFleetGlobal(UUID_1, 0); - vm.prank(alice); - fleet.registerFleetCountry(UUID_2, US, 0); + fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(alice); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 3); + assertEq(count, 2); 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); + assertTrue(found1 && found2); } function test_buildBundle_lifecycle_promotionsAndBurns() public { vm.prank(alice); - uint256 g1 = fleet.registerFleetGlobal(_uuid(100), 0); + uint256 l1 = fleet.registerFleetLocal(_uuid(100), US, ADMIN_CA); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(101), 0); + fleet.registerFleetLocal(_uuid(101), US, ADMIN_CA); vm.prank(alice); - fleet.registerFleetGlobal(_uuid(102), 0); + fleet.registerFleetLocal(_uuid(102), US, ADMIN_CA); vm.prank(alice); uint256 c1 = fleet.registerFleetCountry(_uuid(200), US, 0); @@ -2079,19 +1755,19 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA); vm.prank(alice); - fleet.reassignTier(g1, 3); + fleet.reassignTier(l1, 3); vm.prank(alice); fleet.reassignTier(c1, 1); (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - // Cursor=3: global(1)→include. Count=1. + // Cursor=3: local(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. + // Cursor=0: local(3)+country(1)=4→include. Count=6. assertEq(count, 6); vm.prank(alice); - fleet.burn(g1); + fleet.burn(l1); (, count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 5); @@ -2100,54 +1776,55 @@ contract FleetIdentityTest is Test { // ── 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); + // Fill local: 4+4+4 = 12 in 3 tiers + _registerNLocalAt(alice, US, ADMIN_CA, 4, 0, 0); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 100, 1); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 200, 2); + // Fill country US: 4+4 = 8 in 2 tiers (TIER_CAPACITY = 4) + _registerNCountryAt(bob, US, 4, 1000, 0); + _registerNCountryAt(bob, US, 4, 1100, 1); + + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 20); } function test_buildBundle_exactlyFillsToCapacity() public { - // 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); + // 12 local + 8 country = 20 exactly, spread across tiers (TIER_CAPACITY = 4). + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1000, 0); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1100, 1); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1200, 2); + _registerNCountryAt(alice, US, 4, 2000, 0); + _registerNCountryAt(alice, US, 4, 2100, 1); (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 20); } function test_buildBundle_twentyOneMembers_partialInclusion() public { - // 21 total: admin 8 + country 8 + global 4 + 1 extra country at tier 1. + // 21 total: local 12 + country 8 + 1 extra country at tier 2. // With partial inclusion, bundle fills to 20. - _registerNLocal(alice, US, ADMIN_CA, 8, 1000); - _registerNCountry(alice, US, 8, 2000); - _registerNGlobal(alice, 4); - vm.prank(alice); - fleet.registerFleetCountry(_uuid(3000), US, 1); - - // Cursor=1: country(1)→include. Count=1, room=19. - // Cursor=0: admin(8)≤19→include 8[count=9,room=11]. - // country(8)≤11→include 8[count=17,room=3]. - // global(4)>3→include 3 of 4[count=20]. Bundle full. + // TIER_CAPACITY = 4, so spread across tiers. + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1000, 0); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1100, 1); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1200, 2); + _registerNCountryAt(alice, US, 4, 2000, 0); + _registerNCountryAt(alice, US, 4, 2100, 1); + vm.prank(alice); + fleet.registerFleetCountry(_uuid(3000), US, 2); + + // Cursor=2: local(4)+country(1)=5. Count=5, room=15. + // Cursor=1: local(4)+country(4)=8. Count=13, room=7. + // Cursor=0: local(4)≤7→include 4[count=17,room=3]. + // country(4)>3→include 3 of 4[count=20,room=0]. (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 20); + assertEq(count, 20); // caps at max bundle size } // ── Integrity ── function test_buildBundle_noDuplicateUUIDs() public { - _registerNLocal(alice, US, ADMIN_CA, 5, 1000); - _registerNCountry(bob, US, 4, 2000); - _registerNGlobal(carol, 3); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1000, 0); + _registerNCountryAt(bob, US, 4, 2000, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); for (uint256 i = 0; i < count; i++) { @@ -2158,10 +1835,10 @@ contract FleetIdentityTest is Test { } function test_buildBundle_noNonExistentUUIDs() public { - _registerNLocal(alice, US, ADMIN_CA, 3, 1000); - _registerNCountry(bob, US, 2, 2000); + _registerNLocalAt(alice, US, ADMIN_CA, 3, 1000, 0); + _registerNCountryAt(bob, US, 2, 2000, 0); vm.prank(carol); - fleet.registerFleetGlobal(UUID_1, 0); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 6); @@ -2171,47 +1848,34 @@ contract FleetIdentityTest is Test { } } - 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), 0); - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(3001), 0); + function test_buildBundle_allReturnedAreFromCorrectRegions() public { + // Verify returned UUIDs are from local or country regions. + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1000, 0); + _registerNCountryAt(alice, US, 3, 2000, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); uint256 localFound; uint256 countryFound; - uint256 globalFound; for (uint256 i = 0; i < count; i++) { 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"); + assertEq(localFound, 4, "local count"); + assertEq(countryFound, 3, "country count"); } // ── Fuzz ── - 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)); + function testFuzz_buildBundle_neverExceeds20(uint8 cCount, uint8 lCount) public { + cCount = uint8(bound(cCount, 0, 15)); + lCount = uint8(bound(lCount, 0, 15)); - for (uint256 i = 0; i < gCount; i++) { - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(30_000 + i), i / 4); - } for (uint256 i = 0; i < cCount; i++) { vm.prank(alice); - fleet.registerFleetCountry(_uuid(31_000 + i), US, i / 8); + fleet.registerFleetCountry(_uuid(31_000 + i), US, i / 4); } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); @@ -2222,18 +1886,13 @@ contract FleetIdentityTest is Test { 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)); + function testFuzz_buildBundle_noDuplicates(uint8 cCount, uint8 lCount) public { + cCount = uint8(bound(cCount, 0, 12)); + lCount = uint8(bound(lCount, 0, 12)); - for (uint256 i = 0; i < gCount; i++) { - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(40_000 + i), i / 4); - } for (uint256 i = 0; i < cCount; i++) { vm.prank(alice); - fleet.registerFleetCountry(_uuid(41_000 + i), US, i / 8); + fleet.registerFleetCountry(_uuid(41_000 + i), US, i / 4); } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); @@ -2248,18 +1907,13 @@ contract FleetIdentityTest is Test { } } - 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)); + function testFuzz_buildBundle_allReturnedUUIDsExist(uint8 cCount, uint8 lCount) public { + cCount = uint8(bound(cCount, 0, 12)); + lCount = uint8(bound(lCount, 0, 12)); - for (uint256 i = 0; i < gCount; i++) { - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(50_000 + i), i / 4); - } for (uint256 i = 0; i < cCount; i++) { vm.prank(alice); - fleet.registerFleetCountry(_uuid(51_000 + i), US, i / 8); + fleet.registerFleetCountry(_uuid(51_000 + i), US, i / 4); } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); @@ -2273,18 +1927,13 @@ contract FleetIdentityTest is Test { } } - function testFuzz_buildBundle_partialInclusionInvariant(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)); + function testFuzz_buildBundle_partialInclusionInvariant(uint8 cCount, uint8 lCount) public { + cCount = uint8(bound(cCount, 0, 12)); + lCount = uint8(bound(lCount, 0, 12)); - for (uint256 i = 0; i < gCount; i++) { - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(60_000 + i), i / 4); - } for (uint256 i = 0; i < cCount; i++) { vm.prank(alice); - fleet.registerFleetCountry(_uuid(61_000 + i), US, i / 8); + fleet.registerFleetCountry(_uuid(61_000 + i), US, i / 4); } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); @@ -2338,10 +1987,10 @@ contract FleetIdentityTest is Test { /// @notice When all 24 tiers of a region are full, localInclusionHint should revert. function test_RevertIf_localInclusionHint_allTiersFull() public { - // Fill all 24 tiers of US/ADMIN_CA (8 members each = 192 fleets) - // LOCAL_TIER_CAPACITY = 8, MAX_TIERS = 24 + // Fill all 24 tiers of US/ADMIN_CA (4 members each = 96 fleets) + // TIER_CAPACITY = 4, MAX_TIERS = 24 for (uint256 tier = 0; tier < 24; tier++) { - for (uint256 i = 0; i < 8; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(tier * 100 + i), US, ADMIN_CA, tier); } @@ -2349,7 +1998,7 @@ contract FleetIdentityTest is Test { // Verify all tiers are full for (uint256 tier = 0; tier < 24; tier++) { - assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), tier), 8); + assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), tier), 4); } // localInclusionHint should revert @@ -2359,9 +2008,9 @@ contract FleetIdentityTest is Test { /// @notice When all tiers are full, auto-assign registerFleetLocal should revert. function test_RevertIf_registerFleetLocal_autoAssign_allTiersFull() public { - // Fill all 24 tiers + // Fill all 24 tiers (TIER_CAPACITY = 4) for (uint256 tier = 0; tier < 24; tier++) { - for (uint256 i = 0; i < 8; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(tier * 100 + i), US, ADMIN_CA, tier); } @@ -2375,9 +2024,9 @@ contract FleetIdentityTest is Test { /// @notice countryInclusionHint reverts when all tiers in the country region are full. function test_RevertIf_countryInclusionHint_allTiersFull() public { - // Fill all 24 tiers of country US (8 members each) + // Fill all 24 tiers of country US (4 members each, TIER_CAPACITY = 4) for (uint256 tier = 0; tier < 24; tier++) { - for (uint256 i = 0; i < 8; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetCountry(_uuid(tier * 100 + i), US, tier); } @@ -2387,48 +2036,33 @@ contract FleetIdentityTest is Test { fleet.countryInclusionHint(US); } - /// @notice globalInclusionHint reverts when all tiers in global are full. - function test_RevertIf_globalInclusionHint_allTiersFull() public { - // Fill all 24 tiers of global (4 members each = 96 fleets) - for (uint256 tier = 0; tier < 24; tier++) { - for (uint256 i = 0; i < 4; i++) { - vm.prank(alice); - fleet.registerFleetGlobal(_uuid(tier * 100 + i), tier); - } - } - - vm.expectRevert(FleetIdentity.MaxTiersReached.selector); - fleet.globalInclusionHint(); - } - /// @notice Proves cheapest inclusion tier can be ABOVE maxTierIndex when bundle is /// constrained by higher-priority levels at existing tiers. /// /// Scenario: - /// - Global has 4 fleets at tier 2 (the maxTierIndex) - /// - Country US has 8 fleets at tier 2 - /// - Admin US/CA has 8 fleets at tier 2 - /// - Total at tier 2: 4 + 8 + 8 = 20 (fills bundle completely) - /// - Admin tier 2 is FULL (8 members = capacity), so a new fleet cannot join tier 2. + /// - Fill admin tiers 0, 1, 2 with 4 members each (full) + /// - Country US has 4 fleets at tier 2 (maxTierIndex) + /// - Admin tier 0-2 are FULL (4 members each), so a new fleet cannot join any. /// - Cheapest inclusion should be tier 3 (above maxTierIndex=2). function test_cheapestInclusionTier_aboveMaxTierIndex() public { - // Fill bundle at tier 2: global=4, country=8, admin=8 = 20 - _registerNGlobalAt(alice, 4, 2, 5000); - _registerNCountryAt(alice, US, 8, 6000, 2); - _registerNLocalAt(alice, US, ADMIN_CA, 8, 7000, 2); - - // Verify tier 2 is maxTierIndex (all regions have tierCount = 3, highest = tier 2) - assertEq(fleet.regionTierCount(GLOBAL), 3); + // Fill admin tiers 0, 1, 2 with 4 members each (TIER_CAPACITY = 4) + _registerNLocalAt(alice, US, ADMIN_CA, 4, 4000, 0); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 5000, 1); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 6000, 2); + // Country at tier 2 (sets maxTierIndex across regions) + _registerNCountryAt(alice, US, 4, 7000, 2); + + // Verify tier 2 is maxTierIndex assertEq(fleet.regionTierCount(fleet.countryRegionKey(US)), 3); assertEq(fleet.regionTierCount(fleet.adminRegionKey(US, ADMIN_CA)), 3); - // Admin tier 2 is full (8 members = LOCAL_TIER_CAPACITY) - assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 2), 8); + // All admin tiers 0-2 are full (4 members each = TIER_CAPACITY) + assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 0), 4); + assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 1), 4); + assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 2), 4); - // With partial inclusion: - // - At tier 0,1: countBefore = 20 (bundle full from tier 2), no room. - // - At tier 2: tier is full (8 members = cap), cannot join. - // - At tier 3: above maxTierIndex, countBefore = 0, has room. + // At tiers 0-2: all tiers are full (4 members = cap), cannot join. + // At tier 3: above maxTierIndex, countBefore = 0, has room. (uint256 inclusionTier, uint256 bond) = fleet.localInclusionHint(US, ADMIN_CA); assertEq(inclusionTier, 3, "Should recommend tier 3 (above maxTierIndex=2)"); assertEq(bond, BASE_BOND * 8); // tier 3 bond = BASE_BOND * 2^3 @@ -2439,32 +2073,26 @@ contract FleetIdentityTest is Test { assertEq(fleet.fleetTier(tokenId), 3); // Confirm new fleet appears in bundle at the TOP (first position) - // With partial inclusion: - // - Cursor=3: admin(1). count=1. - // - Cursor=2: admin(8)+country(8)=16. count=17, room=3. global(4)>3 → include 3. - // - count=20, bundle full. (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 20, "Partial inclusion: tier 3 (1) + tier 2 admin (8) + country (8) + 3 global = 20"); + // tier 3 (1) + tier 2 admin (4) + tier 2 country (4) + tier 1 admin (4) + tier 0 admin (4) = 17 + // But capped at 12 local + 8 country = 20 max. We have 13 local + 4 country = 17. + assertEq(count, 17, "tier 3 (1) + tier 2 admin (4) + country (4) + tier 1 admin (4) + tier 0 admin (4) = 17"); assertEq(uuids[0], _uuid(9999), "Tier 3 fleet should be first in bundle"); } /// @notice Edge case: bundle is full from tier maxTierIndex, and all tiers 0..maxTierIndex /// at the candidate region are also full. The cheapest tier is above maxTierIndex. function test_cheapestInclusionTier_aboveMaxTierIndex_candidateTiersFull() public { - // Global tier 0 has 4 fleets - _registerNGlobalAt(alice, 4, 0, 1000); - - // Country tier 0 has 8 fleets → bundle at tier 0 = 4+8+8=20 once admin fills - _registerNCountryAt(alice, US, 8, 2000, 0); + // Country tier 0 has 4 fleets + _registerNCountryAt(alice, US, 4, 1000, 0); - // Admin tier 0 has 8 fleets (full) - _registerNLocalAt(alice, US, ADMIN_CA, 8, 3000, 0); + // Admin tier 0 has 4 fleets (full) + _registerNLocalAt(alice, US, ADMIN_CA, 4, 2000, 0); // Verify admin tier 0 is full - assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 0), 8); + assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 0), 4); - // At tier 0: 4+8+8=20 → bundle full at cursor 0 - // Admin tier 0 is full (8 members), so candidate must go elsewhere. + // Admin tier 0 is full (4 members = TIER_CAPACITY), so candidate must go elsewhere. // Cheapest inclusion tier should be 1 (above maxTierIndex=0). (uint256 inclusionTier,) = fleet.localInclusionHint(US, ADMIN_CA); assertEq(inclusionTier, 1, "Should recommend tier 1 since tier 0 is full"); @@ -2481,9 +2109,9 @@ contract FleetIdentityTest is Test { /// /// Simpler approach: Fill all 24 admin tiers AND make bundle full at every tier. function test_RevertIf_cheapestInclusionTier_exceedsMaxTiers() public { - // Fill all 24 tiers of admin area US/CA with 8 members each + // Fill all 24 tiers of admin area US/CA with 4 members each (TIER_CAPACITY = 4) for (uint256 tier = 0; tier < 24; tier++) { - for (uint256 i = 0; i < 8; i++) { + for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetLocal(_uuid(tier * 100 + i), US, ADMIN_CA, tier); } @@ -2499,36 +2127,37 @@ contract FleetIdentityTest is Test { /// lower-tier inclusion, the hint correctly identifies the cheapest viable tier. function test_cheapestInclusionTier_bundleFullFromHigherTiers() public { // Create a scenario where: - // - Global tier 5 has 4 members - // - Country tier 5 has 8 members - // - Admin tier 5 has 8 members - // Total = 20 at tier 5 → bundle full immediately + // - Admin tiers 0-5 are all full (4 each = TIER_CAPACITY) + // - Country tier 5 has 4 members + // Total at tier 5: 4 country + 4 admin = 8 + // All admin tiers 0-5 are full, so must go to tier 6. - _registerNGlobalAt(alice, 4, 5, 10000); - _registerNCountryAt(alice, US, 8, 11000, 5); - _registerNLocalAt(alice, US, ADMIN_CA, 8, 12000, 5); + // Fill admin tiers 0-5 with 4 members each + for (uint256 tier = 0; tier <= 5; tier++) { + _registerNLocalAt(alice, US, ADMIN_CA, 4, 10000 + tier * 100, tier); + } + // Country at tier 5 + _registerNCountryAt(alice, US, 4, 11000, 5); // maxTierIndex = 5 - // With partial inclusion: - // - At tiers 0-4: countBefore = 20 (from tier 5). No room. - // - At tier 5: tier full (8 = capacity). Cannot join. - // - At tier 6: above maxTierIndex, countBefore = 0. Has room. + // All admin tiers 0-5 are full (4 = capacity). Cannot join any. + // At tier 6: above maxTierIndex, countBefore = 0. Has room. (uint256 inclusionTier,) = fleet.localInclusionHint(US, ADMIN_CA); assertEq(inclusionTier, 6, "Must go above maxTierIndex=5 to tier 6"); } /// @notice Verifies the bundle correctly includes a fleet registered above maxTierIndex. function test_buildBundle_includesFleetAboveMaxTierIndex() public { - // Only global tier 0 has fleets (maxTierIndex = 0) - _registerNGlobalAt(alice, 4, 0, 20000); + // Only country tier 0 has fleets (maxTierIndex = 0) + _registerNCountryAt(alice, US, 4, 20000, 0); // New admin registers at tier 2 (above maxTierIndex) vm.prank(bob); uint256 adminToken = fleet.registerFleetLocal(_uuid(21000), US, ADMIN_CA, 2); - // Bundle should include admin tier 2 first (highest), then global tier 0 + // Bundle should include admin tier 2 first (highest), then country tier 0 (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 5, "Admin tier 2 (1) + Global tier 0 (4) = 5"); + assertEq(count, 5, "Admin tier 2 (1) + Country tier 0 (4) = 5"); // First should be admin tier 2 assertEq(uint256(uint128(uuids[0])), adminToken, "Admin tier 2 fleet should be first"); @@ -2541,42 +2170,39 @@ contract FleetIdentityTest is Test { /// @notice DEMONSTRATES that partial inclusion prevents the scenario where a single /// fleet registration could push an entire tier out of the bundle. /// - /// Scenario: + /// Scenario (2-level system: country + local): /// BEFORE: - /// - Admin tier 0: 8 members - /// - Country tier 0: 8 members - /// - Global tier 0: 4 members - /// - Bundle: all 20 members included (8+8+4=20) + /// - Admin tier 0: 4 members + /// - Country tier 0: 4 members + /// - Bundle: all 8 members included (4+4=8) /// /// AFTER (single admin tier 1 registration): /// - Admin tier 1: 1 member (NEW - above previous maxTierIndex) /// - With PARTIAL INCLUSION: /// - Tier 1: admin(1) → count=1 - /// - Tier 0: admin(8) fits (room=19) → count=9 - /// country(8) fits (room=11) → count=17 - /// global(4) > room(3) → include 3 of 4, count=20 - /// - Final bundle: 20 members (full) - /// - 3 of 4 global tier 0 members STILL INCLUDED! + /// - Tier 0: admin(4) + country(4) = 8, count=9 + /// - Final bundle: 9 members (all fit) /// - /// Result: Only 1 global fleet displaced (the last by array position), not all 4. + /// Result: All original fleets remain included. function test_DEMO_partialInclusionPreventsFullDisplacement() public { // === BEFORE STATE === - // Fill bundle exactly: admin(8) + country(8) + global(4) = 20 - _registerNLocalAt(alice, US, ADMIN_CA, 8, 30000, 0); // Admin tier 0: 8 - _registerNCountryAt(alice, US, 8, 31000, 0); // Country tier 0: 8 - uint256[] memory globalIds = _registerNGlobalAt(alice, 4, 0, 32000); // Global tier 0: 4 + uint32 countryRegion = fleet.countryRegionKey(US); + + // Fill with admin(4) + country(4) = 8 + uint256[] memory localIds = _registerNLocalAt(alice, US, ADMIN_CA, 4, 30000, 0); // Admin tier 0: 4 + uint256[] memory countryIds = _registerNCountryAt(alice, US, 4, 31000, 0); // Country tier 0: 4 - // Verify BEFORE: all 20 members in bundle + // Verify BEFORE: all 8 members in bundle (bytes16[] memory uuidsBefore, uint256 countBefore) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(countBefore, 20, "BEFORE: All 20 members should be in bundle"); + assertEq(countBefore, 8, "BEFORE: All 8 members should be in bundle"); - // Verify all 4 global fleets are included BEFORE - uint256 globalCountBefore; + // Verify all 4 country fleets are included BEFORE + uint256 countryCountBefore; for (uint256 i = 0; i < countBefore; i++) { uint256 tokenId = uint256(uint128(uuidsBefore[i])); - if (fleet.fleetRegion(tokenId) == GLOBAL) globalCountBefore++; + if (fleet.fleetRegion(tokenId) == countryRegion) countryCountBefore++; } - assertEq(globalCountBefore, 4, "BEFORE: All 4 global fleets in bundle"); + assertEq(countryCountBefore, 4, "BEFORE: All 4 country fleets in bundle"); // === SINGLE REGISTRATION === // Bob registers just ONE fleet at admin tier 1 @@ -2586,35 +2212,34 @@ contract FleetIdentityTest is Test { // === AFTER STATE === (bytes16[] memory uuidsAfter, uint256 countAfter) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - // Bundle STILL has 20 members (partial inclusion keeps bundle full) - assertEq(countAfter, 20, "AFTER: Bundle should still have 20 members"); + // Bundle now has 9 members (tier 1: 1 + tier 0: 4+4) + assertEq(countAfter, 9, "AFTER: Bundle should have 9 members"); - // Count how many global fleets are included AFTER - uint256 globalCountAfter; + // Count how many country fleets are included AFTER + uint256 countryCountAfter; for (uint256 i = 0; i < countAfter; i++) { uint256 tokenId = uint256(uint128(uuidsAfter[i])); - if (fleet.fleetRegion(tokenId) == GLOBAL) globalCountAfter++; + if (fleet.fleetRegion(tokenId) == countryRegion) countryCountAfter++; } - assertEq(globalCountAfter, 3, "AFTER: 3 of 4 global fleets still in bundle"); + assertEq(countryCountAfter, 4, "AFTER: All 4 country fleets still in bundle"); - // Verify the FIRST 3 global fleets (by array position) are included - bool[] memory globalIncluded = new bool[](4); + // Verify all country fleets are still included + bool[] memory countryIncluded = new bool[](4); for (uint256 i = 0; i < countAfter; i++) { uint256 tokenId = uint256(uint128(uuidsAfter[i])); - for (uint256 g = 0; g < 4; g++) { - if (tokenId == globalIds[g]) globalIncluded[g] = true; + for (uint256 c = 0; c < 4; c++) { + if (tokenId == countryIds[c]) countryIncluded[c] = true; } } - // First 3 should be included, 4th excluded (array position ordering) - assertTrue(globalIncluded[0], "First global fleet included"); - assertTrue(globalIncluded[1], "Second global fleet included"); - assertTrue(globalIncluded[2], "Third global fleet included"); - assertFalse(globalIncluded[3], "Fourth global fleet excluded (partial inclusion limit)"); + assertTrue(countryIncluded[0], "First country fleet included"); + assertTrue(countryIncluded[1], "Second country fleet included"); + assertTrue(countryIncluded[2], "Third country fleet included"); + assertTrue(countryIncluded[3], "Fourth country fleet included"); // === IMPROVEMENT SUMMARY === emit log_string("=== PARTIAL INCLUSION FIX DEMONSTRATED ==="); - emit log_string("A single tier-1 registration now displaces only 1 global fleet, not 4"); - emit log_named_uint("Global fleets displaced", 1); - emit log_named_uint("Global fleets still included", 3); + emit log_string("A single tier-1 registration does not displace any country fleets"); + emit log_named_uint("Country fleets displaced", 0); + emit log_named_uint("Country fleets still included", 4); } } diff --git a/test/SwarmRegistryL1.t.sol b/test/SwarmRegistryL1.t.sol index ed26ff3b..b11f3a07 100644 --- a/test/SwarmRegistryL1.t.sol +++ b/test/SwarmRegistryL1.t.sol @@ -27,6 +27,10 @@ contract SwarmRegistryL1Test is Test { uint256 constant FLEET_BOND = 100 ether; + // Region constants for fleet registration + uint16 constant US = 840; + uint16 constant ADMIN_CA = 6; // California + 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); @@ -52,7 +56,7 @@ contract SwarmRegistryL1Test is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleetGlobal(bytes16(keccak256(seed)), 0); + return fleetContract.registerFleetLocal(bytes16(keccak256(seed)), US, ADMIN_CA); } function _registerProvider(address owner, string memory url) internal returns (uint256) { diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol index 9c544e2a..58932a4d 100644 --- a/test/SwarmRegistryUniversal.t.sol +++ b/test/SwarmRegistryUniversal.t.sol @@ -27,6 +27,10 @@ contract SwarmRegistryUniversalTest is Test { uint256 constant FLEET_BOND = 100 ether; + // Region constants for fleet registration + uint16 constant US = 840; + uint16 constant ADMIN_CA = 6; // California + event SwarmRegistered( uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner, uint32 filterSize ); @@ -54,7 +58,7 @@ contract SwarmRegistryUniversalTest is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleetGlobal(bytes16(keccak256(seed)), 0); + return fleetContract.registerFleetLocal(bytes16(keccak256(seed)), US, ADMIN_CA); } function _registerProvider(address owner, string memory url) internal returns (uint256) { From 00c8a8b85fd01a3ae005e1edc0a9b359b62750d9 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 16:47:49 +1300 Subject: [PATCH 27/63] remove public caller for tier cap --- test/FleetIdentity.t.sol | 4 ---- 1 file changed, 4 deletions(-) diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 08e83714..6e858556 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -177,10 +177,6 @@ contract FleetIdentityTest is Test { assertEq(fleet.MAX_COUNTRY_IN_BUNDLE(), 8); } - function test_tierCapacity() public view { - assertEq(fleet.tierCapacity(), 4); - } - // --- tierBond --- function test_tierBond_local_tier0() public view { From 45e6afba57949f4f10da1b8c39454045aa17fbb0 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 16:49:42 +1300 Subject: [PATCH 28/63] remove public caller for tier cap --- src/swarms/FleetIdentity.sol | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 0072d844..4d7833e8 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -297,11 +297,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return base; // Local (admin area) } - /// @notice Returns the unified tier capacity (4 for all levels). - function tierCapacity() public pure returns (uint256) { - return TIER_CAPACITY; - } - /// @notice Returns the cheapest tier that guarantees a **local** fleet /// appears in `buildHighestBondedUuidBundle` for (countryCode, adminCode). /// Bounded: O(MAX_TIERS). @@ -550,7 +545,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 currentTier = fleetTier[tokenId]; if (targetTier <= currentTier) revert TargetTierNotHigher(); if (targetTier >= MAX_TIERS) revert MaxTiersReached(); - if (_regionTierMembers[region][targetTier].length >= tierCapacity()) revert TierFull(); + if (_regionTierMembers[region][targetTier].length >= TIER_CAPACITY) revert TierFull(); uint256 currentBond = tierBond(currentTier, region); uint256 targetBond = tierBond(targetTier, region); @@ -581,7 +576,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint32 region = fleetRegion[tokenId]; uint256 currentTier = fleetTier[tokenId]; if (targetTier >= currentTier) revert TargetTierNotLower(); - if (_regionTierMembers[region][targetTier].length >= tierCapacity()) revert TierFull(); + if (_regionTierMembers[region][targetTier].length >= TIER_CAPACITY) revert TierFull(); uint256 currentBond = tierBond(currentTier, region); uint256 targetBond = tierBond(targetTier, region); @@ -605,7 +600,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @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()) revert TierFull(); + if (_regionTierMembers[region][targetTier].length >= TIER_CAPACITY) revert TierFull(); if (targetTier >= regionTierCount[region]) { regionTierCount[region] = targetTier + 1; } @@ -665,7 +660,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 maxTierIndex = _findMaxTierIndex(keys, active); uint32 candidateRegion = keys[candidateLevel]; - uint256 cap = tierCapacity(); + uint256 cap = TIER_CAPACITY; // Find cheapest tier T at candidateRegion. uint256 candidateTierCount = regionTierCount[candidateRegion]; From 07dedc1cfa12cee497934c7c203937c338e46459 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 17:05:03 +1300 Subject: [PATCH 29/63] register same uuid for different regions --- src/swarms/FleetIdentity.sol | 26 ++++++++++-- test/FleetIdentity.t.sol | 79 ++++++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 4d7833e8..a015d3ce 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -40,7 +40,16 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * On-chain indexes track which countries and admin areas have active fleets, * enabling EdgeBeaconScanner enumeration without off-chain indexers. * - * TokenID = uint256(uint128(uuid)), guaranteeing one owner per Proximity UUID. + * **TokenID Encoding** + * + * TokenID = (regionKey << 128) | uuid + * - Bits 0-127: UUID (bytes16 Proximity UUID) + * - Bits 128-159: Region key (32-bit country or admin-area code) + * + * This allows the same UUID to be registered in multiple regions, + * each with a distinct token. Region and UUID can be extracted: + * - uuid = bytes16(uint128(tokenId)) + * - region = uint32(tokenId >> 128) */ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { using SafeERC20 for IERC20; @@ -360,11 +369,21 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } - /// @notice UUID for a token ID. + /// @notice UUID for a token ID (extracts lower 128 bits). function tokenUuid(uint256 tokenId) external pure returns (bytes16) { return bytes16(uint128(tokenId)); } + /// @notice Region key encoded in a token ID (extracts bits 128-159). + function tokenRegion(uint256 tokenId) external pure returns (uint32) { + return uint32(tokenId >> 128); + } + + /// @notice Computes the deterministic token ID for a uuid+region pair. + function computeTokenId(bytes16 uuid, uint32 regionKey) public pure returns (uint256) { + return (uint256(regionKey) << 128) | uint256(uint128(uuid)); + } + /// @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; @@ -518,7 +537,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Shared registration logic. function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { uint256 bond = tierBond(tier, region); - tokenId = uint256(uint128(uuid)); + // TokenID encodes both region and uuid: (region << 128) | uuid + tokenId = (uint256(region) << 128) | uint256(uint128(uuid)); // Effects fleetRegion[tokenId] = region; diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 6e858556..62a1f703 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -96,6 +96,22 @@ contract FleetIdentityTest is Test { // --- Helpers --- + /// @dev Compute tokenId from (uuid, region) using new encoding + function _tokenId(bytes16 uuid, uint32 region) internal pure returns (uint256) { + return (uint256(region) << 128) | uint256(uint128(uuid)); + } + + /// @dev Given a UUID from buildBundle, find tokenId by checking local first, then country + function _findTokenId(bytes16 uuid, uint16 cc, uint16 admin) internal view returns (uint256) { + uint32 localRegion = (uint32(cc) << 12) | uint32(admin); + uint256 localTokenId = _tokenId(uuid, localRegion); + if (fleet.fleetRegion(localTokenId) == localRegion) { + return localTokenId; + } + uint32 countryRegion = uint32(cc); + return _tokenId(uuid, countryRegion); + } + function _uuid(uint256 i) internal pure returns (bytes16) { return bytes16(keccak256(abi.encodePacked("fleet-", i))); } @@ -574,17 +590,36 @@ contract FleetIdentityTest is Test { assertEq(fleet.regionTierCount(_regionUS()), 0); } - function test_burn_allowsReregistration() public { + function test_burn_allowsReregistration_sameRegion() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(alice); fleet.burn(tokenId); + // Same UUID can be re-registered in same region, same tokenId vm.prank(bob); - uint256 newId = fleet.registerFleetCountry(UUID_1, DE, 0); + uint256 newId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); assertEq(newId, tokenId); - assertEq(fleet.fleetRegion(newId), _regionDE()); + assertEq(fleet.fleetRegion(newId), _regionUSCA()); + } + + function test_multiRegion_sameUuidCanRegisterInDifferentRegions() public { + // Same UUID can be registered in multiple regions simultaneously + vm.prank(alice); + uint256 localId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + vm.prank(bob); + uint256 countryId = fleet.registerFleetCountry(UUID_1, DE, 0); + + // Different tokenIds for different regions + assertTrue(localId != countryId, "Different regions should have different tokenIds"); + + // Both have same UUID but different regions + assertEq(fleet.tokenUuid(localId), UUID_1); + assertEq(fleet.tokenUuid(countryId), UUID_1); + assertEq(fleet.fleetRegion(localId), _regionUSCA()); + assertEq(fleet.fleetRegion(countryId), _regionDE()); } function test_RevertIf_burn_notOwner() public { @@ -1126,7 +1161,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); fleet.registerFleetCountry(_uuid(2000), US, inclusionTier); - uint256 tokenId = uint256(uint128(_uuid(2000))); + uint256 tokenId = _tokenId(_uuid(2000), _regionUS()); assertEq(fleet.fleetTier(tokenId), inclusionTier); assertEq(fleet.bonds(tokenId), hintBond); @@ -1342,7 +1377,7 @@ contract FleetIdentityTest is Test { // Verify country UUIDs ARE in the result uint256 countryCount; for (uint256 i = 0; i < count; i++) { - uint256 tokenId = uint256(uint128(uuids[i])); + uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); uint32 region = fleet.fleetRegion(tokenId); if (region == _regionUS()) countryCount++; } @@ -1433,7 +1468,7 @@ contract FleetIdentityTest is Test { // Count how many country tier 0 members are included uint256 countryT0Count; for (uint256 i = 0; i < count; i++) { - uint256 tokenId = uint256(uint128(uuids[i])); + uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); if (fleet.fleetRegion(tokenId) == _regionUS() && fleet.fleetTier(tokenId) == 0) countryT0Count++; } assertEq(countryT0Count, 4, "4 country tier 0 members included"); @@ -1523,7 +1558,7 @@ contract FleetIdentityTest is Test { // Verify local tier 0 full inclusion (4 of 4) uint256 localT0Count; for (uint256 i = 0; i < count; i++) { - uint256 tokenId = uint256(uint128(uuids[i])); + uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); if (fleet.fleetRegion(tokenId) == _regionUSCA() && fleet.fleetTier(tokenId) == 0) localT0Count++; } assertEq(localT0Count, 4, "4 local tier 0 included"); @@ -1563,7 +1598,7 @@ contract FleetIdentityTest is Test { uint256 localCount; uint256 countryCount; for (uint256 i = 0; i < count; i++) { - uint256 tokenId = uint256(uint128(uuids[i])); + uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); uint32 region = fleet.fleetRegion(tokenId); if (region == _regionUS()) countryCount++; else if (region == _regionUSCA()) localCount++; @@ -1839,7 +1874,7 @@ 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])); + uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); assertTrue(fleet.ownerOf(tokenId) != address(0)); } } @@ -1854,7 +1889,7 @@ contract FleetIdentityTest is Test { uint256 localFound; uint256 countryFound; for (uint256 i = 0; i < count; i++) { - uint256 tid = uint256(uint128(uuids[i])); + uint256 tid = _findTokenId(uuids[i], US, ADMIN_CA); uint32 region = fleet.fleetRegion(tid); if (region == _regionUSCA()) localFound++; else if (region == _regionUS()) countryFound++; @@ -1918,7 +1953,7 @@ contract FleetIdentityTest is Test { (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); for (uint256 i = 0; i < count; i++) { - uint256 tokenId = uint256(uint128(uuids[i])); + uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); assertTrue(fleet.ownerOf(tokenId) != address(0), "Fuzz: UUID does not exist"); } } @@ -1936,20 +1971,20 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(_uuid(62_000 + i), US, ADMIN_CA); } - (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + (bytes16[] memory uuids2, uint256 count2) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); // With partial inclusion: for each (region, tier) group in the bundle, // the included members should be a PREFIX of the full tier (registration order). // We verify this by checking that included members are the first N in the tier's array. - for (uint256 i = 0; i < count; i++) { - uint256 tid = uint256(uint128(uuids[i])); + for (uint256 i = 0; i < count2; i++) { + uint256 tid = _findTokenId(uuids2[i], US, ADMIN_CA); uint32 region = fleet.fleetRegion(tid); uint256 tier = fleet.fleetTier(tid); // Count how many from this (region, tier) are in the bundle uint256 inBundle; - for (uint256 j = 0; j < count; j++) { - uint256 tjd = uint256(uint128(uuids[j])); + for (uint256 j = 0; j < count2; j++) { + uint256 tjd = _findTokenId(uuids2[j], US, ADMIN_CA); if (fleet.fleetRegion(tjd) == region && fleet.fleetTier(tjd) == tier) { inBundle++; } @@ -1966,8 +2001,8 @@ contract FleetIdentityTest is Test { uint256 found; for (uint256 m = 0; m < inBundle && m < tierMembers.length; m++) { bytes16 expectedUuid = bytes16(uint128(tierMembers[m])); - for (uint256 j = 0; j < count; j++) { - if (uuids[j] == expectedUuid) { + for (uint256 j = 0; j < count2; j++) { + if (uuids2[j] == expectedUuid) { found++; break; } @@ -2156,7 +2191,7 @@ contract FleetIdentityTest is Test { assertEq(count, 5, "Admin tier 2 (1) + Country tier 0 (4) = 5"); // First should be admin tier 2 - assertEq(uint256(uint128(uuids[0])), adminToken, "Admin tier 2 fleet should be first"); + assertEq(_tokenId(uuids[0], _regionUSCA()), adminToken, "Admin tier 2 fleet should be first"); } // ══════════════════════════════════════════════════════════════════════════════════ @@ -2195,7 +2230,7 @@ contract FleetIdentityTest is Test { // Verify all 4 country fleets are included BEFORE uint256 countryCountBefore; for (uint256 i = 0; i < countBefore; i++) { - uint256 tokenId = uint256(uint128(uuidsBefore[i])); + uint256 tokenId = _findTokenId(uuidsBefore[i], US, ADMIN_CA); if (fleet.fleetRegion(tokenId) == countryRegion) countryCountBefore++; } assertEq(countryCountBefore, 4, "BEFORE: All 4 country fleets in bundle"); @@ -2214,7 +2249,7 @@ contract FleetIdentityTest is Test { // Count how many country fleets are included AFTER uint256 countryCountAfter; for (uint256 i = 0; i < countAfter; i++) { - uint256 tokenId = uint256(uint128(uuidsAfter[i])); + uint256 tokenId = _findTokenId(uuidsAfter[i], US, ADMIN_CA); if (fleet.fleetRegion(tokenId) == countryRegion) countryCountAfter++; } assertEq(countryCountAfter, 4, "AFTER: All 4 country fleets still in bundle"); @@ -2222,7 +2257,7 @@ contract FleetIdentityTest is Test { // Verify all country fleets are still included bool[] memory countryIncluded = new bool[](4); for (uint256 i = 0; i < countAfter; i++) { - uint256 tokenId = uint256(uint128(uuidsAfter[i])); + uint256 tokenId = _findTokenId(uuidsAfter[i], US, ADMIN_CA); for (uint256 c = 0; c < 4; c++) { if (tokenId == countryIds[c]) countryIncluded[c] = true; } From 050564aa2ba24bf43102e9ec43f8a04a7596798a Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 18:41:26 +1300 Subject: [PATCH 30/63] feat(FleetIdentity): enforce UUID ownership across multi-region registrations --- --data | 1 + --header | 1 + --url | 1 + logs/deploy_l1_bridge.log | 32 +++++ logs/deploy_l1_nodl.log | 29 ++++ logs/deploy_l2_bridge.log | 98 ++++++++++++++ src/swarms/FleetIdentity.sol | 36 +++++ test/FleetIdentity.t.sol | 255 ++++++++++++++++++++++++++++++++++- typescript | 3 + 9 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 --data create mode 100644 --header create mode 100644 --url create mode 100644 logs/deploy_l1_bridge.log create mode 100644 logs/deploy_l1_nodl.log create mode 100644 logs/deploy_l2_bridge.log create mode 100644 typescript diff --git a/--data b/--data new file mode 100644 index 00000000..45ec8a7a --- /dev/null +++ b/--data @@ -0,0 +1 @@ +{"jsonrpc":"2.0","error":{"code":-32700,"message":"parse error"},"id":null} diff --git a/--header b/--header new file mode 100644 index 00000000..45ec8a7a --- /dev/null +++ b/--header @@ -0,0 +1 @@ +{"jsonrpc":"2.0","error":{"code":-32700,"message":"parse error"},"id":null} diff --git a/--url b/--url new file mode 100644 index 00000000..45ec8a7a --- /dev/null +++ b/--url @@ -0,0 +1 @@ +{"jsonrpc":"2.0","error":{"code":-32700,"message":"parse error"},"id":null} diff --git a/logs/deploy_l1_bridge.log b/logs/deploy_l1_bridge.log new file mode 100644 index 00000000..b3ac3d42 --- /dev/null +++ b/logs/deploy_l1_bridge.log @@ -0,0 +1,32 @@ +Compiling 1 files with Solc 0.8.26 +Solc 0.8.26 finished in 2.60s +Compiler run successful! +Script ran successfully. + +== Logs == + Deployed L1Bridge at 0x2D02b651Ea9630351719c8c55210e042e940d69a + Granted MINTER_ROLE on NodlL1(0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990) to bridge + +## Setting up 1 EVM. + +========================== + +Chain 1 + +Estimated gas price: 0.222068762 gwei + +Estimated total gas used for script: 2685066 + +Estimated amount required: 0.000596269282508292 ETH + +========================== + + +========================== + +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. + +Transactions saved to: /Users/alex/Documents/rollup/broadcast/DeployL1Bridge.s.sol/1/run-latest.json + +Sensitive values saved to: /Users/alex/Documents/rollup/cache/DeployL1Bridge.s.sol/1/run-latest.json + diff --git a/logs/deploy_l1_nodl.log b/logs/deploy_l1_nodl.log new file mode 100644 index 00000000..ae13ee1e --- /dev/null +++ b/logs/deploy_l1_nodl.log @@ -0,0 +1,29 @@ +No files changed, compilation skipped +Script ran successfully. + +== Logs == + Deployed L1Nodl at 0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990 + +## Setting up 1 EVM. + +========================== + +Chain 1 + +Estimated gas price: 0.251645298 gwei + +Estimated total gas used for script: 4998146 + +Estimated amount required: 0.001257759939617508 ETH + +========================== + + +========================== + +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. + +Transactions saved to: /Users/alex/Documents/rollup/broadcast/DeployL1Nodl.s.sol/1/run-latest.json + +Sensitive values saved to: /Users/alex/Documents/rollup/cache/DeployL1Nodl.s.sol/1/run-latest.json + diff --git a/logs/deploy_l2_bridge.log b/logs/deploy_l2_bridge.log new file mode 100644 index 00000000..5047416e --- /dev/null +++ b/logs/deploy_l2_bridge.log @@ -0,0 +1,98 @@ +Compiling 1 files with Solc 0.8.26 +Solc 0.8.26 finished in 1.70s +Compiler run successful! + +Compiling 1 files with zksolc and solc 0.8.26 +zksolc and solc 0.8.26 finished in 4.05s +Compiler run successful with warnings: +Warning +ZKsync Era comes with native account abstraction support, and therefore the initiator of a +transaction might be different from the contract calling your code. It is highly recommended NOT +to rely on tx.origin, but use msg.sender instead. +Learn more about Account Abstraction at https://docs.zksync.io/build/developer-reference/account-abstraction/ +You may disable this warning with: + a. `suppressedWarnings = ["txorigin"]` in standard JSON. + b. `--suppress-warnings txorigin` in the CLI. + --> lib/era-contracts/l1-contracts/contracts/vendor/AddressAliasHelper.sol:56:42 | + 56 | _recipient = _prevMsgSender == tx.origin + | ^^^^^^^^^ + +Warning +EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. +In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. +In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. +Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 +You may disable this warning with: + 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. + 2. `--suppress-warnings assemblycreate` in the CLI. + --> lib/forge-std/src/StdCheats.sol:494:19 | + 494 | addr := create(0, add(bytecode, 0x20), mload(bytecode)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Warning +EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. +In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. +In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. +Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 +You may disable this warning with: + 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. + 2. `--suppress-warnings assemblycreate` in the CLI. + --> lib/forge-std/src/StdCheats.sol:504:19 | + 504 | addr := create(0, add(bytecode, 0x20), mload(bytecode)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Warning +EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. +In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. +In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. +Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 +You may disable this warning with: + 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. + 2. `--suppress-warnings assemblycreate` in the CLI. + --> lib/forge-std/src/StdCheats.sol:515:19 | + 515 | addr := create(val, add(bytecode, 0x20), mload(bytecode)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Warning +EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. +In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. +In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. +Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 +You may disable this warning with: + 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. + 2. `--suppress-warnings assemblycreate` in the CLI. + --> lib/forge-std/src/StdCheats.sol:525:19 | + 525 | addr := create(val, add(bytecode, 0x20), mload(bytecode)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +2025-10-15T00:35:52.095529Z ERROR backendhandler: failed to get block err=failed to get block; error sending request for url (https://mainnet.era.zksync.io/); operation timed out number=65260273 +2025-10-15T00:35:52.096034Z ERROR sharedbackend: Failed to send/recv `block_hash` err=failed to get block hash for 65260273: failed to get block; error sending request for url (https://mainnet.era.zksync.io/); operation timed out number=65260273 +Script ran successfully. + +== Logs == + Deployed L2Bridge at 0x2c1B65dA72d5Cf19b41dE6eDcCFB7DD83d1B529E + Granted MINTER_ROLE on NODL(0xBD4372e44c5eE654dd838304006E1f0f69983154) to bridge + +## Setting up 1 EVM. + +========================== + +Chain 324 + +Estimated gas price: 0.090500001 gwei + +Estimated total gas used for script: 209410861 + +Estimated amount required: 0.018951683129910861 ETH + +========================== + + +========================== + +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. + +Transactions saved to: /Users/alex/Documents/rollup/broadcast/DeployL2Bridge.s.sol/324/run-latest.json + +Sensitive values saved to: /Users/alex/Documents/rollup/cache/DeployL2Bridge.s.sol/324/run-latest.json + diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index a015d3ce..5b576555 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -67,6 +67,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { error InvalidCountryCode(); error InvalidAdminCode(); error AdminAreaRequired(); + error UuidOwnerMismatch(); // ────────────────────────────────────────────── // Constants & Immutables @@ -136,6 +137,18 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Token ID -> tier index (within its region) the fleet belongs to. mapping(uint256 => uint256) public fleetTier; + // ────────────────────────────────────────────── + // UUID ownership tracking + // ────────────────────────────────────────────── + + /// @notice UUID -> address that first registered a token for this UUID. + /// All subsequent registrations for the same UUID must come from this address. + mapping(bytes16 => address) public uuidOwner; + + /// @notice UUID -> count of active tokens for this UUID (across all regions). + /// When this reaches 0, uuidOwner is cleared. + mapping(bytes16 => uint256) public uuidTokenCount; + // ────────────────────────────────────────────── // On-chain region indexes // ────────────────────────────────────────────── @@ -273,6 +286,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 tier = fleetTier[tokenId]; uint256 refund = tierBond(tier, region); + // Extract UUID for ownership tracking cleanup + bytes16 uuid = bytes16(uint128(tokenId)); + // Effects _removeFromTier(tokenId, region, tier); delete fleetTier[tokenId]; @@ -283,6 +299,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { _trimTierCount(region); _removeFromRegionIndex(region); + // Clean up UUID ownership tracking + uint256 newCount = uuidTokenCount[uuid] - 1; + if (newCount == 0) { + delete uuidOwner[uuid]; + delete uuidTokenCount[uuid]; + } else { + uuidTokenCount[uuid] = newCount; + } + // Interaction if (refund > 0) { BOND_TOKEN.safeTransfer(tokenOwner, refund); @@ -536,6 +561,17 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Shared registration logic. function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { + // UUID ownership enforcement: all tokens for the same UUID must be owned by the same address + address existingOwner = uuidOwner[uuid]; + if (existingOwner == address(0)) { + // First registration for this UUID + uuidOwner[uuid] = msg.sender; + } else if (existingOwner != msg.sender) { + // Subsequent registration by a different address + revert UuidOwnerMismatch(); + } + uuidTokenCount[uuid]++; + uint256 bond = tierBond(tier, region); // TokenID encodes both region and uuid: (region << 128) | uuid tokenId = (uint256(region) << 128) | uint256(uint128(uuid)); diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 62a1f703..37049f99 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -605,11 +605,11 @@ contract FleetIdentityTest is Test { } function test_multiRegion_sameUuidCanRegisterInDifferentRegions() public { - // Same UUID can be registered in multiple regions simultaneously + // Same UUID can be registered in multiple regions simultaneously (by SAME owner) vm.prank(alice); uint256 localId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); - vm.prank(bob); + vm.prank(alice); uint256 countryId = fleet.registerFleetCountry(UUID_1, DE, 0); // Different tokenIds for different regions @@ -620,6 +620,10 @@ contract FleetIdentityTest is Test { assertEq(fleet.tokenUuid(countryId), UUID_1); assertEq(fleet.fleetRegion(localId), _regionUSCA()); assertEq(fleet.fleetRegion(countryId), _regionDE()); + + // Both owned by alice + assertEq(fleet.ownerOf(localId), alice); + assertEq(fleet.ownerOf(countryId), alice); } function test_RevertIf_burn_notOwner() public { @@ -987,6 +991,253 @@ contract FleetIdentityTest is Test { fleet.burn(tokenId); } + // ══════════════════════════════════════════════ + // UUID Ownership Enforcement Tests + // ══════════════════════════════════════════════ + + function test_uuidOwner_setOnFirstRegistration() public { + assertEq(fleet.uuidOwner(UUID_1), address(0), "No owner before registration"); + assertEq(fleet.uuidTokenCount(UUID_1), 0, "No tokens before registration"); + + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + assertEq(fleet.uuidOwner(UUID_1), alice, "Alice is UUID owner after registration"); + assertEq(fleet.uuidTokenCount(UUID_1), 1, "Token count is 1 after registration"); + } + + function test_uuidOwner_sameOwnerCanRegisterMultipleRegions() public { + // Alice registers UUID_1 in first region + vm.prank(alice); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + // Alice can register same UUID in second region + vm.prank(alice); + uint256 id2 = fleet.registerFleetCountry(UUID_1, DE, 0); + + // And a third region + vm.prank(alice); + uint256 id3 = fleet.registerFleetLocal(UUID_1, FR, ADMIN_CA); + + assertEq(fleet.uuidOwner(UUID_1), alice, "Alice is still UUID owner"); + assertEq(fleet.uuidTokenCount(UUID_1), 3, "Token count is 3"); + assertEq(fleet.ownerOf(id1), alice); + assertEq(fleet.ownerOf(id2), alice); + assertEq(fleet.ownerOf(id3), alice); + } + + function test_RevertIf_differentOwnerRegistersSameUuid_local() public { + // Alice registers UUID_1 first + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + // Bob tries to register same UUID in different region → revert + vm.prank(bob); + vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + } + + function test_RevertIf_differentOwnerRegistersSameUuid_country() public { + // Alice registers UUID_1 first + vm.prank(alice); + fleet.registerFleetCountry(UUID_1, US, 0); + + // Bob tries to register same UUID in different country → revert + vm.prank(bob); + vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); + fleet.registerFleetCountry(UUID_1, DE, 0); + } + + function test_RevertIf_differentOwnerRegistersSameUuid_crossLevel() public { + // Alice registers UUID_1 at country level + vm.prank(alice); + fleet.registerFleetCountry(UUID_1, US, 0); + + // Bob tries to register same UUID at local level → revert + vm.prank(bob); + vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + } + + function test_uuidOwner_clearedWhenAllTokensBurned() public { + // Alice registers UUID_1 in one region + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + assertEq(fleet.uuidOwner(UUID_1), alice); + assertEq(fleet.uuidTokenCount(UUID_1), 1); + + // Burn the token + vm.prank(alice); + fleet.burn(tokenId); + + // UUID owner should be cleared + assertEq(fleet.uuidOwner(UUID_1), address(0), "UUID owner cleared after all tokens burned"); + assertEq(fleet.uuidTokenCount(UUID_1), 0, "Token count is 0 after all burned"); + } + + function test_uuidOwner_notClearedWhileTokensRemain() public { + // Alice registers UUID_1 in two regions + vm.prank(alice); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + vm.prank(alice); + uint256 id2 = fleet.registerFleetCountry(UUID_1, DE, 0); + + assertEq(fleet.uuidTokenCount(UUID_1), 2); + + // Burn first token + vm.prank(alice); + fleet.burn(id1); + + // UUID owner should still be alice (one token remains) + assertEq(fleet.uuidOwner(UUID_1), alice, "UUID owner still alice with remaining token"); + assertEq(fleet.uuidTokenCount(UUID_1), 1, "Token count decremented to 1"); + + // Burn second token + vm.prank(alice); + fleet.burn(id2); + + // Now UUID owner should be cleared + assertEq(fleet.uuidOwner(UUID_1), address(0), "UUID owner cleared after all burned"); + assertEq(fleet.uuidTokenCount(UUID_1), 0); + } + + function test_uuidOwner_differentUuidsHaveDifferentOwners() public { + // Alice registers UUID_1 + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + // Bob registers UUID_2 (different UUID, no conflict) + vm.prank(bob); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + + assertEq(fleet.uuidOwner(UUID_1), alice); + assertEq(fleet.uuidOwner(UUID_2), bob); + } + + function test_uuidOwner_canReRegisterAfterBurningAll() public { + // Alice registers and burns UUID_1 + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + vm.prank(alice); + fleet.burn(tokenId); + + // Bob can now register the same UUID (uuid owner was cleared) + vm.prank(bob); + uint256 newTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + assertEq(fleet.uuidOwner(UUID_1), bob, "Bob is now UUID owner"); + assertEq(fleet.uuidTokenCount(UUID_1), 1); + assertEq(fleet.ownerOf(newTokenId), bob); + } + + function test_uuidOwner_transferDoesNotChangeUuidOwner() public { + // Alice registers UUID_1 + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + assertEq(fleet.uuidOwner(UUID_1), alice); + + // Alice transfers to Bob + vm.prank(alice); + fleet.transferFrom(alice, bob, tokenId); + + // Token owner changed but UUID owner did not + assertEq(fleet.ownerOf(tokenId), bob); + assertEq(fleet.uuidOwner(UUID_1), alice, "UUID owner still alice after transfer"); + } + + function test_RevertIf_transferRecipientTriesToRegisterSameUuid() public { + // Alice registers UUID_1 + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + // Alice transfers to Bob + vm.prank(alice); + fleet.transferFrom(alice, bob, tokenId); + + // Bob now owns tokenId, but cannot register NEW tokens for UUID_1 + vm.prank(bob); + vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + } + + function test_uuidOwner_originalOwnerCanStillRegisterAfterTransfer() public { + // Alice registers UUID_1 in one region + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + // Alice transfers to Bob + vm.prank(alice); + fleet.transferFrom(alice, bob, tokenId); + + // Alice can still register UUID_1 in new regions (she's still uuidOwner) + vm.prank(alice); + uint256 newTokenId = fleet.registerFleetCountry(UUID_1, DE, 0); + + assertEq(fleet.ownerOf(newTokenId), alice); + assertEq(fleet.uuidTokenCount(UUID_1), 2); + } + + function testFuzz_uuidOwner_enforcedAcrossAllRegions(uint16 cc1, uint16 cc2, uint16 admin1, uint16 admin2) public { + cc1 = uint16(bound(cc1, 1, 999)); + cc2 = uint16(bound(cc2, 1, 999)); + admin1 = uint16(bound(admin1, 1, 4095)); + admin2 = uint16(bound(admin2, 1, 4095)); + + // Alice registers first + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, cc1, admin1); + + // Bob cannot register same UUID anywhere + vm.prank(bob); + vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); + fleet.registerFleetLocal(UUID_1, cc2, admin2); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); + fleet.registerFleetCountry(UUID_1, cc2, 0); + } + + function testFuzz_uuidOwner_multiRegionTokenCount(uint8 regionCount) public { + regionCount = uint8(bound(regionCount, 1, 10)); + + for (uint8 i = 0; i < regionCount; i++) { + uint16 cc = uint16(1 + i); + vm.prank(alice); + fleet.registerFleetCountry(UUID_1, cc, 0); + } + + assertEq(fleet.uuidTokenCount(UUID_1), regionCount); + assertEq(fleet.uuidOwner(UUID_1), alice); + } + + function testFuzz_uuidOwner_partialBurnPreservesOwnership(uint8 burnCount) public { + uint8 totalTokens = 5; + burnCount = uint8(bound(burnCount, 1, totalTokens - 1)); + + // Register tokens + uint256[] memory tokenIds = new uint256[](totalTokens); + for (uint8 i = 0; i < totalTokens; i++) { + uint16 cc = uint16(1 + i); + vm.prank(alice); + tokenIds[i] = fleet.registerFleetCountry(UUID_1, cc, 0); + } + + assertEq(fleet.uuidTokenCount(UUID_1), totalTokens); + + // Burn some tokens + for (uint8 i = 0; i < burnCount; i++) { + vm.prank(alice); + fleet.burn(tokenIds[i]); + } + + // Owner still alice, count decreased + assertEq(fleet.uuidOwner(UUID_1), alice); + assertEq(fleet.uuidTokenCount(UUID_1), totalTokens - burnCount); + } + function testFuzz_tierBond_geometric(uint256 tier) public view { tier = bound(tier, 0, 10); uint256 expected = BASE_BOND; diff --git a/typescript b/typescript new file mode 100644 index 00000000..00563f89 --- /dev/null +++ b/typescript @@ -0,0 +1,3 @@ +Script started on Fri Feb 13 12:41:51 2026 +% alex@Alexs-MacBook-Pro-2 rollup % [?2004h[?2004l +% alex@Alexs-MacBook-Pro-2 rollup % [?2004h \ No newline at end of file From 90b7d7c2aeb498fb226d94a25c7a36136faaffa3 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 18:58:24 +1300 Subject: [PATCH 31/63] feat(FleetIdentity): add level enforcement and migrateRegion() --- src/swarms/FleetIdentity.sol | 174 +++++++++++++ test/FleetIdentity.t.sol | 477 +++++++++++++++++++++++++++++++++-- 2 files changed, 633 insertions(+), 18 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 5b576555..57ffdba3 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -68,6 +68,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { error InvalidAdminCode(); error AdminAreaRequired(); error UuidOwnerMismatch(); + error UuidLevelMismatch(); + error CannotMigrateLevelWithMultipleTokens(); + error SameRegion(); // ────────────────────────────────────────────── // Constants & Immutables @@ -149,6 +152,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// When this reaches 0, uuidOwner is cleared. mapping(bytes16 => uint256) public uuidTokenCount; + /// @notice UUID -> registration level (0 = none, 1 = local, 2 = country). + /// All tokens for a UUID must be at the same level. + mapping(bytes16 => uint8) public uuidLevel; + // ────────────────────────────────────────────── // On-chain region indexes // ────────────────────────────────────────────── @@ -180,6 +187,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { event FleetBurned( address indexed owner, uint256 indexed tokenId, uint32 indexed regionKey, uint256 tierIndex, uint256 bondRefund ); + event FleetMigrated( + uint256 indexed oldTokenId, + uint256 indexed newTokenId, + uint32 oldRegion, + uint32 newRegion, + uint256 oldTier, + uint256 newTier, + int256 bondDelta + ); // ────────────────────────────────────────────── // Constructor @@ -304,6 +320,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (newCount == 0) { delete uuidOwner[uuid]; delete uuidTokenCount[uuid]; + delete uuidLevel[uuid]; } else { uuidTokenCount[uuid] = newCount; } @@ -316,6 +333,93 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { emit FleetBurned(tokenOwner, tokenId, region, tier, refund); } + // ══════════════════════════════════════════════ + // Migration: Change region (and optionally level) + // ══════════════════════════════════════════════ + + /// @notice Migrate a fleet to a new region, optionally changing level (local ↔ country). + /// @dev Level changes (local → country or vice versa) are only allowed when this is the + /// ONLY token for this UUID. Use `migrationHint()` to check feasibility and bond delta. + /// @param tokenId The fleet token to migrate. + /// @param newRegionKey Target region (country code 1-999 for country level, or packed admin-area). + /// @param targetTier Target tier in the new region. + /// @return newTokenId The new token ID (region encoded in upper bits). + function migrateRegion(uint256 tokenId, uint32 newRegionKey, uint256 targetTier) + external + nonReentrant + returns (uint256 newTokenId) + { + address tokenOwner = ownerOf(tokenId); + if (tokenOwner != msg.sender) revert NotTokenOwner(); + + bytes16 uuid = bytes16(uint128(tokenId)); + uint32 oldRegion = fleetRegion[tokenId]; + uint256 oldTier = fleetTier[tokenId]; + + if (newRegionKey == oldRegion) revert SameRegion(); + + // Determine if this is a level change + bool wasCountry = oldRegion <= MAX_COUNTRY_CODE; + bool isCountry = newRegionKey <= MAX_COUNTRY_CODE; + bool levelChange = wasCountry != isCountry; + + // Level changes require this to be the only token for the UUID + if (levelChange && uuidTokenCount[uuid] > 1) { + revert CannotMigrateLevelWithMultipleTokens(); + } + + // Validate new region key + if (isCountry) { + if (newRegionKey == 0 || newRegionKey > MAX_COUNTRY_CODE) revert InvalidCountryCode(); + } else { + uint16 cc = _countryFromRegion(newRegionKey); + uint16 admin = _adminFromRegion(newRegionKey); + if (cc == 0 || cc > MAX_COUNTRY_CODE) revert InvalidCountryCode(); + if (admin == 0 || admin > MAX_ADMIN_CODE) revert InvalidAdminCode(); + } + + // Validate target tier in new region + _validateExplicitTier(newRegionKey, targetTier); + + // Compute bond delta + uint256 oldBond = tierBond(oldTier, oldRegion); + uint256 newBond = tierBond(targetTier, newRegionKey); + int256 bondDelta = int256(newBond) - int256(oldBond); + + // Compute new tokenId (region encoded in upper bits) + newTokenId = (uint256(newRegionKey) << 128) | uint256(uint128(uuid)); + + // === Effects: Remove from old region === + _removeFromTier(tokenId, oldRegion, oldTier); + delete fleetTier[tokenId]; + delete fleetRegion[tokenId]; + delete _indexInTier[tokenId]; + _burn(tokenId); + _trimTierCount(oldRegion); + _removeFromRegionIndex(oldRegion); + + // Update level if changed + if (levelChange) { + uuidLevel[uuid] = isCountry ? 2 : 1; + } + + // === Effects: Add to new region === + fleetRegion[newTokenId] = newRegionKey; + fleetTier[newTokenId] = targetTier; + _addToTier(newTokenId, newRegionKey, targetTier); + _addToRegionIndex(newRegionKey); + _mint(msg.sender, newTokenId); + + // === Interactions: Handle bond delta === + if (bondDelta > 0) { + BOND_TOKEN.safeTransferFrom(msg.sender, address(this), uint256(bondDelta)); + } else if (bondDelta < 0) { + BOND_TOKEN.safeTransfer(msg.sender, uint256(-bondDelta)); + } + + emit FleetMigrated(tokenId, newTokenId, oldRegion, newRegionKey, oldTier, targetTier, bondDelta); + } + // ══════════════════════════════════════════════ // Views: Bond & tier helpers // ══════════════════════════════════════════════ @@ -368,6 +472,64 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { bond = tierBond(inclusionTier, uint32(countryCode)); } + /// @notice Check if a fleet can migrate to a new region and calculate bond delta. + /// @dev Returns (bondDelta, canMigrate, reason). + /// - bondDelta > 0: additional bond required + /// - bondDelta < 0: refund available + /// - bondDelta = 0: no change + /// @param tokenId The fleet token to check. + /// @param newRegionKey Target region (country code 1-999 for country level, or packed admin-area). + /// @param targetTier Target tier in the new region. + function migrationHint(uint256 tokenId, uint32 newRegionKey, uint256 targetTier) + external + view + returns (int256 bondDelta, bool canMigrate, string memory reason) + { + address owner = _ownerOf(tokenId); + if (owner == address(0)) { + return (0, false, "Token does not exist"); + } + + bytes16 uuid = bytes16(uint128(tokenId)); + uint32 oldRegion = fleetRegion[tokenId]; + uint256 oldTier = fleetTier[tokenId]; + + if (newRegionKey == oldRegion) { + return (0, false, "Same region"); + } + + bool wasCountry = oldRegion <= MAX_COUNTRY_CODE; + bool isCountry = newRegionKey <= MAX_COUNTRY_CODE; + bool levelChange = wasCountry != isCountry; + + // Level changes require single token + if (levelChange && uuidTokenCount[uuid] > 1) { + return (0, false, "Cannot change level with multiple tokens"); + } + + // Validate target tier + if (targetTier >= MAX_TIERS) { + return (0, false, "Target tier exceeds MAX_TIERS"); + } + + // Check if target tier is available (only check fullness - new tiers can be opened) + if (_regionTierMembers[newRegionKey][targetTier].length >= TIER_CAPACITY) { + return (0, false, "Target tier is full"); + } + + uint256 oldBond = tierBond(oldTier, oldRegion); + uint256 newBond = tierBond(targetTier, newRegionKey); + bondDelta = int256(newBond) - int256(oldBond); + canMigrate = true; + if (bondDelta > 0) { + reason = "Additional bond required"; + } else if (bondDelta < 0) { + reason = "Refund available"; + } else { + reason = "No bond change"; + } + } + /// @notice Highest non-empty tier in a region, or 0 if none. function highestActiveTier(uint32 regionKey) external view returns (uint256) { uint256 tierCount = regionTierCount[regionKey]; @@ -570,6 +732,18 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Subsequent registration by a different address revert UuidOwnerMismatch(); } + + // UUID level enforcement: all tokens for a UUID must be at the same level + uint8 level = region <= MAX_COUNTRY_CODE ? 2 : 1; // 2 = country, 1 = local + uint8 existingLevel = uuidLevel[uuid]; + if (existingLevel == 0) { + // First registration for this UUID + uuidLevel[uuid] = level; + } else if (existingLevel != level) { + // Subsequent registration at different level + revert UuidLevelMismatch(); + } + uuidTokenCount[uuid]++; uint256 bond = tierBond(tier, region); diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 37049f99..5d4a86e8 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -132,6 +132,10 @@ contract FleetIdentityTest is Test { return (uint32(US) << 12) | uint32(ADMIN_NY); } + function _makeAdminRegion(uint16 cc, uint16 admin) internal pure returns (uint32) { + return (uint32(cc) << 12) | uint32(admin); + } + function _registerNCountry(address owner, uint16 cc, uint256 count, uint256 startSeed) internal returns (uint256[] memory ids) @@ -605,25 +609,25 @@ contract FleetIdentityTest is Test { } function test_multiRegion_sameUuidCanRegisterInDifferentRegions() public { - // Same UUID can be registered in multiple regions simultaneously (by SAME owner) + // Same UUID can be registered in multiple regions simultaneously (by SAME owner, SAME level) vm.prank(alice); - uint256 localId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 localId1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(alice); - uint256 countryId = fleet.registerFleetCountry(UUID_1, DE, 0); + uint256 localId2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); // Different tokenIds for different regions - assertTrue(localId != countryId, "Different regions should have different tokenIds"); + assertTrue(localId1 != localId2, "Different regions should have different tokenIds"); // Both have same UUID but different regions - assertEq(fleet.tokenUuid(localId), UUID_1); - assertEq(fleet.tokenUuid(countryId), UUID_1); - assertEq(fleet.fleetRegion(localId), _regionUSCA()); - assertEq(fleet.fleetRegion(countryId), _regionDE()); + assertEq(fleet.tokenUuid(localId1), UUID_1); + assertEq(fleet.tokenUuid(localId2), UUID_1); + assertEq(fleet.fleetRegion(localId1), _regionUSCA()); + assertEq(fleet.fleetRegion(localId2), _makeAdminRegion(DE, ADMIN_CA)); // Both owned by alice - assertEq(fleet.ownerOf(localId), alice); - assertEq(fleet.ownerOf(countryId), alice); + assertEq(fleet.ownerOf(localId1), alice); + assertEq(fleet.ownerOf(localId2), alice); } function test_RevertIf_burn_notOwner() public { @@ -1007,15 +1011,15 @@ contract FleetIdentityTest is Test { } function test_uuidOwner_sameOwnerCanRegisterMultipleRegions() public { - // Alice registers UUID_1 in first region + // Alice registers UUID_1 in first region (same level across all) vm.prank(alice); uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); - // Alice can register same UUID in second region + // Alice can register same UUID in second region (same level) vm.prank(alice); - uint256 id2 = fleet.registerFleetCountry(UUID_1, DE, 0); + uint256 id2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); - // And a third region + // And a third region (same level) vm.prank(alice); uint256 id3 = fleet.registerFleetLocal(UUID_1, FR, ADMIN_CA); @@ -1077,12 +1081,12 @@ contract FleetIdentityTest is Test { } function test_uuidOwner_notClearedWhileTokensRemain() public { - // Alice registers UUID_1 in two regions + // Alice registers UUID_1 in two regions (same level) vm.prank(alice); uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); vm.prank(alice); - uint256 id2 = fleet.registerFleetCountry(UUID_1, DE, 0); + uint256 id2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); assertEq(fleet.uuidTokenCount(UUID_1), 2); @@ -1172,9 +1176,9 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.transferFrom(alice, bob, tokenId); - // Alice can still register UUID_1 in new regions (she's still uuidOwner) + // Alice can still register UUID_1 in new regions (she's still uuidOwner, same level) vm.prank(alice); - uint256 newTokenId = fleet.registerFleetCountry(UUID_1, DE, 0); + uint256 newTokenId = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); assertEq(fleet.ownerOf(newTokenId), alice); assertEq(fleet.uuidTokenCount(UUID_1), 2); @@ -1238,6 +1242,442 @@ contract FleetIdentityTest is Test { assertEq(fleet.uuidTokenCount(UUID_1), totalTokens - burnCount); } + // ══════════════════════════════════════════════ + // UUID Level Enforcement Tests + // ══════════════════════════════════════════════ + + function test_uuidLevel_setOnFirstRegistration_local() public { + assertEq(fleet.uuidLevel(UUID_1), 0, "No level before registration"); + + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + assertEq(fleet.uuidLevel(UUID_1), 1, "Level is 1 (local) after local registration"); + } + + function test_uuidLevel_setOnFirstRegistration_country() public { + assertEq(fleet.uuidLevel(UUID_1), 0, "No level before registration"); + + vm.prank(alice); + fleet.registerFleetCountry(UUID_1, US, 0); + + assertEq(fleet.uuidLevel(UUID_1), 2, "Level is 2 (country) after country registration"); + } + + function test_RevertIf_crossLevelRegistration_localThenCountry() public { + // Alice registers UUID_1 at local level + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + // Alice tries to register same UUID at country level → revert + vm.prank(alice); + vm.expectRevert(FleetIdentity.UuidLevelMismatch.selector); + fleet.registerFleetCountry(UUID_1, DE, 0); + } + + function test_RevertIf_crossLevelRegistration_countryThenLocal() public { + // Alice registers UUID_1 at country level + vm.prank(alice); + fleet.registerFleetCountry(UUID_1, US, 0); + + // Alice tries to register same UUID at local level → revert + vm.prank(alice); + vm.expectRevert(FleetIdentity.UuidLevelMismatch.selector); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + } + + function test_uuidLevel_clearedOnLastTokenBurn() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + assertEq(fleet.uuidLevel(UUID_1), 1); + + vm.prank(alice); + fleet.burn(tokenId); + + assertEq(fleet.uuidLevel(UUID_1), 0, "Level cleared after all tokens burned"); + } + + function test_uuidLevel_notClearedWhileTokensRemain() public { + vm.prank(alice); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + + assertEq(fleet.uuidLevel(UUID_1), 1); + + vm.prank(alice); + fleet.burn(id1); + + assertEq(fleet.uuidLevel(UUID_1), 1, "Level preserved while tokens remain"); + } + + function test_uuidLevel_canChangeLevelAfterBurningAll() public { + // Register as local + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + assertEq(fleet.uuidLevel(UUID_1), 1); + + // Burn + vm.prank(alice); + fleet.burn(tokenId); + + // Now can register as country + vm.prank(alice); + fleet.registerFleetCountry(UUID_1, US, 0); + assertEq(fleet.uuidLevel(UUID_1), 2); + } + + // ══════════════════════════════════════════════ + // Migration Tests + // ══════════════════════════════════════════════ + + function test_migrateRegion_localToLocal_sameCountry() public { + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + uint32 oldRegion = fleet.fleetRegion(oldTokenId); + uint32 newRegion = _makeAdminRegion(US, ADMIN_NY); + + vm.prank(alice); + uint256 newTokenId = fleet.migrateRegion(oldTokenId, newRegion, 0); + + // Old token burned + vm.expectRevert(); + fleet.ownerOf(oldTokenId); + + // New token exists + assertEq(fleet.ownerOf(newTokenId), alice); + assertEq(fleet.fleetRegion(newTokenId), newRegion); + assertEq(fleet.fleetTier(newTokenId), 0); + assertEq(fleet.tokenUuid(newTokenId), UUID_1); + + // UUID tracking unchanged + assertEq(fleet.uuidOwner(UUID_1), alice); + assertEq(fleet.uuidTokenCount(UUID_1), 1); + assertEq(fleet.uuidLevel(UUID_1), 1); // still local + } + + function test_migrateRegion_localToLocal_differentCountry() public { + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + + vm.prank(alice); + uint256 newTokenId = fleet.migrateRegion(oldTokenId, newRegion, 0); + + assertEq(fleet.ownerOf(newTokenId), alice); + assertEq(fleet.fleetRegion(newTokenId), newRegion); + assertEq(fleet.uuidLevel(UUID_1), 1); // still local + } + + function test_migrateRegion_countryToCountry() public { + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetCountry(UUID_1, US, 0); + + vm.prank(alice); + uint256 newTokenId = fleet.migrateRegion(oldTokenId, uint32(DE), 0); + + assertEq(fleet.ownerOf(newTokenId), alice); + assertEq(fleet.fleetRegion(newTokenId), uint32(DE)); + assertEq(fleet.uuidLevel(UUID_1), 2); // still country + } + + function test_migrateRegion_localToCountry_singleToken() public { + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + assertEq(fleet.uuidLevel(UUID_1), 1); + + vm.prank(alice); + uint256 newTokenId = fleet.migrateRegion(oldTokenId, uint32(US), 0); + + assertEq(fleet.ownerOf(newTokenId), alice); + assertEq(fleet.fleetRegion(newTokenId), uint32(US)); + assertEq(fleet.uuidLevel(UUID_1), 2); // changed to country + } + + function test_migrateRegion_countryToLocal_singleToken() public { + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetCountry(UUID_1, US, 0); + + assertEq(fleet.uuidLevel(UUID_1), 2); + + uint32 newRegion = _makeAdminRegion(US, ADMIN_CA); + vm.prank(alice); + uint256 newTokenId = fleet.migrateRegion(oldTokenId, newRegion, 0); + + assertEq(fleet.ownerOf(newTokenId), alice); + assertEq(fleet.fleetRegion(newTokenId), newRegion); + assertEq(fleet.uuidLevel(UUID_1), 1); // changed to local + } + + function test_RevertIf_migrateRegion_levelChangeWithMultipleTokens() public { + // Register UUID_1 in two local regions + vm.prank(alice); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + + // Try to migrate one to country level → revert + vm.prank(alice); + vm.expectRevert(FleetIdentity.CannotMigrateLevelWithMultipleTokens.selector); + fleet.migrateRegion(id1, uint32(US), 0); + } + + function test_migrateRegion_sameLevelWithMultipleTokens() public { + // Register UUID_1 in two local regions + vm.prank(alice); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + + // Migrate to different local region (same level) → allowed + uint32 newRegion = _makeAdminRegion(FR, ADMIN_CA); + vm.prank(alice); + uint256 newTokenId = fleet.migrateRegion(id1, newRegion, 0); + + assertEq(fleet.ownerOf(newTokenId), alice); + assertEq(fleet.fleetRegion(newTokenId), newRegion); + assertEq(fleet.uuidTokenCount(UUID_1), 2); // still 2 tokens + } + + function test_migrateRegion_bondRefundOnDowngrade() public { + // Register at tier 1 + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 1); + + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + + // Migrate to tier 0 → should get refund + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + vm.prank(alice); + fleet.migrateRegion(oldTokenId, newRegion, 0); + + uint256 aliceBalanceAfter = bondToken.balanceOf(alice); + uint256 expectedRefund = BASE_BOND; // tier 1 = 2x, tier 0 = 1x, diff = 1x + assertEq(aliceBalanceAfter - aliceBalanceBefore, expectedRefund); + } + + function test_migrateRegion_bondPullOnUpgrade() public { + // Register at tier 0 + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + + // Migrate to tier 1 → should pull additional bond + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + vm.prank(alice); + fleet.migrateRegion(oldTokenId, newRegion, 1); + + uint256 aliceBalanceAfter = bondToken.balanceOf(alice); + uint256 expectedPull = BASE_BOND; // tier 0 = 1x, tier 1 = 2x, diff = 1x + assertEq(aliceBalanceBefore - aliceBalanceAfter, expectedPull); + } + + function test_migrateRegion_localToCountry_bondAdjustment() public { + // Register local at tier 0 (bond = BASE_BOND) + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + + // Migrate to country tier 0 (bond = 8 * BASE_BOND) + vm.prank(alice); + fleet.migrateRegion(oldTokenId, uint32(US), 0); + + uint256 aliceBalanceAfter = bondToken.balanceOf(alice); + uint256 expectedPull = 7 * BASE_BOND; // country = 8x, local = 1x, diff = 7x + assertEq(aliceBalanceBefore - aliceBalanceAfter, expectedPull); + } + + function test_migrateRegion_countryToLocal_bondRefund() public { + // Register country at tier 0 (bond = 8 * BASE_BOND) + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetCountry(UUID_1, US, 0); + + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + + // Migrate to local tier 0 (bond = BASE_BOND) + uint32 newRegion = _makeAdminRegion(US, ADMIN_CA); + vm.prank(alice); + fleet.migrateRegion(oldTokenId, newRegion, 0); + + uint256 aliceBalanceAfter = bondToken.balanceOf(alice); + uint256 expectedRefund = 7 * BASE_BOND; // country = 8x, local = 1x, diff = 7x + assertEq(aliceBalanceAfter - aliceBalanceBefore, expectedRefund); + } + + function test_RevertIf_migrateRegion_notOwner() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + vm.prank(bob); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.migrateRegion(tokenId, newRegion, 0); + } + + function test_RevertIf_migrateRegion_sameRegion() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + uint32 sameRegion = _regionUSCA(); + vm.prank(alice); + vm.expectRevert(FleetIdentity.SameRegion.selector); + fleet.migrateRegion(tokenId, sameRegion, 0); + } + + function test_RevertIf_migrateRegion_tierFull() public { + // Fill tier 0 in DE + _registerNLocalAt(bob, DE, ADMIN_CA, 4, 0, 0); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + vm.prank(alice); + vm.expectRevert(FleetIdentity.TierFull.selector); + fleet.migrateRegion(tokenId, newRegion, 0); + } + + function test_migrateRegion_emitsEvent() public { + vm.prank(alice); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 1); + + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + uint256 expectedNewTokenId = fleet.computeTokenId(UUID_1, newRegion); + + vm.expectEmit(true, true, true, true); + emit FleetIdentity.FleetMigrated( + oldTokenId, + expectedNewTokenId, + _regionUSCA(), + newRegion, + 1, // old tier + 0, // new tier + -int256(BASE_BOND) // refund + ); + + vm.prank(alice); + fleet.migrateRegion(oldTokenId, newRegion, 0); + } + + // ══════════════════════════════════════════════ + // Migration Hint Tests + // ══════════════════════════════════════════════ + + function test_migrationHint_validMigration_refund() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 1); + + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 0); + + assertTrue(canMigrate, "Should be able to migrate"); + assertEq(bondDelta, -int256(BASE_BOND), "Should get refund"); + assertEq(reason, "Refund available"); + } + + function test_migrationHint_validMigration_additionalBond() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 1); + + assertTrue(canMigrate, "Should be able to migrate"); + assertEq(bondDelta, int256(BASE_BOND), "Should require additional bond"); + assertEq(reason, "Additional bond required"); + } + + function test_migrationHint_validMigration_noBondChange() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 0); + + assertTrue(canMigrate); + assertEq(bondDelta, 0); + assertEq(reason, "No bond change"); + } + + function test_migrationHint_tokenDoesNotExist() public view { + uint256 nonExistentToken = 999999; + (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(nonExistentToken, _regionUSCA(), 0); + + assertFalse(canMigrate); + assertEq(bondDelta, 0); + assertEq(reason, "Token does not exist"); + } + + function test_migrationHint_sameRegion() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, _regionUSCA(), 0); + + assertFalse(canMigrate); + assertEq(bondDelta, 0); + assertEq(reason, "Same region"); + } + + function test_migrationHint_cannotChangeLevelWithMultipleTokens() public { + vm.prank(alice); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + + (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(id1, uint32(US), 0); + + assertFalse(canMigrate); + assertEq(bondDelta, 0); + assertEq(reason, "Cannot change level with multiple tokens"); + } + + function test_migrationHint_tierFull() public { + _registerNLocalAt(bob, DE, ADMIN_CA, 4, 0, 0); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + + uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 0); + + assertFalse(canMigrate); + assertEq(bondDelta, 0); + assertEq(reason, "Target tier is full"); + } + + function test_migrationHint_localToCountry_bondDelta() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, uint32(US), 0); + + assertTrue(canMigrate); + assertEq(bondDelta, int256(7 * BASE_BOND), "8x - 1x = 7x additional"); + assertEq(reason, "Additional bond required"); + } + + function test_migrationHint_countryToLocal_bondDelta() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); + + uint32 newRegion = _makeAdminRegion(US, ADMIN_CA); + (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 0); + + assertTrue(canMigrate); + assertEq(bondDelta, -int256(7 * BASE_BOND), "1x - 8x = -7x refund"); + assertEq(reason, "Refund available"); + } + function testFuzz_tierBond_geometric(uint256 tier) public view { tier = bound(tier, 0, 10); uint256 expected = BASE_BOND; @@ -1252,6 +1692,7 @@ contract FleetIdentityTest is Test { function testFuzz_perRegionTiers_newRegionAlwaysStartsAtTier0(uint16 cc) public { cc = uint16(bound(cc, 1, 999)); + vm.assume(cc != US); // Skip US since we fill it below // Fill one country with 8 fleets _registerNCountry(alice, US, 8, 0); From cf6faa08490975b2ac4a3d85aefe95ce08e99d60 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 19 Feb 2026 20:12:25 +1300 Subject: [PATCH 32/63] correct the license --- src/swarms/FleetIdentity.sol | 3 ++- src/swarms/ServiceProvider.sol | 3 ++- src/swarms/SwarmRegistryL1.sol | 3 ++- src/swarms/SwarmRegistryUniversal.sol | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 57ffdba3..ec690434 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -1,4 +1,5 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: BSD-3-Clause-Clear + pragma solidity ^0.8.24; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; diff --git a/src/swarms/ServiceProvider.sol b/src/swarms/ServiceProvider.sol index 80689b9e..e4a777b7 100644 --- a/src/swarms/ServiceProvider.sol +++ b/src/swarms/ServiceProvider.sol @@ -1,4 +1,5 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: BSD-3-Clause-Clear + pragma solidity ^0.8.24; import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol"; diff --git a/src/swarms/SwarmRegistryL1.sol b/src/swarms/SwarmRegistryL1.sol index 5255c516..70da9db3 100644 --- a/src/swarms/SwarmRegistryL1.sol +++ b/src/swarms/SwarmRegistryL1.sol @@ -1,4 +1,5 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: BSD-3-Clause-Clear + pragma solidity ^0.8.24; // NOTE: SSTORE2 is not compatible with ZkSync Era due to EXTCODECOPY limitation. diff --git a/src/swarms/SwarmRegistryUniversal.sol b/src/swarms/SwarmRegistryUniversal.sol index 3da81c08..446312c3 100644 --- a/src/swarms/SwarmRegistryUniversal.sol +++ b/src/swarms/SwarmRegistryUniversal.sol @@ -1,4 +1,5 @@ -// SPDX-License-Identifier: MIT +// SPDX-License-Identifier: BSD-3-Clause-Clear + pragma solidity ^0.8.24; import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; From dcb47a9921a6f3aa8bd9763a28d767d212093ffa Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Mon, 23 Feb 2026 14:56:42 +1300 Subject: [PATCH 33/63] feat: Make country fleet cap flexible in buildHighestBondedUuidBundle Rename MAX_COUNTRY_IN_BUNDLE to PREFERRED_COUNTRY_SLOTS to better reflect its nature as a soft cap rather than a hard limit. Implement two-pass algorithm in buildHighestBondedUuidBundle: - First pass: Apply soft cap on country fleets, prioritizing locals - Second pass: Fill remaining empty slots with skipped country fleets Update _findCheapestInclusionTier to remove hard cap check, allowing country fleets to be guaranteed inclusion when bundle has room. Update _getCountFromTiersAbove to use renamed constant. Add tests for flexible country cap behavior: - test_buildBundle_flexibleCountryCap_fillsRemainingSlots: Verify country exceeds soft cap when locals are scarce - test_buildBundle_flexibleCountryCap_respectsSoftCapWhenLocalsExist: Verify soft cap still applied with sufficient locals All 184 tests pass. --- src/swarms/FleetIdentity.sol | 105 +++++++++++++++++++++++++---------- test/FleetIdentity.t.sol | 54 ++++++++++++++++-- 2 files changed, 124 insertions(+), 35 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index ec690434..0aa68e93 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -30,9 +30,11 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * - Country bond: BASE_BOND * COUNTRY_BOND_MULTIPLIER * 2^tier (8× local) * * Country fleets pay more but appear in all admin-area bundles within - * their country. Bundle caps (MAX_COUNTRY_IN_BUNDLE = 8) ensure locals - * retain majority presence. Caps are flexible: if no country fleets exist, - * locals fill all 20 slots. + * their country. Local fleets get priority over country fleets, with a + * soft cap (PREFERRED_COUNTRY_SLOTS = 8) reserving slots for locals. + * This cap is flexible in both directions: + * - If fewer country fleets exist, locals fill all 20 slots. + * - If fewer local fleets exist, country fleets fill unused slots beyond the cap. * * EdgeBeaconScanner discovery uses 2-level fallback: * 1. Admin area (highest priority) @@ -83,8 +85,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Bond multiplier for country-level registration (8× local). uint256 public constant COUNTRY_BOND_MULTIPLIER = 8; - /// @notice Maximum country fleets in any bundle (flexible cap). - uint256 public constant MAX_COUNTRY_IN_BUNDLE = 8; + /// @notice Preferred number of country fleet slots when locals are available. + /// This is a soft cap: country fleets can fill unused slots beyond this + /// limit when there aren't enough local fleets to fill the bundle. + uint256 public constant PREFERRED_COUNTRY_SLOTS = 8; /// @notice Hard cap on tier count per region. /// @dev Derived from anti-spam analysis: with a bond doubling per tier @@ -586,26 +590,32 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// UUIDs for an EdgeBeaconScanner, merging the highest-bonded tiers across /// admin-area and country levels. /// - /// **Algorithm – Shared-Cursor with Partial Inclusion + Country Cap** + /// **Algorithm – Shared-Cursor with Partial Inclusion + Flexible Country Cap** /// /// Uses a single shared tier-index cursor that descends from the /// highest active tier across both levels. At each cursor position: /// /// 1. Try to include members from admin area tier first (higher priority). - /// 2. Then include country fleets up to MAX_COUNTRY_IN_BUNDLE cap. + /// 2. Include country fleets up to PREFERRED_COUNTRY_SLOTS soft cap. /// 3. Include as many members as fit in remaining bundle capacity. /// Members are included in array order (registration order within tier). /// 4. Decrement cursor and repeat until bundle is full or cursor < 0. /// - /// The country cap (MAX_COUNTRY_IN_BUNDLE = 8) is flexible: if fewer country - /// fleets exist, locals fill the remaining slots. + /// **Second Pass – Fill Remaining Slots:** + /// If the bundle isn't full after the first pass (not enough local fleets), + /// country fleets that were skipped due to the soft cap are included to + /// fill remaining slots, maintaining tier priority order. + /// + /// The country cap (PREFERRED_COUNTRY_SLOTS = 8) is flexible in both directions: + /// - If fewer country fleets exist, locals fill the remaining slots. + /// - If fewer local fleets exist, country fleets fill unused slots beyond the cap. /// /// This guarantees: /// - Higher-bonded fleets always take priority over lower-bonded ones. /// - Within same bond tier, admin area takes precedence over country. /// - Within same tier and level, earlier registrations take precedence. /// - Bundle is always maximally filled (up to 20) if enough fleets exist. - /// - Locals get majority presence (at least 12 slots when country cap reached). + /// - Locals get majority presence (at least 12 slots) when enough exist. /// /// @param countryCode EdgeBeaconScanner country (must be > 0). /// @param adminCode EdgeBeaconScanner admin area (must be > 0). @@ -625,7 +635,13 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { (uint32[2] memory keys, bool[2] memory active) = _resolveBundleLevels(countryCode, adminCode); uint256 maxTierIndex = _findMaxTierIndex(keys, active); - // Descend from the highest tier index using a shared cursor. + // Track how many country members were included per tier in first pass, + // and how many more could be included in second pass. + // We use fixed-size arrays since MAX_TIERS = 24. + uint256[24] memory countryIncludedPerTier; + uint256[24] memory countrySkippedPerTier; + + // === First Pass: Apply soft cap on country fleets === for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; @@ -645,11 +661,21 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; - // Apply country cap for level 1 (LEVEL_COUNTRY) + // Apply country soft cap for level 1 (LEVEL_COUNTRY) if (lvl == LEVEL_COUNTRY) { uint256 countryRoom = - MAX_COUNTRY_IN_BUNDLE > countryIncluded ? MAX_COUNTRY_IN_BUNDLE - countryIncluded : 0; - room = room < countryRoom ? room : countryRoom; + PREFERRED_COUNTRY_SLOTS > countryIncluded ? PREFERRED_COUNTRY_SLOTS - countryIncluded : 0; + uint256 cappedRoom = room < countryRoom ? room : countryRoom; + + // Track how many we're skipping for potential second pass + uint256 wouldInclude = mLen < room ? mLen : room; + uint256 actualInclude = mLen < cappedRoom ? mLen : cappedRoom; + + countryIncludedPerTier[uint256(cursor)] = actualInclude; + if (wouldInclude > actualInclude) { + countrySkippedPerTier[uint256(cursor)] = wouldInclude - actualInclude; + } + room = cappedRoom; } uint256 toInclude = mLen < room ? mLen : room; @@ -667,6 +693,31 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } + // === Second Pass: Fill remaining slots with skipped country fleets === + // If bundle isn't full and we skipped country members due to soft cap, include them now. + if (count < MAX_BONDED_UUID_BUNDLE_SIZE && active[LEVEL_COUNTRY]) { + uint256 countryTc = regionTierCount[keys[LEVEL_COUNTRY]]; + + // Iterate from highest tier down to maintain bond priority + for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { + if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; + if (uint256(cursor) >= countryTc) continue; + if (countrySkippedPerTier[uint256(cursor)] == 0) continue; + + uint256[] storage members = _regionTierMembers[keys[LEVEL_COUNTRY]][uint256(cursor)]; + + // Start from where first pass left off + uint256 startIdx = countryIncludedPerTier[uint256(cursor)]; + uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; + uint256 toInclude = countrySkippedPerTier[uint256(cursor)] < room ? countrySkippedPerTier[uint256(cursor)] : room; + + for (uint256 m = startIdx; m < startIdx + toInclude; ++m) { + uuids[count] = bytes16(uint128(members[m])); + ++count; + } + } + } + // Trim the array to actual size. assembly { mstore(uuids, count) @@ -877,11 +928,13 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// returns the cheapest tier at `candidateLevel` that guarantees bundle /// inclusion. Bounded: O(MAX_TIERS). Works for admin or country level. /// - /// With partial inclusion + country cap, a new fleet is included if: + /// With partial inclusion + flexible country cap, a new fleet is included if: /// (a) The tier has capacity for the new fleet. - /// (b) At that tier position, after higher-priority levels consume slots - /// (respecting MAX_COUNTRY_IN_BUNDLE for country level), + /// (b) At that tier position, after higher-priority levels consume slots, /// there is room for at least one member from the candidate tier. + /// + /// Note: Country fleets are always included if there's room, since + /// the second pass fills unused slots beyond PREFERRED_COUNTRY_SLOTS. function _findCheapestInclusionTier(uint16 countryCode, uint16 adminCode, uint256 candidateLevel) internal view @@ -902,18 +955,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (members >= cap) continue; // (b) Compute how many slots are consumed BEFORE reaching (candidateRegion, T). - // This includes the country cap for country-level candidates. uint256 countBefore; - uint256 countryIncludedBefore; if (T > maxTierIndex) { // Tier above current max: if we join here, we become the new max. // Bundle starts from T, so countBefore = 0. countBefore = 0; - countryIncludedBefore = 0; } else { // Count from tiers strictly above T. - (countBefore, countryIncludedBefore) = _getCountFromTiersAbove(keys, active, maxTierIndex, T); + (countBefore, ) = _getCountFromTiersAbove(keys, active, maxTierIndex, T); // Add higher-priority levels at tier T itself (only admin area for country candidates). if (candidateLevel == LEVEL_COUNTRY && active[LEVEL_ADMIN]) { @@ -930,12 +980,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } - // For country candidates, check against country cap - if (candidateLevel == LEVEL_COUNTRY) { - if (countryIncludedBefore >= MAX_COUNTRY_IN_BUNDLE) continue; - } - // Does the candidate (with +1) fit in bundle? + // Note: With flexible country cap, country fleets can fill any remaining + // slots via second pass, so we only check total bundle room. if (countBefore < MAX_BONDED_UUID_BUNDLE_SIZE) { return T; } @@ -945,7 +992,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @dev Helper to compute count from tiers STRICTLY above T (i.e., tiers > T). - /// Also returns how many country fleets were included (for cap tracking). + /// Also returns how many country fleets were included (for soft cap tracking). function _getCountFromTiersAbove(uint32[2] memory keys, bool[2] memory active, uint256 maxTierIndex, uint256 T) internal view @@ -967,10 +1014,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; - // Apply country cap for LEVEL_COUNTRY + // Apply soft cap for LEVEL_COUNTRY (first pass behavior) if (lvl == LEVEL_COUNTRY) { uint256 countryRoom = - MAX_COUNTRY_IN_BUNDLE > countryIncluded ? MAX_COUNTRY_IN_BUNDLE - countryIncluded : 0; + PREFERRED_COUNTRY_SLOTS > countryIncluded ? PREFERRED_COUNTRY_SLOTS - countryIncluded : 0; room = room < countryRoom ? room : countryRoom; } diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 5d4a86e8..68b05128 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -194,7 +194,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.MAX_TIERS(), 24); assertEq(fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), 20); assertEq(fleet.COUNTRY_BOND_MULTIPLIER(), 8); - assertEq(fleet.MAX_COUNTRY_IN_BUNDLE(), 8); + assertEq(fleet.PREFERRED_COUNTRY_SLOTS(), 8); } // --- tierBond --- @@ -2035,7 +2035,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(_uuid(2000 + i), US, ADMIN_CA, 1); } - // Country tier 1: 4 members (limited by MAX_COUNTRY_IN_BUNDLE) + // Country tier 1: 4 members (soft-capped by PREFERRED_COUNTRY_SLOTS) for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetCountry(_uuid(3000 + i), US, 1); @@ -2050,7 +2050,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_partialInclusion_fillsRemainingSlots() public { // With partial inclusion: if only 3 slots remain for 4 country members, // we include 3 of them (first 3 by array position). - // Country tier 0: 4 members (will be capped by MAX_COUNTRY_IN_BUNDLE) + // Country tier 0: 4 members (soft-capped by PREFERRED_COUNTRY_SLOTS) _registerNCountryAt(alice, US, 4, 0, 0); // Local: 4 at tier 0 + 4 at tier 1 (TIER_CAPACITY = 4) @@ -2363,14 +2363,14 @@ contract FleetIdentityTest is Test { // ── Single level, multiple tiers ── function test_buildBundle_singleLevelMultipleTiers() public { - // Only country, multiple tiers. MAX_COUNTRY_IN_BUNDLE = 8, - // so only 8 of the 12 country members can be included. + // Only country, multiple tiers. With no local fleets, country fleets + // fill all available slots beyond PREFERRED_COUNTRY_SLOTS. _registerNCountryAt(alice, US, 4, 1000, 0); // tier 0: 4 members _registerNCountryAt(alice, US, 4, 2000, 1); // tier 1: 4 members _registerNCountryAt(alice, US, 4, 3000, 2); // tier 2: 4 members (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 8); // capped at MAX_COUNTRY_IN_BUNDLE + assertEq(count, 12); // all country fleets included (no locals to fill slots) // Verify order: tier 2 first (highest bond) uint256[] memory t2 = fleet.getTierMembers(_regionUS(), 2); for (uint256 i = 0; i < 4; i++) { @@ -2394,6 +2394,48 @@ contract FleetIdentityTest is Test { assertEq(uuids[0], _uuid(1100)); // tier 1 comes first (higher bond) } + function test_buildBundle_flexibleCountryCap_fillsRemainingSlots() public { + // Test that country fleets can exceed PREFERRED_COUNTRY_SLOTS when + // there aren't enough local fleets to fill the bundle. + // + // Setup: 2 local fleets + 12 country fleets across 3 tiers + // Expected: All 14 should be included since bundle has room + _registerNLocalAt(alice, US, ADMIN_CA, 2, 1000, 0); + _registerNCountryAt(alice, US, 4, 2000, 0); // tier 0: 4 country + _registerNCountryAt(alice, US, 4, 3000, 1); // tier 1: 4 country + _registerNCountryAt(alice, US, 4, 4000, 2); // tier 2: 4 country + + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + + // All 14 should be included: 2 local + 12 country (beyond soft cap of 8) + assertEq(count, 14); + + // Verify order: tier 2 country (highest bond) → tier 1 country → tier 0 local/country + // First 4 should be tier 2 country fleets + for (uint256 i = 0; i < 4; i++) { + assertEq(uuids[i], _uuid(4000 + i)); + } + } + + function test_buildBundle_flexibleCountryCap_respectsSoftCapWhenLocalsExist() public { + // When there are enough locals, country is soft-capped at PREFERRED_COUNTRY_SLOTS (8) + // in the first pass, but second pass fills remaining slots. + // + // Setup: 8 local fleets + 12 country fleets + // First pass: 8 local + 8 country (soft capped) = 16 + // Second pass: 4 remaining country (to fill slots) = 20 + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1000, 0); + _registerNLocalAt(alice, US, ADMIN_CA, 4, 1100, 1); + _registerNCountryAt(alice, US, 4, 2000, 0); + _registerNCountryAt(alice, US, 4, 3000, 1); + _registerNCountryAt(alice, US, 4, 4000, 2); + + (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + + // Total: 8 local + 12 country = 20 (bundle max) + assertEq(count, 20); + } + // ── Shared cursor: different max tier indices per level ── function test_buildBundle_sharedCursor_levelsAtDifferentMaxTiers() public { From edd5584a8eefbbf6d03d204d59b67f6480f78e92 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Mon, 23 Feb 2026 15:05:20 +1300 Subject: [PATCH 34/63] refactor: Separate validation from state mutation in tier management Make _validateExplicitTier a pure view function that only validates without modifying storage. Move regionTierCount update to _addToTier where the actual tier membership change occurs. This follows better design principles: - Validation functions should be pure checks without side effects - State mutations happen at the logical point of change - No additional gas cost, just cleaner separation of concerns Remove redundant regionTierCount update in _promote since _addToTier now handles it. All 184 tests pass. --- src/swarms/FleetIdentity.sol | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 0aa68e93..0496acb2 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -838,10 +838,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { fleetTier[tokenId] = targetTier; _addToTier(tokenId, region, targetTier); - if (targetTier >= regionTierCount[region]) { - regionTierCount[region] = targetTier + 1; - } - // Interaction if (additionalBond > 0) { BOND_TOKEN.safeTransferFrom(tokenOwner, address(this), additionalBond); @@ -879,13 +875,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { emit FleetDemoted(tokenId, currentTier, targetTier, refund); } - /// @dev Validates and prepares an explicit tier for registration. - function _validateExplicitTier(uint32 region, uint256 targetTier) internal { + /// @dev Validates that a tier is available for registration (pure validation, no state changes). + function _validateExplicitTier(uint32 region, uint256 targetTier) internal view { if (targetTier >= MAX_TIERS) revert MaxTiersReached(); if (_regionTierMembers[region][targetTier].length >= TIER_CAPACITY) revert TierFull(); - if (targetTier >= regionTierCount[region]) { - regionTierCount[region] = targetTier + 1; - } } // -- Bundle-level helpers (shared by buildHighestBondedUuidBundle & inclusion hints) -- @@ -1032,9 +1025,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @dev Appends a token to a region's tier member array and records its index. + /// Updates regionTierCount if this opens a new highest tier. function _addToTier(uint256 tokenId, uint32 region, uint256 tier) internal { _regionTierMembers[region][tier].push(tokenId); _indexInTier[tokenId] = _regionTierMembers[region][tier].length - 1; + + // Update tier count if we're opening a new tier + if (tier >= regionTierCount[region]) { + regionTierCount[region] = tier + 1; + } } /// @dev Swap-and-pop removal from a region's tier member array. From 953a3b289fff015a3e2d4dcdfc8d33ab43afddab Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Mon, 23 Feb 2026 21:48:58 +1300 Subject: [PATCH 35/63] refactor: introduce RegistrationLevel enum and _isCountryRegion helper - Define enum for UUID registration levels (None, Local, Country) with explicit values - Replace magic numbers (0, 1, 2) with enum constants for better code clarity - Add _isCountryRegion() internal helper to encapsulate region type detection - Replace 8 occurrences of '<= MAX_COUNTRY_CODE' checks with semantic helper calls - Improves intent visibility and maintainability throughout the contract - All 184 tests passing --- src/swarms/FleetIdentity.sol | 58 +++++++++++++++++++++++------------- test/FleetIdentity.t.sol | 47 +++++++++++++++-------------- 2 files changed, 62 insertions(+), 43 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 0496acb2..15889f42 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -75,6 +75,18 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { error CannotMigrateLevelWithMultipleTokens(); error SameRegion(); + // ────────────────────────────────────────────── + // Enums + // ────────────────────────────────────────────── + + /// @notice Registration level for a UUID. + enum RegistrationLevel { + None, // 0 - not registered + Local, // 1 - admin area (local) level + Country // 2 - country level + + } + // ────────────────────────────────────────────── // Constants & Immutables // ────────────────────────────────────────────── @@ -157,9 +169,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// When this reaches 0, uuidOwner is cleared. mapping(bytes16 => uint256) public uuidTokenCount; - /// @notice UUID -> registration level (0 = none, 1 = local, 2 = country). + /// @notice UUID -> registration level. /// All tokens for a UUID must be at the same level. - mapping(bytes16 => uint8) public uuidLevel; + mapping(bytes16 => RegistrationLevel) public uuidLevel; // ────────────────────────────────────────────── // On-chain region indexes @@ -364,8 +376,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (newRegionKey == oldRegion) revert SameRegion(); // Determine if this is a level change - bool wasCountry = oldRegion <= MAX_COUNTRY_CODE; - bool isCountry = newRegionKey <= MAX_COUNTRY_CODE; + bool wasCountry = _isCountryRegion(oldRegion); + bool isCountry = _isCountryRegion(newRegionKey); bool levelChange = wasCountry != isCountry; // Level changes require this to be the only token for the UUID @@ -405,7 +417,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Update level if changed if (levelChange) { - uuidLevel[uuid] = isCountry ? 2 : 1; + uuidLevel[uuid] = isCountry ? RegistrationLevel.Country : RegistrationLevel.Local; } // === Effects: Add to new region === @@ -434,7 +446,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// Country: BASE_BOND * COUNTRY_BOND_MULTIPLIER * 2^K (8× local) function tierBond(uint256 tier, uint32 regionKey) public view returns (uint256) { uint256 base = BASE_BOND << tier; - if (regionKey <= MAX_COUNTRY_CODE) { + if (_isCountryRegion(regionKey)) { return base * COUNTRY_BOND_MULTIPLIER; } return base; // Local (admin area) @@ -503,8 +515,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return (0, false, "Same region"); } - bool wasCountry = oldRegion <= MAX_COUNTRY_CODE; - bool isCountry = newRegionKey <= MAX_COUNTRY_CODE; + bool wasCountry = _isCountryRegion(oldRegion); + bool isCountry = _isCountryRegion(newRegionKey); bool levelChange = wasCountry != isCountry; // Level changes require single token @@ -666,11 +678,11 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 countryRoom = PREFERRED_COUNTRY_SLOTS > countryIncluded ? PREFERRED_COUNTRY_SLOTS - countryIncluded : 0; uint256 cappedRoom = room < countryRoom ? room : countryRoom; - + // Track how many we're skipping for potential second pass uint256 wouldInclude = mLen < room ? mLen : room; uint256 actualInclude = mLen < cappedRoom ? mLen : cappedRoom; - + countryIncludedPerTier[uint256(cursor)] = actualInclude; if (wouldInclude > actualInclude) { countrySkippedPerTier[uint256(cursor)] = wouldInclude - actualInclude; @@ -697,7 +709,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // If bundle isn't full and we skipped country members due to soft cap, include them now. if (count < MAX_BONDED_UUID_BUNDLE_SIZE && active[LEVEL_COUNTRY]) { uint256 countryTc = regionTierCount[keys[LEVEL_COUNTRY]]; - + // Iterate from highest tier down to maintain bond priority for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; @@ -705,11 +717,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (countrySkippedPerTier[uint256(cursor)] == 0) continue; uint256[] storage members = _regionTierMembers[keys[LEVEL_COUNTRY]][uint256(cursor)]; - + // Start from where first pass left off uint256 startIdx = countryIncludedPerTier[uint256(cursor)]; uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; - uint256 toInclude = countrySkippedPerTier[uint256(cursor)] < room ? countrySkippedPerTier[uint256(cursor)] : room; + uint256 toInclude = + countrySkippedPerTier[uint256(cursor)] < room ? countrySkippedPerTier[uint256(cursor)] : room; for (uint256 m = startIdx; m < startIdx + toInclude; ++m) { uuids[count] = bytes16(uint128(members[m])); @@ -773,6 +786,11 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return uint16(adminRegion & ADMIN_CODE_MASK); } + /// @dev Returns true if the region key represents a country-level registration. + function _isCountryRegion(uint32 regionKey) internal pure returns (bool) { + return regionKey <= MAX_COUNTRY_CODE; + } + /// @dev Shared registration logic. function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { // UUID ownership enforcement: all tokens for the same UUID must be owned by the same address @@ -786,9 +804,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } // UUID level enforcement: all tokens for a UUID must be at the same level - uint8 level = region <= MAX_COUNTRY_CODE ? 2 : 1; // 2 = country, 1 = local - uint8 existingLevel = uuidLevel[uuid]; - if (existingLevel == 0) { + RegistrationLevel level = _isCountryRegion(region) ? RegistrationLevel.Country : RegistrationLevel.Local; + RegistrationLevel existingLevel = uuidLevel[uuid]; + if (existingLevel == RegistrationLevel.None) { // First registration for this UUID uuidLevel[uuid] = level; } else if (existingLevel != level) { @@ -956,7 +974,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { countBefore = 0; } else { // Count from tiers strictly above T. - (countBefore, ) = _getCountFromTiersAbove(keys, active, maxTierIndex, T); + (countBefore,) = _getCountFromTiersAbove(keys, active, maxTierIndex, T); // Add higher-priority levels at tier T itself (only admin area for country candidates). if (candidateLevel == LEVEL_COUNTRY && active[LEVEL_ADMIN]) { @@ -1029,7 +1047,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { function _addToTier(uint256 tokenId, uint32 region, uint256 tier) internal { _regionTierMembers[region][tier].push(tokenId); _indexInTier[tokenId] = _regionTierMembers[region][tier].length - 1; - + // Update tier count if we're opening a new tier if (tier >= regionTierCount[region]) { regionTierCount[region] = tier + 1; @@ -1063,7 +1081,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @dev Adds a region to the appropriate index set if not already present. function _addToRegionIndex(uint32 region) internal { - if (region <= MAX_COUNTRY_CODE) { + if (_isCountryRegion(region)) { // Country uint16 cc = uint16(region); if (_activeCountryIndex[cc] == 0) { @@ -1083,7 +1101,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { function _removeFromRegionIndex(uint32 region) internal { if (regionTierCount[region] > 0) return; // still has fleets - if (region <= MAX_COUNTRY_CODE) { + if (_isCountryRegion(region)) { uint16 cc = uint16(region); uint256 oneIdx = _activeCountryIndex[cc]; if (oneIdx > 0) { diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 68b05128..d3c60738 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1247,21 +1247,21 @@ contract FleetIdentityTest is Test { // ══════════════════════════════════════════════ function test_uuidLevel_setOnFirstRegistration_local() public { - assertEq(fleet.uuidLevel(UUID_1), 0, "No level before registration"); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 0, "No level before registration"); vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); - assertEq(fleet.uuidLevel(UUID_1), 1, "Level is 1 (local) after local registration"); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1, "Level is 1 (local) after local registration"); } function test_uuidLevel_setOnFirstRegistration_country() public { - assertEq(fleet.uuidLevel(UUID_1), 0, "No level before registration"); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 0, "No level before registration"); vm.prank(alice); fleet.registerFleetCountry(UUID_1, US, 0); - assertEq(fleet.uuidLevel(UUID_1), 2, "Level is 2 (country) after country registration"); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2, "Level is 2 (country) after country registration"); } function test_RevertIf_crossLevelRegistration_localThenCountry() public { @@ -1290,12 +1290,12 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); - assertEq(fleet.uuidLevel(UUID_1), 1); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); vm.prank(alice); fleet.burn(tokenId); - assertEq(fleet.uuidLevel(UUID_1), 0, "Level cleared after all tokens burned"); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 0, "Level cleared after all tokens burned"); } function test_uuidLevel_notClearedWhileTokensRemain() public { @@ -1305,19 +1305,19 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); - assertEq(fleet.uuidLevel(UUID_1), 1); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); vm.prank(alice); fleet.burn(id1); - assertEq(fleet.uuidLevel(UUID_1), 1, "Level preserved while tokens remain"); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1, "Level preserved while tokens remain"); } function test_uuidLevel_canChangeLevelAfterBurningAll() public { // Register as local vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); - assertEq(fleet.uuidLevel(UUID_1), 1); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // Burn vm.prank(alice); @@ -1326,7 +1326,7 @@ contract FleetIdentityTest is Test { // Now can register as country vm.prank(alice); fleet.registerFleetCountry(UUID_1, US, 0); - assertEq(fleet.uuidLevel(UUID_1), 2); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); } // ══════════════════════════════════════════════ @@ -1356,7 +1356,7 @@ contract FleetIdentityTest is Test { // UUID tracking unchanged assertEq(fleet.uuidOwner(UUID_1), alice); assertEq(fleet.uuidTokenCount(UUID_1), 1); - assertEq(fleet.uuidLevel(UUID_1), 1); // still local + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // still local } function test_migrateRegion_localToLocal_differentCountry() public { @@ -1370,7 +1370,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.ownerOf(newTokenId), alice); assertEq(fleet.fleetRegion(newTokenId), newRegion); - assertEq(fleet.uuidLevel(UUID_1), 1); // still local + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // still local } function test_migrateRegion_countryToCountry() public { @@ -1382,28 +1382,28 @@ contract FleetIdentityTest is Test { assertEq(fleet.ownerOf(newTokenId), alice); assertEq(fleet.fleetRegion(newTokenId), uint32(DE)); - assertEq(fleet.uuidLevel(UUID_1), 2); // still country + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // still country } function test_migrateRegion_localToCountry_singleToken() public { vm.prank(alice); uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); - assertEq(fleet.uuidLevel(UUID_1), 1); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); vm.prank(alice); uint256 newTokenId = fleet.migrateRegion(oldTokenId, uint32(US), 0); assertEq(fleet.ownerOf(newTokenId), alice); assertEq(fleet.fleetRegion(newTokenId), uint32(US)); - assertEq(fleet.uuidLevel(UUID_1), 2); // changed to country + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // changed to country } function test_migrateRegion_countryToLocal_singleToken() public { vm.prank(alice); uint256 oldTokenId = fleet.registerFleetCountry(UUID_1, US, 0); - assertEq(fleet.uuidLevel(UUID_1), 2); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); uint32 newRegion = _makeAdminRegion(US, ADMIN_CA); vm.prank(alice); @@ -1411,7 +1411,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.ownerOf(newTokenId), alice); assertEq(fleet.fleetRegion(newTokenId), newRegion); - assertEq(fleet.uuidLevel(UUID_1), 1); // changed to local + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // changed to local } function test_RevertIf_migrateRegion_levelChangeWithMultipleTokens() public { @@ -1610,7 +1610,8 @@ contract FleetIdentityTest is Test { function test_migrationHint_tokenDoesNotExist() public view { uint256 nonExistentToken = 999999; - (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(nonExistentToken, _regionUSCA(), 0); + (int256 bondDelta, bool canMigrate, string memory reason) = + fleet.migrationHint(nonExistentToken, _regionUSCA(), 0); assertFalse(canMigrate); assertEq(bondDelta, 0); @@ -2397,7 +2398,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_flexibleCountryCap_fillsRemainingSlots() public { // Test that country fleets can exceed PREFERRED_COUNTRY_SLOTS when // there aren't enough local fleets to fill the bundle. - // + // // Setup: 2 local fleets + 12 country fleets across 3 tiers // Expected: All 14 should be included since bundle has room _registerNLocalAt(alice, US, ADMIN_CA, 2, 1000, 0); @@ -2406,10 +2407,10 @@ contract FleetIdentityTest is Test { _registerNCountryAt(alice, US, 4, 4000, 2); // tier 2: 4 country (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - + // All 14 should be included: 2 local + 12 country (beyond soft cap of 8) assertEq(count, 14); - + // Verify order: tier 2 country (highest bond) → tier 1 country → tier 0 local/country // First 4 should be tier 2 country fleets for (uint256 i = 0; i < 4; i++) { @@ -2420,7 +2421,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_flexibleCountryCap_respectsSoftCapWhenLocalsExist() public { // When there are enough locals, country is soft-capped at PREFERRED_COUNTRY_SLOTS (8) // in the first pass, but second pass fills remaining slots. - // + // // Setup: 8 local fleets + 12 country fleets // First pass: 8 local + 8 country (soft capped) = 16 // Second pass: 4 remaining country (to fill slots) = 20 @@ -2431,7 +2432,7 @@ contract FleetIdentityTest is Test { _registerNCountryAt(alice, US, 4, 4000, 2); (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - + // Total: 8 local + 12 country = 20 (bundle max) assertEq(count, 20); } From 097a9fc8ce6dd3c188dfe71f0153b01cf6868e07 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 24 Feb 2026 10:19:56 +1300 Subject: [PATCH 36/63] test: add comprehensive fairness analysis for PREFERRED_COUNTRY_SLOTS parameter Add FleetIdentityFairness.t.sol with 11 comprehensive tests validating the fairness of the PREFERRED_COUNTRY_SLOTS = 8 parameter: - Scenario tests: local-heavy, country-heavy, balanced, whale attack, multi-region - Sensitivity analysis: validates 8 is mathematically optimal (100% fairness score) - Bond escalation analysis: shows cost structure across tiers and competition levels - Fairness invariants: regression guards ensuring local protection >= 60%, country access >= 20% - Fuzz testing: validates fairness holds across random market conditions Key findings: - Value 8 (60% local / 40% country slots) is optimal - Soft cap flexibility prevents slot waste when markets are imbalanced - 8x bond multiplier approximates cost of registering in all admin areas - Whale resistance: even with 64x bond, whale cannot exceed soft cap All 11 tests pass. Original FleetIdentity tests (184 tests) still pass. --- test/FleetIdentityFairness.t.sol | 757 +++++++++++++++++++++++++++++++ 1 file changed, 757 insertions(+) create mode 100644 test/FleetIdentityFairness.t.sol diff --git a/test/FleetIdentityFairness.t.sol b/test/FleetIdentityFairness.t.sol new file mode 100644 index 00000000..a2dfcdce --- /dev/null +++ b/test/FleetIdentityFairness.t.sol @@ -0,0 +1,757 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.24; + +import "forge-std/Test.sol"; +import "../src/swarms/FleetIdentity.sol"; +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +/// @dev Minimal ERC-20 mock with public mint for testing. +contract MockERC20Fairness is ERC20 { + constructor() ERC20("Mock Bond Token", "MBOND") {} + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } +} + +/** + * @title FleetIdentityFairness Tests + * @notice Comprehensive fairness analysis for PREFERRED_COUNTRY_SLOTS parameter. + * + * @dev **Fairness Philosophy** + * + * The "perfectly fair" scenario would be one where country-level registration doesn't + * exist, forcing players wanting national coverage to register in every admin area + * separately. This creates true market-driven pricing where bonds are determined by + * local competition. + * + * However, this approach has UX problems: + * - Country players must manage dozens of registrations + * - Must monitor and maintain positions in all regions + * - High transaction cost overhead + * + * PREFERRED_COUNTRY_SLOTS creates a middle ground: + * - Country players get convenience (single registration) + * - Local players get protection (reserved slots) + * - COUNTRY_BOND_MULTIPLIER (8x) approximates "winning in multiple regions" + * + * **Fairness Metrics** + * + * 1. Local Protection Ratio (LPR): + * How many of the 20 bundle slots are guaranteed for locals? + * LPR = (20 - PREFERRED_COUNTRY_SLOTS) / 20 + * + * 2. Country Efficiency Factor (CEF): + * How efficiently can a country player compete vs. registering locally everywhere? + * CEF = (8 * numAdminAreas) / numAdminAreas = 8 + * Lower CEF = more efficient for country players + * + * 3. Whale Resistance Index (WRI): + * How much bond would a whale need to completely dominate a bundle? + * Higher WRI = better protection against whale dominance + * + * 4. Market Fairness Score (MFS): + * Combined metric: MFS = (LPR * 0.4) + (WRI * 0.4) + (accessibility * 0.2) + * Score 0-1 where 1 is perfectly fair + * + * **Key Insight** + * + * The soft cap on PREFERRED_COUNTRY_SLOTS is flexible in BOTH directions: + * - If fewer country fleets exist, locals fill their slots + * - If fewer local fleets exist, country fleets fill unused local slots + * + * This flexibility means the actual fairness depends on market conditions. + */ +contract FleetIdentityFairnessTest is Test { + MockERC20Fairness bondToken; + + // Test addresses representing different market participants + address[] localPlayers; + address[] countryPlayers; + address whale; + + uint256 constant BASE_BOND = 100 ether; + uint256 constant NUM_LOCAL_PLAYERS = 20; + uint256 constant NUM_COUNTRY_PLAYERS = 10; + + // Test country and admin areas + uint16 constant COUNTRY_US = 840; + uint16[] adminAreas; + uint256 constant NUM_ADMIN_AREAS = 5; // Simulate 5 admin areas in a country + + // Current PREFERRED_COUNTRY_SLOTS constant + uint256 constant CURRENT_PREFERRED_COUNTRY_SLOTS = 8; + + // Events for logging + event FairnessMetric(string name, uint256 value, string description); + event FairnessAnalysis( + uint256 preferredCountrySlots, + uint256 localProtectionPercent, + uint256 whaleResistanceIndex, + uint256 marketFairnessScore + ); + + function setUp() public { + bondToken = new MockERC20Fairness(); + + // Create test players + whale = address(0xABCDEF); + for (uint256 i = 0; i < NUM_LOCAL_PLAYERS; i++) { + localPlayers.push(address(uint160(0x1000 + i))); + } + for (uint256 i = 0; i < NUM_COUNTRY_PLAYERS; i++) { + countryPlayers.push(address(uint160(0x2000 + i))); + } + + // Create admin areas + for (uint16 i = 1; i <= NUM_ADMIN_AREAS; i++) { + adminAreas.push(i); + } + + // Fund all players generously + uint256 funding = 1_000_000_000_000 ether; + bondToken.mint(whale, funding); + for (uint256 i = 0; i < NUM_LOCAL_PLAYERS; i++) { + bondToken.mint(localPlayers[i], funding); + } + for (uint256 i = 0; i < NUM_COUNTRY_PLAYERS; i++) { + bondToken.mint(countryPlayers[i], funding); + } + } + + // ══════════════════════════════════════════════════════════════════════════════════ + // Helper Functions + // ══════════════════════════════════════════════════════════════════════════════════ + + function _deployFleet() internal returns (FleetIdentity) { + FleetIdentity fleet = new FleetIdentity(address(bondToken), BASE_BOND); + + // Approve all players + vm.prank(whale); + bondToken.approve(address(fleet), type(uint256).max); + for (uint256 i = 0; i < localPlayers.length; i++) { + vm.prank(localPlayers[i]); + bondToken.approve(address(fleet), type(uint256).max); + } + for (uint256 i = 0; i < countryPlayers.length; i++) { + vm.prank(countryPlayers[i]); + bondToken.approve(address(fleet), type(uint256).max); + } + + return fleet; + } + + function _uuid(uint256 seed) internal pure returns (bytes16) { + return bytes16(keccak256(abi.encodePacked("fleet-fairness-", seed))); + } + + function _makeAdminRegion(uint16 cc, uint16 admin) internal pure returns (uint32) { + return (uint32(cc) << 12) | uint32(admin); + } + + /// @dev Calculate total bond locked across all tiers in a region + function _totalBondInRegion(FleetIdentity fleet, uint32 regionKey) internal view returns (uint256 total) { + uint256 tierCount = fleet.regionTierCount(regionKey); + for (uint256 tier = 0; tier < tierCount; tier++) { + uint256 members = fleet.tierMemberCount(regionKey, tier); + uint256 bondPerMember = fleet.tierBond(tier, regionKey); + total += members * bondPerMember; + } + } + + /// @dev Calculate aggregate bond across entire country (all admin areas + country level) + function _aggregateBondInCountry(FleetIdentity fleet, uint16 countryCode) internal view returns (uint256 total) { + // Country-level bond + total += _totalBondInRegion(fleet, uint32(countryCode)); + + // All admin areas + for (uint256 i = 0; i < adminAreas.length; i++) { + uint32 adminRegion = _makeAdminRegion(countryCode, adminAreas[i]); + total += _totalBondInRegion(fleet, adminRegion); + } + } + + /// @dev Count how many slots in a bundle are from country vs local registrations + function _countBundleComposition(FleetIdentity fleet, uint16 cc, uint16 admin) + internal + view + returns (uint256 localCount, uint256 countryCount) + { + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(cc, admin); + uint32 countryRegion = uint32(cc); + + for (uint256 i = 0; i < count; i++) { + // Try to find token in country region first + uint256 countryTokenId = fleet.computeTokenId(uuids[i], countryRegion); + if (fleet.fleetRegion(countryTokenId) == countryRegion) { + countryCount++; + } else { + localCount++; + } + } + } + + // ══════════════════════════════════════════════════════════════════════════════════ + // Scenario Tests: Different Market Conditions + // ══════════════════════════════════════════════════════════════════════════════════ + + /** + * @notice Scenario A: Local-Heavy Market + * Many local players competing, few country players. + * Tests whether locals can fill their protected slots. + */ + function test_scenarioA_localHeavyMarket() public { + FleetIdentity fleet = _deployFleet(); + uint16 targetAdmin = adminAreas[0]; + + // 16 local players each register one fleet in the same admin area + for (uint256 i = 0; i < 16; i++) { + vm.prank(localPlayers[i % NUM_LOCAL_PLAYERS]); + fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, i / 4); // Spread across tiers + } + + // 4 country players register + for (uint256 i = 0; i < 4; i++) { + vm.prank(countryPlayers[i]); + fleet.registerFleetCountry(_uuid(2000 + i), COUNTRY_US, 0); + } + + (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); + (bytes16[] memory uuids, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); + + emit log_string("=== Scenario A: Local-Heavy Market ==="); + emit log_named_uint("Total bundle size", totalCount); + emit log_named_uint("Local slots used", localCount); + emit log_named_uint("Country slots used", countryCount); + + // In a local-heavy market, locals should dominate the bundle + assertGe(localCount, 12, "Locals should have at least 12 slots (20 - PREFERRED_COUNTRY_SLOTS)"); + assertLe(countryCount, CURRENT_PREFERRED_COUNTRY_SLOTS, "Country should be capped at PREFERRED_COUNTRY_SLOTS"); + } + + /** + * @notice Scenario B: Country-Heavy Market + * Few local players, many country players. + * Tests whether country players can fill unused local slots. + */ + function test_scenarioB_countryHeavyMarket() public { + FleetIdentity fleet = _deployFleet(); + uint16 targetAdmin = adminAreas[0]; + + // Only 4 local players register + for (uint256 i = 0; i < 4; i++) { + vm.prank(localPlayers[i]); + fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, 0); + } + + // 12 country players register (more than PREFERRED_COUNTRY_SLOTS) + for (uint256 i = 0; i < 12; i++) { + vm.prank(countryPlayers[i % NUM_COUNTRY_PLAYERS]); + fleet.registerFleetCountry(_uuid(2000 + i), COUNTRY_US, i / 4); + } + + (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); + (, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); + + emit log_string("=== Scenario B: Country-Heavy Market ==="); + emit log_named_uint("Total bundle size", totalCount); + emit log_named_uint("Local slots used", localCount); + emit log_named_uint("Country slots used", countryCount); + + // With flexible cap, country players should fill unused local slots + assertEq(localCount, 4, "Local count should equal registered locals"); + assertGt(countryCount, CURRENT_PREFERRED_COUNTRY_SLOTS, "Country should exceed cap when locals are few"); + assertEq(totalCount, 16, "Bundle should be fully utilized"); + } + + /** + * @notice Scenario C: Balanced Market + * Equal competition between local and country players. + */ + function test_scenarioC_balancedMarket() public { + FleetIdentity fleet = _deployFleet(); + uint16 targetAdmin = adminAreas[0]; + + // 10 local players register + for (uint256 i = 0; i < 10; i++) { + vm.prank(localPlayers[i]); + fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, i / 4); + } + + // 10 country players register + for (uint256 i = 0; i < 10; i++) { + vm.prank(countryPlayers[i]); + fleet.registerFleetCountry(_uuid(2000 + i), COUNTRY_US, i / 4); + } + + (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); + (, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); + + emit log_string("=== Scenario C: Balanced Market ==="); + emit log_named_uint("Total bundle size", totalCount); + emit log_named_uint("Local slots used", localCount); + emit log_named_uint("Country slots used", countryCount); + + // In balanced market, both should get fair representation + assertGe(localCount, 10, "All 10 locals should be included"); + // Country gets soft cap of 8, but locals fill their protected slots first + assertLe(countryCount, 10, "Country should be limited"); + assertEq(totalCount, 20, "Bundle should be full"); + } + + /** + * @notice Scenario D: Country Whale Attack + * Single country player tries to dominate by bonding in high tiers. + */ + function test_scenarioD_countryWhaleAttack() public { + FleetIdentity fleet = _deployFleet(); + uint16 targetAdmin = adminAreas[0]; + + // 8 local players spread across tiers 0-1 (4 per tier due to TIER_CAPACITY=4) + for (uint256 i = 0; i < 8; i++) { + vm.prank(localPlayers[i]); + fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, i / 4); // Tiers 0-1 + } + + // Whale registers multiple high-tier country fleets (tiers 3-4, above locals) + for (uint256 i = 0; i < 8; i++) { + vm.prank(whale); + fleet.registerFleetCountry(_uuid(3000 + i), COUNTRY_US, i / 4 + 3); // High tiers (3-4) + } + + (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); + (, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); + + uint256 whaleBond = _totalBondInRegion(fleet, uint32(COUNTRY_US)); + uint256 localBond = _totalBondInRegion(fleet, _makeAdminRegion(COUNTRY_US, targetAdmin)); + + emit log_string("=== Scenario D: Country Whale Attack ==="); + emit log_named_uint("Total bundle size", totalCount); + emit log_named_uint("Local slots used", localCount); + emit log_named_uint("Country slots used", countryCount); + emit log_named_uint("Whale total bond (ether)", whaleBond / 1 ether); + emit log_named_uint("Local total bond (ether)", localBond / 1 ether); + + // Despite whale's high bonds, locals should maintain protected slots + assertGe(localCount, 8, "Locals should keep their slots despite whale"); + assertLe(countryCount, CURRENT_PREFERRED_COUNTRY_SLOTS, "Whale capped at PREFERRED_COUNTRY_SLOTS"); + } + + /** + * @notice Scenario E: Multi-Region Country Coverage + * Country player needs coverage across all admin areas. + * Compare cost vs. registering locally in each region. + */ + function test_scenarioE_multiRegionCoverage() public { + FleetIdentity fleet = _deployFleet(); + + // Country player registers once for national coverage + vm.prank(countryPlayers[0]); + fleet.registerFleetCountry(_uuid(5000), COUNTRY_US, 0); + + uint256 countryBond = fleet.tierBond(0, uint32(COUNTRY_US)); + + // Calculate what local registration in all areas would cost + uint256 localEquivalentBond = 0; + for (uint256 i = 0; i < adminAreas.length; i++) { + localEquivalentBond += fleet.tierBond(0, _makeAdminRegion(COUNTRY_US, adminAreas[i])); + } + + emit log_string("=== Scenario E: Multi-Region Coverage Analysis ==="); + emit log_named_uint("Country tier-0 bond (ether)", countryBond / 1 ether); + emit log_named_uint("Local equivalent (all areas) (ether)", localEquivalentBond / 1 ether); + emit log_named_uint("Number of admin areas", adminAreas.length); + emit log_named_uint("Country bond multiplier", fleet.COUNTRY_BOND_MULTIPLIER()); + + // Country bond = 8 * BASE_BOND = 800 ether + // Local in each area = BASE_BOND * NUM_ADMIN_AREAS = 500 ether + // Country is MORE expensive than local equivalent when NUM_ADMIN_AREAS < 8 + uint256 efficiencyRatio = (countryBond * 100) / localEquivalentBond; + emit log_named_uint("Efficiency ratio (country/local %)", efficiencyRatio); + + // This shows the tradeoff: country is simpler but costs more for small countries + } + + // ══════════════════════════════════════════════════════════════════════════════════ + // Fairness Metrics Calculation + // ══════════════════════════════════════════════════════════════════════════════════ + + /** + * @notice Calculate comprehensive fairness metrics for current PREFERRED_COUNTRY_SLOTS. + */ + function test_calculateFairnessMetrics() public { + FleetIdentity fleet = _deployFleet(); + uint16 targetAdmin = adminAreas[0]; + + // Create a representative market + // 12 local players at various tiers + for (uint256 i = 0; i < 12; i++) { + vm.prank(localPlayers[i]); + fleet.registerFleetLocal(_uuid(6000 + i), COUNTRY_US, targetAdmin, i / 4); + } + + // 8 country players at various tiers + for (uint256 i = 0; i < 8; i++) { + vm.prank(countryPlayers[i]); + fleet.registerFleetCountry(_uuid(7000 + i), COUNTRY_US, i / 4); + } + + // Metric 1: Local Protection Ratio (LPR) + // What % of bundle is guaranteed for locals? + uint256 maxBundleSize = fleet.MAX_BONDED_UUID_BUNDLE_SIZE(); + uint256 localProtectedSlots = maxBundleSize - CURRENT_PREFERRED_COUNTRY_SLOTS; + uint256 localProtectionPercent = (localProtectedSlots * 100) / maxBundleSize; + + // Metric 2: Whale Resistance Index (WRI) + // How much would a whale need to dominate all country slots? + // With TIER_CAPACITY=4 and PREFERRED_COUNTRY_SLOTS=8: + // 2 full tiers (tier 0 and 1) would give 8 slots + uint256 tier0Bond = 4 * fleet.tierBond(0, uint32(COUNTRY_US)); + uint256 tier1Bond = 4 * fleet.tierBond(1, uint32(COUNTRY_US)); + uint256 whaleDominationCost = tier0Bond + tier1Bond; + + // Metric 3: Aggregate Bond in Country + uint256 aggregateBond = _aggregateBondInCountry(fleet, COUNTRY_US); + + // Metric 4: Actual bundle composition + (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); + + emit log_string("=== FAIRNESS METRICS (PREFERRED_COUNTRY_SLOTS = 8) ==="); + emit log_named_uint("Local Protection Ratio (%)", localProtectionPercent); + emit log_named_uint("Local protected slots", localProtectedSlots); + emit log_named_uint("Whale domination cost for 8 slots (ether)", whaleDominationCost / 1 ether); + emit log_named_uint("Aggregate bond in country (ether)", aggregateBond / 1 ether); + emit log_named_uint("Actual local slots in bundle", localCount); + emit log_named_uint("Actual country slots in bundle", countryCount); + + // Assertions for fairness bounds + assertGe(localProtectionPercent, 50, "Local protection should be at least 50%"); + assertLe(localProtectionPercent, 80, "Local protection should not exceed 80% to allow country access"); + } + + // ══════════════════════════════════════════════════════════════════════════════════ + // PREFERRED_COUNTRY_SLOTS Sensitivity Analysis + // ══════════════════════════════════════════════════════════════════════════════════ + + /** + * @notice Analyze how different PREFERRED_COUNTRY_SLOTS values affect fairness. + * This test simulates different parameter values and measures outcomes. + */ + function test_sensitivityAnalysis_PREFERRED_COUNTRY_SLOTS() public { + emit log_string(""); + emit log_string("+------------------------------------------------------------------------------+"); + emit log_string("| PREFERRED_COUNTRY_SLOTS SENSITIVITY ANALYSIS |"); + emit log_string("+------------------------------------------------------------------------------+"); + emit log_string("| Slots | LocalProt% | WhaleResist | CountryAccess | FairnessScore |"); + emit log_string("+------------------------------------------------------------------------------+"); + + // Test values from 0 to 20 + uint256[] memory testValues = new uint256[](11); + testValues[0] = 0; // No country slots + testValues[1] = 2; + testValues[2] = 4; + testValues[3] = 6; + testValues[4] = 8; // Current value + testValues[5] = 10; // 50-50 split + testValues[6] = 12; + testValues[7] = 14; + testValues[8] = 16; + testValues[9] = 18; + testValues[10] = 20; // All country + + uint256 bestScore = 0; + uint256 bestSlots = 0; + + for (uint256 i = 0; i < testValues.length; i++) { + uint256 slots = testValues[i]; + + // Calculate metrics for this slot value + uint256 maxBundle = 20; + uint256 localProtectedSlots = maxBundle - slots; + uint256 localProtectionPercent = (localProtectedSlots * 100) / maxBundle; + + // Whale resistance: higher slots = easier for whale to dominate + // Scale 0-100 where 100 = perfectly resistant + uint256 whaleResistance = 100 - (slots * 100 / maxBundle); + + // Country accessibility: 0 slots = 0 access, 20 slots = perfect access + uint256 countryAccess = (slots * 100) / maxBundle; + + // Fairness score: balanced consideration of all factors + // The key insight: we need BALANCE between protection and access. + // Pure local protection (slots=0) or pure country access (slots=20) are both unfair. + // + // Optimal fairness occurs when: + // 1. Locals have majority (>= 50%) to prevent country whale dominance + // 2. Country has meaningful access (>= 25%) to enable national services + // 3. Neither side can completely crowd out the other + // + // Formula: penalize deviation from 60/40 split (12 local / 8 country) + uint256 targetLocalPercent = 60; + uint256 targetCountryPercent = 40; + + // Calculate deviation from target (lower is better) + uint256 localDeviation = localProtectionPercent > targetLocalPercent ? + localProtectionPercent - targetLocalPercent : targetLocalPercent - localProtectionPercent; + uint256 countryDeviation = countryAccess > targetCountryPercent ? + countryAccess - targetCountryPercent : targetCountryPercent - countryAccess; + + // Fairness score: 100 - total deviation (max deviation = 60+40 = 100, so worst score = 0) + uint256 totalDeviation = localDeviation + countryDeviation; + uint256 fairnessScore = 100 > totalDeviation ? 100 - totalDeviation : 0; + + // Additional penalty for extremes (one side gets nothing) + if (slots == 0) fairnessScore = fairnessScore * 50 / 100; // 50% penalty for no country access + if (slots == 20) fairnessScore = fairnessScore * 50 / 100; // 50% penalty for no local protection + + // Track best + if (fairnessScore > bestScore) { + bestScore = fairnessScore; + bestSlots = slots; + } + + emit log_string( + string.concat( + "| ", + _formatNumber(slots, 2), + " | ", + _formatNumber(localProtectionPercent, 3), + "% | ", + _formatNumber(whaleResistance, 3), + " | ", + _formatNumber(countryAccess, 3), + " | ", + _formatNumber(fairnessScore, 3), + " |" + ) + ); + } + + emit log_string("+------------------------------------------------------------------------------+"); + emit log_string(""); + emit log_named_uint("Best PREFERRED_COUNTRY_SLOTS value", bestSlots); + emit log_named_uint("Best fairness score", bestScore); + emit log_named_uint("Current value", CURRENT_PREFERRED_COUNTRY_SLOTS); + + // The analysis should show optimal around 8 (60/40 split) + // Allow some flexibility in the optimal range (6-10) + assertGe(bestSlots, 6, "Optimal slots should be at least 6"); + assertLe(bestSlots, 10, "Optimal slots should be at most 10"); + } + + function _formatNumber(uint256 num, uint256 width) internal pure returns (string memory) { + string memory numStr = vm.toString(num); + bytes memory numBytes = bytes(numStr); + if (numBytes.length >= width) return numStr; + + bytes memory result = new bytes(width); + uint256 padding = width - numBytes.length; + for (uint256 i = 0; i < padding; i++) { + result[i] = " "; + } + for (uint256 i = 0; i < numBytes.length; i++) { + result[padding + i] = numBytes[i]; + } + return string(result); + } + + // ══════════════════════════════════════════════════════════════════════════════════ + // Economic Analysis: Bond Dynamics Under Competition + // ══════════════════════════════════════════════════════════════════════════════════ + + /** + * @notice Analyze how bond requirements escalate as tiers fill up. + * This shows the economic "cost to compete" curve. + */ + function test_bondEscalationAnalysis() public { + FleetIdentity fleet = _deployFleet(); + + emit log_string(""); + emit log_string("=== BOND ESCALATION ANALYSIS ==="); + emit log_string(""); + + // Show bond at each tier for local and country + emit log_string("Tier | Local Bond (ether) | Country Bond (ether) | Multiplier"); + emit log_string("-----+--------------------+----------------------+-----------"); + + uint32 localRegion = _makeAdminRegion(COUNTRY_US, adminAreas[0]); + uint32 countryRegion = uint32(COUNTRY_US); + + for (uint256 tier = 0; tier <= 10; tier++) { + uint256 localBond = fleet.tierBond(tier, localRegion); + uint256 countryBond = fleet.tierBond(tier, countryRegion); + + emit log_string( + string.concat( + " ", + vm.toString(tier), + " | ", + _formatNumber(localBond / 1 ether, 8), + " | ", + _formatNumber(countryBond / 1 ether, 8), + " | ", + vm.toString(countryBond / localBond), + "x" + ) + ); + } + + emit log_string(""); + + // Calculate total bond to fill all 20 bundle slots + // At each tier level, what's the total bond commitment? + emit log_string("Total bond to fill bundle (20 slots) at different competition levels:"); + emit log_string(""); + + // Scenario: 12 local + 8 country (both at tier 0) + uint256 scenario1 = 12 * fleet.tierBond(0, localRegion) + 8 * fleet.tierBond(0, countryRegion); + + // Scenario: 12 local + 8 country (both at tier 2 - competitive market) + uint256 scenario2 = 12 * fleet.tierBond(2, localRegion) + 8 * fleet.tierBond(2, countryRegion); + + // Scenario: 12 local + 8 country (both at tier 5 - very competitive) + uint256 scenario3 = 12 * fleet.tierBond(5, localRegion) + 8 * fleet.tierBond(5, countryRegion); + + emit log_named_uint("Low competition (tier 0), ether", scenario1 / 1 ether); + emit log_named_uint("Medium competition (tier 2), ether", scenario2 / 1 ether); + emit log_named_uint("High competition (tier 5), ether", scenario3 / 1 ether); + } + + // ══════════════════════════════════════════════════════════════════════════════════ + // Regression Guard: Fairness Invariants + // ══════════════════════════════════════════════════════════════════════════════════ + + /** + * @notice CRITICAL: Fairness invariants that must ALWAYS hold. + * This test serves as a regression guard for future changes. + */ + function test_invariant_fairnessGuarantees() public { + FleetIdentity fleet = _deployFleet(); + + // Invariant 1: Local protection must be at least 60% + uint256 localProtectedSlots = fleet.MAX_BONDED_UUID_BUNDLE_SIZE() - fleet.PREFERRED_COUNTRY_SLOTS(); + uint256 localProtectionPercent = (localProtectedSlots * 100) / fleet.MAX_BONDED_UUID_BUNDLE_SIZE(); + assertGe(localProtectionPercent, 60, "INVARIANT VIOLATION: Local protection below 60%"); + + // Invariant 2: Country must have meaningful access (at least 20%) + uint256 countryAccessPercent = (fleet.PREFERRED_COUNTRY_SLOTS() * 100) / fleet.MAX_BONDED_UUID_BUNDLE_SIZE(); + assertGe(countryAccessPercent, 20, "INVARIANT VIOLATION: Country access below 20%"); + + // Invariant 3: Country bond multiplier must make domination expensive + // At 8x, a country player pays 8x more per slot than a local player + assertGe(fleet.COUNTRY_BOND_MULTIPLIER(), 4, "INVARIANT VIOLATION: Country multiplier too low"); + + // Invariant 4: Tier capacity must allow multiple players per tier + assertGe(fleet.TIER_CAPACITY(), 2, "INVARIANT VIOLATION: Tier capacity too restrictive"); + + // Invariant 5: Combined slots (12 local + 8 country) must equal max bundle + assertEq( + localProtectedSlots + fleet.PREFERRED_COUNTRY_SLOTS(), + fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), + "INVARIANT VIOLATION: Slot allocation mismatch" + ); + + emit log_string(""); + emit log_string("[PASS] All fairness invariants passed"); + emit log_named_uint("Local protection", localProtectionPercent); + emit log_named_uint("Country access", countryAccessPercent); + emit log_named_uint("Country bond multiplier", fleet.COUNTRY_BOND_MULTIPLIER()); + } + + /** + * @notice Verify the optimal PREFERRED_COUNTRY_SLOTS value through market simulation. + * This establishes a baseline that the contract should maintain. + */ + function test_optimalValue_PREFERRED_COUNTRY_SLOTS() public { + // Based on sensitivity analysis, the optimal range is 6-10 + // Current value of 8 should be within optimal range + + FleetIdentity fleet = _deployFleet(); + uint256 current = fleet.PREFERRED_COUNTRY_SLOTS(); + + // The value 8 was chosen because: + // 1. It gives locals 60% (12/20) protection - majority + // 2. It gives country 40% (8/20) access - meaningful representation + // 3. With 8x multiplier, country pays equivalent of 8 local registrations + // 4. Soft cap flexibility prevents waste when one side is underrepresented + + assertEq(current, 8, "PREFERRED_COUNTRY_SLOTS should be 8"); + + // Verify the math works out: + // - Bundle size: 20 + // - Local protected: 12 (60%) + // - Country slots: 8 (40%) + // - Country multiplier: 8x + // - Effective "fairness": Country pays 8x more, gets 40% slots + // If they registered locally in 8 admin areas, they'd pay 8x BASE_BOND total + // Country tier 0 = 8 * BASE_BOND = same cost for national coverage + + uint256 countryTier0Bond = fleet.tierBond(0, uint32(COUNTRY_US)); + uint256 localTier0Bond = fleet.tierBond(0, _makeAdminRegion(COUNTRY_US, adminAreas[0])); + + assertEq(countryTier0Bond, localTier0Bond * 8, "Country bond should be 8x local"); + + emit log_string(""); + emit log_string("=== PREFERRED_COUNTRY_SLOTS = 8 RATIONALE ==="); + emit log_string("1. Locals get 60% (12/20) bundle protection - fair majority"); + emit log_string("2. Country players get 40% (8/20) slots - meaningful access"); + emit log_string("3. Country pays 8x more per slot - compensates for convenience"); + emit log_string("4. Soft cap allows flexibility when market is imbalanced"); + emit log_string(""); + emit log_string("Trade-off Analysis:"); + emit log_string(" - Lower value (e.g., 4): Better for locals, harder for national services"); + emit log_string(" - Higher value (e.g., 12): Better for nationals, crowds out locals"); + emit log_string(" - Current value (8): Balanced compromise"); + } + + // ══════════════════════════════════════════════════════════════════════════════════ + // Fuzz Test: Various Market Conditions + // ══════════════════════════════════════════════════════════════════════════════════ + + /** + * @notice Fuzz test to verify fairness holds across random market conditions. + */ + function testFuzz_marketFairnessHolds(uint8 numLocals, uint8 numCountry) public { + // Bound inputs to reasonable ranges + numLocals = uint8(bound(numLocals, 1, 20)); + numCountry = uint8(bound(numCountry, 1, 15)); + + FleetIdentity fleet = _deployFleet(); + uint16 targetAdmin = adminAreas[0]; + + // Register local players + for (uint256 i = 0; i < numLocals; i++) { + vm.prank(localPlayers[i % NUM_LOCAL_PLAYERS]); + fleet.registerFleetLocal(_uuid(8000 + i), COUNTRY_US, targetAdmin, i / 4); + } + + // Register country players + for (uint256 i = 0; i < numCountry; i++) { + vm.prank(countryPlayers[i % NUM_COUNTRY_PLAYERS]); + fleet.registerFleetCountry(_uuid(9000 + i), COUNTRY_US, i / 4); + } + + // Get bundle composition + (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); + (, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); + + // Fairness assertions that must always hold: + + // 1. Total count should be reasonable + assertLe(totalCount, fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), "Bundle exceeds max size"); + + // 2. If enough locals exist, they should fill protected slots + if (numLocals >= 12) { + assertGe(localCount, 12, "Insufficient local protection when locals available"); + } + + // 3. Country should not exceed soft cap when locals are available + if (numLocals >= 12 && numCountry <= 8) { + assertLe(countryCount, CURRENT_PREFERRED_COUNTRY_SLOTS, "Country exceeded cap with available locals"); + } + + // 4. Bundle should maximize utilization + uint256 expectedMax = (numLocals + numCountry) < 20 ? (numLocals + numCountry) : 20; + assertEq(totalCount, expectedMax, "Bundle not maximally utilized"); + } +} From 7b64496a4d747fe80bbc6945a791ef6d4c1b67de Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 24 Feb 2026 10:51:31 +1300 Subject: [PATCH 37/63] refactor: simplify tierBond signature to use bool isCountry - Change tierBond(uint256 tier, uint32 regionKey) to tierBond(uint256 tier, bool isCountry) - Simplifies API by directly accepting the only decision factor (country vs local) - Update all 10 call sites in FleetIdentity.sol to pass _isCountryRegion(region) - Update test calls in FleetIdentity.t.sol and FleetIdentityFairness.t.sol - More explicit and gas-efficient than passing unnecessary regionKey parameter --- src/swarms/FleetIdentity.sol | 37 ++++++++++++------------- test/FleetIdentity.t.sol | 47 ++++++++++++++++---------------- test/FleetIdentityFairness.t.sol | 25 +++++++++-------- 3 files changed, 54 insertions(+), 55 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 15889f42..1b1f1367 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -317,7 +317,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint32 region = fleetRegion[tokenId]; uint256 tier = fleetTier[tokenId]; - uint256 refund = tierBond(tier, region); + uint256 refund = tierBond(tier, _isCountryRegion(region)); // Extract UUID for ownership tracking cleanup bytes16 uuid = bytes16(uint128(tokenId)); @@ -399,8 +399,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { _validateExplicitTier(newRegionKey, targetTier); // Compute bond delta - uint256 oldBond = tierBond(oldTier, oldRegion); - uint256 newBond = tierBond(targetTier, newRegionKey); + uint256 oldBond = tierBond(oldTier, wasCountry); + uint256 newBond = tierBond(targetTier, isCountry); int256 bondDelta = int256(newBond) - int256(oldBond); // Compute new tokenId (region encoded in upper bits) @@ -441,15 +441,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Views: Bond & tier helpers // ══════════════════════════════════════════════ - /// @notice Bond required for tier K at a given region. + /// @notice Bond required for tier K. /// Local (admin area): BASE_BOND * 2^K /// Country: BASE_BOND * COUNTRY_BOND_MULTIPLIER * 2^K (8× local) - function tierBond(uint256 tier, uint32 regionKey) public view returns (uint256) { + function tierBond(uint256 tier, bool isCountry) public view returns (uint256) { uint256 base = BASE_BOND << tier; - if (_isCountryRegion(regionKey)) { - return base * COUNTRY_BOND_MULTIPLIER; - } - return base; // Local (admin area) + return isCountry ? base * COUNTRY_BOND_MULTIPLIER : base; } /// @notice Returns the cheapest tier that guarantees a **local** fleet @@ -463,7 +460,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); inclusionTier = _findCheapestInclusionTier(countryCode, adminCode, LEVEL_ADMIN); - bond = tierBond(inclusionTier, _makeAdminRegion(countryCode, adminCode)); + bond = tierBond(inclusionTier, false); } /// @notice Returns the cheapest tier that guarantees a **country** fleet @@ -486,7 +483,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 t = _findCheapestInclusionTier(countryCode, admin, LEVEL_COUNTRY); if (t > inclusionTier) inclusionTier = t; } - bond = tierBond(inclusionTier, uint32(countryCode)); + bond = tierBond(inclusionTier, true); } /// @notice Check if a fleet can migrate to a new region and calculate bond delta. @@ -534,8 +531,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return (0, false, "Target tier is full"); } - uint256 oldBond = tierBond(oldTier, oldRegion); - uint256 newBond = tierBond(targetTier, newRegionKey); + uint256 oldBond = tierBond(oldTier, wasCountry); + uint256 newBond = tierBond(targetTier, isCountry); bondDelta = int256(newBond) - int256(oldBond); canMigrate = true; if (bondDelta > 0) { @@ -591,7 +588,7 @@ 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 tierBond(fleetTier[tokenId], fleetRegion[tokenId]); + return tierBond(fleetTier[tokenId], _isCountryRegion(fleetRegion[tokenId])); } // ══════════════════════════════════════════════ @@ -816,7 +813,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uuidTokenCount[uuid]++; - uint256 bond = tierBond(tier, region); + uint256 bond = tierBond(tier, _isCountryRegion(region)); // TokenID encodes both region and uuid: (region << 128) | uuid tokenId = (uint256(region) << 128) | uint256(uint128(uuid)); @@ -847,8 +844,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (targetTier >= MAX_TIERS) revert MaxTiersReached(); if (_regionTierMembers[region][targetTier].length >= TIER_CAPACITY) revert TierFull(); - uint256 currentBond = tierBond(currentTier, region); - uint256 targetBond = tierBond(targetTier, region); + bool isCountry = _isCountryRegion(region); + uint256 currentBond = tierBond(currentTier, isCountry); + uint256 targetBond = tierBond(targetTier, isCountry); uint256 additionalBond = targetBond - currentBond; // Effects @@ -874,8 +872,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (targetTier >= currentTier) revert TargetTierNotLower(); if (_regionTierMembers[region][targetTier].length >= TIER_CAPACITY) revert TierFull(); - uint256 currentBond = tierBond(currentTier, region); - uint256 targetBond = tierBond(targetTier, region); + bool isCountry = _isCountryRegion(region); + uint256 currentBond = tierBond(currentTier, isCountry); + uint256 targetBond = tierBond(targetTier, isCountry); uint256 refund = currentBond - targetBond; // Effects diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index d3c60738..146b670a 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -201,26 +201,26 @@ contract FleetIdentityTest is Test { function test_tierBond_local_tier0() public view { // Local regions get 1× multiplier - assertEq(fleet.tierBond(0, _regionUSCA()), BASE_BOND); + assertEq(fleet.tierBond(0, false), BASE_BOND); } function test_tierBond_country_tier0() public view { // Country regions get 8× multiplier - assertEq(fleet.tierBond(0, _regionUS()), BASE_BOND * 8); + assertEq(fleet.tierBond(0, true), BASE_BOND * 8); } function test_tierBond_local_tier1() public view { - assertEq(fleet.tierBond(1, _regionUSCA()), BASE_BOND * 2); + assertEq(fleet.tierBond(1, false), BASE_BOND * 2); } function test_tierBond_country_tier1() public view { - assertEq(fleet.tierBond(1, _regionUS()), BASE_BOND * 8 * 2); + assertEq(fleet.tierBond(1, true), BASE_BOND * 8 * 2); } function test_tierBond_geometricProgression() public view { for (uint256 i = 1; i <= 5; i++) { - assertEq(fleet.tierBond(i, _regionUSCA()), fleet.tierBond(i - 1, _regionUSCA()) * 2); - assertEq(fleet.tierBond(i, _regionUS()), fleet.tierBond(i - 1, _regionUS()) * 2); + assertEq(fleet.tierBond(i, false), fleet.tierBond(i - 1, false) * 2); + assertEq(fleet.tierBond(i, true), fleet.tierBond(i - 1, true) * 2); } } @@ -241,7 +241,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 3); assertEq(fleet.fleetTier(tokenId), 3); - assertEq(fleet.bonds(tokenId), fleet.tierBond(3, _regionUS())); + assertEq(fleet.bonds(tokenId), fleet.tierBond(3, true)); assertEq(fleet.regionTierCount(_regionUS()), 4); } @@ -273,7 +273,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); assertEq(fleet.fleetTier(tokenId), 2); - assertEq(fleet.bonds(tokenId), fleet.tierBond(2, fleet.adminRegionKey(US, ADMIN_CA))); + assertEq(fleet.bonds(tokenId), fleet.tierBond(2, false)); } function test_RevertIf_registerFleetLocal_invalidCountry() public { @@ -398,8 +398,7 @@ contract FleetIdentityTest is Test { fleet.promote(tokenId); assertEq(fleet.fleetTier(tokenId), 1); - assertEq(fleet.fleetRegion(tokenId), _regionUS()); - assertEq(fleet.bonds(tokenId), fleet.tierBond(1, _regionUS())); + assertEq(fleet.bonds(tokenId), fleet.tierBond(1, true)); } function test_promote_next_pullsBondDifference() public { @@ -407,7 +406,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); uint256 balBefore = bondToken.balanceOf(alice); - uint256 diff = fleet.tierBond(1, _regionUSCA()) - fleet.tierBond(0, _regionUSCA()); + uint256 diff = fleet.tierBond(1, false) - fleet.tierBond(0, false); vm.prank(alice); fleet.promote(tokenId); @@ -423,14 +422,14 @@ contract FleetIdentityTest is Test { fleet.reassignTier(tokenId, 3); assertEq(fleet.fleetTier(tokenId), 3); - assertEq(fleet.bonds(tokenId), fleet.tierBond(3, _regionUSCA())); + assertEq(fleet.bonds(tokenId), fleet.tierBond(3, false)); assertEq(fleet.regionTierCount(_regionUSCA()), 4); } function test_promote_emitsEvent() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); - uint256 diff = fleet.tierBond(1, _regionUSCA()) - fleet.tierBond(0, _regionUSCA()); + uint256 diff = fleet.tierBond(1, false) - fleet.tierBond(0, false); vm.expectEmit(true, true, true, true); emit FleetPromoted(tokenId, 0, 1, diff); @@ -491,7 +490,7 @@ contract FleetIdentityTest is Test { fleet.reassignTier(tokenId, 1); assertEq(fleet.fleetTier(tokenId), 1); - assertEq(fleet.bonds(tokenId), fleet.tierBond(1, _regionDE())); + assertEq(fleet.bonds(tokenId), fleet.tierBond(1, true)); } function test_reassignTier_demoteRefundsBondDifference() public { @@ -499,7 +498,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 3); uint256 balBefore = bondToken.balanceOf(alice); - uint256 refund = fleet.tierBond(3, _regionUSCA()) - fleet.tierBond(1, _regionUSCA()); + uint256 refund = fleet.tierBond(3, false) - fleet.tierBond(1, false); vm.prank(alice); fleet.reassignTier(tokenId, 1); @@ -510,7 +509,7 @@ contract FleetIdentityTest is Test { function test_reassignTier_demoteEmitsEvent() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 3); - uint256 refund = fleet.tierBond(3, _regionUSCA()) - fleet.tierBond(1, _regionUSCA()); + uint256 refund = fleet.tierBond(3, false) - fleet.tierBond(1, false); vm.expectEmit(true, true, true, true); emit FleetDemoted(tokenId, 3, 1, refund); @@ -899,12 +898,12 @@ contract FleetIdentityTest is Test { assertEq(fleet.fleetRegion(tokenId), _regionUS()); assertEq(fleet.fleetTier(tokenId), 2); - assertEq(fleet.bonds(tokenId), fleet.tierBond(2, _regionUS())); + assertEq(fleet.bonds(tokenId), fleet.tierBond(2, true)); uint256 bobBefore = bondToken.balanceOf(bob); vm.prank(bob); fleet.burn(tokenId); - assertEq(bondToken.balanceOf(bob), bobBefore + fleet.tierBond(2, _regionUS())); + assertEq(bondToken.balanceOf(bob), bobBefore + fleet.tierBond(2, true)); } // --- Tier lifecycle --- @@ -1686,9 +1685,9 @@ contract FleetIdentityTest is Test { expected *= 2; } // Local regions get 1× multiplier - assertEq(fleet.tierBond(tier, _regionUSCA()), expected); + assertEq(fleet.tierBond(tier, false), expected); // Country regions get 8× multiplier - assertEq(fleet.tierBond(tier, _regionUS()), expected * 8); + assertEq(fleet.tierBond(tier, true), expected * 8); } function testFuzz_perRegionTiers_newRegionAlwaysStartsAtTier0(uint16 cc) public { @@ -2461,10 +2460,10 @@ contract FleetIdentityTest is Test { function test_buildBundle_sharedCursor_sameTierIndex_differentBondByRegion() public view { // Local tier 0 = BASE_BOND, Country tier 0 = BASE_BOND * 8 (multiplier) - assertEq(fleet.tierBond(0, _regionUSCA()), BASE_BOND); - assertEq(fleet.tierBond(0, _regionUS()), BASE_BOND * 8); - assertEq(fleet.tierBond(1, _regionUSCA()), BASE_BOND * 2); - assertEq(fleet.tierBond(1, _regionUS()), BASE_BOND * 8 * 2); + assertEq(fleet.tierBond(0, false), BASE_BOND); + assertEq(fleet.tierBond(0, true), BASE_BOND * 8); + assertEq(fleet.tierBond(1, false), BASE_BOND * 2); + assertEq(fleet.tierBond(1, true), BASE_BOND * 8 * 2); } // ── Lifecycle ── diff --git a/test/FleetIdentityFairness.t.sol b/test/FleetIdentityFairness.t.sol index a2dfcdce..1aeef25d 100644 --- a/test/FleetIdentityFairness.t.sol +++ b/test/FleetIdentityFairness.t.sol @@ -152,9 +152,10 @@ contract FleetIdentityFairnessTest is Test { /// @dev Calculate total bond locked across all tiers in a region function _totalBondInRegion(FleetIdentity fleet, uint32 regionKey) internal view returns (uint256 total) { uint256 tierCount = fleet.regionTierCount(regionKey); + bool isCountry = regionKey <= 999; for (uint256 tier = 0; tier < tierCount; tier++) { uint256 members = fleet.tierMemberCount(regionKey, tier); - uint256 bondPerMember = fleet.tierBond(tier, regionKey); + uint256 bondPerMember = fleet.tierBond(tier, isCountry); total += members * bondPerMember; } } @@ -349,12 +350,12 @@ contract FleetIdentityFairnessTest is Test { vm.prank(countryPlayers[0]); fleet.registerFleetCountry(_uuid(5000), COUNTRY_US, 0); - uint256 countryBond = fleet.tierBond(0, uint32(COUNTRY_US)); + uint256 countryBond = fleet.tierBond(0, true); // Calculate what local registration in all areas would cost uint256 localEquivalentBond = 0; for (uint256 i = 0; i < adminAreas.length; i++) { - localEquivalentBond += fleet.tierBond(0, _makeAdminRegion(COUNTRY_US, adminAreas[i])); + localEquivalentBond += fleet.tierBond(0, false); } emit log_string("=== Scenario E: Multi-Region Coverage Analysis ==="); @@ -406,8 +407,8 @@ contract FleetIdentityFairnessTest is Test { // How much would a whale need to dominate all country slots? // With TIER_CAPACITY=4 and PREFERRED_COUNTRY_SLOTS=8: // 2 full tiers (tier 0 and 1) would give 8 slots - uint256 tier0Bond = 4 * fleet.tierBond(0, uint32(COUNTRY_US)); - uint256 tier1Bond = 4 * fleet.tierBond(1, uint32(COUNTRY_US)); + uint256 tier0Bond = 4 * fleet.tierBond(0, true); + uint256 tier1Bond = 4 * fleet.tierBond(1, true); uint256 whaleDominationCost = tier0Bond + tier1Bond; // Metric 3: Aggregate Bond in Country @@ -578,8 +579,8 @@ contract FleetIdentityFairnessTest is Test { uint32 countryRegion = uint32(COUNTRY_US); for (uint256 tier = 0; tier <= 10; tier++) { - uint256 localBond = fleet.tierBond(tier, localRegion); - uint256 countryBond = fleet.tierBond(tier, countryRegion); + uint256 localBond = fleet.tierBond(tier, false); + uint256 countryBond = fleet.tierBond(tier, true); emit log_string( string.concat( @@ -604,13 +605,13 @@ contract FleetIdentityFairnessTest is Test { emit log_string(""); // Scenario: 12 local + 8 country (both at tier 0) - uint256 scenario1 = 12 * fleet.tierBond(0, localRegion) + 8 * fleet.tierBond(0, countryRegion); + uint256 scenario1 = 12 * fleet.tierBond(0, false) + 8 * fleet.tierBond(0, true); // Scenario: 12 local + 8 country (both at tier 2 - competitive market) - uint256 scenario2 = 12 * fleet.tierBond(2, localRegion) + 8 * fleet.tierBond(2, countryRegion); + uint256 scenario2 = 12 * fleet.tierBond(2, false) + 8 * fleet.tierBond(2, true); // Scenario: 12 local + 8 country (both at tier 5 - very competitive) - uint256 scenario3 = 12 * fleet.tierBond(5, localRegion) + 8 * fleet.tierBond(5, countryRegion); + uint256 scenario3 = 12 * fleet.tierBond(5, false) + 8 * fleet.tierBond(5, true); emit log_named_uint("Low competition (tier 0), ether", scenario1 / 1 ether); emit log_named_uint("Medium competition (tier 2), ether", scenario2 / 1 ether); @@ -686,8 +687,8 @@ contract FleetIdentityFairnessTest is Test { // If they registered locally in 8 admin areas, they'd pay 8x BASE_BOND total // Country tier 0 = 8 * BASE_BOND = same cost for national coverage - uint256 countryTier0Bond = fleet.tierBond(0, uint32(COUNTRY_US)); - uint256 localTier0Bond = fleet.tierBond(0, _makeAdminRegion(COUNTRY_US, adminAreas[0])); + uint256 countryTier0Bond = fleet.tierBond(0, true); + uint256 localTier0Bond = fleet.tierBond(0, false); assertEq(countryTier0Bond, localTier0Bond * 8, "Country bond should be 8x local"); From ecacefe9e2b044ebb56c339f29aaae42c3a82354 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 24 Feb 2026 11:16:03 +1300 Subject: [PATCH 38/63] refactor: remove auto-assign registerFleetLocal, use explicit tier parameter - Remove the 3-argument registerFleetLocal(uuid, countryCode, adminCode) overload - Keep only the explicit tier version: registerFleetLocal(uuid, countryCode, adminCode, targetTier) - Update all test calls (~100) to pass explicit tier values - Rename auto-assign tests to use localInclusionHint() for finding cheapest tier - Fix fuzz tests to spread registrations across tiers (i / 4) to avoid TierFull - All 330 tests pass (184 FleetIdentity + 11 FleetIdentityFairness + 135 SwarmRegistry) --- src/swarms/FleetIdentity.sol | 27 +--- test/FleetIdentity.t.sol | 238 +++++++++++++++--------------- test/SwarmRegistryL1.t.sol | 2 +- test/SwarmRegistryUniversal.t.sol | 2 +- 4 files changed, 129 insertions(+), 140 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 1b1f1367..bcc3ac4e 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -250,27 +250,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Registration: Admin Area (local) // ══════════════════════════════════════════════ - /// @notice Register a fleet under a country + admin area (auto-assign tier). - /// @dev Auto-assign picks the cheapest tier that guarantees the fleet - /// appears in `buildHighestBondedUuidBundle` for this location. - /// Bounded: O(MAX_TIERS) — only 3 regions participate. - /// @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 > MAX_COUNTRY_CODE) revert InvalidCountryCode(); - if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); - uint256 targetTier = _findCheapestInclusionTier(countryCode, adminCode, LEVEL_ADMIN); - uint32 region = _makeAdminRegion(countryCode, adminCode); - _validateExplicitTier(region, targetTier); - tokenId = _register(uuid, region, targetTier); - } - /// @notice Register a fleet under a country + admin area into a specific tier. + /// @dev Use `localInclusionHint(countryCode, adminCode)` to find the cheapest + /// tier that guarantees bundle inclusion. function registerFleetLocal(bytes16 uuid, uint16 countryCode, uint16 adminCode, uint256 targetTier) external nonReentrant @@ -801,7 +783,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } // UUID level enforcement: all tokens for a UUID must be at the same level - RegistrationLevel level = _isCountryRegion(region) ? RegistrationLevel.Country : RegistrationLevel.Local; + bool isCountry = _isCountryRegion(region); + RegistrationLevel level = isCountry ? RegistrationLevel.Country : RegistrationLevel.Local; RegistrationLevel existingLevel = uuidLevel[uuid]; if (existingLevel == RegistrationLevel.None) { // First registration for this UUID @@ -813,7 +796,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uuidTokenCount[uuid]++; - uint256 bond = tierBond(tier, _isCountryRegion(region)); + uint256 bond = tierBond(tier, isCountry); // TokenID encodes both region and uuid: (region << 128) | uuid tokenId = (uint256(region) << 128) | uint256(uint128(uuid)); diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 146b670a..4a42c1ef 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -259,9 +259,9 @@ contract FleetIdentityTest is Test { // --- registerFleetLocal --- - function test_registerFleetLocal_auto_setsRegionAndTier() public { + function test_registerFleetLocal_setsRegionAndTier() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(fleet.fleetRegion(tokenId), _regionUSCA()); assertEq(fleet.fleetTier(tokenId), 0); @@ -279,19 +279,19 @@ contract FleetIdentityTest is Test { function test_RevertIf_registerFleetLocal_invalidCountry() public { vm.prank(alice); vm.expectRevert(FleetIdentity.InvalidCountryCode.selector); - fleet.registerFleetLocal(UUID_1, 0, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, 0, ADMIN_CA, 0); } function test_RevertIf_registerFleetLocal_invalidAdmin_zero() public { vm.prank(alice); vm.expectRevert(FleetIdentity.InvalidAdminCode.selector); - fleet.registerFleetLocal(UUID_1, US, 0); + fleet.registerFleetLocal(UUID_1, US, 0, 0); } function test_RevertIf_registerFleetLocal_invalidAdmin_over4095() public { vm.prank(alice); vm.expectRevert(FleetIdentity.InvalidAdminCode.selector); - fleet.registerFleetLocal(UUID_1, US, 4096); + fleet.registerFleetLocal(UUID_1, US, 4096, 0); } // --- Per-region independent tier indexing (KEY REQUIREMENT) --- @@ -302,7 +302,7 @@ contract FleetIdentityTest is Test { uint256 c1 = fleet.registerFleetCountry(UUID_1, US, 0); // Local level pays 1× multiplier vm.prank(alice); - uint256 l1 = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + uint256 l1 = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA, 0); assertEq(fleet.fleetTier(c1), 0); assertEq(fleet.fleetTier(l1), 0); @@ -332,7 +332,7 @@ contract FleetIdentityTest is Test { // US local is independent - can still join tier 0 vm.prank(bob); - uint256 usca1 = fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA); + uint256 usca1 = fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA, 0); assertEq(fleet.fleetTier(usca1), 0); assertEq(fleet.bonds(usca1), BASE_BOND); } @@ -362,28 +362,34 @@ contract FleetIdentityTest is Test { // NY is independent - can still join tier 0 vm.prank(bob); - uint256 ny1 = fleet.registerFleetLocal(_uuid(500), US, ADMIN_NY); + uint256 ny1 = fleet.registerFleetLocal(_uuid(500), US, ADMIN_NY, 0); assertEq(fleet.fleetTier(ny1), 0); assertEq(fleet.bonds(ny1), BASE_BOND); } - // --- Auto-assign tier logic (local only) --- + // --- Local inclusion hint tier logic --- - function test_autoAssign_local_emptyRegionGetsTier0() public { - // No fleets anywhere — local auto-assign picks tier 0. + function test_localInclusionHint_emptyRegionReturnsTier0() public { + // No fleets anywhere — localInclusionHint returns tier 0. + (uint256 inclusionTier,) = fleet.localInclusionHint(US, ADMIN_CA); + assertEq(inclusionTier, 0); + vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, inclusionTier); assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.regionTierCount(_regionUSCA()), 1); } - function test_autoAssign_local_cheapestInclusionTier() public { + function test_localInclusionHint_returnsCheapestInclusionTier() public { // Fill admin-area tier 0 (4 members) so tier 0 is full. _registerNLocalAt(alice, US, ADMIN_CA, 4, 0, 0); - // Auto-assign should pick tier 1 (cheapest tier with capacity). + // localInclusionHint should return tier 1 (cheapest tier with capacity). + (uint256 inclusionTier,) = fleet.localInclusionHint(US, ADMIN_CA); + assertEq(inclusionTier, 1); + vm.prank(bob); - uint256 tokenId = fleet.registerFleetLocal(_uuid(100), US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(_uuid(100), US, ADMIN_CA, inclusionTier); assertEq(fleet.fleetTier(tokenId), 1); assertEq(fleet.regionTierCount(_regionUSCA()), 2); } @@ -403,7 +409,7 @@ contract FleetIdentityTest is Test { function test_promote_next_pullsBondDifference() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint256 balBefore = bondToken.balanceOf(alice); uint256 diff = fleet.tierBond(1, false) - fleet.tierBond(0, false); @@ -416,7 +422,7 @@ contract FleetIdentityTest is Test { function test_reassignTier_promotesWhenTargetHigher() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.reassignTier(tokenId, 3); @@ -428,7 +434,7 @@ contract FleetIdentityTest is Test { function test_promote_emitsEvent() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint256 diff = fleet.tierBond(1, false) - fleet.tierBond(0, false); vm.expectEmit(true, true, true, true); @@ -440,7 +446,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_promote_notOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -458,7 +464,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_promote_targetTierFull() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); // Fill tier 1 with 4 members for (uint256 i = 0; i < 4; i++) { @@ -473,7 +479,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_reassignTier_exceedsMaxTiers() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); vm.expectRevert(FleetIdentity.MaxTiersReached.selector); @@ -550,7 +556,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_reassignTier_promoteNotOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -561,7 +567,7 @@ contract FleetIdentityTest is Test { function test_burn_refundsTierBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint256 balBefore = bondToken.balanceOf(alice); vm.prank(alice); @@ -574,7 +580,7 @@ contract FleetIdentityTest is Test { function test_burn_emitsEvent() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.expectEmit(true, true, true, true); emit FleetBurned(alice, tokenId, _regionUSCA(), 0, BASE_BOND); @@ -595,14 +601,14 @@ contract FleetIdentityTest is Test { function test_burn_allowsReregistration_sameRegion() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.burn(tokenId); // Same UUID can be re-registered in same region, same tokenId vm.prank(bob); - uint256 newId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 newId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(newId, tokenId); assertEq(fleet.fleetRegion(newId), _regionUSCA()); } @@ -610,10 +616,10 @@ contract FleetIdentityTest is Test { function test_multiRegion_sameUuidCanRegisterInDifferentRegions() public { // Same UUID can be registered in multiple regions simultaneously (by SAME owner, SAME level) vm.prank(alice); - uint256 localId1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 localId1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); - uint256 localId2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + uint256 localId2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); // Different tokenIds for different regions assertTrue(localId1 != localId2, "Different regions should have different tokenIds"); @@ -631,7 +637,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_burn_notOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(bob); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -686,7 +692,7 @@ contract FleetIdentityTest is Test { uint256 usId = fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); - uint256 uscaId = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + uint256 uscaId = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA, 0); uint256[] memory usMembers = fleet.getTierMembers(_regionUS(), 0); assertEq(usMembers.length, 1); @@ -702,7 +708,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); - fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA, 0); bytes16[] memory usUUIDs = fleet.getTierUuids(_regionUS(), 0); assertEq(usUUIDs.length, 1); @@ -752,9 +758,9 @@ contract FleetIdentityTest is Test { function test_activeAdminAreas_trackedCorrectly() public { vm.prank(alice); - fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(bob); - fleet.registerFleetLocal(UUID_2, US, ADMIN_NY); + fleet.registerFleetLocal(UUID_2, US, ADMIN_NY, 0); uint32[] memory areas = fleet.getActiveAdminAreas(); assertEq(areas.length, 2); @@ -762,7 +768,7 @@ contract FleetIdentityTest is Test { function test_activeAdminAreas_removedWhenAllBurned() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(fleet.getActiveAdminAreas().length, 1); @@ -793,13 +799,13 @@ contract FleetIdentityTest is Test { function test_tokenUuid_roundTrip() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(fleet.tokenUuid(tokenId), UUID_1); } function test_bonds_returnsTierBond() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(fleet.bonds(tokenId), BASE_BOND); } @@ -821,7 +827,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.totalSupply(), 2); vm.prank(carol); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA, 0); assertEq(fleet.totalSupply(), 3); } @@ -839,7 +845,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); uint256 c2 = fleet.registerFleetCountry(UUID_2, DE, 0); vm.prank(carol); - uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA, 0); // c1 and c2 are country (8× multiplier), l1 is local (1× multiplier) assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 8 + BASE_BOND * 8 + BASE_BOND); @@ -857,7 +863,7 @@ contract FleetIdentityTest is Test { function test_bondAccounting_reassignTierRoundTrip() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint256 balStart = bondToken.balanceOf(alice); vm.prank(alice); @@ -884,7 +890,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); vm.expectRevert(); - f.registerFleetLocal(UUID_1, US, ADMIN_CA); + f.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); } // --- Transfer preserves region and tier --- @@ -937,7 +943,7 @@ contract FleetIdentityTest is Test { bondToken.approve(address(f), type(uint256).max); vm.prank(alice); - uint256 tokenId = f.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = f.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(f.bonds(tokenId), 0); vm.prank(alice); @@ -962,7 +968,7 @@ contract FleetIdentityTest is Test { admin = uint16(bound(admin, 1, 4095)); vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, cc, admin); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, cc, admin, 0); uint32 expectedRegion = (uint32(cc) << 12) | uint32(admin); assertEq(fleet.fleetRegion(tokenId), expectedRegion); @@ -975,7 +981,7 @@ contract FleetIdentityTest is Test { vm.assume(caller != address(0)); vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(caller); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -987,7 +993,7 @@ contract FleetIdentityTest is Test { vm.assume(caller != address(0)); vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(caller); vm.expectRevert(FleetIdentity.NotTokenOwner.selector); @@ -1003,7 +1009,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.uuidTokenCount(UUID_1), 0, "No tokens before registration"); vm.prank(alice); - fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(fleet.uuidOwner(UUID_1), alice, "Alice is UUID owner after registration"); assertEq(fleet.uuidTokenCount(UUID_1), 1, "Token count is 1 after registration"); @@ -1012,15 +1018,15 @@ contract FleetIdentityTest is Test { function test_uuidOwner_sameOwnerCanRegisterMultipleRegions() public { // Alice registers UUID_1 in first region (same level across all) vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); // Alice can register same UUID in second region (same level) vm.prank(alice); - uint256 id2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + uint256 id2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); // And a third region (same level) vm.prank(alice); - uint256 id3 = fleet.registerFleetLocal(UUID_1, FR, ADMIN_CA); + uint256 id3 = fleet.registerFleetLocal(UUID_1, FR, ADMIN_CA, 0); assertEq(fleet.uuidOwner(UUID_1), alice, "Alice is still UUID owner"); assertEq(fleet.uuidTokenCount(UUID_1), 3, "Token count is 3"); @@ -1032,12 +1038,12 @@ contract FleetIdentityTest is Test { function test_RevertIf_differentOwnerRegistersSameUuid_local() public { // Alice registers UUID_1 first vm.prank(alice); - fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); // Bob tries to register same UUID in different region → revert vm.prank(bob); vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); } function test_RevertIf_differentOwnerRegistersSameUuid_country() public { @@ -1059,13 +1065,13 @@ contract FleetIdentityTest is Test { // Bob tries to register same UUID at local level → revert vm.prank(bob); vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); } function test_uuidOwner_clearedWhenAllTokensBurned() public { // Alice registers UUID_1 in one region vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(fleet.uuidOwner(UUID_1), alice); assertEq(fleet.uuidTokenCount(UUID_1), 1); @@ -1082,10 +1088,10 @@ contract FleetIdentityTest is Test { function test_uuidOwner_notClearedWhileTokensRemain() public { // Alice registers UUID_1 in two regions (same level) vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); - uint256 id2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + uint256 id2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); assertEq(fleet.uuidTokenCount(UUID_1), 2); @@ -1109,11 +1115,11 @@ contract FleetIdentityTest is Test { function test_uuidOwner_differentUuidsHaveDifferentOwners() public { // Alice registers UUID_1 vm.prank(alice); - fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); // Bob registers UUID_2 (different UUID, no conflict) vm.prank(bob); - fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA, 0); assertEq(fleet.uuidOwner(UUID_1), alice); assertEq(fleet.uuidOwner(UUID_2), bob); @@ -1122,13 +1128,13 @@ contract FleetIdentityTest is Test { function test_uuidOwner_canReRegisterAfterBurningAll() public { // Alice registers and burns UUID_1 vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.burn(tokenId); // Bob can now register the same UUID (uuid owner was cleared) vm.prank(bob); - uint256 newTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 newTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(fleet.uuidOwner(UUID_1), bob, "Bob is now UUID owner"); assertEq(fleet.uuidTokenCount(UUID_1), 1); @@ -1138,7 +1144,7 @@ contract FleetIdentityTest is Test { function test_uuidOwner_transferDoesNotChangeUuidOwner() public { // Alice registers UUID_1 vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(fleet.uuidOwner(UUID_1), alice); @@ -1154,7 +1160,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_transferRecipientTriesToRegisterSameUuid() public { // Alice registers UUID_1 vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); // Alice transfers to Bob vm.prank(alice); @@ -1163,13 +1169,13 @@ contract FleetIdentityTest is Test { // Bob now owns tokenId, but cannot register NEW tokens for UUID_1 vm.prank(bob); vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); } function test_uuidOwner_originalOwnerCanStillRegisterAfterTransfer() public { // Alice registers UUID_1 in one region vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); // Alice transfers to Bob vm.prank(alice); @@ -1177,7 +1183,7 @@ contract FleetIdentityTest is Test { // Alice can still register UUID_1 in new regions (she's still uuidOwner, same level) vm.prank(alice); - uint256 newTokenId = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + uint256 newTokenId = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); assertEq(fleet.ownerOf(newTokenId), alice); assertEq(fleet.uuidTokenCount(UUID_1), 2); @@ -1191,12 +1197,12 @@ contract FleetIdentityTest is Test { // Alice registers first vm.prank(alice); - fleet.registerFleetLocal(UUID_1, cc1, admin1); + fleet.registerFleetLocal(UUID_1, cc1, admin1, 0); // Bob cannot register same UUID anywhere vm.prank(bob); vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); - fleet.registerFleetLocal(UUID_1, cc2, admin2); + fleet.registerFleetLocal(UUID_1, cc2, admin2, 0); vm.prank(bob); vm.expectRevert(FleetIdentity.UuidOwnerMismatch.selector); @@ -1249,7 +1255,7 @@ contract FleetIdentityTest is Test { assertEq(uint8(fleet.uuidLevel(UUID_1)), 0, "No level before registration"); vm.prank(alice); - fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1, "Level is 1 (local) after local registration"); } @@ -1266,7 +1272,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_crossLevelRegistration_localThenCountry() public { // Alice registers UUID_1 at local level vm.prank(alice); - fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); // Alice tries to register same UUID at country level → revert vm.prank(alice); @@ -1282,12 +1288,12 @@ contract FleetIdentityTest is Test { // Alice tries to register same UUID at local level → revert vm.prank(alice); vm.expectRevert(FleetIdentity.UuidLevelMismatch.selector); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); } function test_uuidLevel_clearedOnLastTokenBurn() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); @@ -1299,10 +1305,10 @@ contract FleetIdentityTest is Test { function test_uuidLevel_notClearedWhileTokensRemain() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); @@ -1315,7 +1321,7 @@ contract FleetIdentityTest is Test { function test_uuidLevel_canChangeLevelAfterBurningAll() public { // Register as local vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // Burn @@ -1334,7 +1340,7 @@ contract FleetIdentityTest is Test { function test_migrateRegion_localToLocal_sameCountry() public { vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint32 oldRegion = fleet.fleetRegion(oldTokenId); uint32 newRegion = _makeAdminRegion(US, ADMIN_NY); @@ -1360,7 +1366,7 @@ contract FleetIdentityTest is Test { function test_migrateRegion_localToLocal_differentCountry() public { vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); @@ -1386,7 +1392,7 @@ contract FleetIdentityTest is Test { function test_migrateRegion_localToCountry_singleToken() public { vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); @@ -1416,10 +1422,10 @@ contract FleetIdentityTest is Test { function test_RevertIf_migrateRegion_levelChangeWithMultipleTokens() public { // Register UUID_1 in two local regions vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); // Try to migrate one to country level → revert vm.prank(alice); @@ -1430,10 +1436,10 @@ contract FleetIdentityTest is Test { function test_migrateRegion_sameLevelWithMultipleTokens() public { // Register UUID_1 in two local regions vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); // Migrate to different local region (same level) → allowed uint32 newRegion = _makeAdminRegion(FR, ADMIN_CA); @@ -1514,7 +1520,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_migrateRegion_notOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); vm.prank(bob); @@ -1524,7 +1530,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_migrateRegion_sameRegion() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint32 sameRegion = _regionUSCA(); vm.prank(alice); @@ -1537,7 +1543,7 @@ contract FleetIdentityTest is Test { _registerNLocalAt(bob, DE, ADMIN_CA, 4, 0, 0); vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); vm.prank(alice); @@ -1619,7 +1625,7 @@ contract FleetIdentityTest is Test { function test_migrationHint_sameRegion() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, _regionUSCA(), 0); @@ -1630,9 +1636,9 @@ contract FleetIdentityTest is Test { function test_migrationHint_cannotChangeLevelWithMultipleTokens() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(id1, uint32(US), 0); @@ -1645,7 +1651,7 @@ contract FleetIdentityTest is Test { _registerNLocalAt(bob, DE, ADMIN_CA, 4, 0, 0); vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 0); @@ -1728,7 +1734,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); uint256 id2 = fleet.registerFleetCountry(UUID_2, DE, 0); vm.prank(carol); - uint256 id3 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + uint256 id3 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA, 0); uint256 sumBonds = fleet.bonds(id1) + fleet.bonds(id2) + fleet.bonds(id3); assertEq(bondToken.balanceOf(address(fleet)), sumBonds); @@ -1743,9 +1749,9 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 id1 = fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(bob); - uint256 id2 = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + uint256 id2 = fleet.registerFleetLocal(UUID_2, US, ADMIN_CA, 0); vm.prank(carol); - uint256 id3 = fleet.registerFleetLocal(UUID_3, DE, ADMIN_NY); + uint256 id3 = fleet.registerFleetLocal(UUID_3, DE, ADMIN_NY, 0); vm.prank(alice); fleet.reassignTier(id1, 3); @@ -1895,7 +1901,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_singleLocal() public { vm.prank(alice); - fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 1); @@ -1907,7 +1913,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_bothLevelsTied_levelPriorityOrder() public { // Both at tier 0 → shared cursor 0 → level priority: local, country vm.prank(alice); - fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA, 0); vm.prank(alice); fleet.registerFleetCountry(UUID_1, US, 0); @@ -1946,7 +1952,7 @@ contract FleetIdentityTest is Test { fleet.reassignTier(usId, 2); // Local: tier 0 (bond=BASE) vm.prank(alice); - fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 2); @@ -1957,7 +1963,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_multiTierDescendingBond() public { // Local tier 2 (bond=4*BASE) vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.reassignTier(id1, 2); @@ -1969,7 +1975,7 @@ contract FleetIdentityTest is Test { // Local tier 0 (bond=BASE) vm.prank(alice); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 3); @@ -2304,7 +2310,7 @@ contract FleetIdentityTest is Test { 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); + uint256 id = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.reassignTier(id, 2); @@ -2323,7 +2329,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_multipleEmptyTiersInMiddle() public { // Local at tier 5, country at tier 0. Tiers 1-4 empty. vm.prank(alice); - uint256 id = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.reassignTier(id, 5); vm.prank(alice); @@ -2441,7 +2447,7 @@ contract FleetIdentityTest is Test { function test_buildBundle_sharedCursor_levelsAtDifferentMaxTiers() public { // Local at tier 3, Country at tier 1. vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.reassignTier(id1, 3); vm.prank(alice); @@ -2449,7 +2455,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.reassignTier(id2, 1); vm.prank(alice); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 3); @@ -2470,11 +2476,11 @@ contract FleetIdentityTest is Test { function test_buildBundle_afterBurn_reflects() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(bob); - fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA, 0); vm.prank(carol); - fleet.registerFleetLocal(UUID_3, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_3, US, ADMIN_CA, 0); (, uint256 countBefore) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(countBefore, 3); @@ -2490,7 +2496,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetCountry(UUID_1, US, 0); vm.prank(alice); - fleet.registerFleetLocal(UUID_2, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_2, US, ADMIN_CA, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 2); @@ -2505,11 +2511,11 @@ contract FleetIdentityTest is Test { function test_buildBundle_lifecycle_promotionsAndBurns() public { vm.prank(alice); - uint256 l1 = fleet.registerFleetLocal(_uuid(100), US, ADMIN_CA); + uint256 l1 = fleet.registerFleetLocal(_uuid(100), US, ADMIN_CA, 0); vm.prank(alice); - fleet.registerFleetLocal(_uuid(101), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(101), US, ADMIN_CA, 0); vm.prank(alice); - fleet.registerFleetLocal(_uuid(102), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(102), US, ADMIN_CA, 0); vm.prank(alice); uint256 c1 = fleet.registerFleetCountry(_uuid(200), US, 0); @@ -2517,7 +2523,7 @@ contract FleetIdentityTest is Test { fleet.registerFleetCountry(_uuid(201), US, 0); vm.prank(alice); - fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(300), US, ADMIN_CA, 0); vm.prank(alice); fleet.reassignTier(l1, 3); @@ -2603,7 +2609,7 @@ contract FleetIdentityTest is Test { _registerNLocalAt(alice, US, ADMIN_CA, 3, 1000, 0); _registerNCountryAt(bob, US, 2, 2000, 0); vm.prank(carol); - fleet.registerFleetLocal(UUID_1, US, ADMIN_CA); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 6); @@ -2644,7 +2650,7 @@ contract FleetIdentityTest is Test { } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(32_000 + i), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(32_000 + i), US, ADMIN_CA, i / 4); } (, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); @@ -2661,7 +2667,7 @@ contract FleetIdentityTest is Test { } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(42_000 + i), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(42_000 + i), US, ADMIN_CA, i / 4); } (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); @@ -2682,7 +2688,7 @@ contract FleetIdentityTest is Test { } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(52_000 + i), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(52_000 + i), US, ADMIN_CA, i / 4); } (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); @@ -2702,7 +2708,7 @@ contract FleetIdentityTest is Test { } for (uint256 i = 0; i < lCount; i++) { vm.prank(alice); - fleet.registerFleetLocal(_uuid(62_000 + i), US, ADMIN_CA); + fleet.registerFleetLocal(_uuid(62_000 + i), US, ADMIN_CA, i / 4); } (bytes16[] memory uuids2, uint256 count2) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); @@ -2771,8 +2777,8 @@ contract FleetIdentityTest is Test { fleet.localInclusionHint(US, ADMIN_CA); } - /// @notice When all tiers are full, auto-assign registerFleetLocal should revert. - function test_RevertIf_registerFleetLocal_autoAssign_allTiersFull() public { + /// @notice When all tiers are full, registering at any tier should revert with TierFull. + function test_RevertIf_registerFleetLocal_allTiersFull() public { // Fill all 24 tiers (TIER_CAPACITY = 4) for (uint256 tier = 0; tier < 24; tier++) { for (uint256 i = 0; i < 4; i++) { @@ -2781,10 +2787,10 @@ contract FleetIdentityTest is Test { } } - // Auto-assign registration should revert + // Registration at tier 0 (or any full tier) should revert with TierFull vm.prank(bob); - vm.expectRevert(FleetIdentity.MaxTiersReached.selector); - fleet.registerFleetLocal(_uuid(99999), US, ADMIN_CA); + vm.expectRevert(FleetIdentity.TierFull.selector); + fleet.registerFleetLocal(_uuid(99999), US, ADMIN_CA, 0); } /// @notice countryInclusionHint reverts when all tiers in the country region are full. diff --git a/test/SwarmRegistryL1.t.sol b/test/SwarmRegistryL1.t.sol index b11f3a07..41ab53e0 100644 --- a/test/SwarmRegistryL1.t.sol +++ b/test/SwarmRegistryL1.t.sol @@ -56,7 +56,7 @@ contract SwarmRegistryL1Test is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleetLocal(bytes16(keccak256(seed)), US, ADMIN_CA); + return fleetContract.registerFleetLocal(bytes16(keccak256(seed)), US, ADMIN_CA, 0); } function _registerProvider(address owner, string memory url) internal returns (uint256) { diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol index 58932a4d..57321c69 100644 --- a/test/SwarmRegistryUniversal.t.sol +++ b/test/SwarmRegistryUniversal.t.sol @@ -58,7 +58,7 @@ contract SwarmRegistryUniversalTest is Test { function _registerFleet(address owner, bytes memory seed) internal returns (uint256) { vm.prank(owner); - return fleetContract.registerFleetLocal(bytes16(keccak256(seed)), US, ADMIN_CA); + return fleetContract.registerFleetLocal(bytes16(keccak256(seed)), US, ADMIN_CA, 0); } function _registerProvider(address owner, string memory url) internal returns (uint256) { From 56160a669322a44ed306673d4a4ff918b5781c8f Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 24 Feb 2026 12:04:33 +1300 Subject: [PATCH 39/63] chore: remove redundant fleetRegion mapping The region is already encoded in the token ID's upper 128 bits (bits 128-159). Extracting via uint32(tokenId >> 128) saves gas on every registration and burn. - Remove fleetRegion public mapping - Replace all usages with inline region extraction from tokenId - Update test helpers to use ownerOf() instead of relying on mapping defaults - All 195 tests pass --- src/swarms/FleetIdentity.sol | 19 ++++------- test/FleetIdentity.t.sol | 56 +++++++++++++++++--------------- test/FleetIdentityFairness.t.sol | 5 +-- 3 files changed, 38 insertions(+), 42 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index bcc3ac4e..7482d508 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -151,9 +151,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Fleet data // ────────────────────────────────────────────── - /// @notice Token ID -> region key the fleet is registered in. - mapping(uint256 => uint32) public fleetRegion; - /// @notice Token ID -> tier index (within its region) the fleet belongs to. mapping(uint256 => uint256) public fleetTier; @@ -297,7 +294,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); - uint32 region = fleetRegion[tokenId]; + uint32 region = uint32(tokenId >> 128); uint256 tier = fleetTier[tokenId]; uint256 refund = tierBond(tier, _isCountryRegion(region)); @@ -307,7 +304,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Effects _removeFromTier(tokenId, region, tier); delete fleetTier[tokenId]; - delete fleetRegion[tokenId]; delete _indexInTier[tokenId]; _burn(tokenId); @@ -352,7 +348,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (tokenOwner != msg.sender) revert NotTokenOwner(); bytes16 uuid = bytes16(uint128(tokenId)); - uint32 oldRegion = fleetRegion[tokenId]; + uint32 oldRegion = uint32(tokenId >> 128); uint256 oldTier = fleetTier[tokenId]; if (newRegionKey == oldRegion) revert SameRegion(); @@ -391,7 +387,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // === Effects: Remove from old region === _removeFromTier(tokenId, oldRegion, oldTier); delete fleetTier[tokenId]; - delete fleetRegion[tokenId]; delete _indexInTier[tokenId]; _burn(tokenId); _trimTierCount(oldRegion); @@ -403,7 +398,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } // === Effects: Add to new region === - fleetRegion[newTokenId] = newRegionKey; fleetTier[newTokenId] = targetTier; _addToTier(newTokenId, newRegionKey, targetTier); _addToRegionIndex(newRegionKey); @@ -487,7 +481,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } bytes16 uuid = bytes16(uint128(tokenId)); - uint32 oldRegion = fleetRegion[tokenId]; + uint32 oldRegion = uint32(tokenId >> 128); uint256 oldTier = fleetTier[tokenId]; if (newRegionKey == oldRegion) { @@ -570,7 +564,7 @@ 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 tierBond(fleetTier[tokenId], _isCountryRegion(fleetRegion[tokenId])); + return tierBond(fleetTier[tokenId], _isCountryRegion(uint32(tokenId >> 128))); } // ══════════════════════════════════════════════ @@ -801,7 +795,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { tokenId = (uint256(region) << 128) | uint256(uint128(uuid)); // Effects - fleetRegion[tokenId] = region; fleetTier[tokenId] = tier; _addToTier(tokenId, region, tier); @@ -821,7 +814,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); - uint32 region = fleetRegion[tokenId]; + uint32 region = uint32(tokenId >> 128); uint256 currentTier = fleetTier[tokenId]; if (targetTier <= currentTier) revert TargetTierNotHigher(); if (targetTier >= MAX_TIERS) revert MaxTiersReached(); @@ -850,7 +843,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); - uint32 region = fleetRegion[tokenId]; + uint32 region = uint32(tokenId >> 128); uint256 currentTier = fleetTier[tokenId]; if (targetTier >= currentTier) revert TargetTierNotLower(); if (_regionTierMembers[region][targetTier].length >= TIER_CAPACITY) revert TierFull(); diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 4a42c1ef..ed05a8a7 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -105,11 +105,13 @@ contract FleetIdentityTest is Test { function _findTokenId(bytes16 uuid, uint16 cc, uint16 admin) internal view returns (uint256) { uint32 localRegion = (uint32(cc) << 12) | uint32(admin); uint256 localTokenId = _tokenId(uuid, localRegion); - if (fleet.fleetRegion(localTokenId) == localRegion) { + // Check if local token exists by trying to get its owner + try fleet.ownerOf(localTokenId) returns (address) { return localTokenId; + } catch { + uint32 countryRegion = uint32(cc); + return _tokenId(uuid, countryRegion); } - uint32 countryRegion = uint32(cc); - return _tokenId(uuid, countryRegion); } function _uuid(uint256 i) internal pure returns (bytes16) { @@ -230,7 +232,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); - assertEq(fleet.fleetRegion(tokenId), _regionUS()); + assertEq(fleet.tokenRegion(tokenId), _regionUS()); assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND * 8); // Country gets 8× multiplier assertEq(fleet.regionTierCount(_regionUS()), 1); @@ -263,7 +265,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - assertEq(fleet.fleetRegion(tokenId), _regionUSCA()); + assertEq(fleet.tokenRegion(tokenId), _regionUSCA()); assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND); } @@ -610,7 +612,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); uint256 newId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(newId, tokenId); - assertEq(fleet.fleetRegion(newId), _regionUSCA()); + assertEq(fleet.tokenRegion(newId), _regionUSCA()); } function test_multiRegion_sameUuidCanRegisterInDifferentRegions() public { @@ -627,8 +629,8 @@ contract FleetIdentityTest is Test { // Both have same UUID but different regions assertEq(fleet.tokenUuid(localId1), UUID_1); assertEq(fleet.tokenUuid(localId2), UUID_1); - assertEq(fleet.fleetRegion(localId1), _regionUSCA()); - assertEq(fleet.fleetRegion(localId2), _makeAdminRegion(DE, ADMIN_CA)); + assertEq(fleet.tokenRegion(localId1), _regionUSCA()); + assertEq(fleet.tokenRegion(localId2), _makeAdminRegion(DE, ADMIN_CA)); // Both owned by alice assertEq(fleet.ownerOf(localId1), alice); @@ -902,7 +904,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.transferFrom(alice, bob, tokenId); - assertEq(fleet.fleetRegion(tokenId), _regionUS()); + assertEq(fleet.tokenRegion(tokenId), _regionUS()); assertEq(fleet.fleetTier(tokenId), 2); assertEq(fleet.bonds(tokenId), fleet.tierBond(2, true)); @@ -958,7 +960,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, cc, 0); - assertEq(fleet.fleetRegion(tokenId), uint32(cc)); + assertEq(fleet.tokenRegion(tokenId), uint32(cc)); assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND * 8); // Country gets 8× multiplier } @@ -971,7 +973,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetLocal(UUID_1, cc, admin, 0); uint32 expectedRegion = (uint32(cc) << 12) | uint32(admin); - assertEq(fleet.fleetRegion(tokenId), expectedRegion); + assertEq(fleet.tokenRegion(tokenId), expectedRegion); assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND); } @@ -1342,7 +1344,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - uint32 oldRegion = fleet.fleetRegion(oldTokenId); + uint32 oldRegion = fleet.tokenRegion(oldTokenId); uint32 newRegion = _makeAdminRegion(US, ADMIN_NY); vm.prank(alice); @@ -1354,7 +1356,7 @@ contract FleetIdentityTest is Test { // New token exists assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.fleetRegion(newTokenId), newRegion); + assertEq(fleet.tokenRegion(newTokenId), newRegion); assertEq(fleet.fleetTier(newTokenId), 0); assertEq(fleet.tokenUuid(newTokenId), UUID_1); @@ -1374,7 +1376,7 @@ contract FleetIdentityTest is Test { uint256 newTokenId = fleet.migrateRegion(oldTokenId, newRegion, 0); assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.fleetRegion(newTokenId), newRegion); + assertEq(fleet.tokenRegion(newTokenId), newRegion); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // still local } @@ -1386,7 +1388,7 @@ contract FleetIdentityTest is Test { uint256 newTokenId = fleet.migrateRegion(oldTokenId, uint32(DE), 0); assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.fleetRegion(newTokenId), uint32(DE)); + assertEq(fleet.tokenRegion(newTokenId), uint32(DE)); assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // still country } @@ -1400,7 +1402,7 @@ contract FleetIdentityTest is Test { uint256 newTokenId = fleet.migrateRegion(oldTokenId, uint32(US), 0); assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.fleetRegion(newTokenId), uint32(US)); + assertEq(fleet.tokenRegion(newTokenId), uint32(US)); assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // changed to country } @@ -1415,7 +1417,7 @@ contract FleetIdentityTest is Test { uint256 newTokenId = fleet.migrateRegion(oldTokenId, newRegion, 0); assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.fleetRegion(newTokenId), newRegion); + assertEq(fleet.tokenRegion(newTokenId), newRegion); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // changed to local } @@ -1447,7 +1449,7 @@ contract FleetIdentityTest is Test { uint256 newTokenId = fleet.migrateRegion(id1, newRegion, 0); assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.fleetRegion(newTokenId), newRegion); + assertEq(fleet.tokenRegion(newTokenId), newRegion); assertEq(fleet.uuidTokenCount(UUID_1), 2); // still 2 tokens } @@ -2076,7 +2078,7 @@ contract FleetIdentityTest is Test { uint256 countryCount; for (uint256 i = 0; i < count; i++) { uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); - uint32 region = fleet.fleetRegion(tokenId); + uint32 region = fleet.tokenRegion(tokenId); if (region == _regionUS()) countryCount++; } assertEq(countryCount, 4, "4 country members included"); @@ -2167,7 +2169,7 @@ contract FleetIdentityTest is Test { uint256 countryT0Count; for (uint256 i = 0; i < count; i++) { uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); - if (fleet.fleetRegion(tokenId) == _regionUS() && fleet.fleetTier(tokenId) == 0) countryT0Count++; + if (fleet.tokenRegion(tokenId) == _regionUS() && fleet.fleetTier(tokenId) == 0) countryT0Count++; } assertEq(countryT0Count, 4, "4 country tier 0 members included"); } @@ -2257,7 +2259,7 @@ contract FleetIdentityTest is Test { uint256 localT0Count; for (uint256 i = 0; i < count; i++) { uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); - if (fleet.fleetRegion(tokenId) == _regionUSCA() && fleet.fleetTier(tokenId) == 0) localT0Count++; + if (fleet.tokenRegion(tokenId) == _regionUSCA() && fleet.fleetTier(tokenId) == 0) localT0Count++; } assertEq(localT0Count, 4, "4 local tier 0 included"); } @@ -2297,7 +2299,7 @@ contract FleetIdentityTest is Test { uint256 countryCount; for (uint256 i = 0; i < count; i++) { uint256 tokenId = _findTokenId(uuids[i], US, ADMIN_CA); - uint32 region = fleet.fleetRegion(tokenId); + uint32 region = fleet.tokenRegion(tokenId); if (region == _regionUS()) countryCount++; else if (region == _regionUSCA()) localCount++; } @@ -2630,7 +2632,7 @@ contract FleetIdentityTest is Test { uint256 countryFound; for (uint256 i = 0; i < count; i++) { uint256 tid = _findTokenId(uuids[i], US, ADMIN_CA); - uint32 region = fleet.fleetRegion(tid); + uint32 region = fleet.tokenRegion(tid); if (region == _regionUSCA()) localFound++; else if (region == _regionUS()) countryFound++; } @@ -2718,14 +2720,14 @@ contract FleetIdentityTest is Test { // We verify this by checking that included members are the first N in the tier's array. for (uint256 i = 0; i < count2; i++) { uint256 tid = _findTokenId(uuids2[i], US, ADMIN_CA); - uint32 region = fleet.fleetRegion(tid); + uint32 region = fleet.tokenRegion(tid); uint256 tier = fleet.fleetTier(tid); // Count how many from this (region, tier) are in the bundle uint256 inBundle; for (uint256 j = 0; j < count2; j++) { uint256 tjd = _findTokenId(uuids2[j], US, ADMIN_CA); - if (fleet.fleetRegion(tjd) == region && fleet.fleetTier(tjd) == tier) { + if (fleet.tokenRegion(tjd) == region && fleet.fleetTier(tjd) == tier) { inBundle++; } } @@ -2971,7 +2973,7 @@ contract FleetIdentityTest is Test { uint256 countryCountBefore; for (uint256 i = 0; i < countBefore; i++) { uint256 tokenId = _findTokenId(uuidsBefore[i], US, ADMIN_CA); - if (fleet.fleetRegion(tokenId) == countryRegion) countryCountBefore++; + if (fleet.tokenRegion(tokenId) == countryRegion) countryCountBefore++; } assertEq(countryCountBefore, 4, "BEFORE: All 4 country fleets in bundle"); @@ -2990,7 +2992,7 @@ contract FleetIdentityTest is Test { uint256 countryCountAfter; for (uint256 i = 0; i < countAfter; i++) { uint256 tokenId = _findTokenId(uuidsAfter[i], US, ADMIN_CA); - if (fleet.fleetRegion(tokenId) == countryRegion) countryCountAfter++; + if (fleet.tokenRegion(tokenId) == countryRegion) countryCountAfter++; } assertEq(countryCountAfter, 4, "AFTER: All 4 country fleets still in bundle"); diff --git a/test/FleetIdentityFairness.t.sol b/test/FleetIdentityFairness.t.sol index 1aeef25d..5fde2499 100644 --- a/test/FleetIdentityFairness.t.sol +++ b/test/FleetIdentityFairness.t.sol @@ -184,9 +184,10 @@ contract FleetIdentityFairnessTest is Test { for (uint256 i = 0; i < count; i++) { // Try to find token in country region first uint256 countryTokenId = fleet.computeTokenId(uuids[i], countryRegion); - if (fleet.fleetRegion(countryTokenId) == countryRegion) { + // Check if country token exists by trying to get its owner + try fleet.ownerOf(countryTokenId) returns (address) { countryCount++; - } else { + } catch { localCount++; } } From 27067e2c9d7404144bca0c2fdcac56be15ca6376 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 24 Feb 2026 12:25:18 +1300 Subject: [PATCH 40/63] docs: add ISO 3166-2 admin area mappings for 18 countries - Create iso3166-2 subfolder with standardized country lookup tables - Map ISO 3166-2 subdivision codes to dense numeric indices (0 to n-1) - Includes 18 representative countries across all major regions - Update doc structure for better browsability - Files organized by ISO 3166-1 numeric code --- src/swarms/doc/README.md | 47 +++++ src/swarms/doc/iso3166-2/036-Australia.md | 18 ++ src/swarms/doc/iso3166-2/076-Brazil.md | 37 ++++ src/swarms/doc/iso3166-2/124-Canada.md | 23 +++ src/swarms/doc/iso3166-2/156-China.md | 44 +++++ src/swarms/doc/iso3166-2/250-France.md | 28 +++ src/swarms/doc/iso3166-2/276-Germany.md | 26 +++ src/swarms/doc/iso3166-2/356-India.md | 46 +++++ src/swarms/doc/iso3166-2/380-Italy.md | 30 +++ src/swarms/doc/iso3166-2/392-Japan.md | 57 ++++++ src/swarms/doc/iso3166-2/410-South_Korea.md | 27 +++ src/swarms/doc/iso3166-2/484-Mexico.md | 42 ++++ src/swarms/doc/iso3166-2/566-Nigeria.md | 47 +++++ src/swarms/doc/iso3166-2/643-Russia.md | 93 +++++++++ src/swarms/doc/iso3166-2/710-South_Africa.md | 19 ++ src/swarms/doc/iso3166-2/724-Spain.md | 29 +++ src/swarms/doc/iso3166-2/756-Switzerland.md | 36 ++++ .../doc/iso3166-2/826-United_Kingdom.md | 182 ++++++++++++++++++ src/swarms/doc/iso3166-2/840-United_States.md | 67 +++++++ 19 files changed, 898 insertions(+) create mode 100644 src/swarms/doc/README.md create mode 100644 src/swarms/doc/iso3166-2/036-Australia.md create mode 100644 src/swarms/doc/iso3166-2/076-Brazil.md create mode 100644 src/swarms/doc/iso3166-2/124-Canada.md create mode 100644 src/swarms/doc/iso3166-2/156-China.md create mode 100644 src/swarms/doc/iso3166-2/250-France.md create mode 100644 src/swarms/doc/iso3166-2/276-Germany.md create mode 100644 src/swarms/doc/iso3166-2/356-India.md create mode 100644 src/swarms/doc/iso3166-2/380-Italy.md create mode 100644 src/swarms/doc/iso3166-2/392-Japan.md create mode 100644 src/swarms/doc/iso3166-2/410-South_Korea.md create mode 100644 src/swarms/doc/iso3166-2/484-Mexico.md create mode 100644 src/swarms/doc/iso3166-2/566-Nigeria.md create mode 100644 src/swarms/doc/iso3166-2/643-Russia.md create mode 100644 src/swarms/doc/iso3166-2/710-South_Africa.md create mode 100644 src/swarms/doc/iso3166-2/724-Spain.md create mode 100644 src/swarms/doc/iso3166-2/756-Switzerland.md create mode 100644 src/swarms/doc/iso3166-2/826-United_Kingdom.md create mode 100644 src/swarms/doc/iso3166-2/840-United_States.md diff --git a/src/swarms/doc/README.md b/src/swarms/doc/README.md new file mode 100644 index 00000000..71ec2b14 --- /dev/null +++ b/src/swarms/doc/README.md @@ -0,0 +1,47 @@ +# ISO 3166-2 Admin Area Mappings + +The [iso3166-2/](iso3166-2/) directory contains standardized mappings from ISO 3166-2 subdivision codes to dense numeric indices for use with the FleetIdentity contract. + +## File Naming Convention + +Each file in [iso3166-2/](iso3166-2/) is named `{ISO_3166-1_numeric}-{Country_Name}.md` + +Example: [840-United_States.md](iso3166-2/840-United_States.md) for the United States (ISO 3166-1 numeric: 840) + +## Table Format + +Each country file contains a mapping table with three columns: + +| Dense Index | ISO 3166-2 Code | Name | +|-------------|-----------------|------| +| 0 | XX | Full subdivision name | + +- **Dense Index**: Sequential integers from 0 to n-1 (where n = number of subdivisions) +- **ISO 3166-2 Code**: The subdivision code (1-3 alphanumeric characters, without country prefix) +- **Name**: Full official name of the subdivision + +## Usage with FleetIdentity Contract + +The FleetIdentity contract uses: +- **Country Code**: ISO 3166-1 numeric (1-999) +- **Admin Code**: Dense index + 1 (1-4095) + - Add 1 to the dense index when calling contract functions + - Dense index 0 → adminCode 1 + - Dense index 4094 → adminCode 4095 + +## Example + +For California, USA: +- Country: United States (ISO 3166-1 numeric: 840) +- ISO 3166-2: US-CA +- Dense Index: 4 (from table) +- Contract adminCode: 5 (dense index + 1) +- Region Key: `(840 << 12) | 5 = 3440645` + +## Coverage + +This directory aims to provide mappings for all countries with defined ISO 3166-2 subdivisions. Countries without official subdivisions may be omitted. + +## Data Sources + +Mappings are based on ISO 3166-2 standard as maintained by ISO and various national statistical agencies. diff --git a/src/swarms/doc/iso3166-2/036-Australia.md b/src/swarms/doc/iso3166-2/036-Australia.md new file mode 100644 index 00000000..dba22570 --- /dev/null +++ b/src/swarms/doc/iso3166-2/036-Australia.md @@ -0,0 +1,18 @@ +# Australia (036) + +ISO 3166-1 numeric: **036** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | ACT | Australian Capital Territory | +| 1 | NSW | New South Wales | +| 2 | NT | Northern Territory | +| 3 | QLD | Queensland | +| 4 | SA | South Australia | +| 5 | TAS | Tasmania | +| 6 | VIC | Victoria | +| 7 | WA | Western Australia | + +**Total subdivisions:** 8 diff --git a/src/swarms/doc/iso3166-2/076-Brazil.md b/src/swarms/doc/iso3166-2/076-Brazil.md new file mode 100644 index 00000000..8ab8d0fc --- /dev/null +++ b/src/swarms/doc/iso3166-2/076-Brazil.md @@ -0,0 +1,37 @@ +# Brazil (076) + +ISO 3166-1 numeric: **076** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AC | Acre | +| 1 | AL | Alagoas | +| 2 | AP | Amapá | +| 3 | AM | Amazonas | +| 4 | BA | Bahia | +| 5 | CE | Ceará | +| 6 | DF | Federal District | +| 7 | ES | Espírito Santo | +| 8 | GO | Goiás | +| 9 | MA | Maranhão | +| 10 | MT | Mato Grosso | +| 11 | MS | Mato Grosso do Sul | +| 12 | MG | Minas Gerais | +| 13 | PA | Pará | +| 14 | PB | Paraíba | +| 15 | PR | Paraná | +| 16 | PE | Pernambuco | +| 17 | PI | Piauí | +| 18 | RJ | Rio de Janeiro | +| 19 | RN | Rio Grande do Norte | +| 20 | RS | Rio Grande do Sul | +| 21 | RO | Rondônia | +| 22 | RR | Roraima | +| 23 | SC | Santa Catarina | +| 24 | SP | São Paulo | +| 25 | SE | Sergipe | +| 26 | TO | Tocantins | + +**Total subdivisions:** 27 diff --git a/src/swarms/doc/iso3166-2/124-Canada.md b/src/swarms/doc/iso3166-2/124-Canada.md new file mode 100644 index 00000000..e8cadc2f --- /dev/null +++ b/src/swarms/doc/iso3166-2/124-Canada.md @@ -0,0 +1,23 @@ +# Canada (124) + +ISO 3166-1 numeric: **124** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AB | Alberta | +| 1 | BC | British Columbia | +| 2 | MB | Manitoba | +| 3 | NB | New Brunswick | +| 4 | NL | Newfoundland and Labrador | +| 5 | NT | Northwest Territories | +| 6 | NS | Nova Scotia | +| 7 | NU | Nunavut | +| 8 | ON | Ontario | +| 9 | PE | Prince Edward Island | +| 10 | QC | Quebec | +| 11 | SK | Saskatchewan | +| 12 | YT | Yukon | + +**Total subdivisions:** 13 diff --git a/src/swarms/doc/iso3166-2/156-China.md b/src/swarms/doc/iso3166-2/156-China.md new file mode 100644 index 00000000..fc196309 --- /dev/null +++ b/src/swarms/doc/iso3166-2/156-China.md @@ -0,0 +1,44 @@ +# China (156) + +ISO 3166-1 numeric: **156** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AH | Anhui | +| 1 | BJ | Beijing | +| 2 | CQ | Chongqing | +| 3 | FJ | Fujian | +| 4 | GS | Gansu | +| 5 | GD | Guangdong | +| 6 | GX | Guangxi | +| 7 | GZ | Guizhou | +| 8 | HI | Hainan | +| 9 | HE | Hebei | +| 10 | HL | Heilongjiang | +| 11 | HA | Henan | +| 12 | HB | Hubei | +| 13 | HN | Hunan | +| 14 | JS | Jiangsu | +| 15 | JX | Jiangxi | +| 16 | JL | Jilin | +| 17 | LN | Liaoning | +| 18 | NM | Inner Mongolia | +| 19 | NX | Ningxia | +| 20 | QH | Qinghai | +| 21 | SN | Shaanxi | +| 22 | SD | Shandong | +| 23 | SH | Shanghai | +| 24 | SX | Shanxi | +| 25 | SC | Sichuan | +| 26 | TJ | Tianjin | +| 27 | XJ | Xinjiang | +| 28 | XZ | Tibet | +| 29 | YN | Yunnan | +| 30 | ZJ | Zhejiang | +| 31 | HK | Hong Kong | +| 32 | MO | Macao | +| 33 | TW | Taiwan | + +**Total subdivisions:** 34 diff --git a/src/swarms/doc/iso3166-2/250-France.md b/src/swarms/doc/iso3166-2/250-France.md new file mode 100644 index 00000000..7ba69076 --- /dev/null +++ b/src/swarms/doc/iso3166-2/250-France.md @@ -0,0 +1,28 @@ +# France (250) + +ISO 3166-1 numeric: **250** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | ARA | Auvergne-Rhône-Alpes | +| 1 | BFC | Bourgogne-Franche-Comté | +| 2 | BRE | Brittany | +| 3 | CVL | Centre-Val de Loire | +| 4 | COR | Corsica | +| 5 | GES | Grand Est | +| 6 | HDF | Hauts-de-France | +| 7 | IDF | Île-de-France | +| 8 | NOR | Normandy | +| 9 | NAQ | Nouvelle-Aquitaine | +| 10 | OCC | Occitanie | +| 11 | PDL | Pays de la Loire | +| 12 | PAC | Provence-Alpes-Côte d'Azur | +| 13 | GP | Guadeloupe | +| 14 | MQ | Martinique | +| 15 | GF | French Guiana | +| 16 | RE | Réunion | +| 17 | YT | Mayotte | + +**Total subdivisions:** 18 diff --git a/src/swarms/doc/iso3166-2/276-Germany.md b/src/swarms/doc/iso3166-2/276-Germany.md new file mode 100644 index 00000000..30fb23e1 --- /dev/null +++ b/src/swarms/doc/iso3166-2/276-Germany.md @@ -0,0 +1,26 @@ +# Germany (276) + +ISO 3166-1 numeric: **276** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | BW | Baden-Württemberg | +| 1 | BY | Bavaria | +| 2 | BE | Berlin | +| 3 | BB | Brandenburg | +| 4 | HB | Bremen | +| 5 | HH | Hamburg | +| 6 | HE | Hesse | +| 7 | MV | Mecklenburg-Vorpommern | +| 8 | NI | Lower Saxony | +| 9 | NW | North Rhine-Westphalia | +| 10 | RP | Rhineland-Palatinate | +| 11 | SL | Saarland | +| 12 | SN | Saxony | +| 13 | ST | Saxony-Anhalt | +| 14 | SH | Schleswig-Holstein | +| 15 | TH | Thuringia | + +**Total subdivisions:** 16 diff --git a/src/swarms/doc/iso3166-2/356-India.md b/src/swarms/doc/iso3166-2/356-India.md new file mode 100644 index 00000000..1dc05958 --- /dev/null +++ b/src/swarms/doc/iso3166-2/356-India.md @@ -0,0 +1,46 @@ +# India (356) + +ISO 3166-1 numeric: **356** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AN | Andaman and Nicobar Islands | +| 1 | AP | Andhra Pradesh | +| 2 | AR | Arunachal Pradesh | +| 3 | AS | Assam | +| 4 | BR | Bihar | +| 5 | CH | Chandigarh | +| 6 | CT | Chhattisgarh | +| 7 | DH | Dadra and Nagar Haveli and Daman and Diu | +| 8 | DL | Delhi | +| 9 | GA | Goa | +| 10 | GJ | Gujarat | +| 11 | HR | Haryana | +| 12 | HP | Himachal Pradesh | +| 13 | JK | Jammu and Kashmir | +| 14 | JH | Jharkhand | +| 15 | KA | Karnataka | +| 16 | KL | Kerala | +| 17 | LA | Ladakh | +| 18 | LD | Lakshadweep | +| 19 | MP | Madhya Pradesh | +| 20 | MH | Maharashtra | +| 21 | MN | Manipur | +| 22 | ML | Meghalaya | +| 23 | MZ | Mizoram | +| 24 | NL | Nagaland | +| 25 | OR | Odisha | +| 26 | PY | Puducherry | +| 27 | PB | Punjab | +| 28 | RJ | Rajasthan | +| 29 | SK | Sikkim | +| 30 | TN | Tamil Nadu | +| 31 | TG | Telangana | +| 32 | TR | Tripura | +| 33 | UP | Uttar Pradesh | +| 34 | UT | Uttarakhand | +| 35 | WB | West Bengal | + +**Total subdivisions:** 36 diff --git a/src/swarms/doc/iso3166-2/380-Italy.md b/src/swarms/doc/iso3166-2/380-Italy.md new file mode 100644 index 00000000..741969a5 --- /dev/null +++ b/src/swarms/doc/iso3166-2/380-Italy.md @@ -0,0 +1,30 @@ +# Italy (380) + +ISO 3166-1 numeric: **380** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | 65 | Abruzzo | +| 1 | 77 | Basilicata | +| 2 | 78 | Calabria | +| 3 | 72 | Campania | +| 4 | 45 | Emilia-Romagna | +| 5 | 36 | Friuli-Venezia Giulia | +| 6 | 62 | Lazio | +| 7 | 42 | Liguria | +| 8 | 25 | Lombardy | +| 9 | 57 | Marche | +| 10 | 67 | Molise | +| 11 | 21 | Piedmont | +| 12 | 75 | Apulia | +| 13 | 88 | Sardinia | +| 14 | 82 | Sicily | +| 15 | 52 | Tuscany | +| 16 | 32 | Trentino-South Tyrol | +| 17 | 55 | Umbria | +| 18 | 23 | Aosta Valley | +| 19 | 34 | Veneto | + +**Total subdivisions:** 20 diff --git a/src/swarms/doc/iso3166-2/392-Japan.md b/src/swarms/doc/iso3166-2/392-Japan.md new file mode 100644 index 00000000..d8779e49 --- /dev/null +++ b/src/swarms/doc/iso3166-2/392-Japan.md @@ -0,0 +1,57 @@ +# Japan (392) + +ISO 3166-1 numeric: **392** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | 01 | Hokkaido | +| 1 | 02 | Aomori | +| 2 | 03 | Iwate | +| 3 | 04 | Miyagi | +| 4 | 05 | Akita | +| 5 | 06 | Yamagata | +| 6 | 07 | Fukushima | +| 7 | 08 | Ibaraki | +| 8 | 09 | Tochigi | +| 9 | 10 | Gunma | +| 10 | 11 | Saitama | +| 11 | 12 | Chiba | +| 12 | 13 | Tokyo | +| 13 | 14 | Kanagawa | +| 14 | 15 | Niigata | +| 15 | 16 | Toyama | +| 16 | 17 | Ishikawa | +| 17 | 18 | Fukui | +| 18 | 19 | Yamanashi | +| 19 | 20 | Nagano | +| 20 | 21 | Gifu | +| 21 | 22 | Shizuoka | +| 22 | 23 | Aichi | +| 23 | 24 | Mie | +| 24 | 25 | Shiga | +| 25 | 26 | Kyoto | +| 26 | 27 | Osaka | +| 27 | 28 | Hyogo | +| 28 | 29 | Nara | +| 29 | 30 | Wakayama | +| 30 | 31 | Tottori | +| 31 | 32 | Shimane | +| 32 | 33 | Okayama | +| 33 | 34 | Hiroshima | +| 34 | 35 | Yamaguchi | +| 35 | 36 | Tokushima | +| 36 | 37 | Kagawa | +| 37 | 38 | Ehime | +| 38 | 39 | Kochi | +| 39 | 40 | Fukuoka | +| 40 | 41 | Saga | +| 41 | 42 | Nagasaki | +| 42 | 43 | Kumamoto | +| 43 | 44 | Oita | +| 44 | 45 | Miyazaki | +| 45 | 46 | Kagoshima | +| 46 | 47 | Okinawa | + +**Total subdivisions:** 47 diff --git a/src/swarms/doc/iso3166-2/410-South_Korea.md b/src/swarms/doc/iso3166-2/410-South_Korea.md new file mode 100644 index 00000000..33d4cec6 --- /dev/null +++ b/src/swarms/doc/iso3166-2/410-South_Korea.md @@ -0,0 +1,27 @@ +# South Korea (410) + +ISO 3166-1 numeric: **410** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | 11 | Seoul | +| 1 | 26 | Busan | +| 2 | 27 | Daegu | +| 3 | 28 | Incheon | +| 4 | 29 | Gwangju | +| 5 | 30 | Daejeon | +| 6 | 31 | Ulsan | +| 7 | 41 | Gyeonggi | +| 8 | 42 | Gangwon | +| 9 | 43 | North Chungcheong | +| 10 | 44 | South Chungcheong | +| 11 | 45 | North Jeolla | +| 12 | 46 | South Jeolla | +| 13 | 47 | North Gyeongsang | +| 14 | 48 | South Gyeongsang | +| 15 | 49 | Jeju | +| 16 | 50 | Sejong | + +**Total subdivisions:** 17 diff --git a/src/swarms/doc/iso3166-2/484-Mexico.md b/src/swarms/doc/iso3166-2/484-Mexico.md new file mode 100644 index 00000000..c6dc84da --- /dev/null +++ b/src/swarms/doc/iso3166-2/484-Mexico.md @@ -0,0 +1,42 @@ +# Mexico (484) + +ISO 3166-1 numeric: **484** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AGU | Aguascalientes | +| 1 | BCN | Baja California | +| 2 | BCS | Baja California Sur | +| 3 | CAM | Campeche | +| 4 | CHP | Chiapas | +| 5 | CHH | Chihuahua | +| 6 | CMX | Mexico City | +| 7 | COA | Coahuila | +| 8 | COL | Colima | +| 9 | DUR | Durango | +| 10 | GUA | Guanajuato | +| 11 | GRO | Guerrero | +| 12 | HID | Hidalgo | +| 13 | JAL | Jalisco | +| 14 | MEX | State of Mexico | +| 15 | MIC | Michoacán | +| 16 | MOR | Morelos | +| 17 | NAY | Nayarit | +| 18 | NLE | Nuevo León | +| 19 | OAX | Oaxaca | +| 20 | PUE | Puebla | +| 21 | QUE | Querétaro | +| 22 | ROO | Quintana Roo | +| 23 | SLP | San Luis Potosí | +| 24 | SIN | Sinaloa | +| 25 | SON | Sonora | +| 26 | TAB | Tabasco | +| 27 | TAM | Tamaulipas | +| 28 | TLA | Tlaxcala | +| 29 | VER | Veracruz | +| 30 | YUC | Yucatán | +| 31 | ZAC | Zacatecas | + +**Total subdivisions:** 32 diff --git a/src/swarms/doc/iso3166-2/566-Nigeria.md b/src/swarms/doc/iso3166-2/566-Nigeria.md new file mode 100644 index 00000000..5c16c422 --- /dev/null +++ b/src/swarms/doc/iso3166-2/566-Nigeria.md @@ -0,0 +1,47 @@ +# Nigeria (566) + +ISO 3166-1 numeric: **566** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AB | Abia | +| 1 | FC | Abuja Federal Capital Territory | +| 2 | AD | Adamawa | +| 3 | AK | Akwa Ibom | +| 4 | AN | Anambra | +| 5 | BA | Bauchi | +| 6 | BY | Bayelsa | +| 7 | BE | Benue | +| 8 | BO | Borno | +| 9 | CR | Cross River | +| 10 | DE | Delta | +| 11 | EB | Ebonyi | +| 12 | ED | Edo | +| 13 | EK | Ekiti | +| 14 | EN | Enugu | +| 15 | GO | Gombe | +| 16 | IM | Imo | +| 17 | JI | Jigawa | +| 18 | KD | Kaduna | +| 19 | KN | Kano | +| 20 | KT | Katsina | +| 21 | KE | Kebbi | +| 22 | KO | Kogi | +| 23 | KW | Kwara | +| 24 | LA | Lagos | +| 25 | NA | Nasarawa | +| 26 | NI | Niger | +| 27 | OG | Ogun | +| 28 | ON | Ondo | +| 29 | OS | Osun | +| 30 | OY | Oyo | +| 31 | PL | Plateau | +| 32 | RI | Rivers | +| 33 | SO | Sokoto | +| 34 | TA | Taraba | +| 35 | YO | Yobe | +| 36 | ZA | Zamfara | + +**Total subdivisions:** 37 diff --git a/src/swarms/doc/iso3166-2/643-Russia.md b/src/swarms/doc/iso3166-2/643-Russia.md new file mode 100644 index 00000000..b0ee9307 --- /dev/null +++ b/src/swarms/doc/iso3166-2/643-Russia.md @@ -0,0 +1,93 @@ +# Russia (643) + +ISO 3166-1 numeric: **643** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AD | Adygea, Republic of | +| 1 | AL | Altai Republic | +| 2 | ALT | Altai Krai | +| 3 | AMU | Amur Oblast | +| 4 | ARK | Arkhangelsk Oblast | +| 5 | AST | Astrakhan Oblast | +| 6 | BA | Bashkortostan, Republic of | +| 7 | BEL | Belgorod Oblast | +| 8 | BRY | Bryansk Oblast | +| 9 | BU | Buryatia, Republic of | +| 10 | CE | Chechen Republic | +| 11 | CHE | Chelyabinsk Oblast | +| 12 | CHU | Chukotka Autonomous Okrug | +| 13 | CU | Chuvash Republic | +| 14 | DA | Dagestan, Republic of | +| 15 | IN | Ingushetia, Republic of | +| 16 | IRK | Irkutsk Oblast | +| 17 | IVA | Ivanovo Oblast | +| 18 | KB | Kabardino-Balkar Republic | +| 19 | KGD | Kaliningrad Oblast | +| 20 | KL | Kalmykia, Republic of | +| 21 | KLU | Kaluga Oblast | +| 22 | KAM | Kamchatka Krai | +| 23 | KC | Karachay-Cherkess Republic | +| 24 | KR | Karelia, Republic of | +| 25 | KEM | Kemerovo Oblast | +| 26 | KHA | Khabarovsk Krai | +| 27 | KK | Khakassia, Republic of | +| 28 | KHM | Khanty-Mansi Autonomous Okrug | +| 29 | KIR | Kirov Oblast | +| 30 | KO | Komi Republic | +| 31 | KOS | Kostroma Oblast | +| 32 | KDA | Krasnodar Krai | +| 33 | KYA | Krasnoyarsk Krai | +| 34 | KGN | Kurgan Oblast | +| 35 | KRS | Kursk Oblast | +| 36 | LEN | Leningrad Oblast | +| 37 | LIP | Lipetsk Oblast | +| 38 | MAG | Magadan Oblast | +| 39 | ME | Mari El Republic | +| 40 | MO | Mordovia, Republic of | +| 41 | MOS | Moscow Oblast | +| 42 | MOW | Moscow | +| 43 | MUR | Murmansk Oblast | +| 44 | NEN | Nenets Autonomous Okrug | +| 45 | NIZ | Nizhny Novgorod Oblast | +| 46 | NGR | Novgorod Oblast | +| 47 | NVS | Novosibirsk Oblast | +| 48 | OMS | Omsk Oblast | +| 49 | ORE | Orenburg Oblast | +| 50 | ORL | Oryol Oblast | +| 51 | PNZ | Penza Oblast | +| 52 | PER | Perm Krai | +| 53 | PRI | Primorsky Krai | +| 54 | PSK | Pskov Oblast | +| 55 | ROS | Rostov Oblast | +| 56 | RYA | Ryazan Oblast | +| 57 | SA | Sakha (Yakutia), Republic of | +| 58 | SAK | Sakhalin Oblast | +| 59 | SAM | Samara Oblast | +| 60 | SPE | Saint Petersburg | +| 61 | SAR | Saratov Oblast | +| 62 | SE | North Ossetia-Alania, Republic of | +| 63 | SMO | Smolensk Oblast | +| 64 | STA | Stavropol Krai | +| 65 | SVE | Sverdlovsk Oblast | +| 66 | TAM | Tambov Oblast | +| 67 | TA | Tatarstan, Republic of | +| 68 | TOM | Tomsk Oblast | +| 69 | TUL | Tula Oblast | +| 70 | TVE | Tver Oblast | +| 71 | TY | Tuva Republic | +| 72 | TYU | Tyumen Oblast | +| 73 | UD | Udmurt Republic | +| 74 | ULY | Ulyanovsk Oblast | +| 75 | VLA | Vladimir Oblast | +| 76 | VGG | Volgograd Oblast | +| 77 | VLG | Vologda Oblast | +| 78 | VOR | Voronezh Oblast | +| 79 | YAN | Yamalo-Nenets Autonomous Okrug | +| 80 | YAR | Yaroslavl Oblast | +| 81 | YEV | Jewish Autonomous Oblast | +| 82 | ZAB | Zabaykalsky Krai | + +**Total subdivisions:** 83 diff --git a/src/swarms/doc/iso3166-2/710-South_Africa.md b/src/swarms/doc/iso3166-2/710-South_Africa.md new file mode 100644 index 00000000..67d256d6 --- /dev/null +++ b/src/swarms/doc/iso3166-2/710-South_Africa.md @@ -0,0 +1,19 @@ +# South Africa (710) + +ISO 3166-1 numeric: **710** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | EC | Eastern Cape | +| 1 | FS | Free State | +| 2 | GT | Gauteng | +| 3 | NL | KwaZulu-Natal | +| 4 | LP | Limpopo | +| 5 | MP | Mpumalanga | +| 6 | NW | North West | +| 7 | NC | Northern Cape | +| 8 | WC | Western Cape | + +**Total subdivisions:** 9 diff --git a/src/swarms/doc/iso3166-2/724-Spain.md b/src/swarms/doc/iso3166-2/724-Spain.md new file mode 100644 index 00000000..22f90ce5 --- /dev/null +++ b/src/swarms/doc/iso3166-2/724-Spain.md @@ -0,0 +1,29 @@ +# Spain (724) + +ISO 3166-1 numeric: **724** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AN | Andalusia | +| 1 | AR | Aragon | +| 2 | AS | Asturias, Principality of | +| 3 | CN | Canary Islands | +| 4 | CB | Cantabria | +| 5 | CL | Castile and León | +| 6 | CM | Castilla-La Mancha | +| 7 | CT | Catalonia | +| 8 | CE | Ceuta | +| 9 | EX | Extremadura | +| 10 | GA | Galicia | +| 11 | IB | Balearic Islands | +| 12 | RI | La Rioja | +| 13 | MD | Community of Madrid | +| 14 | ML | Melilla | +| 15 | MC | Murcia, Region of | +| 16 | NC | Navarre, Chartered Community of | +| 17 | PV | Basque Country | +| 18 | VC | Valencian Community | + +**Total subdivisions:** 19 diff --git a/src/swarms/doc/iso3166-2/756-Switzerland.md b/src/swarms/doc/iso3166-2/756-Switzerland.md new file mode 100644 index 00000000..3a3cde66 --- /dev/null +++ b/src/swarms/doc/iso3166-2/756-Switzerland.md @@ -0,0 +1,36 @@ +# Switzerland (756) + +ISO 3166-1 numeric: **756** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AG | Aargau | +| 1 | AI | Appenzell Innerrhoden | +| 2 | AR | Appenzell Ausserrhoden | +| 3 | BE | Bern | +| 4 | BL | Basel-Landschaft | +| 5 | BS | Basel-Stadt | +| 6 | FR | Fribourg | +| 7 | GE | Geneva | +| 8 | GL | Glarus | +| 9 | GR | Graubünden | +| 10 | JU | Jura | +| 11 | LU | Lucerne | +| 12 | NE | Neuchâtel | +| 13 | NW | Nidwalden | +| 14 | OW | Obwalden | +| 15 | SG | St. Gallen | +| 16 | SH | Schaffhausen | +| 17 | SO | Solothurn | +| 18 | SZ | Schwyz | +| 19 | TG | Thurgau | +| 20 | TI | Ticino | +| 21 | UR | Uri | +| 22 | VD | Vaud | +| 23 | VS | Valais | +| 24 | ZG | Zug | +| 25 | ZH | Zurich | + +**Total subdivisions:** 26 diff --git a/src/swarms/doc/iso3166-2/826-United_Kingdom.md b/src/swarms/doc/iso3166-2/826-United_Kingdom.md new file mode 100644 index 00000000..ed7226ca --- /dev/null +++ b/src/swarms/doc/iso3166-2/826-United_Kingdom.md @@ -0,0 +1,182 @@ +# United Kingdom (826) + +ISO 3166-1 numeric: **826** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | ENG | England | +| 1 | NIR | Northern Ireland | +| 2 | SCT | Scotland | +| 3 | WLS | Wales | +| 4 | BKM | Buckinghamshire | +| 5 | CAM | Cambridgeshire | +| 6 | CMA | Cumbria | +| 7 | DBY | Derbyshire | +| 8 | DEV | Devon | +| 9 | DOR | Dorset | +| 10 | ESX | East Sussex | +| 11 | ESS | Essex | +| 12 | GLS | Gloucestershire | +| 13 | HAM | Hampshire | +| 14 | HRT | Hertfordshire | +| 15 | KEN | Kent | +| 16 | LAN | Lancashire | +| 17 | LEC | Leicestershire | +| 18 | LIN | Lincolnshire | +| 19 | NFK | Norfolk | +| 20 | NYK | North Yorkshire | +| 21 | NTH | Northamptonshire | +| 22 | NTT | Nottinghamshire | +| 23 | OXF | Oxfordshire | +| 24 | SOM | Somerset | +| 25 | STS | Staffordshire | +| 26 | SFK | Suffolk | +| 27 | SRY | Surrey | +| 28 | WAR | Warwickshire | +| 29 | WSX | West Sussex | +| 30 | WOR | Worcestershire | +| 31 | LND | London, City of | +| 32 | BDG | Barking and Dagenham | +| 33 | BNE | Barnet | +| 34 | BEX | Bexley | +| 35 | BEN | Brent | +| 36 | BRY | Bromley | +| 37 | CMD | Camden | +| 38 | CRY | Croydon | +| 39 | EAL | Ealing | +| 40 | ENF | Enfield | +| 41 | GRE | Greenwich | +| 42 | HCK | Hackney | +| 43 | HMF | Hammersmith and Fulham | +| 44 | HRY | Haringey | +| 45 | HRW | Harrow | +| 46 | HAV | Havering | +| 47 | HIL | Hillingdon | +| 48 | HNS | Hounslow | +| 49 | ISL | Islington | +| 50 | KEC | Kensington and Chelsea | +| 51 | KTT | Kingston upon Thames | +| 52 | LBH | Lambeth | +| 53 | LEW | Lewisham | +| 54 | MRT | Merton | +| 55 | NWM | Newham | +| 56 | RDB | Redbridge | +| 57 | RIC | Richmond upon Thames | +| 58 | SWK | Southwark | +| 59 | STN | Sutton | +| 60 | TWH | Tower Hamlets | +| 61 | WFT | Waltham Forest | +| 62 | WND | Wandsworth | +| 63 | WSM | Westminster | +| 64 | BNS | Barnsley | +| 65 | BIR | Birmingham | +| 66 | BOL | Bolton | +| 67 | BRD | Bradford | +| 68 | BRI | Brighton and Hove | +| 69 | BST | Bristol, City of | +| 70 | CAL | Calderdale | +| 71 | COV | Coventry | +| 72 | DER | Derby | +| 73 | DUD | Dudley | +| 74 | GAT | Gateshead | +| 75 | KIR | Kirklees | +| 76 | KWL | Knowsley | +| 77 | LDS | Leeds | +| 78 | LCE | Leicester | +| 79 | LIV | Liverpool | +| 80 | MAN | Manchester | +| 81 | NET | Newcastle upon Tyne | +| 82 | NTY | North Tyneside | +| 83 | OLD | Oldham | +| 84 | PTE | Peterborough | +| 85 | PLY | Plymouth | +| 86 | RCH | Rochdale | +| 87 | ROT | Rotherham | +| 88 | SLF | Salford | +| 89 | SAW | Sandwell | +| 90 | SFT | Sefton | +| 91 | SHF | Sheffield | +| 92 | SOL | Solihull | +| 93 | STY | South Tyneside | +| 94 | SHN | Southampton | +| 95 | SGC | South Gloucestershire | +| 96 | STH | Southend-on-Sea | +| 97 | SKP | Stockport | +| 98 | STE | Stoke-on-Trent | +| 99 | SND | Sunderland | +| 100 | TAM | Tameside | +| 101 | TRF | Trafford | +| 102 | WKF | Wakefield | +| 103 | WLL | Walsall | +| 104 | WGN | Wigan | +| 105 | WRL | Wirral | +| 106 | WLV | Wolverhampton | +| 107 | ABE | Aberdeen City | +| 108 | ABD | Aberdeenshire | +| 109 | ANS | Angus | +| 110 | AGB | Argyll and Bute | +| 111 | CLK | Clackmannanshire | +| 112 | DGY | Dumfries and Galloway | +| 113 | DND | Dundee City | +| 114 | EAY | East Ayrshire | +| 115 | EDU | East Dunbartonshire | +| 116 | ELN | East Lothian | +| 117 | ERW | East Renfrewshire | +| 118 | EDH | Edinburgh, City of | +| 119 | ELS | Eilean Siar | +| 120 | FAL | Falkirk | +| 121 | FIF | Fife | +| 122 | GLG | Glasgow City | +| 123 | HLD | Highland | +| 124 | IVC | Inverclyde | +| 125 | MLN | Midlothian | +| 126 | MRY | Moray | +| 127 | NAY | North Ayrshire | +| 128 | NLK | North Lanarkshire | +| 129 | ORK | Orkney Islands | +| 130 | PKN | Perth and Kinross | +| 131 | RFW | Renfrewshire | +| 132 | SCB | Scottish Borders | +| 133 | ZET | Shetland Islands | +| 134 | SAY | South Ayrshire | +| 135 | SLK | South Lanarkshire | +| 136 | STG | Stirling | +| 137 | WDU | West Dunbartonshire | +| 138 | WLN | West Lothian | +| 139 | BGW | Blaenau Gwent | +| 140 | BGE | Bridgend | +| 141 | CAY | Caerphilly | +| 142 | CRF | Cardiff | +| 143 | CMN | Carmarthenshire | +| 144 | CGN | Ceredigion | +| 145 | CWY | Conwy | +| 146 | DEN | Denbighshire | +| 147 | FLN | Flintshire | +| 148 | GWN | Gwynedd | +| 149 | AGY | Isle of Anglesey | +| 150 | MTY | Merthyr Tydfil | +| 151 | MON | Monmouthshire | +| 152 | NTL | Neath Port Talbot | +| 153 | NWP | Newport | +| 154 | PEM | Pembrokeshire | +| 155 | POW | Powys | +| 156 | RCT | Rhondda Cynon Taf | +| 157 | SWA | Swansea | +| 158 | TOF | Torfaen | +| 159 | VGL | Vale of Glamorgan | +| 160 | WRX | Wrexham | +| 161 | ANT | Antrim and Newtownabbey | +| 162 | ARD | Ards and North Down | +| 163 | ABC | Armagh City, Banbridge and Craigavon | +| 164 | BFS | Belfast | +| 165 | CCG | Causeway Coast and Glens | +| 166 | DRS | Derry City and Strabane | +| 167 | FMO | Fermanagh and Omagh | +| 168 | LBC | Lisburn and Castlereagh | +| 169 | MEA | Mid and East Antrim | +| 170 | MUL | Mid Ulster | +| 171 | NMD | Newry, Mourne and Down | + +**Total subdivisions:** 172 diff --git a/src/swarms/doc/iso3166-2/840-United_States.md b/src/swarms/doc/iso3166-2/840-United_States.md new file mode 100644 index 00000000..7d0e4972 --- /dev/null +++ b/src/swarms/doc/iso3166-2/840-United_States.md @@ -0,0 +1,67 @@ +# United States (840) + +ISO 3166-1 numeric: **840** + +## Admin Area Mappings + +| Dense Index | ISO 3166-2 | Name | +|-------------|------------|------| +| 0 | AL | Alabama | +| 1 | AK | Alaska | +| 2 | AZ | Arizona | +| 3 | AR | Arkansas | +| 4 | CA | California | +| 5 | CO | Colorado | +| 6 | CT | Connecticut | +| 7 | DE | Delaware | +| 8 | FL | Florida | +| 9 | GA | Georgia | +| 10 | HI | Hawaii | +| 11 | ID | Idaho | +| 12 | IL | Illinois | +| 13 | IN | Indiana | +| 14 | IA | Iowa | +| 15 | KS | Kansas | +| 16 | KY | Kentucky | +| 17 | LA | Louisiana | +| 18 | ME | Maine | +| 19 | MD | Maryland | +| 20 | MA | Massachusetts | +| 21 | MI | Michigan | +| 22 | MN | Minnesota | +| 23 | MS | Mississippi | +| 24 | MO | Missouri | +| 25 | MT | Montana | +| 26 | NE | Nebraska | +| 27 | NV | Nevada | +| 28 | NH | New Hampshire | +| 29 | NJ | New Jersey | +| 30 | NM | New Mexico | +| 31 | NY | New York | +| 32 | NC | North Carolina | +| 33 | ND | North Dakota | +| 34 | OH | Ohio | +| 35 | OK | Oklahoma | +| 36 | OR | Oregon | +| 37 | PA | Pennsylvania | +| 38 | RI | Rhode Island | +| 39 | SC | South Carolina | +| 40 | SD | South Dakota | +| 41 | TN | Tennessee | +| 42 | TX | Texas | +| 43 | UT | Utah | +| 44 | VT | Vermont | +| 45 | VA | Virginia | +| 46 | WA | Washington | +| 47 | WV | West Virginia | +| 48 | WI | Wisconsin | +| 49 | WY | Wyoming | +| 50 | DC | District of Columbia | +| 51 | AS | American Samoa | +| 52 | GU | Guam | +| 53 | MP | Northern Mariana Islands | +| 54 | PR | Puerto Rico | +| 55 | UM | United States Minor Outlying Islands | +| 56 | VI | Virgin Islands, U.S. | + +**Total subdivisions:** 57 From 78b1a9c09f474dd0038dc738c85b90b63ad96722 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Tue, 24 Feb 2026 12:46:47 +1300 Subject: [PATCH 41/63] Optimize region key encoding to 8-bit admin code - Reduce MAX_ADMIN_CODE from 4095 to 255 (sufficient for all real-world countries) - Update ADMIN_SHIFT from 12 to 8 and ADMIN_CODE_MASK from 0xFFF to 0xFF - Update documentation with ISO 3166-2 mapping details - Minor code improvements: use ++i over i++, change tokenUuid/tokenRegion to public - Update error message for consistency --- src/swarms/FleetIdentity.sol | 20 +++++++++++--------- test/FleetIdentity.t.sol | 2 +- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 7482d508..e20ee951 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -17,7 +17,7 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * * Fleets register at exactly one level: * - Country — regionKey = countryCode (ISO 3166-1 numeric, 1-999) - * - Admin Area — regionKey = (countryCode << 12) | adminCode (>= 4096) + * - Admin Area — regionKey = (countryCode << 8) | adminCode (>= 256) * * Each regionKey has its **own independent tier namespace** — tier indices * start at 0 for every region. The first fleet in any region always pays @@ -116,7 +116,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint16 internal constant MAX_COUNTRY_CODE = 999; /// @notice Upper bound for admin-area codes within a country. - uint16 internal constant MAX_ADMIN_CODE = 4095; + /// @dev Set to 255 to cover all real-world countries (UK has ~172, the highest). + /// Dense indices from ISO 3166-2 mappings range 0-254, stored as adminCode 1-255. + uint16 internal constant MAX_ADMIN_CODE = 255; /// @dev Bundle level index: admin area (highest priority). uint256 internal constant LEVEL_ADMIN = 0; @@ -124,9 +126,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 internal constant LEVEL_COUNTRY = 1; /// @dev Bit shift for packing countryCode into an admin-area region key. - uint256 private constant ADMIN_SHIFT = 12; + uint256 private constant ADMIN_SHIFT = 8; /// @dev Bitmask for extracting adminCode from an admin-area region key. - uint32 private constant ADMIN_CODE_MASK = 0xFFF; + uint32 private constant ADMIN_CODE_MASK = 0xFF; /// @notice The ERC-20 token used for bonds (immutable, e.g. NODL). IERC20 public immutable BOND_TOKEN; @@ -494,7 +496,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Level changes require single token if (levelChange && uuidTokenCount[uuid] > 1) { - return (0, false, "Cannot change level with multiple tokens"); + return (0, false, "Can't change level: multi tokens"); } // Validate target tier @@ -541,18 +543,18 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { 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])); + for (uint256 i = 0; i < members.length; ++i) { + uuids[i] = tokenUuid(members[i]); } } /// @notice UUID for a token ID (extracts lower 128 bits). - function tokenUuid(uint256 tokenId) external pure returns (bytes16) { + function tokenUuid(uint256 tokenId) public pure returns (bytes16) { return bytes16(uint128(tokenId)); } /// @notice Region key encoded in a token ID (extracts bits 128-159). - function tokenRegion(uint256 tokenId) external pure returns (uint32) { + function tokenRegion(uint256 tokenId) public pure returns (uint32) { return uint32(tokenId >> 128); } diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index ed05a8a7..5b9d12c2 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1646,7 +1646,7 @@ contract FleetIdentityTest is Test { assertFalse(canMigrate); assertEq(bondDelta, 0); - assertEq(reason, "Cannot change level with multiple tokens"); + assertEq(reason, "Can't change level: multi tokens"); } function test_migrationHint_tierFull() public { From ee56652e6bea5098e8924af9d46fbcf80515e129 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 25 Feb 2026 12:04:22 +1300 Subject: [PATCH 42/63] feat: Add owned-only UUID mode and fix ADMIN_SHIFT value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement RegistrationLevel.Owned for standalone UUID ownership without region registration - Add claimUuid(), unregisterToOwned(), and releaseUuid() functions - Remove migrateRegion functionality (replaced by unregisterToOwned + re-register) - Change ADMIN_SHIFT from 8 to 10 to eliminate country/admin key overlap - Update ADMIN_CODE_MASK to 0x3FF for 10-bit admin codes - Update _register() to handle Owned→Registered transitions - Update _update() to transfer uuidOwner on owned-only token transfers - Update tests with owned-only mode tests and correct shift values - All 191 tests passing (180 FleetIdentity + 11 FleetIdentityFairness) --- src/swarms/FleetIdentity.sol | 341 +++++++++---------- src/swarms/doc/README.md | 6 +- test/FleetIdentity.t.sol | 542 +++++++++++++++---------------- test/FleetIdentityFairness.t.sol | 2 +- 4 files changed, 443 insertions(+), 448 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index e20ee951..8d0bf556 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -72,8 +72,11 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { error AdminAreaRequired(); error UuidOwnerMismatch(); error UuidLevelMismatch(); - error CannotMigrateLevelWithMultipleTokens(); - error SameRegion(); + error UuidAlreadyOwned(); + error UuidNotOwned(); + error NotUuidOwner(); + error CannotUnregisterMultipleTokens(); + error AlreadyRegistered(); // ────────────────────────────────────────────── // Enums @@ -83,8 +86,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { enum RegistrationLevel { None, // 0 - not registered Local, // 1 - admin area (local) level - Country // 2 - country level - + Country, // 2 - country level + Owned // 3 - owned but not registered in any region } // ────────────────────────────────────────────── @@ -126,9 +129,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 internal constant LEVEL_COUNTRY = 1; /// @dev Bit shift for packing countryCode into an admin-area region key. - uint256 private constant ADMIN_SHIFT = 8; + uint256 private constant ADMIN_SHIFT = 10; /// @dev Bitmask for extracting adminCode from an admin-area region key. - uint32 private constant ADMIN_CODE_MASK = 0xFF; + uint32 private constant ADMIN_CODE_MASK = 0x3FF; + + /// @notice Region key for owned-only UUIDs (not registered in any region). + uint32 public constant OWNED_REGION_KEY = 0; /// @notice The ERC-20 token used for bonds (immutable, e.g. NODL). IERC20 public immutable BOND_TOKEN; @@ -203,15 +209,11 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { event FleetBurned( address indexed owner, uint256 indexed tokenId, uint32 indexed regionKey, uint256 tierIndex, uint256 bondRefund ); - event FleetMigrated( - uint256 indexed oldTokenId, - uint256 indexed newTokenId, - uint32 oldRegion, - uint32 newRegion, - uint256 oldTier, - uint256 newTier, - int256 bondDelta + event UuidClaimed(address indexed owner, bytes16 indexed uuid, uint256 tokenId, uint256 bond); + event UuidUnregistered( + address indexed owner, bytes16 indexed uuid, uint256 oldTokenId, uint256 newTokenId, uint256 refund ); + event UuidReleased(address indexed owner, bytes16 indexed uuid, uint256 refund); // ────────────────────────────────────────────── // Constructor @@ -291,26 +293,35 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Burn // ══════════════════════════════════════════════ - /// @notice Burns the fleet NFT and refunds the tier bond to the token owner. + /// @notice Burns the fleet NFT and refunds the bond to the token owner. + /// Handles both registered fleets (regional) and owned-only UUIDs. function burn(uint256 tokenId) external nonReentrant { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); uint32 region = uint32(tokenId >> 128); - uint256 tier = fleetTier[tokenId]; - uint256 refund = tierBond(tier, _isCountryRegion(region)); - - // Extract UUID for ownership tracking cleanup bytes16 uuid = bytes16(uint128(tokenId)); + uint256 refund; + uint256 tier; + + if (region == OWNED_REGION_KEY) { + // Owned-only token: no tier structures, just BASE_BOND + refund = BASE_BOND; + tier = 0; + _burn(tokenId); + } else { + // Registered fleet: remove from tier structures + tier = fleetTier[tokenId]; + refund = tierBond(tier, _isCountryRegion(region)); - // Effects - _removeFromTier(tokenId, region, tier); - delete fleetTier[tokenId]; - delete _indexInTier[tokenId]; - _burn(tokenId); + _removeFromTier(tokenId, region, tier); + delete fleetTier[tokenId]; + delete _indexInTier[tokenId]; + _burn(tokenId); - _trimTierCount(region); - _removeFromRegionIndex(region); + _trimTierCount(region); + _removeFromRegionIndex(region); + } // Clean up UUID ownership tracking uint256 newCount = uuidTokenCount[uuid] - 1; @@ -331,88 +342,99 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } // ══════════════════════════════════════════════ - // Migration: Change region (and optionally level) + // UUID Ownership (Owned-Only Mode) // ══════════════════════════════════════════════ - /// @notice Migrate a fleet to a new region, optionally changing level (local ↔ country). - /// @dev Level changes (local → country or vice versa) are only allowed when this is the - /// ONLY token for this UUID. Use `migrationHint()` to check feasibility and bond delta. - /// @param tokenId The fleet token to migrate. - /// @param newRegionKey Target region (country code 1-999 for country level, or packed admin-area). - /// @param targetTier Target tier in the new region. - /// @return newTokenId The new token ID (region encoded in upper bits). - function migrateRegion(uint256 tokenId, uint32 newRegionKey, uint256 targetTier) - external - nonReentrant - returns (uint256 newTokenId) - { - address tokenOwner = ownerOf(tokenId); - if (tokenOwner != msg.sender) revert NotTokenOwner(); + /// @notice Claim ownership of a UUID without registering in any region. + /// Costs BASE_BOND. The UUID can later be registered via registerFleetLocal/Country. + /// @param uuid The Proximity UUID to claim. + /// @return tokenId The token ID for the owned-only UUID (region=0). + function claimUuid(bytes16 uuid) external nonReentrant returns (uint256 tokenId) { + if (uuid == bytes16(0)) revert InvalidUUID(); + if (uuidOwner[uuid] != address(0)) revert UuidAlreadyOwned(); - bytes16 uuid = bytes16(uint128(tokenId)); - uint32 oldRegion = uint32(tokenId >> 128); - uint256 oldTier = fleetTier[tokenId]; + // Set ownership + uuidOwner[uuid] = msg.sender; + uuidLevel[uuid] = RegistrationLevel.Owned; + uuidTokenCount[uuid] = 1; - if (newRegionKey == oldRegion) revert SameRegion(); + // Mint token with region=0 + tokenId = uint256(uint128(uuid)); + _mint(msg.sender, tokenId); - // Determine if this is a level change - bool wasCountry = _isCountryRegion(oldRegion); - bool isCountry = _isCountryRegion(newRegionKey); - bool levelChange = wasCountry != isCountry; + // Pull bond + BOND_TOKEN.safeTransferFrom(msg.sender, address(this), BASE_BOND); - // Level changes require this to be the only token for the UUID - if (levelChange && uuidTokenCount[uuid] > 1) { - revert CannotMigrateLevelWithMultipleTokens(); - } + emit UuidClaimed(msg.sender, uuid, tokenId, BASE_BOND); + } - // Validate new region key - if (isCountry) { - if (newRegionKey == 0 || newRegionKey > MAX_COUNTRY_CODE) revert InvalidCountryCode(); - } else { - uint16 cc = _countryFromRegion(newRegionKey); - uint16 admin = _adminFromRegion(newRegionKey); - if (cc == 0 || cc > MAX_COUNTRY_CODE) revert InvalidCountryCode(); - if (admin == 0 || admin > MAX_ADMIN_CODE) revert InvalidAdminCode(); - } + /// @notice Move a registered fleet back to owned-only mode, receiving a partial refund. + /// Only works when this is the ONLY token for the UUID. + /// @param tokenId The fleet token to unregister. + /// @return newTokenId The new owned-only token ID (region=0). + function unregisterToOwned(uint256 tokenId) external nonReentrant returns (uint256 newTokenId) { + address tokenOwner = ownerOf(tokenId); + if (tokenOwner != msg.sender) revert NotTokenOwner(); - // Validate target tier in new region - _validateExplicitTier(newRegionKey, targetTier); + bytes16 uuid = bytes16(uint128(tokenId)); + uint32 region = uint32(tokenId >> 128); - // Compute bond delta - uint256 oldBond = tierBond(oldTier, wasCountry); - uint256 newBond = tierBond(targetTier, isCountry); - int256 bondDelta = int256(newBond) - int256(oldBond); + // Must be a registered fleet, not already owned-only + if (region == OWNED_REGION_KEY) revert UuidNotOwned(); - // Compute new tokenId (region encoded in upper bits) - newTokenId = (uint256(newRegionKey) << 128) | uint256(uint128(uuid)); + // Must be the only token for this UUID + if (uuidTokenCount[uuid] > 1) revert CannotUnregisterMultipleTokens(); + + uint256 tier = fleetTier[tokenId]; + uint256 currentBond = tierBond(tier, _isCountryRegion(region)); + uint256 refund = currentBond - BASE_BOND; - // === Effects: Remove from old region === - _removeFromTier(tokenId, oldRegion, oldTier); + // === Effects: Remove from region === + _removeFromTier(tokenId, region, tier); delete fleetTier[tokenId]; delete _indexInTier[tokenId]; _burn(tokenId); - _trimTierCount(oldRegion); - _removeFromRegionIndex(oldRegion); + _trimTierCount(region); + _removeFromRegionIndex(region); - // Update level if changed - if (levelChange) { - uuidLevel[uuid] = isCountry ? RegistrationLevel.Country : RegistrationLevel.Local; - } + // Update level to Owned (resets level for future registration flexibility) + uuidLevel[uuid] = RegistrationLevel.Owned; - // === Effects: Add to new region === - fleetTier[newTokenId] = targetTier; - _addToTier(newTokenId, newRegionKey, targetTier); - _addToRegionIndex(newRegionKey); + // Mint owned-only token + newTokenId = uint256(uint128(uuid)); _mint(msg.sender, newTokenId); - // === Interactions: Handle bond delta === - if (bondDelta > 0) { - BOND_TOKEN.safeTransferFrom(msg.sender, address(this), uint256(bondDelta)); - } else if (bondDelta < 0) { - BOND_TOKEN.safeTransfer(msg.sender, uint256(-bondDelta)); + // Interaction: refund excess bond + if (refund > 0) { + BOND_TOKEN.safeTransfer(msg.sender, refund); } - emit FleetMigrated(tokenId, newTokenId, oldRegion, newRegionKey, oldTier, targetTier, bondDelta); + emit UuidUnregistered(msg.sender, uuid, tokenId, newTokenId, refund); + } + + /// @notice Release an owned-only UUID, refunding the BASE_BOND. + /// After release, the UUID can be claimed by anyone. + /// @param uuid The UUID to release (must be in owned-only state). + function releaseUuid(bytes16 uuid) external nonReentrant { + if (uuidLevel[uuid] != RegistrationLevel.Owned) revert UuidNotOwned(); + if (uuidOwner[uuid] != msg.sender) revert NotUuidOwner(); + + // Get the token ID for this owned-only UUID + uint256 tokenId = uint256(uint128(uuid)); + address tokenOwner = ownerOf(tokenId); + + // Burn the token + _burn(tokenId); + + // Clear ownership + delete uuidOwner[uuid]; + delete uuidTokenCount[uuid]; + delete uuidLevel[uuid]; + + // Refund BASE_BOND to token owner (not necessarily uuidOwner due to transfers) + BOND_TOKEN.safeTransfer(tokenOwner, BASE_BOND); + + emit UuidReleased(tokenOwner, uuid, BASE_BOND); } // ══════════════════════════════════════════════ @@ -464,64 +486,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { bond = tierBond(inclusionTier, true); } - /// @notice Check if a fleet can migrate to a new region and calculate bond delta. - /// @dev Returns (bondDelta, canMigrate, reason). - /// - bondDelta > 0: additional bond required - /// - bondDelta < 0: refund available - /// - bondDelta = 0: no change - /// @param tokenId The fleet token to check. - /// @param newRegionKey Target region (country code 1-999 for country level, or packed admin-area). - /// @param targetTier Target tier in the new region. - function migrationHint(uint256 tokenId, uint32 newRegionKey, uint256 targetTier) - external - view - returns (int256 bondDelta, bool canMigrate, string memory reason) - { - address owner = _ownerOf(tokenId); - if (owner == address(0)) { - return (0, false, "Token does not exist"); - } - - bytes16 uuid = bytes16(uint128(tokenId)); - uint32 oldRegion = uint32(tokenId >> 128); - uint256 oldTier = fleetTier[tokenId]; - - if (newRegionKey == oldRegion) { - return (0, false, "Same region"); - } - - bool wasCountry = _isCountryRegion(oldRegion); - bool isCountry = _isCountryRegion(newRegionKey); - bool levelChange = wasCountry != isCountry; - - // Level changes require single token - if (levelChange && uuidTokenCount[uuid] > 1) { - return (0, false, "Can't change level: multi tokens"); - } - - // Validate target tier - if (targetTier >= MAX_TIERS) { - return (0, false, "Target tier exceeds MAX_TIERS"); - } - - // Check if target tier is available (only check fullness - new tiers can be opened) - if (_regionTierMembers[newRegionKey][targetTier].length >= TIER_CAPACITY) { - return (0, false, "Target tier is full"); - } - - uint256 oldBond = tierBond(oldTier, wasCountry); - uint256 newBond = tierBond(targetTier, isCountry); - bondDelta = int256(newBond) - int256(oldBond); - canMigrate = true; - if (bondDelta > 0) { - reason = "Additional bond required"; - } else if (bondDelta < 0) { - reason = "Refund available"; - } else { - reason = "No bond change"; - } - } - /// @notice Highest non-empty tier in a region, or 0 if none. function highestActiveTier(uint32 regionKey) external view returns (uint256) { uint256 tierCount = regionTierCount[regionKey]; @@ -566,7 +530,14 @@ 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 tierBond(fleetTier[tokenId], _isCountryRegion(uint32(tokenId >> 128))); + uint32 region = uint32(tokenId >> 128); + if (region == OWNED_REGION_KEY) return BASE_BOND; + return tierBond(fleetTier[tokenId], _isCountryRegion(region)); + } + + /// @notice Returns true if the UUID is in owned-only state (claimed but not registered). + function isOwnedOnly(bytes16 uuid) external view returns (bool) { + return uuidLevel[uuid] == RegistrationLevel.Owned; } // ══════════════════════════════════════════════ @@ -762,44 +733,68 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @dev Returns true if the region key represents a country-level registration. + /// Region 0 (owned-only) is not a country region. function _isCountryRegion(uint32 regionKey) internal pure returns (bool) { - return regionKey <= MAX_COUNTRY_CODE; + return regionKey > 0 && regionKey <= MAX_COUNTRY_CODE; } - /// @dev Shared registration logic. + /// @dev Shared registration logic. Handles both fresh registrations and Owned → Registered transitions. function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { - // UUID ownership enforcement: all tokens for the same UUID must be owned by the same address + RegistrationLevel existingLevel = uuidLevel[uuid]; address existingOwner = uuidOwner[uuid]; - if (existingOwner == address(0)) { - // First registration for this UUID - uuidOwner[uuid] = msg.sender; - } else if (existingOwner != msg.sender) { - // Subsequent registration by a different address - revert UuidOwnerMismatch(); + bool isCountry = _isCountryRegion(region); + RegistrationLevel targetLevel = isCountry ? RegistrationLevel.Country : RegistrationLevel.Local; + + // Handle Owned → Registered transition + if (existingLevel == RegistrationLevel.Owned) { + if (existingOwner != msg.sender) revert UuidOwnerMismatch(); + + // Burn the owned-only token + uint256 ownedTokenId = uint256(uint128(uuid)); + _burn(ownedTokenId); + + // Update level + uuidLevel[uuid] = targetLevel; + // uuidTokenCount stays the same (1) + + // Calculate bond: full tier bond minus BASE_BOND already paid + uint256 fullBond = tierBond(tier, isCountry); + uint256 incrementalBond = fullBond - BASE_BOND; + + // Mint new token + tokenId = (uint256(region) << 128) | uint256(uint128(uuid)); + fleetTier[tokenId] = tier; + _addToTier(tokenId, region, tier); + _addToRegionIndex(region); + _mint(msg.sender, tokenId); + + // Pull incremental bond + if (incrementalBond > 0) { + BOND_TOKEN.safeTransferFrom(msg.sender, address(this), incrementalBond); + } + + emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, incrementalBond); + return tokenId; } - // UUID level enforcement: all tokens for a UUID must be at the same level - bool isCountry = _isCountryRegion(region); - RegistrationLevel level = isCountry ? RegistrationLevel.Country : RegistrationLevel.Local; - RegistrationLevel existingLevel = uuidLevel[uuid]; - if (existingLevel == RegistrationLevel.None) { + // Standard registration (None or existing level) + if (existingOwner == address(0)) { // First registration for this UUID - uuidLevel[uuid] = level; - } else if (existingLevel != level) { - // Subsequent registration at different level - revert UuidLevelMismatch(); + uuidOwner[uuid] = msg.sender; + uuidLevel[uuid] = targetLevel; + } else { + if (existingOwner != msg.sender) revert UuidOwnerMismatch(); + if (existingLevel != targetLevel) revert UuidLevelMismatch(); } uuidTokenCount[uuid]++; uint256 bond = tierBond(tier, isCountry); - // TokenID encodes both region and uuid: (region << 128) | uuid tokenId = (uint256(region) << 128) | uint256(uint128(uuid)); // Effects fleetTier[tokenId] = tier; _addToTier(tokenId, region, tier); - _addToRegionIndex(region); _mint(msg.sender, tokenId); @@ -1113,7 +1108,17 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // ────────────────────────────────────────────── function _update(address to, uint256 tokenId, address auth) internal override(ERC721Enumerable) returns (address) { - return super._update(to, tokenId, auth); + address from = super._update(to, tokenId, auth); + + // For owned-only tokens, transfer uuidOwner when the token is transferred + // This allows marketplace trading of owned-only UUIDs + uint32 region = uint32(tokenId >> 128); + if (region == OWNED_REGION_KEY && from != address(0) && to != address(0)) { + bytes16 uuid = bytes16(uint128(tokenId)); + uuidOwner[uuid] = to; + } + + return from; } function _increaseBalance(address account, uint128 value) internal override(ERC721Enumerable) { diff --git a/src/swarms/doc/README.md b/src/swarms/doc/README.md index 71ec2b14..f1ccf631 100644 --- a/src/swarms/doc/README.md +++ b/src/swarms/doc/README.md @@ -12,8 +12,8 @@ Example: [840-United_States.md](iso3166-2/840-United_States.md) for the United S Each country file contains a mapping table with three columns: -| Dense Index | ISO 3166-2 Code | Name | -|-------------|-----------------|------| +| Dense Index | ISO 3166-2 Code | Name | +| ----------- | --------------- | --------------------- | | 0 | XX | Full subdivision name | - **Dense Index**: Sequential integers from 0 to n-1 (where n = number of subdivisions) @@ -23,6 +23,7 @@ Each country file contains a mapping table with three columns: ## Usage with FleetIdentity Contract The FleetIdentity contract uses: + - **Country Code**: ISO 3166-1 numeric (1-999) - **Admin Code**: Dense index + 1 (1-4095) - Add 1 to the dense index when calling contract functions @@ -32,6 +33,7 @@ The FleetIdentity contract uses: ## Example For California, USA: + - Country: United States (ISO 3166-1 numeric: 840) - ISO 3166-2: US-CA - Dense Index: 4 (from table) diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 5b9d12c2..0fa8d13e 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -103,7 +103,7 @@ contract FleetIdentityTest is Test { /// @dev Given a UUID from buildBundle, find tokenId by checking local first, then country function _findTokenId(bytes16 uuid, uint16 cc, uint16 admin) internal view returns (uint256) { - uint32 localRegion = (uint32(cc) << 12) | uint32(admin); + uint32 localRegion = (uint32(cc) << 10) | uint32(admin); uint256 localTokenId = _tokenId(uuid, localRegion); // Check if local token exists by trying to get its owner try fleet.ownerOf(localTokenId) returns (address) { @@ -127,15 +127,15 @@ contract FleetIdentityTest is Test { } function _regionUSCA() internal pure returns (uint32) { - return (uint32(US) << 12) | uint32(ADMIN_CA); + return (uint32(US) << 10) | uint32(ADMIN_CA); } function _regionUSNY() internal pure returns (uint32) { - return (uint32(US) << 12) | uint32(ADMIN_NY); + return (uint32(US) << 10) | uint32(ADMIN_NY); } function _makeAdminRegion(uint16 cc, uint16 admin) internal pure returns (uint32) { - return (uint32(cc) << 12) | uint32(admin); + return (uint32(cc) << 10) | uint32(admin); } function _registerNCountry(address owner, uint16 cc, uint256 count, uint256 startSeed) @@ -788,12 +788,12 @@ contract FleetIdentityTest is Test { } function test_adminRegionKey() public view { - assertEq(fleet.adminRegionKey(US, ADMIN_CA), (uint32(US) << 12) | uint32(ADMIN_CA)); + assertEq(fleet.adminRegionKey(US, ADMIN_CA), (uint32(US) << 10) | uint32(ADMIN_CA)); } function test_regionKeyNoOverlap_countryVsAdmin() public pure { uint32 maxCountry = 999; - uint32 minAdmin = (uint32(1) << 12) | uint32(1); + uint32 minAdmin = (uint32(1) << 10) | uint32(1); assertTrue(minAdmin > maxCountry); } @@ -967,12 +967,12 @@ contract FleetIdentityTest is Test { function testFuzz_registerFleetLocal_validCodes(uint16 cc, uint16 admin) public { cc = uint16(bound(cc, 1, 999)); - admin = uint16(bound(admin, 1, 4095)); + admin = uint16(bound(admin, 1, 255)); vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, cc, admin, 0); - uint32 expectedRegion = (uint32(cc) << 12) | uint32(admin); + uint32 expectedRegion = (uint32(cc) << 10) | uint32(admin); assertEq(fleet.tokenRegion(tokenId), expectedRegion); assertEq(fleet.fleetTier(tokenId), 0); assertEq(fleet.bonds(tokenId), BASE_BOND); @@ -1194,8 +1194,8 @@ contract FleetIdentityTest is Test { function testFuzz_uuidOwner_enforcedAcrossAllRegions(uint16 cc1, uint16 cc2, uint16 admin1, uint16 admin2) public { cc1 = uint16(bound(cc1, 1, 999)); cc2 = uint16(bound(cc2, 1, 999)); - admin1 = uint16(bound(admin1, 1, 4095)); - admin2 = uint16(bound(admin2, 1, 4095)); + admin1 = uint16(bound(admin1, 1, 255)); + admin2 = uint16(bound(admin2, 1, 255)); // Alice registers first vm.prank(alice); @@ -1337,353 +1337,341 @@ contract FleetIdentityTest is Test { } // ══════════════════════════════════════════════ - // Migration Tests + // Owned-Only Mode Tests // ══════════════════════════════════════════════ - function test_migrateRegion_localToLocal_sameCountry() public { - vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - uint32 oldRegion = fleet.tokenRegion(oldTokenId); - uint32 newRegion = _makeAdminRegion(US, ADMIN_NY); - + function test_claimUuid_basic() public { + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + vm.prank(alice); - uint256 newTokenId = fleet.migrateRegion(oldTokenId, newRegion, 0); - - // Old token burned - vm.expectRevert(); - fleet.ownerOf(oldTokenId); - - // New token exists - assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.tokenRegion(newTokenId), newRegion); - assertEq(fleet.fleetTier(newTokenId), 0); - assertEq(fleet.tokenUuid(newTokenId), UUID_1); - - // UUID tracking unchanged + uint256 tokenId = fleet.claimUuid(UUID_1); + + // Token minted + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(fleet.tokenUuid(tokenId), UUID_1); + assertEq(fleet.tokenRegion(tokenId), 0); // OWNED_REGION_KEY + + // UUID ownership set assertEq(fleet.uuidOwner(UUID_1), alice); assertEq(fleet.uuidTokenCount(UUID_1), 1); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // still local + assertTrue(fleet.isOwnedOnly(UUID_1)); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Owned + + // Bond pulled + assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), BASE_BOND); + + // bonds() returns BASE_BOND for owned-only + assertEq(fleet.bonds(tokenId), BASE_BOND); } - function test_migrateRegion_localToLocal_differentCountry() public { - vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); - + function test_RevertIf_claimUuid_alreadyOwned() public { vm.prank(alice); - uint256 newTokenId = fleet.migrateRegion(oldTokenId, newRegion, 0); - - assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.tokenRegion(newTokenId), newRegion); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // still local + fleet.claimUuid(UUID_1); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.UuidAlreadyOwned.selector); + fleet.claimUuid(UUID_1); } - function test_migrateRegion_countryToCountry() public { - vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetCountry(UUID_1, US, 0); - + function test_RevertIf_claimUuid_alreadyRegistered() public { vm.prank(alice); - uint256 newTokenId = fleet.migrateRegion(oldTokenId, uint32(DE), 0); - - assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.tokenRegion(newTokenId), uint32(DE)); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // still country + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.UuidAlreadyOwned.selector); + fleet.claimUuid(UUID_1); } - function test_migrateRegion_localToCountry_singleToken() public { - vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); - + function test_RevertIf_claimUuid_invalidUuid() public { vm.prank(alice); - uint256 newTokenId = fleet.migrateRegion(oldTokenId, uint32(US), 0); - - assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.tokenRegion(newTokenId), uint32(US)); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // changed to country + vm.expectRevert(FleetIdentity.InvalidUUID.selector); + fleet.claimUuid(bytes16(0)); } - function test_migrateRegion_countryToLocal_singleToken() public { + function test_registerFromOwned_local() public { + // First claim vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetCountry(UUID_1, US, 0); - - assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); - - uint32 newRegion = _makeAdminRegion(US, ADMIN_CA); + uint256 ownedTokenId = fleet.claimUuid(UUID_1); + + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + + // Register from owned state vm.prank(alice); - uint256 newTokenId = fleet.migrateRegion(oldTokenId, newRegion, 0); - - assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.tokenRegion(newTokenId), newRegion); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // changed to local + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + // Old owned token burned + vm.expectRevert(); + fleet.ownerOf(ownedTokenId); + + // New token exists + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(fleet.tokenRegion(tokenId), _regionUSCA()); + assertEq(fleet.fleetTier(tokenId), 0); + + // UUID state updated + assertEq(fleet.uuidOwner(UUID_1), alice); + assertEq(fleet.uuidTokenCount(UUID_1), 1); // still 1 + assertFalse(fleet.isOwnedOnly(UUID_1)); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // Local + + // Only incremental bond pulled (tier 0 local = BASE_BOND, already paid BASE_BOND) + assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), 0); } - function test_RevertIf_migrateRegion_levelChangeWithMultipleTokens() public { - // Register UUID_1 in two local regions - vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + function test_registerFromOwned_country() public { vm.prank(alice); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); - - // Try to migrate one to country level → revert + fleet.claimUuid(UUID_1); + + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + vm.prank(alice); - vm.expectRevert(FleetIdentity.CannotMigrateLevelWithMultipleTokens.selector); - fleet.migrateRegion(id1, uint32(US), 0); + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); + + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(fleet.tokenRegion(tokenId), uint32(US)); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // Country + + // Incremental bond: country tier 0 = 8*BASE_BOND, already paid BASE_BOND, so 7*BASE_BOND + assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), 7 * BASE_BOND); } - function test_migrateRegion_sameLevelWithMultipleTokens() public { - // Register UUID_1 in two local regions + function test_registerFromOwned_higherTier() public { vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - vm.prank(alice); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); - - // Migrate to different local region (same level) → allowed - uint32 newRegion = _makeAdminRegion(FR, ADMIN_CA); + fleet.claimUuid(UUID_1); + + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + + // Register at tier 2 local (4*BASE_BOND) vm.prank(alice); - uint256 newTokenId = fleet.migrateRegion(id1, newRegion, 0); - - assertEq(fleet.ownerOf(newTokenId), alice); - assertEq(fleet.tokenRegion(newTokenId), newRegion); - assertEq(fleet.uuidTokenCount(UUID_1), 2); // still 2 tokens + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); + + // Incremental: 4*BASE_BOND - BASE_BOND = 3*BASE_BOND + assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), 3 * BASE_BOND); } - function test_migrateRegion_bondRefundOnDowngrade() public { - // Register at tier 1 + function test_unregisterToOwned_basic() public { vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 1); - + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - - // Migrate to tier 0 → should get refund - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + vm.prank(alice); - fleet.migrateRegion(oldTokenId, newRegion, 0); - - uint256 aliceBalanceAfter = bondToken.balanceOf(alice); - uint256 expectedRefund = BASE_BOND; // tier 1 = 2x, tier 0 = 1x, diff = 1x - assertEq(aliceBalanceAfter - aliceBalanceBefore, expectedRefund); + uint256 ownedTokenId = fleet.unregisterToOwned(tokenId); + + // Old token burned + vm.expectRevert(); + fleet.ownerOf(tokenId); + + // New owned-only token exists + assertEq(fleet.ownerOf(ownedTokenId), alice); + assertEq(fleet.tokenRegion(ownedTokenId), 0); + + // UUID state updated + assertTrue(fleet.isOwnedOnly(UUID_1)); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Owned + + // No refund for tier 0 local (BASE_BOND - BASE_BOND = 0) + assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, 0); } - function test_migrateRegion_bondPullOnUpgrade() public { - // Register at tier 0 + function test_unregisterToOwned_withRefund() public { + // Register at tier 2 local (4*BASE_BOND) vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - - // Migrate to tier 1 → should pull additional bond - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + vm.prank(alice); - fleet.migrateRegion(oldTokenId, newRegion, 1); - - uint256 aliceBalanceAfter = bondToken.balanceOf(alice); - uint256 expectedPull = BASE_BOND; // tier 0 = 1x, tier 1 = 2x, diff = 1x - assertEq(aliceBalanceBefore - aliceBalanceAfter, expectedPull); + fleet.unregisterToOwned(tokenId); + + // Refund: 4*BASE_BOND - BASE_BOND = 3*BASE_BOND + assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, 3 * BASE_BOND); } - function test_migrateRegion_localToCountry_bondAdjustment() public { - // Register local at tier 0 (bond = BASE_BOND) + function test_unregisterToOwned_fromCountry() public { + // Register country tier 0 (8*BASE_BOND) vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - - // Migrate to country tier 0 (bond = 8 * BASE_BOND) + vm.prank(alice); - fleet.migrateRegion(oldTokenId, uint32(US), 0); - - uint256 aliceBalanceAfter = bondToken.balanceOf(alice); - uint256 expectedPull = 7 * BASE_BOND; // country = 8x, local = 1x, diff = 7x - assertEq(aliceBalanceBefore - aliceBalanceAfter, expectedPull); + fleet.unregisterToOwned(tokenId); + + // Refund: 8*BASE_BOND - BASE_BOND = 7*BASE_BOND + assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, 7 * BASE_BOND); + + // Level reset to Owned + assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); } - function test_migrateRegion_countryToLocal_bondRefund() public { - // Register country at tier 0 (bond = 8 * BASE_BOND) + function test_RevertIf_unregisterToOwned_multipleTokens() public { vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetCountry(UUID_1, US, 0); - - uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - - // Migrate to local tier 0 (bond = BASE_BOND) - uint32 newRegion = _makeAdminRegion(US, ADMIN_CA); + uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); - fleet.migrateRegion(oldTokenId, newRegion, 0); - - uint256 aliceBalanceAfter = bondToken.balanceOf(alice); - uint256 expectedRefund = 7 * BASE_BOND; // country = 8x, local = 1x, diff = 7x - assertEq(aliceBalanceAfter - aliceBalanceBefore, expectedRefund); - } - - function test_RevertIf_migrateRegion_notOwner() public { + fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); + vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); - vm.prank(bob); - vm.expectRevert(FleetIdentity.NotTokenOwner.selector); - fleet.migrateRegion(tokenId, newRegion, 0); + vm.expectRevert(FleetIdentity.CannotUnregisterMultipleTokens.selector); + fleet.unregisterToOwned(id1); } - function test_RevertIf_migrateRegion_sameRegion() public { + function test_RevertIf_unregisterToOwned_alreadyOwned() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - uint32 sameRegion = _regionUSCA(); + uint256 tokenId = fleet.claimUuid(UUID_1); + vm.prank(alice); - vm.expectRevert(FleetIdentity.SameRegion.selector); - fleet.migrateRegion(tokenId, sameRegion, 0); + vm.expectRevert(FleetIdentity.UuidNotOwned.selector); + fleet.unregisterToOwned(tokenId); } - function test_RevertIf_migrateRegion_tierFull() public { - // Fill tier 0 in DE - _registerNLocalAt(bob, DE, ADMIN_CA, 4, 0, 0); - + function test_releaseUuid_basic() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); + uint256 tokenId = fleet.claimUuid(UUID_1); + + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + vm.prank(alice); - vm.expectRevert(FleetIdentity.TierFull.selector); - fleet.migrateRegion(tokenId, newRegion, 0); + fleet.releaseUuid(UUID_1); + + // Token burned + vm.expectRevert(); + fleet.ownerOf(tokenId); + + // UUID cleared + assertEq(fleet.uuidOwner(UUID_1), address(0)); + assertEq(fleet.uuidTokenCount(UUID_1), 0); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 0); // None + + // Refund received + assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, BASE_BOND); } - function test_migrateRegion_emitsEvent() public { + function test_releaseUuid_afterTransfer() public { vm.prank(alice); - uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 1); - - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); - uint256 expectedNewTokenId = fleet.computeTokenId(UUID_1, newRegion); - - vm.expectEmit(true, true, true, true); - emit FleetIdentity.FleetMigrated( - oldTokenId, - expectedNewTokenId, - _regionUSCA(), - newRegion, - 1, // old tier - 0, // new tier - -int256(BASE_BOND) // refund - ); - + uint256 tokenId = fleet.claimUuid(UUID_1); + + // Transfer to bob vm.prank(alice); - fleet.migrateRegion(oldTokenId, newRegion, 0); - } - - // ══════════════════════════════════════════════ - // Migration Hint Tests - // ══════════════════════════════════════════════ - - function test_migrationHint_validMigration_refund() public { + fleet.transferFrom(alice, bob, tokenId); + + // uuidOwner should have updated + assertEq(fleet.uuidOwner(UUID_1), bob); + + // Alice cannot release vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 1); - - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); - (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 0); - - assertTrue(canMigrate, "Should be able to migrate"); - assertEq(bondDelta, -int256(BASE_BOND), "Should get refund"); - assertEq(reason, "Refund available"); + vm.expectRevert(FleetIdentity.NotUuidOwner.selector); + fleet.releaseUuid(UUID_1); + + // Bob can release + uint256 bobBalanceBefore = bondToken.balanceOf(bob); + vm.prank(bob); + fleet.releaseUuid(UUID_1); + assertEq(bondToken.balanceOf(bob) - bobBalanceBefore, BASE_BOND); } - function test_migrationHint_validMigration_additionalBond() public { + function test_RevertIf_releaseUuid_notOwned() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); - (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 1); - - assertTrue(canMigrate, "Should be able to migrate"); - assertEq(bondDelta, int256(BASE_BOND), "Should require additional bond"); - assertEq(reason, "Additional bond required"); - } - - function test_migrationHint_validMigration_noBondChange() public { + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); - (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 0); - - assertTrue(canMigrate); - assertEq(bondDelta, 0); - assertEq(reason, "No bond change"); + vm.expectRevert(FleetIdentity.UuidNotOwned.selector); + fleet.releaseUuid(UUID_1); } - function test_migrationHint_tokenDoesNotExist() public view { - uint256 nonExistentToken = 999999; - (int256 bondDelta, bool canMigrate, string memory reason) = - fleet.migrationHint(nonExistentToken, _regionUSCA(), 0); - - assertFalse(canMigrate); - assertEq(bondDelta, 0); - assertEq(reason, "Token does not exist"); - } - - function test_migrationHint_sameRegion() public { + function test_ownedOnly_transfer_updatesUuidOwner() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, _regionUSCA(), 0); - - assertFalse(canMigrate); - assertEq(bondDelta, 0); - assertEq(reason, "Same region"); + uint256 tokenId = fleet.claimUuid(UUID_1); + + assertEq(fleet.uuidOwner(UUID_1), alice); + + vm.prank(alice); + fleet.transferFrom(alice, bob, tokenId); + + // uuidOwner updated on transfer for owned-only tokens + assertEq(fleet.uuidOwner(UUID_1), bob); + assertEq(fleet.ownerOf(tokenId), bob); } - function test_migrationHint_cannotChangeLevelWithMultipleTokens() public { + function test_ownedOnly_notInBundle() public { + // Claim some UUIDs as owned-only vm.prank(alice); - uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + fleet.claimUuid(UUID_1); vm.prank(alice); - fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); - - (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(id1, uint32(US), 0); - - assertFalse(canMigrate); - assertEq(bondDelta, 0); - assertEq(reason, "Can't change level: multi tokens"); + fleet.claimUuid(UUID_2); + + // Bundle should be empty + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + assertEq(count, 0); + + // Now register one + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + // Bundle should contain only the registered one + (uuids, count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); + assertEq(count, 1); + assertEq(uuids[0], UUID_1); } - function test_migrationHint_tierFull() public { - _registerNLocalAt(bob, DE, ADMIN_CA, 4, 0, 0); - + function test_burn_ownedOnly() public { vm.prank(alice); - uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - uint32 newRegion = _makeAdminRegion(DE, ADMIN_CA); - (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 0); - - assertFalse(canMigrate); - assertEq(bondDelta, 0); - assertEq(reason, "Target tier is full"); + uint256 tokenId = fleet.claimUuid(UUID_1); + + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); + + vm.prank(alice); + fleet.burn(tokenId); + + // Token burned + vm.expectRevert(); + fleet.ownerOf(tokenId); + + // UUID cleared + assertEq(fleet.uuidOwner(UUID_1), address(0)); + + // Refund received + assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, BASE_BOND); } - function test_migrationHint_localToCountry_bondDelta() public { + function test_ownedOnly_canReRegisterAfterRelease() public { + vm.prank(alice); + fleet.claimUuid(UUID_1); + vm.prank(alice); + fleet.releaseUuid(UUID_1); + + // Bob can now claim or register + vm.prank(bob); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - - (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, uint32(US), 0); - - assertTrue(canMigrate); - assertEq(bondDelta, int256(7 * BASE_BOND), "8x - 1x = 7x additional"); - assertEq(reason, "Additional bond required"); + + assertEq(fleet.ownerOf(tokenId), bob); + assertEq(fleet.uuidOwner(UUID_1), bob); } - function test_migrationHint_countryToLocal_bondDelta() public { + function test_migration_viaUnregisterAndReregister() public { + // This test shows the new migration pattern using unregisterToOwned + + // Register local in US vm.prank(alice); - uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); - - uint32 newRegion = _makeAdminRegion(US, ADMIN_CA); - (int256 bondDelta, bool canMigrate, string memory reason) = fleet.migrationHint(tokenId, newRegion, 0); - - assertTrue(canMigrate); - assertEq(bondDelta, -int256(7 * BASE_BOND), "1x - 8x = -7x refund"); - assertEq(reason, "Refund available"); + uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + uint256 aliceBalanceAfterRegister = bondToken.balanceOf(alice); + + // Unregister to owned (no refund at tier 0 local) + vm.prank(alice); + fleet.unregisterToOwned(oldTokenId); + + // Re-register in DE as country (pays 8*BASE_BOND - BASE_BOND = 7*BASE_BOND) + vm.prank(alice); + uint256 newTokenId = fleet.registerFleetCountry(UUID_1, DE, 0); + + assertEq(fleet.ownerOf(newTokenId), alice); + assertEq(fleet.tokenRegion(newTokenId), uint32(DE)); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // Country + + // Net bond change: 7*BASE_BOND additional + assertEq(aliceBalanceAfterRegister - bondToken.balanceOf(alice), 7 * BASE_BOND); } function testFuzz_tierBond_geometric(uint256 tier) public view { diff --git a/test/FleetIdentityFairness.t.sol b/test/FleetIdentityFairness.t.sol index 5fde2499..8b757f2b 100644 --- a/test/FleetIdentityFairness.t.sol +++ b/test/FleetIdentityFairness.t.sol @@ -146,7 +146,7 @@ contract FleetIdentityFairnessTest is Test { } function _makeAdminRegion(uint16 cc, uint16 admin) internal pure returns (uint32) { - return (uint32(cc) << 12) | uint32(admin); + return (uint32(cc) << 10) | uint32(admin); } /// @dev Calculate total bond locked across all tiers in a region From e3d04bf85e7fa9975ad94f600d82d425511202b4 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 25 Feb 2026 12:55:52 +1300 Subject: [PATCH 43/63] refactor: simplify bundle algorithm and remove soft-cap logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace complex two-pass soft-cap system with simple tier-descent algorithm: - Iterate from max tier down to 0 - At each tier: take locals first, then country - Stop when bundle is full (20 slots) This removes ~150 lines of complexity including: - PREFERRED_COUNTRY_SLOTS constant (was 8) - _appendCountryUuidsWithCap helper (~30 lines) - _fillSkippedCountryUuids helper (~25 lines) - Soft-cap tracking arrays (countryIncludedPerTier, countrySkippedPerTier) **Fairness Model Change**: Shift from slot-guarantee (60/40 split) to economic-advantage model - Country fleets pay 8× more than locals (COUNTRY_BOND_MULTIPLIER) - Locals can reach tier 3 for the same cost country pays for tier 0 - This 8× multiplier is now the primary fairness mechanism - Enable future adjustments to fairness via the multiplier **Tests**: - FleetIdentity.t.sol: 180 passed (removed soft-cap assertions, renamed tests) - FleetIdentityFairness.t.sol: Rewritten with economic-advantage focus (13 tests) - Total: 193 tests pass **Lost Features** (documented): - Hard 60/40 slot allocation guarantee - Two-pass bundle filling system - Explicit whale resistance via soft cap (now via cost difference) See: https://github.com/nodl-ai/rollup/discussions/bundle-simplification --- src/swarms/FleetIdentity.sol | 201 +++------ test/FleetIdentity.t.sol | 27 +- test/FleetIdentityFairness.t.sol | 703 +++++++++++-------------------- 3 files changed, 323 insertions(+), 608 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 8d0bf556..8dcf5482 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -17,7 +17,7 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * * Fleets register at exactly one level: * - Country — regionKey = countryCode (ISO 3166-1 numeric, 1-999) - * - Admin Area — regionKey = (countryCode << 8) | adminCode (>= 256) + * - Admin Area — regionKey = (countryCode << 10) | adminCode (>= 1024) * * Each regionKey has its **own independent tier namespace** — tier indices * start at 0 for every region. The first fleet in any region always pays @@ -29,12 +29,11 @@ import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol * - Local bond: BASE_BOND * 2^tier * - Country bond: BASE_BOND * COUNTRY_BOND_MULTIPLIER * 2^tier (8× local) * - * Country fleets pay more but appear in all admin-area bundles within - * their country. Local fleets get priority over country fleets, with a - * soft cap (PREFERRED_COUNTRY_SLOTS = 8) reserving slots for locals. - * This cap is flexible in both directions: - * - If fewer country fleets exist, locals fill all 20 slots. - * - If fewer local fleets exist, country fleets fill unused slots beyond the cap. + * Country fleets pay 8× more but appear in all admin-area bundles within + * their country. This economic difference provides locals a significant + * advantage: a local can reach tier 3 for the same cost a country player + * pays for tier 0. Bundle slots are filled by simple tier-descent priority: + * higher tier first, locals before country within each tier. * * EdgeBeaconScanner discovery uses 2-level fallback: * 1. Admin area (highest priority) @@ -100,10 +99,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Bond multiplier for country-level registration (8× local). uint256 public constant COUNTRY_BOND_MULTIPLIER = 8; - /// @notice Preferred number of country fleet slots when locals are available. - /// This is a soft cap: country fleets can fill unused slots beyond this - /// limit when there aren't enough local fleets to fill the bundle. - uint256 public constant PREFERRED_COUNTRY_SLOTS = 8; + /// @notice Hard cap on tier count per region. /// @dev Derived from anti-spam analysis: with a bond doubling per tier @@ -544,36 +540,18 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Views: EdgeBeaconScanner discovery // ══════════════════════════════════════════════ - /// @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 and country levels. - /// - /// **Algorithm – Shared-Cursor with Partial Inclusion + Flexible Country Cap** - /// - /// Uses a single shared tier-index cursor that descends from the - /// highest active tier across both levels. At each cursor position: - /// - /// 1. Try to include members from admin area tier first (higher priority). - /// 2. Include country fleets up to PREFERRED_COUNTRY_SLOTS soft cap. - /// 3. Include as many members as fit in remaining bundle capacity. - /// Members are included in array order (registration order within tier). - /// 4. Decrement cursor and repeat until bundle is full or cursor < 0. + /// @notice Builds a priority-ordered bundle of up to 20 UUIDs for an EdgeBeaconScanner, + /// merging the highest-bonded tiers across admin-area and country levels. /// - /// **Second Pass – Fill Remaining Slots:** - /// If the bundle isn't full after the first pass (not enough local fleets), - /// country fleets that were skipped due to the soft cap are included to - /// fill remaining slots, maintaining tier priority order. + /// @dev **Priority Rules:** + /// 1. Higher bond tier always beats lower bond tier + /// 2. Within same tier: local (admin area) beats country + /// 3. Within same tier + level: earlier registration wins /// - /// The country cap (PREFERRED_COUNTRY_SLOTS = 8) is flexible in both directions: - /// - If fewer country fleets exist, locals fill the remaining slots. - /// - If fewer local fleets exist, country fleets fill unused slots beyond the cap. - /// - /// This guarantees: - /// - Higher-bonded fleets always take priority over lower-bonded ones. - /// - Within same bond tier, admin area takes precedence over country. - /// - Within same tier and level, earlier registrations take precedence. - /// - Bundle is always maximally filled (up to 20) if enough fleets exist. - /// - Locals get majority presence (at least 12 slots) when enough exist. + /// **Economic Fairness:** Country fleets pay 8× more (COUNTRY_BOND_MULTIPLIER) + /// than local fleets at the same tier. This means a local can reach tier 3 + /// for the same cost a country player pays for tier 0, giving locals a + /// significant economic advantage when competing for bundle slots. /// /// @param countryCode EdgeBeaconScanner country (must be > 0). /// @param adminCode EdgeBeaconScanner admin area (must be > 0). @@ -587,100 +565,57 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (adminCode == 0) revert AdminAreaRequired(); if (countryCode == 0) revert InvalidCountryCode(); - uuids = new bytes16[](MAX_BONDED_UUID_BUNDLE_SIZE); - uint256 countryIncluded = 0; + uint32 adminKey = _makeAdminRegion(countryCode, adminCode); + uint32 countryKey = uint32(countryCode); - (uint32[2] memory keys, bool[2] memory active) = _resolveBundleLevels(countryCode, adminCode); - uint256 maxTierIndex = _findMaxTierIndex(keys, active); - - // Track how many country members were included per tier in first pass, - // and how many more could be included in second pass. - // We use fixed-size arrays since MAX_TIERS = 24. - uint256[24] memory countryIncludedPerTier; - uint256[24] memory countrySkippedPerTier; + uint256 adminTiers = regionTierCount[adminKey]; + uint256 countryTiers = regionTierCount[countryKey]; - // === First Pass: Apply soft cap on country fleets === - for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { - if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; + // Find highest active tier across both levels + uint256 maxTier = adminTiers > 0 ? adminTiers - 1 : 0; + if (countryTiers > 0 && countryTiers - 1 > maxTier) maxTier = countryTiers - 1; - // Try each level at this cursor position in priority order: admin (0), country (1). - for (uint256 lvl = 0; lvl < 2; ++lvl) { - if (!active[lvl]) continue; - if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; - - // Skip if this level doesn't have a tier at this cursor index. - uint256 tc = regionTierCount[keys[lvl]]; - if (tc == 0 || uint256(cursor) >= tc) 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; - - // Apply country soft cap for level 1 (LEVEL_COUNTRY) - if (lvl == LEVEL_COUNTRY) { - uint256 countryRoom = - PREFERRED_COUNTRY_SLOTS > countryIncluded ? PREFERRED_COUNTRY_SLOTS - countryIncluded : 0; - uint256 cappedRoom = room < countryRoom ? room : countryRoom; - - // Track how many we're skipping for potential second pass - uint256 wouldInclude = mLen < room ? mLen : room; - uint256 actualInclude = mLen < cappedRoom ? mLen : cappedRoom; + uuids = new bytes16[](MAX_BONDED_UUID_BUNDLE_SIZE); - countryIncludedPerTier[uint256(cursor)] = actualInclude; - if (wouldInclude > actualInclude) { - countrySkippedPerTier[uint256(cursor)] = wouldInclude - actualInclude; - } - room = cappedRoom; - } + // Simple tier-descent: at each tier, locals first, then country + for (uint256 tier = maxTier + 1; tier > 0 && count < MAX_BONDED_UUID_BUNDLE_SIZE;) { + unchecked { --tier; } - uint256 toInclude = mLen < room ? mLen : room; + // Include local (admin area) members first (higher priority within tier) + count = _appendTierUuids(adminKey, tier, adminTiers, uuids, count); - // Include members in array order (registration order) - for (uint256 m = 0; m < toInclude; ++m) { - uuids[count] = bytes16(uint128(members[m])); - ++count; - } + if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; - // Track country inclusions - if (lvl == LEVEL_COUNTRY) { - countryIncluded += toInclude; - } - } + // Include country members + count = _appendTierUuids(countryKey, tier, countryTiers, uuids, count); } - // === Second Pass: Fill remaining slots with skipped country fleets === - // If bundle isn't full and we skipped country members due to soft cap, include them now. - if (count < MAX_BONDED_UUID_BUNDLE_SIZE && active[LEVEL_COUNTRY]) { - uint256 countryTc = regionTierCount[keys[LEVEL_COUNTRY]]; - - // Iterate from highest tier down to maintain bond priority - for (int256 cursor = int256(maxTierIndex); cursor >= 0; --cursor) { - if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; - if (uint256(cursor) >= countryTc) continue; - if (countrySkippedPerTier[uint256(cursor)] == 0) continue; - - uint256[] storage members = _regionTierMembers[keys[LEVEL_COUNTRY]][uint256(cursor)]; + // Trim array to actual size + assembly { + mstore(uuids, count) + } + } - // Start from where first pass left off - uint256 startIdx = countryIncludedPerTier[uint256(cursor)]; - uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; - uint256 toInclude = - countrySkippedPerTier[uint256(cursor)] < room ? countrySkippedPerTier[uint256(cursor)] : room; + /// @dev Appends UUIDs from a tier to the bundle array. Returns new count. + function _appendTierUuids( + uint32 regionKey, + uint256 tier, + uint256 tierCount, + bytes16[] memory uuids, + uint256 count + ) internal view returns (uint256) { + if (tier >= tierCount) return count; - for (uint256 m = startIdx; m < startIdx + toInclude; ++m) { - uuids[count] = bytes16(uint128(members[m])); - ++count; - } - } - } + uint256[] storage members = _regionTierMembers[regionKey][tier]; + uint256 len = members.length; + uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; + uint256 toInclude = len < room ? len : room; - // Trim the array to actual size. - assembly { - mstore(uuids, count) + for (uint256 i = 0; i < toInclude; ++i) { + uuids[count] = bytes16(uint128(members[i])); + unchecked { ++count; } } + return count; } // ══════════════════════════════════════════════ @@ -911,13 +846,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// returns the cheapest tier at `candidateLevel` that guarantees bundle /// inclusion. Bounded: O(MAX_TIERS). Works for admin or country level. /// - /// With partial inclusion + flexible country cap, a new fleet is included if: + /// A new fleet is included if: /// (a) The tier has capacity for the new fleet. /// (b) At that tier position, after higher-priority levels consume slots, /// there is room for at least one member from the candidate tier. - /// - /// Note: Country fleets are always included if there's room, since - /// the second pass fills unused slots beyond PREFERRED_COUNTRY_SLOTS. function _findCheapestInclusionTier(uint16 countryCode, uint16 adminCode, uint256 candidateLevel) internal view @@ -946,7 +878,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { countBefore = 0; } else { // Count from tiers strictly above T. - (countBefore,) = _getCountFromTiersAbove(keys, active, maxTierIndex, T); + countBefore = _getCountFromTiersAbove(keys, active, maxTierIndex, T); // Add higher-priority levels at tier T itself (only admin area for country candidates). if (candidateLevel == LEVEL_COUNTRY && active[LEVEL_ADMIN]) { @@ -964,8 +896,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } // Does the candidate (with +1) fit in bundle? - // Note: With flexible country cap, country fleets can fill any remaining - // slots via second pass, so we only check total bundle room. if (countBefore < MAX_BONDED_UUID_BUNDLE_SIZE) { return T; } @@ -975,16 +905,17 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @dev Helper to compute count from tiers STRICTLY above T (i.e., tiers > T). - /// Also returns how many country fleets were included (for soft cap tracking). + /// Simple tier-descent: at each tier, locals first, then country. function _getCountFromTiersAbove(uint32[2] memory keys, bool[2] memory active, uint256 maxTierIndex, uint256 T) internal view - returns (uint256 count, uint256 countryIncluded) + returns (uint256 count) { // Process tiers from maxTierIndex down to T+1 (exclusive of T). for (int256 cursor = int256(maxTierIndex); cursor > int256(T); --cursor) { if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; + // lvl 0 = admin (local), lvl 1 = country for (uint256 lvl = 0; lvl < 2; ++lvl) { if (!active[lvl]) continue; if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; @@ -996,20 +927,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (m == 0) continue; uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; - - // Apply soft cap for LEVEL_COUNTRY (first pass behavior) - if (lvl == LEVEL_COUNTRY) { - uint256 countryRoom = - PREFERRED_COUNTRY_SLOTS > countryIncluded ? PREFERRED_COUNTRY_SLOTS - countryIncluded : 0; - room = room < countryRoom ? room : countryRoom; - } - uint256 toInclude = m < room ? m : room; count += toInclude; - - if (lvl == LEVEL_COUNTRY) { - countryIncluded += toInclude; - } } } } diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 0fa8d13e..60c98c81 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -196,7 +196,6 @@ contract FleetIdentityTest is Test { assertEq(fleet.MAX_TIERS(), 24); assertEq(fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), 20); assertEq(fleet.COUNTRY_BOND_MULTIPLIER(), 8); - assertEq(fleet.PREFERRED_COUNTRY_SLOTS(), 8); } // --- tierBond --- @@ -2031,7 +2030,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(_uuid(2000 + i), US, ADMIN_CA, 1); } - // Country tier 1: 4 members (soft-capped by PREFERRED_COUNTRY_SLOTS) + // Country tier 1: 4 members for (uint256 i = 0; i < 4; i++) { vm.prank(alice); fleet.registerFleetCountry(_uuid(3000 + i), US, 1); @@ -2044,9 +2043,8 @@ contract FleetIdentityTest is Test { } function test_buildBundle_partialInclusion_fillsRemainingSlots() public { - // With partial inclusion: if only 3 slots remain for 4 country members, - // we include 3 of them (first 3 by array position). - // Country tier 0: 4 members (soft-capped by PREFERRED_COUNTRY_SLOTS) + // With partial inclusion: bundle fills remaining slots. + // Country tier 0: 4 members _registerNCountryAt(alice, US, 4, 0, 0); // Local: 4 at tier 0 + 4 at tier 1 (TIER_CAPACITY = 4) @@ -2359,14 +2357,13 @@ contract FleetIdentityTest is Test { // ── Single level, multiple tiers ── function test_buildBundle_singleLevelMultipleTiers() public { - // Only country, multiple tiers. With no local fleets, country fleets - // fill all available slots beyond PREFERRED_COUNTRY_SLOTS. + // Only country, multiple tiers. Country fleets fill all available slots. _registerNCountryAt(alice, US, 4, 1000, 0); // tier 0: 4 members _registerNCountryAt(alice, US, 4, 2000, 1); // tier 1: 4 members _registerNCountryAt(alice, US, 4, 3000, 2); // tier 2: 4 members (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - assertEq(count, 12); // all country fleets included (no locals to fill slots) + assertEq(count, 12); // all country fleets included // Verify order: tier 2 first (highest bond) uint256[] memory t2 = fleet.getTierMembers(_regionUS(), 2); for (uint256 i = 0; i < 4; i++) { @@ -2390,9 +2387,8 @@ contract FleetIdentityTest is Test { assertEq(uuids[0], _uuid(1100)); // tier 1 comes first (higher bond) } - function test_buildBundle_flexibleCountryCap_fillsRemainingSlots() public { - // Test that country fleets can exceed PREFERRED_COUNTRY_SLOTS when - // there aren't enough local fleets to fill the bundle. + function test_buildBundle_countryFillsSlots() public { + // Test that country fleets fill bundle slots when room is available. // // Setup: 2 local fleets + 12 country fleets across 3 tiers // Expected: All 14 should be included since bundle has room @@ -2403,7 +2399,7 @@ contract FleetIdentityTest is Test { (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); - // All 14 should be included: 2 local + 12 country (beyond soft cap of 8) + // All 14 should be included: 2 local + 12 country assertEq(count, 14); // Verify order: tier 2 country (highest bond) → tier 1 country → tier 0 local/country @@ -2413,13 +2409,10 @@ contract FleetIdentityTest is Test { } } - function test_buildBundle_flexibleCountryCap_respectsSoftCapWhenLocalsExist() public { - // When there are enough locals, country is soft-capped at PREFERRED_COUNTRY_SLOTS (8) - // in the first pass, but second pass fills remaining slots. + function test_buildBundle_localsPriorityWithinTier() public { + // When locals and country compete at same tier, locals are included first. // // Setup: 8 local fleets + 12 country fleets - // First pass: 8 local + 8 country (soft capped) = 16 - // Second pass: 4 remaining country (to fill slots) = 20 _registerNLocalAt(alice, US, ADMIN_CA, 4, 1000, 0); _registerNLocalAt(alice, US, ADMIN_CA, 4, 1100, 1); _registerNCountryAt(alice, US, 4, 2000, 0); diff --git a/test/FleetIdentityFairness.t.sol b/test/FleetIdentityFairness.t.sol index 8b757f2b..f1923c5a 100644 --- a/test/FleetIdentityFairness.t.sol +++ b/test/FleetIdentityFairness.t.sol @@ -16,51 +16,44 @@ contract MockERC20Fairness is ERC20 { /** * @title FleetIdentityFairness Tests - * @notice Comprehensive fairness analysis for PREFERRED_COUNTRY_SLOTS parameter. + * @notice Economic fairness analysis for FleetIdentity bundle allocation. * - * @dev **Fairness Philosophy** + * @dev **Fairness Philosophy - Economic Advantage Model** * - * The "perfectly fair" scenario would be one where country-level registration doesn't - * exist, forcing players wanting national coverage to register in every admin area - * separately. This creates true market-driven pricing where bonds are determined by - * local competition. + * The FleetIdentity contract uses a simple tier-descent algorithm: + * - Iterate from highest tier to lowest + * - At each tier: include local fleets first, then country fleets + * - Stop when bundle is full (20 slots) * - * However, this approach has UX problems: - * - Country players must manage dozens of registrations - * - Must monitor and maintain positions in all regions - * - High transaction cost overhead + * **Economic Fairness via COUNTRY_BOND_MULTIPLIER (8×)** * - * PREFERRED_COUNTRY_SLOTS creates a middle ground: - * - Country players get convenience (single registration) - * - Local players get protection (reserved slots) - * - COUNTRY_BOND_MULTIPLIER (8x) approximates "winning in multiple regions" + * Country fleets pay 8× more than local fleets at the same tier: + * - Local tier 0: BASE_BOND * 1 = 100 NODL + * - Country tier 0: BASE_BOND * 8 = 800 NODL + * - Local tier 3: BASE_BOND * 8 = 800 NODL (same cost!) * - * **Fairness Metrics** + * This means a local player can reach tier 3 for the same cost a country player + * pays for tier 0. The 8× multiplier provides significant economic advantage to locals: * - * 1. Local Protection Ratio (LPR): - * How many of the 20 bundle slots are guaranteed for locals? - * LPR = (20 - PREFERRED_COUNTRY_SLOTS) / 20 + * | Tier | Local Bond | Country Bond | Country Overpay vs Local Same Tier | + * |------|------------|--------------|-----------------------------------| + * | 0 | 100 NODL | 800 NODL | 8× | + * | 1 | 200 NODL | 1600 NODL | 8× | + * | 2 | 400 NODL | 3200 NODL | 8× | + * | 3 | 800 NODL | 6400 NODL | 8× | * - * 2. Country Efficiency Factor (CEF): - * How efficiently can a country player compete vs. registering locally everywhere? - * CEF = (8 * numAdminAreas) / numAdminAreas = 8 - * Lower CEF = more efficient for country players + * **Priority Rules** * - * 3. Whale Resistance Index (WRI): - * How much bond would a whale need to completely dominate a bundle? - * Higher WRI = better protection against whale dominance + * 1. Higher tier always wins (regardless of level) + * 2. Within same tier: local beats country + * 3. Within same tier + level: earlier registration wins * - * 4. Market Fairness Score (MFS): - * Combined metric: MFS = (LPR * 0.4) + (WRI * 0.4) + (accessibility * 0.2) - * Score 0-1 where 1 is perfectly fair + * **Whale Attack Analysis** * - * **Key Insight** - * - * The soft cap on PREFERRED_COUNTRY_SLOTS is flexible in BOTH directions: - * - If fewer country fleets exist, locals fill their slots - * - If fewer local fleets exist, country fleets fill unused local slots - * - * This flexibility means the actual fairness depends on market conditions. + * A country whale trying to dominate must pay significantly more: + * - To fill 8 country slots at tier 3: 8 × 6400 NODL = 51,200 NODL + * - 12 locals could counter at tier 3 for: 12 × 800 NODL = 9,600 NODL + * - Whale pays 5.3× more to compete at the same tier level */ contract FleetIdentityFairnessTest is Test { MockERC20Fairness bondToken; @@ -77,19 +70,7 @@ contract FleetIdentityFairnessTest is Test { // Test country and admin areas uint16 constant COUNTRY_US = 840; uint16[] adminAreas; - uint256 constant NUM_ADMIN_AREAS = 5; // Simulate 5 admin areas in a country - - // Current PREFERRED_COUNTRY_SLOTS constant - uint256 constant CURRENT_PREFERRED_COUNTRY_SLOTS = 8; - - // Events for logging - event FairnessMetric(string name, uint256 value, string description); - event FairnessAnalysis( - uint256 preferredCountrySlots, - uint256 localProtectionPercent, - uint256 whaleResistanceIndex, - uint256 marketFairnessScore - ); + uint256 constant NUM_ADMIN_AREAS = 5; function setUp() public { bondToken = new MockERC20Fairness(); @@ -149,29 +130,6 @@ contract FleetIdentityFairnessTest is Test { return (uint32(cc) << 10) | uint32(admin); } - /// @dev Calculate total bond locked across all tiers in a region - function _totalBondInRegion(FleetIdentity fleet, uint32 regionKey) internal view returns (uint256 total) { - uint256 tierCount = fleet.regionTierCount(regionKey); - bool isCountry = regionKey <= 999; - for (uint256 tier = 0; tier < tierCount; tier++) { - uint256 members = fleet.tierMemberCount(regionKey, tier); - uint256 bondPerMember = fleet.tierBond(tier, isCountry); - total += members * bondPerMember; - } - } - - /// @dev Calculate aggregate bond across entire country (all admin areas + country level) - function _aggregateBondInCountry(FleetIdentity fleet, uint16 countryCode) internal view returns (uint256 total) { - // Country-level bond - total += _totalBondInRegion(fleet, uint32(countryCode)); - - // All admin areas - for (uint256 i = 0; i < adminAreas.length; i++) { - uint32 adminRegion = _makeAdminRegion(countryCode, adminAreas[i]); - total += _totalBondInRegion(fleet, adminRegion); - } - } - /// @dev Count how many slots in a bundle are from country vs local registrations function _countBundleComposition(FleetIdentity fleet, uint16 cc, uint16 admin) internal @@ -184,7 +142,6 @@ contract FleetIdentityFairnessTest is Test { for (uint256 i = 0; i < count; i++) { // Try to find token in country region first uint256 countryTokenId = fleet.computeTokenId(uuids[i], countryRegion); - // Check if country token exists by trying to get its owner try fleet.ownerOf(countryTokenId) returns (address) { countryCount++; } catch { @@ -194,376 +151,278 @@ contract FleetIdentityFairnessTest is Test { } // ══════════════════════════════════════════════════════════════════════════════════ - // Scenario Tests: Different Market Conditions + // Scenario Tests: Priority & Economic Behavior // ══════════════════════════════════════════════════════════════════════════════════ /** * @notice Scenario A: Local-Heavy Market * Many local players competing, few country players. - * Tests whether locals can fill their protected slots. + * Tests that locals correctly fill slots by tier-descent priority. */ function test_scenarioA_localHeavyMarket() public { FleetIdentity fleet = _deployFleet(); uint16 targetAdmin = adminAreas[0]; - // 16 local players each register one fleet in the same admin area + // 16 local players at tiers 0-3 (4 per tier due to TIER_CAPACITY) for (uint256 i = 0; i < 16; i++) { vm.prank(localPlayers[i % NUM_LOCAL_PLAYERS]); - fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, i / 4); // Spread across tiers + fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, i / 4); } - // 4 country players register + // 4 country players at tier 0 for (uint256 i = 0; i < 4; i++) { vm.prank(countryPlayers[i]); fleet.registerFleetCountry(_uuid(2000 + i), COUNTRY_US, 0); } (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); - (bytes16[] memory uuids, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); + (, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); emit log_string("=== Scenario A: Local-Heavy Market ==="); emit log_named_uint("Total bundle size", totalCount); emit log_named_uint("Local slots used", localCount); emit log_named_uint("Country slots used", countryCount); - // In a local-heavy market, locals should dominate the bundle - assertGe(localCount, 12, "Locals should have at least 12 slots (20 - PREFERRED_COUNTRY_SLOTS)"); - assertLe(countryCount, CURRENT_PREFERRED_COUNTRY_SLOTS, "Country should be capped at PREFERRED_COUNTRY_SLOTS"); + // With tier-descent priority, all 16 locals fill first, then 4 country + assertEq(localCount, 16, "All 16 locals should be included"); + assertEq(countryCount, 4, "All 4 country should fill remaining slots"); + assertEq(totalCount, 20, "Bundle should be full"); } /** * @notice Scenario B: Country-Heavy Market - * Few local players, many country players. - * Tests whether country players can fill unused local slots. + * Few local players, many country players at higher tiers. + * Tests that higher-tier country beats lower-tier local. */ - function test_scenarioB_countryHeavyMarket() public { + function test_scenarioB_countryHighTierDominance() public { FleetIdentity fleet = _deployFleet(); uint16 targetAdmin = adminAreas[0]; - // Only 4 local players register + // 4 local players at tier 0 for (uint256 i = 0; i < 4; i++) { vm.prank(localPlayers[i]); fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, 0); } - // 12 country players register (more than PREFERRED_COUNTRY_SLOTS) + // 12 country players at tiers 1-3 (4 per tier) + // These are at HIGHER tiers, so they come first in bundle for (uint256 i = 0; i < 12; i++) { vm.prank(countryPlayers[i % NUM_COUNTRY_PLAYERS]); - fleet.registerFleetCountry(_uuid(2000 + i), COUNTRY_US, i / 4); + fleet.registerFleetCountry(_uuid(2000 + i), COUNTRY_US, (i / 4) + 1); } (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); (, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); - emit log_string("=== Scenario B: Country-Heavy Market ==="); + emit log_string("=== Scenario B: Country High-Tier Dominance ==="); emit log_named_uint("Total bundle size", totalCount); emit log_named_uint("Local slots used", localCount); emit log_named_uint("Country slots used", countryCount); - // With flexible cap, country players should fill unused local slots - assertEq(localCount, 4, "Local count should equal registered locals"); - assertGt(countryCount, CURRENT_PREFERRED_COUNTRY_SLOTS, "Country should exceed cap when locals are few"); - assertEq(totalCount, 16, "Bundle should be fully utilized"); + // Country at tiers 1-3 comes before locals at tier 0 + assertEq(countryCount, 12, "All 12 country (higher tiers) included first"); + assertEq(localCount, 4, "Tier-0 locals fill remaining slots"); + assertEq(totalCount, 16, "Total should equal all registered fleets"); } /** - * @notice Scenario C: Balanced Market - * Equal competition between local and country players. + * @notice Scenario C: Same-Tier Competition + * Locals and country at the same tier. + * Tests that locals get priority within the same tier. */ - function test_scenarioC_balancedMarket() public { + function test_scenarioC_sameTierLocalPriority() public { FleetIdentity fleet = _deployFleet(); uint16 targetAdmin = adminAreas[0]; - // 10 local players register - for (uint256 i = 0; i < 10; i++) { + // 4 local at tier 0 + for (uint256 i = 0; i < 4; i++) { vm.prank(localPlayers[i]); - fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, i / 4); + fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, 0); } - // 10 country players register - for (uint256 i = 0; i < 10; i++) { + // 4 country at tier 0 (same tier) + for (uint256 i = 0; i < 4; i++) { vm.prank(countryPlayers[i]); - fleet.registerFleetCountry(_uuid(2000 + i), COUNTRY_US, i / 4); + fleet.registerFleetCountry(_uuid(2000 + i), COUNTRY_US, 0); } - (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); - (, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); - emit log_string("=== Scenario C: Balanced Market ==="); - emit log_named_uint("Total bundle size", totalCount); - emit log_named_uint("Local slots used", localCount); - emit log_named_uint("Country slots used", countryCount); + emit log_string("=== Scenario C: Same-Tier Local Priority ==="); + emit log_named_uint("Total bundle size", count); - // In balanced market, both should get fair representation - assertGe(localCount, 10, "All 10 locals should be included"); - // Country gets soft cap of 8, but locals fill their protected slots first - assertLe(countryCount, 10, "Country should be limited"); - assertEq(totalCount, 20, "Bundle should be full"); + // First 4 should be locals (priority within same tier) + for (uint256 i = 0; i < 4; i++) { + assertEq(uuids[i], _uuid(1000 + i), "Locals should come first"); + } + // Next 4 should be country + for (uint256 i = 0; i < 4; i++) { + assertEq(uuids[4 + i], _uuid(2000 + i), "Country should follow locals"); + } } /** - * @notice Scenario D: Country Whale Attack - * Single country player tries to dominate by bonding in high tiers. + * @notice Scenario D: Country Whale at High Tier + * Single whale registers many high-tier country fleets. + * Tests that whale can dominate IF they outbid locals on tier level. */ - function test_scenarioD_countryWhaleAttack() public { + function test_scenarioD_countryWhaleHighTier() public { FleetIdentity fleet = _deployFleet(); uint16 targetAdmin = adminAreas[0]; - // 8 local players spread across tiers 0-1 (4 per tier due to TIER_CAPACITY=4) - for (uint256 i = 0; i < 8; i++) { + // 12 locals at tiers 0-2 (4 per tier) + for (uint256 i = 0; i < 12; i++) { vm.prank(localPlayers[i]); - fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, i / 4); // Tiers 0-1 + fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, i / 4); } - // Whale registers multiple high-tier country fleets (tiers 3-4, above locals) + // Whale registers 8 country fleets at tiers 3-4 (4 per tier due to TIER_CAPACITY) + // This is above all locals (tiers 0-2) for (uint256 i = 0; i < 8; i++) { vm.prank(whale); - fleet.registerFleetCountry(_uuid(3000 + i), COUNTRY_US, i / 4 + 3); // High tiers (3-4) + fleet.registerFleetCountry(_uuid(3000 + i), COUNTRY_US, 3 + (i / 4)); } + (, uint256 count) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); - (, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); - uint256 whaleBond = _totalBondInRegion(fleet, uint32(COUNTRY_US)); - uint256 localBond = _totalBondInRegion(fleet, _makeAdminRegion(COUNTRY_US, targetAdmin)); + emit log_string("=== Scenario D: Country Whale at High Tier ==="); + emit log_named_uint("Total bundle size", count); + emit log_named_uint("Local slots", localCount); + emit log_named_uint("Country slots", countryCount); - emit log_string("=== Scenario D: Country Whale Attack ==="); - emit log_named_uint("Total bundle size", totalCount); - emit log_named_uint("Local slots used", localCount); - emit log_named_uint("Country slots used", countryCount); - emit log_named_uint("Whale total bond (ether)", whaleBond / 1 ether); - emit log_named_uint("Local total bond (ether)", localBond / 1 ether); - - // Despite whale's high bonds, locals should maintain protected slots - assertGe(localCount, 8, "Locals should keep their slots despite whale"); - assertLe(countryCount, CURRENT_PREFERRED_COUNTRY_SLOTS, "Whale capped at PREFERRED_COUNTRY_SLOTS"); + // Whale's tier-3/4 country fleets come first (highest tiers) + // Then locals at tiers 0-2 fill remaining slots + assertEq(countryCount, 8, "Whale's 8 high-tier country fleets included"); + assertEq(localCount, 12, "All 12 locals at lower tiers included"); + assertEq(count, 20, "Bundle full"); } /** - * @notice Scenario E: Multi-Region Country Coverage - * Country player needs coverage across all admin areas. - * Compare cost vs. registering locally in each region. + * @notice Scenario E: Locals Counter Whale by Matching Tier + * Shows that locals can economically counter a country whale. */ - function test_scenarioE_multiRegionCoverage() public { + function test_scenarioE_localsCounterWhale() public { FleetIdentity fleet = _deployFleet(); + uint16 targetAdmin = adminAreas[0]; + + // Whale registers 4 country fleets at tier 3 + // Cost: 4 × (BASE_BOND × 8 × 8) = 4 × 6400 = 25,600 NODL + for (uint256 i = 0; i < 4; i++) { + vm.prank(whale); + fleet.registerFleetCountry(_uuid(3000 + i), COUNTRY_US, 3); + } + + // 4 locals match at tier 3 (same priority, but cheaper!) + // Cost: 4 × (BASE_BOND × 8) = 4 × 800 = 3,200 NODL + for (uint256 i = 0; i < 4; i++) { + vm.prank(localPlayers[i]); + fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, 3); + } - // Country player registers once for national coverage - vm.prank(countryPlayers[0]); - fleet.registerFleetCountry(_uuid(5000), COUNTRY_US, 0); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); - uint256 countryBond = fleet.tierBond(0, true); + emit log_string("=== Scenario E: Locals Counter Whale ==="); + emit log_named_uint("Total bundle size", count); - // Calculate what local registration in all areas would cost - uint256 localEquivalentBond = 0; - for (uint256 i = 0; i < adminAreas.length; i++) { - localEquivalentBond += fleet.tierBond(0, false); + // Locals get priority at tier 3 (same tier, local-first) + for (uint256 i = 0; i < 4; i++) { + assertEq(uuids[i], _uuid(1000 + i), "Locals come first at same tier"); + } + for (uint256 i = 0; i < 4; i++) { + assertEq(uuids[4 + i], _uuid(3000 + i), "Country follows at same tier"); } - emit log_string("=== Scenario E: Multi-Region Coverage Analysis ==="); - emit log_named_uint("Country tier-0 bond (ether)", countryBond / 1 ether); - emit log_named_uint("Local equivalent (all areas) (ether)", localEquivalentBond / 1 ether); - emit log_named_uint("Number of admin areas", adminAreas.length); - emit log_named_uint("Country bond multiplier", fleet.COUNTRY_BOND_MULTIPLIER()); + // Calculate cost ratio + uint256 whaleCost = 4 * fleet.tierBond(3, true); // 25,600 NODL + uint256 localCost = 4 * fleet.tierBond(3, false); // 3,200 NODL - // Country bond = 8 * BASE_BOND = 800 ether - // Local in each area = BASE_BOND * NUM_ADMIN_AREAS = 500 ether - // Country is MORE expensive than local equivalent when NUM_ADMIN_AREAS < 8 - uint256 efficiencyRatio = (countryBond * 100) / localEquivalentBond; - emit log_named_uint("Efficiency ratio (country/local %)", efficiencyRatio); + emit log_named_uint("Whale total cost (ether)", whaleCost / 1 ether); + emit log_named_uint("Locals total cost (ether)", localCost / 1 ether); + emit log_named_uint("Whale overpay factor", whaleCost / localCost); - // This shows the tradeoff: country is simpler but costs more for small countries + assertEq(whaleCost / localCost, 8, "Whale pays 8x more for same tier"); } // ══════════════════════════════════════════════════════════════════════════════════ - // Fairness Metrics Calculation + // Economic Metrics & Analysis // ══════════════════════════════════════════════════════════════════════════════════ /** - * @notice Calculate comprehensive fairness metrics for current PREFERRED_COUNTRY_SLOTS. + * @notice Verify the 8× economic advantage constants. */ - function test_calculateFairnessMetrics() public { + function test_economicAdvantage_8xMultiplier() public { FleetIdentity fleet = _deployFleet(); - uint16 targetAdmin = adminAreas[0]; - // Create a representative market - // 12 local players at various tiers - for (uint256 i = 0; i < 12; i++) { - vm.prank(localPlayers[i]); - fleet.registerFleetLocal(_uuid(6000 + i), COUNTRY_US, targetAdmin, i / 4); - } + // Verify multiplier + assertEq(fleet.COUNTRY_BOND_MULTIPLIER(), 8, "Multiplier should be 8"); - // 8 country players at various tiers - for (uint256 i = 0; i < 8; i++) { - vm.prank(countryPlayers[i]); - fleet.registerFleetCountry(_uuid(7000 + i), COUNTRY_US, i / 4); + // At every tier, country pays exactly 8× local + for (uint256 tier = 0; tier < 6; tier++) { + uint256 localBond = fleet.tierBond(tier, false); + uint256 countryBond = fleet.tierBond(tier, true); + assertEq(countryBond, localBond * 8, "Country should pay 8x at every tier"); } + } - // Metric 1: Local Protection Ratio (LPR) - // What % of bundle is guaranteed for locals? - uint256 maxBundleSize = fleet.MAX_BONDED_UUID_BUNDLE_SIZE(); - uint256 localProtectedSlots = maxBundleSize - CURRENT_PREFERRED_COUNTRY_SLOTS; - uint256 localProtectionPercent = (localProtectedSlots * 100) / maxBundleSize; + /** + * @notice Demonstrate that a local at tier N+3 costs the same as country at tier N. + */ + function test_economicAdvantage_localTierEquivalence() public { + FleetIdentity fleet = _deployFleet(); - // Metric 2: Whale Resistance Index (WRI) - // How much would a whale need to dominate all country slots? - // With TIER_CAPACITY=4 and PREFERRED_COUNTRY_SLOTS=8: - // 2 full tiers (tier 0 and 1) would give 8 slots - uint256 tier0Bond = 4 * fleet.tierBond(0, true); - uint256 tier1Bond = 4 * fleet.tierBond(1, true); - uint256 whaleDominationCost = tier0Bond + tier1Bond; + // Local tier 3 = Country tier 0 + assertEq( + fleet.tierBond(3, false), + fleet.tierBond(0, true), + "Local tier 3 should equal country tier 0" + ); - // Metric 3: Aggregate Bond in Country - uint256 aggregateBond = _aggregateBondInCountry(fleet, COUNTRY_US); + // Local tier 4 = Country tier 1 + assertEq( + fleet.tierBond(4, false), + fleet.tierBond(1, true), + "Local tier 4 should equal country tier 1" + ); - // Metric 4: Actual bundle composition - (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); + // Local tier 5 = Country tier 2 + assertEq( + fleet.tierBond(5, false), + fleet.tierBond(2, true), + "Local tier 5 should equal country tier 2" + ); - emit log_string("=== FAIRNESS METRICS (PREFERRED_COUNTRY_SLOTS = 8) ==="); - emit log_named_uint("Local Protection Ratio (%)", localProtectionPercent); - emit log_named_uint("Local protected slots", localProtectedSlots); - emit log_named_uint("Whale domination cost for 8 slots (ether)", whaleDominationCost / 1 ether); - emit log_named_uint("Aggregate bond in country (ether)", aggregateBond / 1 ether); - emit log_named_uint("Actual local slots in bundle", localCount); - emit log_named_uint("Actual country slots in bundle", countryCount); - - // Assertions for fairness bounds - assertGe(localProtectionPercent, 50, "Local protection should be at least 50%"); - assertLe(localProtectionPercent, 80, "Local protection should not exceed 80% to allow country access"); + emit log_string("=== Local Tier Equivalence ==="); + emit log_string("Local tier N+3 costs the same as Country tier N"); + emit log_string("This gives locals a 3-tier economic advantage"); } - // ══════════════════════════════════════════════════════════════════════════════════ - // PREFERRED_COUNTRY_SLOTS Sensitivity Analysis - // ══════════════════════════════════════════════════════════════════════════════════ - /** - * @notice Analyze how different PREFERRED_COUNTRY_SLOTS values affect fairness. - * This test simulates different parameter values and measures outcomes. + * @notice Analyze country registration efficiency across admin areas. */ - function test_sensitivityAnalysis_PREFERRED_COUNTRY_SLOTS() public { - emit log_string(""); - emit log_string("+------------------------------------------------------------------------------+"); - emit log_string("| PREFERRED_COUNTRY_SLOTS SENSITIVITY ANALYSIS |"); - emit log_string("+------------------------------------------------------------------------------+"); - emit log_string("| Slots | LocalProt% | WhaleResist | CountryAccess | FairnessScore |"); - emit log_string("+------------------------------------------------------------------------------+"); - - // Test values from 0 to 20 - uint256[] memory testValues = new uint256[](11); - testValues[0] = 0; // No country slots - testValues[1] = 2; - testValues[2] = 4; - testValues[3] = 6; - testValues[4] = 8; // Current value - testValues[5] = 10; // 50-50 split - testValues[6] = 12; - testValues[7] = 14; - testValues[8] = 16; - testValues[9] = 18; - testValues[10] = 20; // All country - - uint256 bestScore = 0; - uint256 bestSlots = 0; - - for (uint256 i = 0; i < testValues.length; i++) { - uint256 slots = testValues[i]; - - // Calculate metrics for this slot value - uint256 maxBundle = 20; - uint256 localProtectedSlots = maxBundle - slots; - uint256 localProtectionPercent = (localProtectedSlots * 100) / maxBundle; - - // Whale resistance: higher slots = easier for whale to dominate - // Scale 0-100 where 100 = perfectly resistant - uint256 whaleResistance = 100 - (slots * 100 / maxBundle); - - // Country accessibility: 0 slots = 0 access, 20 slots = perfect access - uint256 countryAccess = (slots * 100) / maxBundle; - - // Fairness score: balanced consideration of all factors - // The key insight: we need BALANCE between protection and access. - // Pure local protection (slots=0) or pure country access (slots=20) are both unfair. - // - // Optimal fairness occurs when: - // 1. Locals have majority (>= 50%) to prevent country whale dominance - // 2. Country has meaningful access (>= 25%) to enable national services - // 3. Neither side can completely crowd out the other - // - // Formula: penalize deviation from 60/40 split (12 local / 8 country) - uint256 targetLocalPercent = 60; - uint256 targetCountryPercent = 40; - - // Calculate deviation from target (lower is better) - uint256 localDeviation = localProtectionPercent > targetLocalPercent ? - localProtectionPercent - targetLocalPercent : targetLocalPercent - localProtectionPercent; - uint256 countryDeviation = countryAccess > targetCountryPercent ? - countryAccess - targetCountryPercent : targetCountryPercent - countryAccess; - - // Fairness score: 100 - total deviation (max deviation = 60+40 = 100, so worst score = 0) - uint256 totalDeviation = localDeviation + countryDeviation; - uint256 fairnessScore = 100 > totalDeviation ? 100 - totalDeviation : 0; - - // Additional penalty for extremes (one side gets nothing) - if (slots == 0) fairnessScore = fairnessScore * 50 / 100; // 50% penalty for no country access - if (slots == 20) fairnessScore = fairnessScore * 50 / 100; // 50% penalty for no local protection - - // Track best - if (fairnessScore > bestScore) { - bestScore = fairnessScore; - bestSlots = slots; - } + function test_economicAdvantage_multiRegionEfficiency() public { + FleetIdentity fleet = _deployFleet(); - emit log_string( - string.concat( - "| ", - _formatNumber(slots, 2), - " | ", - _formatNumber(localProtectionPercent, 3), - "% | ", - _formatNumber(whaleResistance, 3), - " | ", - _formatNumber(countryAccess, 3), - " | ", - _formatNumber(fairnessScore, 3), - " |" - ) - ); - } + // Single country registration covers ALL admin areas + uint256 countryBond = fleet.tierBond(0, true); // 800 NODL - emit log_string("+------------------------------------------------------------------------------+"); - emit log_string(""); - emit log_named_uint("Best PREFERRED_COUNTRY_SLOTS value", bestSlots); - emit log_named_uint("Best fairness score", bestScore); - emit log_named_uint("Current value", CURRENT_PREFERRED_COUNTRY_SLOTS); - - // The analysis should show optimal around 8 (60/40 split) - // Allow some flexibility in the optimal range (6-10) - assertGe(bestSlots, 6, "Optimal slots should be at least 6"); - assertLe(bestSlots, 10, "Optimal slots should be at most 10"); - } + // To cover N admin areas locally, costs N × local_bond + uint256 localPerArea = fleet.tierBond(0, false); // 100 NODL - function _formatNumber(uint256 num, uint256 width) internal pure returns (string memory) { - string memory numStr = vm.toString(num); - bytes memory numBytes = bytes(numStr); - if (numBytes.length >= width) return numStr; + emit log_string("=== Multi-Region Efficiency Analysis ==="); + emit log_named_uint("Country tier-0 bond (ether)", countryBond / 1 ether); + emit log_named_uint("Local tier-0 bond per area (ether)", localPerArea / 1 ether); - bytes memory result = new bytes(width); - uint256 padding = width - numBytes.length; - for (uint256 i = 0; i < padding; i++) { - result[i] = " "; - } - for (uint256 i = 0; i < numBytes.length; i++) { - result[padding + i] = numBytes[i]; - } - return string(result); - } + // Country is MORE efficient when covering > 8 admin areas + // Break-even: 8 local registrations = 1 country registration + uint256 breakEvenAreas = countryBond / localPerArea; + emit log_named_uint("Break-even admin areas", breakEvenAreas); - // ══════════════════════════════════════════════════════════════════════════════════ - // Economic Analysis: Bond Dynamics Under Competition - // ══════════════════════════════════════════════════════════════════════════════════ + assertEq(breakEvenAreas, 8, "Country efficient for 8+ admin areas"); + } /** - * @notice Analyze how bond requirements escalate as tiers fill up. - * This shows the economic "cost to compete" curve. + * @notice Bond escalation analysis showing geometric growth. */ function test_bondEscalationAnalysis() public { FleetIdentity fleet = _deployFleet(); @@ -571,157 +430,98 @@ contract FleetIdentityFairnessTest is Test { emit log_string(""); emit log_string("=== BOND ESCALATION ANALYSIS ==="); emit log_string(""); + emit log_string("Tier | Local Bond (ether) | Country Bond (ether)"); + emit log_string("-----+--------------------+---------------------"); - // Show bond at each tier for local and country - emit log_string("Tier | Local Bond (ether) | Country Bond (ether) | Multiplier"); - emit log_string("-----+--------------------+----------------------+-----------"); - - uint32 localRegion = _makeAdminRegion(COUNTRY_US, adminAreas[0]); - uint32 countryRegion = uint32(COUNTRY_US); - - for (uint256 tier = 0; tier <= 10; tier++) { + for (uint256 tier = 0; tier <= 6; tier++) { uint256 localBond = fleet.tierBond(tier, false); uint256 countryBond = fleet.tierBond(tier, true); - emit log_string( - string.concat( - " ", - vm.toString(tier), - " | ", - _formatNumber(localBond / 1 ether, 8), - " | ", - _formatNumber(countryBond / 1 ether, 8), - " | ", - vm.toString(countryBond / localBond), - "x" - ) - ); + // Verify geometric progression (2× per tier) + if (tier > 0) { + assertEq(localBond, fleet.tierBond(tier - 1, false) * 2, "Local should double each tier"); + assertEq(countryBond, fleet.tierBond(tier - 1, true) * 2, "Country should double each tier"); + } } - - emit log_string(""); - - // Calculate total bond to fill all 20 bundle slots - // At each tier level, what's the total bond commitment? - emit log_string("Total bond to fill bundle (20 slots) at different competition levels:"); - emit log_string(""); - - // Scenario: 12 local + 8 country (both at tier 0) - uint256 scenario1 = 12 * fleet.tierBond(0, false) + 8 * fleet.tierBond(0, true); - - // Scenario: 12 local + 8 country (both at tier 2 - competitive market) - uint256 scenario2 = 12 * fleet.tierBond(2, false) + 8 * fleet.tierBond(2, true); - - // Scenario: 12 local + 8 country (both at tier 5 - very competitive) - uint256 scenario3 = 12 * fleet.tierBond(5, false) + 8 * fleet.tierBond(5, true); - - emit log_named_uint("Low competition (tier 0), ether", scenario1 / 1 ether); - emit log_named_uint("Medium competition (tier 2), ether", scenario2 / 1 ether); - emit log_named_uint("High competition (tier 5), ether", scenario3 / 1 ether); } // ══════════════════════════════════════════════════════════════════════════════════ - // Regression Guard: Fairness Invariants + // Invariant Tests // ══════════════════════════════════════════════════════════════════════════════════ /** - * @notice CRITICAL: Fairness invariants that must ALWAYS hold. - * This test serves as a regression guard for future changes. + * @notice CRITICAL: Core invariants that must ALWAYS hold. */ - function test_invariant_fairnessGuarantees() public { + function test_invariant_coreGuarantees() public { FleetIdentity fleet = _deployFleet(); - // Invariant 1: Local protection must be at least 60% - uint256 localProtectedSlots = fleet.MAX_BONDED_UUID_BUNDLE_SIZE() - fleet.PREFERRED_COUNTRY_SLOTS(); - uint256 localProtectionPercent = (localProtectedSlots * 100) / fleet.MAX_BONDED_UUID_BUNDLE_SIZE(); - assertGe(localProtectionPercent, 60, "INVARIANT VIOLATION: Local protection below 60%"); - - // Invariant 2: Country must have meaningful access (at least 20%) - uint256 countryAccessPercent = (fleet.PREFERRED_COUNTRY_SLOTS() * 100) / fleet.MAX_BONDED_UUID_BUNDLE_SIZE(); - assertGe(countryAccessPercent, 20, "INVARIANT VIOLATION: Country access below 20%"); + // Invariant 1: Country multiplier is exactly 8 + assertEq(fleet.COUNTRY_BOND_MULTIPLIER(), 8, "INVARIANT: Country multiplier must be 8"); - // Invariant 3: Country bond multiplier must make domination expensive - // At 8x, a country player pays 8x more per slot than a local player - assertGe(fleet.COUNTRY_BOND_MULTIPLIER(), 4, "INVARIANT VIOLATION: Country multiplier too low"); + // Invariant 2: Tier capacity allows fair competition + assertEq(fleet.TIER_CAPACITY(), 4, "INVARIANT: Tier capacity must be 4"); - // Invariant 4: Tier capacity must allow multiple players per tier - assertGe(fleet.TIER_CAPACITY(), 2, "INVARIANT VIOLATION: Tier capacity too restrictive"); + // Invariant 3: Bundle size reasonable for discovery + assertEq(fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), 20, "INVARIANT: Bundle size must be 20"); - // Invariant 5: Combined slots (12 local + 8 country) must equal max bundle - assertEq( - localProtectedSlots + fleet.PREFERRED_COUNTRY_SLOTS(), - fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), - "INVARIANT VIOLATION: Slot allocation mismatch" - ); + // Invariant 4: Bond doubles per tier (geometric) + for (uint256 t = 1; t <= 5; t++) { + assertEq( + fleet.tierBond(t, false), + fleet.tierBond(t - 1, false) * 2, + "INVARIANT: Bond must double per tier" + ); + } - emit log_string(""); - emit log_string("[PASS] All fairness invariants passed"); - emit log_named_uint("Local protection", localProtectionPercent); - emit log_named_uint("Country access", countryAccessPercent); - emit log_named_uint("Country bond multiplier", fleet.COUNTRY_BOND_MULTIPLIER()); + emit log_string("[PASS] All core invariants verified"); } /** - * @notice Verify the optimal PREFERRED_COUNTRY_SLOTS value through market simulation. - * This establishes a baseline that the contract should maintain. + * @notice Bundle always respects tier-descent priority. */ - function test_optimalValue_PREFERRED_COUNTRY_SLOTS() public { - // Based on sensitivity analysis, the optimal range is 6-10 - // Current value of 8 should be within optimal range - + function test_invariant_tierDescentPriority() public { FleetIdentity fleet = _deployFleet(); - uint256 current = fleet.PREFERRED_COUNTRY_SLOTS(); - - // The value 8 was chosen because: - // 1. It gives locals 60% (12/20) protection - majority - // 2. It gives country 40% (8/20) access - meaningful representation - // 3. With 8x multiplier, country pays equivalent of 8 local registrations - // 4. Soft cap flexibility prevents waste when one side is underrepresented - - assertEq(current, 8, "PREFERRED_COUNTRY_SLOTS should be 8"); + uint16 targetAdmin = adminAreas[0]; - // Verify the math works out: - // - Bundle size: 20 - // - Local protected: 12 (60%) - // - Country slots: 8 (40%) - // - Country multiplier: 8x - // - Effective "fairness": Country pays 8x more, gets 40% slots - // If they registered locally in 8 admin areas, they'd pay 8x BASE_BOND total - // Country tier 0 = 8 * BASE_BOND = same cost for national coverage + // Mixed setup: locals at tier 1, country at tier 2 + for (uint256 i = 0; i < 4; i++) { + vm.prank(localPlayers[i]); + fleet.registerFleetLocal(_uuid(1000 + i), COUNTRY_US, targetAdmin, 1); + } + for (uint256 i = 0; i < 4; i++) { + vm.prank(countryPlayers[i]); + fleet.registerFleetCountry(_uuid(2000 + i), COUNTRY_US, 2); + } - uint256 countryTier0Bond = fleet.tierBond(0, true); - uint256 localTier0Bond = fleet.tierBond(0, false); + (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); - assertEq(countryTier0Bond, localTier0Bond * 8, "Country bond should be 8x local"); + // Tier 2 (country) must come before tier 1 (local) - higher tier wins + for (uint256 i = 0; i < 4; i++) { + assertEq(uuids[i], _uuid(2000 + i), "INVARIANT: Higher tier must come first"); + } + for (uint256 i = 0; i < 4; i++) { + assertEq(uuids[4 + i], _uuid(1000 + i), "Lower tier follows"); + } - emit log_string(""); - emit log_string("=== PREFERRED_COUNTRY_SLOTS = 8 RATIONALE ==="); - emit log_string("1. Locals get 60% (12/20) bundle protection - fair majority"); - emit log_string("2. Country players get 40% (8/20) slots - meaningful access"); - emit log_string("3. Country pays 8x more per slot - compensates for convenience"); - emit log_string("4. Soft cap allows flexibility when market is imbalanced"); - emit log_string(""); - emit log_string("Trade-off Analysis:"); - emit log_string(" - Lower value (e.g., 4): Better for locals, harder for national services"); - emit log_string(" - Higher value (e.g., 12): Better for nationals, crowds out locals"); - emit log_string(" - Current value (8): Balanced compromise"); + assertEq(count, 8); } // ══════════════════════════════════════════════════════════════════════════════════ - // Fuzz Test: Various Market Conditions + // Fuzz Tests // ══════════════════════════════════════════════════════════════════════════════════ /** - * @notice Fuzz test to verify fairness holds across random market conditions. + * @notice Fuzz test to verify bundle properties across random market conditions. */ - function testFuzz_marketFairnessHolds(uint8 numLocals, uint8 numCountry) public { + function testFuzz_bundleProperties(uint8 numLocals, uint8 numCountry) public { // Bound inputs to reasonable ranges - numLocals = uint8(bound(numLocals, 1, 20)); - numCountry = uint8(bound(numCountry, 1, 15)); + numLocals = uint8(bound(numLocals, 1, 16)); + numCountry = uint8(bound(numCountry, 1, 12)); FleetIdentity fleet = _deployFleet(); uint16 targetAdmin = adminAreas[0]; - // Register local players + // Register local players (spread across tiers for variety) for (uint256 i = 0; i < numLocals; i++) { vm.prank(localPlayers[i % NUM_LOCAL_PLAYERS]); fleet.registerFleetLocal(_uuid(8000 + i), COUNTRY_US, targetAdmin, i / 4); @@ -733,27 +533,30 @@ contract FleetIdentityFairnessTest is Test { fleet.registerFleetCountry(_uuid(9000 + i), COUNTRY_US, i / 4); } - // Get bundle composition - (uint256 localCount, uint256 countryCount) = _countBundleComposition(fleet, COUNTRY_US, targetAdmin); - (, uint256 totalCount) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); + // Get bundle + (, uint256 count) = fleet.buildHighestBondedUuidBundle(COUNTRY_US, targetAdmin); - // Fairness assertions that must always hold: + // Properties that must always hold: - // 1. Total count should be reasonable - assertLe(totalCount, fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), "Bundle exceeds max size"); + // 1. Bundle never exceeds max size + assertLe(count, fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), "Bundle must not exceed max"); - // 2. If enough locals exist, they should fill protected slots - if (numLocals >= 12) { - assertGe(localCount, 12, "Insufficient local protection when locals available"); - } + // 2. Bundle includes as many as possible (up to registered count) + uint256 totalRegistered = uint256(numLocals) + uint256(numCountry); + uint256 expectedMax = totalRegistered < 20 ? totalRegistered : 20; + assertEq(count, expectedMax, "Bundle should maximize utilization"); + } - // 3. Country should not exceed soft cap when locals are available - if (numLocals >= 12 && numCountry <= 8) { - assertLe(countryCount, CURRENT_PREFERRED_COUNTRY_SLOTS, "Country exceeded cap with available locals"); - } + /** + * @notice Fuzz that 8x multiplier always holds at any tier. + */ + function testFuzz_constantMultiplier(uint8 tier) public { + tier = uint8(bound(tier, 0, 20)); + FleetIdentity fleet = _deployFleet(); + + uint256 localBond = fleet.tierBond(tier, false); + uint256 countryBond = fleet.tierBond(tier, true); - // 4. Bundle should maximize utilization - uint256 expectedMax = (numLocals + numCountry) < 20 ? (numLocals + numCountry) : 20; - assertEq(totalCount, expectedMax, "Bundle not maximally utilized"); + assertEq(countryBond, localBond * 8, "8x multiplier must hold at all tiers"); } } From b7f9ef16f10b11684970b04a4758417a4f72ce22 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 25 Feb 2026 12:58:46 +1300 Subject: [PATCH 44/63] refactor loop --- src/swarms/FleetIdentity.sol | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 8dcf5482..24561e04 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -584,8 +584,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Include local (admin area) members first (higher priority within tier) count = _appendTierUuids(adminKey, tier, adminTiers, uuids, count); - if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; - // Include country members count = _appendTierUuids(countryKey, tier, countryTiers, uuids, count); } From efa5e32a051ed67cf29b7f87e597a667f9905307 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 25 Feb 2026 14:35:37 +1300 Subject: [PATCH 45/63] refactor(FleetIdentity): maximize coupling between buildHighestBondedUuidBundle and findCheapestInclusionTier - Extract _buildHighestBondedUuidBundle to return highestTier and lowestTier for inclusion-tier logic - Simplify _findCheapestInclusionTier to fully rely on bundle builder, unwinding count by subtracting both regions' contributions - Remove _getCountFromTiersAbove and _resolveBundleLevels helpers (no longer needed) - Remove LEVEL_ADMIN/LEVEL_COUNTRY constants, change isCountry parameter to bool - Simplify _appendTierUuids by removing tierCount check (empty arrays are no-ops) - Improve documentation for internal bundle builder and inclusion logic - Tests: all 180 FleetIdentity tests pass --- src/swarms/FleetIdentity.sol | 204 +++++++++++++---------------------- 1 file changed, 77 insertions(+), 127 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 24561e04..383179d9 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -119,11 +119,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// Dense indices from ISO 3166-2 mappings range 0-254, stored as adminCode 1-255. uint16 internal constant MAX_ADMIN_CODE = 255; - /// @dev Bundle level index: admin area (highest priority). - uint256 internal constant LEVEL_ADMIN = 0; - /// @dev Bundle level index: country. - uint256 internal constant LEVEL_COUNTRY = 1; - /// @dev Bit shift for packing countryCode into an admin-area region key. uint256 private constant ADMIN_SHIFT = 10; /// @dev Bitmask for extracting adminCode from an admin-area region key. @@ -455,7 +450,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { { if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); - inclusionTier = _findCheapestInclusionTier(countryCode, adminCode, LEVEL_ADMIN); + inclusionTier = _findCheapestInclusionTier(countryCode, adminCode, false); bond = tierBond(inclusionTier, false); } @@ -469,14 +464,14 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); // Check the country-only location (no admin area active). - inclusionTier = _findCheapestInclusionTier(countryCode, 0, LEVEL_COUNTRY); + inclusionTier = _findCheapestInclusionTier(countryCode, 0, true); // Scan all active admin areas belonging to this country. for (uint256 i = 0; i < _activeAdminAreas.length; ++i) { uint32 rk = _activeAdminAreas[i]; if (_countryFromRegion(rk) != countryCode) continue; uint16 admin = _adminFromRegion(rk); - uint256 t = _findCheapestInclusionTier(countryCode, admin, LEVEL_COUNTRY); + uint256 t = _findCheapestInclusionTier(countryCode, admin, true); if (t > inclusionTier) inclusionTier = t; } bond = tierBond(inclusionTier, true); @@ -562,30 +557,42 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { view returns (bytes16[] memory uuids, uint256 count) { - if (adminCode == 0) revert AdminAreaRequired(); if (countryCode == 0) revert InvalidCountryCode(); + if (adminCode == 0) revert AdminAreaRequired(); - uint32 adminKey = _makeAdminRegion(countryCode, adminCode); uint32 countryKey = uint32(countryCode); + uint32 adminKey = _makeAdminRegion(countryCode, adminCode); - uint256 adminTiers = regionTierCount[adminKey]; - uint256 countryTiers = regionTierCount[countryKey]; + (uuids, count, , ) = _buildHighestBondedUuidBundle(countryKey, adminKey); + } - // Find highest active tier across both levels - uint256 maxTier = adminTiers > 0 ? adminTiers - 1 : 0; - if (countryTiers > 0 && countryTiers - 1 > maxTier) maxTier = countryTiers - 1; + /// @dev Internal bundle builder that returns additional state for `_findCheapestInclusionTier`. + /// + /// Builds a priority-ordered bundle by descending from highestTier to tier 0, + /// including admin-area members before country members at each tier. + /// + /// @return uuids The UUIDs included in the bundle (trimmed to actual count). + /// @return count Number of UUIDs in the bundle. + /// @return highestTier The highest tier with any registered members. + /// @return lowestTier The lowest tier processed (may be > 0 if bundle filled early). + function _buildHighestBondedUuidBundle(uint32 countryKey, uint32 adminKey) + internal + view + returns (bytes16[] memory uuids, uint256 count, uint256 highestTier, uint256 lowestTier) + { + highestTier = _findMaxTierIndex(countryKey, adminKey); uuids = new bytes16[](MAX_BONDED_UUID_BUNDLE_SIZE); // Simple tier-descent: at each tier, locals first, then country - for (uint256 tier = maxTier + 1; tier > 0 && count < MAX_BONDED_UUID_BUNDLE_SIZE;) { - unchecked { --tier; } + for (lowestTier = highestTier + 1; lowestTier > 0 && count < MAX_BONDED_UUID_BUNDLE_SIZE;) { + unchecked { --lowestTier; } - // Include local (admin area) members first (higher priority within tier) - count = _appendTierUuids(adminKey, tier, adminTiers, uuids, count); + // Include local (admin area) members first + count = _appendTierUuids(adminKey, lowestTier, uuids, count); // Include country members - count = _appendTierUuids(countryKey, tier, countryTiers, uuids, count); + count = _appendTierUuids(countryKey, lowestTier, uuids, count); } // Trim array to actual size @@ -594,16 +601,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } - /// @dev Appends UUIDs from a tier to the bundle array. Returns new count. + /// @dev Appends UUIDs from a region's tier to the bundle array. + /// If the tier has no members (empty region or tier beyond regionTierCount), + /// this is a no-op. Returns the updated count. function _appendTierUuids( uint32 regionKey, uint256 tier, - uint256 tierCount, bytes16[] memory uuids, uint256 count ) internal view returns (uint256) { - if (tier >= tierCount) return count; - uint256[] storage members = _regionTierMembers[regionKey][tier]; uint256 len = members.length; uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; @@ -806,129 +812,73 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // -- Bundle-level helpers (shared by buildHighestBondedUuidBundle & inclusion hints) -- - /// @dev Resolves the two bundle levels from a location. - /// Level 0 = admin area (highest priority), Level 1 = country. - function _resolveBundleLevels(uint16 countryCode, uint16 adminCode) - internal - pure - returns (uint32[2] memory keys, bool[2] memory active) - { - if (countryCode > 0 && adminCode > 0) { - keys[LEVEL_ADMIN] = _makeAdminRegion(countryCode, adminCode); - active[LEVEL_ADMIN] = true; - } - if (countryCode > 0) { - keys[LEVEL_COUNTRY] = uint32(countryCode); - active[LEVEL_COUNTRY] = true; - } - } - /// @dev Finds the highest active tier index across both bundle levels. - function _findMaxTierIndex(uint32[2] memory keys, bool[2] memory active) + function _findMaxTierIndex(uint32 countryKey, uint32 adminKey) internal view returns (uint256 maxTierIndex) { - for (uint256 lvl = 0; lvl < 2; ++lvl) { - if (!active[lvl]) continue; - uint256 tc = regionTierCount[keys[lvl]]; - if (tc > 0 && tc - 1 > maxTierIndex) { - maxTierIndex = tc - 1; - } - } + uint256 adminTiers = regionTierCount[adminKey]; + uint256 countryTiers = regionTierCount[countryKey]; + + uint256 maxTier = adminTiers > 0 ? adminTiers - 1 : 0; + if (countryTiers > 0 && countryTiers - 1 > maxTier) maxTier = countryTiers - 1; + return maxTier; } // -- Inclusion-tier logic -- - /// @dev Simulates `buildHighestBondedUuidBundle(countryCode, adminCode)` and - /// returns the cheapest tier at `candidateLevel` that guarantees bundle - /// inclusion. Bounded: O(MAX_TIERS). Works for admin or country level. + /// @dev Uses `_buildHighestBondedUuidBundle` to determine the cheapest tier at + /// `candidateRegion` that guarantees bundle inclusion. Bounded: O(MAX_TIERS). + /// + /// Walks from the bundle's lowestTier upward, "unwinding" the bundle count + /// by subtracting both regions' contributions at each tier. Returns the first + /// tier where: + /// (a) The tier has capacity (< TIER_CAPACITY members). + /// (b) The unwound count shows room in the bundle (< MAX_BONDED_UUID_BUNDLE_SIZE). + /// + /// If no existing tier qualifies and highestTier + 1 < MAX_TIERS, returns + /// highestTier + 1 (joining above current max guarantees inclusion). /// - /// A new fleet is included if: - /// (a) The tier has capacity for the new fleet. - /// (b) At that tier position, after higher-priority levels consume slots, - /// there is room for at least one member from the candidate tier. - function _findCheapestInclusionTier(uint16 countryCode, uint16 adminCode, uint256 candidateLevel) + /// @param countryCode The country code for the bundle location. + /// @param adminCode The admin area code (0 for country-only bundles). + /// @param isCountry True if candidate is joining country region, false for admin. + function _findCheapestInclusionTier(uint16 countryCode, uint16 adminCode, bool isCountry) internal view returns (uint256) { - (uint32[2] memory keys, bool[2] memory active) = _resolveBundleLevels(countryCode, adminCode); - uint256 maxTierIndex = _findMaxTierIndex(keys, active); - - uint32 candidateRegion = keys[candidateLevel]; - uint256 cap = TIER_CAPACITY; - - // Find cheapest tier T at candidateRegion. - uint256 candidateTierCount = regionTierCount[candidateRegion]; - - for (uint256 T = 0; T < MAX_TIERS; ++T) { - // (a) Room in the tier? - uint256 members = (T < candidateTierCount) ? _regionTierMembers[candidateRegion][T].length : 0; - if (members >= cap) continue; - - // (b) Compute how many slots are consumed BEFORE reaching (candidateRegion, T). - uint256 countBefore; - - if (T > maxTierIndex) { - // Tier above current max: if we join here, we become the new max. - // Bundle starts from T, so countBefore = 0. - countBefore = 0; - } else { - // Count from tiers strictly above T. - countBefore = _getCountFromTiersAbove(keys, active, maxTierIndex, T); - - // Add higher-priority levels at tier T itself (only admin area for country candidates). - if (candidateLevel == LEVEL_COUNTRY && active[LEVEL_ADMIN]) { - if (countBefore < MAX_BONDED_UUID_BUNDLE_SIZE) { - uint256 ltc = regionTierCount[keys[LEVEL_ADMIN]]; - if (ltc > 0 && T < ltc) { - uint256 m = _regionTierMembers[keys[LEVEL_ADMIN]][T].length; - if (m > 0) { - uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - countBefore; - countBefore += (m < room ? m : room); - } - } - } - } - } - - // Does the candidate (with +1) fit in bundle? - if (countBefore < MAX_BONDED_UUID_BUNDLE_SIZE) { - return T; - } - } - - revert MaxTiersReached(); - } + uint32 countryKey = uint32(countryCode); + uint32 adminKey = _makeAdminRegion(countryCode, adminCode); + uint32 candidateRegion = isCountry ? countryKey : adminKey; - /// @dev Helper to compute count from tiers STRICTLY above T (i.e., tiers > T). - /// Simple tier-descent: at each tier, locals first, then country. - function _getCountFromTiersAbove(uint32[2] memory keys, bool[2] memory active, uint256 maxTierIndex, uint256 T) - internal - view - returns (uint256 count) - { - // Process tiers from maxTierIndex down to T+1 (exclusive of T). - for (int256 cursor = int256(maxTierIndex); cursor > int256(T); --cursor) { - if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; + (, uint256 count, uint256 highestTier, uint256 lowestTier) = _buildHighestBondedUuidBundle(countryKey, adminKey); - // lvl 0 = admin (local), lvl 1 = country - for (uint256 lvl = 0; lvl < 2; ++lvl) { - if (!active[lvl]) continue; - if (count >= MAX_BONDED_UUID_BUNDLE_SIZE) break; + // Walk from lowestTier upward, unwinding the bundle count at each tier. + // Subtracting both regions' contributions simulates "what if we built the + // bundle stopping at this tier instead". + for (uint256 tier = lowestTier; tier <= highestTier; ++tier) { + bool tierHasCapacity = _regionTierMembers[candidateRegion][tier].length < TIER_CAPACITY; + bool bundleHasRoom = count < MAX_BONDED_UUID_BUNDLE_SIZE; - uint256 tc = regionTierCount[keys[lvl]]; - if (tc == 0 || uint256(cursor) >= tc) continue; + if (tierHasCapacity && bundleHasRoom) { + return tier; + } - uint256 m = _regionTierMembers[keys[lvl]][uint256(cursor)].length; - if (m == 0) continue; + // Unwind: subtract both regions' contributions at this tier. + // Use saturating subtraction to handle edge cases gracefully. + uint256 adminMembers = _regionTierMembers[adminKey][tier].length; + uint256 countryMembers = _regionTierMembers[countryKey][tier].length; + uint256 tierTotal = adminMembers + countryMembers; + count = tierTotal > count ? 0 : count - tierTotal; + } - uint256 room = MAX_BONDED_UUID_BUNDLE_SIZE - count; - uint256 toInclude = m < room ? m : room; - count += toInclude; - } + // No fit in existing tiers — try joining above current max. + if (highestTier < MAX_TIERS - 1) { + return highestTier + 1; } + + revert MaxTiersReached(); } /// @dev Appends a token to a region's tier member array and records its index. From 6f7deba9405e30b3c3363bd2bffa4d08f9fc5597 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 25 Feb 2026 15:10:06 +1300 Subject: [PATCH 46/63] refactor: restore None as default RegistrationLevel enum value - Restore None (0) as the first enum value for unregistered UUIDs - Reorder enum: None, Owned, Local, Country - Simplify _register() logic to check existingLevel directly - Remove unnecessary existingOwner check as primary condition - Update all test assertions to match new enum values - All 193 tests passing --- src/swarms/FleetIdentity.sol | 10 +++++----- test/FleetIdentity.t.sol | 26 +++++++++++++------------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 383179d9..c9108ec8 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -83,10 +83,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Registration level for a UUID. enum RegistrationLevel { - None, // 0 - not registered - Local, // 1 - admin area (local) level - Country, // 2 - country level - Owned // 3 - owned but not registered in any region + None, // 0 - not registered (default) + Owned, // 1 - owned but not registered in any region + Local, // 2 - admin area (local) level + Country // 3 - country level } // ────────────────────────────────────────────── @@ -717,7 +717,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } // Standard registration (None or existing level) - if (existingOwner == address(0)) { + if (existingLevel == RegistrationLevel.None) { // First registration for this UUID uuidOwner[uuid] = msg.sender; uuidLevel[uuid] = targetLevel; diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 60c98c81..0ce65526 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -1258,7 +1258,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1, "Level is 1 (local) after local registration"); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2, "Level is 2 (local) after local registration"); } function test_uuidLevel_setOnFirstRegistration_country() public { @@ -1267,7 +1267,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetCountry(UUID_1, US, 0); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 2, "Level is 2 (country) after country registration"); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 3, "Level is 3 (country) after country registration"); } function test_RevertIf_crossLevelRegistration_localThenCountry() public { @@ -1296,7 +1296,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); vm.prank(alice); fleet.burn(tokenId); @@ -1311,19 +1311,19 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); vm.prank(alice); fleet.burn(id1); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1, "Level preserved while tokens remain"); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2, "Level preserved while tokens remain"); } function test_uuidLevel_canChangeLevelAfterBurningAll() public { // Register as local vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // Burn vm.prank(alice); @@ -1332,7 +1332,7 @@ contract FleetIdentityTest is Test { // Now can register as country vm.prank(alice); fleet.registerFleetCountry(UUID_1, US, 0); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); } // ══════════════════════════════════════════════ @@ -1354,7 +1354,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.uuidOwner(UUID_1), alice); assertEq(fleet.uuidTokenCount(UUID_1), 1); assertTrue(fleet.isOwnedOnly(UUID_1)); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Owned + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // Owned // Bond pulled assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), BASE_BOND); @@ -1411,7 +1411,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.uuidOwner(UUID_1), alice); assertEq(fleet.uuidTokenCount(UUID_1), 1); // still 1 assertFalse(fleet.isOwnedOnly(UUID_1)); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // Local + assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // Local // Only incremental bond pulled (tier 0 local = BASE_BOND, already paid BASE_BOND) assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), 0); @@ -1428,7 +1428,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.ownerOf(tokenId), alice); assertEq(fleet.tokenRegion(tokenId), uint32(US)); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // Country + assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Country // Incremental bond: country tier 0 = 8*BASE_BOND, already paid BASE_BOND, so 7*BASE_BOND assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), 7 * BASE_BOND); @@ -1467,7 +1467,7 @@ contract FleetIdentityTest is Test { // UUID state updated assertTrue(fleet.isOwnedOnly(UUID_1)); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Owned + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // Owned // No refund for tier 0 local (BASE_BOND - BASE_BOND = 0) assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, 0); @@ -1501,7 +1501,7 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, 7 * BASE_BOND); // Level reset to Owned - assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); + assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); } function test_RevertIf_unregisterToOwned_multipleTokens() public { @@ -1667,7 +1667,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.ownerOf(newTokenId), alice); assertEq(fleet.tokenRegion(newTokenId), uint32(DE)); - assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // Country + assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Country // Net bond change: 7*BASE_BOND additional assertEq(aliceBalanceAfterRegister - bondToken.balanceOf(alice), 7 * BASE_BOND); From f4327c9c179b8ee69888aed3710873e9417c8576 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 25 Feb 2026 15:26:24 +1300 Subject: [PATCH 47/63] refactor(FleetIdentity): extract helpers and simplify registration logic - Extract bond transfer helpers: _pullBond() and _refundBond() - Extract UUID cleanup: _clearUuidOwnership() and _decrementUuidCount() - Extract tier cleanup: _cleanupFleetFromTier() consolidates removal sequence - Add token minting helper: _mintFleetToken() for shared minting logic - Refactor _register() into clearer if/else structure with three explicit paths - Use tokenRegion() and tokenUuid() accessors throughout (replace inline shifts) - Simplify burn(), unregisterToOwned(), releaseUuid(), _promote(), _demote() - All 193 tests pass, no behavioral changes Benefits: - Reduced code duplication (3 cleanup patterns consolidated) - Improved readability (dedicated helpers with clear intent) - Easier maintenance (single point of change for common operations) - Consistent token ID encoding/decoding (named accessors) --- src/swarms/FleetIdentity.sol | 221 ++++++++++++++++++----------------- 1 file changed, 111 insertions(+), 110 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index c9108ec8..7fad0f63 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -99,8 +99,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Bond multiplier for country-level registration (8× local). uint256 public constant COUNTRY_BOND_MULTIPLIER = 8; - - /// @notice Hard cap on tier count per region. /// @dev Derived from anti-spam analysis: with a bond doubling per tier /// and capacity 4, a spammer spending half the total token supply @@ -290,8 +288,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); - uint32 region = uint32(tokenId >> 128); - bytes16 uuid = bytes16(uint128(tokenId)); + uint32 region = tokenRegion(tokenId); + bytes16 uuid = tokenUuid(tokenId); uint256 refund; uint256 tier; @@ -304,30 +302,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Registered fleet: remove from tier structures tier = fleetTier[tokenId]; refund = tierBond(tier, _isCountryRegion(region)); - - _removeFromTier(tokenId, region, tier); - delete fleetTier[tokenId]; - delete _indexInTier[tokenId]; + _cleanupFleetFromTier(tokenId, region, tier); _burn(tokenId); - - _trimTierCount(region); - _removeFromRegionIndex(region); } - // Clean up UUID ownership tracking - uint256 newCount = uuidTokenCount[uuid] - 1; - if (newCount == 0) { - delete uuidOwner[uuid]; - delete uuidTokenCount[uuid]; - delete uuidLevel[uuid]; - } else { - uuidTokenCount[uuid] = newCount; - } - - // Interaction - if (refund > 0) { - BOND_TOKEN.safeTransfer(tokenOwner, refund); - } + _decrementUuidCount(uuid); + _refundBond(tokenOwner, refund); emit FleetBurned(tokenOwner, tokenId, region, tier, refund); } @@ -353,8 +333,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { tokenId = uint256(uint128(uuid)); _mint(msg.sender, tokenId); - // Pull bond - BOND_TOKEN.safeTransferFrom(msg.sender, address(this), BASE_BOND); + _pullBond(msg.sender, BASE_BOND); emit UuidClaimed(msg.sender, uuid, tokenId, BASE_BOND); } @@ -367,8 +346,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); - bytes16 uuid = bytes16(uint128(tokenId)); - uint32 region = uint32(tokenId >> 128); + bytes16 uuid = tokenUuid(tokenId); + uint32 region = tokenRegion(tokenId); // Must be a registered fleet, not already owned-only if (region == OWNED_REGION_KEY) revert UuidNotOwned(); @@ -380,13 +359,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 currentBond = tierBond(tier, _isCountryRegion(region)); uint256 refund = currentBond - BASE_BOND; - // === Effects: Remove from region === - _removeFromTier(tokenId, region, tier); - delete fleetTier[tokenId]; - delete _indexInTier[tokenId]; + // Effects: Remove from region + _cleanupFleetFromTier(tokenId, region, tier); _burn(tokenId); - _trimTierCount(region); - _removeFromRegionIndex(region); // Update level to Owned (resets level for future registration flexibility) uuidLevel[uuid] = RegistrationLevel.Owned; @@ -395,10 +370,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { newTokenId = uint256(uint128(uuid)); _mint(msg.sender, newTokenId); - // Interaction: refund excess bond - if (refund > 0) { - BOND_TOKEN.safeTransfer(msg.sender, refund); - } + _refundBond(msg.sender, refund); emit UuidUnregistered(msg.sender, uuid, tokenId, newTokenId, refund); } @@ -414,16 +386,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 tokenId = uint256(uint128(uuid)); address tokenOwner = ownerOf(tokenId); - // Burn the token + // Effects _burn(tokenId); + _clearUuidOwnership(uuid); - // Clear ownership - delete uuidOwner[uuid]; - delete uuidTokenCount[uuid]; - delete uuidLevel[uuid]; - - // Refund BASE_BOND to token owner (not necessarily uuidOwner due to transfers) - BOND_TOKEN.safeTransfer(tokenOwner, BASE_BOND); + // Interaction + _refundBond(tokenOwner, BASE_BOND); emit UuidReleased(tokenOwner, uuid, BASE_BOND); } @@ -521,7 +489,7 @@ 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; - uint32 region = uint32(tokenId >> 128); + uint32 region = tokenRegion(tokenId); if (region == OWNED_REGION_KEY) return BASE_BOND; return tierBond(fleetTier[tokenId], _isCountryRegion(region)); } @@ -616,7 +584,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 toInclude = len < room ? len : room; for (uint256 i = 0; i < toInclude; ++i) { - uuids[count] = bytes16(uint128(members[i])); + uuids[count] = tokenUuid(members[i]); unchecked { ++count; } } return count; @@ -677,72 +645,111 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return regionKey > 0 && regionKey <= MAX_COUNTRY_CODE; } - /// @dev Shared registration logic. Handles both fresh registrations and Owned → Registered transitions. + // -- Bond transfer helpers -- + + /// @dev Pulls bond tokens from an address (CEI: call after state changes). + function _pullBond(address from, uint256 amount) internal { + if (amount > 0) { + BOND_TOKEN.safeTransferFrom(from, address(this), amount); + } + } + + /// @dev Refunds bond tokens to an address (CEI: call after state changes). + function _refundBond(address to, uint256 amount) internal { + if (amount > 0) { + BOND_TOKEN.safeTransfer(to, amount); + } + } + + // -- UUID ownership helpers -- + + /// @dev Clears all UUID ownership state. Used when last token for a UUID is burned. + function _clearUuidOwnership(bytes16 uuid) internal { + delete uuidOwner[uuid]; + delete uuidTokenCount[uuid]; + delete uuidLevel[uuid]; + } + + /// @dev Decrements UUID token count. Clears ownership if count reaches zero. + /// @return newCount The new token count after decrement. + function _decrementUuidCount(bytes16 uuid) internal returns (uint256 newCount) { + newCount = uuidTokenCount[uuid] - 1; + if (newCount == 0) { + _clearUuidOwnership(uuid); + } else { + uuidTokenCount[uuid] = newCount; + } + } + + // -- Tier cleanup helpers -- + + /// @dev Removes a fleet from its tier and cleans up associated state. + /// Does NOT burn the token - caller must handle that. + function _cleanupFleetFromTier(uint256 tokenId, uint32 region, uint256 tier) internal { + _removeFromTier(tokenId, region, tier); + delete fleetTier[tokenId]; + delete _indexInTier[tokenId]; + _trimTierCount(region); + _removeFromRegionIndex(region); + } + + // -- Registration helpers -- + + /// @dev Mints a fleet token and sets up tier membership. Does NOT handle bonds or UUID ownership. + /// @return tokenId The newly minted token ID. + function _mintFleetToken(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { + tokenId = computeTokenId(uuid, region); + fleetTier[tokenId] = tier; + _addToTier(tokenId, region, tier); + _addToRegionIndex(region); + _mint(msg.sender, tokenId); + } + + /// @dev Shared registration logic. Handles fresh, Owned → Registered, and multi-region registrations. function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { RegistrationLevel existingLevel = uuidLevel[uuid]; - address existingOwner = uuidOwner[uuid]; bool isCountry = _isCountryRegion(region); RegistrationLevel targetLevel = isCountry ? RegistrationLevel.Country : RegistrationLevel.Local; - // Handle Owned → Registered transition if (existingLevel == RegistrationLevel.Owned) { - if (existingOwner != msg.sender) revert UuidOwnerMismatch(); - - // Burn the owned-only token - uint256 ownedTokenId = uint256(uint128(uuid)); - _burn(ownedTokenId); + // Owned → Registered transition: burn owned token, credit BASE_BOND already paid + if (uuidOwner[uuid] != msg.sender) revert UuidOwnerMismatch(); - // Update level + _burn(uint256(uint128(uuid))); // Burn owned-only token uuidLevel[uuid] = targetLevel; - // uuidTokenCount stays the same (1) - // Calculate bond: full tier bond minus BASE_BOND already paid - uint256 fullBond = tierBond(tier, isCountry); - uint256 incrementalBond = fullBond - BASE_BOND; + tokenId = _mintFleetToken(uuid, region, tier); - // Mint new token - tokenId = (uint256(region) << 128) | uint256(uint128(uuid)); - fleetTier[tokenId] = tier; - _addToTier(tokenId, region, tier); - _addToRegionIndex(region); - _mint(msg.sender, tokenId); - - // Pull incremental bond - if (incrementalBond > 0) { - BOND_TOKEN.safeTransferFrom(msg.sender, address(this), incrementalBond); - } + uint256 incrementalBond = tierBond(tier, isCountry) - BASE_BOND; + _pullBond(msg.sender, incrementalBond); emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, incrementalBond); - return tokenId; - } - - // Standard registration (None or existing level) - if (existingLevel == RegistrationLevel.None) { - // First registration for this UUID + } else if (existingLevel == RegistrationLevel.None) { + // Fresh registration: set UUID ownership uuidOwner[uuid] = msg.sender; uuidLevel[uuid] = targetLevel; + uuidTokenCount[uuid] = 1; + + tokenId = _mintFleetToken(uuid, region, tier); + + uint256 bond = tierBond(tier, isCountry); + _pullBond(msg.sender, bond); + + emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, bond); } else { - if (existingOwner != msg.sender) revert UuidOwnerMismatch(); + // Multi-region registration: same owner, same level, additional token + if (uuidOwner[uuid] != msg.sender) revert UuidOwnerMismatch(); if (existingLevel != targetLevel) revert UuidLevelMismatch(); + + uuidTokenCount[uuid]++; + + tokenId = _mintFleetToken(uuid, region, tier); + + uint256 bond = tierBond(tier, isCountry); + _pullBond(msg.sender, bond); + + emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, bond); } - - uuidTokenCount[uuid]++; - - uint256 bond = tierBond(tier, isCountry); - tokenId = (uint256(region) << 128) | uint256(uint128(uuid)); - - // Effects - fleetTier[tokenId] = tier; - _addToTier(tokenId, region, tier); - _addToRegionIndex(region); - _mint(msg.sender, tokenId); - - // Interaction - if (bond > 0) { - BOND_TOKEN.safeTransferFrom(msg.sender, address(this), bond); - } - - emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, bond); } /// @dev Shared promotion logic. @@ -750,7 +757,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); - uint32 region = uint32(tokenId >> 128); + uint32 region = tokenRegion(tokenId); uint256 currentTier = fleetTier[tokenId]; if (targetTier <= currentTier) revert TargetTierNotHigher(); if (targetTier >= MAX_TIERS) revert MaxTiersReached(); @@ -767,9 +774,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { _addToTier(tokenId, region, targetTier); // Interaction - if (additionalBond > 0) { - BOND_TOKEN.safeTransferFrom(tokenOwner, address(this), additionalBond); - } + _pullBond(tokenOwner, additionalBond); emit FleetPromoted(tokenId, currentTier, targetTier, additionalBond); } @@ -779,7 +784,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { address tokenOwner = ownerOf(tokenId); if (tokenOwner != msg.sender) revert NotTokenOwner(); - uint32 region = uint32(tokenId >> 128); + uint32 region = tokenRegion(tokenId); uint256 currentTier = fleetTier[tokenId]; if (targetTier >= currentTier) revert TargetTierNotLower(); if (_regionTierMembers[region][targetTier].length >= TIER_CAPACITY) revert TierFull(); @@ -793,13 +798,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { _removeFromTier(tokenId, region, currentTier); fleetTier[tokenId] = targetTier; _addToTier(tokenId, region, targetTier); - _trimTierCount(region); // Interaction - if (refund > 0) { - BOND_TOKEN.safeTransfer(tokenOwner, refund); - } + _refundBond(tokenOwner, refund); emit FleetDemoted(tokenId, currentTier, targetTier, refund); } @@ -979,10 +981,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // For owned-only tokens, transfer uuidOwner when the token is transferred // This allows marketplace trading of owned-only UUIDs - uint32 region = uint32(tokenId >> 128); + uint32 region = tokenRegion(tokenId); if (region == OWNED_REGION_KEY && from != address(0) && to != address(0)) { - bytes16 uuid = bytes16(uint128(tokenId)); - uuidOwner[uuid] = to; + uuidOwner[tokenUuid(tokenId)] = to; } return from; From a8d8b15f19746cb934932a6eb90514e099113e87 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 25 Feb 2026 15:47:29 +1300 Subject: [PATCH 48/63] docs: Update ISO 3166-2 admin area indices to 1-indexed - Changed all 18 ISO 3166-2 country mapping files to use 1-indexed admin codes instead of 0-indexed - Updated column header from "Dense Index" to "Admin Code" - Updated README.md to clarify that admin codes are 1-indexed in table files - Aligns with FleetIdentity contract requirement that adminCode > 0 - Removed confusing "dense index + 1" conceptual step for end users --- src/swarms/doc/README.md | 29 +- src/swarms/doc/iso3166-2/036-Australia.md | 18 +- src/swarms/doc/iso3166-2/076-Brazil.md | 56 +-- src/swarms/doc/iso3166-2/124-Canada.md | 28 +- src/swarms/doc/iso3166-2/156-China.md | 70 ++-- src/swarms/doc/iso3166-2/250-France.md | 38 +- src/swarms/doc/iso3166-2/276-Germany.md | 34 +- src/swarms/doc/iso3166-2/356-India.md | 74 ++-- src/swarms/doc/iso3166-2/380-Italy.md | 42 +-- src/swarms/doc/iso3166-2/392-Japan.md | 96 ++--- src/swarms/doc/iso3166-2/410-South_Korea.md | 36 +- src/swarms/doc/iso3166-2/484-Mexico.md | 66 ++-- src/swarms/doc/iso3166-2/566-Nigeria.md | 76 ++-- src/swarms/doc/iso3166-2/643-Russia.md | 168 ++++----- src/swarms/doc/iso3166-2/710-South_Africa.md | 20 +- src/swarms/doc/iso3166-2/724-Spain.md | 40 +- src/swarms/doc/iso3166-2/756-Switzerland.md | 54 +-- .../doc/iso3166-2/826-United_Kingdom.md | 346 +++++++++--------- src/swarms/doc/iso3166-2/840-United_States.md | 116 +++--- 19 files changed, 703 insertions(+), 704 deletions(-) diff --git a/src/swarms/doc/README.md b/src/swarms/doc/README.md index f1ccf631..40a83132 100644 --- a/src/swarms/doc/README.md +++ b/src/swarms/doc/README.md @@ -1,6 +1,6 @@ # ISO 3166-2 Admin Area Mappings -The [iso3166-2/](iso3166-2/) directory contains standardized mappings from ISO 3166-2 subdivision codes to dense numeric indices for use with the FleetIdentity contract. +The [iso3166-2/](iso3166-2/) directory contains standardized mappings from ISO 3166-2 subdivision codes to admin codes for use with the FleetIdentity contract. ## File Naming Convention @@ -12,11 +12,11 @@ Example: [840-United_States.md](iso3166-2/840-United_States.md) for the United S Each country file contains a mapping table with three columns: -| Dense Index | ISO 3166-2 Code | Name | -| ----------- | --------------- | --------------------- | -| 0 | XX | Full subdivision name | +| Admin Code | ISO 3166-2 Code | Name | +| ---------- | --------------- | --------------------- | +| 1 | XX | Full subdivision name | -- **Dense Index**: Sequential integers from 0 to n-1 (where n = number of subdivisions) +- **Admin Code**: Sequential integers from 1 to n (where n = number of subdivisions). This is the 1-indexed admin code that must be provided when calling FleetIdentity contract functions. - **ISO 3166-2 Code**: The subdivision code (1-3 alphanumeric characters, without country prefix) - **Name**: Full official name of the subdivision @@ -25,20 +25,19 @@ Each country file contains a mapping table with three columns: The FleetIdentity contract uses: - **Country Code**: ISO 3166-1 numeric (1-999) -- **Admin Code**: Dense index + 1 (1-4095) - - Add 1 to the dense index when calling contract functions - - Dense index 0 → adminCode 1 - - Dense index 4094 → adminCode 4095 +- **Admin Code**: Values from the table column (1-4095) + - Admin codes are 1-indexed in the table files + - Valid range: 1-255 (soft limit covers all real-world countries) + - Admin codes must be > 0 (0 is invalid and will revert with `InvalidAdminCode()`) ## Example -For California, USA: +For Alberta, Canada: -- Country: United States (ISO 3166-1 numeric: 840) -- ISO 3166-2: US-CA -- Dense Index: 4 (from table) -- Contract adminCode: 5 (dense index + 1) -- Region Key: `(840 << 12) | 5 = 3440645` +- Country: Canada (ISO 3166-1 numeric: 124) +- ISO 3166-2: CA-AB +- Admin Code: 1 (from table) +- Region Key: `(124 << 10) | 1 = 127001` (computed as `(countryCode << 10) | adminCode`) ## Coverage diff --git a/src/swarms/doc/iso3166-2/036-Australia.md b/src/swarms/doc/iso3166-2/036-Australia.md index dba22570..1e00f105 100644 --- a/src/swarms/doc/iso3166-2/036-Australia.md +++ b/src/swarms/doc/iso3166-2/036-Australia.md @@ -4,15 +4,15 @@ ISO 3166-1 numeric: **036** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | ACT | Australian Capital Territory | -| 1 | NSW | New South Wales | -| 2 | NT | Northern Territory | -| 3 | QLD | Queensland | -| 4 | SA | South Australia | -| 5 | TAS | Tasmania | -| 6 | VIC | Victoria | -| 7 | WA | Western Australia | +| 1 | ACT | Australian Capital Territory | +| 2 | NSW | New South Wales | +| 3 | NT | Northern Territory | +| 4 | QLD | Queensland | +| 5 | SA | South Australia | +| 6 | TAS | Tasmania | +| 7 | VIC | Victoria | +| 8 | WA | Western Australia | **Total subdivisions:** 8 diff --git a/src/swarms/doc/iso3166-2/076-Brazil.md b/src/swarms/doc/iso3166-2/076-Brazil.md index 8ab8d0fc..665733d7 100644 --- a/src/swarms/doc/iso3166-2/076-Brazil.md +++ b/src/swarms/doc/iso3166-2/076-Brazil.md @@ -4,34 +4,34 @@ ISO 3166-1 numeric: **076** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AC | Acre | -| 1 | AL | Alagoas | -| 2 | AP | Amapá | -| 3 | AM | Amazonas | -| 4 | BA | Bahia | -| 5 | CE | Ceará | -| 6 | DF | Federal District | -| 7 | ES | Espírito Santo | -| 8 | GO | Goiás | -| 9 | MA | Maranhão | -| 10 | MT | Mato Grosso | -| 11 | MS | Mato Grosso do Sul | -| 12 | MG | Minas Gerais | -| 13 | PA | Pará | -| 14 | PB | Paraíba | -| 15 | PR | Paraná | -| 16 | PE | Pernambuco | -| 17 | PI | Piauí | -| 18 | RJ | Rio de Janeiro | -| 19 | RN | Rio Grande do Norte | -| 20 | RS | Rio Grande do Sul | -| 21 | RO | Rondônia | -| 22 | RR | Roraima | -| 23 | SC | Santa Catarina | -| 24 | SP | São Paulo | -| 25 | SE | Sergipe | -| 26 | TO | Tocantins | +| 1 | AC | Acre | +| 2 | AL | Alagoas | +| 3 | AP | Amapá | +| 4 | AM | Amazonas | +| 5 | BA | Bahia | +| 6 | CE | Ceará | +| 7 | DF | Federal District | +| 8 | ES | Espírito Santo | +| 9 | GO | Goiás | +| 10 | MA | Maranhão | +| 11 | MT | Mato Grosso | +| 12 | MS | Mato Grosso do Sul | +| 13 | MG | Minas Gerais | +| 14 | PA | Pará | +| 15 | PB | Paraíba | +| 16 | PR | Paraná | +| 17 | PE | Pernambuco | +| 18 | PI | Piauí | +| 19 | RJ | Rio de Janeiro | +| 20 | RN | Rio Grande do Norte | +| 21 | RS | Rio Grande do Sul | +| 22 | RO | Rondônia | +| 23 | RR | Roraima | +| 24 | SC | Santa Catarina | +| 25 | SP | São Paulo | +| 26 | SE | Sergipe | +| 27 | TO | Tocantins | **Total subdivisions:** 27 diff --git a/src/swarms/doc/iso3166-2/124-Canada.md b/src/swarms/doc/iso3166-2/124-Canada.md index e8cadc2f..f55ed1e8 100644 --- a/src/swarms/doc/iso3166-2/124-Canada.md +++ b/src/swarms/doc/iso3166-2/124-Canada.md @@ -4,20 +4,20 @@ ISO 3166-1 numeric: **124** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AB | Alberta | -| 1 | BC | British Columbia | -| 2 | MB | Manitoba | -| 3 | NB | New Brunswick | -| 4 | NL | Newfoundland and Labrador | -| 5 | NT | Northwest Territories | -| 6 | NS | Nova Scotia | -| 7 | NU | Nunavut | -| 8 | ON | Ontario | -| 9 | PE | Prince Edward Island | -| 10 | QC | Quebec | -| 11 | SK | Saskatchewan | -| 12 | YT | Yukon | +| 1 | AB | Alberta | +| 2 | BC | British Columbia | +| 3 | MB | Manitoba | +| 4 | NB | New Brunswick | +| 5 | NL | Newfoundland and Labrador | +| 6 | NT | Northwest Territories | +| 7 | NS | Nova Scotia | +| 8 | NU | Nunavut | +| 9 | ON | Ontario | +| 10 | PE | Prince Edward Island | +| 11 | QC | Quebec | +| 12 | SK | Saskatchewan | +| 13 | YT | Yukon | **Total subdivisions:** 13 diff --git a/src/swarms/doc/iso3166-2/156-China.md b/src/swarms/doc/iso3166-2/156-China.md index fc196309..c934c1cc 100644 --- a/src/swarms/doc/iso3166-2/156-China.md +++ b/src/swarms/doc/iso3166-2/156-China.md @@ -4,41 +4,41 @@ ISO 3166-1 numeric: **156** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AH | Anhui | -| 1 | BJ | Beijing | -| 2 | CQ | Chongqing | -| 3 | FJ | Fujian | -| 4 | GS | Gansu | -| 5 | GD | Guangdong | -| 6 | GX | Guangxi | -| 7 | GZ | Guizhou | -| 8 | HI | Hainan | -| 9 | HE | Hebei | -| 10 | HL | Heilongjiang | -| 11 | HA | Henan | -| 12 | HB | Hubei | -| 13 | HN | Hunan | -| 14 | JS | Jiangsu | -| 15 | JX | Jiangxi | -| 16 | JL | Jilin | -| 17 | LN | Liaoning | -| 18 | NM | Inner Mongolia | -| 19 | NX | Ningxia | -| 20 | QH | Qinghai | -| 21 | SN | Shaanxi | -| 22 | SD | Shandong | -| 23 | SH | Shanghai | -| 24 | SX | Shanxi | -| 25 | SC | Sichuan | -| 26 | TJ | Tianjin | -| 27 | XJ | Xinjiang | -| 28 | XZ | Tibet | -| 29 | YN | Yunnan | -| 30 | ZJ | Zhejiang | -| 31 | HK | Hong Kong | -| 32 | MO | Macao | -| 33 | TW | Taiwan | +| 1 | AH | Anhui | +| 2 | BJ | Beijing | +| 3 | CQ | Chongqing | +| 4 | FJ | Fujian | +| 5 | GS | Gansu | +| 6 | GD | Guangdong | +| 7 | GX | Guangxi | +| 8 | GZ | Guizhou | +| 9 | HI | Hainan | +| 10 | HE | Hebei | +| 11 | HL | Heilongjiang | +| 12 | HA | Henan | +| 13 | HB | Hubei | +| 14 | HN | Hunan | +| 15 | JS | Jiangsu | +| 16 | JX | Jiangxi | +| 17 | JL | Jilin | +| 18 | LN | Liaoning | +| 19 | NM | Inner Mongolia | +| 20 | NX | Ningxia | +| 21 | QH | Qinghai | +| 22 | SN | Shaanxi | +| 23 | SD | Shandong | +| 24 | SH | Shanghai | +| 25 | SX | Shanxi | +| 26 | SC | Sichuan | +| 27 | TJ | Tianjin | +| 28 | XJ | Xinjiang | +| 29 | XZ | Tibet | +| 30 | YN | Yunnan | +| 31 | ZJ | Zhejiang | +| 32 | HK | Hong Kong | +| 33 | MO | Macao | +| 34 | TW | Taiwan | **Total subdivisions:** 34 diff --git a/src/swarms/doc/iso3166-2/250-France.md b/src/swarms/doc/iso3166-2/250-France.md index 7ba69076..1d253878 100644 --- a/src/swarms/doc/iso3166-2/250-France.md +++ b/src/swarms/doc/iso3166-2/250-France.md @@ -4,25 +4,25 @@ ISO 3166-1 numeric: **250** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | ARA | Auvergne-Rhône-Alpes | -| 1 | BFC | Bourgogne-Franche-Comté | -| 2 | BRE | Brittany | -| 3 | CVL | Centre-Val de Loire | -| 4 | COR | Corsica | -| 5 | GES | Grand Est | -| 6 | HDF | Hauts-de-France | -| 7 | IDF | Île-de-France | -| 8 | NOR | Normandy | -| 9 | NAQ | Nouvelle-Aquitaine | -| 10 | OCC | Occitanie | -| 11 | PDL | Pays de la Loire | -| 12 | PAC | Provence-Alpes-Côte d'Azur | -| 13 | GP | Guadeloupe | -| 14 | MQ | Martinique | -| 15 | GF | French Guiana | -| 16 | RE | Réunion | -| 17 | YT | Mayotte | +| 1 | ARA | Auvergne-Rhône-Alpes | +| 2 | BFC | Bourgogne-Franche-Comté | +| 3 | BRE | Brittany | +| 4 | CVL | Centre-Val de Loire | +| 5 | COR | Corsica | +| 6 | GES | Grand Est | +| 7 | HDF | Hauts-de-France | +| 8 | IDF | Île-de-France | +| 9 | NOR | Normandy | +| 10 | NAQ | Nouvelle-Aquitaine | +| 11 | OCC | Occitanie | +| 12 | PDL | Pays de la Loire | +| 13 | PAC | Provence-Alpes-Côte d'Azur | +| 14 | GP | Guadeloupe | +| 15 | MQ | Martinique | +| 16 | GF | French Guiana | +| 17 | RE | Réunion | +| 18 | YT | Mayotte | **Total subdivisions:** 18 diff --git a/src/swarms/doc/iso3166-2/276-Germany.md b/src/swarms/doc/iso3166-2/276-Germany.md index 30fb23e1..dea73125 100644 --- a/src/swarms/doc/iso3166-2/276-Germany.md +++ b/src/swarms/doc/iso3166-2/276-Germany.md @@ -4,23 +4,23 @@ ISO 3166-1 numeric: **276** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | BW | Baden-Württemberg | -| 1 | BY | Bavaria | -| 2 | BE | Berlin | -| 3 | BB | Brandenburg | -| 4 | HB | Bremen | -| 5 | HH | Hamburg | -| 6 | HE | Hesse | -| 7 | MV | Mecklenburg-Vorpommern | -| 8 | NI | Lower Saxony | -| 9 | NW | North Rhine-Westphalia | -| 10 | RP | Rhineland-Palatinate | -| 11 | SL | Saarland | -| 12 | SN | Saxony | -| 13 | ST | Saxony-Anhalt | -| 14 | SH | Schleswig-Holstein | -| 15 | TH | Thuringia | +| 1 | BW | Baden-Württemberg | +| 2 | BY | Bavaria | +| 3 | BE | Berlin | +| 4 | BB | Brandenburg | +| 5 | HB | Bremen | +| 6 | HH | Hamburg | +| 7 | HE | Hesse | +| 8 | MV | Mecklenburg-Vorpommern | +| 9 | NI | Lower Saxony | +| 10 | NW | North Rhine-Westphalia | +| 11 | RP | Rhineland-Palatinate | +| 12 | SL | Saarland | +| 13 | SN | Saxony | +| 14 | ST | Saxony-Anhalt | +| 15 | SH | Schleswig-Holstein | +| 16 | TH | Thuringia | **Total subdivisions:** 16 diff --git a/src/swarms/doc/iso3166-2/356-India.md b/src/swarms/doc/iso3166-2/356-India.md index 1dc05958..b6ec245a 100644 --- a/src/swarms/doc/iso3166-2/356-India.md +++ b/src/swarms/doc/iso3166-2/356-India.md @@ -4,43 +4,43 @@ ISO 3166-1 numeric: **356** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AN | Andaman and Nicobar Islands | -| 1 | AP | Andhra Pradesh | -| 2 | AR | Arunachal Pradesh | -| 3 | AS | Assam | -| 4 | BR | Bihar | -| 5 | CH | Chandigarh | -| 6 | CT | Chhattisgarh | -| 7 | DH | Dadra and Nagar Haveli and Daman and Diu | -| 8 | DL | Delhi | -| 9 | GA | Goa | -| 10 | GJ | Gujarat | -| 11 | HR | Haryana | -| 12 | HP | Himachal Pradesh | -| 13 | JK | Jammu and Kashmir | -| 14 | JH | Jharkhand | -| 15 | KA | Karnataka | -| 16 | KL | Kerala | -| 17 | LA | Ladakh | -| 18 | LD | Lakshadweep | -| 19 | MP | Madhya Pradesh | -| 20 | MH | Maharashtra | -| 21 | MN | Manipur | -| 22 | ML | Meghalaya | -| 23 | MZ | Mizoram | -| 24 | NL | Nagaland | -| 25 | OR | Odisha | -| 26 | PY | Puducherry | -| 27 | PB | Punjab | -| 28 | RJ | Rajasthan | -| 29 | SK | Sikkim | -| 30 | TN | Tamil Nadu | -| 31 | TG | Telangana | -| 32 | TR | Tripura | -| 33 | UP | Uttar Pradesh | -| 34 | UT | Uttarakhand | -| 35 | WB | West Bengal | +| 1 | AN | Andaman and Nicobar Islands | +| 2 | AP | Andhra Pradesh | +| 3 | AR | Arunachal Pradesh | +| 4 | AS | Assam | +| 5 | BR | Bihar | +| 6 | CH | Chandigarh | +| 7 | CT | Chhattisgarh | +| 8 | DH | Dadra and Nagar Haveli and Daman and Diu | +| 9 | DL | Delhi | +| 10 | GA | Goa | +| 11 | GJ | Gujarat | +| 12 | HR | Haryana | +| 13 | HP | Himachal Pradesh | +| 14 | JK | Jammu and Kashmir | +| 15 | JH | Jharkhand | +| 16 | KA | Karnataka | +| 17 | KL | Kerala | +| 18 | LA | Ladakh | +| 19 | LD | Lakshadweep | +| 20 | MP | Madhya Pradesh | +| 21 | MH | Maharashtra | +| 22 | MN | Manipur | +| 23 | ML | Meghalaya | +| 24 | MZ | Mizoram | +| 25 | NL | Nagaland | +| 26 | OR | Odisha | +| 27 | PY | Puducherry | +| 28 | PB | Punjab | +| 29 | RJ | Rajasthan | +| 30 | SK | Sikkim | +| 31 | TN | Tamil Nadu | +| 32 | TG | Telangana | +| 33 | TR | Tripura | +| 34 | UP | Uttar Pradesh | +| 35 | UT | Uttarakhand | +| 36 | WB | West Bengal | **Total subdivisions:** 36 diff --git a/src/swarms/doc/iso3166-2/380-Italy.md b/src/swarms/doc/iso3166-2/380-Italy.md index 741969a5..06d5c5f5 100644 --- a/src/swarms/doc/iso3166-2/380-Italy.md +++ b/src/swarms/doc/iso3166-2/380-Italy.md @@ -4,27 +4,27 @@ ISO 3166-1 numeric: **380** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | 65 | Abruzzo | -| 1 | 77 | Basilicata | -| 2 | 78 | Calabria | -| 3 | 72 | Campania | -| 4 | 45 | Emilia-Romagna | -| 5 | 36 | Friuli-Venezia Giulia | -| 6 | 62 | Lazio | -| 7 | 42 | Liguria | -| 8 | 25 | Lombardy | -| 9 | 57 | Marche | -| 10 | 67 | Molise | -| 11 | 21 | Piedmont | -| 12 | 75 | Apulia | -| 13 | 88 | Sardinia | -| 14 | 82 | Sicily | -| 15 | 52 | Tuscany | -| 16 | 32 | Trentino-South Tyrol | -| 17 | 55 | Umbria | -| 18 | 23 | Aosta Valley | -| 19 | 34 | Veneto | +| 1 | 65 | Abruzzo | +| 2 | 77 | Basilicata | +| 3 | 78 | Calabria | +| 4 | 72 | Campania | +| 5 | 45 | Emilia-Romagna | +| 6 | 36 | Friuli-Venezia Giulia | +| 7 | 62 | Lazio | +| 8 | 42 | Liguria | +| 9 | 25 | Lombardy | +| 10 | 57 | Marche | +| 11 | 67 | Molise | +| 12 | 21 | Piedmont | +| 13 | 75 | Apulia | +| 14 | 88 | Sardinia | +| 15 | 82 | Sicily | +| 16 | 52 | Tuscany | +| 17 | 32 | Trentino-South Tyrol | +| 18 | 55 | Umbria | +| 19 | 23 | Aosta Valley | +| 20 | 34 | Veneto | **Total subdivisions:** 20 diff --git a/src/swarms/doc/iso3166-2/392-Japan.md b/src/swarms/doc/iso3166-2/392-Japan.md index d8779e49..d7952e81 100644 --- a/src/swarms/doc/iso3166-2/392-Japan.md +++ b/src/swarms/doc/iso3166-2/392-Japan.md @@ -4,54 +4,54 @@ ISO 3166-1 numeric: **392** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | 01 | Hokkaido | -| 1 | 02 | Aomori | -| 2 | 03 | Iwate | -| 3 | 04 | Miyagi | -| 4 | 05 | Akita | -| 5 | 06 | Yamagata | -| 6 | 07 | Fukushima | -| 7 | 08 | Ibaraki | -| 8 | 09 | Tochigi | -| 9 | 10 | Gunma | -| 10 | 11 | Saitama | -| 11 | 12 | Chiba | -| 12 | 13 | Tokyo | -| 13 | 14 | Kanagawa | -| 14 | 15 | Niigata | -| 15 | 16 | Toyama | -| 16 | 17 | Ishikawa | -| 17 | 18 | Fukui | -| 18 | 19 | Yamanashi | -| 19 | 20 | Nagano | -| 20 | 21 | Gifu | -| 21 | 22 | Shizuoka | -| 22 | 23 | Aichi | -| 23 | 24 | Mie | -| 24 | 25 | Shiga | -| 25 | 26 | Kyoto | -| 26 | 27 | Osaka | -| 27 | 28 | Hyogo | -| 28 | 29 | Nara | -| 29 | 30 | Wakayama | -| 30 | 31 | Tottori | -| 31 | 32 | Shimane | -| 32 | 33 | Okayama | -| 33 | 34 | Hiroshima | -| 34 | 35 | Yamaguchi | -| 35 | 36 | Tokushima | -| 36 | 37 | Kagawa | -| 37 | 38 | Ehime | -| 38 | 39 | Kochi | -| 39 | 40 | Fukuoka | -| 40 | 41 | Saga | -| 41 | 42 | Nagasaki | -| 42 | 43 | Kumamoto | -| 43 | 44 | Oita | -| 44 | 45 | Miyazaki | -| 45 | 46 | Kagoshima | -| 46 | 47 | Okinawa | +| 1 | 01 | Hokkaido | +| 2 | 02 | Aomori | +| 3 | 03 | Iwate | +| 4 | 04 | Miyagi | +| 5 | 05 | Akita | +| 6 | 06 | Yamagata | +| 7 | 07 | Fukushima | +| 8 | 08 | Ibaraki | +| 9 | 09 | Tochigi | +| 10 | 10 | Gunma | +| 11 | 11 | Saitama | +| 12 | 12 | Chiba | +| 13 | 13 | Tokyo | +| 14 | 14 | Kanagawa | +| 15 | 15 | Niigata | +| 16 | 16 | Toyama | +| 17 | 17 | Ishikawa | +| 18 | 18 | Fukui | +| 19 | 19 | Yamanashi | +| 20 | 20 | Nagano | +| 21 | 21 | Gifu | +| 22 | 22 | Shizuoka | +| 23 | 23 | Aichi | +| 24 | 24 | Mie | +| 25 | 25 | Shiga | +| 26 | 26 | Kyoto | +| 27 | 27 | Osaka | +| 28 | 28 | Hyogo | +| 29 | 29 | Nara | +| 30 | 30 | Wakayama | +| 31 | 31 | Tottori | +| 32 | 32 | Shimane | +| 33 | 33 | Okayama | +| 34 | 34 | Hiroshima | +| 35 | 35 | Yamaguchi | +| 36 | 36 | Tokushima | +| 37 | 37 | Kagawa | +| 38 | 38 | Ehime | +| 39 | 39 | Kochi | +| 40 | 40 | Fukuoka | +| 41 | 41 | Saga | +| 42 | 42 | Nagasaki | +| 43 | 43 | Kumamoto | +| 44 | 44 | Oita | +| 45 | 45 | Miyazaki | +| 46 | 46 | Kagoshima | +| 47 | 47 | Okinawa | **Total subdivisions:** 47 diff --git a/src/swarms/doc/iso3166-2/410-South_Korea.md b/src/swarms/doc/iso3166-2/410-South_Korea.md index 33d4cec6..fc145bc4 100644 --- a/src/swarms/doc/iso3166-2/410-South_Korea.md +++ b/src/swarms/doc/iso3166-2/410-South_Korea.md @@ -4,24 +4,24 @@ ISO 3166-1 numeric: **410** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | 11 | Seoul | -| 1 | 26 | Busan | -| 2 | 27 | Daegu | -| 3 | 28 | Incheon | -| 4 | 29 | Gwangju | -| 5 | 30 | Daejeon | -| 6 | 31 | Ulsan | -| 7 | 41 | Gyeonggi | -| 8 | 42 | Gangwon | -| 9 | 43 | North Chungcheong | -| 10 | 44 | South Chungcheong | -| 11 | 45 | North Jeolla | -| 12 | 46 | South Jeolla | -| 13 | 47 | North Gyeongsang | -| 14 | 48 | South Gyeongsang | -| 15 | 49 | Jeju | -| 16 | 50 | Sejong | +| 1 | 11 | Seoul | +| 2 | 26 | Busan | +| 3 | 27 | Daegu | +| 4 | 28 | Incheon | +| 5 | 29 | Gwangju | +| 6 | 30 | Daejeon | +| 7 | 31 | Ulsan | +| 8 | 41 | Gyeonggi | +| 9 | 42 | Gangwon | +| 10 | 43 | North Chungcheong | +| 11 | 44 | South Chungcheong | +| 12 | 45 | North Jeolla | +| 13 | 46 | South Jeolla | +| 14 | 47 | North Gyeongsang | +| 15 | 48 | South Gyeongsang | +| 16 | 49 | Jeju | +| 17 | 50 | Sejong | **Total subdivisions:** 17 diff --git a/src/swarms/doc/iso3166-2/484-Mexico.md b/src/swarms/doc/iso3166-2/484-Mexico.md index c6dc84da..61b384f3 100644 --- a/src/swarms/doc/iso3166-2/484-Mexico.md +++ b/src/swarms/doc/iso3166-2/484-Mexico.md @@ -4,39 +4,39 @@ ISO 3166-1 numeric: **484** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AGU | Aguascalientes | -| 1 | BCN | Baja California | -| 2 | BCS | Baja California Sur | -| 3 | CAM | Campeche | -| 4 | CHP | Chiapas | -| 5 | CHH | Chihuahua | -| 6 | CMX | Mexico City | -| 7 | COA | Coahuila | -| 8 | COL | Colima | -| 9 | DUR | Durango | -| 10 | GUA | Guanajuato | -| 11 | GRO | Guerrero | -| 12 | HID | Hidalgo | -| 13 | JAL | Jalisco | -| 14 | MEX | State of Mexico | -| 15 | MIC | Michoacán | -| 16 | MOR | Morelos | -| 17 | NAY | Nayarit | -| 18 | NLE | Nuevo León | -| 19 | OAX | Oaxaca | -| 20 | PUE | Puebla | -| 21 | QUE | Querétaro | -| 22 | ROO | Quintana Roo | -| 23 | SLP | San Luis Potosí | -| 24 | SIN | Sinaloa | -| 25 | SON | Sonora | -| 26 | TAB | Tabasco | -| 27 | TAM | Tamaulipas | -| 28 | TLA | Tlaxcala | -| 29 | VER | Veracruz | -| 30 | YUC | Yucatán | -| 31 | ZAC | Zacatecas | +| 1 | AGU | Aguascalientes | +| 2 | BCN | Baja California | +| 3 | BCS | Baja California Sur | +| 4 | CAM | Campeche | +| 5 | CHP | Chiapas | +| 6 | CHH | Chihuahua | +| 7 | CMX | Mexico City | +| 8 | COA | Coahuila | +| 9 | COL | Colima | +| 10 | DUR | Durango | +| 11 | GUA | Guanajuato | +| 12 | GRO | Guerrero | +| 13 | HID | Hidalgo | +| 14 | JAL | Jalisco | +| 15 | MEX | State of Mexico | +| 16 | MIC | Michoacán | +| 17 | MOR | Morelos | +| 18 | NAY | Nayarit | +| 19 | NLE | Nuevo León | +| 20 | OAX | Oaxaca | +| 21 | PUE | Puebla | +| 22 | QUE | Querétaro | +| 23 | ROO | Quintana Roo | +| 24 | SLP | San Luis Potosí | +| 25 | SIN | Sinaloa | +| 26 | SON | Sonora | +| 27 | TAB | Tabasco | +| 28 | TAM | Tamaulipas | +| 29 | TLA | Tlaxcala | +| 30 | VER | Veracruz | +| 31 | YUC | Yucatán | +| 32 | ZAC | Zacatecas | **Total subdivisions:** 32 diff --git a/src/swarms/doc/iso3166-2/566-Nigeria.md b/src/swarms/doc/iso3166-2/566-Nigeria.md index 5c16c422..83b523c2 100644 --- a/src/swarms/doc/iso3166-2/566-Nigeria.md +++ b/src/swarms/doc/iso3166-2/566-Nigeria.md @@ -4,44 +4,44 @@ ISO 3166-1 numeric: **566** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AB | Abia | -| 1 | FC | Abuja Federal Capital Territory | -| 2 | AD | Adamawa | -| 3 | AK | Akwa Ibom | -| 4 | AN | Anambra | -| 5 | BA | Bauchi | -| 6 | BY | Bayelsa | -| 7 | BE | Benue | -| 8 | BO | Borno | -| 9 | CR | Cross River | -| 10 | DE | Delta | -| 11 | EB | Ebonyi | -| 12 | ED | Edo | -| 13 | EK | Ekiti | -| 14 | EN | Enugu | -| 15 | GO | Gombe | -| 16 | IM | Imo | -| 17 | JI | Jigawa | -| 18 | KD | Kaduna | -| 19 | KN | Kano | -| 20 | KT | Katsina | -| 21 | KE | Kebbi | -| 22 | KO | Kogi | -| 23 | KW | Kwara | -| 24 | LA | Lagos | -| 25 | NA | Nasarawa | -| 26 | NI | Niger | -| 27 | OG | Ogun | -| 28 | ON | Ondo | -| 29 | OS | Osun | -| 30 | OY | Oyo | -| 31 | PL | Plateau | -| 32 | RI | Rivers | -| 33 | SO | Sokoto | -| 34 | TA | Taraba | -| 35 | YO | Yobe | -| 36 | ZA | Zamfara | +| 1 | AB | Abia | +| 2 | FC | Abuja Federal Capital Territory | +| 3 | AD | Adamawa | +| 4 | AK | Akwa Ibom | +| 5 | AN | Anambra | +| 6 | BA | Bauchi | +| 7 | BY | Bayelsa | +| 8 | BE | Benue | +| 9 | BO | Borno | +| 10 | CR | Cross River | +| 11 | DE | Delta | +| 12 | EB | Ebonyi | +| 13 | ED | Edo | +| 14 | EK | Ekiti | +| 15 | EN | Enugu | +| 16 | GO | Gombe | +| 17 | IM | Imo | +| 18 | JI | Jigawa | +| 19 | KD | Kaduna | +| 20 | KN | Kano | +| 21 | KT | Katsina | +| 22 | KE | Kebbi | +| 23 | KO | Kogi | +| 24 | KW | Kwara | +| 25 | LA | Lagos | +| 26 | NA | Nasarawa | +| 27 | NI | Niger | +| 28 | OG | Ogun | +| 29 | ON | Ondo | +| 30 | OS | Osun | +| 31 | OY | Oyo | +| 32 | PL | Plateau | +| 33 | RI | Rivers | +| 34 | SO | Sokoto | +| 35 | TA | Taraba | +| 36 | YO | Yobe | +| 37 | ZA | Zamfara | **Total subdivisions:** 37 diff --git a/src/swarms/doc/iso3166-2/643-Russia.md b/src/swarms/doc/iso3166-2/643-Russia.md index b0ee9307..0705c6c6 100644 --- a/src/swarms/doc/iso3166-2/643-Russia.md +++ b/src/swarms/doc/iso3166-2/643-Russia.md @@ -4,90 +4,90 @@ ISO 3166-1 numeric: **643** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AD | Adygea, Republic of | -| 1 | AL | Altai Republic | -| 2 | ALT | Altai Krai | -| 3 | AMU | Amur Oblast | -| 4 | ARK | Arkhangelsk Oblast | -| 5 | AST | Astrakhan Oblast | -| 6 | BA | Bashkortostan, Republic of | -| 7 | BEL | Belgorod Oblast | -| 8 | BRY | Bryansk Oblast | -| 9 | BU | Buryatia, Republic of | -| 10 | CE | Chechen Republic | -| 11 | CHE | Chelyabinsk Oblast | -| 12 | CHU | Chukotka Autonomous Okrug | -| 13 | CU | Chuvash Republic | -| 14 | DA | Dagestan, Republic of | -| 15 | IN | Ingushetia, Republic of | -| 16 | IRK | Irkutsk Oblast | -| 17 | IVA | Ivanovo Oblast | -| 18 | KB | Kabardino-Balkar Republic | -| 19 | KGD | Kaliningrad Oblast | -| 20 | KL | Kalmykia, Republic of | -| 21 | KLU | Kaluga Oblast | -| 22 | KAM | Kamchatka Krai | -| 23 | KC | Karachay-Cherkess Republic | -| 24 | KR | Karelia, Republic of | -| 25 | KEM | Kemerovo Oblast | -| 26 | KHA | Khabarovsk Krai | -| 27 | KK | Khakassia, Republic of | -| 28 | KHM | Khanty-Mansi Autonomous Okrug | -| 29 | KIR | Kirov Oblast | -| 30 | KO | Komi Republic | -| 31 | KOS | Kostroma Oblast | -| 32 | KDA | Krasnodar Krai | -| 33 | KYA | Krasnoyarsk Krai | -| 34 | KGN | Kurgan Oblast | -| 35 | KRS | Kursk Oblast | -| 36 | LEN | Leningrad Oblast | -| 37 | LIP | Lipetsk Oblast | -| 38 | MAG | Magadan Oblast | -| 39 | ME | Mari El Republic | -| 40 | MO | Mordovia, Republic of | -| 41 | MOS | Moscow Oblast | -| 42 | MOW | Moscow | -| 43 | MUR | Murmansk Oblast | -| 44 | NEN | Nenets Autonomous Okrug | -| 45 | NIZ | Nizhny Novgorod Oblast | -| 46 | NGR | Novgorod Oblast | -| 47 | NVS | Novosibirsk Oblast | -| 48 | OMS | Omsk Oblast | -| 49 | ORE | Orenburg Oblast | -| 50 | ORL | Oryol Oblast | -| 51 | PNZ | Penza Oblast | -| 52 | PER | Perm Krai | -| 53 | PRI | Primorsky Krai | -| 54 | PSK | Pskov Oblast | -| 55 | ROS | Rostov Oblast | -| 56 | RYA | Ryazan Oblast | -| 57 | SA | Sakha (Yakutia), Republic of | -| 58 | SAK | Sakhalin Oblast | -| 59 | SAM | Samara Oblast | -| 60 | SPE | Saint Petersburg | -| 61 | SAR | Saratov Oblast | -| 62 | SE | North Ossetia-Alania, Republic of | -| 63 | SMO | Smolensk Oblast | -| 64 | STA | Stavropol Krai | -| 65 | SVE | Sverdlovsk Oblast | -| 66 | TAM | Tambov Oblast | -| 67 | TA | Tatarstan, Republic of | -| 68 | TOM | Tomsk Oblast | -| 69 | TUL | Tula Oblast | -| 70 | TVE | Tver Oblast | -| 71 | TY | Tuva Republic | -| 72 | TYU | Tyumen Oblast | -| 73 | UD | Udmurt Republic | -| 74 | ULY | Ulyanovsk Oblast | -| 75 | VLA | Vladimir Oblast | -| 76 | VGG | Volgograd Oblast | -| 77 | VLG | Vologda Oblast | -| 78 | VOR | Voronezh Oblast | -| 79 | YAN | Yamalo-Nenets Autonomous Okrug | -| 80 | YAR | Yaroslavl Oblast | -| 81 | YEV | Jewish Autonomous Oblast | -| 82 | ZAB | Zabaykalsky Krai | +| 1 | AD | Adygea, Republic of | +| 2 | AL | Altai Republic | +| 3 | ALT | Altai Krai | +| 4 | AMU | Amur Oblast | +| 5 | ARK | Arkhangelsk Oblast | +| 6 | AST | Astrakhan Oblast | +| 7 | BA | Bashkortostan, Republic of | +| 8 | BEL | Belgorod Oblast | +| 9 | BRY | Bryansk Oblast | +| 10 | BU | Buryatia, Republic of | +| 11 | CE | Chechen Republic | +| 12 | CHE | Chelyabinsk Oblast | +| 13 | CHU | Chukotka Autonomous Okrug | +| 14 | CU | Chuvash Republic | +| 15 | DA | Dagestan, Republic of | +| 16 | IN | Ingushetia, Republic of | +| 17 | IRK | Irkutsk Oblast | +| 18 | IVA | Ivanovo Oblast | +| 19 | KB | Kabardino-Balkar Republic | +| 20 | KGD | Kaliningrad Oblast | +| 21 | KL | Kalmykia, Republic of | +| 22 | KLU | Kaluga Oblast | +| 23 | KAM | Kamchatka Krai | +| 24 | KC | Karachay-Cherkess Republic | +| 25 | KR | Karelia, Republic of | +| 26 | KEM | Kemerovo Oblast | +| 27 | KHA | Khabarovsk Krai | +| 28 | KK | Khakassia, Republic of | +| 29 | KHM | Khanty-Mansi Autonomous Okrug | +| 30 | KIR | Kirov Oblast | +| 31 | KO | Komi Republic | +| 32 | KOS | Kostroma Oblast | +| 33 | KDA | Krasnodar Krai | +| 34 | KYA | Krasnoyarsk Krai | +| 35 | KGN | Kurgan Oblast | +| 36 | KRS | Kursk Oblast | +| 37 | LEN | Leningrad Oblast | +| 38 | LIP | Lipetsk Oblast | +| 39 | MAG | Magadan Oblast | +| 40 | ME | Mari El Republic | +| 41 | MO | Mordovia, Republic of | +| 42 | MOS | Moscow Oblast | +| 43 | MOW | Moscow | +| 44 | MUR | Murmansk Oblast | +| 45 | NEN | Nenets Autonomous Okrug | +| 46 | NIZ | Nizhny Novgorod Oblast | +| 47 | NGR | Novgorod Oblast | +| 48 | NVS | Novosibirsk Oblast | +| 49 | OMS | Omsk Oblast | +| 50 | ORE | Orenburg Oblast | +| 51 | ORL | Oryol Oblast | +| 52 | PNZ | Penza Oblast | +| 53 | PER | Perm Krai | +| 54 | PRI | Primorsky Krai | +| 55 | PSK | Pskov Oblast | +| 56 | ROS | Rostov Oblast | +| 57 | RYA | Ryazan Oblast | +| 58 | SA | Sakha (Yakutia), Republic of | +| 59 | SAK | Sakhalin Oblast | +| 60 | SAM | Samara Oblast | +| 61 | SPE | Saint Petersburg | +| 62 | SAR | Saratov Oblast | +| 63 | SE | North Ossetia-Alania, Republic of | +| 64 | SMO | Smolensk Oblast | +| 65 | STA | Stavropol Krai | +| 66 | SVE | Sverdlovsk Oblast | +| 67 | TAM | Tambov Oblast | +| 68 | TA | Tatarstan, Republic of | +| 69 | TOM | Tomsk Oblast | +| 70 | TUL | Tula Oblast | +| 71 | TVE | Tver Oblast | +| 72 | TY | Tuva Republic | +| 73 | TYU | Tyumen Oblast | +| 74 | UD | Udmurt Republic | +| 75 | ULY | Ulyanovsk Oblast | +| 76 | VLA | Vladimir Oblast | +| 77 | VGG | Volgograd Oblast | +| 78 | VLG | Vologda Oblast | +| 79 | VOR | Voronezh Oblast | +| 80 | YAN | Yamalo-Nenets Autonomous Okrug | +| 81 | YAR | Yaroslavl Oblast | +| 82 | YEV | Jewish Autonomous Oblast | +| 83 | ZAB | Zabaykalsky Krai | **Total subdivisions:** 83 diff --git a/src/swarms/doc/iso3166-2/710-South_Africa.md b/src/swarms/doc/iso3166-2/710-South_Africa.md index 67d256d6..99a19bd7 100644 --- a/src/swarms/doc/iso3166-2/710-South_Africa.md +++ b/src/swarms/doc/iso3166-2/710-South_Africa.md @@ -4,16 +4,16 @@ ISO 3166-1 numeric: **710** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | EC | Eastern Cape | -| 1 | FS | Free State | -| 2 | GT | Gauteng | -| 3 | NL | KwaZulu-Natal | -| 4 | LP | Limpopo | -| 5 | MP | Mpumalanga | -| 6 | NW | North West | -| 7 | NC | Northern Cape | -| 8 | WC | Western Cape | +| 1 | EC | Eastern Cape | +| 2 | FS | Free State | +| 3 | GT | Gauteng | +| 4 | NL | KwaZulu-Natal | +| 5 | LP | Limpopo | +| 6 | MP | Mpumalanga | +| 7 | NW | North West | +| 8 | NC | Northern Cape | +| 9 | WC | Western Cape | **Total subdivisions:** 9 diff --git a/src/swarms/doc/iso3166-2/724-Spain.md b/src/swarms/doc/iso3166-2/724-Spain.md index 22f90ce5..6c435504 100644 --- a/src/swarms/doc/iso3166-2/724-Spain.md +++ b/src/swarms/doc/iso3166-2/724-Spain.md @@ -4,26 +4,26 @@ ISO 3166-1 numeric: **724** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AN | Andalusia | -| 1 | AR | Aragon | -| 2 | AS | Asturias, Principality of | -| 3 | CN | Canary Islands | -| 4 | CB | Cantabria | -| 5 | CL | Castile and León | -| 6 | CM | Castilla-La Mancha | -| 7 | CT | Catalonia | -| 8 | CE | Ceuta | -| 9 | EX | Extremadura | -| 10 | GA | Galicia | -| 11 | IB | Balearic Islands | -| 12 | RI | La Rioja | -| 13 | MD | Community of Madrid | -| 14 | ML | Melilla | -| 15 | MC | Murcia, Region of | -| 16 | NC | Navarre, Chartered Community of | -| 17 | PV | Basque Country | -| 18 | VC | Valencian Community | +| 1 | AN | Andalusia | +| 2 | AR | Aragon | +| 3 | AS | Asturias, Principality of | +| 4 | CN | Canary Islands | +| 5 | CB | Cantabria | +| 6 | CL | Castile and León | +| 7 | CM | Castilla-La Mancha | +| 8 | CT | Catalonia | +| 9 | CE | Ceuta | +| 10 | EX | Extremadura | +| 11 | GA | Galicia | +| 12 | IB | Balearic Islands | +| 13 | RI | La Rioja | +| 14 | MD | Community of Madrid | +| 15 | ML | Melilla | +| 16 | MC | Murcia, Region of | +| 17 | NC | Navarre, Chartered Community of | +| 18 | PV | Basque Country | +| 19 | VC | Valencian Community | **Total subdivisions:** 19 diff --git a/src/swarms/doc/iso3166-2/756-Switzerland.md b/src/swarms/doc/iso3166-2/756-Switzerland.md index 3a3cde66..978590ee 100644 --- a/src/swarms/doc/iso3166-2/756-Switzerland.md +++ b/src/swarms/doc/iso3166-2/756-Switzerland.md @@ -4,33 +4,33 @@ ISO 3166-1 numeric: **756** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AG | Aargau | -| 1 | AI | Appenzell Innerrhoden | -| 2 | AR | Appenzell Ausserrhoden | -| 3 | BE | Bern | -| 4 | BL | Basel-Landschaft | -| 5 | BS | Basel-Stadt | -| 6 | FR | Fribourg | -| 7 | GE | Geneva | -| 8 | GL | Glarus | -| 9 | GR | Graubünden | -| 10 | JU | Jura | -| 11 | LU | Lucerne | -| 12 | NE | Neuchâtel | -| 13 | NW | Nidwalden | -| 14 | OW | Obwalden | -| 15 | SG | St. Gallen | -| 16 | SH | Schaffhausen | -| 17 | SO | Solothurn | -| 18 | SZ | Schwyz | -| 19 | TG | Thurgau | -| 20 | TI | Ticino | -| 21 | UR | Uri | -| 22 | VD | Vaud | -| 23 | VS | Valais | -| 24 | ZG | Zug | -| 25 | ZH | Zurich | +| 1 | AG | Aargau | +| 2 | AI | Appenzell Innerrhoden | +| 3 | AR | Appenzell Ausserrhoden | +| 4 | BE | Bern | +| 5 | BL | Basel-Landschaft | +| 6 | BS | Basel-Stadt | +| 7 | FR | Fribourg | +| 8 | GE | Geneva | +| 9 | GL | Glarus | +| 10 | GR | Graubünden | +| 11 | JU | Jura | +| 12 | LU | Lucerne | +| 13 | NE | Neuchâtel | +| 14 | NW | Nidwalden | +| 15 | OW | Obwalden | +| 16 | SG | St. Gallen | +| 17 | SH | Schaffhausen | +| 18 | SO | Solothurn | +| 19 | SZ | Schwyz | +| 20 | TG | Thurgau | +| 21 | TI | Ticino | +| 22 | UR | Uri | +| 23 | VD | Vaud | +| 24 | VS | Valais | +| 25 | ZG | Zug | +| 26 | ZH | Zurich | **Total subdivisions:** 26 diff --git a/src/swarms/doc/iso3166-2/826-United_Kingdom.md b/src/swarms/doc/iso3166-2/826-United_Kingdom.md index ed7226ca..96eea194 100644 --- a/src/swarms/doc/iso3166-2/826-United_Kingdom.md +++ b/src/swarms/doc/iso3166-2/826-United_Kingdom.md @@ -4,179 +4,179 @@ ISO 3166-1 numeric: **826** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | ENG | England | -| 1 | NIR | Northern Ireland | -| 2 | SCT | Scotland | -| 3 | WLS | Wales | -| 4 | BKM | Buckinghamshire | -| 5 | CAM | Cambridgeshire | -| 6 | CMA | Cumbria | -| 7 | DBY | Derbyshire | -| 8 | DEV | Devon | -| 9 | DOR | Dorset | -| 10 | ESX | East Sussex | -| 11 | ESS | Essex | -| 12 | GLS | Gloucestershire | -| 13 | HAM | Hampshire | -| 14 | HRT | Hertfordshire | -| 15 | KEN | Kent | -| 16 | LAN | Lancashire | -| 17 | LEC | Leicestershire | -| 18 | LIN | Lincolnshire | -| 19 | NFK | Norfolk | -| 20 | NYK | North Yorkshire | -| 21 | NTH | Northamptonshire | -| 22 | NTT | Nottinghamshire | -| 23 | OXF | Oxfordshire | -| 24 | SOM | Somerset | -| 25 | STS | Staffordshire | -| 26 | SFK | Suffolk | -| 27 | SRY | Surrey | -| 28 | WAR | Warwickshire | -| 29 | WSX | West Sussex | -| 30 | WOR | Worcestershire | -| 31 | LND | London, City of | -| 32 | BDG | Barking and Dagenham | -| 33 | BNE | Barnet | -| 34 | BEX | Bexley | -| 35 | BEN | Brent | -| 36 | BRY | Bromley | -| 37 | CMD | Camden | -| 38 | CRY | Croydon | -| 39 | EAL | Ealing | -| 40 | ENF | Enfield | -| 41 | GRE | Greenwich | -| 42 | HCK | Hackney | -| 43 | HMF | Hammersmith and Fulham | -| 44 | HRY | Haringey | -| 45 | HRW | Harrow | -| 46 | HAV | Havering | -| 47 | HIL | Hillingdon | -| 48 | HNS | Hounslow | -| 49 | ISL | Islington | -| 50 | KEC | Kensington and Chelsea | -| 51 | KTT | Kingston upon Thames | -| 52 | LBH | Lambeth | -| 53 | LEW | Lewisham | -| 54 | MRT | Merton | -| 55 | NWM | Newham | -| 56 | RDB | Redbridge | -| 57 | RIC | Richmond upon Thames | -| 58 | SWK | Southwark | -| 59 | STN | Sutton | -| 60 | TWH | Tower Hamlets | -| 61 | WFT | Waltham Forest | -| 62 | WND | Wandsworth | -| 63 | WSM | Westminster | -| 64 | BNS | Barnsley | -| 65 | BIR | Birmingham | -| 66 | BOL | Bolton | -| 67 | BRD | Bradford | -| 68 | BRI | Brighton and Hove | -| 69 | BST | Bristol, City of | -| 70 | CAL | Calderdale | -| 71 | COV | Coventry | -| 72 | DER | Derby | -| 73 | DUD | Dudley | -| 74 | GAT | Gateshead | -| 75 | KIR | Kirklees | -| 76 | KWL | Knowsley | -| 77 | LDS | Leeds | -| 78 | LCE | Leicester | -| 79 | LIV | Liverpool | -| 80 | MAN | Manchester | -| 81 | NET | Newcastle upon Tyne | -| 82 | NTY | North Tyneside | -| 83 | OLD | Oldham | -| 84 | PTE | Peterborough | -| 85 | PLY | Plymouth | -| 86 | RCH | Rochdale | -| 87 | ROT | Rotherham | -| 88 | SLF | Salford | -| 89 | SAW | Sandwell | -| 90 | SFT | Sefton | -| 91 | SHF | Sheffield | -| 92 | SOL | Solihull | -| 93 | STY | South Tyneside | -| 94 | SHN | Southampton | -| 95 | SGC | South Gloucestershire | -| 96 | STH | Southend-on-Sea | -| 97 | SKP | Stockport | -| 98 | STE | Stoke-on-Trent | -| 99 | SND | Sunderland | -| 100 | TAM | Tameside | -| 101 | TRF | Trafford | -| 102 | WKF | Wakefield | -| 103 | WLL | Walsall | -| 104 | WGN | Wigan | -| 105 | WRL | Wirral | -| 106 | WLV | Wolverhampton | -| 107 | ABE | Aberdeen City | -| 108 | ABD | Aberdeenshire | -| 109 | ANS | Angus | -| 110 | AGB | Argyll and Bute | -| 111 | CLK | Clackmannanshire | -| 112 | DGY | Dumfries and Galloway | -| 113 | DND | Dundee City | -| 114 | EAY | East Ayrshire | -| 115 | EDU | East Dunbartonshire | -| 116 | ELN | East Lothian | -| 117 | ERW | East Renfrewshire | -| 118 | EDH | Edinburgh, City of | -| 119 | ELS | Eilean Siar | -| 120 | FAL | Falkirk | -| 121 | FIF | Fife | -| 122 | GLG | Glasgow City | -| 123 | HLD | Highland | -| 124 | IVC | Inverclyde | -| 125 | MLN | Midlothian | -| 126 | MRY | Moray | -| 127 | NAY | North Ayrshire | -| 128 | NLK | North Lanarkshire | -| 129 | ORK | Orkney Islands | -| 130 | PKN | Perth and Kinross | -| 131 | RFW | Renfrewshire | -| 132 | SCB | Scottish Borders | -| 133 | ZET | Shetland Islands | -| 134 | SAY | South Ayrshire | -| 135 | SLK | South Lanarkshire | -| 136 | STG | Stirling | -| 137 | WDU | West Dunbartonshire | -| 138 | WLN | West Lothian | -| 139 | BGW | Blaenau Gwent | -| 140 | BGE | Bridgend | -| 141 | CAY | Caerphilly | -| 142 | CRF | Cardiff | -| 143 | CMN | Carmarthenshire | -| 144 | CGN | Ceredigion | -| 145 | CWY | Conwy | -| 146 | DEN | Denbighshire | -| 147 | FLN | Flintshire | -| 148 | GWN | Gwynedd | -| 149 | AGY | Isle of Anglesey | -| 150 | MTY | Merthyr Tydfil | -| 151 | MON | Monmouthshire | -| 152 | NTL | Neath Port Talbot | -| 153 | NWP | Newport | -| 154 | PEM | Pembrokeshire | -| 155 | POW | Powys | -| 156 | RCT | Rhondda Cynon Taf | -| 157 | SWA | Swansea | -| 158 | TOF | Torfaen | -| 159 | VGL | Vale of Glamorgan | -| 160 | WRX | Wrexham | -| 161 | ANT | Antrim and Newtownabbey | -| 162 | ARD | Ards and North Down | -| 163 | ABC | Armagh City, Banbridge and Craigavon | -| 164 | BFS | Belfast | -| 165 | CCG | Causeway Coast and Glens | -| 166 | DRS | Derry City and Strabane | -| 167 | FMO | Fermanagh and Omagh | -| 168 | LBC | Lisburn and Castlereagh | -| 169 | MEA | Mid and East Antrim | -| 170 | MUL | Mid Ulster | -| 171 | NMD | Newry, Mourne and Down | +| 1 | ENG | England | +| 2 | NIR | Northern Ireland | +| 3 | SCT | Scotland | +| 4 | WLS | Wales | +| 5 | BKM | Buckinghamshire | +| 6 | CAM | Cambridgeshire | +| 7 | CMA | Cumbria | +| 8 | DBY | Derbyshire | +| 9 | DEV | Devon | +| 10 | DOR | Dorset | +| 11 | ESX | East Sussex | +| 12 | ESS | Essex | +| 13 | GLS | Gloucestershire | +| 14 | HAM | Hampshire | +| 15 | HRT | Hertfordshire | +| 16 | KEN | Kent | +| 17 | LAN | Lancashire | +| 18 | LEC | Leicestershire | +| 19 | LIN | Lincolnshire | +| 20 | NFK | Norfolk | +| 21 | NYK | North Yorkshire | +| 22 | NTH | Northamptonshire | +| 23 | NTT | Nottinghamshire | +| 24 | OXF | Oxfordshire | +| 25 | SOM | Somerset | +| 26 | STS | Staffordshire | +| 27 | SFK | Suffolk | +| 28 | SRY | Surrey | +| 29 | WAR | Warwickshire | +| 30 | WSX | West Sussex | +| 31 | WOR | Worcestershire | +| 32 | LND | London, City of | +| 33 | BDG | Barking and Dagenham | +| 34 | BNE | Barnet | +| 35 | BEX | Bexley | +| 36 | BEN | Brent | +| 37 | BRY | Bromley | +| 38 | CMD | Camden | +| 39 | CRY | Croydon | +| 40 | EAL | Ealing | +| 41 | ENF | Enfield | +| 42 | GRE | Greenwich | +| 43 | HCK | Hackney | +| 44 | HMF | Hammersmith and Fulham | +| 45 | HRY | Haringey | +| 46 | HRW | Harrow | +| 47 | HAV | Havering | +| 48 | HIL | Hillingdon | +| 49 | HNS | Hounslow | +| 50 | ISL | Islington | +| 51 | KEC | Kensington and Chelsea | +| 52 | KTT | Kingston upon Thames | +| 53 | LBH | Lambeth | +| 54 | LEW | Lewisham | +| 55 | MRT | Merton | +| 56 | NWM | Newham | +| 57 | RDB | Redbridge | +| 58 | RIC | Richmond upon Thames | +| 59 | SWK | Southwark | +| 60 | STN | Sutton | +| 61 | TWH | Tower Hamlets | +| 62 | WFT | Waltham Forest | +| 63 | WND | Wandsworth | +| 64 | WSM | Westminster | +| 65 | BNS | Barnsley | +| 66 | BIR | Birmingham | +| 67 | BOL | Bolton | +| 68 | BRD | Bradford | +| 69 | BRI | Brighton and Hove | +| 70 | BST | Bristol, City of | +| 71 | CAL | Calderdale | +| 72 | COV | Coventry | +| 73 | DER | Derby | +| 74 | DUD | Dudley | +| 75 | GAT | Gateshead | +| 76 | KIR | Kirklees | +| 77 | KWL | Knowsley | +| 78 | LDS | Leeds | +| 79 | LCE | Leicester | +| 80 | LIV | Liverpool | +| 81 | MAN | Manchester | +| 82 | NET | Newcastle upon Tyne | +| 83 | NTY | North Tyneside | +| 84 | OLD | Oldham | +| 85 | PTE | Peterborough | +| 86 | PLY | Plymouth | +| 87 | RCH | Rochdale | +| 88 | ROT | Rotherham | +| 89 | SLF | Salford | +| 90 | SAW | Sandwell | +| 91 | SFT | Sefton | +| 92 | SHF | Sheffield | +| 93 | SOL | Solihull | +| 94 | STY | South Tyneside | +| 95 | SHN | Southampton | +| 96 | SGC | South Gloucestershire | +| 97 | STH | Southend-on-Sea | +| 98 | SKP | Stockport | +| 99 | STE | Stoke-on-Trent | +| 100 | SND | Sunderland | +| 101 | TAM | Tameside | +| 102 | TRF | Trafford | +| 103 | WKF | Wakefield | +| 104 | WLL | Walsall | +| 105 | WGN | Wigan | +| 106 | WRL | Wirral | +| 107 | WLV | Wolverhampton | +| 108 | ABE | Aberdeen City | +| 109 | ABD | Aberdeenshire | +| 110 | ANS | Angus | +| 111 | AGB | Argyll and Bute | +| 112 | CLK | Clackmannanshire | +| 113 | DGY | Dumfries and Galloway | +| 114 | DND | Dundee City | +| 115 | EAY | East Ayrshire | +| 116 | EDU | East Dunbartonshire | +| 117 | ELN | East Lothian | +| 118 | ERW | East Renfrewshire | +| 119 | EDH | Edinburgh, City of | +| 120 | ELS | Eilean Siar | +| 121 | FAL | Falkirk | +| 122 | FIF | Fife | +| 123 | GLG | Glasgow City | +| 124 | HLD | Highland | +| 125 | IVC | Inverclyde | +| 126 | MLN | Midlothian | +| 127 | MRY | Moray | +| 128 | NAY | North Ayrshire | +| 129 | NLK | North Lanarkshire | +| 130 | ORK | Orkney Islands | +| 131 | PKN | Perth and Kinross | +| 132 | RFW | Renfrewshire | +| 133 | SCB | Scottish Borders | +| 134 | ZET | Shetland Islands | +| 135 | SAY | South Ayrshire | +| 136 | SLK | South Lanarkshire | +| 137 | STG | Stirling | +| 138 | WDU | West Dunbartonshire | +| 139 | WLN | West Lothian | +| 140 | BGW | Blaenau Gwent | +| 141 | BGE | Bridgend | +| 142 | CAY | Caerphilly | +| 143 | CRF | Cardiff | +| 144 | CMN | Carmarthenshire | +| 145 | CGN | Ceredigion | +| 146 | CWY | Conwy | +| 147 | DEN | Denbighshire | +| 148 | FLN | Flintshire | +| 149 | GWN | Gwynedd | +| 150 | AGY | Isle of Anglesey | +| 151 | MTY | Merthyr Tydfil | +| 152 | MON | Monmouthshire | +| 153 | NTL | Neath Port Talbot | +| 154 | NWP | Newport | +| 155 | PEM | Pembrokeshire | +| 156 | POW | Powys | +| 157 | RCT | Rhondda Cynon Taf | +| 158 | SWA | Swansea | +| 159 | TOF | Torfaen | +| 160 | VGL | Vale of Glamorgan | +| 161 | WRX | Wrexham | +| 162 | ANT | Antrim and Newtownabbey | +| 163 | ARD | Ards and North Down | +| 164 | ABC | Armagh City, Banbridge and Craigavon | +| 165 | BFS | Belfast | +| 166 | CCG | Causeway Coast and Glens | +| 167 | DRS | Derry City and Strabane | +| 168 | FMO | Fermanagh and Omagh | +| 169 | LBC | Lisburn and Castlereagh | +| 170 | MEA | Mid and East Antrim | +| 171 | MUL | Mid Ulster | +| 172 | NMD | Newry, Mourne and Down | **Total subdivisions:** 172 diff --git a/src/swarms/doc/iso3166-2/840-United_States.md b/src/swarms/doc/iso3166-2/840-United_States.md index 7d0e4972..1259bd49 100644 --- a/src/swarms/doc/iso3166-2/840-United_States.md +++ b/src/swarms/doc/iso3166-2/840-United_States.md @@ -4,64 +4,64 @@ ISO 3166-1 numeric: **840** ## Admin Area Mappings -| Dense Index | ISO 3166-2 | Name | +| Admin Code | ISO 3166-2 | Name | |-------------|------------|------| -| 0 | AL | Alabama | -| 1 | AK | Alaska | -| 2 | AZ | Arizona | -| 3 | AR | Arkansas | -| 4 | CA | California | -| 5 | CO | Colorado | -| 6 | CT | Connecticut | -| 7 | DE | Delaware | -| 8 | FL | Florida | -| 9 | GA | Georgia | -| 10 | HI | Hawaii | -| 11 | ID | Idaho | -| 12 | IL | Illinois | -| 13 | IN | Indiana | -| 14 | IA | Iowa | -| 15 | KS | Kansas | -| 16 | KY | Kentucky | -| 17 | LA | Louisiana | -| 18 | ME | Maine | -| 19 | MD | Maryland | -| 20 | MA | Massachusetts | -| 21 | MI | Michigan | -| 22 | MN | Minnesota | -| 23 | MS | Mississippi | -| 24 | MO | Missouri | -| 25 | MT | Montana | -| 26 | NE | Nebraska | -| 27 | NV | Nevada | -| 28 | NH | New Hampshire | -| 29 | NJ | New Jersey | -| 30 | NM | New Mexico | -| 31 | NY | New York | -| 32 | NC | North Carolina | -| 33 | ND | North Dakota | -| 34 | OH | Ohio | -| 35 | OK | Oklahoma | -| 36 | OR | Oregon | -| 37 | PA | Pennsylvania | -| 38 | RI | Rhode Island | -| 39 | SC | South Carolina | -| 40 | SD | South Dakota | -| 41 | TN | Tennessee | -| 42 | TX | Texas | -| 43 | UT | Utah | -| 44 | VT | Vermont | -| 45 | VA | Virginia | -| 46 | WA | Washington | -| 47 | WV | West Virginia | -| 48 | WI | Wisconsin | -| 49 | WY | Wyoming | -| 50 | DC | District of Columbia | -| 51 | AS | American Samoa | -| 52 | GU | Guam | -| 53 | MP | Northern Mariana Islands | -| 54 | PR | Puerto Rico | -| 55 | UM | United States Minor Outlying Islands | -| 56 | VI | Virgin Islands, U.S. | +| 1 | AL | Alabama | +| 2 | AK | Alaska | +| 3 | AZ | Arizona | +| 4 | AR | Arkansas | +| 5 | CA | California | +| 6 | CO | Colorado | +| 7 | CT | Connecticut | +| 8 | DE | Delaware | +| 9 | FL | Florida | +| 10 | GA | Georgia | +| 11 | HI | Hawaii | +| 12 | ID | Idaho | +| 13 | IL | Illinois | +| 14 | IN | Indiana | +| 15 | IA | Iowa | +| 16 | KS | Kansas | +| 17 | KY | Kentucky | +| 18 | LA | Louisiana | +| 19 | ME | Maine | +| 20 | MD | Maryland | +| 21 | MA | Massachusetts | +| 22 | MI | Michigan | +| 23 | MN | Minnesota | +| 24 | MS | Mississippi | +| 25 | MO | Missouri | +| 26 | MT | Montana | +| 27 | NE | Nebraska | +| 28 | NV | Nevada | +| 29 | NH | New Hampshire | +| 30 | NJ | New Jersey | +| 31 | NM | New Mexico | +| 32 | NY | New York | +| 33 | NC | North Carolina | +| 34 | ND | North Dakota | +| 35 | OH | Ohio | +| 36 | OK | Oklahoma | +| 37 | OR | Oregon | +| 38 | PA | Pennsylvania | +| 39 | RI | Rhode Island | +| 40 | SC | South Carolina | +| 41 | SD | South Dakota | +| 42 | TN | Tennessee | +| 43 | TX | Texas | +| 44 | UT | Utah | +| 45 | VT | Vermont | +| 46 | VA | Virginia | +| 47 | WA | Washington | +| 48 | WV | West Virginia | +| 49 | WI | Wisconsin | +| 50 | WY | Wyoming | +| 51 | DC | District of Columbia | +| 52 | AS | American Samoa | +| 53 | GU | Guam | +| 54 | MP | Northern Mariana Islands | +| 55 | PR | Puerto Rico | +| 56 | UM | United States Minor Outlying Islands | +| 57 | VI | Virgin Islands, U.S. | **Total subdivisions:** 57 From 56caf50dbaca60425cecf613a82517adda113fba Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Wed, 25 Feb 2026 17:03:05 +1300 Subject: [PATCH 49/63] refactor: remove trivial countryRegionKey, make makeAdminRegion public - Remove countryRegionKey() external function (trivial uint32 cast) - Rename _makeAdminRegion() to makeAdminRegion() for public use - Remove adminRegionKey() wrapper; callers use makeAdminRegion directly - Keep getActiveCountries/getActiveAdminAreas (required for full-array returns) - Update all internal calls and tests to use makeAdminRegion() - All 192 tests pass --- src/swarms/FleetIdentity.sol | 25 ++++++------------------- test/FleetIdentity.t.sol | 25 ++++++++++--------------- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 7fad0f63..9f389d27 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -251,7 +251,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (uuid == bytes16(0)) revert InvalidUUID(); if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); - uint32 regionKey = _makeAdminRegion(countryCode, adminCode); + uint32 regionKey = makeAdminRegion(countryCode, adminCode); _validateExplicitTier(regionKey, targetTier); tokenId = _register(uuid, regionKey, targetTier); } @@ -529,7 +529,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (adminCode == 0) revert AdminAreaRequired(); uint32 countryKey = uint32(countryCode); - uint32 adminKey = _makeAdminRegion(countryCode, adminCode); + uint32 adminKey = makeAdminRegion(countryCode, adminCode); (uuids, count, , ) = _buildHighestBondedUuidBundle(countryKey, adminKey); } @@ -604,18 +604,10 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { 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 _makeAdminRegion(countryCode, adminCode); + /// @dev Country region key is simply uint32(countryCode) - no helper needed. + function makeAdminRegion(uint16 countryCode, uint16 adminCode) public pure returns (uint32) { + return (uint32(countryCode) << uint32(ADMIN_SHIFT)) | uint32(adminCode); } // ══════════════════════════════════════════════ @@ -624,11 +616,6 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // -- Region key encoding -- - /// @dev Packs a country code and admin code into an admin-area region key. - function _makeAdminRegion(uint16 countryCode, uint16 adminCode) internal pure returns (uint32) { - return (uint32(countryCode) << uint32(ADMIN_SHIFT)) | uint32(adminCode); - } - /// @dev Extracts the country code from an admin-area region key. function _countryFromRegion(uint32 adminRegion) internal pure returns (uint16) { return uint16(adminRegion >> uint32(ADMIN_SHIFT)); @@ -851,7 +838,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { returns (uint256) { uint32 countryKey = uint32(countryCode); - uint32 adminKey = _makeAdminRegion(countryCode, adminCode); + uint32 adminKey = makeAdminRegion(countryCode, adminCode); uint32 candidateRegion = isCountry ? countryKey : adminKey; (, uint256 count, uint256 highestTier, uint256 lowestTier) = _buildHighestBondedUuidBundle(countryKey, adminKey); diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 0ce65526..6bfb9291 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -781,13 +781,8 @@ contract FleetIdentityTest is Test { // --- Region key helpers --- - function test_countryRegionKey() public view { - assertEq(fleet.countryRegionKey(US), uint32(US)); - assertEq(fleet.countryRegionKey(DE), uint32(DE)); - } - - function test_adminRegionKey() public view { - assertEq(fleet.adminRegionKey(US, ADMIN_CA), (uint32(US) << 10) | uint32(ADMIN_CA)); + function test_makeAdminRegion() public view { + assertEq(fleet.makeAdminRegion(US, ADMIN_CA), (uint32(US) << 10) | uint32(ADMIN_CA)); } function test_regionKeyNoOverlap_countryVsAdmin() public pure { @@ -2752,7 +2747,7 @@ contract FleetIdentityTest is Test { // Verify all tiers are full for (uint256 tier = 0; tier < 24; tier++) { - assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), tier), 4); + assertEq(fleet.tierMemberCount(fleet.makeAdminRegion(US, ADMIN_CA), tier), 4); } // localInclusionHint should revert @@ -2807,13 +2802,13 @@ contract FleetIdentityTest is Test { _registerNCountryAt(alice, US, 4, 7000, 2); // Verify tier 2 is maxTierIndex - assertEq(fleet.regionTierCount(fleet.countryRegionKey(US)), 3); - assertEq(fleet.regionTierCount(fleet.adminRegionKey(US, ADMIN_CA)), 3); + assertEq(fleet.regionTierCount(uint32(US)), 3); + assertEq(fleet.regionTierCount(fleet.makeAdminRegion(US, ADMIN_CA)), 3); // All admin tiers 0-2 are full (4 members each = TIER_CAPACITY) - assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 0), 4); - assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 1), 4); - assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 2), 4); + assertEq(fleet.tierMemberCount(fleet.makeAdminRegion(US, ADMIN_CA), 0), 4); + assertEq(fleet.tierMemberCount(fleet.makeAdminRegion(US, ADMIN_CA), 1), 4); + assertEq(fleet.tierMemberCount(fleet.makeAdminRegion(US, ADMIN_CA), 2), 4); // At tiers 0-2: all tiers are full (4 members = cap), cannot join. // At tier 3: above maxTierIndex, countBefore = 0, has room. @@ -2844,7 +2839,7 @@ contract FleetIdentityTest is Test { _registerNLocalAt(alice, US, ADMIN_CA, 4, 2000, 0); // Verify admin tier 0 is full - assertEq(fleet.tierMemberCount(fleet.adminRegionKey(US, ADMIN_CA), 0), 4); + assertEq(fleet.tierMemberCount(fleet.makeAdminRegion(US, ADMIN_CA), 0), 4); // Admin tier 0 is full (4 members = TIER_CAPACITY), so candidate must go elsewhere. // Cheapest inclusion tier should be 1 (above maxTierIndex=0). @@ -2940,7 +2935,7 @@ contract FleetIdentityTest is Test { /// Result: All original fleets remain included. function test_DEMO_partialInclusionPreventsFullDisplacement() public { // === BEFORE STATE === - uint32 countryRegion = fleet.countryRegionKey(US); + uint32 countryRegion = uint32(US); // Fill with admin(4) + country(4) = 8 uint256[] memory localIds = _registerNLocalAt(alice, US, ADMIN_CA, 4, 30000, 0); // Admin tier 0: 4 From 2ba283cde6591ab670653613733e0f04dbe01510 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 26 Feb 2026 11:20:29 +1300 Subject: [PATCH 50/63] feat(swarms): change country bond multiplier to 16 --- src/swarms/FleetIdentity.sol | 4 +-- test/FleetIdentity.t.sol | 64 ++++++++++++++++++------------------ 2 files changed, 34 insertions(+), 34 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 9f389d27..d8a8d208 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -96,8 +96,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// @notice Unified tier capacity for all levels. uint256 public constant TIER_CAPACITY = 4; - /// @notice Bond multiplier for country-level registration (8× local). - uint256 public constant COUNTRY_BOND_MULTIPLIER = 8; + /// @notice Bond multiplier for country-level registration (16× local). + uint256 public constant COUNTRY_BOND_MULTIPLIER = 16; /// @notice Hard cap on tier count per region. /// @dev Derived from anti-spam analysis: with a bond doubling per tier diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 6bfb9291..ca5408bc 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -195,7 +195,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.TIER_CAPACITY(), 4); assertEq(fleet.MAX_TIERS(), 24); assertEq(fleet.MAX_BONDED_UUID_BUNDLE_SIZE(), 20); - assertEq(fleet.COUNTRY_BOND_MULTIPLIER(), 8); + assertEq(fleet.COUNTRY_BOND_MULTIPLIER(), 16); } // --- tierBond --- @@ -206,8 +206,8 @@ contract FleetIdentityTest is Test { } function test_tierBond_country_tier0() public view { - // Country regions get 8× multiplier - assertEq(fleet.tierBond(0, true), BASE_BOND * 8); + // Country regions get 16x multiplier + assertEq(fleet.tierBond(0, true), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER()); } function test_tierBond_local_tier1() public view { @@ -215,7 +215,7 @@ contract FleetIdentityTest is Test { } function test_tierBond_country_tier1() public view { - assertEq(fleet.tierBond(1, true), BASE_BOND * 8 * 2); + assertEq(fleet.tierBond(1, true), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER() * 2); } function test_tierBond_geometricProgression() public view { @@ -233,7 +233,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.tokenRegion(tokenId), _regionUS()); assertEq(fleet.fleetTier(tokenId), 0); - assertEq(fleet.bonds(tokenId), BASE_BOND * 8); // Country gets 8× multiplier + assertEq(fleet.bonds(tokenId), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER()); // Country gets 16x multiplier assertEq(fleet.regionTierCount(_regionUS()), 1); } @@ -298,7 +298,7 @@ contract FleetIdentityTest is Test { // --- Per-region independent tier indexing (KEY REQUIREMENT) --- function test_perRegionTiers_firstFleetInEachLevelPaysBondWithMultiplier() public { - // Country level pays 8× multiplier + // Country level pays 16x multiplier vm.prank(alice); uint256 c1 = fleet.registerFleetCountry(UUID_1, US, 0); // Local level pays 1× multiplier @@ -308,7 +308,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.fleetTier(c1), 0); assertEq(fleet.fleetTier(l1), 0); - assertEq(fleet.bonds(c1), BASE_BOND * 8); // Country gets 8× multiplier + assertEq(fleet.bonds(c1), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER()); // Country gets 16× multiplier assertEq(fleet.bonds(l1), BASE_BOND); // Local gets 1× multiplier } @@ -322,13 +322,13 @@ contract FleetIdentityTest is Test { vm.prank(bob); uint256 us21 = fleet.registerFleetCountry(_uuid(100), US, 1); assertEq(fleet.fleetTier(us21), 1); - assertEq(fleet.bonds(us21), BASE_BOND * 8 * 2); // Country tier 1: 8× * 2^1 + assertEq(fleet.bonds(us21), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER() * 2); // Country tier 1: 16× * 2^1 // DE country is independent - can still join tier 0 vm.prank(bob); uint256 de1 = fleet.registerFleetCountry(_uuid(200), DE, 0); assertEq(fleet.fleetTier(de1), 0); - assertEq(fleet.bonds(de1), BASE_BOND * 8); + assertEq(fleet.bonds(de1), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER()); assertEq(fleet.regionTierCount(_regionDE()), 1); // US local is independent - can still join tier 0 @@ -347,13 +347,13 @@ contract FleetIdentityTest is Test { vm.prank(bob); uint256 us21 = fleet.registerFleetCountry(_uuid(500), US, 1); assertEq(fleet.fleetTier(us21), 1); - assertEq(fleet.bonds(us21), BASE_BOND * 8 * 2); // Country tier 1: 8× * 2^1 + assertEq(fleet.bonds(us21), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER() * 2); // Country tier 1: 16× * 2^1 // DE country is independent - can still join tier 0 vm.prank(bob); uint256 de1 = fleet.registerFleetCountry(_uuid(600), DE, 0); assertEq(fleet.fleetTier(de1), 0); - assertEq(fleet.bonds(de1), BASE_BOND * 8); // Country tier 0: 8× * 2^0 + assertEq(fleet.bonds(de1), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER()); // Country tier 0: 16× * 2^0 } function test_perRegionTiers_twoAdminAreasIndependent() public { @@ -843,12 +843,12 @@ contract FleetIdentityTest is Test { vm.prank(carol); uint256 l1 = fleet.registerFleetLocal(UUID_3, US, ADMIN_CA, 0); - // c1 and c2 are country (8× multiplier), l1 is local (1× multiplier) - assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 8 + BASE_BOND * 8 + BASE_BOND); + // c1 and c2 are country (16x multiplier), l1 is local (1× multiplier) + assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER() + BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER() + BASE_BOND); vm.prank(bob); fleet.burn(c2); - assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * 8 + BASE_BOND); + assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER() + BASE_BOND); vm.prank(alice); fleet.burn(c1); @@ -956,7 +956,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.tokenRegion(tokenId), uint32(cc)); assertEq(fleet.fleetTier(tokenId), 0); - assertEq(fleet.bonds(tokenId), BASE_BOND * 8); // Country gets 8× multiplier + assertEq(fleet.bonds(tokenId), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER()); // Country gets 16x multiplier } function testFuzz_registerFleetLocal_validCodes(uint16 cc, uint16 admin) public { @@ -1425,8 +1425,8 @@ contract FleetIdentityTest is Test { assertEq(fleet.tokenRegion(tokenId), uint32(US)); assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Country - // Incremental bond: country tier 0 = 8*BASE_BOND, already paid BASE_BOND, so 7*BASE_BOND - assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), 7 * BASE_BOND); + // Incremental bond: country tier 0 = 16*BASE_BOND, already paid BASE_BOND, so 15*BASE_BOND + assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), 15 * BASE_BOND); } function test_registerFromOwned_higherTier() public { @@ -1483,7 +1483,7 @@ contract FleetIdentityTest is Test { } function test_unregisterToOwned_fromCountry() public { - // Register country tier 0 (8*BASE_BOND) + // Register country tier 0 (16*BASE_BOND) vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); @@ -1492,8 +1492,8 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.unregisterToOwned(tokenId); - // Refund: 8*BASE_BOND - BASE_BOND = 7*BASE_BOND - assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, 7 * BASE_BOND); + // Refund: 16*BASE_BOND - BASE_BOND = 15*BASE_BOND + assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, 15 * BASE_BOND); // Level reset to Owned assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); @@ -1656,7 +1656,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.unregisterToOwned(oldTokenId); - // Re-register in DE as country (pays 8*BASE_BOND - BASE_BOND = 7*BASE_BOND) + // Re-register in DE as country (pays 16*BASE_BOND - BASE_BOND = 15*BASE_BOND) vm.prank(alice); uint256 newTokenId = fleet.registerFleetCountry(UUID_1, DE, 0); @@ -1664,8 +1664,8 @@ contract FleetIdentityTest is Test { assertEq(fleet.tokenRegion(newTokenId), uint32(DE)); assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Country - // Net bond change: 7*BASE_BOND additional - assertEq(aliceBalanceAfterRegister - bondToken.balanceOf(alice), 7 * BASE_BOND); + // Net bond change: 15*BASE_BOND additional + assertEq(aliceBalanceAfterRegister - bondToken.balanceOf(alice), 15 * BASE_BOND); } function testFuzz_tierBond_geometric(uint256 tier) public view { @@ -1676,8 +1676,8 @@ contract FleetIdentityTest is Test { } // Local regions get 1× multiplier assertEq(fleet.tierBond(tier, false), expected); - // Country regions get 8× multiplier - assertEq(fleet.tierBond(tier, true), expected * 8); + // Country regions get 16x multiplier + assertEq(fleet.tierBond(tier, true), expected * fleet.COUNTRY_BOND_MULTIPLIER()); } function testFuzz_perRegionTiers_newRegionAlwaysStartsAtTier0(uint16 cc) public { @@ -1692,7 +1692,7 @@ contract FleetIdentityTest is Test { vm.prank(bob); uint256 tokenId = fleet.registerFleetCountry(_uuid(999), cc, 0); assertEq(fleet.fleetTier(tokenId), 0); - assertEq(fleet.bonds(tokenId), BASE_BOND * 8); // Country gets 8× multiplier + assertEq(fleet.bonds(tokenId), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER()); // Country gets 16x multiplier } function testFuzz_tierAssignment_autoFillsSequentiallyPerRegion(uint8 count) public { @@ -1761,7 +1761,7 @@ contract FleetIdentityTest is Test { function test_countryInclusionHint_emptyReturnsZero() public view { (uint256 tier, uint256 bond) = fleet.countryInclusionHint(US); assertEq(tier, 0); - assertEq(bond, BASE_BOND * 8); // Country pays 8× multiplier + assertEq(bond, BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER()); // Country pays 16x multiplier } function test_countryInclusionHint_onlyCountryFleets() public { @@ -1772,7 +1772,7 @@ contract FleetIdentityTest is Test { // Tier 0 is full → cheapest inclusion = tier 1. (uint256 tier, uint256 bond) = fleet.countryInclusionHint(US); assertEq(tier, 1); - assertEq(bond, BASE_BOND * 8 * 2); // Country pays 8× multiplier, tier 1 = 2× base + assertEq(bond, BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER() * 2); // Country pays 16x multiplier, tier 1 = 2× base } function test_countryInclusionHint_adminAreaCreatesPressure() public { @@ -2443,11 +2443,11 @@ contract FleetIdentityTest is Test { } function test_buildBundle_sharedCursor_sameTierIndex_differentBondByRegion() public view { - // Local tier 0 = BASE_BOND, Country tier 0 = BASE_BOND * 8 (multiplier) + // Local tier 0 = BASE_BOND, Country tier 0 = BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER() (multiplier) assertEq(fleet.tierBond(0, false), BASE_BOND); - assertEq(fleet.tierBond(0, true), BASE_BOND * 8); + assertEq(fleet.tierBond(0, true), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER()); assertEq(fleet.tierBond(1, false), BASE_BOND * 2); - assertEq(fleet.tierBond(1, true), BASE_BOND * 8 * 2); + assertEq(fleet.tierBond(1, true), BASE_BOND * fleet.COUNTRY_BOND_MULTIPLIER() * 2); } // ── Lifecycle ── @@ -2814,7 +2814,7 @@ contract FleetIdentityTest is Test { // At tier 3: above maxTierIndex, countBefore = 0, has room. (uint256 inclusionTier, uint256 bond) = fleet.localInclusionHint(US, ADMIN_CA); assertEq(inclusionTier, 3, "Should recommend tier 3 (above maxTierIndex=2)"); - assertEq(bond, BASE_BOND * 8); // tier 3 bond = BASE_BOND * 2^3 + assertEq(bond, BASE_BOND * 8); // local tier 3 bond = BASE_BOND * 2^3 // Verify registration at tier 3 works vm.prank(bob); From f839da92c135b2cdd8f221c82cd67650f3473560 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 26 Feb 2026 11:24:00 +1300 Subject: [PATCH 51/63] fix: exclude iso3166-2 docs from cspell --- .cspell.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index 541973b0..04ce2bc4 100644 --- a/.cspell.json +++ b/.cspell.json @@ -12,7 +12,8 @@ "deployments-zk", "cache_hardhat-zk", "zkout", - "clk-gateway/src/validators.test.ts" + "clk-gateway/src/validators.test.ts", + "src/swarms/doc/iso3166-2" ], "ignoreWords": [ "NODL", From 77ce4598153c33dba8de89c9f4b1557f96a7bbaa Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 26 Feb 2026 11:32:28 +1300 Subject: [PATCH 52/63] chore: remove local files --- logs/deploy_l1_bridge.log | 32 ------------- logs/deploy_l1_nodl.log | 29 ------------ logs/deploy_l2_bridge.log | 98 --------------------------------------- typescript | 3 -- 4 files changed, 162 deletions(-) delete mode 100644 logs/deploy_l1_bridge.log delete mode 100644 logs/deploy_l1_nodl.log delete mode 100644 logs/deploy_l2_bridge.log delete mode 100644 typescript diff --git a/logs/deploy_l1_bridge.log b/logs/deploy_l1_bridge.log deleted file mode 100644 index b3ac3d42..00000000 --- a/logs/deploy_l1_bridge.log +++ /dev/null @@ -1,32 +0,0 @@ -Compiling 1 files with Solc 0.8.26 -Solc 0.8.26 finished in 2.60s -Compiler run successful! -Script ran successfully. - -== Logs == - Deployed L1Bridge at 0x2D02b651Ea9630351719c8c55210e042e940d69a - Granted MINTER_ROLE on NodlL1(0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990) to bridge - -## Setting up 1 EVM. - -========================== - -Chain 1 - -Estimated gas price: 0.222068762 gwei - -Estimated total gas used for script: 2685066 - -Estimated amount required: 0.000596269282508292 ETH - -========================== - - -========================== - -ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. - -Transactions saved to: /Users/alex/Documents/rollup/broadcast/DeployL1Bridge.s.sol/1/run-latest.json - -Sensitive values saved to: /Users/alex/Documents/rollup/cache/DeployL1Bridge.s.sol/1/run-latest.json - diff --git a/logs/deploy_l1_nodl.log b/logs/deploy_l1_nodl.log deleted file mode 100644 index ae13ee1e..00000000 --- a/logs/deploy_l1_nodl.log +++ /dev/null @@ -1,29 +0,0 @@ -No files changed, compilation skipped -Script ran successfully. - -== Logs == - Deployed L1Nodl at 0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990 - -## Setting up 1 EVM. - -========================== - -Chain 1 - -Estimated gas price: 0.251645298 gwei - -Estimated total gas used for script: 4998146 - -Estimated amount required: 0.001257759939617508 ETH - -========================== - - -========================== - -ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. - -Transactions saved to: /Users/alex/Documents/rollup/broadcast/DeployL1Nodl.s.sol/1/run-latest.json - -Sensitive values saved to: /Users/alex/Documents/rollup/cache/DeployL1Nodl.s.sol/1/run-latest.json - diff --git a/logs/deploy_l2_bridge.log b/logs/deploy_l2_bridge.log deleted file mode 100644 index 5047416e..00000000 --- a/logs/deploy_l2_bridge.log +++ /dev/null @@ -1,98 +0,0 @@ -Compiling 1 files with Solc 0.8.26 -Solc 0.8.26 finished in 1.70s -Compiler run successful! - -Compiling 1 files with zksolc and solc 0.8.26 -zksolc and solc 0.8.26 finished in 4.05s -Compiler run successful with warnings: -Warning -ZKsync Era comes with native account abstraction support, and therefore the initiator of a -transaction might be different from the contract calling your code. It is highly recommended NOT -to rely on tx.origin, but use msg.sender instead. -Learn more about Account Abstraction at https://docs.zksync.io/build/developer-reference/account-abstraction/ -You may disable this warning with: - a. `suppressedWarnings = ["txorigin"]` in standard JSON. - b. `--suppress-warnings txorigin` in the CLI. - --> lib/era-contracts/l1-contracts/contracts/vendor/AddressAliasHelper.sol:56:42 | - 56 | _recipient = _prevMsgSender == tx.origin - | ^^^^^^^^^ - -Warning -EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. -In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. -In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. -Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 -You may disable this warning with: - 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. - 2. `--suppress-warnings assemblycreate` in the CLI. - --> lib/forge-std/src/StdCheats.sol:494:19 | - 494 | addr := create(0, add(bytecode, 0x20), mload(bytecode)) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Warning -EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. -In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. -In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. -Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 -You may disable this warning with: - 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. - 2. `--suppress-warnings assemblycreate` in the CLI. - --> lib/forge-std/src/StdCheats.sol:504:19 | - 504 | addr := create(0, add(bytecode, 0x20), mload(bytecode)) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Warning -EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. -In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. -In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. -Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 -You may disable this warning with: - 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. - 2. `--suppress-warnings assemblycreate` in the CLI. - --> lib/forge-std/src/StdCheats.sol:515:19 | - 515 | addr := create(val, add(bytecode, 0x20), mload(bytecode)) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -Warning -EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. -In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. -In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. -Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 -You may disable this warning with: - 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. - 2. `--suppress-warnings assemblycreate` in the CLI. - --> lib/forge-std/src/StdCheats.sol:525:19 | - 525 | addr := create(val, add(bytecode, 0x20), mload(bytecode)) - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -2025-10-15T00:35:52.095529Z ERROR backendhandler: failed to get block err=failed to get block; error sending request for url (https://mainnet.era.zksync.io/); operation timed out number=65260273 -2025-10-15T00:35:52.096034Z ERROR sharedbackend: Failed to send/recv `block_hash` err=failed to get block hash for 65260273: failed to get block; error sending request for url (https://mainnet.era.zksync.io/); operation timed out number=65260273 -Script ran successfully. - -== Logs == - Deployed L2Bridge at 0x2c1B65dA72d5Cf19b41dE6eDcCFB7DD83d1B529E - Granted MINTER_ROLE on NODL(0xBD4372e44c5eE654dd838304006E1f0f69983154) to bridge - -## Setting up 1 EVM. - -========================== - -Chain 324 - -Estimated gas price: 0.090500001 gwei - -Estimated total gas used for script: 209410861 - -Estimated amount required: 0.018951683129910861 ETH - -========================== - - -========================== - -ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. - -Transactions saved to: /Users/alex/Documents/rollup/broadcast/DeployL2Bridge.s.sol/324/run-latest.json - -Sensitive values saved to: /Users/alex/Documents/rollup/cache/DeployL2Bridge.s.sol/324/run-latest.json - diff --git a/typescript b/typescript deleted file mode 100644 index 00563f89..00000000 --- a/typescript +++ /dev/null @@ -1,3 +0,0 @@ -Script started on Fri Feb 13 12:41:51 2026 -% alex@Alexs-MacBook-Pro-2 rollup % [?2004h[?2004l -% alex@Alexs-MacBook-Pro-2 rollup % [?2004h \ No newline at end of file From f5dbd1ce162033ea65e3e70b68f624ecbec4fc0b Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 26 Feb 2026 11:33:30 +1300 Subject: [PATCH 53/63] chore: remove local files --- --data | 1 - --header | 1 - --url | 1 - 3 files changed, 3 deletions(-) delete mode 100644 --data delete mode 100644 --header delete mode 100644 --url diff --git a/--data b/--data deleted file mode 100644 index 45ec8a7a..00000000 --- a/--data +++ /dev/null @@ -1 +0,0 @@ -{"jsonrpc":"2.0","error":{"code":-32700,"message":"parse error"},"id":null} diff --git a/--header b/--header deleted file mode 100644 index 45ec8a7a..00000000 --- a/--header +++ /dev/null @@ -1 +0,0 @@ -{"jsonrpc":"2.0","error":{"code":-32700,"message":"parse error"},"id":null} diff --git a/--url b/--url deleted file mode 100644 index 45ec8a7a..00000000 --- a/--url +++ /dev/null @@ -1 +0,0 @@ -{"jsonrpc":"2.0","error":{"code":-32700,"message":"parse error"},"id":null} From 928db5b8aa3513878fb2896fdb9101aada9eab1d Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 26 Feb 2026 11:59:01 +1300 Subject: [PATCH 54/63] docs: update swarm system documentation to reflect FleetIdentity geographic registration - Updated assistant-guide.md with geographic registration (country + admin area levels) - Added UUID ownership lifecycle (Owned, Local, Country registration levels) - Documented tier system, bond economics, and inclusion hints - Updated sequence diagrams for registration, discovery, and lifecycle flows - Added comprehensive FleetIdentity documentation (regions, tiers, bundles) - Created new sequence-fleet-identity.md covering region encoding and bundle discovery - Updated discovery flows to use geographic bundle priority - Clarified multi-region registration and unregister-to-owned flows - All changes verified with spellcheck (0 issues) --- src/swarms/doc/README.md | 17 ++ src/swarms/doc/assistant-guide.md | 288 ++++++++++++++++++---- src/swarms/doc/graph-architecture.md | 46 +++- src/swarms/doc/sequence-discovery.md | 110 ++++++++- src/swarms/doc/sequence-fleet-identity.md | 273 ++++++++++++++++++++ src/swarms/doc/sequence-lifecycle.md | 211 ++++++++++++++++ src/swarms/doc/sequence-registration.md | 26 +- 7 files changed, 907 insertions(+), 64 deletions(-) create mode 100644 src/swarms/doc/sequence-fleet-identity.md diff --git a/src/swarms/doc/README.md b/src/swarms/doc/README.md index 40a83132..8dd517a9 100644 --- a/src/swarms/doc/README.md +++ b/src/swarms/doc/README.md @@ -1,3 +1,20 @@ +# Swarm System Documentation + +This directory contains documentation for the Swarm System smart contracts. + +## Documentation Index + +| Document | Description | +| :------------------------------------------------------- | :--------------------------------------------- | +| [assistant-guide.md](assistant-guide.md) | Architecture overview and workflows | +| [graph-architecture.md](graph-architecture.md) | Contract architecture diagrams | +| [sequence-registration.md](sequence-registration.md) | Fleet & swarm registration flows | +| [sequence-lifecycle.md](sequence-lifecycle.md) | Swarm & fleet lifecycle (update, delete, burn) | +| [sequence-discovery.md](sequence-discovery.md) | Client discovery flows | +| [sequence-fleet-identity.md](sequence-fleet-identity.md) | FleetIdentity: regions, tiers, bundles | + +--- + # ISO 3166-2 Admin Area Mappings The [iso3166-2/](iso3166-2/) directory contains standardized mappings from ISO 3166-2 subdivision codes to admin codes for use with the FleetIdentity contract. diff --git a/src/swarms/doc/assistant-guide.md b/src/swarms/doc/assistant-guide.md index ffa3cb4b..33c247c0 100644 --- a/src/swarms/doc/assistant-guide.md +++ b/src/swarms/doc/assistant-guide.md @@ -15,28 +15,159 @@ Two registry variants exist for different deployment targets: | Contract | Role | Key Identity | Token | | :--------------------------- | :---------------------------------- | :--------------------------------------- | :---- | -| **`FleetIdentity`** | Fleet Registry (ERC-721 Enumerable) | `uint256(uint128(uuid))` | SFID | +| **`FleetIdentity`** | Fleet Registry (ERC-721 Enumerable) | `(regionKey << 128) \| uint128(uuid)` | SFID | | **`ServiceProvider`** | Service Registry (ERC-721) | `keccak256(url)` | SSV | | **`SwarmRegistryL1`** | Swarm Registry (L1) | `keccak256(fleetId, providerId, filter)` | — | | **`SwarmRegistryUniversal`** | Swarm Registry (Universal) | `keccak256(fleetId, providerId, filter)` | — | All contracts are **permissionless** — access control is enforced through NFT ownership rather than admin roles. `FleetIdentity` additionally requires an ERC-20 bond (e.g. NODL) to register a fleet, acting as an anti-spam / anti-abuse mechanism. -Both NFT contracts support **burning** — the token owner can call `burn(tokenId)` to destroy their NFT. Burning a `FleetIdentity` token refunds the full bond to the owner. Burning either NFT makes any swarms referencing that token _orphaned_. +Both NFT contracts support **burning** — the token owner can call `burn(tokenId)` to destroy their NFT. Burning a `FleetIdentity` token refunds the tier bond to the owner. Burning either NFT makes any swarms referencing that token _orphaned_. + +### FleetIdentity: Two-Level Geographic Registration + +`FleetIdentity` implements a **two-level geographic registration** system: + +- **Country Level** — `regionKey = countryCode` (ISO 3166-1 numeric, 1-999) +- **Admin Area (Local) Level** — `regionKey = (countryCode << 10) | adminCode` (>= 1024) + +Each region has its own independent tier namespace. The first fleet in any region always pays the level-appropriate base bond. + +**TokenID Encoding:** + +``` +tokenId = (regionKey << 128) | uint256(uint128(uuid)) +``` + +- Bits 0-127: UUID (Proximity UUID as bytes16) +- Bits 128-159: Region key (country or admin-area code) + +This allows the same UUID to be registered in multiple regions, each with a distinct token. + +### Economic Model (Tier System) + +| Parameter | Value | +| :------------------ | :--------------------------------------------------------- | +| **Tier Capacity** | 4 members per tier | +| **Max Tiers** | 24 per region | +| **Local Bond** | `BASE_BOND * 2^tier` | +| **Country Bond** | `BASE_BOND * COUNTRY_BOND_MULTIPLIER * 2^tier` (16× local) | +| **Max Bundle Size** | 20 UUIDs | + +Country fleets pay 16× more but appear in all admin-area bundles within their country. This economic difference provides locals a significant advantage: a local can reach tier 3 for the same cost a country player pays for tier 0. + +### UUID Ownership Model + +UUIDs have an ownership model with registration levels: + +| Level | Value | Description | +| :-------- | :---- | :--------------------------------------- | +| `None` | 0 | Not registered (default) | +| `Owned` | 1 | Claimed but not registered in any region | +| `Local` | 2 | Registered at admin area level | +| `Country` | 3 | Registered at country level | + +- **UUID Owner**: The address that first registered a token for a UUID. All subsequent registrations must come from this address. +- **Multi-Region**: The same UUID can have multiple tokens in different regions (all at the same level, all by the same owner). +- **Transfer**: Owned-only tokens transfer `uuidOwner` when the NFT is transferred. --- ## 2. Operational Workflows -### A. Provider & Fleet Setup (One-Time) +### A. Provider Setup (One-Time) + +**Service Provider** calls `ServiceProvider.registerProvider("https://cms.example.com")`. Receives `providerTokenId` (= `keccak256(url)`). + +### B. Fleet Registration Options + +Fleet Owners have multiple paths to register fleets: + +#### B1. Direct Registration (Country Level) + +```solidity +// 1. Approve bond token +NODL.approve(fleetIdentityAddress, requiredBond); + +// 2. Get inclusion hint (off-chain call - free) +(uint256 tier, uint256 bond) = fleetIdentity.countryInclusionHint(840); // US = 840 + +// 3. Register at the recommended tier +uint256 tokenId = fleetIdentity.registerFleetCountry(uuid, 840, tier); +// Returns tokenId = (840 << 128) | uint128(uuid) +``` + +#### B2. Direct Registration (Local/Admin Area Level) + +```solidity +// 1. Approve bond token +NODL.approve(fleetIdentityAddress, requiredBond); + +// 2. Get inclusion hint (off-chain call - free) +(uint256 tier, uint256 bond) = fleetIdentity.localInclusionHint(840, 5); // US, California + +// 3. Register at the recommended tier +uint256 tokenId = fleetIdentity.registerFleetLocal(uuid, 840, 5, tier); +// Returns tokenId = ((840 << 10 | 5) << 128) | uint128(uuid) +``` + +#### B3. Claim-First Flow (Reserve UUID, Register Later) + +```solidity +// 1. Claim UUID ownership (costs BASE_BOND) +NODL.approve(fleetIdentityAddress, BASE_BOND); +uint256 ownedTokenId = fleetIdentity.claimUuid(uuid); +// Returns tokenId = uint128(uuid) (regionKey = 0) + +// 2. Later: Register from owned state (burns owned token, mints regional token) +// Only pays incremental bond (tier bond - BASE_BOND already paid) +uint256 tokenId = fleetIdentity.registerFleetLocal(uuid, 840, 5, targetTier); +``` + +### C. Fleet Tier Management + +Fleets can promote or demote within their region: + +```solidity +// Promote to next tier (pulls additional bond) +fleetIdentity.promote(tokenId); + +// Reassign to any tier (promotes or demotes) +fleetIdentity.reassignTier(tokenId, targetTier); +// If targetTier > current: pulls additional bond +// If targetTier < current: refunds bond difference +``` + +### D. Unregister to Owned State + +A fleet with a **single token** can unregister back to owned-only state: + +```solidity +// Returns to owned state, refunds (tierBond - BASE_BOND) +uint256 ownedTokenId = fleetIdentity.unregisterToOwned(tokenId); +// Reverts if UUID has multiple tokens (multi-region registration) +``` + +### E. Release UUID Ownership + +An owned-only UUID can be fully released, refunding BASE_BOND: -1. **Service Provider**: Calls `ServiceProvider.registerProvider("https://cms.example.com")`. Receives `providerTokenId` (= `keccak256(url)`). -2. **Fleet Owner**: - 1. Approves the bond token: `NODL.approve(fleetIdentityAddress, bondAmount)`. - 2. Calls `FleetIdentity.registerFleet(0xUUID..., bondAmount)`. Receives `fleetId` (= `uint256(uint128(uuid))`). The `bondAmount` must be ≥ `MIN_BOND` (set at deploy). - 3. _(Optional)_ Calls `FleetIdentity.increaseBond(fleetId, additionalAmount)` to top-up later. Anyone can top-up any fleet's bond. +```solidity +// Must be in Owned state (not registered in any region) +fleetIdentity.releaseUuid(uuid); +// Clears uuidOwner, allows anyone to claim the UUID +``` + +### F. Burn Fleet Token + +```solidity +// Burns token and refunds tier bond +fleetIdentity.burn(tokenId); +// For owned-only tokens: refunds BASE_BOND +// For registered tokens: refunds tierBond(tier, isCountry) +``` -### B. Swarm Registration (Per Batch of Tags) +### G. Swarm Registration (Per Batch of Tags) A Fleet Owner groups tags into a "Swarm" (chunk of ~10k-20k tags) and registers them. @@ -55,7 +186,7 @@ A Fleet Owner groups tags into a "Swarm" (chunk of ~10k-20k tags) and registers // Returns the deterministic swarmId ``` -### C. Swarm Approval Flow +### H. Swarm Approval Flow After registration a swarm starts in `REGISTERED` status and requires provider approval: @@ -64,14 +195,14 @@ After registration a swarm starts in `REGISTERED` status and requires provider a Only the owner of the provider NFT (`providerId`) can accept or reject. -### D. Swarm Updates +### I. 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 +### J. Swarm Deletion The fleet owner can permanently remove a swarm: @@ -79,7 +210,7 @@ The fleet owner can permanently remove a swarm: swarmRegistry.deleteSwarm(swarmId); ``` -### F. Orphan Detection & Cleanup +### K. Orphan Detection & Cleanup When a fleet or provider NFT is burned, swarms referencing it become _orphaned_: @@ -141,49 +272,116 @@ This means the same (fleet, provider, filter) triple always produces the same ID A client (mobile phone or gateway) scans a BLE beacon and wants to find its owner and backend service. -### Step 1: Scan & Detect +### Discovery Option A: Geographic Bundle Discovery (Recommended) -- EdgeBeaconScanner detects iBeacon: `UUID: E2C5...`, `Major: 1`, `Minor: 50`, `MAC: AA:BB...`. +Use the priority-ordered bundle based on EdgeBeaconScanner location. -### Step 2: Identify Fleet +#### Step 1: Get Priority Bundle -- 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". +```solidity +// EdgeBeaconScanner knows its location: US, California (country=840, admin=5) +(bytes16[] memory uuids, uint256 count) = fleetIdentity.buildHighestBondedUuidBundle(840, 5); +// Returns up to 20 UUIDs, priority-ordered: +// 1. Higher tier first +// 2. Local (admin area) before country within same tier +// 3. Earlier registration within same tier+level +``` -### Step 3: Find Swarms +#### Step 2: Match Detected Beacon UUID -- EdgeBeaconScanner reads `swarmRegistry.fleetSwarms(42, index)` for each index (array of swarm IDs for that fleet). -- **Result**: List of `SwarmID`s: `[101, 102, 105]`. +```solidity +bytes16 detectedUUID = ...; // From iBeacon advertisement + +for (uint256 i = 0; i < count; i++) { + if (uuids[i] == detectedUUID) { + // Found! Now find the token ID + // Try local region first, then country + uint32 localRegion = (840 << 10) | 5; + uint256 tokenId = fleetIdentity.computeTokenId(detectedUUID, localRegion); + if (fleetIdentity.ownerOf(tokenId) exists) { ... } + // else try country region + uint256 tokenId = fleetIdentity.computeTokenId(detectedUUID, 840); + } +} +``` -### Step 4: Membership Check (Find the specific Swarm) +#### Step 3: Enumerate Swarms & Check Membership -For each SwarmID in the list: +Same as Option B Steps 3-5. -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. +### Discovery Option B: Direct Fleet Lookup -### Step 5: Service Discovery +For when you know the UUID and want to find its fleet directly. -Once Membership is confirmed (e.g., in Swarm 101): +#### Step 1: Enumerate Active Regions -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. +```solidity +// Get all countries with active fleets +uint16[] memory countries = fleetIdentity.getActiveCountries(); + +// Get all admin areas with active fleets +uint32[] memory adminAreas = fleetIdentity.getActiveAdminAreas(); +``` + +#### Step 2: Find Fleet Token + +```solidity +bytes16 uuid = ...; // From iBeacon + +// Try each potential region (start with user's location) +uint32 region = (840 << 10) | 5; // US-CA +uint256 tokenId = fleetIdentity.computeTokenId(uuid, region); + +try fleetIdentity.ownerOf(tokenId) returns (address owner) { + // Found the fleet! +} catch { + // Try country-level + tokenId = fleetIdentity.computeTokenId(uuid, 840); +} +``` + +#### Step 3: Find Swarms + +```solidity +// Enumerate swarms for this fleet +uint256[] memory swarmIds = new uint256[](100); // estimate +for (uint256 i = 0; ; i++) { + try swarmRegistry.fleetSwarms(tokenId, i) returns (uint256 swarmId) { + swarmIds[i] = swarmId; + } catch { + break; // End of array + } +} +``` + +#### Step 4: Membership Check + +```solidity +// Construct tagHash based on swarm's tagType +(uint256 fleetId, uint256 providerId, uint32 filterLen, uint8 fpSize, + SwarmStatus status, TagType tagType) = swarmRegistry.swarms(swarmId); + +// Build tagId per schema (see Section 3) +bytes memory tagId; +if (tagType == TagType.IBEACON_PAYLOAD_ONLY) { + tagId = abi.encodePacked(uuid, major, minor); +} else if (tagType == TagType.IBEACON_INCLUDES_MAC) { + bytes6 normalizedMac = isRandomMac ? bytes6(0xFFFFFFFFFFFF) : realMac; + tagId = abi.encodePacked(uuid, major, minor, normalizedMac); +} + +bytes32 tagHash = keccak256(tagId); +bool isMember = swarmRegistry.checkMembership(swarmId, tagHash); +``` + +#### Step 5: Service Discovery + +```solidity +if (isMember && status == SwarmStatus.ACCEPTED) { + string memory url = serviceProvider.providerUrls(providerId); + // Connect to url +} +``` --- diff --git a/src/swarms/doc/graph-architecture.md b/src/swarms/doc/graph-architecture.md index 8bf78f4d..74833451 100644 --- a/src/swarms/doc/graph-architecture.md +++ b/src/swarms/doc/graph-architecture.md @@ -3,7 +3,7 @@ ```mermaid graph TB subgraph NFTs["Identity Layer (ERC-721)"] - FI["FleetIdentity
SFID
tokenId = uint128(uuid)"] + FI["FleetIdentity
SFID
tokenId = (regionKey << 128) | uuid"] SP["ServiceProvider
SSV
tokenId = keccak256(url)"] end @@ -17,10 +17,11 @@ graph TB ANY(("Anyone
(EdgeBeaconScanner / Purger)")) end - FO -- "registerFleet(uuid, bondAmount)" --> FI + FO -- "claimUuid(uuid) /
registerFleetLocal(uuid, cc, admin, tier) /
registerFleetCountry(uuid, cc, tier)" --> FI FO -- "registerSwarm / update / delete" --> REG PRV -- "registerProvider(url)" --> SP PRV -- "acceptSwarm / rejectSwarm" --> REG + ANY -- "checkMembership / purgeOrphanedSwarm /
buildHighestBondedUuidBundle" --> FI ANY -- "checkMembership / purgeOrphanedSwarm" --> REG REG -. "ownerOf(fleetId)" .-> FI @@ -40,15 +41,34 @@ graph TB classDiagram class FleetIdentity { +IERC20 BOND_TOKEN (immutable) - +uint256 MIN_BOND (immutable) - +mapping bonds - +registerFleet(uuid, bondAmount) tokenId - +increaseBond(tokenId, amount) + +uint256 BASE_BOND (immutable) + +uint256 TIER_CAPACITY = 4 + +uint256 MAX_TIERS = 24 + +uint256 COUNTRY_BOND_MULTIPLIER = 16 + +uint256 MAX_BONDED_UUID_BUNDLE_SIZE = 20 + +mapping uuidOwner + +mapping uuidLevel + +mapping uuidTokenCount + +mapping regionTierCount + +mapping fleetTier + +claimUuid(uuid) tokenId + +registerFleetLocal(uuid, cc, admin, tier) tokenId + +registerFleetCountry(uuid, cc, tier) tokenId + +promote(tokenId) + +reassignTier(tokenId, targetTier) + +unregisterToOwned(tokenId) newTokenId + +releaseUuid(uuid) +burn(tokenId) + +localInclusionHint(cc, admin) tier, bond + +countryInclusionHint(cc) tier, bond + +buildHighestBondedUuidBundle(cc, admin) uuids, count + +getActiveCountries() uint16[] + +getActiveAdminAreas() uint32[] +tokenUuid(tokenId) bytes16 - +totalSupply() uint256 - +tokenByIndex(index) uint256 - +tokenOfOwnerByIndex(owner, index) uint256 + +tokenRegion(tokenId) uint32 + +computeTokenId(uuid, region) uint256 + +tierBond(tier, isCountry) uint256 + +bonds(tokenId) uint256 } class ServiceProvider { @@ -96,6 +116,14 @@ classDiagram GENERIC } + class RegistrationLevel { + <> + None = 0 + Owned = 1 + Local = 2 + Country = 3 + } + SwarmRegistry --> FleetIdentity : validates ownership SwarmRegistry --> ServiceProvider : validates ownership SwarmRegistry *-- Swarm : stores diff --git a/src/swarms/doc/sequence-discovery.md b/src/swarms/doc/sequence-discovery.md index ea8cf32a..f42ea645 100644 --- a/src/swarms/doc/sequence-discovery.md +++ b/src/swarms/doc/sequence-discovery.md @@ -1,6 +1,6 @@ # Client Discovery Sequence -## Full Discovery Flow: BLE Signal → Service URL +## Geographic Bundle Discovery Flow (Recommended) ```mermaid sequenceDiagram @@ -9,19 +9,90 @@ sequenceDiagram participant SR as SwarmRegistry participant SP as ServiceProvider + Note over EBS: Knows location: US-California
(countryCode=840, adminCode=5) Note over EBS: Detects iBeacon:
UUID, Major, Minor, MAC rect rgb(240, 248, 255) - Note right of EBS: Step 1 — Identify fleet - EBS ->>+ FI: ownerOf(uint128(uuid)) - FI -->>- EBS: fleet owner address (fleet exists ✓) + Note right of EBS: Step 1 — Get Priority Bundle + EBS ->>+ FI: buildHighestBondedUuidBundle(840, 5) + Note over FI: Merges admin-area + country tiers
Priority: higher tier first,
locals before country + FI -->>- EBS: (uuids[], count) — up to 20 UUIDs + end + + rect rgb(255, 248, 240) + Note right of EBS: Step 2 — Match Detected UUID + Note over EBS: Check if detectedUUID is in bundle + Note over EBS: If found: compute tokenId + EBS ->>+ FI: computeTokenId(uuid, (840<<10)|5) + FI -->>- EBS: tokenId (try local first) + alt Token exists + EBS ->>+ FI: ownerOf(tokenId) + FI -->>- EBS: owner address ✓ + else Try country region + EBS ->>+ FI: computeTokenId(uuid, 840) + FI -->>- EBS: tokenId + end + end + + rect rgb(240, 255, 240) + Note right of EBS: Step 3 — Find Swarms + EBS ->>+ SR: fleetSwarms(tokenId, 0) + SR -->>- EBS: swarmId_0 + Note over EBS: ... iterate until revert + end + + rect rgb(248, 240, 255) + Note right of EBS: Step 4 — Membership Check + Note over EBS: Read swarms[swarmId].tagType + Note over EBS: Construct tagId per schema + Note over EBS: tagHash = keccak256(tagId) + EBS ->>+ SR: checkMembership(swarmId, tagHash) + SR -->>- EBS: true ✓ + end + + rect rgb(255, 255, 240) + Note right of EBS: Step 5 — Resolve Service URL + EBS ->>+ SR: swarms(swarmId) + SR -->>- EBS: { providerId, status: ACCEPTED, ... } + EBS ->>+ SP: providerUrls(providerId) + SP -->>- EBS: "https://api.example.com" + end + + Note over EBS: Connect to service URL ✓ +``` + +## Direct Fleet Lookup Flow + +```mermaid +sequenceDiagram + actor EBS as EdgeBeaconScanner (Client) + participant FI as FleetIdentity + participant SR as SwarmRegistry + participant SP as ServiceProvider + + Note over EBS: Detects iBeacon:
UUID, Major, Minor, MAC + + rect rgb(240, 248, 255) + Note right of EBS: Step 1 — Find Fleet by Region + Note over EBS: Try user's location first + EBS ->>+ FI: computeTokenId(uuid, (840<<10)|5) + FI -->>- EBS: tokenId + EBS ->>+ FI: ownerOf(tokenId) + alt Local token exists + FI -->>- EBS: owner ✓ + else Try country level + EBS ->>+ FI: computeTokenId(uuid, 840) + FI -->>- EBS: tokenId + EBS ->>+ FI: ownerOf(tokenId) + FI -->>- EBS: owner ✓ + end end rect rgb(255, 248, 240) Note right of EBS: Step 2 — Enumerate swarms - EBS ->>+ SR: fleetSwarms(fleetId, 0) + EBS ->>+ SR: fleetSwarms(tokenId, 0) SR -->>- EBS: swarmId_0 - EBS ->>+ SR: fleetSwarms(fleetId, 1) + EBS ->>+ SR: fleetSwarms(tokenId, 1) SR -->>- EBS: swarmId_1 Note over EBS: ... iterate until revert (end of array) end @@ -49,6 +120,33 @@ sequenceDiagram Note over EBS: Connect to service URL ✓ ``` +## Region Enumeration (For Indexers) + +```mermaid +sequenceDiagram + actor IDX as Indexer + participant FI as FleetIdentity + + rect rgb(240, 248, 255) + Note right of IDX: Enumerate active regions + IDX ->>+ FI: getActiveCountries() + FI -->>- IDX: [840, 276, 392, ...] + IDX ->>+ FI: getActiveAdminAreas() + FI -->>- IDX: [860165, 282629, ...] + Note over IDX: regionKey = (cc << 10) | admin
860165 = (840 << 10) | 5 = US-CA + end + + rect rgb(255, 248, 240) + Note right of IDX: Get tier data for a region + IDX ->>+ FI: regionTierCount(860165) + FI -->>- IDX: 3 (tiers 0, 1, 2 exist) + IDX ->>+ FI: getTierMembers(860165, 0) + FI -->>- IDX: [tokenId1, tokenId2, ...] + IDX ->>+ FI: getTierUuids(860165, 0) + FI -->>- IDX: [uuid1, uuid2, ...] + end +``` + ## Tag Hash Construction by TagType ```mermaid diff --git a/src/swarms/doc/sequence-fleet-identity.md b/src/swarms/doc/sequence-fleet-identity.md new file mode 100644 index 00000000..d43fb30d --- /dev/null +++ b/src/swarms/doc/sequence-fleet-identity.md @@ -0,0 +1,273 @@ +# FleetIdentity: Geographic Registration & Discovery + +This document covers the FleetIdentity contract's geographic registration system, tier economics, and bundle-based discovery. + +## Region Key Encoding + +FleetIdentity uses a 32-bit region key to identify geographic regions: + +| Level | Encoding | Range | Example | +| :------------- | :--------------------------------- | :----- | :-------------------------------- | +| **Owned-Only** | `0` | 0 | UUID claimed but not registered | +| **Country** | `countryCode` | 1-999 | US = 840 | +| **Admin Area** | `(countryCode << 10) \| adminCode` | ≥ 1024 | US-CA = (840 << 10) \| 5 = 860165 | + +### TokenID Encoding + +``` +tokenId = (regionKey << 128) | uint256(uint128(uuid)) +``` + +- **Bits 0-127**: UUID (Proximity UUID as bytes16) +- **Bits 128-159**: Region key + +**Helper Functions:** + +```solidity +// Extract UUID from tokenId +bytes16 uuid = fleetIdentity.tokenUuid(tokenId); + +// Extract region key from tokenId +uint32 region = fleetIdentity.tokenRegion(tokenId); + +// Compute tokenId from components +uint256 tokenId = fleetIdentity.computeTokenId(uuid, regionKey); + +// Build admin area region key +uint32 adminRegion = fleetIdentity.makeAdminRegion(countryCode, adminCode); +``` + +## Tier System & Bond Economics + +Each region has its own independent tier namespace with geometric bond progression. + +### Constants + +```solidity +TIER_CAPACITY = 4 // Max members per tier +MAX_TIERS = 24 // Max tiers per region +COUNTRY_BOND_MULTIPLIER = 16 // Country pays 16× local +MAX_BONDED_UUID_BUNDLE_SIZE = 20 // Bundle limit +``` + +### Bond Calculations + +| Registration Level | Tier Bond Formula | +| :--------------------- | :------------------------ | +| **Owned-Only** | `BASE_BOND` (flat) | +| **Local (Admin Area)** | `BASE_BOND * 2^tier` | +| **Country** | `BASE_BOND * 16 * 2^tier` | + +**Example with BASE_BOND = 100:** + +| Tier | Local Bond | Country Bond | +| :--- | :--------- | :----------- | +| 0 | 100 | 1,600 | +| 1 | 200 | 3,200 | +| 2 | 400 | 6,400 | +| 3 | 800 | 12,800 | +| 4 | 1,600 | 25,600 | + +### Economic Fairness + +Country fleets pay 16× more but appear in **all** admin-area bundles within their country. This gives locals a significant advantage: + +- A local at tier 4 (1,600) costs the same as a country at tier 0 (1,600) +- The local gets higher priority in their specific admin area + +## Inclusion Hints + +Off-chain (free) view functions that recommend the cheapest tier guaranteeing bundle inclusion. + +### Local Inclusion Hint + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + FO ->>+ FI: localInclusionHint(840, 5) + Note over FI: Simulates buildHighestBondedUuidBundle(840, 5) + Note over FI: Finds cheapest tier with:
1. Capacity (< 4 members)
2. Bundle room (< 20 total) + FI -->>- FO: (tier=1, bond=200) + + Note over FO: Register at recommended tier + FO ->>+ FI: registerFleetLocal(uuid, 840, 5, 1) + FI -->>- FO: tokenId +``` + +### Country Inclusion Hint + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + FO ->>+ FI: countryInclusionHint(840) + Note over FI: Scans ALL active admin areas in US + Note over FI: Finds tier that guarantees inclusion
across all bundles + Note over FI: ⚠️ Unbounded view (free off-chain) + FI -->>- FO: (tier=2, bond=6400) + + Note over FO: Register at recommended tier + FO ->>+ FI: registerFleetCountry(uuid, 840, 2) + FI -->>- FO: tokenId +``` + +## Bundle Discovery Algorithm + +`buildHighestBondedUuidBundle(countryCode, adminCode)` returns up to 20 UUIDs for a location. + +### Priority Rules + +1. **Tier Priority**: Higher tier always wins +2. **Level Priority**: Within same tier, local beats country +3. **Time Priority**: Within same tier+level, earlier registration wins + +### Algorithm Visualization + +```mermaid +flowchart TD + START([buildHighestBondedUuidBundle]) --> FIND[Find highest tier
across admin + country] + FIND --> LOOP{count < 20 AND
tier >= 0?} + + LOOP -->|Yes| LOCAL[Add admin-area tier members] + LOCAL --> COUNTRY[Add country tier members] + COUNTRY --> DEC[tier--] + DEC --> LOOP + + LOOP -->|No| RETURN([Return uuids, count]) + + style START fill:#4a9eff,color:#fff + style RETURN fill:#2ecc71,color:#fff +``` + +### Example Bundle Construction + +**Scenario**: Query `buildHighestBondedUuidBundle(840, 5)` (US-California) + +| Region | Tier 2 | Tier 1 | Tier 0 | +| :------------ | :----- | :--------- | :--------- | +| US-CA (local) | A, B | C, D, E, F | G | +| US (country) | P | Q, R | S, T, U, V | + +**Result** (20 max): `[A, B, P, C, D, E, F, Q, R, G, S, T, U, V]` (14 UUIDs) + +- Tier 2: A, B (local), then P (country) +- Tier 1: C, D, E, F (local), then Q, R (country) +- Tier 0: G (local), then S, T, U, V (country) + +## Multi-Region Registration + +The same UUID can be registered in multiple regions at the same level. + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + Note over FO: Register in US-California + FO ->>+ FI: registerFleetLocal(uuid, 840, 5, 0) + Note over FI: uuidOwner = msg.sender + Note over FI: uuidLevel = Local + Note over FI: uuidTokenCount = 1 + FI -->>- FO: tokenId_1 = ((840<<10|5)<<128) | uuid + + Note over FO: Register same UUID in Germany + FO ->>+ FI: registerFleetLocal(uuid, 276, 1, 0) + Note over FI: Verify same owner + Note over FI: Verify same level (Local) + Note over FI: uuidTokenCount = 2 + FI -->>- FO: tokenId_2 = ((276<<10|1)<<128) | uuid + + Note over FO: Cannot mix levels + FO ->>+ FI: registerFleetCountry(uuid, 392, 0) + FI -->>- FO: ❌ revert UuidLevelMismatch() + + Note over FO: Cannot unregisterToOwned (multiple tokens) + FO ->>+ FI: unregisterToOwned(tokenId_1) + FI -->>- FO: ❌ revert CannotUnregisterMultipleTokens() +``` + +## Region Indexes + +On-chain indexes enable enumeration without off-chain indexers. + +### Active Region Queries + +```solidity +// Get all countries with at least one active fleet +uint16[] memory countries = fleetIdentity.getActiveCountries(); +// Returns: [840, 276, 392, ...] (ISO 3166-1 numeric codes) + +// Get all admin areas with at least one active fleet +uint32[] memory adminAreas = fleetIdentity.getActiveAdminAreas(); +// Returns: [860165, 282629, ...] (encoded region keys) +``` + +### Tier Data Queries + +```solidity +// Number of tiers in a region +uint256 tierCount = fleetIdentity.regionTierCount(regionKey); + +// Members in a specific tier +uint256[] memory tokenIds = fleetIdentity.getTierMembers(regionKey, tier); + +// UUIDs in a specific tier +bytes16[] memory uuids = fleetIdentity.getTierUuids(regionKey, tier); + +// Member count +uint256 count = fleetIdentity.tierMemberCount(regionKey, tier); +``` + +### Index Maintenance + +Indexes are automatically maintained: + +- **Registration**: Adds region to index if first fleet in that region +- **Burn/Demote**: Removes region from index if last fleet leaves + +```mermaid +flowchart LR + REG[registerFleet*] --> CHECK{First in region?} + CHECK -->|Yes| ADD[Add to _activeCountries
or _activeAdminAreas] + CHECK -->|No| SKIP[Already indexed] + + BURN[burn / demotion
empties region] --> TRIM[_trimTierCount] + TRIM --> REMOVE{Region empty?} + REMOVE -->|Yes| DEL[Remove from index] + REMOVE -->|No| KEEP[Keep in index] +``` + +## ISO 3166 Code Reference + +### Country Codes (ISO 3166-1 Numeric) + +| Code | Country | +| :--- | :------------- | +| 840 | United States | +| 276 | Germany | +| 250 | France | +| 392 | Japan | +| 826 | United Kingdom | +| 124 | Canada | + +See [iso3166-2/](iso3166-2/) directory for admin area mappings per country. + +### Admin Area Code Format + +Admin codes are 1-indexed integers mapped from ISO 3166-2 subdivisions: + +- Valid range: 1-255 (covers all real-world countries) +- Code 0 is invalid (reverts with `InvalidAdminCode()`) + +Example for US states: +| Admin Code | ISO 3166-2 | Name | +|:-----------|:-----------|:-----| +| 1 | AL | Alabama | +| 5 | CA | California | +| 32 | NY | New York | +| 43 | TX | Texas | + +See [iso3166-2/840-United_States.md](iso3166-2/840-United_States.md) for complete mapping. diff --git a/src/swarms/doc/sequence-lifecycle.md b/src/swarms/doc/sequence-lifecycle.md index cdcedfae..4fbbae3a 100644 --- a/src/swarms/doc/sequence-lifecycle.md +++ b/src/swarms/doc/sequence-lifecycle.md @@ -110,3 +110,214 @@ flowchart LR style D fill:#e74c3c,color:#fff style C fill:#2ecc71,color:#fff ``` + +--- + +# FleetIdentity Lifecycle + +## UUID Registration Level State Machine + +```mermaid +stateDiagram-v2 + [*] --> None : (default) + + None --> Owned : claimUuid() + None --> Local : registerFleetLocal() + None --> Country : registerFleetCountry() + + Owned --> Local : registerFleetLocal()
(burns owned token) + Owned --> Country : registerFleetCountry()
(burns owned token) + Owned --> [*] : releaseUuid() / burn() + + Local --> Owned : unregisterToOwned()
(single token only) + Local --> [*] : burn() all tokens + + Country --> Owned : unregisterToOwned()
(single token only) + Country --> [*] : burn() all tokens + + Note right of Owned : regionKey = 0
Bond = BASE_BOND + Note right of Local : regionKey = (cc<<10)|admin
Bond = BASE_BOND * 2^tier + Note right of Country : regionKey = cc
Bond = BASE_BOND * 16 * 2^tier +``` + +## UUID Claim & Release Flow + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + rect rgb(240, 248, 255) + Note right of FO: Claim UUID ownership + FO ->>+ TOKEN: approve(FleetIdentity, BASE_BOND) + TOKEN -->>- FO: ✓ + FO ->>+ FI: claimUuid(uuid) + Note over FI: uuidOwner[uuid] = msg.sender + Note over FI: uuidLevel[uuid] = Owned + Note over FI: uuidTokenCount[uuid] = 1 + FI ->>+ TOKEN: transferFrom(owner, this, BASE_BOND) + TOKEN -->>- FI: ✓ + FI -->>- FO: tokenId = uint128(uuid) + end + + rect rgb(255, 248, 240) + Note right of FO: Later: Release UUID + FO ->>+ FI: releaseUuid(uuid) + Note over FI: Require uuidLevel == Owned + Note over FI: Require uuidOwner == msg.sender + Note over FI: Burns token, clears ownership + FI ->>+ TOKEN: transfer(owner, BASE_BOND) + TOKEN -->>- FI: ✓ + FI -->>- FO: ✓ UUID can be claimed by anyone + end +``` + +## Owned → Registered Transition + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + Note over FO: UUID already claimed (has owned token) + + rect rgb(240, 255, 240) + FO ->>+ FI: registerFleetLocal(uuid, cc, admin, tier) + Note over FI: Verify uuidOwner[uuid] == msg.sender + Note over FI: Burns owned token (region=0) + Note over FI: uuidLevel = Local + Note over FI: Mints new regional token + Note over FI: incrementalBond = tierBond - BASE_BOND + FI ->>+ TOKEN: transferFrom(owner, this, incrementalBond) + TOKEN -->>- FI: ✓ + FI -->>- FO: tokenId = ((cc<<10|admin)<<128) | uuid + end +``` + +## Registered → Owned Transition (Unregister) + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + Note over FO: Has registered fleet token + Note over FO: Must be ONLY token for this UUID + + rect rgb(255, 248, 240) + FO ->>+ FI: unregisterToOwned(tokenId) + Note over FI: Verify single token for UUID + Note over FI: Calculate refund = tierBond - BASE_BOND + Note over FI: Remove from tier structures + Note over FI: Burn regional token + Note over FI: uuidLevel = Owned + Note over FI: Mint owned token (region=0) + FI ->>+ TOKEN: transfer(owner, refund) + TOKEN -->>- FI: ✓ + FI -->>- FO: newTokenId = uint128(uuid) + end +``` + +## Multi-Region Registration + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + Note over FO: UUID already registered (Local or Country) + + rect rgb(240, 248, 255) + Note right of FO: Register same UUID in additional region + FO ->>+ FI: registerFleetLocal(uuid, DE, 1, tier) + Note over FI: Verify uuidOwner[uuid] == msg.sender + Note over FI: Verify uuidLevel matches (Local) + Note over FI: uuidTokenCount++ + Note over FI: Mint new token for DE region + FI -->>- FO: tokenId = ((DE<<10|1)<<128) | uuid + end + + Note over FO: Now has 2 tokens for same UUID + Note over FO: Cannot unregisterToOwned (multiple tokens) +``` + +## Fleet Tier Promotion & Demotion + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + rect rgb(240, 255, 240) + Note right of FO: Promote to higher tier + FO ->>+ FI: promote(tokenId) + Note over FI: targetTier = currentTier + 1 + Note over FI: Verify tier has capacity + Note over FI: additionalBond = tierBond(new) - tierBond(old) + FI ->>+ TOKEN: transferFrom(owner, this, additionalBond) + TOKEN -->>- FI: ✓ + FI -->>- FO: ✓ FleetPromoted event + end + + rect rgb(255, 248, 240) + Note right of FO: Demote to lower tier + FO ->>+ FI: reassignTier(tokenId, lowerTier) + Note over FI: Verify tier has capacity + Note over FI: refund = tierBond(old) - tierBond(new) + FI ->>+ TOKEN: transfer(owner, refund) + TOKEN -->>- FI: ✓ + FI -->>- FO: ✓ FleetDemoted event + end +``` + +## Fleet Burn (Registered vs Owned) + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + alt Burning registered fleet token + FO ->>+ FI: burn(tokenId) + Note over FI: region = tokenRegion(tokenId) + Note over FI: refund = tierBond(tier, isCountry) + Note over FI: Remove from tier structures + Note over FI: Decrement uuidTokenCount + Note over FI: If count == 0: clear uuidOwner + FI ->>+ TOKEN: transfer(owner, refund) + TOKEN -->>- FI: ✓ + FI -->>- FO: ✓ FleetBurned event + else Burning owned-only token + FO ->>+ FI: burn(tokenId) + Note over FI: region = 0 (owned) + Note over FI: refund = BASE_BOND + Note over FI: Clear UUID ownership + FI ->>+ TOKEN: transfer(owner, BASE_BOND) + TOKEN -->>- FI: ✓ + FI -->>- FO: ✓ FleetBurned event + end +``` + +## Owned-Only Token Transfer + +```mermaid +sequenceDiagram + actor ALICE as Alice (Seller) + actor BOB as Bob (Buyer) + participant FI as FleetIdentity + + Note over ALICE: Owns UUID in Owned state + + ALICE ->>+ FI: transferFrom(alice, bob, tokenId) + Note over FI: region = 0 (owned-only) + Note over FI: ERC721 transfer executes + Note over FI: uuidOwner[uuid] = bob + FI -->>- BOB: ✓ Token transferred + + Note over BOB: Now owns UUID
Can register to regions
Can release for BASE_BOND refund +``` diff --git a/src/swarms/doc/sequence-registration.md b/src/swarms/doc/sequence-registration.md index 37c44833..88b59d39 100644 --- a/src/swarms/doc/sequence-registration.md +++ b/src/swarms/doc/sequence-registration.md @@ -14,10 +14,28 @@ sequenceDiagram 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) + alt Option A: Direct Registration (Local) + FO ->>+ FI: registerFleetLocal(uuid, 840, 5, tier) + Note over FI: countryCode=840 (US), adminCode=5 + Note over FI: Bond = BASE_BOND * 2^tier + Note over FI: Sets uuidOwner, uuidLevel=Local + FI -->>- FO: tokenId = ((840<<10|5)<<128) | uuid + else Option B: Direct Registration (Country) + FO ->>+ FI: registerFleetCountry(uuid, 840, tier) + Note over FI: Bond = BASE_BOND * 16 * 2^tier + Note over FI: Sets uuidOwner, uuidLevel=Country + FI -->>- FO: tokenId = (840<<128) | uuid + else Option C: Claim-First Flow + FO ->>+ FI: claimUuid(uuid) + Note over FI: Costs BASE_BOND + Note over FI: Sets uuidLevel=Owned + FI -->>- FO: tokenId = uuid (regionKey=0) + Note over FO: Later: register with incremental bond + FO ->>+ FI: registerFleetLocal(uuid, 840, 5, tier) + Note over FI: Burns owned token + Note over FI: Only pulls (tierBond - BASE_BOND) + FI -->>- FO: tokenId = ((840<<10|5)<<128) | uuid + end PRV ->>+ SP: registerProvider(url) SP -->>- PRV: providerId = keccak256(url) From ba1e52794a1b8aadfac91b1cbced5b9089d3ee8f Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Thu, 26 Feb 2026 15:31:14 +1300 Subject: [PATCH 55/63] feat: add buildCountryOnlyBundle and fleet maintenance documentation - Add buildCountryOnlyBundle() function for country-only fleet position verification - Add comprehensive fleet maintenance guide with periodic inclusion checking - Document country fleet maintenance when no active admin areas exist - Add 6 new tests for buildCountryOnlyBundle functionality - Update sequence-fleet-identity.md with country-only bundle info - Update README with new maintenance documentation link --- logs/deploy_l1_bridge.log | 32 ++ logs/deploy_l1_nodl.log | 29 ++ logs/deploy_l2_bridge.log | 98 ++++ src/swarms/FleetIdentity.sol | 28 + src/swarms/doc/README.md | 17 +- src/swarms/doc/sequence-fleet-identity.md | 20 + src/swarms/doc/sequence-fleet-maintenance.md | 520 +++++++++++++++++++ test/FleetIdentity.t.sol | 87 ++++ typescript | 3 + 9 files changed, 826 insertions(+), 8 deletions(-) create mode 100644 logs/deploy_l1_bridge.log create mode 100644 logs/deploy_l1_nodl.log create mode 100644 logs/deploy_l2_bridge.log create mode 100644 src/swarms/doc/sequence-fleet-maintenance.md create mode 100644 typescript diff --git a/logs/deploy_l1_bridge.log b/logs/deploy_l1_bridge.log new file mode 100644 index 00000000..b3ac3d42 --- /dev/null +++ b/logs/deploy_l1_bridge.log @@ -0,0 +1,32 @@ +Compiling 1 files with Solc 0.8.26 +Solc 0.8.26 finished in 2.60s +Compiler run successful! +Script ran successfully. + +== Logs == + Deployed L1Bridge at 0x2D02b651Ea9630351719c8c55210e042e940d69a + Granted MINTER_ROLE on NodlL1(0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990) to bridge + +## Setting up 1 EVM. + +========================== + +Chain 1 + +Estimated gas price: 0.222068762 gwei + +Estimated total gas used for script: 2685066 + +Estimated amount required: 0.000596269282508292 ETH + +========================== + + +========================== + +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. + +Transactions saved to: /Users/alex/Documents/rollup/broadcast/DeployL1Bridge.s.sol/1/run-latest.json + +Sensitive values saved to: /Users/alex/Documents/rollup/cache/DeployL1Bridge.s.sol/1/run-latest.json + diff --git a/logs/deploy_l1_nodl.log b/logs/deploy_l1_nodl.log new file mode 100644 index 00000000..ae13ee1e --- /dev/null +++ b/logs/deploy_l1_nodl.log @@ -0,0 +1,29 @@ +No files changed, compilation skipped +Script ran successfully. + +== Logs == + Deployed L1Nodl at 0x6dd0E17ec6fE56c5f58a0Fe2Bb813B9b5cc25990 + +## Setting up 1 EVM. + +========================== + +Chain 1 + +Estimated gas price: 0.251645298 gwei + +Estimated total gas used for script: 4998146 + +Estimated amount required: 0.001257759939617508 ETH + +========================== + + +========================== + +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. + +Transactions saved to: /Users/alex/Documents/rollup/broadcast/DeployL1Nodl.s.sol/1/run-latest.json + +Sensitive values saved to: /Users/alex/Documents/rollup/cache/DeployL1Nodl.s.sol/1/run-latest.json + diff --git a/logs/deploy_l2_bridge.log b/logs/deploy_l2_bridge.log new file mode 100644 index 00000000..5047416e --- /dev/null +++ b/logs/deploy_l2_bridge.log @@ -0,0 +1,98 @@ +Compiling 1 files with Solc 0.8.26 +Solc 0.8.26 finished in 1.70s +Compiler run successful! + +Compiling 1 files with zksolc and solc 0.8.26 +zksolc and solc 0.8.26 finished in 4.05s +Compiler run successful with warnings: +Warning +ZKsync Era comes with native account abstraction support, and therefore the initiator of a +transaction might be different from the contract calling your code. It is highly recommended NOT +to rely on tx.origin, but use msg.sender instead. +Learn more about Account Abstraction at https://docs.zksync.io/build/developer-reference/account-abstraction/ +You may disable this warning with: + a. `suppressedWarnings = ["txorigin"]` in standard JSON. + b. `--suppress-warnings txorigin` in the CLI. + --> lib/era-contracts/l1-contracts/contracts/vendor/AddressAliasHelper.sol:56:42 | + 56 | _recipient = _prevMsgSender == tx.origin + | ^^^^^^^^^ + +Warning +EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. +In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. +In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. +Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 +You may disable this warning with: + 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. + 2. `--suppress-warnings assemblycreate` in the CLI. + --> lib/forge-std/src/StdCheats.sol:494:19 | + 494 | addr := create(0, add(bytecode, 0x20), mload(bytecode)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Warning +EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. +In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. +In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. +Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 +You may disable this warning with: + 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. + 2. `--suppress-warnings assemblycreate` in the CLI. + --> lib/forge-std/src/StdCheats.sol:504:19 | + 504 | addr := create(0, add(bytecode, 0x20), mload(bytecode)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Warning +EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. +In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. +In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. +Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 +You may disable this warning with: + 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. + 2. `--suppress-warnings assemblycreate` in the CLI. + --> lib/forge-std/src/StdCheats.sol:515:19 | + 515 | addr := create(val, add(bytecode, 0x20), mload(bytecode)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Warning +EraVM does not use bytecode for contract deployment. Instead, it refers to contracts using their bytecode hashes. +In order to deploy a contract, please use the `new` operator in Solidity instead of raw 'create'/'create2' in assembly. +In Solidity v0.6 and older, it can be a false-positive warning if there is 'create(' or 'create2(' in comments within assembly. +Learn more about CREATE/CREATE2 EraVM limitations at https://docs.zksync.io/zksync-protocol/differences/evm-instructions#create-create2 +You may disable this warning with: + 1. `suppressedWarnings = ["assemblycreate"]` in standard JSON. + 2. `--suppress-warnings assemblycreate` in the CLI. + --> lib/forge-std/src/StdCheats.sol:525:19 | + 525 | addr := create(val, add(bytecode, 0x20), mload(bytecode)) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +2025-10-15T00:35:52.095529Z ERROR backendhandler: failed to get block err=failed to get block; error sending request for url (https://mainnet.era.zksync.io/); operation timed out number=65260273 +2025-10-15T00:35:52.096034Z ERROR sharedbackend: Failed to send/recv `block_hash` err=failed to get block hash for 65260273: failed to get block; error sending request for url (https://mainnet.era.zksync.io/); operation timed out number=65260273 +Script ran successfully. + +== Logs == + Deployed L2Bridge at 0x2c1B65dA72d5Cf19b41dE6eDcCFB7DD83d1B529E + Granted MINTER_ROLE on NODL(0xBD4372e44c5eE654dd838304006E1f0f69983154) to bridge + +## Setting up 1 EVM. + +========================== + +Chain 324 + +Estimated gas price: 0.090500001 gwei + +Estimated total gas used for script: 209410861 + +Estimated amount required: 0.018951683129910861 ETH + +========================== + + +========================== + +ONCHAIN EXECUTION COMPLETE & SUCCESSFUL. + +Transactions saved to: /Users/alex/Documents/rollup/broadcast/DeployL2Bridge.s.sol/324/run-latest.json + +Sensitive values saved to: /Users/alex/Documents/rollup/cache/DeployL2Bridge.s.sol/324/run-latest.json + diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index d8a8d208..323d6769 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -534,6 +534,34 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { (uuids, count, , ) = _buildHighestBondedUuidBundle(countryKey, adminKey); } + /// @notice Builds a bundle containing ONLY country-level fleets for a country. + /// Use this when no admin areas are active to verify country fleet positions. + /// + /// @dev When no admin areas exist in a country, EdgeBeaconScanners are not yet + /// active there. This function lets country fleet owners inspect their + /// competitive position before scanners come online. + /// + /// The returned bundle represents the country-only contribution to any + /// future admin-area bundle. Local fleets (when they appear) will have + /// priority over country fleets at the same tier. + /// + /// @param countryCode ISO 3166-1 numeric country code (1-999). + /// @return uuids The country-only UUID bundle (up to 20). + /// @return count Actual number of UUIDs returned. + function buildCountryOnlyBundle(uint16 countryCode) + external + view + returns (bytes16[] memory uuids, uint256 count) + { + if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); + + uint32 countryKey = uint32(countryCode); + // Use a virtual admin region with no members (adminCode=0) + uint32 adminKey = makeAdminRegion(countryCode, 0); + + (uuids, count, , ) = _buildHighestBondedUuidBundle(countryKey, adminKey); + } + /// @dev Internal bundle builder that returns additional state for `_findCheapestInclusionTier`. /// /// Builds a priority-ordered bundle by descending from highestTier to tier 0, diff --git a/src/swarms/doc/README.md b/src/swarms/doc/README.md index 8dd517a9..17dbbf80 100644 --- a/src/swarms/doc/README.md +++ b/src/swarms/doc/README.md @@ -4,14 +4,15 @@ This directory contains documentation for the Swarm System smart contracts. ## Documentation Index -| Document | Description | -| :------------------------------------------------------- | :--------------------------------------------- | -| [assistant-guide.md](assistant-guide.md) | Architecture overview and workflows | -| [graph-architecture.md](graph-architecture.md) | Contract architecture diagrams | -| [sequence-registration.md](sequence-registration.md) | Fleet & swarm registration flows | -| [sequence-lifecycle.md](sequence-lifecycle.md) | Swarm & fleet lifecycle (update, delete, burn) | -| [sequence-discovery.md](sequence-discovery.md) | Client discovery flows | -| [sequence-fleet-identity.md](sequence-fleet-identity.md) | FleetIdentity: regions, tiers, bundles | +| Document | Description | +| :------------------------------------------------------------- | :--------------------------------------------- | +| [assistant-guide.md](assistant-guide.md) | Architecture overview and workflows | +| [graph-architecture.md](graph-architecture.md) | Contract architecture diagrams | +| [sequence-registration.md](sequence-registration.md) | Fleet & swarm registration flows | +| [sequence-lifecycle.md](sequence-lifecycle.md) | Swarm & fleet lifecycle (update, delete, burn) | +| [sequence-discovery.md](sequence-discovery.md) | Client discovery flows | +| [sequence-fleet-identity.md](sequence-fleet-identity.md) | FleetIdentity: regions, tiers, bundles | +| [sequence-fleet-maintenance.md](sequence-fleet-maintenance.md) | Fleet maintenance: staying included in bundles | --- diff --git a/src/swarms/doc/sequence-fleet-identity.md b/src/swarms/doc/sequence-fleet-identity.md index d43fb30d..9b38c82d 100644 --- a/src/swarms/doc/sequence-fleet-identity.md +++ b/src/swarms/doc/sequence-fleet-identity.md @@ -157,6 +157,22 @@ flowchart TD - Tier 1: C, D, E, F (local), then Q, R (country) - Tier 0: G (local), then S, T, U, V (country) +### Country-Only Bundle + +When no admin areas are active in a country (no EdgeBeaconScanners deployed yet), use `buildCountryOnlyBundle(countryCode)` to inspect country-level competition. + +```solidity +// Get bundle with ONLY country-level fleets +(bytes16[] memory uuids, uint256 count) = fleetIdentity.buildCountryOnlyBundle(840); +``` + +This is useful for: + +- Verifying your position among other country-level competitors before scanners come online +- Understanding what your bundle contribution will be when admin areas become active + +**Note:** When admin areas do become active, local fleets at the same tier will have priority over country fleets. + ## Multi-Region Registration The same UUID can be registered in multiple regions at the same level. @@ -271,3 +287,7 @@ Example for US states: | 43 | TX | Texas | See [iso3166-2/840-United_States.md](iso3166-2/840-United_States.md) for complete mapping. + +## Related Documentation + +- **[sequence-fleet-maintenance.md](sequence-fleet-maintenance.md)** — Fleet maintenance guide: checking bundle inclusion, tier reassignment, bond optimization, and handling market competition. diff --git a/src/swarms/doc/sequence-fleet-maintenance.md b/src/swarms/doc/sequence-fleet-maintenance.md new file mode 100644 index 00000000..add47f79 --- /dev/null +++ b/src/swarms/doc/sequence-fleet-maintenance.md @@ -0,0 +1,520 @@ +# Fleet Maintenance: Staying Included in Bundles + +This document covers the ongoing maintenance flows that fleet owners must follow to ensure their UUIDs remain included in EdgeBeaconScanner bundles as market conditions change. + +## Overview + +After registering a fleet in one or more regions, the competitive landscape can change: + +- New fleets may register at higher tiers +- Existing fleets may promote to higher tiers +- Bundle slots are limited (20 max per location) + +Fleet owners should periodically check their inclusion status and adjust their tier if necessary. + +## Maintenance Cycle + +```mermaid +flowchart TD + START([Fleet Registered]) --> WAIT[Wait ~24 hours] + WAIT --> CHECK{Check inclusion
in target bundles} + + CHECK -->|Included| OPTIMIZE{Want to
minimize bond?} + CHECK -->|Not Included| HINT[Get inclusion hint] + + OPTIMIZE -->|Yes| DEMOTE_CHECK[Check if lower tier
still guarantees inclusion] + OPTIMIZE -->|No| WAIT + + DEMOTE_CHECK -->|Can demote| DEMOTE[reassignTier to lower tier
→ receive refund] + DEMOTE_CHECK -->|Stay| WAIT + DEMOTE --> WAIT + + HINT --> APPROVE[Approve bond difference] + APPROVE --> REASSIGN[reassignTier to suggested tier] + + REASSIGN -->|Success| PROPAGATE[Wait for edge network
to propagate changes] + REASSIGN -->|TierFull| HINT + + PROPAGATE --> WAIT + + style START fill:#4a9eff,color:#fff + style REASSIGN fill:#2ecc71,color:#fff + style DEMOTE fill:#2ecc71,color:#fff +``` + +## Checking Inclusion Status + +### For Local (Admin Area) Fleets + +Local fleets only need to check the single admin area where they're registered. + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + Note over FO: Check if my UUID is in the bundle + FO ->>+ FI: buildHighestBondedUuidBundle(countryCode, adminCode) + FI -->>- FO: (uuids[], count) + + alt My UUID in uuids[] + Note over FO: ✓ Still included, no action needed + else My UUID not in uuids[] + Note over FO: ⚠️ Need to promote to higher tier + end +``` + +**Example (TypeScript):** + +```typescript +const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle(840, 5); // US-California +const myUuid = "0x" + myUuidBytes.toString("hex"); +const isIncluded = uuids + .slice(0, count) + .some((u) => u.toLowerCase() === myUuid.toLowerCase()); + +if (!isIncluded) { + console.log("Fleet dropped from bundle - need to promote"); +} +``` + +### For Country Fleets + +Country fleets must check **every active admin area** in their country. A country fleet pays 16× more to appear in ALL bundles within the country. + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + Note over FO: Get all active admin areas + FO ->>+ FI: getActiveAdminAreas() + FI -->>- FO: adminRegionKeys[] + + loop For each admin area in my country + Note over FO: Extract countryCode from regionKey + Note over FO: Filter: (regionKey >> 10) == myCountryCode + FO ->>+ FI: buildHighestBondedUuidBundle(countryCode, adminCode) + FI -->>- FO: (uuids[], count) + Note over FO: Check if my UUID is included + end + + alt Included in ALL admin areas + Note over FO: ✓ Full inclusion maintained + else Missing from some areas + Note over FO: ⚠️ Need to promote or use countryInclusionHint + end +``` + +**Example (TypeScript):** + +```typescript +const myCountryCode = 840; // US +const adminAreas = await fleetIdentity.getActiveAdminAreas(); + +// Filter to admin areas in my country +const myAdminAreas = adminAreas.filter( + (rk) => Number(rk >> 10n) === myCountryCode, +); + +// Check each bundle +const missingAreas: number[] = []; +for (const rk of myAdminAreas) { + const adminCode = Number(rk & 0x3ffn); + const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle( + myCountryCode, + adminCode, + ); + const isIncluded = uuids + .slice(0, count) + .some((u) => u.toLowerCase() === myUuid.toLowerCase()); + if (!isIncluded) { + missingAreas.push(adminCode); + } +} + +if (missingAreas.length > 0) { + console.log( + `Missing from ${missingAreas.length} admin areas - need to promote`, + ); +} +``` + +### For Country Fleets: No Active Admin Areas + +When there are **no active admin areas** in your country (no EdgeBeaconScanners deployed yet), use `buildCountryOnlyBundle` to verify your position among other country-level competitors. + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + Note over FO: Check if any admin areas are active + FO ->>+ FI: getActiveAdminAreas() + FI -->>- FO: adminRegionKeys[] + + alt No admin areas for my country + Note over FO: No EdgeBeaconScanners yet
Use country-only bundle + FO ->>+ FI: buildCountryOnlyBundle(countryCode) + FI -->>- FO: (uuids[], count) + Note over FO: Check my position among
other country fleets + else Admin areas exist + Note over FO: Use buildHighestBondedUuidBundle
for each admin area + end +``` + +**Example (TypeScript):** + +```typescript +const myCountryCode = 840; // US +const adminAreas = await fleetIdentity.getActiveAdminAreas(); + +// Filter to admin areas in my country +const myAdminAreas = adminAreas.filter( + (rk) => Number(rk >> 10n) === myCountryCode, +); + +if (myAdminAreas.length === 0) { + // No admin areas active - check country-only bundle + const [uuids, count] = await fleetIdentity.buildCountryOnlyBundle( + myCountryCode, + ); + const isIncluded = uuids + .slice(0, count) + .some((u) => u.toLowerCase() === myUuid.toLowerCase()); + + if (isIncluded) { + console.log("✓ Included in country-only bundle"); + console.log( + "Note: No EdgeBeaconScanners active yet - waiting for admin areas", + ); + } else { + console.log("⚠️ Not in country-only bundle - consider promoting"); + } +} else { + // Check each active admin area + // ... (see previous example) +} +``` + +**Key Points for Country-Only Scenarios:** + +1. **No EdgeBeaconScanners yet:** If no admin areas are active, there are no scanners to discover your fleet. +2. **Use `buildCountryOnlyBundle`:** Verify your position among other country-level competitors. +3. **Use `countryInclusionHint`:** Works correctly even with no active admin areas - it calculates the tier needed for country-only competition. +4. **Prepare for locals:** When admin areas become active, local fleets will have priority over country fleets at the same tier. Consider using `countryInclusionHint` periodically to stay ahead. + +## Getting the Required Tier + +### For Local Fleets + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + FO ->>+ FI: localInclusionHint(countryCode, adminCode) + FI -->>- FO: (inclusionTier, requiredBond) + + Note over FO: The returned tier guarantees
inclusion in this specific bundle +``` + +### For Country Fleets + +Use `countryInclusionHint` to find the tier that guarantees inclusion across **all** active admin areas in the country. + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + FO ->>+ FI: countryInclusionHint(countryCode) + Note over FI: Scans ALL active admin areas
in the country (unbounded, free off-chain) + FI -->>- FO: (inclusionTier, requiredBond) + + Note over FO: This tier guarantees inclusion
in EVERY bundle in the country +``` + +**Alternative: Blind Promote** + +Instead of computing the exact required tier, you can simply promote by one tier and check if it resolves the issue: + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + FO ->>+ FI: promote(tokenId) + Note over FI: Moves to currentTier + 1 + FI -->>- FO: ✓ FleetPromoted + + Note over FO: Re-check bundles after
edge network propagates +``` + +This is simpler but may overpay if a smaller promotion would suffice. + +## Reassigning Tiers + +### Bond Approval Required + +Before calling `reassignTier` to a **higher** tier, you must approve the bond difference: + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + Note over FO: Calculate bond difference + FO ->>+ FI: tierBond(currentTier, isCountry) + FI -->>- FO: currentBond + FO ->>+ FI: tierBond(targetTier, isCountry) + FI -->>- FO: targetBond + + Note over FO: additionalBond = targetBond - currentBond + + FO ->>+ TOKEN: approve(FleetIdentity, additionalBond) + TOKEN -->>- FO: ✓ + + FO ->>+ FI: reassignTier(tokenId, targetTier) + FI ->>+ TOKEN: transferFrom(owner, this, additionalBond) + TOKEN -->>- FI: ✓ + FI -->>- FO: ✓ FleetPromoted event +``` + +**Example (TypeScript):** + +```typescript +const currentTier = await fleetIdentity.fleetTier(tokenId); +const isCountry = tokenRegion <= 999; // Country codes are 1-999 + +const currentBond = await fleetIdentity.tierBond(currentTier, isCountry); +const targetBond = await fleetIdentity.tierBond(targetTier, isCountry); +const additionalBond = targetBond - currentBond; + +// Approve the bond difference +await bondToken.approve(fleetIdentity.address, additionalBond); + +// Reassign to higher tier +await fleetIdentity.reassignTier(tokenId, targetTier); +``` + +### Handling Race Conditions + +If `reassignTier` reverts with `TierFull`, another fleet claimed the slot first. Re-query the inclusion hint and retry: + +```mermaid +flowchart TD + HINT[Get inclusionHint] --> REASSIGN[reassignTier] + REASSIGN -->|Success| DONE([✓ Promoted]) + REASSIGN -->|TierFull| WAIT[Brief delay] + WAIT --> HINT + + style DONE fill:#2ecc71,color:#fff +``` + +In practice, this race condition is rare (only occurs when multiple fleets compete for the last slot in a tier). Most fleet owners will succeed on the first or second attempt. + +## Minimizing Bond (Demoting) + +Fleet owners who want to keep their bond minimal can periodically check if a lower tier still guarantees inclusion. + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + Note over FO: Check if I can demote + FO ->>+ FI: localInclusionHint(cc, admin) + FI -->>- FO: (suggestedTier, bond) + + FO ->>+ FI: fleetTier(tokenId) + FI -->>- FO: currentTier + + alt suggestedTier < currentTier + Note over FO: Can save NODL by demoting! + FO ->>+ FI: reassignTier(tokenId, suggestedTier) + Note over FI: refund = currentBond - newBond + FI ->>+ TOKEN: transfer(owner, refund) + TOKEN -->>- FI: ✓ + FI -->>- FO: ✓ FleetDemoted event + else suggestedTier >= currentTier + Note over FO: No demotion possible + end +``` + +**Example (TypeScript):** + +```typescript +// Check recommended tier for my location +const [suggestedTier] = await fleetIdentity.localInclusionHint( + countryCode, + adminCode, +); +const currentTier = await fleetIdentity.fleetTier(tokenId); + +if (suggestedTier < currentTier) { + // Can demote and get a refund! + const refund = + (await fleetIdentity.tierBond(currentTier, isCountry)) - + (await fleetIdentity.tierBond(suggestedTier, isCountry)); + console.log( + `Can demote from tier ${currentTier} to ${suggestedTier}, refund: ${refund}`, + ); + + // No approval needed for demotion (refunds, doesn't pull) + await fleetIdentity.reassignTier(tokenId, suggestedTier); +} +``` + +## Edge Network Propagation + +After any tier change (promotion or demotion), the edge network takes time to react: + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant EDGE as Edge Network + + FO ->>+ FI: reassignTier(tokenId, newTier) + FI -->>- FO: ✓ Event emitted + + Note over FI,EDGE: Block finalized on L2 + + EDGE ->>+ FI: Monitor FleetPromoted/Demoted events + FI -->>- EDGE: Event data + + Note over EDGE: Fetch updated bundle
buildHighestBondedUuidBundle() + + Note over EDGE: Propagate to EdgeBeaconScanners
(~minutes to hours depending on network) + + Note over FO: Fleet now appears in
EdgeBeaconScanner bundle +``` + +**Timing Expectations:** + +| Phase | Duration | +| :----------------------- | :----------------------- | +| Transaction confirmation | ~1-2 seconds (ZkSync) | +| Event indexing | ~1-10 seconds | +| Edge network sync | ~minutes to hours | +| EdgeBeaconScanner update | Varies by implementation | + +Fleet owners should not expect immediate inclusion after a tier change. A 24-hour check interval is recommended for routine maintenance. + +## Complete Maintenance Example + +```typescript +async function maintainFleetInclusion( + fleetIdentity: FleetIdentity, + bondToken: ERC20, + tokenId: bigint, + myUuid: string, + countryCode: number, + adminCode: number, +) { + // 1. Check current inclusion + const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle( + countryCode, + adminCode, + ); + const isIncluded = uuids + .slice(0, Number(count)) + .some((u) => u.toLowerCase() === myUuid.toLowerCase()); + + if (isIncluded) { + console.log("✓ Fleet is included in bundle"); + + // Optional: Check if we can demote to save bond + const [suggestedTier] = await fleetIdentity.localInclusionHint( + countryCode, + adminCode, + ); + const currentTier = await fleetIdentity.fleetTier(tokenId); + + if (suggestedTier < currentTier) { + console.log(`Can demote from tier ${currentTier} to ${suggestedTier}`); + await fleetIdentity.reassignTier(tokenId, suggestedTier); + } + return; + } + + // 2. Not included - get required tier + const [requiredTier, requiredBond] = await fleetIdentity.localInclusionHint( + countryCode, + adminCode, + ); + const currentTier = await fleetIdentity.fleetTier(tokenId); + const tokenRegion = await fleetIdentity.tokenRegion(tokenId); + const isCountry = tokenRegion <= 999; + + console.log( + `Fleet dropped. Current: tier ${currentTier}, Required: tier ${requiredTier}`, + ); + + // 3. Calculate and approve bond difference + const currentBond = await fleetIdentity.tierBond(currentTier, isCountry); + const targetBond = await fleetIdentity.tierBond(requiredTier, isCountry); + const additionalBond = targetBond - currentBond; + + await bondToken.approve(await fleetIdentity.getAddress(), additionalBond); + + // 4. Reassign tier (with retry on TierFull) + let attempts = 0; + while (attempts < 3) { + try { + await fleetIdentity.reassignTier(tokenId, requiredTier); + console.log(`✓ Promoted to tier ${requiredTier}`); + return; + } catch (e: any) { + if (e.message.includes("TierFull")) { + attempts++; + console.log(`Tier ${requiredTier} full, re-checking hint...`); + const [newTier] = await fleetIdentity.localInclusionHint( + countryCode, + adminCode, + ); + requiredTier = newTier; + // Re-approve if tier changed + const newTargetBond = await fleetIdentity.tierBond( + requiredTier, + isCountry, + ); + if (newTargetBond > targetBond) { + await bondToken.approve( + await fleetIdentity.getAddress(), + newTargetBond - currentBond, + ); + } + } else { + throw e; + } + } + } + + console.log("Failed after 3 attempts - market too volatile"); +} +``` + +## Summary + +| Task | Frequency | Method | +| :-------------------------- | :-------------- | :----------------------------- | +| Check inclusion | ~24 hours | `buildHighestBondedUuidBundle` | +| Get required tier (local) | As needed | `localInclusionHint` | +| Get required tier (country) | As needed | `countryInclusionHint` | +| Calculate bond | Before reassign | `tierBond(tier, isCountry)` | +| Move to new tier | As needed | `reassignTier(tokenId, tier)` | +| Quick promote | Alternative | `promote(tokenId)` | +| Check demotion opportunity | Optionally | Compare hint tier vs current | + +**Key Points:** + +1. Check inclusion periodically (~24 hours) +2. Use inclusion hints to find the cheapest tier that guarantees inclusion +3. Approve bond difference before promoting (`BOND_TOKEN.approve`) +4. Handle `TierFull` by re-querying the hint +5. Edge network propagation takes time after tier changes +6. Optionally demote when market conditions allow to minimize bond diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index ca5408bc..c23e1a27 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -2991,4 +2991,91 @@ contract FleetIdentityTest is Test { emit log_named_uint("Country fleets displaced", 0); emit log_named_uint("Country fleets still included", 4); } + + // ══════════════════════════════════════════════════════════════════════════════ + // buildCountryOnlyBundle tests + // ══════════════════════════════════════════════════════════════════════════════ + + function test_buildCountryOnlyBundle_emptyCountry() public view { + // No fleets registered yet + (bytes16[] memory uuids, uint256 count) = fleet.buildCountryOnlyBundle(US); + assertEq(count, 0, "Empty country should have 0 UUIDs"); + assertEq(uuids.length, 0, "Array should be trimmed to 0"); + } + + function test_buildCountryOnlyBundle_onlyCountryFleets() public { + // Register 3 country fleets at different tiers + vm.prank(alice); + fleet.registerFleetCountry(_uuid(1), US, 0); + vm.prank(alice); + fleet.registerFleetCountry(_uuid(2), US, 1); + vm.prank(alice); + fleet.registerFleetCountry(_uuid(3), US, 2); + + (bytes16[] memory uuids, uint256 count) = fleet.buildCountryOnlyBundle(US); + assertEq(count, 3, "Should include all 3 country fleets"); + + // Verify tier priority order (highest first) + assertEq(uuids[0], _uuid(3), "Tier 2 should be first"); + assertEq(uuids[1], _uuid(2), "Tier 1 should be second"); + assertEq(uuids[2], _uuid(1), "Tier 0 should be third"); + } + + function test_buildCountryOnlyBundle_excludesLocalFleets() public { + // Register country fleet + vm.prank(alice); + fleet.registerFleetCountry(_uuid(1), US, 0); + + // Register local fleet in same country + vm.prank(alice); + fleet.registerFleetLocal(_uuid(2), US, ADMIN_CA, 0); + + // Country-only bundle should ONLY include country fleet + (bytes16[] memory uuids, uint256 count) = fleet.buildCountryOnlyBundle(US); + assertEq(count, 1, "Should only include country fleet"); + assertEq(uuids[0], _uuid(1), "Should be the country fleet UUID"); + } + + function test_buildCountryOnlyBundle_respectsMaxBundleSize() public { + // Register 24 country fleets across 6 tiers (4 per tier = TIER_CAPACITY) + // This gives us more than MAX_BONDED_UUID_BUNDLE_SIZE (20) + for (uint256 tier = 0; tier < 6; tier++) { + for (uint256 i = 0; i < 4; i++) { + vm.prank(alice); + fleet.registerFleetCountry(_uuid(tier * 100 + i), US, tier); + } + } + + (bytes16[] memory uuids, uint256 count) = fleet.buildCountryOnlyBundle(US); + assertEq(count, 20, "Should cap at 20 UUIDs"); + assertEq(uuids.length, 20, "Array should be trimmed to 20"); + } + + function test_RevertIf_buildCountryOnlyBundle_invalidCountryCode() public { + vm.expectRevert(FleetIdentity.InvalidCountryCode.selector); + fleet.buildCountryOnlyBundle(0); + + vm.expectRevert(FleetIdentity.InvalidCountryCode.selector); + fleet.buildCountryOnlyBundle(1000); // > MAX_COUNTRY_CODE (999) + } + + function test_buildCountryOnlyBundle_multipleCountriesIndependent() public { + // Register in US (country 840) + vm.prank(alice); + fleet.registerFleetCountry(_uuid(1), US, 0); + + // Register in Germany (country 276) + vm.prank(alice); + fleet.registerFleetCountry(_uuid(2), DE, 0); + + // US bundle should only have US fleet + (bytes16[] memory usUuids, uint256 usCount) = fleet.buildCountryOnlyBundle(US); + assertEq(usCount, 1, "US should have 1 fleet"); + assertEq(usUuids[0], _uuid(1), "Should be US fleet"); + + // Germany bundle should only have Germany fleet + (bytes16[] memory deUuids, uint256 deCount) = fleet.buildCountryOnlyBundle(DE); + assertEq(deCount, 1, "Germany should have 1 fleet"); + assertEq(deUuids[0], _uuid(2), "Should be Germany fleet"); + } } diff --git a/typescript b/typescript new file mode 100644 index 00000000..00563f89 --- /dev/null +++ b/typescript @@ -0,0 +1,3 @@ +Script started on Fri Feb 13 12:41:51 2026 +% alex@Alexs-MacBook-Pro-2 rollup % [?2004h[?2004l +% alex@Alexs-MacBook-Pro-2 rollup % [?2004h \ No newline at end of file From 48b02ae1c398324e43bc7a0a36aa873b3788a9bc Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 27 Feb 2026 09:28:01 +1300 Subject: [PATCH 56/63] refactor: make swarms UUID-based instead of token ID-based - Change Swarm struct from fleetId (uint256) to fleetUuid (bytes16) - Update SwarmRegistryL1 and SwarmRegistryUniversal to use UUID ownership - Change mappings from fleetSwarms to uuidSwarms and swarmIndexInFleet to swarmIndexInUuid - Update all functions to verify ownership via FLEET_CONTRACT.uuidOwner(fleetUuid) - Update tests to reflect UUID-based architecture - Allows swarms to be defined for any registered UUID regardless of geographic tier - Update copilot-instructions.md with L1-only contract build requirements --- .github/copilot-instructions.md | 17 ++++ src/swarms/SwarmRegistryL1.sol | 109 ++++++++++++++------------ src/swarms/SwarmRegistryUniversal.sol | 109 ++++++++++++++------------ test/SwarmRegistryL1.t.sol | 99 ++++++++++++----------- test/SwarmRegistryUniversal.t.sol | 109 ++++++++++++++------------ 5 files changed, 242 insertions(+), 201 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7cdccfc5..37ae67c2 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,12 +1,14 @@ # 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. @@ -17,6 +19,7 @@ - Minimize on-chain storage; use events for off-chain indexing. ## Testing Standards + - **Framework**: Foundry (Forge). - **Methodology**: - **Unit Tests**: Comprehensive coverage for all functions. @@ -28,6 +31,20 @@ - `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). + +## L1-Only Contracts (No --zksync flag) + +The following contracts use opcodes/patterns incompatible with ZkSync Era and must be built/tested **without** the `--zksync` flag: + +- **SwarmRegistryL1**: Uses `SSTORE2` (relies on `EXTCODECOPY` which is unsupported on ZkSync). + +For these contracts, use: + +```bash +forge build --match-path src/swarms/SwarmRegistryL1.sol +forge test --match-path test/SwarmRegistryL1.t.sol +``` diff --git a/src/swarms/SwarmRegistryL1.sol b/src/swarms/SwarmRegistryL1.sol index 70da9db3..b9bf4f7b 100644 --- a/src/swarms/SwarmRegistryL1.sol +++ b/src/swarms/SwarmRegistryL1.sol @@ -13,11 +13,17 @@ 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. + * + * Swarms are defined for a **fleet UUID** (not a token ID), allowing swarms to be + * registered for any UUID that has been claimed/registered in FleetIdentity, + * regardless of whether it's assigned to a region or is in "owned-only" mode. + * This decouples swarm management from geographic tier placement. */ contract SwarmRegistryL1 is ReentrancyGuard { error InvalidFingerprintSize(); error InvalidFilterSize(); - error NotFleetOwner(); + error InvalidUuid(); + error NotUuidOwner(); error ProviderDoesNotExist(); error NotProviderOwner(); error SwarmNotFound(); @@ -42,7 +48,7 @@ contract SwarmRegistryL1 is ReentrancyGuard { } struct Swarm { - uint256 fleetId; // The Fleet UUID (as uint) + bytes16 fleetUuid; // Fleet UUID (not token ID) - allows swarms for any registered UUID uint256 providerId; // The Service Provider TokenID address filterPointer; // SSTORE2 pointer uint8 fingerprintSize; @@ -59,27 +65,27 @@ contract SwarmRegistryL1 is ReentrancyGuard { // SwarmID -> Swarm mapping(uint256 => Swarm) public swarms; - // FleetID -> List of SwarmIDs - mapping(uint256 => uint256[]) public fleetSwarms; + // UUID -> List of SwarmIDs (keyed by fleet UUID, not token ID) + mapping(bytes16 => uint256[]) public uuidSwarms; - // SwarmID -> index in fleetSwarms[fleetId] (for O(1) removal) - mapping(uint256 => uint256) public swarmIndexInFleet; + // SwarmID -> index in uuidSwarms[fleetUuid] (for O(1) removal) + mapping(uint256 => uint256) public swarmIndexInUuid; - event SwarmRegistered(uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner); + event SwarmRegistered(uint256 indexed swarmId, bytes16 indexed fleetUuid, 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); + event SwarmDeleted(uint256 indexed swarmId, bytes16 indexed fleetUuid, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, bytes16 indexed fleetUuid, 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) + /// @return swarmId keccak256(fleetUuid, providerId, filterData) + function computeSwarmId(bytes16 fleetUuid, uint256 providerId, bytes calldata filterData) public pure returns (uint256) { - return uint256(keccak256(abi.encode(fleetId, providerId, filterData))); + return uint256(keccak256(abi.encode(fleetUuid, providerId, filterData))); } constructor(address _fleetContract, address _providerContract) { @@ -90,20 +96,23 @@ contract SwarmRegistryL1 is ReentrancyGuard { PROVIDER_CONTRACT = ServiceProvider(_providerContract); } - /// @notice Registers a new swarm. Caller must own the fleet NFT. - /// @param fleetId Fleet token ID. + /// @notice Registers a new swarm. Caller must own the fleet UUID (via FleetIdentity.uuidOwner). + /// @param fleetUuid Fleet UUID (bytes16) - the UUID must be registered in FleetIdentity. /// @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, + bytes16 fleetUuid, uint256 providerId, bytes calldata filterData, uint8 fingerprintSize, TagType tagType ) external nonReentrant returns (uint256 swarmId) { + if (fleetUuid == bytes16(0)) { + revert InvalidUuid(); + } if (fingerprintSize == 0 || fingerprintSize > MAX_FINGERPRINT_SIZE) { revert InvalidFingerprintSize(); } @@ -111,32 +120,33 @@ contract SwarmRegistryL1 is ReentrancyGuard { revert InvalidFilterSize(); } - if (FLEET_CONTRACT.ownerOf(fleetId) != msg.sender) { - revert NotFleetOwner(); + // Check UUID ownership - works for any registered UUID regardless of region + if (FLEET_CONTRACT.uuidOwner(fleetUuid) != msg.sender) { + revert NotUuidOwner(); } if (PROVIDER_CONTRACT.ownerOf(providerId) == address(0)) { revert ProviderDoesNotExist(); } - swarmId = computeSwarmId(fleetId, providerId, filterData); + swarmId = computeSwarmId(fleetUuid, providerId, filterData); if (swarms[swarmId].filterPointer != address(0)) { revert SwarmAlreadyExists(); } Swarm storage s = swarms[swarmId]; - s.fleetId = fleetId; + s.fleetUuid = fleetUuid; s.providerId = providerId; s.fingerprintSize = fingerprintSize; s.tagType = tagType; s.status = SwarmStatus.REGISTERED; - fleetSwarms[fleetId].push(swarmId); - swarmIndexInFleet[swarmId] = fleetSwarms[fleetId].length - 1; + uuidSwarms[fleetUuid].push(swarmId); + swarmIndexInUuid[swarmId] = uuidSwarms[fleetUuid].length - 1; s.filterPointer = SSTORE2.write(filterData); - emit SwarmRegistered(swarmId, fleetId, providerId, msg.sender); + emit SwarmRegistered(swarmId, fleetUuid, providerId, msg.sender); } /// @notice Approves a swarm. Caller must own the provider NFT. @@ -171,7 +181,7 @@ contract SwarmRegistryL1 is ReentrancyGuard { emit SwarmStatusChanged(swarmId, SwarmStatus.REJECTED); } - /// @notice Replaces the XOR filter. Resets status to REGISTERED. Caller must own the fleet NFT. + /// @notice Replaces the XOR filter. Resets status to REGISTERED. Caller must own the fleet UUID. /// @param swarmId The swarm to update. /// @param newFilterData Replacement filter blob. function updateSwarmFilter(uint256 swarmId, bytes calldata newFilterData) external nonReentrant { @@ -179,8 +189,8 @@ contract SwarmRegistryL1 is ReentrancyGuard { if (s.filterPointer == address(0)) { revert SwarmNotFound(); } - if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { - revert NotFleetOwner(); + if (FLEET_CONTRACT.uuidOwner(s.fleetUuid) != msg.sender) { + revert NotUuidOwner(); } if (newFilterData.length == 0 || newFilterData.length > 24576) { revert InvalidFilterSize(); @@ -193,7 +203,7 @@ contract SwarmRegistryL1 is ReentrancyGuard { emit SwarmFilterUpdated(swarmId, msg.sender, uint32(newFilterData.length)); } - /// @notice Reassigns the service provider. Resets status to REGISTERED. Caller must own the fleet NFT. + /// @notice Reassigns the service provider. Resets status to REGISTERED. Caller must own the fleet UUID. /// @param swarmId The swarm to update. /// @param newProviderId New provider token ID. function updateSwarmProvider(uint256 swarmId, uint256 newProviderId) external { @@ -201,8 +211,8 @@ contract SwarmRegistryL1 is ReentrancyGuard { if (s.filterPointer == address(0)) { revert SwarmNotFound(); } - if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { - revert NotFleetOwner(); + if (FLEET_CONTRACT.uuidOwner(s.fleetUuid) != msg.sender) { + revert NotUuidOwner(); } if (PROVIDER_CONTRACT.ownerOf(newProviderId) == address(0)) { revert ProviderDoesNotExist(); @@ -217,39 +227,36 @@ contract SwarmRegistryL1 is ReentrancyGuard { emit SwarmProviderUpdated(swarmId, oldProvider, newProviderId); } - /// @notice Permanently deletes a swarm. Caller must own the fleet NFT. + /// @notice Permanently deletes a swarm. Caller must own the fleet UUID. /// @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(); + if (FLEET_CONTRACT.uuidOwner(s.fleetUuid) != msg.sender) { + revert NotUuidOwner(); } - uint256 fleetId = s.fleetId; + bytes16 fleetUuid = s.fleetUuid; - _removeFromFleetSwarms(fleetId, swarmId); + _removeFromUuidSwarms(fleetUuid, swarmId); delete swarms[swarmId]; - emit SwarmDeleted(swarmId, fleetId, msg.sender); + emit SwarmDeleted(swarmId, fleetUuid, msg.sender); } - /// @notice Returns whether the swarm's fleet and provider NFTs still exist (i.e. have not been burned). + /// @notice Returns whether the swarm's fleet UUID and provider NFT are still valid. /// @param swarmId The swarm to check. - /// @return fleetValid True if the fleet NFT exists. + /// @return fleetValid True if the fleet UUID is still owned (uuidOwner != address(0)). /// @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; - } + // Fleet is valid if UUID is still owned (not released) + fleetValid = FLEET_CONTRACT.uuidOwner(s.fleetUuid) != address(0); try PROVIDER_CONTRACT.ownerOf(s.providerId) returns (address) { providerValid = true; @@ -258,7 +265,7 @@ contract SwarmRegistryL1 is ReentrancyGuard { } } - /// @notice Permissionless-ly removes a swarm whose fleet or provider NFT has been burned. + /// @notice Permissionless-ly removes a swarm whose fleet UUID has been released or provider NFT has been burned. /// @param swarmId The orphaned swarm to purge. function purgeOrphanedSwarm(uint256 swarmId) external { Swarm storage s = swarms[swarmId]; @@ -267,13 +274,13 @@ contract SwarmRegistryL1 is ReentrancyGuard { (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); if (fleetValid && providerValid) revert SwarmNotOrphaned(); - uint256 fleetId = s.fleetId; + bytes16 fleetUuid = s.fleetUuid; - _removeFromFleetSwarms(fleetId, swarmId); + _removeFromUuidSwarms(fleetUuid, swarmId); delete swarms[swarmId]; - emit SwarmPurged(swarmId, fleetId, msg.sender); + emit SwarmPurged(swarmId, fleetUuid, msg.sender); } /// @notice Tests tag membership against the swarm's XOR filter. @@ -324,17 +331,17 @@ contract SwarmRegistryL1 is ReentrancyGuard { } /** - * @dev O(1) removal of a swarm from its fleet's swarm list using index tracking. + * @dev O(1) removal of a swarm from its UUID's swarm list using index tracking. */ - function _removeFromFleetSwarms(uint256 fleetId, uint256 swarmId) internal { - uint256[] storage arr = fleetSwarms[fleetId]; - uint256 index = swarmIndexInFleet[swarmId]; + function _removeFromUuidSwarms(bytes16 fleetUuid, uint256 swarmId) internal { + uint256[] storage arr = uuidSwarms[fleetUuid]; + uint256 index = swarmIndexInUuid[swarmId]; uint256 lastId = arr[arr.length - 1]; arr[index] = lastId; - swarmIndexInFleet[lastId] = index; + swarmIndexInUuid[lastId] = index; arr.pop(); - delete swarmIndexInFleet[swarmId]; + delete swarmIndexInUuid[swarmId]; } /** diff --git a/src/swarms/SwarmRegistryUniversal.sol b/src/swarms/SwarmRegistryUniversal.sol index 446312c3..cce43166 100644 --- a/src/swarms/SwarmRegistryUniversal.sol +++ b/src/swarms/SwarmRegistryUniversal.sol @@ -10,11 +10,17 @@ 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. + * + * Swarms are defined for a **fleet UUID** (not a token ID), allowing swarms to be + * registered for any UUID that has been claimed/registered in FleetIdentity, + * regardless of whether it's assigned to a region or is in "owned-only" mode. + * This decouples swarm management from geographic tier placement. */ contract SwarmRegistryUniversal is ReentrancyGuard { error InvalidFingerprintSize(); error InvalidFilterSize(); - error NotFleetOwner(); + error InvalidUuid(); + error NotUuidOwner(); error ProviderDoesNotExist(); error NotProviderOwner(); error SwarmNotFound(); @@ -39,7 +45,7 @@ contract SwarmRegistryUniversal is ReentrancyGuard { } struct Swarm { - uint256 fleetId; + bytes16 fleetUuid; // Fleet UUID (not token ID) - allows swarms for any registered UUID uint256 providerId; uint32 filterLength; // Length of filter in bytes (max ~4GB, practically limited) uint8 fingerprintSize; @@ -62,26 +68,26 @@ contract SwarmRegistryUniversal is ReentrancyGuard { /// @notice SwarmID -> XOR filter data (stored as bytes) mapping(uint256 => bytes) internal filterData; - /// @notice FleetID -> List of SwarmIDs - mapping(uint256 => uint256[]) public fleetSwarms; + /// @notice UUID -> List of SwarmIDs (keyed by fleet UUID, not token ID) + mapping(bytes16 => uint256[]) public uuidSwarms; - /// @notice SwarmID -> index in fleetSwarms[fleetId] (for O(1) removal) - mapping(uint256 => uint256) public swarmIndexInFleet; + /// @notice SwarmID -> index in uuidSwarms[fleetUuid] (for O(1) removal) + mapping(uint256 => uint256) public swarmIndexInUuid; event SwarmRegistered( - uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner, uint32 filterSize + uint256 indexed swarmId, bytes16 indexed fleetUuid, 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); + event SwarmDeleted(uint256 indexed swarmId, bytes16 indexed fleetUuid, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, bytes16 indexed fleetUuid, 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))); + /// @return swarmId keccak256(fleetUuid, providerId, filter) + function computeSwarmId(bytes16 fleetUuid, uint256 providerId, bytes calldata filter) public pure returns (uint256) { + return uint256(keccak256(abi.encode(fleetUuid, providerId, filter))); } constructor(address _fleetContract, address _providerContract) { @@ -92,20 +98,23 @@ contract SwarmRegistryUniversal is ReentrancyGuard { PROVIDER_CONTRACT = ServiceProvider(_providerContract); } - /// @notice Registers a new swarm. Caller must own the fleet NFT. - /// @param fleetId Fleet token ID. + /// @notice Registers a new swarm. Caller must own the fleet UUID (via FleetIdentity.uuidOwner). + /// @param fleetUuid Fleet UUID (bytes16) - the UUID must be registered in FleetIdentity. /// @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, + bytes16 fleetUuid, uint256 providerId, bytes calldata filter, uint8 fingerprintSize, TagType tagType ) external nonReentrant returns (uint256 swarmId) { + if (fleetUuid == bytes16(0)) { + revert InvalidUuid(); + } if (fingerprintSize == 0 || fingerprintSize > MAX_FINGERPRINT_SIZE) { revert InvalidFingerprintSize(); } @@ -116,21 +125,22 @@ contract SwarmRegistryUniversal is ReentrancyGuard { revert FilterTooLarge(); } - if (FLEET_CONTRACT.ownerOf(fleetId) != msg.sender) { - revert NotFleetOwner(); + // Check UUID ownership - works for any registered UUID regardless of region + if (FLEET_CONTRACT.uuidOwner(fleetUuid) != msg.sender) { + revert NotUuidOwner(); } if (PROVIDER_CONTRACT.ownerOf(providerId) == address(0)) { revert ProviderDoesNotExist(); } - swarmId = computeSwarmId(fleetId, providerId, filter); + swarmId = computeSwarmId(fleetUuid, providerId, filter); if (swarms[swarmId].filterLength != 0) { revert SwarmAlreadyExists(); } Swarm storage s = swarms[swarmId]; - s.fleetId = fleetId; + s.fleetUuid = fleetUuid; s.providerId = providerId; s.filterLength = uint32(filter.length); s.fingerprintSize = fingerprintSize; @@ -139,10 +149,10 @@ contract SwarmRegistryUniversal is ReentrancyGuard { filterData[swarmId] = filter; - fleetSwarms[fleetId].push(swarmId); - swarmIndexInFleet[swarmId] = fleetSwarms[fleetId].length - 1; + uuidSwarms[fleetUuid].push(swarmId); + swarmIndexInUuid[swarmId] = uuidSwarms[fleetUuid].length - 1; - emit SwarmRegistered(swarmId, fleetId, providerId, msg.sender, uint32(filter.length)); + emit SwarmRegistered(swarmId, fleetUuid, providerId, msg.sender, uint32(filter.length)); } /// @notice Approves a swarm. Caller must own the provider NFT. @@ -177,7 +187,7 @@ contract SwarmRegistryUniversal is ReentrancyGuard { emit SwarmStatusChanged(swarmId, SwarmStatus.REJECTED); } - /// @notice Replaces the XOR filter. Resets status to REGISTERED. Caller must own the fleet NFT. + /// @notice Replaces the XOR filter. Resets status to REGISTERED. Caller must own the fleet UUID. /// @param swarmId The swarm to update. /// @param newFilterData Replacement filter blob. function updateSwarmFilter(uint256 swarmId, bytes calldata newFilterData) external nonReentrant { @@ -185,8 +195,8 @@ contract SwarmRegistryUniversal is ReentrancyGuard { if (s.filterLength == 0) { revert SwarmNotFound(); } - if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { - revert NotFleetOwner(); + if (FLEET_CONTRACT.uuidOwner(s.fleetUuid) != msg.sender) { + revert NotUuidOwner(); } if (newFilterData.length == 0) { revert InvalidFilterSize(); @@ -202,7 +212,7 @@ contract SwarmRegistryUniversal is ReentrancyGuard { emit SwarmFilterUpdated(swarmId, msg.sender, uint32(newFilterData.length)); } - /// @notice Reassigns the service provider. Resets status to REGISTERED. Caller must own the fleet NFT. + /// @notice Reassigns the service provider. Resets status to REGISTERED. Caller must own the fleet UUID. /// @param swarmId The swarm to update. /// @param newProviderId New provider token ID. function updateSwarmProvider(uint256 swarmId, uint256 newProviderId) external { @@ -210,8 +220,8 @@ contract SwarmRegistryUniversal is ReentrancyGuard { if (s.filterLength == 0) { revert SwarmNotFound(); } - if (FLEET_CONTRACT.ownerOf(s.fleetId) != msg.sender) { - revert NotFleetOwner(); + if (FLEET_CONTRACT.uuidOwner(s.fleetUuid) != msg.sender) { + revert NotUuidOwner(); } if (PROVIDER_CONTRACT.ownerOf(newProviderId) == address(0)) { revert ProviderDoesNotExist(); @@ -226,40 +236,37 @@ contract SwarmRegistryUniversal is ReentrancyGuard { emit SwarmProviderUpdated(swarmId, oldProvider, newProviderId); } - /// @notice Permanently deletes a swarm. Caller must own the fleet NFT. + /// @notice Permanently deletes a swarm. Caller must own the fleet UUID. /// @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(); + if (FLEET_CONTRACT.uuidOwner(s.fleetUuid) != msg.sender) { + revert NotUuidOwner(); } - uint256 fleetId = s.fleetId; + bytes16 fleetUuid = s.fleetUuid; - _removeFromFleetSwarms(fleetId, swarmId); + _removeFromUuidSwarms(fleetUuid, swarmId); delete swarms[swarmId]; delete filterData[swarmId]; - emit SwarmDeleted(swarmId, fleetId, msg.sender); + emit SwarmDeleted(swarmId, fleetUuid, msg.sender); } - /// @notice Returns whether the swarm's fleet and provider NFTs still exist (i.e. have not been burned). + /// @notice Returns whether the swarm's fleet UUID and provider NFT are still valid. /// @param swarmId The swarm to check. - /// @return fleetValid True if the fleet NFT exists. + /// @return fleetValid True if the fleet UUID is still owned (uuidOwner != address(0)). /// @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; - } + // Fleet is valid if UUID is still owned (not released) + fleetValid = FLEET_CONTRACT.uuidOwner(s.fleetUuid) != address(0); try PROVIDER_CONTRACT.ownerOf(s.providerId) returns (address) { providerValid = true; @@ -268,7 +275,7 @@ contract SwarmRegistryUniversal is ReentrancyGuard { } } - /// @notice Permissionless-ly removes a swarm whose fleet or provider NFT has been burned. + /// @notice Permissionless-ly removes a swarm whose fleet UUID has been released or provider NFT has been burned. /// @param swarmId The orphaned swarm to purge. function purgeOrphanedSwarm(uint256 swarmId) external { Swarm storage s = swarms[swarmId]; @@ -277,14 +284,14 @@ contract SwarmRegistryUniversal is ReentrancyGuard { (bool fleetValid, bool providerValid) = isSwarmValid(swarmId); if (fleetValid && providerValid) revert SwarmNotOrphaned(); - uint256 fleetId = s.fleetId; + bytes16 fleetUuid = s.fleetUuid; - _removeFromFleetSwarms(fleetId, swarmId); + _removeFromUuidSwarms(fleetUuid, swarmId); delete swarms[swarmId]; delete filterData[swarmId]; - emit SwarmPurged(swarmId, fleetId, msg.sender); + emit SwarmPurged(swarmId, fleetUuid, msg.sender); } /// @notice Tests tag membership against the swarm's XOR filter. @@ -335,17 +342,17 @@ contract SwarmRegistryUniversal is ReentrancyGuard { } /** - * @dev O(1) removal of a swarm from its fleet's swarm list using index tracking. + * @dev O(1) removal of a swarm from its UUID's swarm list using index tracking. */ - function _removeFromFleetSwarms(uint256 fleetId, uint256 swarmId) internal { - uint256[] storage arr = fleetSwarms[fleetId]; - uint256 index = swarmIndexInFleet[swarmId]; + function _removeFromUuidSwarms(bytes16 fleetUuid, uint256 swarmId) internal { + uint256[] storage arr = uuidSwarms[fleetUuid]; + uint256 index = swarmIndexInUuid[swarmId]; uint256 lastId = arr[arr.length - 1]; arr[index] = lastId; - swarmIndexInFleet[lastId] = index; + swarmIndexInUuid[lastId] = index; arr.pop(); - delete swarmIndexInFleet[swarmId]; + delete swarmIndexInUuid[swarmId]; } /** diff --git a/test/SwarmRegistryL1.t.sol b/test/SwarmRegistryL1.t.sol index 41ab53e0..f97bcbeb 100644 --- a/test/SwarmRegistryL1.t.sol +++ b/test/SwarmRegistryL1.t.sol @@ -31,12 +31,12 @@ contract SwarmRegistryL1Test is Test { uint16 constant US = 840; uint16 constant ADMIN_CA = 6; // California - event SwarmRegistered(uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner); + event SwarmRegistered(uint256 indexed swarmId, bytes16 indexed fleetUuid, 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); + event SwarmDeleted(uint256 indexed swarmId, bytes16 indexed fleetUuid, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, bytes16 indexed fleetUuid, address indexed purgedBy); function setUp() public { bondToken = new MockBondTokenL1(); @@ -59,6 +59,10 @@ contract SwarmRegistryL1Test is Test { return fleetContract.registerFleetLocal(bytes16(keccak256(seed)), US, ADMIN_CA, 0); } + function _getFleetUuid(uint256 fleetId) internal pure returns (bytes16) { + return bytes16(uint128(fleetId)); + } + function _registerProvider(address owner, string memory url) internal returns (uint256) { vm.prank(owner); return providerContract.registerProvider(url); @@ -72,8 +76,9 @@ contract SwarmRegistryL1Test is Test { uint8 fpSize, SwarmRegistryL1.TagType tagType ) internal returns (uint256) { + bytes16 fleetUuid = _getFleetUuid(fleetId); vm.prank(owner); - return swarmRegistry.registerSwarm(fleetId, providerId, filter, fpSize, tagType); + return swarmRegistry.registerSwarm(fleetUuid, providerId, filter, fpSize, tagType); } function getExpectedValues(bytes memory tagId, uint256 m, uint8 fpSize) @@ -136,8 +141,8 @@ contract SwarmRegistryL1Test is Test { 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)); + // Swarm ID is deterministic hash of (fleetUuid, providerId, filter) + uint256 expectedId = swarmRegistry.computeSwarmId(_getFleetUuid(fleetId), providerId, new bytes(100)); assertEq(swarmId, expectedId); } @@ -149,7 +154,7 @@ contract SwarmRegistryL1Test is Test { _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.VENDOR_ID); ( - uint256 storedFleetId, + bytes16 storedFleetUuid, uint256 storedProviderId, address filterPointer, uint8 storedFpSize, @@ -157,7 +162,7 @@ contract SwarmRegistryL1Test is Test { SwarmRegistryL1.SwarmStatus storedStatus ) = swarmRegistry.swarms(swarmId); - assertEq(storedFleetId, fleetId); + assertEq(storedFleetUuid, _getFleetUuid(fleetId)); assertEq(storedProviderId, providerId); assertTrue(filterPointer != address(0)); assertEq(storedFpSize, 8); @@ -171,7 +176,7 @@ contract SwarmRegistryL1Test is Test { bytes memory filter = new bytes(32); - uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + uint256 expectedId = swarmRegistry.computeSwarmId(_getFleetUuid(fleetId), providerId, filter); uint256 swarmId = _registerSwarm(fleetOwner, fleetId, providerId, filter, 8, SwarmRegistryL1.TagType.GENERIC); assertEq(swarmId, expectedId); @@ -185,7 +190,7 @@ contract SwarmRegistryL1Test is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryL1.SwarmAlreadyExists.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(32), 8, SwarmRegistryL1.TagType.GENERIC); } function test_registerSwarm_emitsSwarmRegistered() public { @@ -193,15 +198,15 @@ contract SwarmRegistryL1Test is Test { uint256 providerId = _registerProvider(providerOwner, "url1"); bytes memory filter = new bytes(50); - uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + uint256 expectedId = swarmRegistry.computeSwarmId(_getFleetUuid(fleetId), providerId, filter); vm.expectEmit(true, true, true, true); - emit SwarmRegistered(expectedId, fleetId, providerId, fleetOwner); + emit SwarmRegistered(expectedId, _getFleetUuid(fleetId), providerId, fleetOwner); _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryL1.TagType.GENERIC); } - function test_registerSwarm_linksFleetSwarms() public { + function test_registerSwarm_linksUuidSwarms() public { uint256 fleetId = _registerFleet(fleetOwner, "f1"); uint256 providerId1 = _registerProvider(providerOwner, "url1"); uint256 providerId2 = _registerProvider(providerOwner, "url2"); @@ -211,8 +216,8 @@ contract SwarmRegistryL1Test is Test { 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); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), swarmId1); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1), swarmId2); } function test_registerSwarm_allTagTypes() public { @@ -247,12 +252,12 @@ contract SwarmRegistryL1Test is Test { // registerSwarm — reverts // ============================== - function test_RevertIf_registerSwarm_notFleetOwner() public { + function test_RevertIf_registerSwarm_notUuidOwner() 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); + vm.expectRevert(SwarmRegistryL1.NotUuidOwner.selector); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), 1, new bytes(10), 16, SwarmRegistryL1.TagType.GENERIC); } function test_RevertIf_registerSwarm_fingerprintSizeZero() public { @@ -261,7 +266,7 @@ contract SwarmRegistryL1Test is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryL1.InvalidFingerprintSize.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 0, SwarmRegistryL1.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(32), 0, SwarmRegistryL1.TagType.GENERIC); } function test_RevertIf_registerSwarm_fingerprintSizeExceedsMax() public { @@ -270,7 +275,7 @@ contract SwarmRegistryL1Test is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryL1.InvalidFingerprintSize.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 17, SwarmRegistryL1.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(32), 17, SwarmRegistryL1.TagType.GENERIC); } function test_RevertIf_registerSwarm_emptyFilter() public { @@ -279,7 +284,7 @@ contract SwarmRegistryL1Test is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(0), 8, SwarmRegistryL1.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(0), 8, SwarmRegistryL1.TagType.GENERIC); } function test_RevertIf_registerSwarm_filterTooLarge() public { @@ -288,7 +293,7 @@ contract SwarmRegistryL1Test is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryL1.InvalidFilterSize.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(24577), 8, SwarmRegistryL1.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(24577), 8, SwarmRegistryL1.TagType.GENERIC); } function test_registerSwarm_maxFingerprintSize() public { @@ -489,9 +494,9 @@ contract SwarmRegistryL1Test is Test { // 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); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), s1); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1), s2); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 2), s3); } // ============================== @@ -527,7 +532,7 @@ contract SwarmRegistryL1Test is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryL1.InvalidFingerprintSize.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), fpSize, SwarmRegistryL1.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(32), fpSize, SwarmRegistryL1.TagType.GENERIC); } // ============================== @@ -587,7 +592,7 @@ contract SwarmRegistryL1Test is Test { _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); vm.prank(caller); - vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + vm.expectRevert(SwarmRegistryL1.NotUuidOwner.selector); swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); } @@ -659,7 +664,7 @@ contract SwarmRegistryL1Test is Test { _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); vm.prank(caller); - vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + vm.expectRevert(SwarmRegistryL1.NotUuidOwner.selector); swarmRegistry.updateSwarmProvider(swarmId, providerId2); } @@ -687,7 +692,7 @@ contract SwarmRegistryL1Test is Test { _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); vm.expectEmit(true, true, true, true); - emit SwarmDeleted(swarmId, fleetId, fleetOwner); + emit SwarmDeleted(swarmId, _getFleetUuid(fleetId), fleetOwner); vm.prank(fleetOwner); swarmRegistry.deleteSwarm(swarmId); @@ -697,7 +702,7 @@ contract SwarmRegistryL1Test is Test { assertEq(pointer, address(0)); } - function test_deleteSwarm_removesFromFleetSwarms() public { + function test_deleteSwarm_removesFromUuidSwarms() public { uint256 fleetId = _registerFleet(fleetOwner, "f1"); uint256 providerId1 = _registerProvider(providerOwner, "url1"); uint256 providerId2 = _registerProvider(providerOwner, "url2"); @@ -712,9 +717,9 @@ contract SwarmRegistryL1Test is Test { swarmRegistry.deleteSwarm(swarm1); // Only swarm2 should remain in fleetSwarms - assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm2); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), swarm2); vm.expectRevert(); - swarmRegistry.fleetSwarms(fleetId, 1); // Should be out of bounds + swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1); // Should be out of bounds } function test_deleteSwarm_swapAndPop() public { @@ -735,10 +740,10 @@ contract SwarmRegistryL1Test is Test { swarmRegistry.deleteSwarm(swarm2); // swarm3 should be swapped to index 1 - assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm1); - assertEq(swarmRegistry.fleetSwarms(fleetId, 1), swarm3); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), swarm1); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1), swarm3); vm.expectRevert(); - swarmRegistry.fleetSwarms(fleetId, 2); // Should be out of bounds + swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 2); // Should be out of bounds } function test_RevertIf_deleteSwarm_swarmNotFound() public { @@ -754,7 +759,7 @@ contract SwarmRegistryL1Test is Test { _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryL1.TagType.GENERIC); vm.prank(caller); - vm.expectRevert(SwarmRegistryL1.NotFleetOwner.selector); + vm.expectRevert(SwarmRegistryL1.NotUuidOwner.selector); swarmRegistry.deleteSwarm(swarmId); } @@ -775,7 +780,7 @@ contract SwarmRegistryL1Test is Test { assertEq(pointer, address(0)); } - function test_deleteSwarm_updatesSwarmIndexInFleet() public { + function test_deleteSwarm_updatesSwarmIndexInUuid() public { uint256 fleetId = _registerFleet(fleetOwner, "f1"); uint256 p1 = _registerProvider(providerOwner, "url1"); uint256 p2 = _registerProvider(providerOwner, "url2"); @@ -786,17 +791,17 @@ contract SwarmRegistryL1Test is Test { 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); + assertEq(swarmRegistry.swarmIndexInUuid(s1), 0); + assertEq(swarmRegistry.swarmIndexInUuid(s2), 1); + assertEq(swarmRegistry.swarmIndexInUuid(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 + assertEq(swarmRegistry.swarmIndexInUuid(s3), 0); + assertEq(swarmRegistry.swarmIndexInUuid(s2), 1); + assertEq(swarmRegistry.swarmIndexInUuid(s1), 0); // deleted, reset to 0 } // ============================== @@ -881,7 +886,7 @@ contract SwarmRegistryL1Test is Test { // Anyone can purge vm.expectEmit(true, true, true, true); - emit SwarmPurged(swarmId, fleetId, caller); + emit SwarmPurged(swarmId, _getFleetUuid(fleetId), caller); vm.prank(caller); swarmRegistry.purgeOrphanedSwarm(swarmId); @@ -908,7 +913,7 @@ contract SwarmRegistryL1Test is Test { assertEq(pointer, address(0)); } - function test_purgeOrphanedSwarm_removesFromFleetSwarms() public { + function test_purgeOrphanedSwarm_removesFromUuidSwarms() public { uint256 fleetId = _registerFleet(fleetOwner, "f1"); uint256 p1 = _registerProvider(providerOwner, "url1"); uint256 p2 = _registerProvider(providerOwner, "url2"); @@ -924,9 +929,9 @@ contract SwarmRegistryL1Test is Test { swarmRegistry.purgeOrphanedSwarm(s1); // s2 should be swapped to index 0 - assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s2); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), s2); vm.expectRevert(); - swarmRegistry.fleetSwarms(fleetId, 1); + swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1); } function test_RevertIf_purgeOrphanedSwarm_swarmNotFound() public { diff --git a/test/SwarmRegistryUniversal.t.sol b/test/SwarmRegistryUniversal.t.sol index 57321c69..644a1a79 100644 --- a/test/SwarmRegistryUniversal.t.sol +++ b/test/SwarmRegistryUniversal.t.sol @@ -32,13 +32,13 @@ contract SwarmRegistryUniversalTest is Test { uint16 constant ADMIN_CA = 6; // California event SwarmRegistered( - uint256 indexed swarmId, uint256 indexed fleetId, uint256 indexed providerId, address owner, uint32 filterSize + uint256 indexed swarmId, bytes16 indexed fleetUuid, 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); + event SwarmDeleted(uint256 indexed swarmId, bytes16 indexed fleetUuid, address indexed owner); + event SwarmPurged(uint256 indexed swarmId, bytes16 indexed fleetUuid, address indexed purgedBy); function setUp() public { bondToken = new MockBondTokenUniv(); @@ -61,6 +61,10 @@ contract SwarmRegistryUniversalTest is Test { return fleetContract.registerFleetLocal(bytes16(keccak256(seed)), US, ADMIN_CA, 0); } + function _getFleetUuid(uint256 fleetId) internal pure returns (bytes16) { + return bytes16(uint128(fleetId)); + } + function _registerProvider(address owner, string memory url) internal returns (uint256) { vm.prank(owner); return providerContract.registerProvider(url); @@ -74,8 +78,9 @@ contract SwarmRegistryUniversalTest is Test { uint8 fpSize, SwarmRegistryUniversal.TagType tagType ) internal returns (uint256) { + bytes16 fleetUuid = _getFleetUuid(fleetId); vm.prank(owner); - return swarmRegistry.registerSwarm(fleetId, providerId, filter, fpSize, tagType); + return swarmRegistry.registerSwarm(fleetUuid, providerId, filter, fpSize, tagType); } function getExpectedValues(bytes memory tagId, uint256 m, uint8 fpSize) @@ -137,8 +142,8 @@ contract SwarmRegistryUniversalTest is Test { 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)); + // Swarm ID is deterministic hash of (fleetUuid, providerId, filter) + uint256 expectedId = swarmRegistry.computeSwarmId(_getFleetUuid(fleetId), providerId, new bytes(100)); assertEq(swarmId, expectedId); } @@ -150,7 +155,7 @@ contract SwarmRegistryUniversalTest is Test { _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 12, SwarmRegistryUniversal.TagType.VENDOR_ID); ( - uint256 storedFleetId, + bytes16 storedFleetUuid, uint256 storedProviderId, uint32 storedFilterLen, uint8 storedFpSize, @@ -158,7 +163,7 @@ contract SwarmRegistryUniversalTest is Test { SwarmRegistryUniversal.SwarmStatus storedStatus ) = swarmRegistry.swarms(swarmId); - assertEq(storedFleetId, fleetId); + assertEq(storedFleetUuid, _getFleetUuid(fleetId)); assertEq(storedProviderId, providerId); assertEq(storedFilterLen, 50); assertEq(storedFpSize, 12); @@ -192,7 +197,7 @@ contract SwarmRegistryUniversalTest is Test { bytes memory filter = new bytes(32); - uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + uint256 expectedId = swarmRegistry.computeSwarmId(_getFleetUuid(fleetId), providerId, filter); uint256 swarmId = _registerSwarm(fleetOwner, fleetId, providerId, filter, 8, SwarmRegistryUniversal.TagType.GENERIC); @@ -207,7 +212,7 @@ contract SwarmRegistryUniversalTest is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryUniversal.SwarmAlreadyExists.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(32), 8, SwarmRegistryUniversal.TagType.GENERIC); } function test_registerSwarm_emitsSwarmRegistered() public { @@ -215,15 +220,15 @@ contract SwarmRegistryUniversalTest is Test { uint256 providerId = _registerProvider(providerOwner, "url1"); bytes memory filter = new bytes(50); - uint256 expectedId = swarmRegistry.computeSwarmId(fleetId, providerId, filter); + uint256 expectedId = swarmRegistry.computeSwarmId(_getFleetUuid(fleetId), providerId, filter); vm.expectEmit(true, true, true, true); - emit SwarmRegistered(expectedId, fleetId, providerId, fleetOwner, 50); + emit SwarmRegistered(expectedId, _getFleetUuid(fleetId), providerId, fleetOwner, 50); _registerSwarm(fleetOwner, fleetId, providerId, filter, 16, SwarmRegistryUniversal.TagType.GENERIC); } - function test_registerSwarm_linksFleetSwarms() public { + function test_registerSwarm_linksUuidSwarms() public { uint256 fleetId = _registerFleet(fleetOwner, "f1"); uint256 providerId1 = _registerProvider(providerOwner, "url1"); uint256 providerId2 = _registerProvider(providerOwner, "url2"); @@ -233,8 +238,8 @@ contract SwarmRegistryUniversalTest is Test { 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); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), s1); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1), s2); } function test_registerSwarm_allTagTypes() public { @@ -274,8 +279,8 @@ contract SwarmRegistryUniversalTest is Test { 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); + vm.expectRevert(SwarmRegistryUniversal.NotUuidOwner.selector); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), 1, new bytes(10), 16, SwarmRegistryUniversal.TagType.GENERIC); } function test_RevertIf_registerSwarm_fingerprintSizeZero() public { @@ -284,7 +289,7 @@ contract SwarmRegistryUniversalTest is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryUniversal.InvalidFingerprintSize.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 0, SwarmRegistryUniversal.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(32), 0, SwarmRegistryUniversal.TagType.GENERIC); } function test_RevertIf_registerSwarm_fingerprintSizeExceedsMax() public { @@ -293,7 +298,7 @@ contract SwarmRegistryUniversalTest is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryUniversal.InvalidFingerprintSize.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), 17, SwarmRegistryUniversal.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(32), 17, SwarmRegistryUniversal.TagType.GENERIC); } function test_RevertIf_registerSwarm_emptyFilter() public { @@ -302,7 +307,7 @@ contract SwarmRegistryUniversalTest is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryUniversal.InvalidFilterSize.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(0), 8, SwarmRegistryUniversal.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(0), 8, SwarmRegistryUniversal.TagType.GENERIC); } function test_RevertIf_registerSwarm_filterTooLarge() public { @@ -311,7 +316,7 @@ contract SwarmRegistryUniversalTest is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryUniversal.FilterTooLarge.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(24577), 8, SwarmRegistryUniversal.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(24577), 8, SwarmRegistryUniversal.TagType.GENERIC); } function test_registerSwarm_maxFingerprintSize() public { @@ -568,9 +573,9 @@ contract SwarmRegistryUniversalTest is Test { // 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); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), s1); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1), s2); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 2), s3); } // ============================== @@ -608,7 +613,7 @@ contract SwarmRegistryUniversalTest is Test { vm.prank(fleetOwner); vm.expectRevert(SwarmRegistryUniversal.InvalidFingerprintSize.selector); - swarmRegistry.registerSwarm(fleetId, providerId, new bytes(32), fpSize, SwarmRegistryUniversal.TagType.GENERIC); + swarmRegistry.registerSwarm(_getFleetUuid(fleetId), providerId, new bytes(32), fpSize, SwarmRegistryUniversal.TagType.GENERIC); } function testFuzz_registerSwarm_filterSizeRange(uint256 size) public { @@ -686,7 +691,7 @@ contract SwarmRegistryUniversalTest is Test { _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); vm.prank(caller); - vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + vm.expectRevert(SwarmRegistryUniversal.NotUuidOwner.selector); swarmRegistry.updateSwarmFilter(swarmId, new bytes(100)); } @@ -758,7 +763,7 @@ contract SwarmRegistryUniversalTest is Test { _registerSwarm(fleetOwner, fleetId, providerId1, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); vm.prank(caller); - vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + vm.expectRevert(SwarmRegistryUniversal.NotUuidOwner.selector); swarmRegistry.updateSwarmProvider(swarmId, providerId2); } @@ -786,18 +791,18 @@ contract SwarmRegistryUniversalTest is Test { _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); vm.expectEmit(true, true, true, true); - emit SwarmDeleted(swarmId, fleetId, fleetOwner); + emit SwarmDeleted(swarmId, _getFleetUuid(fleetId), fleetOwner); vm.prank(fleetOwner); swarmRegistry.deleteSwarm(swarmId); // Swarm should be zeroed - (uint256 fleetIdAfter,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); - assertEq(fleetIdAfter, 0); + (bytes16 fleetUuidAfter,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(fleetUuidAfter, bytes16(0)); assertEq(filterLength, 0); } - function test_deleteSwarm_removesFromFleetSwarms() public { + function test_deleteSwarm_removesFromUuidSwarms() public { uint256 fleetId = _registerFleet(fleetOwner, "f1"); uint256 providerId1 = _registerProvider(providerOwner, "url1"); uint256 providerId2 = _registerProvider(providerOwner, "url2"); @@ -812,9 +817,9 @@ contract SwarmRegistryUniversalTest is Test { swarmRegistry.deleteSwarm(swarm1); // Only swarm2 should remain in fleetSwarms - assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm2); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), swarm2); vm.expectRevert(); - swarmRegistry.fleetSwarms(fleetId, 1); // Should be out of bounds + swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1); // Should be out of bounds } function test_deleteSwarm_swapAndPop() public { @@ -835,10 +840,10 @@ contract SwarmRegistryUniversalTest is Test { swarmRegistry.deleteSwarm(swarm2); // swarm3 should be swapped to index 1 - assertEq(swarmRegistry.fleetSwarms(fleetId, 0), swarm1); - assertEq(swarmRegistry.fleetSwarms(fleetId, 1), swarm3); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), swarm1); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1), swarm3); vm.expectRevert(); - swarmRegistry.fleetSwarms(fleetId, 2); // Should be out of bounds + swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 2); // Should be out of bounds } function test_deleteSwarm_clearsFilterData() public { @@ -875,7 +880,7 @@ contract SwarmRegistryUniversalTest is Test { _registerSwarm(fleetOwner, fleetId, providerId, new bytes(50), 8, SwarmRegistryUniversal.TagType.GENERIC); vm.prank(caller); - vm.expectRevert(SwarmRegistryUniversal.NotFleetOwner.selector); + vm.expectRevert(SwarmRegistryUniversal.NotUuidOwner.selector); swarmRegistry.deleteSwarm(swarmId); } @@ -892,11 +897,11 @@ contract SwarmRegistryUniversalTest is Test { vm.prank(fleetOwner); swarmRegistry.deleteSwarm(swarmId); - (uint256 fleetIdAfter,,,,,) = swarmRegistry.swarms(swarmId); - assertEq(fleetIdAfter, 0); + (bytes16 fleetUuidAfter,,,,,) = swarmRegistry.swarms(swarmId); + assertEq(fleetUuidAfter, bytes16(0)); } - function test_deleteSwarm_updatesSwarmIndexInFleet() public { + function test_deleteSwarm_updatesSwarmIndexInUuid() public { uint256 fleetId = _registerFleet(fleetOwner, "f1"); uint256 p1 = _registerProvider(providerOwner, "url1"); uint256 p2 = _registerProvider(providerOwner, "url2"); @@ -907,17 +912,17 @@ contract SwarmRegistryUniversalTest is Test { 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); + assertEq(swarmRegistry.swarmIndexInUuid(s1), 0); + assertEq(swarmRegistry.swarmIndexInUuid(s2), 1); + assertEq(swarmRegistry.swarmIndexInUuid(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 + assertEq(swarmRegistry.swarmIndexInUuid(s3), 0); + assertEq(swarmRegistry.swarmIndexInUuid(s2), 1); + assertEq(swarmRegistry.swarmIndexInUuid(s1), 0); // deleted, reset to 0 } // ============================== @@ -998,7 +1003,7 @@ contract SwarmRegistryUniversalTest is Test { providerContract.burn(providerId); vm.expectEmit(true, true, true, true); - emit SwarmPurged(swarmId, fleetId, caller); + emit SwarmPurged(swarmId, _getFleetUuid(fleetId), caller); vm.prank(caller); swarmRegistry.purgeOrphanedSwarm(swarmId); @@ -1019,12 +1024,12 @@ contract SwarmRegistryUniversalTest is Test { vm.prank(caller); swarmRegistry.purgeOrphanedSwarm(swarmId); - (uint256 fId,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); - assertEq(fId, 0); + (bytes16 fUuid,, uint32 filterLength,,,) = swarmRegistry.swarms(swarmId); + assertEq(fUuid, bytes16(0)); assertEq(filterLength, 0); } - function test_purgeOrphanedSwarm_removesFromFleetSwarms() public { + function test_purgeOrphanedSwarm_removesFromUuidSwarms() public { uint256 fleetId = _registerFleet(fleetOwner, "f1"); uint256 p1 = _registerProvider(providerOwner, "url1"); uint256 p2 = _registerProvider(providerOwner, "url2"); @@ -1040,9 +1045,9 @@ contract SwarmRegistryUniversalTest is Test { swarmRegistry.purgeOrphanedSwarm(s1); // s2 should be swapped to index 0 - assertEq(swarmRegistry.fleetSwarms(fleetId, 0), s2); + assertEq(swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 0), s2); vm.expectRevert(); - swarmRegistry.fleetSwarms(fleetId, 1); + swarmRegistry.uuidSwarms(_getFleetUuid(fleetId), 1); } function test_purgeOrphanedSwarm_clearsFilterData() public { From 3be0d72495dd691f818c0f3cdba6489bad015f36 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 27 Feb 2026 09:31:47 +1300 Subject: [PATCH 57/63] docs: update swarm documentation for UUID-based ownership MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change fleetSwarms → uuidSwarms and swarmIndexInFleet → swarmIndexInUuid - Update registerSwarm/computeSwarmId to use fleetUuid instead of fleetId - Change ownership verification from ownerOf(fleetId) to uuidOwner(fleetUuid) - Update Swarm struct field from uint256 fleetId to bytes16 fleetUuid - Update sequence diagrams and code examples throughout docs --- src/swarms/doc/assistant-guide.md | 20 ++++++++++---------- src/swarms/doc/graph-architecture.md | 12 ++++++------ src/swarms/doc/sequence-discovery.md | 6 +++--- src/swarms/doc/sequence-lifecycle.md | 16 ++++++++-------- src/swarms/doc/sequence-registration.md | 10 +++++----- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/swarms/doc/assistant-guide.md b/src/swarms/doc/assistant-guide.md index 33c247c0..82856c61 100644 --- a/src/swarms/doc/assistant-guide.md +++ b/src/swarms/doc/assistant-guide.md @@ -17,8 +17,8 @@ Two registry variants exist for different deployment targets: | :--------------------------- | :---------------------------------- | :--------------------------------------- | :---- | | **`FleetIdentity`** | Fleet Registry (ERC-721 Enumerable) | `(regionKey << 128) \| 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)` | — | +| **`SwarmRegistryL1`** | Swarm Registry (L1) | `keccak256(fleetUuid, providerId, filter)` | — | +| **`SwarmRegistryUniversal`** | Swarm Registry (Universal) | `keccak256(fleetUuid, providerId, filter)` | — | All contracts are **permissionless** — access control is enforced through NFT ownership rather than admin roles. `FleetIdentity` additionally requires an ERC-20 bond (e.g. NODL) to register a fleet, acting as an anti-spam / anti-abuse mechanism. @@ -173,11 +173,11 @@ A Fleet Owner groups tags into a "Swarm" (chunk of ~10k-20k tags) and registers 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. +3. **(Optional) Predict Swarm ID**: Call `computeSwarmId(fleetUuid, providerId, filterData)` off-chain to obtain the deterministic ID before submitting the transaction. 4. **Register**: ```solidity swarmRegistry.registerSwarm( - fleetId, + fleetUuid, providerId, filterData, 16, // Fingerprint size in bits (1–16) @@ -261,10 +261,10 @@ To verify membership on-chain, the contract uses **3-hash XOR logic**. Swarm IDs are **deterministic** — derived from the swarm's core identity: ``` -swarmId = uint256(keccak256(abi.encode(fleetId, providerId, filterData))) +swarmId = uint256(keccak256(abi.encode(fleetUuid, 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`. +This means the same (UUID, 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`. --- @@ -343,10 +343,10 @@ try fleetIdentity.ownerOf(tokenId) returns (address owner) { #### Step 3: Find Swarms ```solidity -// Enumerate swarms for this fleet +// Enumerate swarms for this UUID uint256[] memory swarmIds = new uint256[](100); // estimate for (uint256 i = 0; ; i++) { - try swarmRegistry.fleetSwarms(tokenId, i) returns (uint256 swarmId) { + try swarmRegistry.uuidSwarms(detectedUUID, i) returns (uint256 swarmId) { swarmIds[i] = swarmId; } catch { break; // End of array @@ -358,7 +358,7 @@ for (uint256 i = 0; ; i++) { ```solidity // Construct tagHash based on swarm's tagType -(uint256 fleetId, uint256 providerId, uint32 filterLen, uint8 fpSize, +(bytes16 fleetUuid, uint256 providerId, uint32 filterLen, uint8 fpSize, SwarmStatus status, TagType tagType) = swarmRegistry.swarms(swarmId); // Build tagId per schema (see Section 3) @@ -400,7 +400,7 @@ if (isMember && status == SwarmStatus.ACCEPTED) { ### Deletion Performance -Both registries use an **O(1) swap-and-pop** strategy for removing swarms from the `fleetSwarms` array, tracked via the `swarmIndexInFleet` mapping. +Both registries use an **O(1) swap-and-pop** strategy for removing swarms from the `uuidSwarms` array, tracked via the `swarmIndexInUuid` mapping. --- diff --git a/src/swarms/doc/graph-architecture.md b/src/swarms/doc/graph-architecture.md index 74833451..bd5435b7 100644 --- a/src/swarms/doc/graph-architecture.md +++ b/src/swarms/doc/graph-architecture.md @@ -24,7 +24,7 @@ graph TB ANY -- "checkMembership / purgeOrphanedSwarm /
buildHighestBondedUuidBundle" --> FI ANY -- "checkMembership / purgeOrphanedSwarm" --> REG - REG -. "ownerOf(fleetId)" .-> FI + REG -. "uuidOwner(fleetUuid)" .-> FI REG -. "ownerOf(providerId)" .-> SP style FI fill:#4a9eff,color:#fff @@ -79,10 +79,10 @@ classDiagram class SwarmRegistry { +mapping swarms - +mapping fleetSwarms - +mapping swarmIndexInFleet - +computeSwarmId(fleetId, providerId, filter) swarmId - +registerSwarm(fleetId, providerId, filter, fpSize, tagType) swarmId + +mapping uuidSwarms + +mapping swarmIndexInUuid + +computeSwarmId(fleetUuid, providerId, filter) swarmId + +registerSwarm(fleetUuid, providerId, filter, fpSize, tagType) swarmId +acceptSwarm(swarmId) +rejectSwarm(swarmId) +updateSwarmFilter(swarmId, newFilter) @@ -94,7 +94,7 @@ classDiagram } class Swarm { - uint256 fleetId + bytes16 fleetUuid uint256 providerId uint8 fingerprintSize TagType tagType diff --git a/src/swarms/doc/sequence-discovery.md b/src/swarms/doc/sequence-discovery.md index f42ea645..6b4d1890 100644 --- a/src/swarms/doc/sequence-discovery.md +++ b/src/swarms/doc/sequence-discovery.md @@ -36,7 +36,7 @@ sequenceDiagram rect rgb(240, 255, 240) Note right of EBS: Step 3 — Find Swarms - EBS ->>+ SR: fleetSwarms(tokenId, 0) + EBS ->>+ SR: uuidSwarms(uuid, 0) SR -->>- EBS: swarmId_0 Note over EBS: ... iterate until revert end @@ -90,9 +90,9 @@ sequenceDiagram rect rgb(255, 248, 240) Note right of EBS: Step 2 — Enumerate swarms - EBS ->>+ SR: fleetSwarms(tokenId, 0) + EBS ->>+ SR: uuidSwarms(uuid, 0) SR -->>- EBS: swarmId_0 - EBS ->>+ SR: fleetSwarms(tokenId, 1) + EBS ->>+ SR: uuidSwarms(uuid, 1) SR -->>- EBS: swarmId_1 Note over EBS: ... iterate until revert (end of array) end diff --git a/src/swarms/doc/sequence-lifecycle.md b/src/swarms/doc/sequence-lifecycle.md index 4fbbae3a..f44525d2 100644 --- a/src/swarms/doc/sequence-lifecycle.md +++ b/src/swarms/doc/sequence-lifecycle.md @@ -28,7 +28,7 @@ sequenceDiagram rect rgb(255, 248, 240) Note right of FO: Update XOR filter FO ->>+ SR: updateSwarmFilter(swarmId, newFilter) - SR ->>+ FI: ownerOf(fleetId) + SR ->>+ FI: uuidOwner(fleetUuid) FI -->>- SR: msg.sender ✓ Note over SR: Write new filter data Note over SR: status → REGISTERED @@ -38,7 +38,7 @@ sequenceDiagram rect rgb(240, 248, 255) Note right of FO: Update service provider FO ->>+ SR: updateSwarmProvider(swarmId, newProviderId) - SR ->>+ FI: ownerOf(fleetId) + SR ->>+ FI: uuidOwner(fleetUuid) FI -->>- SR: msg.sender ✓ Note over SR: providerId → newProviderId Note over SR: status → REGISTERED @@ -55,9 +55,9 @@ sequenceDiagram participant FI as FleetIdentity FO ->>+ SR: deleteSwarm(swarmId) - SR ->>+ FI: ownerOf(fleetId) + SR ->>+ FI: uuidOwner(fleetUuid) FI -->>- SR: msg.sender ✓ - Note over SR: Remove from fleetSwarms[] (O(1) swap-and-pop) + Note over SR: Remove from uuidSwarms[] (O(1) swap-and-pop) Note over SR: delete swarms[swarmId] Note over SR: delete filterData[swarmId] (Universal only) SR -->>- FO: ✓ SwarmDeleted event @@ -83,16 +83,16 @@ sequenceDiagram 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 ->>+ NFT: uuidOwner(fleetUuid) + NFT -->>- SR: ❌ address(0) or reverts 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: Confirms UUID is no longer owned + Note over SR: Remove from uuidSwarms[] (O(1)) Note over SR: delete swarms[swarmId] Note over SR: Gas refund → Purger SR -->>- Purger: ✓ SwarmPurged event diff --git a/src/swarms/doc/sequence-registration.md b/src/swarms/doc/sequence-registration.md index 88b59d39..7e04c7af 100644 --- a/src/swarms/doc/sequence-registration.md +++ b/src/swarms/doc/sequence-registration.md @@ -55,12 +55,12 @@ sequenceDiagram rect rgb(240, 248, 255) Note right of FO: Registration (fleet owner) - FO ->>+ SR: registerSwarm(fleetId, providerId, filter, fpSize, tagType) - SR ->>+ FI: ownerOf(fleetId) + FO ->>+ SR: registerSwarm(fleetUuid, providerId, filter, fpSize, tagType) + SR ->>+ FI: uuidOwner(fleetUuid) FI -->>- SR: msg.sender ✓ SR ->>+ SP: ownerOf(providerId) SP -->>- SR: address ✓ (exists) - Note over SR: swarmId = keccak256(fleetId, providerId, filter) + Note over SR: swarmId = keccak256(fleetUuid, providerId, filter) Note over SR: status = REGISTERED SR -->>- FO: swarmId end @@ -90,8 +90,8 @@ sequenceDiagram actor FO as Fleet Owner participant SR as SwarmRegistry - FO ->>+ SR: registerSwarm(fleetId, providerId, sameFilter, ...) - Note over SR: swarmId = keccak256(fleetId, providerId, sameFilter) + FO ->>+ SR: registerSwarm(fleetUuid, providerId, sameFilter, ...) + Note over SR: swarmId = keccak256(fleetUuid, providerId, sameFilter) Note over SR: swarms[swarmId] already exists SR -->>- FO: ❌ revert SwarmAlreadyExists() ``` From 941ea749d44b15cac0f02b6ac5e563e55c5826a2 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 27 Feb 2026 10:04:41 +1300 Subject: [PATCH 58/63] doc(swarms): remove ownership check for edge --- src/swarms/doc/assistant-guide.md | 8 ++-- src/swarms/doc/sequence-discovery.md | 69 ---------------------------- 2 files changed, 4 insertions(+), 73 deletions(-) diff --git a/src/swarms/doc/assistant-guide.md b/src/swarms/doc/assistant-guide.md index 82856c61..05c75803 100644 --- a/src/swarms/doc/assistant-guide.md +++ b/src/swarms/doc/assistant-guide.md @@ -13,10 +13,10 @@ Two registry variants exist for different deployment targets: ### Core Components -| Contract | Role | Key Identity | Token | -| :--------------------------- | :---------------------------------- | :--------------------------------------- | :---- | -| **`FleetIdentity`** | Fleet Registry (ERC-721 Enumerable) | `(regionKey << 128) \| uint128(uuid)` | SFID | -| **`ServiceProvider`** | Service Registry (ERC-721) | `keccak256(url)` | SSV | +| Contract | Role | Key Identity | Token | +| :--------------------------- | :---------------------------------- | :----------------------------------------- | :---- | +| **`FleetIdentity`** | Fleet Registry (ERC-721 Enumerable) | `(regionKey << 128) \| uint128(uuid)` | SFID | +| **`ServiceProvider`** | Service Registry (ERC-721) | `keccak256(url)` | SSV | | **`SwarmRegistryL1`** | Swarm Registry (L1) | `keccak256(fleetUuid, providerId, filter)` | — | | **`SwarmRegistryUniversal`** | Swarm Registry (Universal) | `keccak256(fleetUuid, providerId, filter)` | — | diff --git a/src/swarms/doc/sequence-discovery.md b/src/swarms/doc/sequence-discovery.md index 6b4d1890..21b9021b 100644 --- a/src/swarms/doc/sequence-discovery.md +++ b/src/swarms/doc/sequence-discovery.md @@ -22,16 +22,6 @@ sequenceDiagram rect rgb(255, 248, 240) Note right of EBS: Step 2 — Match Detected UUID Note over EBS: Check if detectedUUID is in bundle - Note over EBS: If found: compute tokenId - EBS ->>+ FI: computeTokenId(uuid, (840<<10)|5) - FI -->>- EBS: tokenId (try local first) - alt Token exists - EBS ->>+ FI: ownerOf(tokenId) - FI -->>- EBS: owner address ✓ - else Try country region - EBS ->>+ FI: computeTokenId(uuid, 840) - FI -->>- EBS: tokenId - end end rect rgb(240, 255, 240) @@ -61,65 +51,6 @@ sequenceDiagram Note over EBS: Connect to service URL ✓ ``` -## Direct Fleet Lookup Flow - -```mermaid -sequenceDiagram - actor EBS as EdgeBeaconScanner (Client) - participant FI as FleetIdentity - participant SR as SwarmRegistry - participant SP as ServiceProvider - - Note over EBS: Detects iBeacon:
UUID, Major, Minor, MAC - - rect rgb(240, 248, 255) - Note right of EBS: Step 1 — Find Fleet by Region - Note over EBS: Try user's location first - EBS ->>+ FI: computeTokenId(uuid, (840<<10)|5) - FI -->>- EBS: tokenId - EBS ->>+ FI: ownerOf(tokenId) - alt Local token exists - FI -->>- EBS: owner ✓ - else Try country level - EBS ->>+ FI: computeTokenId(uuid, 840) - FI -->>- EBS: tokenId - EBS ->>+ FI: ownerOf(tokenId) - FI -->>- EBS: owner ✓ - end - end - - rect rgb(255, 248, 240) - Note right of EBS: Step 2 — Enumerate swarms - EBS ->>+ SR: uuidSwarms(uuid, 0) - SR -->>- EBS: swarmId_0 - EBS ->>+ SR: uuidSwarms(uuid, 1) - SR -->>- EBS: swarmId_1 - Note over EBS: ... iterate until revert (end of array) - end - - rect rgb(240, 255, 240) - Note right of EBS: Step 3 — Find matching swarm - Note over EBS: Read swarms[swarmId_0].tagType - Note over EBS: Construct tagId per schema:
UUID || Major || Minor [|| MAC] - Note over EBS: tagHash = keccak256(tagId) - EBS ->>+ SR: checkMembership(swarmId_0, tagHash) - SR -->>- EBS: false (not in this swarm) - - EBS ->>+ SR: checkMembership(swarmId_1, tagHash) - SR -->>- EBS: true ✓ (tag found!) - end - - rect rgb(248, 240, 255) - Note right of EBS: Step 4 — Resolve service URL - EBS ->>+ SR: swarms(swarmId_1) - SR -->>- EBS: { providerId, status: ACCEPTED, ... } - EBS ->>+ SP: providerUrls(providerId) - SP -->>- EBS: "https://api.acme-tracking.com" - end - - Note over EBS: Connect to service URL ✓ -``` - ## Region Enumeration (For Indexers) ```mermaid From ad77108d05a11efe7cdc782cdb2fe4bd78e546f7 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 27 Feb 2026 10:28:33 +1300 Subject: [PATCH 59/63] minor doc correction --- src/swarms/doc/sequence-fleet-maintenance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/swarms/doc/sequence-fleet-maintenance.md b/src/swarms/doc/sequence-fleet-maintenance.md index add47f79..d9042626 100644 --- a/src/swarms/doc/sequence-fleet-maintenance.md +++ b/src/swarms/doc/sequence-fleet-maintenance.md @@ -199,7 +199,7 @@ if (myAdminAreas.length === 0) { **Key Points for Country-Only Scenarios:** -1. **No EdgeBeaconScanners yet:** If no admin areas are active, there are no scanners to discover your fleet. +1. **No EdgeBeaconScanners yet:** If no admin areas are active, it is either because in this specific country all players have chosen the country-level coverage or you may be the first in this country. 2. **Use `buildCountryOnlyBundle`:** Verify your position among other country-level competitors. 3. **Use `countryInclusionHint`:** Works correctly even with no active admin areas - it calculates the tier needed for country-only competition. 4. **Prepare for locals:** When admin areas become active, local fleets will have priority over country fleets at the same tier. Consider using `countryInclusionHint` periodically to stay ahead. From 3e80931a5368ca8afa3c29f428574161f5ad2495 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 27 Feb 2026 11:07:04 +1300 Subject: [PATCH 60/63] docs: restructure swarm documentation for Notion export Reorganize documentation with professional top-down structure: - README.md: Overview & architecture (entry page) - data-model.md: Contract interfaces, enums, storage - fleet-registration.md: Registration flows, tier economics - swarm-operations.md: Swarm registration, filters, approval - lifecycle.md: State machines, transitions - discovery.md: Client discovery flows - maintenance.md: Bundle monitoring, tier optimization - iso3166-reference.md: Geographic codes reference Removed redundant files: - assistant-guide.md (content distributed to focused docs) - graph-architecture.md (merged into data-model.md) - sequence-*.md files (consolidated into topic-focused docs) --- src/swarms/doc/README.md | 192 ++++--- src/swarms/doc/assistant-guide.md | 407 --------------- src/swarms/doc/data-model.md | 162 ++++++ src/swarms/doc/discovery.md | 169 ++++++ src/swarms/doc/fleet-registration.md | 237 +++++++++ src/swarms/doc/graph-architecture.md | 132 ----- src/swarms/doc/iso3166-reference.md | 97 ++++ src/swarms/doc/lifecycle.md | 121 +++++ src/swarms/doc/maintenance.md | 166 ++++++ src/swarms/doc/sequence-discovery.md | 105 ---- src/swarms/doc/sequence-fleet-identity.md | 293 ----------- src/swarms/doc/sequence-fleet-maintenance.md | 520 ------------------- src/swarms/doc/sequence-lifecycle.md | 323 ------------ src/swarms/doc/sequence-registration.md | 97 ---- src/swarms/doc/swarm-operations.md | 197 +++++++ 15 files changed, 1278 insertions(+), 1940 deletions(-) delete mode 100644 src/swarms/doc/assistant-guide.md create mode 100644 src/swarms/doc/data-model.md create mode 100644 src/swarms/doc/discovery.md create mode 100644 src/swarms/doc/fleet-registration.md delete mode 100644 src/swarms/doc/graph-architecture.md create mode 100644 src/swarms/doc/iso3166-reference.md create mode 100644 src/swarms/doc/lifecycle.md create mode 100644 src/swarms/doc/maintenance.md delete mode 100644 src/swarms/doc/sequence-discovery.md delete mode 100644 src/swarms/doc/sequence-fleet-identity.md delete mode 100644 src/swarms/doc/sequence-fleet-maintenance.md delete mode 100644 src/swarms/doc/sequence-lifecycle.md delete mode 100644 src/swarms/doc/sequence-registration.md create mode 100644 src/swarms/doc/swarm-operations.md diff --git a/src/swarms/doc/README.md b/src/swarms/doc/README.md index 17dbbf80..b7f927a8 100644 --- a/src/swarms/doc/README.md +++ b/src/swarms/doc/README.md @@ -1,66 +1,132 @@ -# Swarm System Documentation - -This directory contains documentation for the Swarm System smart contracts. - -## Documentation Index - -| Document | Description | -| :------------------------------------------------------------- | :--------------------------------------------- | -| [assistant-guide.md](assistant-guide.md) | Architecture overview and workflows | -| [graph-architecture.md](graph-architecture.md) | Contract architecture diagrams | -| [sequence-registration.md](sequence-registration.md) | Fleet & swarm registration flows | -| [sequence-lifecycle.md](sequence-lifecycle.md) | Swarm & fleet lifecycle (update, delete, burn) | -| [sequence-discovery.md](sequence-discovery.md) | Client discovery flows | -| [sequence-fleet-identity.md](sequence-fleet-identity.md) | FleetIdentity: regions, tiers, bundles | -| [sequence-fleet-maintenance.md](sequence-fleet-maintenance.md) | Fleet maintenance: staying included in bundles | +# Swarm System Technical Specification + +Privacy-preserving BLE tag registry enabling decentralized device discovery without revealing individual tag identities on-chain. + +## Architecture + +```mermaid +graph TB + subgraph NFTs["Identity Layer (ERC-721)"] + FI["FleetIdentity
SFID
tokenId = (regionKey << 128) | uuid"] + SP["ServiceProvider
SSV
tokenId = keccak256(url)"] + end + + subgraph Registries["Registry Layer"] + REG["SwarmRegistry
L1: SSTORE2 filter storage
Universal: native bytes storage"] + end + + subgraph Actors + FO(("Fleet
Owner")) + PRV(("Service
Provider")) + ANY(("Client /
Purger")) + end + + FO -- "registerFleet* / claimUuid" --> FI + FO -- "registerSwarm / update / delete" --> REG + PRV -- "registerProvider(url)" --> SP + PRV -- "acceptSwarm / rejectSwarm" --> REG + ANY -- "buildHighestBondedUuidBundle /
checkMembership / purge" --> REG + + REG -. "uuidOwner(fleetUuid)" .-> FI + REG -. "ownerOf(providerId)" .-> SP + + style FI fill:#4a9eff,color:#fff + style SP fill:#4a9eff,color:#fff + style REG fill:#ff9f43,color:#fff + style FO fill:#2ecc71,color:#fff + style PRV fill:#2ecc71,color:#fff + style ANY fill:#95a5a6,color:#fff +``` + +## Core Components + +| Contract | Role | Identity | Token | +| :----------------------- | :------------------------------ | :----------------------------------------- | :---- | +| **FleetIdentity** | Fleet registry (ERC-721) | `(regionKey << 128) \| uuid` | SFID | +| **ServiceProvider** | Backend URL registry (ERC-721) | `keccak256(url)` | SSV | +| **SwarmRegistryL1** | Tag group registry (L1) | `keccak256(fleetUuid, providerId, filter)` | — | +| **SwarmRegistryUniversal** | Tag group registry (ZkSync+) | `keccak256(fleetUuid, providerId, filter)` | — | + +All contracts are **permissionless**—access control via NFT ownership. FleetIdentity requires ERC-20 bond (anti-spam). + +## Key Concepts + +### Swarm +A group of ~10k-20k BLE tags represented by an XOR filter. Tags are never enumerated on-chain; membership is verified via cryptographic filter. + +### UUID Ownership +UUIDs (iBeacon Proximity UUID) have ownership levels: + +| Level | Region Key | Bond | Description | +| :------ | :--------- | :----------------- | :----------------------------- | +| Owned | 0 | BASE_BOND | Reserved, not in any region | +| Local | ≥1024 | BASE_BOND × 2^tier | Registered in admin area | +| Country | 1-999 | BASE_BOND × 16 × 2^tier | Registered at country level | + +### Geographic Tiers +Each region has independent tier competition: +- **Tier capacity**: 4 members per tier +- **Max tiers**: 24 per region +- **Bundle size**: Up to 20 UUIDs returned to clients + +Country fleets pay 16× more but appear in all admin-area bundles within their country. + +### Token ID Encoding +``` +tokenId = (regionKey << 128) | uint256(uint128(uuid)) +``` +- Bits 0-127: UUID +- Bits 128-159: Region key + +## Documentation + +| Document | Description | +| :------- | :---------- | +| [data-model.md](data-model.md) | Contract interfaces, enums, storage layout | +| [fleet-registration.md](fleet-registration.md) | Fleet & UUID registration, tier economics | +| [swarm-operations.md](swarm-operations.md) | Swarm registration, filters, provider approval | +| [lifecycle.md](lifecycle.md) | State machines, updates, deletion, orphan cleanup | +| [discovery.md](discovery.md) | Client discovery flows, tag hash construction | +| [maintenance.md](maintenance.md) | Bundle inclusion monitoring, tier optimization | +| [iso3166-reference.md](iso3166-reference.md) | ISO 3166-1/2 codes and admin area mappings | + +## End-to-End Flow + +```mermaid +sequenceDiagram + participant FO as Fleet Owner + participant FI as FleetIdentity + participant SR as SwarmRegistry + participant SP as ServiceProvider + participant Client as EdgeBeaconScanner + + Note over FO: 1. Register fleet + FO->>FI: registerFleetLocal(uuid, cc, admin, tier) + + Note over FO: 2. Register provider + FO->>SP: registerProvider(url) + + Note over FO: 3. Register swarm + FO->>SR: registerSwarm(uuid, providerId, filter, ...) + + Note over FO: 4. Provider approves + FO->>SR: acceptSwarm(swarmId) + + Note over Client: 5. Client discovers + Client->>FI: buildHighestBondedUuidBundle(cc, admin) + Client->>SR: uuidSwarms(uuid, 0) + Client->>SR: checkMembership(swarmId, tagHash) + Client->>SP: providerUrls(providerId) + Note over Client: Connect to service URL +``` + +## Storage Variants + +| Variant | Chain | Filter Storage | Deletion Behavior | +| :------ | :---- | :------------- | :---------------- | +| **SwarmRegistryL1** | Ethereum L1 | SSTORE2 (contract bytecode) | Struct cleared; bytecode persists | +| **SwarmRegistryUniversal** | ZkSync Era, all EVM | `mapping(uint256 => bytes)` | Full deletion, gas refund | --- -# ISO 3166-2 Admin Area Mappings - -The [iso3166-2/](iso3166-2/) directory contains standardized mappings from ISO 3166-2 subdivision codes to admin codes for use with the FleetIdentity contract. - -## File Naming Convention - -Each file in [iso3166-2/](iso3166-2/) is named `{ISO_3166-1_numeric}-{Country_Name}.md` - -Example: [840-United_States.md](iso3166-2/840-United_States.md) for the United States (ISO 3166-1 numeric: 840) - -## Table Format - -Each country file contains a mapping table with three columns: - -| Admin Code | ISO 3166-2 Code | Name | -| ---------- | --------------- | --------------------- | -| 1 | XX | Full subdivision name | - -- **Admin Code**: Sequential integers from 1 to n (where n = number of subdivisions). This is the 1-indexed admin code that must be provided when calling FleetIdentity contract functions. -- **ISO 3166-2 Code**: The subdivision code (1-3 alphanumeric characters, without country prefix) -- **Name**: Full official name of the subdivision - -## Usage with FleetIdentity Contract - -The FleetIdentity contract uses: - -- **Country Code**: ISO 3166-1 numeric (1-999) -- **Admin Code**: Values from the table column (1-4095) - - Admin codes are 1-indexed in the table files - - Valid range: 1-255 (soft limit covers all real-world countries) - - Admin codes must be > 0 (0 is invalid and will revert with `InvalidAdminCode()`) - -## Example - -For Alberta, Canada: - -- Country: Canada (ISO 3166-1 numeric: 124) -- ISO 3166-2: CA-AB -- Admin Code: 1 (from table) -- Region Key: `(124 << 10) | 1 = 127001` (computed as `(countryCode << 10) | adminCode`) - -## Coverage - -This directory aims to provide mappings for all countries with defined ISO 3166-2 subdivisions. Countries without official subdivisions may be omitted. - -## Data Sources - -Mappings are based on ISO 3166-2 standard as maintained by ISO and various national statistical agencies. +*For implementation details, see individual documentation pages.* diff --git a/src/swarms/doc/assistant-guide.md b/src/swarms/doc/assistant-guide.md deleted file mode 100644 index 05c75803..00000000 --- a/src/swarms/doc/assistant-guide.md +++ /dev/null @@ -1,407 +0,0 @@ -# Swarm System Architecture & Implementation Guide - -> **Context for AI Agents**: This document outlines the architecture, constraints, and operational logic of the Swarm Smart Contract system. Use this context when modifying contracts, writing SDKs, or debugging verifiers. - -## 1. System Overview - -The Swarm System is a privacy-preserving registry for **BLE (Bluetooth Low Energy)** tag swarms. It allows Fleet Owners to manage large sets of tags (~10k-20k) and link them to Service Providers (Backend URLs) without revealing the individual identity of every tag on-chain. - -Two registry variants exist for different deployment targets: - -- **`SwarmRegistryL1`** — Ethereum L1, uses SSTORE2 (contract bytecode) for gas-efficient filter storage. Not compatible with ZkSync Era. -- **`SwarmRegistryUniversal`** — All EVM chains including ZkSync Era, uses native `bytes` storage. - -### Core Components - -| Contract | Role | Key Identity | Token | -| :--------------------------- | :---------------------------------- | :----------------------------------------- | :---- | -| **`FleetIdentity`** | Fleet Registry (ERC-721 Enumerable) | `(regionKey << 128) \| uint128(uuid)` | SFID | -| **`ServiceProvider`** | Service Registry (ERC-721) | `keccak256(url)` | SSV | -| **`SwarmRegistryL1`** | Swarm Registry (L1) | `keccak256(fleetUuid, providerId, filter)` | — | -| **`SwarmRegistryUniversal`** | Swarm Registry (Universal) | `keccak256(fleetUuid, providerId, filter)` | — | - -All contracts are **permissionless** — access control is enforced through NFT ownership rather than admin roles. `FleetIdentity` additionally requires an ERC-20 bond (e.g. NODL) to register a fleet, acting as an anti-spam / anti-abuse mechanism. - -Both NFT contracts support **burning** — the token owner can call `burn(tokenId)` to destroy their NFT. Burning a `FleetIdentity` token refunds the tier bond to the owner. Burning either NFT makes any swarms referencing that token _orphaned_. - -### FleetIdentity: Two-Level Geographic Registration - -`FleetIdentity` implements a **two-level geographic registration** system: - -- **Country Level** — `regionKey = countryCode` (ISO 3166-1 numeric, 1-999) -- **Admin Area (Local) Level** — `regionKey = (countryCode << 10) | adminCode` (>= 1024) - -Each region has its own independent tier namespace. The first fleet in any region always pays the level-appropriate base bond. - -**TokenID Encoding:** - -``` -tokenId = (regionKey << 128) | uint256(uint128(uuid)) -``` - -- Bits 0-127: UUID (Proximity UUID as bytes16) -- Bits 128-159: Region key (country or admin-area code) - -This allows the same UUID to be registered in multiple regions, each with a distinct token. - -### Economic Model (Tier System) - -| Parameter | Value | -| :------------------ | :--------------------------------------------------------- | -| **Tier Capacity** | 4 members per tier | -| **Max Tiers** | 24 per region | -| **Local Bond** | `BASE_BOND * 2^tier` | -| **Country Bond** | `BASE_BOND * COUNTRY_BOND_MULTIPLIER * 2^tier` (16× local) | -| **Max Bundle Size** | 20 UUIDs | - -Country fleets pay 16× more but appear in all admin-area bundles within their country. This economic difference provides locals a significant advantage: a local can reach tier 3 for the same cost a country player pays for tier 0. - -### UUID Ownership Model - -UUIDs have an ownership model with registration levels: - -| Level | Value | Description | -| :-------- | :---- | :--------------------------------------- | -| `None` | 0 | Not registered (default) | -| `Owned` | 1 | Claimed but not registered in any region | -| `Local` | 2 | Registered at admin area level | -| `Country` | 3 | Registered at country level | - -- **UUID Owner**: The address that first registered a token for a UUID. All subsequent registrations must come from this address. -- **Multi-Region**: The same UUID can have multiple tokens in different regions (all at the same level, all by the same owner). -- **Transfer**: Owned-only tokens transfer `uuidOwner` when the NFT is transferred. - ---- - -## 2. Operational Workflows - -### A. Provider Setup (One-Time) - -**Service Provider** calls `ServiceProvider.registerProvider("https://cms.example.com")`. Receives `providerTokenId` (= `keccak256(url)`). - -### B. Fleet Registration Options - -Fleet Owners have multiple paths to register fleets: - -#### B1. Direct Registration (Country Level) - -```solidity -// 1. Approve bond token -NODL.approve(fleetIdentityAddress, requiredBond); - -// 2. Get inclusion hint (off-chain call - free) -(uint256 tier, uint256 bond) = fleetIdentity.countryInclusionHint(840); // US = 840 - -// 3. Register at the recommended tier -uint256 tokenId = fleetIdentity.registerFleetCountry(uuid, 840, tier); -// Returns tokenId = (840 << 128) | uint128(uuid) -``` - -#### B2. Direct Registration (Local/Admin Area Level) - -```solidity -// 1. Approve bond token -NODL.approve(fleetIdentityAddress, requiredBond); - -// 2. Get inclusion hint (off-chain call - free) -(uint256 tier, uint256 bond) = fleetIdentity.localInclusionHint(840, 5); // US, California - -// 3. Register at the recommended tier -uint256 tokenId = fleetIdentity.registerFleetLocal(uuid, 840, 5, tier); -// Returns tokenId = ((840 << 10 | 5) << 128) | uint128(uuid) -``` - -#### B3. Claim-First Flow (Reserve UUID, Register Later) - -```solidity -// 1. Claim UUID ownership (costs BASE_BOND) -NODL.approve(fleetIdentityAddress, BASE_BOND); -uint256 ownedTokenId = fleetIdentity.claimUuid(uuid); -// Returns tokenId = uint128(uuid) (regionKey = 0) - -// 2. Later: Register from owned state (burns owned token, mints regional token) -// Only pays incremental bond (tier bond - BASE_BOND already paid) -uint256 tokenId = fleetIdentity.registerFleetLocal(uuid, 840, 5, targetTier); -``` - -### C. Fleet Tier Management - -Fleets can promote or demote within their region: - -```solidity -// Promote to next tier (pulls additional bond) -fleetIdentity.promote(tokenId); - -// Reassign to any tier (promotes or demotes) -fleetIdentity.reassignTier(tokenId, targetTier); -// If targetTier > current: pulls additional bond -// If targetTier < current: refunds bond difference -``` - -### D. Unregister to Owned State - -A fleet with a **single token** can unregister back to owned-only state: - -```solidity -// Returns to owned state, refunds (tierBond - BASE_BOND) -uint256 ownedTokenId = fleetIdentity.unregisterToOwned(tokenId); -// Reverts if UUID has multiple tokens (multi-region registration) -``` - -### E. Release UUID Ownership - -An owned-only UUID can be fully released, refunding BASE_BOND: - -```solidity -// Must be in Owned state (not registered in any region) -fleetIdentity.releaseUuid(uuid); -// Clears uuidOwner, allows anyone to claim the UUID -``` - -### F. Burn Fleet Token - -```solidity -// Burns token and refunds tier bond -fleetIdentity.burn(tokenId); -// For owned-only tokens: refunds BASE_BOND -// For registered tokens: refunds tierBond(tier, isCountry) -``` - -### G. 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(fleetUuid, providerId, filterData)` off-chain to obtain the deterministic ID before submitting the transaction. -4. **Register**: - ```solidity - swarmRegistry.registerSwarm( - fleetUuid, - providerId, - filterData, - 16, // Fingerprint size in bits (1–16) - TagType.IBEACON_INCLUDES_MAC // or PAYLOAD_ONLY, VENDOR_ID, GENERIC - ); - // Returns the deterministic swarmId - ``` - -### H. 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. - -### I. 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)` - -### J. Swarm Deletion - -The fleet owner can permanently remove a swarm: - -```solidity -swarmRegistry.deleteSwarm(swarmId); -``` - -### K. 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(fleetUuid, providerId, filterData))) -``` - -This means the same (UUID, provider, filter) triple always produces the same ID, and duplicate registrations revert with `SwarmAlreadyExists()`. The `computeSwarmId` function is `public pure`, so it can be called off-chain at zero cost via `eth_call`. - ---- - -## 4. Client Discovery Flow (The "EdgeBeaconScanner" Perspective) - -A client (mobile phone or gateway) scans a BLE beacon and wants to find its owner and backend service. - -### Discovery Option A: Geographic Bundle Discovery (Recommended) - -Use the priority-ordered bundle based on EdgeBeaconScanner location. - -#### Step 1: Get Priority Bundle - -```solidity -// EdgeBeaconScanner knows its location: US, California (country=840, admin=5) -(bytes16[] memory uuids, uint256 count) = fleetIdentity.buildHighestBondedUuidBundle(840, 5); -// Returns up to 20 UUIDs, priority-ordered: -// 1. Higher tier first -// 2. Local (admin area) before country within same tier -// 3. Earlier registration within same tier+level -``` - -#### Step 2: Match Detected Beacon UUID - -```solidity -bytes16 detectedUUID = ...; // From iBeacon advertisement - -for (uint256 i = 0; i < count; i++) { - if (uuids[i] == detectedUUID) { - // Found! Now find the token ID - // Try local region first, then country - uint32 localRegion = (840 << 10) | 5; - uint256 tokenId = fleetIdentity.computeTokenId(detectedUUID, localRegion); - if (fleetIdentity.ownerOf(tokenId) exists) { ... } - // else try country region - uint256 tokenId = fleetIdentity.computeTokenId(detectedUUID, 840); - } -} -``` - -#### Step 3: Enumerate Swarms & Check Membership - -Same as Option B Steps 3-5. - -### Discovery Option B: Direct Fleet Lookup - -For when you know the UUID and want to find its fleet directly. - -#### Step 1: Enumerate Active Regions - -```solidity -// Get all countries with active fleets -uint16[] memory countries = fleetIdentity.getActiveCountries(); - -// Get all admin areas with active fleets -uint32[] memory adminAreas = fleetIdentity.getActiveAdminAreas(); -``` - -#### Step 2: Find Fleet Token - -```solidity -bytes16 uuid = ...; // From iBeacon - -// Try each potential region (start with user's location) -uint32 region = (840 << 10) | 5; // US-CA -uint256 tokenId = fleetIdentity.computeTokenId(uuid, region); - -try fleetIdentity.ownerOf(tokenId) returns (address owner) { - // Found the fleet! -} catch { - // Try country-level - tokenId = fleetIdentity.computeTokenId(uuid, 840); -} -``` - -#### Step 3: Find Swarms - -```solidity -// Enumerate swarms for this UUID -uint256[] memory swarmIds = new uint256[](100); // estimate -for (uint256 i = 0; ; i++) { - try swarmRegistry.uuidSwarms(detectedUUID, i) returns (uint256 swarmId) { - swarmIds[i] = swarmId; - } catch { - break; // End of array - } -} -``` - -#### Step 4: Membership Check - -```solidity -// Construct tagHash based on swarm's tagType -(bytes16 fleetUuid, uint256 providerId, uint32 filterLen, uint8 fpSize, - SwarmStatus status, TagType tagType) = swarmRegistry.swarms(swarmId); - -// Build tagId per schema (see Section 3) -bytes memory tagId; -if (tagType == TagType.IBEACON_PAYLOAD_ONLY) { - tagId = abi.encodePacked(uuid, major, minor); -} else if (tagType == TagType.IBEACON_INCLUDES_MAC) { - bytes6 normalizedMac = isRandomMac ? bytes6(0xFFFFFFFFFFFF) : realMac; - tagId = abi.encodePacked(uuid, major, minor, normalizedMac); -} - -bytes32 tagHash = keccak256(tagId); -bool isMember = swarmRegistry.checkMembership(swarmId, tagHash); -``` - -#### Step 5: Service Discovery - -```solidity -if (isMember && status == SwarmStatus.ACCEPTED) { - string memory url = serviceProvider.providerUrls(providerId); - // Connect to url -} -``` - ---- - -## 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 `uuidSwarms` array, tracked via the `swarmIndexInUuid` mapping. - ---- - -**Note**: This architecture ensures that an EdgeBeaconScanner can go from **Raw Signal** → **Verified Service URL** entirely on-chain (data-wise), without a centralized indexer, while privacy of the 10,000 other tags in the swarm is preserved. diff --git a/src/swarms/doc/data-model.md b/src/swarms/doc/data-model.md new file mode 100644 index 00000000..6cbeeb5c --- /dev/null +++ b/src/swarms/doc/data-model.md @@ -0,0 +1,162 @@ +# Data Model & Contract Interfaces + +## Contract Classes + +```mermaid +classDiagram + class FleetIdentity { + +IERC20 BOND_TOKEN + +uint256 BASE_BOND + +uint256 TIER_CAPACITY = 4 + +uint256 MAX_TIERS = 24 + +uint256 COUNTRY_BOND_MULTIPLIER = 16 + +uint256 MAX_BONDED_UUID_BUNDLE_SIZE = 20 + +mapping uuidOwner : bytes16 → address + +mapping uuidLevel : bytes16 → RegistrationLevel + +mapping uuidTokenCount : bytes16 → uint256 + +mapping regionTierCount : uint32 → uint256 + +mapping fleetTier : uint256 → uint256 + -- + +claimUuid(uuid) → tokenId + +registerFleetLocal(uuid, cc, admin, tier) → tokenId + +registerFleetCountry(uuid, cc, tier) → tokenId + +promote(tokenId) + +reassignTier(tokenId, targetTier) + +unregisterToOwned(tokenId) → newTokenId + +releaseUuid(uuid) + +burn(tokenId) + -- + +localInclusionHint(cc, admin) → tier, bond + +countryInclusionHint(cc) → tier, bond + +buildHighestBondedUuidBundle(cc, admin) → uuids[], count + +buildCountryOnlyBundle(cc) → uuids[], count + +getActiveCountries() → uint16[] + +getActiveAdminAreas() → uint32[] + +tokenUuid(tokenId) → bytes16 + +tokenRegion(tokenId) → uint32 + +computeTokenId(uuid, region) → uint256 + +tierBond(tier, isCountry) → uint256 + } + + class ServiceProvider { + +mapping providerUrls : uint256 → string + -- + +registerProvider(url) → tokenId + +burn(tokenId) + } + + class SwarmRegistry { + +mapping swarms : uint256 → Swarm + +mapping uuidSwarms : bytes16 → uint256[] + +mapping swarmIndexInUuid : uint256 → uint256 + -- + +computeSwarmId(fleetUuid, providerId, filter) → swarmId + +registerSwarm(fleetUuid, 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 + } +``` + +## Struct: Swarm + +```solidity +struct Swarm { + bytes16 fleetUuid; // UUID that owns this swarm + uint256 providerId; // ServiceProvider token ID + uint32 filterLength; // XOR filter byte length + uint8 fingerprintSize; // Fingerprint bits (1-16) + SwarmStatus status; // Registration state + TagType tagType; // Tag identity scheme +} +``` + +## Enumerations + +### SwarmStatus +| Value | Description | +| :---- | :---------- | +| `REGISTERED` | Awaiting provider approval | +| `ACCEPTED` | Provider approved; active | +| `REJECTED` | Provider rejected | + +### TagType +| Value | Format | Use Case | +| :---- | :----- | :------- | +| `IBEACON_PAYLOAD_ONLY` | UUID ∥ Major ∥ Minor (20B) | Standard iBeacon | +| `IBEACON_INCLUDES_MAC` | UUID ∥ Major ∥ Minor ∥ MAC (26B) | Anti-spoofing | +| `VENDOR_ID` | companyID ∥ hash(vendorBytes) | Non-iBeacon BLE | +| `GENERIC` | Custom | Extensible | + +### RegistrationLevel +| Value | Region Key | Description | +| :---- | :--------- | :---------- | +| `None` (0) | — | Not registered | +| `Owned` (1) | 0 | Claimed, no region | +| `Local` (2) | ≥1024 | Admin area | +| `Country` (3) | 1-999 | Country-wide | + +## Region Key Encoding + +``` +Country: regionKey = countryCode (1-999) +Admin Area: regionKey = (countryCode << 10) | adminCode (≥1024) +``` + +**Token ID:** +``` +tokenId = (regionKey << 128) | uint256(uint128(uuid)) +``` + +**Helper functions:** +```solidity +bytes16 uuid = fleetIdentity.tokenUuid(tokenId); +uint32 region = fleetIdentity.tokenRegion(tokenId); +uint256 tokenId = fleetIdentity.computeTokenId(uuid, regionKey); +uint32 adminRegion = fleetIdentity.makeAdminRegion(countryCode, adminCode); +``` + +## Swarm ID Derivation + +Deterministic and collision-free: +```solidity +swarmId = uint256(keccak256(abi.encode(fleetUuid, providerId, filterData))) +``` + +Duplicate registration reverts with `SwarmAlreadyExists()`. + +## XOR Filter Membership + +3-hash XOR verification: + +``` +Input: h = keccak256(tagId) +M = filterLength * 8 / fingerprintSize // slots + +h1 = uint32(h) % M +h2 = uint32(h >> 32) % M +h3 = uint32(h >> 64) % M +fp = (h >> 96) & ((1 << fingerprintSize) - 1) + +Valid if: Filter[h1] ^ Filter[h2] ^ Filter[h3] == fp +``` + +## Storage Notes + +### SwarmRegistryL1 +- Filter stored as **contract bytecode** via SSTORE2 +- Gas-efficient reads (EXTCODECOPY) +- Bytecode persists after deletion (immutable) + +### SwarmRegistryUniversal +- Filter stored in `mapping(uint256 => bytes)` +- Full deletion reclaims storage +- `getFilterData(swarmId)` for off-chain retrieval + +### Deletion Performance +O(1) swap-and-pop via `swarmIndexInUuid` mapping. diff --git a/src/swarms/doc/discovery.md b/src/swarms/doc/discovery.md new file mode 100644 index 00000000..bb2b7818 --- /dev/null +++ b/src/swarms/doc/discovery.md @@ -0,0 +1,169 @@ +# Client Discovery + +## Overview + +Clients (mobile apps, gateways) discover BLE tags and resolve them to backend services entirely on-chain. + +``` +BLE Signal → UUID Match → Swarm Lookup → Membership Check → Service URL +``` + +## Geographic Bundle Discovery (Recommended) + +Use location-based priority bundles for efficient discovery. + +```mermaid +sequenceDiagram + actor Client as EdgeBeaconScanner + participant FI as FleetIdentity + participant SR as SwarmRegistry + participant SP as ServiceProvider + + Note over Client: Location: US-California (840, 5)
Detected: UUID, Major, Minor, MAC + + Client->>+FI: buildHighestBondedUuidBundle(840, 5) + FI-->>-Client: (uuids[], count) — up to 20 UUIDs + + Note over Client: Check if detectedUUID in bundle + + Client->>+SR: uuidSwarms(uuid, 0) + SR-->>-Client: swarmId + Note over Client: Iterate until revert + + Note over Client: Build tagHash per TagType + Client->>+SR: checkMembership(swarmId, tagHash) + SR-->>-Client: true + + Client->>+SR: swarms(swarmId) + SR-->>-Client: {providerId, status: ACCEPTED, ...} + + Client->>+SP: providerUrls(providerId) + SP-->>-Client: "https://api.example.com" + + Note over Client: Connect to service +``` + +### Bundle Priority + +1. **Tier**: Higher tier first +2. **Level**: Local before country (same tier) +3. **Time**: Earlier registration (same tier+level) + +## Direct UUID Lookup + +When UUID is known but location isn't: + +```solidity +// Try regions +uint32 localRegion = (840 << 10) | 5; +uint256 tokenId = fleetIdentity.computeTokenId(uuid, localRegion); +try fleetIdentity.ownerOf(tokenId) { /* found */ } +catch { /* try country: computeTokenId(uuid, 840) */ } + +// Enumerate swarms +for (uint i = 0; ; i++) { + try swarmRegistry.uuidSwarms(uuid, i) returns (uint256 swarmId) { + // process swarmId + } catch { break; } +} +``` + +## Tag Hash Construction + +```mermaid +flowchart TD + A[Read swarm.tagType] --> B{TagType?} + + B -->|IBEACON_PAYLOAD_ONLY| C["UUID ∥ Major ∥ Minor (20B)"] + B -->|IBEACON_INCLUDES_MAC| D{MAC type?} + B -->|VENDOR_ID| E["companyID ∥ hash(vendorBytes)"] + B -->|GENERIC| F["custom scheme"] + + D -->|Public| G["UUID ∥ Major ∥ Minor ∥ realMAC (26B)"] + D -->|Random| H["UUID ∥ Major ∥ Minor ∥ FF:FF:FF:FF:FF:FF"] + + 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 +``` + +### MAC Address Types + +| Address Type Bits | MAC Type | Action | +| :---------------- | :------- | :----- | +| `00` | Public | Use real MAC | +| `01`, `11` | Random/Private | Use `FF:FF:FF:FF:FF:FF` | + +## Region Enumeration (Indexers) + +```solidity +// Active countries +uint16[] memory countries = fleetIdentity.getActiveCountries(); +// [840, 276, 392, ...] + +// Active admin areas +uint32[] memory adminAreas = fleetIdentity.getActiveAdminAreas(); +// [860165, 282629, ...] → (cc << 10) | admin + +// Tier data +uint256 tierCount = fleetIdentity.regionTierCount(regionKey); +uint256[] memory tokenIds = fleetIdentity.getTierMembers(regionKey, tier); +bytes16[] memory uuids = fleetIdentity.getTierUuids(regionKey, tier); +``` + +## Complete Discovery Example + +```solidity +function discoverService( + bytes16 uuid, + uint16 major, + uint16 minor, + bytes6 mac, + uint16 countryCode, + uint8 adminCode +) external view returns (string memory serviceUrl, bool found) { + // 1. Check bundle + (bytes16[] memory uuids, uint256 count) = + fleetIdentity.buildHighestBondedUuidBundle(countryCode, adminCode); + + for (uint i = 0; i < count; i++) { + if (uuids[i] != uuid) continue; + + // 2. Find swarms + for (uint j = 0; ; j++) { + uint256 swarmId; + try swarmRegistry.uuidSwarms(uuid, j) returns (uint256 id) { + swarmId = id; + } catch { break; } + + // 3. Get swarm data + (,uint256 providerId,,,SwarmStatus status, TagType tagType) = + swarmRegistry.swarms(swarmId); + + if (status != SwarmStatus.ACCEPTED) continue; + + // 4. Build tagId + bytes memory tagId; + if (tagType == TagType.IBEACON_PAYLOAD_ONLY) { + tagId = abi.encodePacked(uuid, major, minor); + } else if (tagType == TagType.IBEACON_INCLUDES_MAC) { + tagId = abi.encodePacked(uuid, major, minor, mac); + } + + // 5. Check membership + if (swarmRegistry.checkMembership(swarmId, keccak256(tagId))) { + return (serviceProvider.providerUrls(providerId), true); + } + } + } + + return ("", false); +} +``` diff --git a/src/swarms/doc/fleet-registration.md b/src/swarms/doc/fleet-registration.md new file mode 100644 index 00000000..0f5c00e9 --- /dev/null +++ b/src/swarms/doc/fleet-registration.md @@ -0,0 +1,237 @@ +# Fleet Registration + +## Registration Paths + +```mermaid +stateDiagram-v2 + [*] --> None : (default) + + None --> Owned : claimUuid() + None --> Local : registerFleetLocal() + None --> Country : registerFleetCountry() + + Owned --> Local : registerFleetLocal() + Owned --> Country : registerFleetCountry() + Owned --> [*] : releaseUuid() / burn() + + Local --> Owned : unregisterToOwned() + Local --> [*] : burn() + + Country --> Owned : unregisterToOwned() + Country --> [*] : burn() +``` + +## Direct Registration + +### Local (Admin Area) + +```solidity +// 1. Approve bond +NODL.approve(fleetIdentityAddress, requiredBond); + +// 2. Get recommended tier (free off-chain call) +(uint256 tier, uint256 bond) = fleetIdentity.localInclusionHint(840, 5); + +// 3. Register +uint256 tokenId = fleetIdentity.registerFleetLocal(uuid, 840, 5, tier); +// tokenId = ((840 << 10 | 5) << 128) | uint128(uuid) +``` + +### Country + +```solidity +// 1. Approve bond +NODL.approve(fleetIdentityAddress, requiredBond); + +// 2. Get recommended tier +(uint256 tier, uint256 bond) = fleetIdentity.countryInclusionHint(840); + +// 3. Register +uint256 tokenId = fleetIdentity.registerFleetCountry(uuid, 840, tier); +// tokenId = (840 << 128) | uint128(uuid) +``` + +## Claim-First Flow + +Reserve UUID, register later: + +```solidity +// 1. Claim (costs BASE_BOND) +NODL.approve(fleetIdentityAddress, BASE_BOND); +uint256 ownedTokenId = fleetIdentity.claimUuid(uuid); +// tokenId = uint128(uuid), regionKey = 0 + +// 2. Later: Register (pays incremental: tierBond - BASE_BOND) +uint256 tokenId = fleetIdentity.registerFleetLocal(uuid, 840, 5, tier); +// Burns owned token, mints regional token +``` + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + FO->>TOKEN: approve(FleetIdentity, BASE_BOND) + FO->>+FI: claimUuid(uuid) + FI->>TOKEN: transferFrom(owner, this, BASE_BOND) + FI-->>-FO: tokenId = uint128(uuid) + + Note over FO: Later... + + FO->>TOKEN: approve(FleetIdentity, incrementalBond) + FO->>+FI: registerFleetLocal(uuid, cc, admin, tier) + Note over FI: Burns owned token + FI->>TOKEN: transferFrom(owner, this, tierBond - BASE_BOND) + FI-->>-FO: tokenId = ((cc<<10|admin)<<128) | uuid +``` + +## Tier Economics + +### Bond Formula + +| Level | Formula | +| :---- | :------ | +| Owned | `BASE_BOND` | +| Local | `BASE_BOND × 2^tier` | +| Country | `BASE_BOND × 16 × 2^tier` | + +**Example (BASE_BOND = 100):** + +| Tier | Local | Country | +| :--- | ----: | ------: | +| 0 | 100 | 1,600 | +| 1 | 200 | 3,200 | +| 2 | 400 | 6,400 | +| 3 | 800 | 12,800 | + +### Economic Design + +- **Tier capacity**: 4 members per tier +- **Max tiers**: 24 per region +- **Bundle limit**: 20 UUIDs per location + +Country fleets pay 16× but appear in **all** admin-area bundles. Locals have cost advantage within their area. + +## Tier Management + +### Promote + +```solidity +// Approve additional bond +fleetIdentity.promote(tokenId); +// Moves to currentTier + 1 +``` + +### Reassign + +```solidity +// Move to any tier +fleetIdentity.reassignTier(tokenId, targetTier); +// Promotion: pulls difference +// Demotion: refunds difference +``` + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + alt Promote + FO->>TOKEN: approve(additionalBond) + FO->>+FI: reassignTier(tokenId, higherTier) + FI->>TOKEN: transferFrom(owner, this, diff) + FI-->>-FO: FleetPromoted + else Demote + FO->>+FI: reassignTier(tokenId, lowerTier) + FI->>TOKEN: transfer(owner, refund) + FI-->>-FO: FleetDemoted + end +``` + +## Unregister to Owned + +Return to owned-only state (single token required): + +```solidity +uint256 ownedTokenId = fleetIdentity.unregisterToOwned(tokenId); +// Refunds tierBond - BASE_BOND +// Reverts if UUID has multiple tokens +``` + +## Release UUID + +Fully release UUID ownership: + +```solidity +fleetIdentity.releaseUuid(uuid); +// Must be in Owned state +// Refunds BASE_BOND +// UUID can be claimed by anyone +``` + +## Multi-Region Registration + +Same UUID can have multiple tokens at the **same level**: + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + + FO->>+FI: registerFleetLocal(uuid, 840, 5, 0) + Note over FI: uuidLevel = Local, tokenCount = 1 + FI-->>-FO: tokenId_US + + FO->>+FI: registerFleetLocal(uuid, 276, 1, 0) + Note over FI: Same owner, same level, tokenCount = 2 + FI-->>-FO: tokenId_DE + + FO->>+FI: registerFleetCountry(uuid, 392, 0) + FI-->>-FO: ❌ UuidLevelMismatch() +``` + +**Constraints:** +- All tokens must be same level (Local or Country) +- Cannot `unregisterToOwned` with multiple tokens +- Each region pays its own tier bond + +## Burning + +```solidity +fleetIdentity.burn(tokenId); +// Refunds full tier bond +// Decrements tokenCount +// Clears uuidOwner if last token +``` + +## Owned Token Transfer + +Owned-only tokens transfer UUID ownership: + +```solidity +// ERC-721 transfer +fleetIdentity.transferFrom(alice, bob, tokenId); +// uuidOwner[uuid] = bob +// Bob can now register to regions +``` + +Registered tokens can also transfer but do not change `uuidOwner`. + +## Inclusion Hints + +View functions that recommend cheapest tier guaranteeing bundle inclusion. + +### Local Hint +```solidity +(uint256 tier, uint256 bond) = fleetIdentity.localInclusionHint(cc, admin); +// Simulates bundle for specific admin area +``` + +### Country Hint +```solidity +(uint256 tier, uint256 bond) = fleetIdentity.countryInclusionHint(cc); +// Scans ALL active admin areas (unbounded, free off-chain) +// Returns tier guaranteeing inclusion everywhere +``` diff --git a/src/swarms/doc/graph-architecture.md b/src/swarms/doc/graph-architecture.md deleted file mode 100644 index bd5435b7..00000000 --- a/src/swarms/doc/graph-architecture.md +++ /dev/null @@ -1,132 +0,0 @@ -# Swarm System — Contract Architecture - -```mermaid -graph TB - subgraph NFTs["Identity Layer (ERC-721)"] - FI["FleetIdentity
SFID
tokenId = (regionKey << 128) | uuid"] - SP["ServiceProvider
SSV
tokenId = keccak256(url)"] - end - - subgraph Registries["Registry Layer"] - REG["SwarmRegistry
L1 variant: SSTORE2 filter storage
Universal variant: native bytes storage"] - end - - subgraph Actors - FO(("Fleet
Owner")) - PRV(("Service
Provider")) - ANY(("Anyone
(EdgeBeaconScanner / Purger)")) - end - - FO -- "claimUuid(uuid) /
registerFleetLocal(uuid, cc, admin, tier) /
registerFleetCountry(uuid, cc, tier)" --> FI - FO -- "registerSwarm / update / delete" --> REG - PRV -- "registerProvider(url)" --> SP - PRV -- "acceptSwarm / rejectSwarm" --> REG - ANY -- "checkMembership / purgeOrphanedSwarm /
buildHighestBondedUuidBundle" --> FI - ANY -- "checkMembership / purgeOrphanedSwarm" --> REG - - REG -. "uuidOwner(fleetUuid)" .-> FI - REG -. "ownerOf(providerId)" .-> SP - - style FI fill:#4a9eff,color:#fff - style SP fill:#4a9eff,color:#fff - style REG fill:#ff9f43,color:#fff - style FO fill:#2ecc71,color:#fff - style PRV fill:#2ecc71,color:#fff - style ANY fill:#95a5a6,color:#fff -``` - -## Swarm Data Model - -```mermaid -classDiagram - class FleetIdentity { - +IERC20 BOND_TOKEN (immutable) - +uint256 BASE_BOND (immutable) - +uint256 TIER_CAPACITY = 4 - +uint256 MAX_TIERS = 24 - +uint256 COUNTRY_BOND_MULTIPLIER = 16 - +uint256 MAX_BONDED_UUID_BUNDLE_SIZE = 20 - +mapping uuidOwner - +mapping uuidLevel - +mapping uuidTokenCount - +mapping regionTierCount - +mapping fleetTier - +claimUuid(uuid) tokenId - +registerFleetLocal(uuid, cc, admin, tier) tokenId - +registerFleetCountry(uuid, cc, tier) tokenId - +promote(tokenId) - +reassignTier(tokenId, targetTier) - +unregisterToOwned(tokenId) newTokenId - +releaseUuid(uuid) - +burn(tokenId) - +localInclusionHint(cc, admin) tier, bond - +countryInclusionHint(cc) tier, bond - +buildHighestBondedUuidBundle(cc, admin) uuids, count - +getActiveCountries() uint16[] - +getActiveAdminAreas() uint32[] - +tokenUuid(tokenId) bytes16 - +tokenRegion(tokenId) uint32 - +computeTokenId(uuid, region) uint256 - +tierBond(tier, isCountry) uint256 - +bonds(tokenId) uint256 - } - - class ServiceProvider { - +mapping providerUrls - +registerProvider(url) tokenId - +burn(tokenId) - } - - class SwarmRegistry { - +mapping swarms - +mapping uuidSwarms - +mapping swarmIndexInUuid - +computeSwarmId(fleetUuid, providerId, filter) swarmId - +registerSwarm(fleetUuid, 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 { - bytes16 fleetUuid - uint256 providerId - uint8 fingerprintSize - TagType tagType - SwarmStatus status - } - - class SwarmStatus { - <> - REGISTERED - ACCEPTED - REJECTED - } - - class TagType { - <> - IBEACON_PAYLOAD_ONLY - IBEACON_INCLUDES_MAC - VENDOR_ID - GENERIC - } - - class RegistrationLevel { - <> - None = 0 - Owned = 1 - Local = 2 - Country = 3 - } - - SwarmRegistry --> FleetIdentity : validates ownership - SwarmRegistry --> ServiceProvider : validates ownership - SwarmRegistry *-- Swarm : stores - Swarm --> SwarmStatus - Swarm --> TagType -``` diff --git a/src/swarms/doc/iso3166-reference.md b/src/swarms/doc/iso3166-reference.md new file mode 100644 index 00000000..6ae43f25 --- /dev/null +++ b/src/swarms/doc/iso3166-reference.md @@ -0,0 +1,97 @@ +# ISO 3166 Reference + +## Country Codes (ISO 3166-1 Numeric) + +FleetIdentity uses ISO 3166-1 numeric codes (1-999) for country identification. + +| Code | Country | +| :--- | :------ | +| 124 | Canada | +| 250 | France | +| 276 | Germany | +| 392 | Japan | +| 826 | United Kingdom | +| 840 | United States | + +## Admin Area Codes + +Admin codes map ISO 3166-2 subdivisions to 1-indexed integers. + +### Region Key Encoding + +``` +Country: regionKey = countryCode +Admin Area: regionKey = (countryCode << 10) | adminCode +``` + +**Examples:** +| Location | Country | Admin | Region Key | +| :------- | ------: | ----: | ---------: | +| United States | 840 | — | 840 | +| US-California | 840 | 5 | 860,165 | +| Canada | 124 | — | 124 | +| CA-Alberta | 124 | 1 | 127,001 | + +## Admin Area Mapping Files + +The [iso3166-2/](iso3166-2/) directory contains per-country mappings. + +### File Format + +Filename: `{ISO_3166-1_numeric}-{Country_Name}.md` + +| Admin Code | ISO 3166-2 | Name | +| ---------: | :--------- | :--- | +| 1 | XX | Full subdivision name | +| 2 | YY | ... | + +### Constraints + +- Admin codes: 1-indexed integers +- Valid range: 1-255 (covers all real-world subdivisions) +- Code 0 is invalid (reverts with `InvalidAdminCode()`) + +## United States (840) + +Selected entries from [iso3166-2/840-United_States.md](iso3166-2/840-United_States.md): + +| Admin | ISO 3166-2 | State | +| ----: | :--------- | :---- | +| 1 | AL | Alabama | +| 5 | CA | California | +| 32 | NY | New York | +| 43 | TX | Texas | + +## Usage + +```solidity +// US-California +uint16 countryCode = 840; +uint8 adminCode = 5; +uint32 regionKey = fleetIdentity.makeAdminRegion(countryCode, adminCode); +// regionKey = (840 << 10) | 5 = 860165 + +// Register +fleetIdentity.registerFleetLocal(uuid, countryCode, adminCode, tier); +// tokenId = (860165 << 128) | uint128(uuid) +``` + +## Contract Functions + +```solidity +// Build region key +uint32 region = fleetIdentity.makeAdminRegion(countryCode, adminCode); + +// Active regions +uint16[] memory countries = fleetIdentity.getActiveCountries(); +uint32[] memory adminAreas = fleetIdentity.getActiveAdminAreas(); + +// Extract from token +uint32 region = fleetIdentity.tokenRegion(tokenId); +// If region < 1024: country-level +// If region >= 1024: adminCode = region & 0x3FF, countryCode = region >> 10 +``` + +## Data Source + +Mappings based on ISO 3166-2 standard maintained by ISO and national statistical agencies. diff --git a/src/swarms/doc/lifecycle.md b/src/swarms/doc/lifecycle.md new file mode 100644 index 00000000..87c04057 --- /dev/null +++ b/src/swarms/doc/lifecycle.md @@ -0,0 +1,121 @@ +# Lifecycle & State Machines + +## UUID Registration States + +```mermaid +stateDiagram-v2 + [*] --> None + + None --> Owned : claimUuid() + None --> Local : registerFleetLocal() + None --> Country : registerFleetCountry() + + Owned --> Local : registerFleetLocal() + Owned --> Country : registerFleetCountry() + Owned --> [*] : releaseUuid() / burn() + + Local --> Owned : unregisterToOwned() + Local --> [*] : burn() all tokens + + Country --> Owned : unregisterToOwned() + Country --> [*] : burn() all tokens + + note right of Owned : regionKey = 0 + note right of Local : regionKey ≥ 1024 + note right of Country : regionKey 1-999 +``` + +### State Transitions + +| From | To | Function | Bond Effect | +| :--- | :- | :------- | :---------- | +| None | Owned | `claimUuid()` | Pull BASE_BOND | +| None | Local | `registerFleetLocal()` | Pull tierBond | +| None | Country | `registerFleetCountry()` | Pull tierBond | +| Owned | Local | `registerFleetLocal()` | Pull (tierBond - BASE_BOND) | +| Owned | Country | `registerFleetCountry()` | Pull (tierBond - BASE_BOND) | +| Local/Country | Owned | `unregisterToOwned()` | Refund (tierBond - BASE_BOND) | +| Owned | None | `releaseUuid()` / `burn()` | Refund BASE_BOND | +| Local/Country | None | `burn()` | Refund tierBond | + +## Swarm Status States + +```mermaid +stateDiagram-v2 + [*] --> REGISTERED : registerSwarm() + + REGISTERED --> ACCEPTED : acceptSwarm() + REGISTERED --> REJECTED : rejectSwarm() + + ACCEPTED --> REGISTERED : updateSwarm*() + REJECTED --> REGISTERED : updateSwarm*() + + REGISTERED --> [*] : delete / purge + ACCEPTED --> [*] : delete / purge + REJECTED --> [*] : delete / purge +``` + +### Status Effects + +| Status | checkMembership | Provider Action Required | +| :----- | :-------------- | :----------------------- | +| REGISTERED | Reverts | Accept or reject | +| ACCEPTED | Works | None | +| REJECTED | Reverts | None (fleet can update to retry) | + +## Fleet Token Lifecycle + +```mermaid +sequenceDiagram + participant TOKEN as BOND_TOKEN + participant FI as FleetIdentity + + Note over FI: Registration + FI->>TOKEN: transferFrom(owner, this, tierBond) + + Note over FI: Promotion + FI->>TOKEN: transferFrom(owner, this, additionalBond) + + Note over FI: Demotion + FI->>TOKEN: transfer(owner, refund) + + Note over FI: Unregister to Owned + FI->>TOKEN: transfer(owner, tierBond - BASE_BOND) + + Note over FI: Burn + FI->>TOKEN: transfer(owner, fullBond) +``` + +## Orphan Lifecycle + +```mermaid +flowchart TD + ACTIVE[Swarm Active] --> BURN{NFT burned?} + BURN -->|No| ACTIVE + BURN -->|Yes| ORPHAN[Swarm Orphaned] + ORPHAN --> CHECK[isSwarmValid returns false] + CHECK --> PURGE[Anyone: purgeOrphanedSwarm] + PURGE --> DELETED[Swarm Deleted + Gas Refund] +``` + +### Orphan Guards + +These operations revert with `SwarmOrphaned()` if either NFT invalid: +- `acceptSwarm(swarmId)` +- `rejectSwarm(swarmId)` +- `checkMembership(swarmId, tagHash)` + +## Region Index Maintenance + +```mermaid +flowchart LR + REG[registerFleet*] --> FIRST{First in region?} + FIRST -->|Yes| ADD[Add to activeCountries/activeAdminAreas] + FIRST -->|No| SKIP[Already indexed] + + BURN[burn / demotion] --> EMPTY{Region empty?} + EMPTY -->|Yes| REMOVE[Remove from index] + EMPTY -->|No| KEEP[Keep] +``` + +Indexes are automatically maintained—no manual intervention needed. diff --git a/src/swarms/doc/maintenance.md b/src/swarms/doc/maintenance.md new file mode 100644 index 00000000..20cf3a00 --- /dev/null +++ b/src/swarms/doc/maintenance.md @@ -0,0 +1,166 @@ +# Fleet Maintenance + +## Overview + +After registration, fleet owners must monitor bundle inclusion as market conditions change: +- New fleets registering at higher tiers +- Existing fleets promoting +- Bundle slots limited to 20 per location + +## Maintenance Cycle + +```mermaid +flowchart TD + START([Registered]) --> CHECK{In bundle?} + + CHECK -->|Yes| OPTIMIZE{Lower tier
possible?} + CHECK -->|No| PROMOTE[Promote] + + OPTIMIZE -->|Yes| DEMOTE[Demote → refund] + OPTIMIZE -->|No| WAIT + + DEMOTE --> WAIT + PROMOTE --> WAIT + + WAIT[Wait 24h] --> CHECK + + style DEMOTE fill:#2ecc71,color:#fff + style PROMOTE fill:#ff9f43,color:#fff +``` + +## Check Inclusion + +### Local Fleets + +```typescript +const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle( + countryCode, adminCode +); +const isIncluded = uuids.slice(0, count) + .some(u => u.toLowerCase() === myUuid.toLowerCase()); +``` + +### Country Fleets + +Must check **every** active admin area in their country: + +```typescript +const adminAreas = await fleetIdentity.getActiveAdminAreas(); +const myAdminAreas = adminAreas.filter(rk => Number(rk >> 10n) === myCountryCode); + +const missingAreas = []; +for (const rk of myAdminAreas) { + const adminCode = Number(rk & 0x3ffn); + const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle( + myCountryCode, adminCode + ); + if (!uuids.slice(0, count).some(u => u === myUuid)) { + missingAreas.push(adminCode); + } +} +``` + +### No Active Admin Areas + +When no EdgeBeaconScanners deployed yet: + +```typescript +const [uuids, count] = await fleetIdentity.buildCountryOnlyBundle(countryCode); +// Check position among country-level competitors only +``` + +## Get Required Tier + +### Local + +```solidity +(uint256 tier, uint256 bond) = fleetIdentity.localInclusionHint(cc, admin); +``` + +### Country + +```solidity +(uint256 tier, uint256 bond) = fleetIdentity.countryInclusionHint(cc); +// Scans ALL active admin areas (unbounded view, free off-chain) +``` + +## Promote + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + FO->>+FI: tierBond(currentTier, isCountry) + FI-->>-FO: currentBond + FO->>+FI: tierBond(targetTier, isCountry) + FI-->>-FO: targetBond + + Note over FO: additionalBond = targetBond - currentBond + + FO->>TOKEN: approve(FleetIdentity, additionalBond) + FO->>+FI: reassignTier(tokenId, targetTier) + FI->>TOKEN: transferFrom(...) + FI-->>-FO: FleetPromoted +``` + +### Quick Promote + +```solidity +fleetIdentity.promote(tokenId); +// Moves to currentTier + 1 +``` + +### Handle TierFull + +```typescript +while (attempts < 3) { + try { + await fleetIdentity.reassignTier(tokenId, requiredTier); + break; + } catch (e) { + if (e.message.includes("TierFull")) { + const [newTier] = await fleetIdentity.localInclusionHint(cc, admin); + requiredTier = newTier; + // Re-approve if needed + } else throw e; + } +} +``` + +## Demote (Save Bond) + +No approval needed—refunds automatically: + +```typescript +const [suggestedTier] = await fleetIdentity.localInclusionHint(cc, admin); +const currentTier = await fleetIdentity.fleetTier(tokenId); + +if (suggestedTier < currentTier) { + await fleetIdentity.reassignTier(tokenId, suggestedTier); + // Refund deposited to owner +} +``` + +## Propagation Timing + +| Phase | Duration | +| :---- | :------- | +| Transaction confirmation | ~1-2s (ZkSync) | +| Event indexing | ~1-10s | +| Edge network sync | Minutes to hours | + +**Recommendation**: 24-hour check interval. + +## Summary + +| Task | Method | +| :--- | :----- | +| Check inclusion (local) | `buildHighestBondedUuidBundle(cc, admin)` | +| Check inclusion (country) | Loop all admin areas | +| Get required tier (local) | `localInclusionHint(cc, admin)` | +| Get required tier (country) | `countryInclusionHint(cc)` | +| Calculate bond | `tierBond(tier, isCountry)` | +| Move tier | `reassignTier(tokenId, tier)` | +| Quick promote | `promote(tokenId)` | diff --git a/src/swarms/doc/sequence-discovery.md b/src/swarms/doc/sequence-discovery.md deleted file mode 100644 index 21b9021b..00000000 --- a/src/swarms/doc/sequence-discovery.md +++ /dev/null @@ -1,105 +0,0 @@ -# Client Discovery Sequence - -## Geographic Bundle Discovery Flow (Recommended) - -```mermaid -sequenceDiagram - actor EBS as EdgeBeaconScanner (Client) - participant FI as FleetIdentity - participant SR as SwarmRegistry - participant SP as ServiceProvider - - Note over EBS: Knows location: US-California
(countryCode=840, adminCode=5) - Note over EBS: Detects iBeacon:
UUID, Major, Minor, MAC - - rect rgb(240, 248, 255) - Note right of EBS: Step 1 — Get Priority Bundle - EBS ->>+ FI: buildHighestBondedUuidBundle(840, 5) - Note over FI: Merges admin-area + country tiers
Priority: higher tier first,
locals before country - FI -->>- EBS: (uuids[], count) — up to 20 UUIDs - end - - rect rgb(255, 248, 240) - Note right of EBS: Step 2 — Match Detected UUID - Note over EBS: Check if detectedUUID is in bundle - end - - rect rgb(240, 255, 240) - Note right of EBS: Step 3 — Find Swarms - EBS ->>+ SR: uuidSwarms(uuid, 0) - SR -->>- EBS: swarmId_0 - Note over EBS: ... iterate until revert - end - - rect rgb(248, 240, 255) - Note right of EBS: Step 4 — Membership Check - Note over EBS: Read swarms[swarmId].tagType - Note over EBS: Construct tagId per schema - Note over EBS: tagHash = keccak256(tagId) - EBS ->>+ SR: checkMembership(swarmId, tagHash) - SR -->>- EBS: true ✓ - end - - rect rgb(255, 255, 240) - Note right of EBS: Step 5 — Resolve Service URL - EBS ->>+ SR: swarms(swarmId) - SR -->>- EBS: { providerId, status: ACCEPTED, ... } - EBS ->>+ SP: providerUrls(providerId) - SP -->>- EBS: "https://api.example.com" - end - - Note over EBS: Connect to service URL ✓ -``` - -## Region Enumeration (For Indexers) - -```mermaid -sequenceDiagram - actor IDX as Indexer - participant FI as FleetIdentity - - rect rgb(240, 248, 255) - Note right of IDX: Enumerate active regions - IDX ->>+ FI: getActiveCountries() - FI -->>- IDX: [840, 276, 392, ...] - IDX ->>+ FI: getActiveAdminAreas() - FI -->>- IDX: [860165, 282629, ...] - Note over IDX: regionKey = (cc << 10) | admin
860165 = (840 << 10) | 5 = US-CA - end - - rect rgb(255, 248, 240) - Note right of IDX: Get tier data for a region - IDX ->>+ FI: regionTierCount(860165) - FI -->>- IDX: 3 (tiers 0, 1, 2 exist) - IDX ->>+ FI: getTierMembers(860165, 0) - FI -->>- IDX: [tokenId1, tokenId2, ...] - IDX ->>+ FI: getTierUuids(860165, 0) - FI -->>- IDX: [uuid1, uuid2, ...] - end -``` - -## 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-fleet-identity.md b/src/swarms/doc/sequence-fleet-identity.md deleted file mode 100644 index 9b38c82d..00000000 --- a/src/swarms/doc/sequence-fleet-identity.md +++ /dev/null @@ -1,293 +0,0 @@ -# FleetIdentity: Geographic Registration & Discovery - -This document covers the FleetIdentity contract's geographic registration system, tier economics, and bundle-based discovery. - -## Region Key Encoding - -FleetIdentity uses a 32-bit region key to identify geographic regions: - -| Level | Encoding | Range | Example | -| :------------- | :--------------------------------- | :----- | :-------------------------------- | -| **Owned-Only** | `0` | 0 | UUID claimed but not registered | -| **Country** | `countryCode` | 1-999 | US = 840 | -| **Admin Area** | `(countryCode << 10) \| adminCode` | ≥ 1024 | US-CA = (840 << 10) \| 5 = 860165 | - -### TokenID Encoding - -``` -tokenId = (regionKey << 128) | uint256(uint128(uuid)) -``` - -- **Bits 0-127**: UUID (Proximity UUID as bytes16) -- **Bits 128-159**: Region key - -**Helper Functions:** - -```solidity -// Extract UUID from tokenId -bytes16 uuid = fleetIdentity.tokenUuid(tokenId); - -// Extract region key from tokenId -uint32 region = fleetIdentity.tokenRegion(tokenId); - -// Compute tokenId from components -uint256 tokenId = fleetIdentity.computeTokenId(uuid, regionKey); - -// Build admin area region key -uint32 adminRegion = fleetIdentity.makeAdminRegion(countryCode, adminCode); -``` - -## Tier System & Bond Economics - -Each region has its own independent tier namespace with geometric bond progression. - -### Constants - -```solidity -TIER_CAPACITY = 4 // Max members per tier -MAX_TIERS = 24 // Max tiers per region -COUNTRY_BOND_MULTIPLIER = 16 // Country pays 16× local -MAX_BONDED_UUID_BUNDLE_SIZE = 20 // Bundle limit -``` - -### Bond Calculations - -| Registration Level | Tier Bond Formula | -| :--------------------- | :------------------------ | -| **Owned-Only** | `BASE_BOND` (flat) | -| **Local (Admin Area)** | `BASE_BOND * 2^tier` | -| **Country** | `BASE_BOND * 16 * 2^tier` | - -**Example with BASE_BOND = 100:** - -| Tier | Local Bond | Country Bond | -| :--- | :--------- | :----------- | -| 0 | 100 | 1,600 | -| 1 | 200 | 3,200 | -| 2 | 400 | 6,400 | -| 3 | 800 | 12,800 | -| 4 | 1,600 | 25,600 | - -### Economic Fairness - -Country fleets pay 16× more but appear in **all** admin-area bundles within their country. This gives locals a significant advantage: - -- A local at tier 4 (1,600) costs the same as a country at tier 0 (1,600) -- The local gets higher priority in their specific admin area - -## Inclusion Hints - -Off-chain (free) view functions that recommend the cheapest tier guaranteeing bundle inclusion. - -### Local Inclusion Hint - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - FO ->>+ FI: localInclusionHint(840, 5) - Note over FI: Simulates buildHighestBondedUuidBundle(840, 5) - Note over FI: Finds cheapest tier with:
1. Capacity (< 4 members)
2. Bundle room (< 20 total) - FI -->>- FO: (tier=1, bond=200) - - Note over FO: Register at recommended tier - FO ->>+ FI: registerFleetLocal(uuid, 840, 5, 1) - FI -->>- FO: tokenId -``` - -### Country Inclusion Hint - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - FO ->>+ FI: countryInclusionHint(840) - Note over FI: Scans ALL active admin areas in US - Note over FI: Finds tier that guarantees inclusion
across all bundles - Note over FI: ⚠️ Unbounded view (free off-chain) - FI -->>- FO: (tier=2, bond=6400) - - Note over FO: Register at recommended tier - FO ->>+ FI: registerFleetCountry(uuid, 840, 2) - FI -->>- FO: tokenId -``` - -## Bundle Discovery Algorithm - -`buildHighestBondedUuidBundle(countryCode, adminCode)` returns up to 20 UUIDs for a location. - -### Priority Rules - -1. **Tier Priority**: Higher tier always wins -2. **Level Priority**: Within same tier, local beats country -3. **Time Priority**: Within same tier+level, earlier registration wins - -### Algorithm Visualization - -```mermaid -flowchart TD - START([buildHighestBondedUuidBundle]) --> FIND[Find highest tier
across admin + country] - FIND --> LOOP{count < 20 AND
tier >= 0?} - - LOOP -->|Yes| LOCAL[Add admin-area tier members] - LOCAL --> COUNTRY[Add country tier members] - COUNTRY --> DEC[tier--] - DEC --> LOOP - - LOOP -->|No| RETURN([Return uuids, count]) - - style START fill:#4a9eff,color:#fff - style RETURN fill:#2ecc71,color:#fff -``` - -### Example Bundle Construction - -**Scenario**: Query `buildHighestBondedUuidBundle(840, 5)` (US-California) - -| Region | Tier 2 | Tier 1 | Tier 0 | -| :------------ | :----- | :--------- | :--------- | -| US-CA (local) | A, B | C, D, E, F | G | -| US (country) | P | Q, R | S, T, U, V | - -**Result** (20 max): `[A, B, P, C, D, E, F, Q, R, G, S, T, U, V]` (14 UUIDs) - -- Tier 2: A, B (local), then P (country) -- Tier 1: C, D, E, F (local), then Q, R (country) -- Tier 0: G (local), then S, T, U, V (country) - -### Country-Only Bundle - -When no admin areas are active in a country (no EdgeBeaconScanners deployed yet), use `buildCountryOnlyBundle(countryCode)` to inspect country-level competition. - -```solidity -// Get bundle with ONLY country-level fleets -(bytes16[] memory uuids, uint256 count) = fleetIdentity.buildCountryOnlyBundle(840); -``` - -This is useful for: - -- Verifying your position among other country-level competitors before scanners come online -- Understanding what your bundle contribution will be when admin areas become active - -**Note:** When admin areas do become active, local fleets at the same tier will have priority over country fleets. - -## Multi-Region Registration - -The same UUID can be registered in multiple regions at the same level. - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - Note over FO: Register in US-California - FO ->>+ FI: registerFleetLocal(uuid, 840, 5, 0) - Note over FI: uuidOwner = msg.sender - Note over FI: uuidLevel = Local - Note over FI: uuidTokenCount = 1 - FI -->>- FO: tokenId_1 = ((840<<10|5)<<128) | uuid - - Note over FO: Register same UUID in Germany - FO ->>+ FI: registerFleetLocal(uuid, 276, 1, 0) - Note over FI: Verify same owner - Note over FI: Verify same level (Local) - Note over FI: uuidTokenCount = 2 - FI -->>- FO: tokenId_2 = ((276<<10|1)<<128) | uuid - - Note over FO: Cannot mix levels - FO ->>+ FI: registerFleetCountry(uuid, 392, 0) - FI -->>- FO: ❌ revert UuidLevelMismatch() - - Note over FO: Cannot unregisterToOwned (multiple tokens) - FO ->>+ FI: unregisterToOwned(tokenId_1) - FI -->>- FO: ❌ revert CannotUnregisterMultipleTokens() -``` - -## Region Indexes - -On-chain indexes enable enumeration without off-chain indexers. - -### Active Region Queries - -```solidity -// Get all countries with at least one active fleet -uint16[] memory countries = fleetIdentity.getActiveCountries(); -// Returns: [840, 276, 392, ...] (ISO 3166-1 numeric codes) - -// Get all admin areas with at least one active fleet -uint32[] memory adminAreas = fleetIdentity.getActiveAdminAreas(); -// Returns: [860165, 282629, ...] (encoded region keys) -``` - -### Tier Data Queries - -```solidity -// Number of tiers in a region -uint256 tierCount = fleetIdentity.regionTierCount(regionKey); - -// Members in a specific tier -uint256[] memory tokenIds = fleetIdentity.getTierMembers(regionKey, tier); - -// UUIDs in a specific tier -bytes16[] memory uuids = fleetIdentity.getTierUuids(regionKey, tier); - -// Member count -uint256 count = fleetIdentity.tierMemberCount(regionKey, tier); -``` - -### Index Maintenance - -Indexes are automatically maintained: - -- **Registration**: Adds region to index if first fleet in that region -- **Burn/Demote**: Removes region from index if last fleet leaves - -```mermaid -flowchart LR - REG[registerFleet*] --> CHECK{First in region?} - CHECK -->|Yes| ADD[Add to _activeCountries
or _activeAdminAreas] - CHECK -->|No| SKIP[Already indexed] - - BURN[burn / demotion
empties region] --> TRIM[_trimTierCount] - TRIM --> REMOVE{Region empty?} - REMOVE -->|Yes| DEL[Remove from index] - REMOVE -->|No| KEEP[Keep in index] -``` - -## ISO 3166 Code Reference - -### Country Codes (ISO 3166-1 Numeric) - -| Code | Country | -| :--- | :------------- | -| 840 | United States | -| 276 | Germany | -| 250 | France | -| 392 | Japan | -| 826 | United Kingdom | -| 124 | Canada | - -See [iso3166-2/](iso3166-2/) directory for admin area mappings per country. - -### Admin Area Code Format - -Admin codes are 1-indexed integers mapped from ISO 3166-2 subdivisions: - -- Valid range: 1-255 (covers all real-world countries) -- Code 0 is invalid (reverts with `InvalidAdminCode()`) - -Example for US states: -| Admin Code | ISO 3166-2 | Name | -|:-----------|:-----------|:-----| -| 1 | AL | Alabama | -| 5 | CA | California | -| 32 | NY | New York | -| 43 | TX | Texas | - -See [iso3166-2/840-United_States.md](iso3166-2/840-United_States.md) for complete mapping. - -## Related Documentation - -- **[sequence-fleet-maintenance.md](sequence-fleet-maintenance.md)** — Fleet maintenance guide: checking bundle inclusion, tier reassignment, bond optimization, and handling market competition. diff --git a/src/swarms/doc/sequence-fleet-maintenance.md b/src/swarms/doc/sequence-fleet-maintenance.md deleted file mode 100644 index d9042626..00000000 --- a/src/swarms/doc/sequence-fleet-maintenance.md +++ /dev/null @@ -1,520 +0,0 @@ -# Fleet Maintenance: Staying Included in Bundles - -This document covers the ongoing maintenance flows that fleet owners must follow to ensure their UUIDs remain included in EdgeBeaconScanner bundles as market conditions change. - -## Overview - -After registering a fleet in one or more regions, the competitive landscape can change: - -- New fleets may register at higher tiers -- Existing fleets may promote to higher tiers -- Bundle slots are limited (20 max per location) - -Fleet owners should periodically check their inclusion status and adjust their tier if necessary. - -## Maintenance Cycle - -```mermaid -flowchart TD - START([Fleet Registered]) --> WAIT[Wait ~24 hours] - WAIT --> CHECK{Check inclusion
in target bundles} - - CHECK -->|Included| OPTIMIZE{Want to
minimize bond?} - CHECK -->|Not Included| HINT[Get inclusion hint] - - OPTIMIZE -->|Yes| DEMOTE_CHECK[Check if lower tier
still guarantees inclusion] - OPTIMIZE -->|No| WAIT - - DEMOTE_CHECK -->|Can demote| DEMOTE[reassignTier to lower tier
→ receive refund] - DEMOTE_CHECK -->|Stay| WAIT - DEMOTE --> WAIT - - HINT --> APPROVE[Approve bond difference] - APPROVE --> REASSIGN[reassignTier to suggested tier] - - REASSIGN -->|Success| PROPAGATE[Wait for edge network
to propagate changes] - REASSIGN -->|TierFull| HINT - - PROPAGATE --> WAIT - - style START fill:#4a9eff,color:#fff - style REASSIGN fill:#2ecc71,color:#fff - style DEMOTE fill:#2ecc71,color:#fff -``` - -## Checking Inclusion Status - -### For Local (Admin Area) Fleets - -Local fleets only need to check the single admin area where they're registered. - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - Note over FO: Check if my UUID is in the bundle - FO ->>+ FI: buildHighestBondedUuidBundle(countryCode, adminCode) - FI -->>- FO: (uuids[], count) - - alt My UUID in uuids[] - Note over FO: ✓ Still included, no action needed - else My UUID not in uuids[] - Note over FO: ⚠️ Need to promote to higher tier - end -``` - -**Example (TypeScript):** - -```typescript -const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle(840, 5); // US-California -const myUuid = "0x" + myUuidBytes.toString("hex"); -const isIncluded = uuids - .slice(0, count) - .some((u) => u.toLowerCase() === myUuid.toLowerCase()); - -if (!isIncluded) { - console.log("Fleet dropped from bundle - need to promote"); -} -``` - -### For Country Fleets - -Country fleets must check **every active admin area** in their country. A country fleet pays 16× more to appear in ALL bundles within the country. - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - Note over FO: Get all active admin areas - FO ->>+ FI: getActiveAdminAreas() - FI -->>- FO: adminRegionKeys[] - - loop For each admin area in my country - Note over FO: Extract countryCode from regionKey - Note over FO: Filter: (regionKey >> 10) == myCountryCode - FO ->>+ FI: buildHighestBondedUuidBundle(countryCode, adminCode) - FI -->>- FO: (uuids[], count) - Note over FO: Check if my UUID is included - end - - alt Included in ALL admin areas - Note over FO: ✓ Full inclusion maintained - else Missing from some areas - Note over FO: ⚠️ Need to promote or use countryInclusionHint - end -``` - -**Example (TypeScript):** - -```typescript -const myCountryCode = 840; // US -const adminAreas = await fleetIdentity.getActiveAdminAreas(); - -// Filter to admin areas in my country -const myAdminAreas = adminAreas.filter( - (rk) => Number(rk >> 10n) === myCountryCode, -); - -// Check each bundle -const missingAreas: number[] = []; -for (const rk of myAdminAreas) { - const adminCode = Number(rk & 0x3ffn); - const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle( - myCountryCode, - adminCode, - ); - const isIncluded = uuids - .slice(0, count) - .some((u) => u.toLowerCase() === myUuid.toLowerCase()); - if (!isIncluded) { - missingAreas.push(adminCode); - } -} - -if (missingAreas.length > 0) { - console.log( - `Missing from ${missingAreas.length} admin areas - need to promote`, - ); -} -``` - -### For Country Fleets: No Active Admin Areas - -When there are **no active admin areas** in your country (no EdgeBeaconScanners deployed yet), use `buildCountryOnlyBundle` to verify your position among other country-level competitors. - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - Note over FO: Check if any admin areas are active - FO ->>+ FI: getActiveAdminAreas() - FI -->>- FO: adminRegionKeys[] - - alt No admin areas for my country - Note over FO: No EdgeBeaconScanners yet
Use country-only bundle - FO ->>+ FI: buildCountryOnlyBundle(countryCode) - FI -->>- FO: (uuids[], count) - Note over FO: Check my position among
other country fleets - else Admin areas exist - Note over FO: Use buildHighestBondedUuidBundle
for each admin area - end -``` - -**Example (TypeScript):** - -```typescript -const myCountryCode = 840; // US -const adminAreas = await fleetIdentity.getActiveAdminAreas(); - -// Filter to admin areas in my country -const myAdminAreas = adminAreas.filter( - (rk) => Number(rk >> 10n) === myCountryCode, -); - -if (myAdminAreas.length === 0) { - // No admin areas active - check country-only bundle - const [uuids, count] = await fleetIdentity.buildCountryOnlyBundle( - myCountryCode, - ); - const isIncluded = uuids - .slice(0, count) - .some((u) => u.toLowerCase() === myUuid.toLowerCase()); - - if (isIncluded) { - console.log("✓ Included in country-only bundle"); - console.log( - "Note: No EdgeBeaconScanners active yet - waiting for admin areas", - ); - } else { - console.log("⚠️ Not in country-only bundle - consider promoting"); - } -} else { - // Check each active admin area - // ... (see previous example) -} -``` - -**Key Points for Country-Only Scenarios:** - -1. **No EdgeBeaconScanners yet:** If no admin areas are active, it is either because in this specific country all players have chosen the country-level coverage or you may be the first in this country. -2. **Use `buildCountryOnlyBundle`:** Verify your position among other country-level competitors. -3. **Use `countryInclusionHint`:** Works correctly even with no active admin areas - it calculates the tier needed for country-only competition. -4. **Prepare for locals:** When admin areas become active, local fleets will have priority over country fleets at the same tier. Consider using `countryInclusionHint` periodically to stay ahead. - -## Getting the Required Tier - -### For Local Fleets - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - FO ->>+ FI: localInclusionHint(countryCode, adminCode) - FI -->>- FO: (inclusionTier, requiredBond) - - Note over FO: The returned tier guarantees
inclusion in this specific bundle -``` - -### For Country Fleets - -Use `countryInclusionHint` to find the tier that guarantees inclusion across **all** active admin areas in the country. - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - FO ->>+ FI: countryInclusionHint(countryCode) - Note over FI: Scans ALL active admin areas
in the country (unbounded, free off-chain) - FI -->>- FO: (inclusionTier, requiredBond) - - Note over FO: This tier guarantees inclusion
in EVERY bundle in the country -``` - -**Alternative: Blind Promote** - -Instead of computing the exact required tier, you can simply promote by one tier and check if it resolves the issue: - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - FO ->>+ FI: promote(tokenId) - Note over FI: Moves to currentTier + 1 - FI -->>- FO: ✓ FleetPromoted - - Note over FO: Re-check bundles after
edge network propagates -``` - -This is simpler but may overpay if a smaller promotion would suffice. - -## Reassigning Tiers - -### Bond Approval Required - -Before calling `reassignTier` to a **higher** tier, you must approve the bond difference: - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - participant TOKEN as BOND_TOKEN - - Note over FO: Calculate bond difference - FO ->>+ FI: tierBond(currentTier, isCountry) - FI -->>- FO: currentBond - FO ->>+ FI: tierBond(targetTier, isCountry) - FI -->>- FO: targetBond - - Note over FO: additionalBond = targetBond - currentBond - - FO ->>+ TOKEN: approve(FleetIdentity, additionalBond) - TOKEN -->>- FO: ✓ - - FO ->>+ FI: reassignTier(tokenId, targetTier) - FI ->>+ TOKEN: transferFrom(owner, this, additionalBond) - TOKEN -->>- FI: ✓ - FI -->>- FO: ✓ FleetPromoted event -``` - -**Example (TypeScript):** - -```typescript -const currentTier = await fleetIdentity.fleetTier(tokenId); -const isCountry = tokenRegion <= 999; // Country codes are 1-999 - -const currentBond = await fleetIdentity.tierBond(currentTier, isCountry); -const targetBond = await fleetIdentity.tierBond(targetTier, isCountry); -const additionalBond = targetBond - currentBond; - -// Approve the bond difference -await bondToken.approve(fleetIdentity.address, additionalBond); - -// Reassign to higher tier -await fleetIdentity.reassignTier(tokenId, targetTier); -``` - -### Handling Race Conditions - -If `reassignTier` reverts with `TierFull`, another fleet claimed the slot first. Re-query the inclusion hint and retry: - -```mermaid -flowchart TD - HINT[Get inclusionHint] --> REASSIGN[reassignTier] - REASSIGN -->|Success| DONE([✓ Promoted]) - REASSIGN -->|TierFull| WAIT[Brief delay] - WAIT --> HINT - - style DONE fill:#2ecc71,color:#fff -``` - -In practice, this race condition is rare (only occurs when multiple fleets compete for the last slot in a tier). Most fleet owners will succeed on the first or second attempt. - -## Minimizing Bond (Demoting) - -Fleet owners who want to keep their bond minimal can periodically check if a lower tier still guarantees inclusion. - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - participant TOKEN as BOND_TOKEN - - Note over FO: Check if I can demote - FO ->>+ FI: localInclusionHint(cc, admin) - FI -->>- FO: (suggestedTier, bond) - - FO ->>+ FI: fleetTier(tokenId) - FI -->>- FO: currentTier - - alt suggestedTier < currentTier - Note over FO: Can save NODL by demoting! - FO ->>+ FI: reassignTier(tokenId, suggestedTier) - Note over FI: refund = currentBond - newBond - FI ->>+ TOKEN: transfer(owner, refund) - TOKEN -->>- FI: ✓ - FI -->>- FO: ✓ FleetDemoted event - else suggestedTier >= currentTier - Note over FO: No demotion possible - end -``` - -**Example (TypeScript):** - -```typescript -// Check recommended tier for my location -const [suggestedTier] = await fleetIdentity.localInclusionHint( - countryCode, - adminCode, -); -const currentTier = await fleetIdentity.fleetTier(tokenId); - -if (suggestedTier < currentTier) { - // Can demote and get a refund! - const refund = - (await fleetIdentity.tierBond(currentTier, isCountry)) - - (await fleetIdentity.tierBond(suggestedTier, isCountry)); - console.log( - `Can demote from tier ${currentTier} to ${suggestedTier}, refund: ${refund}`, - ); - - // No approval needed for demotion (refunds, doesn't pull) - await fleetIdentity.reassignTier(tokenId, suggestedTier); -} -``` - -## Edge Network Propagation - -After any tier change (promotion or demotion), the edge network takes time to react: - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - participant EDGE as Edge Network - - FO ->>+ FI: reassignTier(tokenId, newTier) - FI -->>- FO: ✓ Event emitted - - Note over FI,EDGE: Block finalized on L2 - - EDGE ->>+ FI: Monitor FleetPromoted/Demoted events - FI -->>- EDGE: Event data - - Note over EDGE: Fetch updated bundle
buildHighestBondedUuidBundle() - - Note over EDGE: Propagate to EdgeBeaconScanners
(~minutes to hours depending on network) - - Note over FO: Fleet now appears in
EdgeBeaconScanner bundle -``` - -**Timing Expectations:** - -| Phase | Duration | -| :----------------------- | :----------------------- | -| Transaction confirmation | ~1-2 seconds (ZkSync) | -| Event indexing | ~1-10 seconds | -| Edge network sync | ~minutes to hours | -| EdgeBeaconScanner update | Varies by implementation | - -Fleet owners should not expect immediate inclusion after a tier change. A 24-hour check interval is recommended for routine maintenance. - -## Complete Maintenance Example - -```typescript -async function maintainFleetInclusion( - fleetIdentity: FleetIdentity, - bondToken: ERC20, - tokenId: bigint, - myUuid: string, - countryCode: number, - adminCode: number, -) { - // 1. Check current inclusion - const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle( - countryCode, - adminCode, - ); - const isIncluded = uuids - .slice(0, Number(count)) - .some((u) => u.toLowerCase() === myUuid.toLowerCase()); - - if (isIncluded) { - console.log("✓ Fleet is included in bundle"); - - // Optional: Check if we can demote to save bond - const [suggestedTier] = await fleetIdentity.localInclusionHint( - countryCode, - adminCode, - ); - const currentTier = await fleetIdentity.fleetTier(tokenId); - - if (suggestedTier < currentTier) { - console.log(`Can demote from tier ${currentTier} to ${suggestedTier}`); - await fleetIdentity.reassignTier(tokenId, suggestedTier); - } - return; - } - - // 2. Not included - get required tier - const [requiredTier, requiredBond] = await fleetIdentity.localInclusionHint( - countryCode, - adminCode, - ); - const currentTier = await fleetIdentity.fleetTier(tokenId); - const tokenRegion = await fleetIdentity.tokenRegion(tokenId); - const isCountry = tokenRegion <= 999; - - console.log( - `Fleet dropped. Current: tier ${currentTier}, Required: tier ${requiredTier}`, - ); - - // 3. Calculate and approve bond difference - const currentBond = await fleetIdentity.tierBond(currentTier, isCountry); - const targetBond = await fleetIdentity.tierBond(requiredTier, isCountry); - const additionalBond = targetBond - currentBond; - - await bondToken.approve(await fleetIdentity.getAddress(), additionalBond); - - // 4. Reassign tier (with retry on TierFull) - let attempts = 0; - while (attempts < 3) { - try { - await fleetIdentity.reassignTier(tokenId, requiredTier); - console.log(`✓ Promoted to tier ${requiredTier}`); - return; - } catch (e: any) { - if (e.message.includes("TierFull")) { - attempts++; - console.log(`Tier ${requiredTier} full, re-checking hint...`); - const [newTier] = await fleetIdentity.localInclusionHint( - countryCode, - adminCode, - ); - requiredTier = newTier; - // Re-approve if tier changed - const newTargetBond = await fleetIdentity.tierBond( - requiredTier, - isCountry, - ); - if (newTargetBond > targetBond) { - await bondToken.approve( - await fleetIdentity.getAddress(), - newTargetBond - currentBond, - ); - } - } else { - throw e; - } - } - } - - console.log("Failed after 3 attempts - market too volatile"); -} -``` - -## Summary - -| Task | Frequency | Method | -| :-------------------------- | :-------------- | :----------------------------- | -| Check inclusion | ~24 hours | `buildHighestBondedUuidBundle` | -| Get required tier (local) | As needed | `localInclusionHint` | -| Get required tier (country) | As needed | `countryInclusionHint` | -| Calculate bond | Before reassign | `tierBond(tier, isCountry)` | -| Move to new tier | As needed | `reassignTier(tokenId, tier)` | -| Quick promote | Alternative | `promote(tokenId)` | -| Check demotion opportunity | Optionally | Compare hint tier vs current | - -**Key Points:** - -1. Check inclusion periodically (~24 hours) -2. Use inclusion hints to find the cheapest tier that guarantees inclusion -3. Approve bond difference before promoting (`BOND_TOKEN.approve`) -4. Handle `TierFull` by re-querying the hint -5. Edge network propagation takes time after tier changes -6. Optionally demote when market conditions allow to minimize bond diff --git a/src/swarms/doc/sequence-lifecycle.md b/src/swarms/doc/sequence-lifecycle.md deleted file mode 100644 index f44525d2..00000000 --- a/src/swarms/doc/sequence-lifecycle.md +++ /dev/null @@ -1,323 +0,0 @@ -# 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: uuidOwner(fleetUuid) - 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: uuidOwner(fleetUuid) - 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: uuidOwner(fleetUuid) - FI -->>- SR: msg.sender ✓ - Note over SR: Remove from uuidSwarms[] (O(1) swap-and-pop) - Note over SR: delete swarms[swarmId] - Note over SR: delete filterData[swarmId] (Universal only) - SR -->>- FO: ✓ SwarmDeleted event -``` - -## Orphan Detection & Permissionless Cleanup - -```mermaid -sequenceDiagram - actor Owner as NFT Owner - actor Purger as Anyone - participant NFT as FleetIdentity / ServiceProvider - participant SR as SwarmRegistry - - rect rgb(255, 240, 240) - Note right of Owner: NFT owner burns their token - Owner ->>+ NFT: burn(tokenId) - Note over NFT: If FleetIdentity: refunds full bond
to token owner via BOND_TOKEN.safeTransfer - NFT -->>- Owner: ✓ token destroyed + bond refunded - Note over SR: Swarms referencing this token
are now orphaned (lazy invalidation) - end - - rect rgb(255, 248, 240) - Note right of Purger: Anyone checks validity - Purger ->>+ SR: isSwarmValid(swarmId) - SR ->>+ NFT: uuidOwner(fleetUuid) - NFT -->>- SR: ❌ address(0) or reverts - 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 UUID is no longer owned - Note over SR: Remove from uuidSwarms[] (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 -``` - ---- - -# FleetIdentity Lifecycle - -## UUID Registration Level State Machine - -```mermaid -stateDiagram-v2 - [*] --> None : (default) - - None --> Owned : claimUuid() - None --> Local : registerFleetLocal() - None --> Country : registerFleetCountry() - - Owned --> Local : registerFleetLocal()
(burns owned token) - Owned --> Country : registerFleetCountry()
(burns owned token) - Owned --> [*] : releaseUuid() / burn() - - Local --> Owned : unregisterToOwned()
(single token only) - Local --> [*] : burn() all tokens - - Country --> Owned : unregisterToOwned()
(single token only) - Country --> [*] : burn() all tokens - - Note right of Owned : regionKey = 0
Bond = BASE_BOND - Note right of Local : regionKey = (cc<<10)|admin
Bond = BASE_BOND * 2^tier - Note right of Country : regionKey = cc
Bond = BASE_BOND * 16 * 2^tier -``` - -## UUID Claim & Release Flow - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - participant TOKEN as BOND_TOKEN - - rect rgb(240, 248, 255) - Note right of FO: Claim UUID ownership - FO ->>+ TOKEN: approve(FleetIdentity, BASE_BOND) - TOKEN -->>- FO: ✓ - FO ->>+ FI: claimUuid(uuid) - Note over FI: uuidOwner[uuid] = msg.sender - Note over FI: uuidLevel[uuid] = Owned - Note over FI: uuidTokenCount[uuid] = 1 - FI ->>+ TOKEN: transferFrom(owner, this, BASE_BOND) - TOKEN -->>- FI: ✓ - FI -->>- FO: tokenId = uint128(uuid) - end - - rect rgb(255, 248, 240) - Note right of FO: Later: Release UUID - FO ->>+ FI: releaseUuid(uuid) - Note over FI: Require uuidLevel == Owned - Note over FI: Require uuidOwner == msg.sender - Note over FI: Burns token, clears ownership - FI ->>+ TOKEN: transfer(owner, BASE_BOND) - TOKEN -->>- FI: ✓ - FI -->>- FO: ✓ UUID can be claimed by anyone - end -``` - -## Owned → Registered Transition - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - participant TOKEN as BOND_TOKEN - - Note over FO: UUID already claimed (has owned token) - - rect rgb(240, 255, 240) - FO ->>+ FI: registerFleetLocal(uuid, cc, admin, tier) - Note over FI: Verify uuidOwner[uuid] == msg.sender - Note over FI: Burns owned token (region=0) - Note over FI: uuidLevel = Local - Note over FI: Mints new regional token - Note over FI: incrementalBond = tierBond - BASE_BOND - FI ->>+ TOKEN: transferFrom(owner, this, incrementalBond) - TOKEN -->>- FI: ✓ - FI -->>- FO: tokenId = ((cc<<10|admin)<<128) | uuid - end -``` - -## Registered → Owned Transition (Unregister) - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - participant TOKEN as BOND_TOKEN - - Note over FO: Has registered fleet token - Note over FO: Must be ONLY token for this UUID - - rect rgb(255, 248, 240) - FO ->>+ FI: unregisterToOwned(tokenId) - Note over FI: Verify single token for UUID - Note over FI: Calculate refund = tierBond - BASE_BOND - Note over FI: Remove from tier structures - Note over FI: Burn regional token - Note over FI: uuidLevel = Owned - Note over FI: Mint owned token (region=0) - FI ->>+ TOKEN: transfer(owner, refund) - TOKEN -->>- FI: ✓ - FI -->>- FO: newTokenId = uint128(uuid) - end -``` - -## Multi-Region Registration - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - - Note over FO: UUID already registered (Local or Country) - - rect rgb(240, 248, 255) - Note right of FO: Register same UUID in additional region - FO ->>+ FI: registerFleetLocal(uuid, DE, 1, tier) - Note over FI: Verify uuidOwner[uuid] == msg.sender - Note over FI: Verify uuidLevel matches (Local) - Note over FI: uuidTokenCount++ - Note over FI: Mint new token for DE region - FI -->>- FO: tokenId = ((DE<<10|1)<<128) | uuid - end - - Note over FO: Now has 2 tokens for same UUID - Note over FO: Cannot unregisterToOwned (multiple tokens) -``` - -## Fleet Tier Promotion & Demotion - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - participant TOKEN as BOND_TOKEN - - rect rgb(240, 255, 240) - Note right of FO: Promote to higher tier - FO ->>+ FI: promote(tokenId) - Note over FI: targetTier = currentTier + 1 - Note over FI: Verify tier has capacity - Note over FI: additionalBond = tierBond(new) - tierBond(old) - FI ->>+ TOKEN: transferFrom(owner, this, additionalBond) - TOKEN -->>- FI: ✓ - FI -->>- FO: ✓ FleetPromoted event - end - - rect rgb(255, 248, 240) - Note right of FO: Demote to lower tier - FO ->>+ FI: reassignTier(tokenId, lowerTier) - Note over FI: Verify tier has capacity - Note over FI: refund = tierBond(old) - tierBond(new) - FI ->>+ TOKEN: transfer(owner, refund) - TOKEN -->>- FI: ✓ - FI -->>- FO: ✓ FleetDemoted event - end -``` - -## Fleet Burn (Registered vs Owned) - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - participant FI as FleetIdentity - participant TOKEN as BOND_TOKEN - - alt Burning registered fleet token - FO ->>+ FI: burn(tokenId) - Note over FI: region = tokenRegion(tokenId) - Note over FI: refund = tierBond(tier, isCountry) - Note over FI: Remove from tier structures - Note over FI: Decrement uuidTokenCount - Note over FI: If count == 0: clear uuidOwner - FI ->>+ TOKEN: transfer(owner, refund) - TOKEN -->>- FI: ✓ - FI -->>- FO: ✓ FleetBurned event - else Burning owned-only token - FO ->>+ FI: burn(tokenId) - Note over FI: region = 0 (owned) - Note over FI: refund = BASE_BOND - Note over FI: Clear UUID ownership - FI ->>+ TOKEN: transfer(owner, BASE_BOND) - TOKEN -->>- FI: ✓ - FI -->>- FO: ✓ FleetBurned event - end -``` - -## Owned-Only Token Transfer - -```mermaid -sequenceDiagram - actor ALICE as Alice (Seller) - actor BOB as Bob (Buyer) - participant FI as FleetIdentity - - Note over ALICE: Owns UUID in Owned state - - ALICE ->>+ FI: transferFrom(alice, bob, tokenId) - Note over FI: region = 0 (owned-only) - Note over FI: ERC721 transfer executes - Note over FI: uuidOwner[uuid] = bob - FI -->>- BOB: ✓ Token transferred - - Note over BOB: Now owns UUID
Can register to regions
Can release for BASE_BOND refund -``` diff --git a/src/swarms/doc/sequence-registration.md b/src/swarms/doc/sequence-registration.md deleted file mode 100644 index 7e04c7af..00000000 --- a/src/swarms/doc/sequence-registration.md +++ /dev/null @@ -1,97 +0,0 @@ -# Swarm Registration & Approval Sequence - -## One-Time Setup - -```mermaid -sequenceDiagram - actor FO as Fleet Owner - actor PRV as Service Provider - participant FI as FleetIdentity - participant SP as ServiceProvider - - Note over FO, SP: One-time setup (independent, any order) - - Note over FO: Approve bond token first: - Note over FO: NODL.approve(FleetIdentity, bondAmount) - - alt Option A: Direct Registration (Local) - FO ->>+ FI: registerFleetLocal(uuid, 840, 5, tier) - Note over FI: countryCode=840 (US), adminCode=5 - Note over FI: Bond = BASE_BOND * 2^tier - Note over FI: Sets uuidOwner, uuidLevel=Local - FI -->>- FO: tokenId = ((840<<10|5)<<128) | uuid - else Option B: Direct Registration (Country) - FO ->>+ FI: registerFleetCountry(uuid, 840, tier) - Note over FI: Bond = BASE_BOND * 16 * 2^tier - Note over FI: Sets uuidOwner, uuidLevel=Country - FI -->>- FO: tokenId = (840<<128) | uuid - else Option C: Claim-First Flow - FO ->>+ FI: claimUuid(uuid) - Note over FI: Costs BASE_BOND - Note over FI: Sets uuidLevel=Owned - FI -->>- FO: tokenId = uuid (regionKey=0) - Note over FO: Later: register with incremental bond - FO ->>+ FI: registerFleetLocal(uuid, 840, 5, tier) - Note over FI: Burns owned token - Note over FI: Only pulls (tierBond - BASE_BOND) - FI -->>- FO: tokenId = ((840<<10|5)<<128) | uuid - end - - 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(fleetUuid, providerId, filter, fpSize, tagType) - SR ->>+ FI: uuidOwner(fleetUuid) - FI -->>- SR: msg.sender ✓ - SR ->>+ SP: ownerOf(providerId) - SP -->>- SR: address ✓ (exists) - Note over SR: swarmId = keccak256(fleetUuid, 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(fleetUuid, providerId, sameFilter, ...) - Note over SR: swarmId = keccak256(fleetUuid, providerId, sameFilter) - Note over SR: swarms[swarmId] already exists - SR -->>- FO: ❌ revert SwarmAlreadyExists() -``` diff --git a/src/swarms/doc/swarm-operations.md b/src/swarms/doc/swarm-operations.md new file mode 100644 index 00000000..7859c2be --- /dev/null +++ b/src/swarms/doc/swarm-operations.md @@ -0,0 +1,197 @@ +# Swarm Operations + +## Overview + +A **Swarm** is a cryptographic representation of ~10k-20k BLE tags. Individual tags are never enumerated on-chain—membership is verified via XOR filter. + +## Registration Flow + +```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 + + FO->>+SR: registerSwarm(fleetUuid, providerId, filter, fpSize, tagType) + SR->>FI: uuidOwner(fleetUuid) + SR->>SP: ownerOf(providerId) + Note over SR: swarmId = keccak256(fleetUuid, providerId, filter) + SR-->>-FO: swarmId (status: REGISTERED) + + PRV->>+SR: acceptSwarm(swarmId) + SR->>SP: ownerOf(providerId) + SR-->>-PRV: status: ACCEPTED +``` + +### Parameters + +| Parameter | Type | Description | +| :-------- | :--- | :---------- | +| `fleetUuid` | bytes16 | UUID that owns this swarm | +| `providerId` | uint256 | ServiceProvider token ID | +| `filter` | bytes | XOR filter data | +| `fpSize` | uint8 | Fingerprint size (1-16 bits) | +| `tagType` | TagType | Tag identity scheme | + +### Swarm ID + +Deterministic derivation: +```solidity +swarmId = uint256(keccak256(abi.encode(fleetUuid, providerId, filter))) +``` + +Duplicate registration reverts with `SwarmAlreadyExists()`. + +## XOR Filter Construction + +### Off-Chain Steps + +1. **Build TagIDs** for all tags per TagType schema +2. **Hash each TagID**: `tagHash = keccak256(tagId)` +3. **Construct XOR filter** using Peeling Algorithm +4. **Submit filter** in `registerSwarm()` + +### TagType Schemas + +| Type | Format | Bytes | +| :--- | :----- | ----: | +| `IBEACON_PAYLOAD_ONLY` | UUID ∥ Major ∥ Minor | 20 | +| `IBEACON_INCLUDES_MAC` | UUID ∥ Major ∥ Minor ∥ MAC | 26 | +| `VENDOR_ID` | companyID ∥ hash(vendorBytes) | varies | +| `GENERIC` | custom | varies | + +### MAC Normalization (IBEACON_INCLUDES_MAC) + +| MAC Type | Action | +| :------- | :----- | +| Public/Static (00) | Use real MAC | +| Random/Private (01, 11) | Replace with `FF:FF:FF:FF:FF:FF` | + +This supports rotating privacy MACs while validating "it's a privacy tag." + +### Filter Membership Math + +``` +h = keccak256(tagId) +M = filterLength * 8 / fingerprintSize // slot count + +h1 = uint32(h) % M +h2 = uint32(h >> 32) % M +h3 = uint32(h >> 64) % M +fp = (h >> 96) & ((1 << fingerprintSize) - 1) + +Member if: Filter[h1] ^ Filter[h2] ^ Filter[h3] == fp +``` + +## Provider Approval + +```mermaid +stateDiagram-v2 + [*] --> REGISTERED : registerSwarm() + + REGISTERED --> ACCEPTED : acceptSwarm() + REGISTERED --> REJECTED : rejectSwarm() + + ACCEPTED --> REGISTERED : updateSwarm*() + REJECTED --> REGISTERED : updateSwarm*() + + REGISTERED --> [*] : deleteSwarm() / purge + ACCEPTED --> [*] : deleteSwarm() / purge + REJECTED --> [*] : deleteSwarm() / purge +``` + +| Action | Caller | Effect | +| :----- | :----- | :----- | +| `acceptSwarm(swarmId)` | Provider owner | status → ACCEPTED | +| `rejectSwarm(swarmId)` | Provider owner | status → REJECTED | + +Only `ACCEPTED` swarms pass `checkMembership()`. + +## Updates + +Both operations reset status to `REGISTERED`: + +```solidity +// Replace filter +swarmRegistry.updateSwarmFilter(swarmId, newFilterData); + +// Change provider +swarmRegistry.updateSwarmProvider(swarmId, newProviderId); +``` + +```mermaid +sequenceDiagram + actor FO as Fleet Owner + participant SR as SwarmRegistry + participant FI as FleetIdentity + + FO->>+SR: updateSwarmFilter(swarmId, newFilter) + SR->>FI: uuidOwner(fleetUuid) + Note over SR: status → REGISTERED + SR-->>-FO: ✓ (requires re-approval) +``` + +## Deletion + +```solidity +swarmRegistry.deleteSwarm(swarmId); +``` + +- Removes from `uuidSwarms[]` (O(1) swap-and-pop) +- Deletes `swarms[swarmId]` +- Universal variant: deletes `filterData[swarmId]` + +## Orphan Handling + +When fleet or provider NFT is burned, referencing swarms become **orphaned**. + +### Detection + +```solidity +(bool fleetValid, bool providerValid) = swarmRegistry.isSwarmValid(swarmId); +// Returns (false, _) if UUID has no owner +// Returns (_, false) if provider NFT burned +``` + +### Cleanup + +Anyone can purge orphaned swarms: + +```solidity +swarmRegistry.purgeOrphanedSwarm(swarmId); +// Gas refund incentive +``` + +```mermaid +sequenceDiagram + actor Anyone as Purger + participant SR as SwarmRegistry + + Anyone->>+SR: isSwarmValid(swarmId) + SR-->>-Anyone: (false, true) + + Anyone->>+SR: purgeOrphanedSwarm(swarmId) + Note over SR: Verify orphaned + Note over SR: Delete swarm data + SR-->>-Anyone: SwarmPurged event + gas refund +``` + +### Guards + +Operations revert with `SwarmOrphaned()` if either NFT is invalid: +- `acceptSwarm()` +- `rejectSwarm()` +- `checkMembership()` + +## Storage Variants + +| Variant | Filter Storage | Deletion | +| :------ | :------------- | :------- | +| **L1** | SSTORE2 (contract bytecode) | Struct cleared; bytecode persists | +| **Universal** | `mapping(uint256 => bytes)` | Full deletion | + +Universal exposes `getFilterData(swarmId)` for off-chain retrieval. From 7f59a6b5e1ec174e65e662fdd51493fbf590d311 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 27 Feb 2026 11:21:32 +1300 Subject: [PATCH 61/63] docs: formatting and accuracy corrections - Fix table column alignment and padding for consistency - Add proper blank lines after headings and before lists - Remove trailing whitespace - Standardize TypeScript/Solidity code indentation - Fix End-to-End Flow: Provider Owner now correctly shown for registerProvider() and acceptSwarm() instead of Fleet Owner --- src/swarms/doc/README.md | 67 +++++++++++---------- src/swarms/doc/data-model.md | 41 ++++++++----- src/swarms/doc/discovery.md | 26 ++++---- src/swarms/doc/fleet-registration.md | 19 +++--- src/swarms/doc/iso3166-reference.md | 36 ++++++------ src/swarms/doc/lifecycle.md | 31 +++++----- src/swarms/doc/maintenance.md | 88 +++++++++++++++------------- src/swarms/doc/swarm-operations.md | 46 ++++++++------- 8 files changed, 191 insertions(+), 163 deletions(-) diff --git a/src/swarms/doc/README.md b/src/swarms/doc/README.md index b7f927a8..5ad5b482 100644 --- a/src/swarms/doc/README.md +++ b/src/swarms/doc/README.md @@ -40,31 +40,35 @@ graph TB ## Core Components -| Contract | Role | Identity | Token | -| :----------------------- | :------------------------------ | :----------------------------------------- | :---- | -| **FleetIdentity** | Fleet registry (ERC-721) | `(regionKey << 128) \| uuid` | SFID | -| **ServiceProvider** | Backend URL registry (ERC-721) | `keccak256(url)` | SSV | -| **SwarmRegistryL1** | Tag group registry (L1) | `keccak256(fleetUuid, providerId, filter)` | — | -| **SwarmRegistryUniversal** | Tag group registry (ZkSync+) | `keccak256(fleetUuid, providerId, filter)` | — | +| Contract | Role | Identity | Token | +| :------------------------- | :----------------------------- | :----------------------------------------- | :---- | +| **FleetIdentity** | Fleet registry (ERC-721) | `(regionKey << 128) \| uuid` | SFID | +| **ServiceProvider** | Backend URL registry (ERC-721) | `keccak256(url)` | SSV | +| **SwarmRegistryL1** | Tag group registry (L1) | `keccak256(fleetUuid, providerId, filter)` | — | +| **SwarmRegistryUniversal** | Tag group registry (ZkSync+) | `keccak256(fleetUuid, providerId, filter)` | — | All contracts are **permissionless**—access control via NFT ownership. FleetIdentity requires ERC-20 bond (anti-spam). ## Key Concepts ### Swarm + A group of ~10k-20k BLE tags represented by an XOR filter. Tags are never enumerated on-chain; membership is verified via cryptographic filter. ### UUID Ownership + UUIDs (iBeacon Proximity UUID) have ownership levels: -| Level | Region Key | Bond | Description | -| :------ | :--------- | :----------------- | :----------------------------- | -| Owned | 0 | BASE_BOND | Reserved, not in any region | -| Local | ≥1024 | BASE_BOND × 2^tier | Registered in admin area | +| Level | Region Key | Bond | Description | +| :------ | :--------- | :---------------------- | :-------------------------- | +| Owned | 0 | BASE_BOND | Reserved, not in any region | +| Local | ≥1024 | BASE_BOND × 2^tier | Registered in admin area | | Country | 1-999 | BASE_BOND × 16 × 2^tier | Registered at country level | ### Geographic Tiers + Each region has independent tier competition: + - **Tier capacity**: 4 members per tier - **Max tiers**: 24 per region - **Bundle size**: Up to 20 UUIDs returned to clients @@ -72,29 +76,32 @@ Each region has independent tier competition: Country fleets pay 16× more but appear in all admin-area bundles within their country. ### Token ID Encoding + ``` tokenId = (regionKey << 128) | uint256(uint128(uuid)) ``` + - Bits 0-127: UUID - Bits 128-159: Region key ## Documentation -| Document | Description | -| :------- | :---------- | -| [data-model.md](data-model.md) | Contract interfaces, enums, storage layout | -| [fleet-registration.md](fleet-registration.md) | Fleet & UUID registration, tier economics | -| [swarm-operations.md](swarm-operations.md) | Swarm registration, filters, provider approval | -| [lifecycle.md](lifecycle.md) | State machines, updates, deletion, orphan cleanup | -| [discovery.md](discovery.md) | Client discovery flows, tag hash construction | -| [maintenance.md](maintenance.md) | Bundle inclusion monitoring, tier optimization | -| [iso3166-reference.md](iso3166-reference.md) | ISO 3166-1/2 codes and admin area mappings | +| Document | Description | +| :--------------------------------------------- | :------------------------------------------------ | +| [data-model.md](data-model.md) | Contract interfaces, enums, storage layout | +| [fleet-registration.md](fleet-registration.md) | Fleet & UUID registration, tier economics | +| [swarm-operations.md](swarm-operations.md) | Swarm registration, filters, provider approval | +| [lifecycle.md](lifecycle.md) | State machines, updates, deletion, orphan cleanup | +| [discovery.md](discovery.md) | Client discovery flows, tag hash construction | +| [maintenance.md](maintenance.md) | Bundle inclusion monitoring, tier optimization | +| [iso3166-reference.md](iso3166-reference.md) | ISO 3166-1/2 codes and admin area mappings | ## End-to-End Flow ```mermaid sequenceDiagram participant FO as Fleet Owner + participant PO as Provider Owner participant FI as FleetIdentity participant SR as SwarmRegistry participant SP as ServiceProvider @@ -102,16 +109,16 @@ sequenceDiagram Note over FO: 1. Register fleet FO->>FI: registerFleetLocal(uuid, cc, admin, tier) - + Note over FO: 2. Register provider - FO->>SP: registerProvider(url) - + PO->>SP: registerProvider(url) + Note over FO: 3. Register swarm FO->>SR: registerSwarm(uuid, providerId, filter, ...) - + Note over FO: 4. Provider approves - FO->>SR: acceptSwarm(swarmId) - + PO->>SR: acceptSwarm(swarmId) + Note over Client: 5. Client discovers Client->>FI: buildHighestBondedUuidBundle(cc, admin) Client->>SR: uuidSwarms(uuid, 0) @@ -122,11 +129,11 @@ sequenceDiagram ## Storage Variants -| Variant | Chain | Filter Storage | Deletion Behavior | -| :------ | :---- | :------------- | :---------------- | -| **SwarmRegistryL1** | Ethereum L1 | SSTORE2 (contract bytecode) | Struct cleared; bytecode persists | -| **SwarmRegistryUniversal** | ZkSync Era, all EVM | `mapping(uint256 => bytes)` | Full deletion, gas refund | +| Variant | Chain | Filter Storage | Deletion Behavior | +| :------------------------- | :------------------ | :-------------------------- | :-------------------------------- | +| **SwarmRegistryL1** | Ethereum L1 | SSTORE2 (contract bytecode) | Struct cleared; bytecode persists | +| **SwarmRegistryUniversal** | ZkSync Era, all EVM | `mapping(uint256 => bytes)` | Full deletion, gas refund | --- -*For implementation details, see individual documentation pages.* +_For implementation details, see individual documentation pages._ diff --git a/src/swarms/doc/data-model.md b/src/swarms/doc/data-model.md index 6cbeeb5c..56dd5fff 100644 --- a/src/swarms/doc/data-model.md +++ b/src/swarms/doc/data-model.md @@ -79,27 +79,30 @@ struct Swarm { ## Enumerations ### SwarmStatus -| Value | Description | -| :---- | :---------- | + +| Value | Description | +| :----------- | :------------------------- | | `REGISTERED` | Awaiting provider approval | -| `ACCEPTED` | Provider approved; active | -| `REJECTED` | Provider rejected | +| `ACCEPTED` | Provider approved; active | +| `REJECTED` | Provider rejected | ### TagType -| Value | Format | Use Case | -| :---- | :----- | :------- | -| `IBEACON_PAYLOAD_ONLY` | UUID ∥ Major ∥ Minor (20B) | Standard iBeacon | -| `IBEACON_INCLUDES_MAC` | UUID ∥ Major ∥ Minor ∥ MAC (26B) | Anti-spoofing | -| `VENDOR_ID` | companyID ∥ hash(vendorBytes) | Non-iBeacon BLE | -| `GENERIC` | Custom | Extensible | + +| Value | Format | Use Case | +| :--------------------- | :------------------------------- | :--------------- | +| `IBEACON_PAYLOAD_ONLY` | UUID ∥ Major ∥ Minor (20B) | Standard iBeacon | +| `IBEACON_INCLUDES_MAC` | UUID ∥ Major ∥ Minor ∥ MAC (26B) | Anti-spoofing | +| `VENDOR_ID` | companyID ∥ hash(vendorBytes) | Non-iBeacon BLE | +| `GENERIC` | Custom | Extensible | ### RegistrationLevel -| Value | Region Key | Description | -| :---- | :--------- | :---------- | -| `None` (0) | — | Not registered | -| `Owned` (1) | 0 | Claimed, no region | -| `Local` (2) | ≥1024 | Admin area | -| `Country` (3) | 1-999 | Country-wide | + +| Value | Region Key | Description | +| :------------ | :--------- | :----------------- | +| `None` (0) | — | Not registered | +| `Owned` (1) | 0 | Claimed, no region | +| `Local` (2) | ≥1024 | Admin area | +| `Country` (3) | 1-999 | Country-wide | ## Region Key Encoding @@ -109,11 +112,13 @@ Admin Area: regionKey = (countryCode << 10) | adminCode (≥1024) ``` **Token ID:** + ``` tokenId = (regionKey << 128) | uint256(uint128(uuid)) ``` **Helper functions:** + ```solidity bytes16 uuid = fleetIdentity.tokenUuid(tokenId); uint32 region = fleetIdentity.tokenRegion(tokenId); @@ -124,6 +129,7 @@ uint32 adminRegion = fleetIdentity.makeAdminRegion(countryCode, adminCode); ## Swarm ID Derivation Deterministic and collision-free: + ```solidity swarmId = uint256(keccak256(abi.encode(fleetUuid, providerId, filterData))) ``` @@ -149,14 +155,17 @@ Valid if: Filter[h1] ^ Filter[h2] ^ Filter[h3] == fp ## Storage Notes ### SwarmRegistryL1 + - Filter stored as **contract bytecode** via SSTORE2 - Gas-efficient reads (EXTCODECOPY) - Bytecode persists after deletion (immutable) ### SwarmRegistryUniversal + - Filter stored in `mapping(uint256 => bytes)` - Full deletion reclaims storage - `getFilterData(swarmId)` for off-chain retrieval ### Deletion Performance + O(1) swap-and-pop via `swarmIndexInUuid` mapping. diff --git a/src/swarms/doc/discovery.md b/src/swarms/doc/discovery.md index bb2b7818..a71d0ef4 100644 --- a/src/swarms/doc/discovery.md +++ b/src/swarms/doc/discovery.md @@ -96,10 +96,10 @@ flowchart TD ### MAC Address Types -| Address Type Bits | MAC Type | Action | -| :---------------- | :------- | :----- | -| `00` | Public | Use real MAC | -| `01`, `11` | Random/Private | Use `FF:FF:FF:FF:FF:FF` | +| Address Type Bits | MAC Type | Action | +| :---------------- | :------------- | :---------------------- | +| `00` | Public | Use real MAC | +| `01`, `11` | Random/Private | Use `FF:FF:FF:FF:FF:FF` | ## Region Enumeration (Indexers) @@ -130,25 +130,25 @@ function discoverService( uint8 adminCode ) external view returns (string memory serviceUrl, bool found) { // 1. Check bundle - (bytes16[] memory uuids, uint256 count) = + (bytes16[] memory uuids, uint256 count) = fleetIdentity.buildHighestBondedUuidBundle(countryCode, adminCode); - + for (uint i = 0; i < count; i++) { if (uuids[i] != uuid) continue; - + // 2. Find swarms for (uint j = 0; ; j++) { uint256 swarmId; try swarmRegistry.uuidSwarms(uuid, j) returns (uint256 id) { swarmId = id; } catch { break; } - + // 3. Get swarm data - (,uint256 providerId,,,SwarmStatus status, TagType tagType) = + (,uint256 providerId,,,SwarmStatus status, TagType tagType) = swarmRegistry.swarms(swarmId); - + if (status != SwarmStatus.ACCEPTED) continue; - + // 4. Build tagId bytes memory tagId; if (tagType == TagType.IBEACON_PAYLOAD_ONLY) { @@ -156,14 +156,14 @@ function discoverService( } else if (tagType == TagType.IBEACON_INCLUDES_MAC) { tagId = abi.encodePacked(uuid, major, minor, mac); } - + // 5. Check membership if (swarmRegistry.checkMembership(swarmId, keccak256(tagId))) { return (serviceProvider.providerUrls(providerId), true); } } } - + return ("", false); } ``` diff --git a/src/swarms/doc/fleet-registration.md b/src/swarms/doc/fleet-registration.md index 0f5c00e9..bad93293 100644 --- a/src/swarms/doc/fleet-registration.md +++ b/src/swarms/doc/fleet-registration.md @@ -90,20 +90,20 @@ sequenceDiagram ### Bond Formula -| Level | Formula | -| :---- | :------ | -| Owned | `BASE_BOND` | -| Local | `BASE_BOND × 2^tier` | +| Level | Formula | +| :------ | :------------------------ | +| Owned | `BASE_BOND` | +| Local | `BASE_BOND × 2^tier` | | Country | `BASE_BOND × 16 × 2^tier` | **Example (BASE_BOND = 100):** | Tier | Local | Country | | :--- | ----: | ------: | -| 0 | 100 | 1,600 | -| 1 | 200 | 3,200 | -| 2 | 400 | 6,400 | -| 3 | 800 | 12,800 | +| 0 | 100 | 1,600 | +| 1 | 200 | 3,200 | +| 2 | 400 | 6,400 | +| 3 | 800 | 12,800 | ### Economic Design @@ -193,6 +193,7 @@ sequenceDiagram ``` **Constraints:** + - All tokens must be same level (Local or Country) - Cannot `unregisterToOwned` with multiple tokens - Each region pays its own tier bond @@ -224,12 +225,14 @@ Registered tokens can also transfer but do not change `uuidOwner`. View functions that recommend cheapest tier guaranteeing bundle inclusion. ### Local Hint + ```solidity (uint256 tier, uint256 bond) = fleetIdentity.localInclusionHint(cc, admin); // Simulates bundle for specific admin area ``` ### Country Hint + ```solidity (uint256 tier, uint256 bond) = fleetIdentity.countryInclusionHint(cc); // Scans ALL active admin areas (unbounded, free off-chain) diff --git a/src/swarms/doc/iso3166-reference.md b/src/swarms/doc/iso3166-reference.md index 6ae43f25..855865d7 100644 --- a/src/swarms/doc/iso3166-reference.md +++ b/src/swarms/doc/iso3166-reference.md @@ -4,14 +4,14 @@ FleetIdentity uses ISO 3166-1 numeric codes (1-999) for country identification. -| Code | Country | -| :--- | :------ | -| 124 | Canada | -| 250 | France | -| 276 | Germany | -| 392 | Japan | -| 826 | United Kingdom | -| 840 | United States | +| Code | Country | +| :--- | :------------- | +| 124 | Canada | +| 250 | France | +| 276 | Germany | +| 392 | Japan | +| 826 | United Kingdom | +| 840 | United States | ## Admin Area Codes @@ -40,10 +40,10 @@ The [iso3166-2/](iso3166-2/) directory contains per-country mappings. Filename: `{ISO_3166-1_numeric}-{Country_Name}.md` -| Admin Code | ISO 3166-2 | Name | -| ---------: | :--------- | :--- | -| 1 | XX | Full subdivision name | -| 2 | YY | ... | +| Admin Code | ISO 3166-2 | Name | +| ---------: | :--------- | :-------------------- | +| 1 | XX | Full subdivision name | +| 2 | YY | ... | ### Constraints @@ -55,12 +55,12 @@ Filename: `{ISO_3166-1_numeric}-{Country_Name}.md` Selected entries from [iso3166-2/840-United_States.md](iso3166-2/840-United_States.md): -| Admin | ISO 3166-2 | State | -| ----: | :--------- | :---- | -| 1 | AL | Alabama | -| 5 | CA | California | -| 32 | NY | New York | -| 43 | TX | Texas | +| Admin | ISO 3166-2 | State | +| ----: | :--------- | :--------- | +| 1 | AL | Alabama | +| 5 | CA | California | +| 32 | NY | New York | +| 43 | TX | Texas | ## Usage diff --git a/src/swarms/doc/lifecycle.md b/src/swarms/doc/lifecycle.md index 87c04057..31530e75 100644 --- a/src/swarms/doc/lifecycle.md +++ b/src/swarms/doc/lifecycle.md @@ -27,16 +27,16 @@ stateDiagram-v2 ### State Transitions -| From | To | Function | Bond Effect | -| :--- | :- | :------- | :---------- | -| None | Owned | `claimUuid()` | Pull BASE_BOND | -| None | Local | `registerFleetLocal()` | Pull tierBond | -| None | Country | `registerFleetCountry()` | Pull tierBond | -| Owned | Local | `registerFleetLocal()` | Pull (tierBond - BASE_BOND) | -| Owned | Country | `registerFleetCountry()` | Pull (tierBond - BASE_BOND) | -| Local/Country | Owned | `unregisterToOwned()` | Refund (tierBond - BASE_BOND) | -| Owned | None | `releaseUuid()` / `burn()` | Refund BASE_BOND | -| Local/Country | None | `burn()` | Refund tierBond | +| From | To | Function | Bond Effect | +| :------------ | :------ | :------------------------- | :---------------------------- | +| None | Owned | `claimUuid()` | Pull BASE_BOND | +| None | Local | `registerFleetLocal()` | Pull tierBond | +| None | Country | `registerFleetCountry()` | Pull tierBond | +| Owned | Local | `registerFleetLocal()` | Pull (tierBond - BASE_BOND) | +| Owned | Country | `registerFleetCountry()` | Pull (tierBond - BASE_BOND) | +| Local/Country | Owned | `unregisterToOwned()` | Refund (tierBond - BASE_BOND) | +| Owned | None | `releaseUuid()` / `burn()` | Refund BASE_BOND | +| Local/Country | None | `burn()` | Refund tierBond | ## Swarm Status States @@ -57,11 +57,11 @@ stateDiagram-v2 ### Status Effects -| Status | checkMembership | Provider Action Required | -| :----- | :-------------- | :----------------------- | -| REGISTERED | Reverts | Accept or reject | -| ACCEPTED | Works | None | -| REJECTED | Reverts | None (fleet can update to retry) | +| Status | checkMembership | Provider Action Required | +| :--------- | :-------------- | :------------------------------- | +| REGISTERED | Reverts | Accept or reject | +| ACCEPTED | Works | None | +| REJECTED | Reverts | None (fleet can update to retry) | ## Fleet Token Lifecycle @@ -101,6 +101,7 @@ flowchart TD ### Orphan Guards These operations revert with `SwarmOrphaned()` if either NFT invalid: + - `acceptSwarm(swarmId)` - `rejectSwarm(swarmId)` - `checkMembership(swarmId, tagHash)` diff --git a/src/swarms/doc/maintenance.md b/src/swarms/doc/maintenance.md index 20cf3a00..1e1316ec 100644 --- a/src/swarms/doc/maintenance.md +++ b/src/swarms/doc/maintenance.md @@ -3,6 +3,7 @@ ## Overview After registration, fleet owners must monitor bundle inclusion as market conditions change: + - New fleets registering at higher tiers - Existing fleets promoting - Bundle slots limited to 20 per location @@ -12,16 +13,16 @@ After registration, fleet owners must monitor bundle inclusion as market conditi ```mermaid flowchart TD START([Registered]) --> CHECK{In bundle?} - + CHECK -->|Yes| OPTIMIZE{Lower tier
possible?} CHECK -->|No| PROMOTE[Promote] - + OPTIMIZE -->|Yes| DEMOTE[Demote → refund] OPTIMIZE -->|No| WAIT - + DEMOTE --> WAIT PROMOTE --> WAIT - + WAIT[Wait 24h] --> CHECK style DEMOTE fill:#2ecc71,color:#fff @@ -34,10 +35,12 @@ flowchart TD ```typescript const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle( - countryCode, adminCode + countryCode, + adminCode, ); -const isIncluded = uuids.slice(0, count) - .some(u => u.toLowerCase() === myUuid.toLowerCase()); +const isIncluded = uuids + .slice(0, count) + .some((u) => u.toLowerCase() === myUuid.toLowerCase()); ``` ### Country Fleets @@ -46,17 +49,20 @@ Must check **every** active admin area in their country: ```typescript const adminAreas = await fleetIdentity.getActiveAdminAreas(); -const myAdminAreas = adminAreas.filter(rk => Number(rk >> 10n) === myCountryCode); +const myAdminAreas = adminAreas.filter( + (rk) => Number(rk >> 10n) === myCountryCode, +); const missingAreas = []; for (const rk of myAdminAreas) { - const adminCode = Number(rk & 0x3ffn); - const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle( - myCountryCode, adminCode - ); - if (!uuids.slice(0, count).some(u => u === myUuid)) { - missingAreas.push(adminCode); - } + const adminCode = Number(rk & 0x3ffn); + const [uuids, count] = await fleetIdentity.buildHighestBondedUuidBundle( + myCountryCode, + adminCode, + ); + if (!uuids.slice(0, count).some((u) => u === myUuid)) { + missingAreas.push(adminCode); + } } ``` @@ -116,16 +122,16 @@ fleetIdentity.promote(tokenId); ```typescript while (attempts < 3) { - try { - await fleetIdentity.reassignTier(tokenId, requiredTier); - break; - } catch (e) { - if (e.message.includes("TierFull")) { - const [newTier] = await fleetIdentity.localInclusionHint(cc, admin); - requiredTier = newTier; - // Re-approve if needed - } else throw e; - } + try { + await fleetIdentity.reassignTier(tokenId, requiredTier); + break; + } catch (e) { + if (e.message.includes("TierFull")) { + const [newTier] = await fleetIdentity.localInclusionHint(cc, admin); + requiredTier = newTier; + // Re-approve if needed + } else throw e; + } } ``` @@ -138,29 +144,29 @@ const [suggestedTier] = await fleetIdentity.localInclusionHint(cc, admin); const currentTier = await fleetIdentity.fleetTier(tokenId); if (suggestedTier < currentTier) { - await fleetIdentity.reassignTier(tokenId, suggestedTier); - // Refund deposited to owner + await fleetIdentity.reassignTier(tokenId, suggestedTier); + // Refund deposited to owner } ``` ## Propagation Timing -| Phase | Duration | -| :---- | :------- | -| Transaction confirmation | ~1-2s (ZkSync) | -| Event indexing | ~1-10s | -| Edge network sync | Minutes to hours | +| Phase | Duration | +| :----------------------- | :--------------- | +| Transaction confirmation | ~1-2s (ZkSync) | +| Event indexing | ~1-10s | +| Edge network sync | Minutes to hours | **Recommendation**: 24-hour check interval. ## Summary -| Task | Method | -| :--- | :----- | -| Check inclusion (local) | `buildHighestBondedUuidBundle(cc, admin)` | -| Check inclusion (country) | Loop all admin areas | -| Get required tier (local) | `localInclusionHint(cc, admin)` | -| Get required tier (country) | `countryInclusionHint(cc)` | -| Calculate bond | `tierBond(tier, isCountry)` | -| Move tier | `reassignTier(tokenId, tier)` | -| Quick promote | `promote(tokenId)` | +| Task | Method | +| :-------------------------- | :---------------------------------------- | +| Check inclusion (local) | `buildHighestBondedUuidBundle(cc, admin)` | +| Check inclusion (country) | Loop all admin areas | +| Get required tier (local) | `localInclusionHint(cc, admin)` | +| Get required tier (country) | `countryInclusionHint(cc)` | +| Calculate bond | `tierBond(tier, isCountry)` | +| Move tier | `reassignTier(tokenId, tier)` | +| Quick promote | `promote(tokenId)` | diff --git a/src/swarms/doc/swarm-operations.md b/src/swarms/doc/swarm-operations.md index 7859c2be..853352b8 100644 --- a/src/swarms/doc/swarm-operations.md +++ b/src/swarms/doc/swarm-operations.md @@ -29,17 +29,18 @@ sequenceDiagram ### Parameters -| Parameter | Type | Description | -| :-------- | :--- | :---------- | -| `fleetUuid` | bytes16 | UUID that owns this swarm | -| `providerId` | uint256 | ServiceProvider token ID | -| `filter` | bytes | XOR filter data | -| `fpSize` | uint8 | Fingerprint size (1-16 bits) | -| `tagType` | TagType | Tag identity scheme | +| Parameter | Type | Description | +| :----------- | :------ | :--------------------------- | +| `fleetUuid` | bytes16 | UUID that owns this swarm | +| `providerId` | uint256 | ServiceProvider token ID | +| `filter` | bytes | XOR filter data | +| `fpSize` | uint8 | Fingerprint size (1-16 bits) | +| `tagType` | TagType | Tag identity scheme | ### Swarm ID Deterministic derivation: + ```solidity swarmId = uint256(keccak256(abi.encode(fleetUuid, providerId, filter))) ``` @@ -57,18 +58,18 @@ Duplicate registration reverts with `SwarmAlreadyExists()`. ### TagType Schemas -| Type | Format | Bytes | -| :--- | :----- | ----: | -| `IBEACON_PAYLOAD_ONLY` | UUID ∥ Major ∥ Minor | 20 | -| `IBEACON_INCLUDES_MAC` | UUID ∥ Major ∥ Minor ∥ MAC | 26 | -| `VENDOR_ID` | companyID ∥ hash(vendorBytes) | varies | -| `GENERIC` | custom | varies | +| Type | Format | Bytes | +| :--------------------- | :---------------------------- | -----: | +| `IBEACON_PAYLOAD_ONLY` | UUID ∥ Major ∥ Minor | 20 | +| `IBEACON_INCLUDES_MAC` | UUID ∥ Major ∥ Minor ∥ MAC | 26 | +| `VENDOR_ID` | companyID ∥ hash(vendorBytes) | varies | +| `GENERIC` | custom | varies | ### MAC Normalization (IBEACON_INCLUDES_MAC) -| MAC Type | Action | -| :------- | :----- | -| Public/Static (00) | Use real MAC | +| MAC Type | Action | +| :---------------------- | :------------------------------- | +| Public/Static (00) | Use real MAC | | Random/Private (01, 11) | Replace with `FF:FF:FF:FF:FF:FF` | This supports rotating privacy MACs while validating "it's a privacy tag." @@ -104,8 +105,8 @@ stateDiagram-v2 REJECTED --> [*] : deleteSwarm() / purge ``` -| Action | Caller | Effect | -| :----- | :----- | :----- | +| Action | Caller | Effect | +| :--------------------- | :------------- | :---------------- | | `acceptSwarm(swarmId)` | Provider owner | status → ACCEPTED | | `rejectSwarm(swarmId)` | Provider owner | status → REJECTED | @@ -183,15 +184,16 @@ sequenceDiagram ### Guards Operations revert with `SwarmOrphaned()` if either NFT is invalid: + - `acceptSwarm()` - `rejectSwarm()` - `checkMembership()` ## Storage Variants -| Variant | Filter Storage | Deletion | -| :------ | :------------- | :------- | -| **L1** | SSTORE2 (contract bytecode) | Struct cleared; bytecode persists | -| **Universal** | `mapping(uint256 => bytes)` | Full deletion | +| Variant | Filter Storage | Deletion | +| :------------ | :-------------------------- | :-------------------------------- | +| **L1** | SSTORE2 (contract bytecode) | Struct cleared; bytecode persists | +| **Universal** | `mapping(uint256 => bytes)` | Full deletion | Universal exposes `getFilterData(swarmId)` for off-chain retrieval. From 193424b38456eac7c683bdd479e1f4255b5d88d3 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 27 Feb 2026 11:30:51 +1300 Subject: [PATCH 62/63] docs(swarms): revise privacy terminology to reflect iOS constraints - Replace 'privacy-preserving' with 'non-enumerating' + 'membership proofs' - Add Privacy Model section with data visibility table - UUID=public (iOS CLBeaconRegion), Major/Minor=filter-protected, MAC=Android-only --- src/swarms/doc/README.md | 14 +- src/swarms/doc/assistant-guide.md | 407 ++++++++++++++++++++++++++++++ 2 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/swarms/doc/assistant-guide.md diff --git a/src/swarms/doc/README.md b/src/swarms/doc/README.md index 5ad5b482..efd28207 100644 --- a/src/swarms/doc/README.md +++ b/src/swarms/doc/README.md @@ -1,6 +1,6 @@ # Swarm System Technical Specification -Privacy-preserving BLE tag registry enabling decentralized device discovery without revealing individual tag identities on-chain. +BLE tag registry enabling decentralized device discovery using cryptographic membership proofs. Individual tags within a swarm are not enumerated on-chain. ## Architecture @@ -84,6 +84,18 @@ tokenId = (regionKey << 128) | uint256(uint128(uuid)) - Bits 0-127: UUID - Bits 128-159: Region key +## Privacy Model + +The system provides **non-enumerating** tag verification—individual tags aren't listed on-chain; membership is proven via XOR filter. + +| Data | Visibility | Notes | +| :------------ | :--------------- | :------------------------------------------ | +| UUID | Public | Required for iOS background beacon detection | +| Major/Minor | Filter-protected | Hashed, not enumerated | +| MAC address | Android-only | iOS does not expose BLE MAC addresses | + +**Limitation**: UUID must be public for iOS `CLBeaconRegion` background monitoring. The system protects the specific Major/Minor combinations within that UUID's swarm. + ## Documentation | Document | Description | diff --git a/src/swarms/doc/assistant-guide.md b/src/swarms/doc/assistant-guide.md new file mode 100644 index 00000000..c4bd497c --- /dev/null +++ b/src/swarms/doc/assistant-guide.md @@ -0,0 +1,407 @@ +# 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 **non-enumerating** 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) using cryptographic membership proofs—individual tags are never listed on-chain. + +Two registry variants exist for different deployment targets: + +- **`SwarmRegistryL1`** — Ethereum L1, uses SSTORE2 (contract bytecode) for gas-efficient filter storage. Not compatible with ZkSync Era. +- **`SwarmRegistryUniversal`** — All EVM chains including ZkSync Era, uses native `bytes` storage. + +### Core Components + +| Contract | Role | Key Identity | Token | +| :--------------------------- | :---------------------------------- | :----------------------------------------- | :---- | +| **`FleetIdentity`** | Fleet Registry (ERC-721 Enumerable) | `(regionKey << 128) \| uint128(uuid)` | SFID | +| **`ServiceProvider`** | Service Registry (ERC-721) | `keccak256(url)` | SSV | +| **`SwarmRegistryL1`** | Swarm Registry (L1) | `keccak256(fleetUuid, providerId, filter)` | — | +| **`SwarmRegistryUniversal`** | Swarm Registry (Universal) | `keccak256(fleetUuid, providerId, filter)` | — | + +All contracts are **permissionless** — access control is enforced through NFT ownership rather than admin roles. `FleetIdentity` additionally requires an ERC-20 bond (e.g. NODL) to register a fleet, acting as an anti-spam / anti-abuse mechanism. + +Both NFT contracts support **burning** — the token owner can call `burn(tokenId)` to destroy their NFT. Burning a `FleetIdentity` token refunds the tier bond to the owner. Burning either NFT makes any swarms referencing that token _orphaned_. + +### FleetIdentity: Two-Level Geographic Registration + +`FleetIdentity` implements a **two-level geographic registration** system: + +- **Country Level** — `regionKey = countryCode` (ISO 3166-1 numeric, 1-999) +- **Admin Area (Local) Level** — `regionKey = (countryCode << 10) | adminCode` (>= 1024) + +Each region has its own independent tier namespace. The first fleet in any region always pays the level-appropriate base bond. + +**TokenID Encoding:** + +``` +tokenId = (regionKey << 128) | uint256(uint128(uuid)) +``` + +- Bits 0-127: UUID (Proximity UUID as bytes16) +- Bits 128-159: Region key (country or admin-area code) + +This allows the same UUID to be registered in multiple regions, each with a distinct token. + +### Economic Model (Tier System) + +| Parameter | Value | +| :------------------ | :--------------------------------------------------------- | +| **Tier Capacity** | 4 members per tier | +| **Max Tiers** | 24 per region | +| **Local Bond** | `BASE_BOND * 2^tier` | +| **Country Bond** | `BASE_BOND * COUNTRY_BOND_MULTIPLIER * 2^tier` (16× local) | +| **Max Bundle Size** | 20 UUIDs | + +Country fleets pay 16× more but appear in all admin-area bundles within their country. This economic difference provides locals a significant advantage: a local can reach tier 3 for the same cost a country player pays for tier 0. + +### UUID Ownership Model + +UUIDs have an ownership model with registration levels: + +| Level | Value | Description | +| :-------- | :---- | :--------------------------------------- | +| `None` | 0 | Not registered (default) | +| `Owned` | 1 | Claimed but not registered in any region | +| `Local` | 2 | Registered at admin area level | +| `Country` | 3 | Registered at country level | + +- **UUID Owner**: The address that first registered a token for a UUID. All subsequent registrations must come from this address. +- **Multi-Region**: The same UUID can have multiple tokens in different regions (all at the same level, all by the same owner). +- **Transfer**: Owned-only tokens transfer `uuidOwner` when the NFT is transferred. + +--- + +## 2. Operational Workflows + +### A. Provider Setup (One-Time) + +**Service Provider** calls `ServiceProvider.registerProvider("https://cms.example.com")`. Receives `providerTokenId` (= `keccak256(url)`). + +### B. Fleet Registration Options + +Fleet Owners have multiple paths to register fleets: + +#### B1. Direct Registration (Country Level) + +```solidity +// 1. Approve bond token +NODL.approve(fleetIdentityAddress, requiredBond); + +// 2. Get inclusion hint (off-chain call - free) +(uint256 tier, uint256 bond) = fleetIdentity.countryInclusionHint(840); // US = 840 + +// 3. Register at the recommended tier +uint256 tokenId = fleetIdentity.registerFleetCountry(uuid, 840, tier); +// Returns tokenId = (840 << 128) | uint128(uuid) +``` + +#### B2. Direct Registration (Local/Admin Area Level) + +```solidity +// 1. Approve bond token +NODL.approve(fleetIdentityAddress, requiredBond); + +// 2. Get inclusion hint (off-chain call - free) +(uint256 tier, uint256 bond) = fleetIdentity.localInclusionHint(840, 5); // US, California + +// 3. Register at the recommended tier +uint256 tokenId = fleetIdentity.registerFleetLocal(uuid, 840, 5, tier); +// Returns tokenId = ((840 << 10 | 5) << 128) | uint128(uuid) +``` + +#### B3. Claim-First Flow (Reserve UUID, Register Later) + +```solidity +// 1. Claim UUID ownership (costs BASE_BOND) +NODL.approve(fleetIdentityAddress, BASE_BOND); +uint256 ownedTokenId = fleetIdentity.claimUuid(uuid); +// Returns tokenId = uint128(uuid) (regionKey = 0) + +// 2. Later: Register from owned state (burns owned token, mints regional token) +// Only pays incremental bond (tier bond - BASE_BOND already paid) +uint256 tokenId = fleetIdentity.registerFleetLocal(uuid, 840, 5, targetTier); +``` + +### C. Fleet Tier Management + +Fleets can promote or demote within their region: + +```solidity +// Promote to next tier (pulls additional bond) +fleetIdentity.promote(tokenId); + +// Reassign to any tier (promotes or demotes) +fleetIdentity.reassignTier(tokenId, targetTier); +// If targetTier > current: pulls additional bond +// If targetTier < current: refunds bond difference +``` + +### D. Unregister to Owned State + +A fleet with a **single token** can unregister back to owned-only state: + +```solidity +// Returns to owned state, refunds (tierBond - BASE_BOND) +uint256 ownedTokenId = fleetIdentity.unregisterToOwned(tokenId); +// Reverts if UUID has multiple tokens (multi-region registration) +``` + +### E. Release UUID Ownership + +An owned-only UUID can be fully released, refunding BASE_BOND: + +```solidity +// Must be in Owned state (not registered in any region) +fleetIdentity.releaseUuid(uuid); +// Clears uuidOwner, allows anyone to claim the UUID +``` + +### F. Burn Fleet Token + +```solidity +// Burns token and refunds tier bond +fleetIdentity.burn(tokenId); +// For owned-only tokens: refunds BASE_BOND +// For registered tokens: refunds tierBond(tier, isCountry) +``` + +### G. 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(fleetUuid, providerId, filterData)` off-chain to obtain the deterministic ID before submitting the transaction. +4. **Register**: + ```solidity + swarmRegistry.registerSwarm( + fleetUuid, + providerId, + filterData, + 16, // Fingerprint size in bits (1–16) + TagType.IBEACON_INCLUDES_MAC // or PAYLOAD_ONLY, VENDOR_ID, GENERIC + ); + // Returns the deterministic swarmId + ``` + +### H. 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. + +### I. 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)` + +### J. Swarm Deletion + +The fleet owner can permanently remove a swarm: + +```solidity +swarmRegistry.deleteSwarm(swarmId); +``` + +### K. 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(fleetUuid, providerId, filterData))) +``` + +This means the same (UUID, provider, filter) triple always produces the same ID, and duplicate registrations revert with `SwarmAlreadyExists()`. The `computeSwarmId` function is `public pure`, so it can be called off-chain at zero cost via `eth_call`. + +--- + +## 4. Client Discovery Flow (The "EdgeBeaconScanner" Perspective) + +A client (mobile phone or gateway) scans a BLE beacon and wants to find its owner and backend service. + +### Discovery Option A: Geographic Bundle Discovery (Recommended) + +Use the priority-ordered bundle based on EdgeBeaconScanner location. + +#### Step 1: Get Priority Bundle + +```solidity +// EdgeBeaconScanner knows its location: US, California (country=840, admin=5) +(bytes16[] memory uuids, uint256 count) = fleetIdentity.buildHighestBondedUuidBundle(840, 5); +// Returns up to 20 UUIDs, priority-ordered: +// 1. Higher tier first +// 2. Local (admin area) before country within same tier +// 3. Earlier registration within same tier+level +``` + +#### Step 2: Match Detected Beacon UUID + +```solidity +bytes16 detectedUUID = ...; // From iBeacon advertisement + +for (uint256 i = 0; i < count; i++) { + if (uuids[i] == detectedUUID) { + // Found! Now find the token ID + // Try local region first, then country + uint32 localRegion = (840 << 10) | 5; + uint256 tokenId = fleetIdentity.computeTokenId(detectedUUID, localRegion); + if (fleetIdentity.ownerOf(tokenId) exists) { ... } + // else try country region + uint256 tokenId = fleetIdentity.computeTokenId(detectedUUID, 840); + } +} +``` + +#### Step 3: Enumerate Swarms & Check Membership + +Same as Option B Steps 3-5. + +### Discovery Option B: Direct Fleet Lookup + +For when you know the UUID and want to find its fleet directly. + +#### Step 1: Enumerate Active Regions + +```solidity +// Get all countries with active fleets +uint16[] memory countries = fleetIdentity.getActiveCountries(); + +// Get all admin areas with active fleets +uint32[] memory adminAreas = fleetIdentity.getActiveAdminAreas(); +``` + +#### Step 2: Find Fleet Token + +```solidity +bytes16 uuid = ...; // From iBeacon + +// Try each potential region (start with user's location) +uint32 region = (840 << 10) | 5; // US-CA +uint256 tokenId = fleetIdentity.computeTokenId(uuid, region); + +try fleetIdentity.ownerOf(tokenId) returns (address owner) { + // Found the fleet! +} catch { + // Try country-level + tokenId = fleetIdentity.computeTokenId(uuid, 840); +} +``` + +#### Step 3: Find Swarms + +```solidity +// Enumerate swarms for this UUID +uint256[] memory swarmIds = new uint256[](100); // estimate +for (uint256 i = 0; ; i++) { + try swarmRegistry.uuidSwarms(detectedUUID, i) returns (uint256 swarmId) { + swarmIds[i] = swarmId; + } catch { + break; // End of array + } +} +``` + +#### Step 4: Membership Check + +```solidity +// Construct tagHash based on swarm's tagType +(bytes16 fleetUuid, uint256 providerId, uint32 filterLen, uint8 fpSize, + SwarmStatus status, TagType tagType) = swarmRegistry.swarms(swarmId); + +// Build tagId per schema (see Section 3) +bytes memory tagId; +if (tagType == TagType.IBEACON_PAYLOAD_ONLY) { + tagId = abi.encodePacked(uuid, major, minor); +} else if (tagType == TagType.IBEACON_INCLUDES_MAC) { + bytes6 normalizedMac = isRandomMac ? bytes6(0xFFFFFFFFFFFF) : realMac; + tagId = abi.encodePacked(uuid, major, minor, normalizedMac); +} + +bytes32 tagHash = keccak256(tagId); +bool isMember = swarmRegistry.checkMembership(swarmId, tagHash); +``` + +#### Step 5: Service Discovery + +```solidity +if (isMember && status == SwarmStatus.ACCEPTED) { + string memory url = serviceProvider.providerUrls(providerId); + // Connect to url +} +``` + +--- + +## 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 `uuidSwarms` array, tracked via the `swarmIndexInUuid` mapping. + +--- + +**Note**: This architecture ensures that an EdgeBeaconScanner can go from **Raw Signal** → **Verified Service URL** entirely on-chain (data-wise), without a centralized indexer, while privacy of the 10,000 other tags in the swarm is preserved. From dbaaf3a025b145c5f1b5d8f304a906538ce8d464 Mon Sep 17 00:00:00 2001 From: Alex Sedighi Date: Fri, 27 Feb 2026 17:50:12 +1300 Subject: [PATCH 63/63] feat: add operator delegation to FleetIdentity * UUID owners can delegate tier maintenance to an operator * Operator manages promote/demote; owner retains burn rights * Bond split: owner pays BASE_BOND, operator pays/receives tier excess setOperator() atomically transfers tier bonds between operators * registerFleetLocalWithOperator/registerFleetCountryWithOperator for registration with operator * Comprehensive tests covering all operator scenarios * Documentation updated (README, data-model, lifecycle, maintenance, registration, assistant-guide) --- src/swarms/FleetIdentity.sol | 270 ++++++++++++++++++--- src/swarms/doc/README.md | 22 +- src/swarms/doc/assistant-guide.md | 45 +++- src/swarms/doc/data-model.md | 5 + src/swarms/doc/fleet-registration.md | 54 +++++ src/swarms/doc/lifecycle.md | 44 ++-- src/swarms/doc/maintenance.md | 25 +- test/FleetIdentity.t.sol | 335 ++++++++++++++++++++++++++- 8 files changed, 717 insertions(+), 83 deletions(-) diff --git a/src/swarms/FleetIdentity.sol b/src/swarms/FleetIdentity.sol index 323d6769..0dba18ba 100644 --- a/src/swarms/FleetIdentity.sol +++ b/src/swarms/FleetIdentity.sol @@ -76,6 +76,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { error NotUuidOwner(); error CannotUnregisterMultipleTokens(); error AlreadyRegistered(); + error NotOperator(); + error OperatorNotAllowedForOwnedOnly(); // ────────────────────────────────────────────── // Enums @@ -167,6 +169,12 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { /// All tokens for a UUID must be at the same level. mapping(bytes16 => RegistrationLevel) public uuidLevel; + /// @notice UUID -> operator address for tier maintenance. + /// If address(0), the uuidOwner acts as operator. + /// Operator can only be set for registered UUIDs (Local or Country level). + /// The operator pays/receives tier bond differentials; owner pays BASE_BOND. + mapping(bytes16 => address) public uuidOperator; + // ────────────────────────────────────────────── // On-chain region indexes // ────────────────────────────────────────────── @@ -189,7 +197,14 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 indexed tokenId, uint32 regionKey, uint256 tierIndex, - uint256 bondAmount + uint256 bondAmount, + address operator + ); + event OperatorSet( + bytes16 indexed uuid, + address indexed oldOperator, + address indexed newOperator, + uint256 tierExcessTransferred ); event FleetPromoted( uint256 indexed tokenId, uint256 indexed fromTier, uint256 indexed toTier, uint256 additionalBond @@ -233,7 +248,27 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); uint32 regionKey = uint32(countryCode); _validateExplicitTier(regionKey, targetTier); - tokenId = _register(uuid, regionKey, targetTier); + tokenId = _register(uuid, regionKey, targetTier, address(0)); + } + + /// @notice Register a fleet under a country with a designated operator. + /// @dev The owner pays BASE_BOND, the operator pays tier excess. + /// Setting operator to address(0) or msg.sender makes owner the operator. + /// @param uuid Proximity UUID for the fleet. + /// @param countryCode ISO 3166-1 numeric country code (1-999). + /// @param targetTier Target tier for registration. + /// @param operator Address responsible for tier maintenance. address(0) = owner. + function registerFleetCountryWithOperator( + bytes16 uuid, + uint16 countryCode, + uint256 targetTier, + address operator + ) external nonReentrant returns (uint256 tokenId) { + if (uuid == bytes16(0)) revert InvalidUUID(); + if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); + uint32 regionKey = uint32(countryCode); + _validateExplicitTier(regionKey, targetTier); + tokenId = _register(uuid, regionKey, targetTier, operator); } // ══════════════════════════════════════════════ @@ -253,7 +288,30 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); uint32 regionKey = makeAdminRegion(countryCode, adminCode); _validateExplicitTier(regionKey, targetTier); - tokenId = _register(uuid, regionKey, targetTier); + tokenId = _register(uuid, regionKey, targetTier, address(0)); + } + + /// @notice Register a fleet under a country + admin area with a designated operator. + /// @dev The owner pays BASE_BOND, the operator pays tier excess. + /// Setting operator to address(0) or msg.sender makes owner the operator. + /// @param uuid Proximity UUID for the fleet. + /// @param countryCode ISO 3166-1 numeric country code (1-999). + /// @param adminCode Admin area code (1-255). + /// @param targetTier Target tier for registration. + /// @param operator Address responsible for tier maintenance. address(0) = owner. + function registerFleetLocalWithOperator( + bytes16 uuid, + uint16 countryCode, + uint16 adminCode, + uint256 targetTier, + address operator + ) external nonReentrant returns (uint256 tokenId) { + if (uuid == bytes16(0)) revert InvalidUUID(); + if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); + if (adminCode == 0 || adminCode > MAX_ADMIN_CODE) revert InvalidAdminCode(); + uint32 regionKey = makeAdminRegion(countryCode, adminCode); + _validateExplicitTier(regionKey, targetTier); + tokenId = _register(uuid, regionKey, targetTier, operator); } // ══════════════════════════════════════════════ @@ -261,13 +319,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // ══════════════════════════════════════════════ /// @notice Promotes a fleet to the next tier within its region. + /// Only callable by the effective operator (or owner if no operator set). function promote(uint256 tokenId) external nonReentrant { _promote(tokenId, fleetTier[tokenId] + 1); } /// @notice Moves a fleet to a different tier within its region. - /// If targetTier > current tier, promotes (pulls additional bond). - /// If targetTier < current tier, demotes (refunds bond difference). + /// If targetTier > current tier, promotes (pulls additional bond from operator). + /// If targetTier < current tier, demotes (refunds bond difference to operator). + /// Only callable by the effective operator (or owner if no operator set). function reassignTier(uint256 tokenId, uint256 targetTier) external nonReentrant { uint256 currentTier = fleetTier[tokenId]; if (targetTier == currentTier) revert TargetTierSameAsCurrent(); @@ -278,38 +338,96 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } + // ══════════════════════════════════════════════ + // Operator Management + // ══════════════════════════════════════════════ + + /// @notice Sets or changes the operator for a UUID. + /// The operator is responsible for tier maintenance (promote/demote). + /// When changing operators, the new operator must pay the old operator + /// for all accumulated tier bond excess across all registered regions. + /// @dev Only the UUID owner can call this. Cannot be called for owned-only UUIDs. + /// Setting operator to owner address or address(0) clears the explicit operator. + /// @param uuid The UUID to set the operator for. + /// @param newOperator The new operator address. Use address(0) or owner to clear. + function setOperator(bytes16 uuid, address newOperator) external nonReentrant { + // Only owner can set operator + if (uuidOwner[uuid] != msg.sender) revert NotUuidOwner(); + + // Cannot set operator for owned-only UUIDs (no tiers to manage) + RegistrationLevel level = uuidLevel[uuid]; + if (level == RegistrationLevel.None || level == RegistrationLevel.Owned) { + revert OperatorNotAllowedForOwnedOnly(); + } + + address oldOperator = operatorOf(uuid); + + // Normalize: if newOperator is owner, store as address(0) + address storedOperator = (newOperator == msg.sender) ? address(0) : newOperator; + address effectiveNewOperator = (storedOperator == address(0)) ? msg.sender : storedOperator; + + // Calculate tier excess to transfer between operators + uint256 tierExcess = _computeTotalTierExcess(uuid); + + // Effects: Update operator + uuidOperator[uuid] = storedOperator; + + // Interactions: Transfer tier bond excess from new operator to old operator + if (tierExcess > 0 && oldOperator != effectiveNewOperator) { + // Pull from new operator + _pullBond(effectiveNewOperator, tierExcess); + // Refund to old operator + _refundBond(oldOperator, tierExcess); + } + + emit OperatorSet(uuid, oldOperator, effectiveNewOperator, tierExcess); + } + // ══════════════════════════════════════════════ // Burn // ══════════════════════════════════════════════ - /// @notice Burns the fleet NFT and refunds the bond to the token owner. - /// Handles both registered fleets (regional) and owned-only UUIDs. + /// @notice Burns the fleet NFT and refunds the bond. + /// For owned-only tokens: full BASE_BOND refund to owner. + /// For registered fleets: BASE_BOND to owner, tier excess to operator. + /// Only the token owner can burn. function burn(uint256 tokenId) external nonReentrant { - address tokenOwner = ownerOf(tokenId); - if (tokenOwner != msg.sender) revert NotTokenOwner(); + address tokenHolder = ownerOf(tokenId); + if (tokenHolder != msg.sender) revert NotTokenOwner(); uint32 region = tokenRegion(tokenId); bytes16 uuid = tokenUuid(tokenId); - uint256 refund; + address owner = uuidOwner[uuid]; uint256 tier; + uint256 ownerRefund; + uint256 operatorRefund; if (region == OWNED_REGION_KEY) { - // Owned-only token: no tier structures, just BASE_BOND - refund = BASE_BOND; + // Owned-only token: no tier structures, just BASE_BOND to owner + ownerRefund = BASE_BOND; + operatorRefund = 0; tier = 0; _burn(tokenId); } else { - // Registered fleet: remove from tier structures + // Registered fleet: split refund between owner and operator tier = fleetTier[tokenId]; - refund = tierBond(tier, _isCountryRegion(region)); + uint256 totalBond = tierBond(tier, _isCountryRegion(region)); + ownerRefund = BASE_BOND; + operatorRefund = totalBond - BASE_BOND; + + address operator = operatorOf(uuid); + _cleanupFleetFromTier(tokenId, region, tier); _burn(tokenId); + + // Refund tier excess to operator + _refundBond(operator, operatorRefund); } _decrementUuidCount(uuid); - _refundBond(tokenOwner, refund); + _refundBond(owner, ownerRefund); - emit FleetBurned(tokenOwner, tokenId, region, tier, refund); + emit FleetBurned(tokenHolder, tokenId, region, tier, ownerRefund + operatorRefund); } // ══════════════════════════════════════════════ @@ -339,7 +457,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @notice Move a registered fleet back to owned-only mode, receiving a partial refund. - /// Only works when this is the ONLY token for the UUID. + /// Tier excess is refunded to the operator. Only works with a single token. /// @param tokenId The fleet token to unregister. /// @return newTokenId The new owned-only token ID (region=0). function unregisterToOwned(uint256 tokenId) external nonReentrant returns (uint256 newTokenId) { @@ -358,6 +476,9 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { uint256 tier = fleetTier[tokenId]; uint256 currentBond = tierBond(tier, _isCountryRegion(region)); uint256 refund = currentBond - BASE_BOND; + + // Get operator before clearing state + address operator = operatorOf(uuid); // Effects: Remove from region _cleanupFleetFromTier(tokenId, region, tier); @@ -365,12 +486,15 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { // Update level to Owned (resets level for future registration flexibility) uuidLevel[uuid] = RegistrationLevel.Owned; + // Clear operator (owned-only has no tiers to manage) + delete uuidOperator[uuid]; // Mint owned-only token newTokenId = uint256(uint128(uuid)); _mint(msg.sender, newTokenId); - _refundBond(msg.sender, refund); + // Refund tier excess to operator (not owner) + _refundBond(operator, refund); emit UuidUnregistered(msg.sender, uuid, tokenId, newTokenId, refund); } @@ -499,6 +623,18 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { return uuidLevel[uuid] == RegistrationLevel.Owned; } + /// @notice Returns the effective operator for a UUID. + /// If no explicit operator is set, returns the uuidOwner (owner acts as operator). + /// Returns address(0) if UUID is not registered. + /// @param uuid The UUID to query. + /// @return operator The effective operator address responsible for tier maintenance. + function operatorOf(bytes16 uuid) public view returns (address operator) { + operator = uuidOperator[uuid]; + if (operator == address(0)) { + operator = uuidOwner[uuid]; + } + } + // ══════════════════════════════════════════════ // Views: EdgeBeaconScanner discovery // ══════════════════════════════════════════════ @@ -683,6 +819,7 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { delete uuidOwner[uuid]; delete uuidTokenCount[uuid]; delete uuidLevel[uuid]; + delete uuidOperator[uuid]; } /// @dev Decrements UUID token count. Clears ownership if count reaches zero. @@ -696,6 +833,40 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } } + /// @dev Computes total tier bond excess for a UUID across all registered regions. + /// Tier excess = sum of (tierBond - BASE_BOND) for each token. + /// This represents the amount the operator has paid beyond the base ownership bond. + /// @param uuid The UUID to compute tier excess for. + /// @return excess Total tier bond excess across all tokens for this UUID. + function _computeTotalTierExcess(bytes16 uuid) internal view returns (uint256 excess) { + address owner = uuidOwner[uuid]; + if (owner == address(0)) return 0; + + uint256 tokenCount = uuidTokenCount[uuid]; + if (tokenCount == 0) return 0; + + // Iterate through owner's tokens to find those belonging to this UUID + uint256 ownerBalance = balanceOf(owner); + uint256 found = 0; + + for (uint256 i = 0; i < ownerBalance && found < tokenCount; ++i) { + uint256 tokenId = tokenOfOwnerByIndex(owner, i); + bytes16 tokenUuidVal = tokenUuid(tokenId); + + if (tokenUuidVal == uuid) { + uint32 region = tokenRegion(tokenId); + // Skip owned-only tokens (region == 0) + if (region != OWNED_REGION_KEY) { + uint256 tier = fleetTier[tokenId]; + bool isCountry = _isCountryRegion(region); + uint256 tokenBond = tierBond(tier, isCountry); + excess += tokenBond - BASE_BOND; + } + ++found; + } + } + } + // -- Tier cleanup helpers -- /// @dev Removes a fleet from its tier and cleans up associated state. @@ -721,10 +892,19 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { } /// @dev Shared registration logic. Handles fresh, Owned → Registered, and multi-region registrations. - function _register(bytes16 uuid, uint32 region, uint256 tier) internal returns (uint256 tokenId) { + /// Supports split bond payment: owner pays BASE_BOND, operator pays tier excess. + /// @param uuid The Proximity UUID to register. + /// @param region The region key (country or admin area). + /// @param tier Target tier for registration. + /// @param operator Operator address. address(0) or msg.sender = owner acts as operator. + function _register(bytes16 uuid, uint32 region, uint256 tier, address operator) internal returns (uint256 tokenId) { RegistrationLevel existingLevel = uuidLevel[uuid]; bool isCountry = _isCountryRegion(region); RegistrationLevel targetLevel = isCountry ? RegistrationLevel.Country : RegistrationLevel.Local; + + // Normalize operator: address(0) or msg.sender means owner is operator + address storedOperator = (operator == address(0) || operator == msg.sender) ? address(0) : operator; + address effectiveOperator = (storedOperator == address(0)) ? msg.sender : storedOperator; if (existingLevel == RegistrationLevel.Owned) { // Owned → Registered transition: burn owned token, credit BASE_BOND already paid @@ -732,27 +912,33 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { _burn(uint256(uint128(uuid))); // Burn owned-only token uuidLevel[uuid] = targetLevel; + uuidOperator[uuid] = storedOperator; tokenId = _mintFleetToken(uuid, region, tier); - uint256 incrementalBond = tierBond(tier, isCountry) - BASE_BOND; - _pullBond(msg.sender, incrementalBond); + // Tier excess goes from operator (BASE_BOND was already paid by owner) + uint256 tierExcess = tierBond(tier, isCountry) - BASE_BOND; + _pullBond(effectiveOperator, tierExcess); - emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, incrementalBond); + emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, tierExcess, effectiveOperator); } else if (existingLevel == RegistrationLevel.None) { // Fresh registration: set UUID ownership uuidOwner[uuid] = msg.sender; uuidLevel[uuid] = targetLevel; uuidTokenCount[uuid] = 1; + uuidOperator[uuid] = storedOperator; tokenId = _mintFleetToken(uuid, region, tier); - uint256 bond = tierBond(tier, isCountry); - _pullBond(msg.sender, bond); + // Owner pays BASE_BOND, operator pays tier excess + uint256 fullBond = tierBond(tier, isCountry); + uint256 tierExcess = fullBond - BASE_BOND; + _pullBond(msg.sender, BASE_BOND); + _pullBond(effectiveOperator, tierExcess); - emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, bond); + emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, fullBond, effectiveOperator); } else { - // Multi-region registration: same owner, same level, additional token + // Multi-region registration: same owner, same level, use existing operator if (uuidOwner[uuid] != msg.sender) revert UuidOwnerMismatch(); if (existingLevel != targetLevel) revert UuidLevelMismatch(); @@ -760,17 +946,24 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { tokenId = _mintFleetToken(uuid, region, tier); + // For multi-region, use existing operator and they pay full tierBond for new region + address existingOperator = operatorOf(uuid); uint256 bond = tierBond(tier, isCountry); - _pullBond(msg.sender, bond); - emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, bond); + // Owner pays BASE_BOND, operator pays tier excess + uint256 tierExcess = bond - BASE_BOND; + _pullBond(msg.sender, BASE_BOND); + _pullBond(existingOperator, tierExcess); + + emit FleetRegistered(msg.sender, uuid, tokenId, region, tier, bond, existingOperator); } } - /// @dev Shared promotion logic. + /// @dev Shared promotion logic. Only operator can call. function _promote(uint256 tokenId, uint256 targetTier) internal { - address tokenOwner = ownerOf(tokenId); - if (tokenOwner != msg.sender) revert NotTokenOwner(); + bytes16 uuid = tokenUuid(tokenId); + address operator = operatorOf(uuid); + if (operator != msg.sender) revert NotOperator(); uint32 region = tokenRegion(tokenId); uint256 currentTier = fleetTier[tokenId]; @@ -788,16 +981,17 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { fleetTier[tokenId] = targetTier; _addToTier(tokenId, region, targetTier); - // Interaction - _pullBond(tokenOwner, additionalBond); + // Interaction: pull from operator + _pullBond(operator, additionalBond); emit FleetPromoted(tokenId, currentTier, targetTier, additionalBond); } - /// @dev Shared demotion logic. Refunds bond difference. + /// @dev Shared demotion logic. Refunds bond difference to operator. function _demote(uint256 tokenId, uint256 targetTier) internal { - address tokenOwner = ownerOf(tokenId); - if (tokenOwner != msg.sender) revert NotTokenOwner(); + bytes16 uuid = tokenUuid(tokenId); + address operator = operatorOf(uuid); + if (operator != msg.sender) revert NotOperator(); uint32 region = tokenRegion(tokenId); uint256 currentTier = fleetTier[tokenId]; @@ -815,8 +1009,8 @@ contract FleetIdentity is ERC721Enumerable, ReentrancyGuard { _addToTier(tokenId, region, targetTier); _trimTierCount(region); - // Interaction - _refundBond(tokenOwner, refund); + // Interaction: refund to operator + _refundBond(operator, refund); emit FleetDemoted(tokenId, currentTier, targetTier, refund); } diff --git a/src/swarms/doc/README.md b/src/swarms/doc/README.md index efd28207..16ee0263 100644 --- a/src/swarms/doc/README.md +++ b/src/swarms/doc/README.md @@ -75,6 +75,18 @@ Each region has independent tier competition: Country fleets pay 16× more but appear in all admin-area bundles within their country. +### Operator Delegation + +UUID owners can delegate tier maintenance to an **operator**: + +- **Default**: `operatorOf(uuid)` returns the UUID owner +- **Delegation**: UUID owner calls `setOperator(uuid, operator)` +- **Bond Split**: Owner pays BASE_BOND, operator pays tier excess +- **Permissions**: Only operator can promote/demote; owner retains burn rights +- **Transfer**: When changing operators, tier bonds transfer atomically + +This enables cold-wallet ownership with hot-wallet tier management. + ### Token ID Encoding ``` @@ -88,11 +100,11 @@ tokenId = (regionKey << 128) | uint256(uint128(uuid)) The system provides **non-enumerating** tag verification—individual tags aren't listed on-chain; membership is proven via XOR filter. -| Data | Visibility | Notes | -| :------------ | :--------------- | :------------------------------------------ | -| UUID | Public | Required for iOS background beacon detection | -| Major/Minor | Filter-protected | Hashed, not enumerated | -| MAC address | Android-only | iOS does not expose BLE MAC addresses | +| Data | Visibility | Notes | +| :---------- | :--------------- | :------------------------------------------- | +| UUID | Public | Required for iOS background beacon detection | +| Major/Minor | Filter-protected | Hashed, not enumerated | +| MAC address | Android-only | iOS does not expose BLE MAC addresses | **Limitation**: UUID must be public for iOS `CLBeaconRegion` background monitoring. The system protects the specific Major/Minor combinations within that UUID's swarm. diff --git a/src/swarms/doc/assistant-guide.md b/src/swarms/doc/assistant-guide.md index c4bd497c..1a7376ca 100644 --- a/src/swarms/doc/assistant-guide.md +++ b/src/swarms/doc/assistant-guide.md @@ -148,7 +148,32 @@ uint256 ownedTokenId = fleetIdentity.unregisterToOwned(tokenId); // Reverts if UUID has multiple tokens (multi-region registration) ``` -### E. Release UUID Ownership +### E. Operator Delegation + +UUID owners can delegate tier management to an operator wallet: + +```solidity +// Set operator at registration time (owner pays BASE_BOND, operator pays tier excess) +fleetIdentity.registerFleetLocalWithOperator(uuid, 840, 5, tier, operatorAddress); + +// Or set operator after registration (transfers tier bonds atomically) +fleetIdentity.setOperator(uuid, operatorAddress); + +// Check current operator (returns owner if none set) +address manager = fleetIdentity.operatorOf(uuid); + +// Clear operator (reverts to owner-managed) +fleetIdentity.setOperator(uuid, address(0)); +``` + +**Key Points:** + +- Operator handles `promote()` and `reassignTier()` calls +- Owner retains `burn()` rights and `setOperator()` control +- Tier excess bonds transfer between operators when changing +- Cannot set operator for owned-only UUIDs (must be registered) + +### F. Release UUID Ownership An owned-only UUID can be fully released, refunding BASE_BOND: @@ -158,16 +183,16 @@ fleetIdentity.releaseUuid(uuid); // Clears uuidOwner, allows anyone to claim the UUID ``` -### F. Burn Fleet Token +### G. Burn Fleet Token ```solidity -// Burns token and refunds tier bond +// Burns token and refunds bonds (split between owner and operator) fleetIdentity.burn(tokenId); -// For owned-only tokens: refunds BASE_BOND -// For registered tokens: refunds tierBond(tier, isCountry) +// For owned-only tokens: refunds BASE_BOND to owner +// For registered tokens: refunds BASE_BOND to owner, tier excess to operator ``` -### G. Swarm Registration (Per Batch of Tags) +### H. Swarm Registration (Per Batch of Tags) A Fleet Owner groups tags into a "Swarm" (chunk of ~10k-20k tags) and registers them. @@ -186,7 +211,7 @@ A Fleet Owner groups tags into a "Swarm" (chunk of ~10k-20k tags) and registers // Returns the deterministic swarmId ``` -### H. Swarm Approval Flow +### I. Swarm Approval Flow After registration a swarm starts in `REGISTERED` status and requires provider approval: @@ -195,14 +220,14 @@ After registration a swarm starts in `REGISTERED` status and requires provider a Only the owner of the provider NFT (`providerId`) can accept or reject. -### I. Swarm Updates +### J. 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)` -### J. Swarm Deletion +### K. Swarm Deletion The fleet owner can permanently remove a swarm: @@ -210,7 +235,7 @@ The fleet owner can permanently remove a swarm: swarmRegistry.deleteSwarm(swarmId); ``` -### K. Orphan Detection & Cleanup +### L. Orphan Detection & Cleanup When a fleet or provider NFT is burned, swarms referencing it become _orphaned_: diff --git a/src/swarms/doc/data-model.md b/src/swarms/doc/data-model.md index 56dd5fff..36617fe9 100644 --- a/src/swarms/doc/data-model.md +++ b/src/swarms/doc/data-model.md @@ -12,6 +12,7 @@ classDiagram +uint256 COUNTRY_BOND_MULTIPLIER = 16 +uint256 MAX_BONDED_UUID_BUNDLE_SIZE = 20 +mapping uuidOwner : bytes16 → address + +mapping uuidOperator : bytes16 → address +mapping uuidLevel : bytes16 → RegistrationLevel +mapping uuidTokenCount : bytes16 → uint256 +mapping regionTierCount : uint32 → uint256 @@ -19,12 +20,16 @@ classDiagram -- +claimUuid(uuid) → tokenId +registerFleetLocal(uuid, cc, admin, tier) → tokenId + +registerFleetLocalWithOperator(uuid, cc, admin, tier, operator) → tokenId +registerFleetCountry(uuid, cc, tier) → tokenId + +registerFleetCountryWithOperator(uuid, cc, tier, operator) → tokenId +promote(tokenId) +reassignTier(tokenId, targetTier) +unregisterToOwned(tokenId) → newTokenId +releaseUuid(uuid) +burn(tokenId) + +setOperator(uuid, newOperator) + +operatorOf(uuid) → address -- +localInclusionHint(cc, admin) → tier, bond +countryInclusionHint(cc) → tier, bond diff --git a/src/swarms/doc/fleet-registration.md b/src/swarms/doc/fleet-registration.md index bad93293..a577d2bc 100644 --- a/src/swarms/doc/fleet-registration.md +++ b/src/swarms/doc/fleet-registration.md @@ -86,6 +86,60 @@ sequenceDiagram FI-->>-FO: tokenId = ((cc<<10|admin)<<128) | uuid ``` +## Registering with Operator + +Delegate tier management to a separate wallet at registration time: + +```solidity +// Owner approves BASE_BOND +NODL.approve(fleetIdentityAddress, BASE_BOND); + +// Operator approves tier excess +// (as operator wallet or via prior approval) +uint256 tierExcess = fleetIdentity.tierBond(tier, false) - BASE_BOND; +// Operator must have approved tierExcess to FleetIdentity + +// Register with operator (pulls BASE_BOND from owner, tierExcess from operator) +uint256 tokenId = fleetIdentity.registerFleetLocalWithOperator(uuid, 840, 5, tier, operatorAddress); +``` + +```mermaid +sequenceDiagram + actor Owner + actor Operator + participant FI as FleetIdentity + participant TOKEN as BOND_TOKEN + + Owner->>TOKEN: approve(FleetIdentity, BASE_BOND) + Operator->>TOKEN: approve(FleetIdentity, tierExcess) + Owner->>+FI: registerFleetLocalWithOperator(uuid, cc, admin, tier, operator) + FI->>TOKEN: transferFrom(owner, this, BASE_BOND) + FI->>TOKEN: transferFrom(operator, this, tierExcess) + FI-->>-Owner: tokenId + + Note over Owner,Operator: Operator can now promote/demote +``` + +### Set or Change Operator Later + +```solidity +// Owner sets operator (transfers tier bonds atomically) +fleetIdentity.setOperator(uuid, newOperator); +// - Refunds tier excess to old operator (or owner if none set) +// - Pulls tier excess from new operator +// - Emits OperatorSet(uuid, oldOperator, newOperator, tierExcessTransferred) +``` + +### Clear Operator + +```solidity +// Owner clears operator (reverts to owner-managed) +fleetIdentity.setOperator(uuid, address(0)); +// - Refunds tier excess to old operator +// - Pulls tier excess from owner +// - operatorOf(uuid) returns owner again +``` + ## Tier Economics ### Bond Formula diff --git a/src/swarms/doc/lifecycle.md b/src/swarms/doc/lifecycle.md index 31530e75..c7aae383 100644 --- a/src/swarms/doc/lifecycle.md +++ b/src/swarms/doc/lifecycle.md @@ -27,16 +27,16 @@ stateDiagram-v2 ### State Transitions -| From | To | Function | Bond Effect | -| :------------ | :------ | :------------------------- | :---------------------------- | -| None | Owned | `claimUuid()` | Pull BASE_BOND | -| None | Local | `registerFleetLocal()` | Pull tierBond | -| None | Country | `registerFleetCountry()` | Pull tierBond | -| Owned | Local | `registerFleetLocal()` | Pull (tierBond - BASE_BOND) | -| Owned | Country | `registerFleetCountry()` | Pull (tierBond - BASE_BOND) | -| Local/Country | Owned | `unregisterToOwned()` | Refund (tierBond - BASE_BOND) | -| Owned | None | `releaseUuid()` / `burn()` | Refund BASE_BOND | -| Local/Country | None | `burn()` | Refund tierBond | +| From | To | Function | Bond Effect | +| :------------ | :------ | :------------------------- | :-------------------------------------------------- | +| None | Owned | `claimUuid()` | Pull BASE_BOND from owner | +| None | Local | `registerFleetLocal()` | Pull tierBond from owner (or split with operator) | +| None | Country | `registerFleetCountry()` | Pull tierBond from owner (or split with operator) | +| Owned | Local | `registerFleetLocal()` | Pull (tierBond - BASE_BOND) from owner/operator | +| Owned | Country | `registerFleetCountry()` | Pull (tierBond - BASE_BOND) from owner/operator | +| Local/Country | Owned | `unregisterToOwned()` | Refund (tierBond - BASE_BOND) to operator, clear op | +| Owned | None | `releaseUuid()` / `burn()` | Refund BASE_BOND to owner | +| Local/Country | None | `burn()` | Refund BASE_BOND to owner, tier excess to operator | ## Swarm Status States @@ -69,21 +69,29 @@ stateDiagram-v2 sequenceDiagram participant TOKEN as BOND_TOKEN participant FI as FleetIdentity + participant Owner + participant Operator - Note over FI: Registration - FI->>TOKEN: transferFrom(owner, this, tierBond) + Note over FI: Registration (with operator) + FI->>TOKEN: transferFrom(owner, this, BASE_BOND) + FI->>TOKEN: transferFrom(operator, this, tierExcess) - Note over FI: Promotion - FI->>TOKEN: transferFrom(owner, this, additionalBond) + Note over FI: Promotion (operator pays) + FI->>TOKEN: transferFrom(operator, this, additionalBond) - Note over FI: Demotion - FI->>TOKEN: transfer(owner, refund) + Note over FI: Demotion (operator receives) + FI->>TOKEN: transfer(operator, refund) + + Note over FI: Change Operator + FI->>TOKEN: transfer(oldOperator, tierExcess) + FI->>TOKEN: transferFrom(newOperator, this, tierExcess) Note over FI: Unregister to Owned - FI->>TOKEN: transfer(owner, tierBond - BASE_BOND) + FI->>TOKEN: transfer(operator, tierExcess) Note over FI: Burn - FI->>TOKEN: transfer(owner, fullBond) + FI->>TOKEN: transfer(owner, BASE_BOND) + FI->>TOKEN: transfer(operator, tierExcess) ``` ## Orphan Lifecycle diff --git a/src/swarms/doc/maintenance.md b/src/swarms/doc/maintenance.md index 1e1316ec..77a1b42d 100644 --- a/src/swarms/doc/maintenance.md +++ b/src/swarms/doc/maintenance.md @@ -2,12 +2,35 @@ ## Overview -After registration, fleet owners must monitor bundle inclusion as market conditions change: +After registration, fleet owners (or their designated operators) must monitor bundle inclusion as market conditions change: - New fleets registering at higher tiers - Existing fleets promoting - Bundle slots limited to 20 per location +## Operator Delegation + +Fleet tier management can be delegated to an **operator**: + +```solidity +// Owner delegates to operator (transfers tier bonds) +fleetIdentity.setOperator(uuid, operatorAddress); + +// Check who manages tiers +address manager = fleetIdentity.operatorOf(uuid); // returns operator or owner +``` + +**Bond responsibilities:** + +- **Owner**: Always holds BASE_BOND +- **Operator**: Holds tier excess (tierBond - BASE_BOND) + +**Permissions:** + +- **Promote/Demote**: Only operator (or owner if no operator set) +- **Burn**: Only token holder (ERC-721 ownerOf) +- **SetOperator**: Only UUID owner + ## Maintenance Cycle ```mermaid diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index c23e1a27..e0ed401f 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -66,7 +66,14 @@ contract FleetIdentityTest is Test { uint256 indexed tokenId, uint32 regionKey, uint256 tierIndex, - uint256 bondAmount + uint256 bondAmount, + address operator + ); + event OperatorSet( + bytes16 indexed uuid, + address indexed oldOperator, + address indexed newOperator, + uint256 tierExcessTransferred ); event FleetPromoted( uint256 indexed tokenId, uint256 indexed fromTier, uint256 indexed toTier, uint256 additionalBond @@ -445,12 +452,12 @@ contract FleetIdentityTest is Test { fleet.promote(tokenId); } - function test_RevertIf_promote_notOwner() public { + function test_RevertIf_promote_notOperator() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(bob); - vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + vm.expectRevert(FleetIdentity.NotOperator.selector); fleet.promote(tokenId); } @@ -535,12 +542,12 @@ contract FleetIdentityTest is Test { assertEq(fleet.regionTierCount(_regionUSCA()), 1); } - function test_RevertIf_reassignTier_demoteNotOwner() public { + function test_RevertIf_reassignTier_demoteNotOperator() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); vm.prank(bob); - vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + vm.expectRevert(FleetIdentity.NotOperator.selector); fleet.reassignTier(tokenId, 0); } @@ -555,12 +562,12 @@ contract FleetIdentityTest is Test { fleet.reassignTier(tokenId, 0); } - function test_RevertIf_reassignTier_promoteNotOwner() public { + function test_RevertIf_reassignTier_promoteNotOperator() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(bob); - vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + vm.expectRevert(FleetIdentity.NotOperator.selector); fleet.reassignTier(tokenId, 3); } @@ -902,10 +909,12 @@ contract FleetIdentityTest is Test { assertEq(fleet.fleetTier(tokenId), 2); assertEq(fleet.bonds(tokenId), fleet.tierBond(2, true)); - uint256 bobBefore = bondToken.balanceOf(bob); + // After transfer, bob holds the token but alice is still uuidOwner/operator. + // On burn, alice (as uuidOwner) gets BASE_BOND, and alice (as operator) gets tier excess. + uint256 aliceBefore = bondToken.balanceOf(alice); vm.prank(bob); fleet.burn(tokenId); - assertEq(bondToken.balanceOf(bob), bobBefore + fleet.tierBond(2, true)); + assertEq(bondToken.balanceOf(alice), aliceBefore + fleet.tierBond(2, true)); } // --- Tier lifecycle --- @@ -972,7 +981,7 @@ contract FleetIdentityTest is Test { assertEq(fleet.bonds(tokenId), BASE_BOND); } - function testFuzz_promote_onlyOwner(address caller) public { + function testFuzz_promote_onlyOperator(address caller) public { vm.assume(caller != alice); vm.assume(caller != address(0)); @@ -980,7 +989,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(caller); - vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + vm.expectRevert(FleetIdentity.NotOperator.selector); fleet.promote(tokenId); } @@ -3078,4 +3087,308 @@ contract FleetIdentityTest is Test { assertEq(deCount, 1, "Germany should have 1 fleet"); assertEq(deUuids[0], _uuid(2), "Should be Germany fleet"); } + + // ══════════════════════════════════════════════ + // Operator Tests + // ══════════════════════════════════════════════ + + function test_operatorOf_defaultsToUuidOwner() public { + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + // No operator set, should default to uuidOwner + assertEq(fleet.operatorOf(UUID_1), alice); + } + + function test_operatorOf_returnsSetOperator() public { + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + vm.prank(alice); + fleet.setOperator(UUID_1, bob); + + assertEq(fleet.operatorOf(UUID_1), bob); + } + + function test_setOperator_emitsEvent() public { + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + // Just verify setOperator succeeds and changes state + vm.prank(alice); + fleet.setOperator(UUID_1, bob); + assertEq(fleet.operatorOf(UUID_1), bob); + } + + function test_setOperator_transfersTierExcess() public { + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); + + uint256 tierExcess = fleet.tierBond(2, false) - BASE_BOND; + uint256 aliceBefore = bondToken.balanceOf(alice); + uint256 bobBefore = bondToken.balanceOf(bob); + + vm.prank(alice); + fleet.setOperator(UUID_1, bob); + + // Alice gets tier excess refunded, bob pays tier excess + assertEq(bondToken.balanceOf(alice), aliceBefore + tierExcess); + assertEq(bondToken.balanceOf(bob), bobBefore - tierExcess); + } + + function test_setOperator_multiRegion_transfersAllTierExcess() public { + // Register in two local regions at different tiers + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_NY, 1); + + uint256 tierExcessFirst = fleet.tierBond(2, false) - BASE_BOND; + uint256 tierExcessSecond = fleet.tierBond(1, false) - BASE_BOND; + uint256 totalTierExcess = tierExcessFirst + tierExcessSecond; + + uint256 aliceBefore = bondToken.balanceOf(alice); + uint256 bobBefore = bondToken.balanceOf(bob); + + vm.prank(alice); + fleet.setOperator(UUID_1, bob); + + assertEq(bondToken.balanceOf(alice), aliceBefore + totalTierExcess); + assertEq(bondToken.balanceOf(bob), bobBefore - totalTierExcess); + } + + function test_setOperator_zeroTierExcess_noTransfer() public { + // Register at tier 0, tierExcess = 0 + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + uint256 aliceBefore = bondToken.balanceOf(alice); + uint256 bobBefore = bondToken.balanceOf(bob); + + vm.prank(alice); + fleet.setOperator(UUID_1, bob); + + // No tier excess, no transfer + assertEq(bondToken.balanceOf(alice), aliceBefore); + assertEq(bondToken.balanceOf(bob), bobBefore); + } + + function test_setOperator_changeOperator_transfersBetweenOperators() public { + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); + + // Set bob as operator + vm.prank(alice); + fleet.setOperator(UUID_1, bob); + + uint256 tierExcess = fleet.tierBond(2, false) - BASE_BOND; + uint256 bobBefore = bondToken.balanceOf(bob); + uint256 carolBefore = bondToken.balanceOf(carol); + + // Change operator from bob to carol + vm.prank(alice); + fleet.setOperator(UUID_1, carol); + + assertEq(bondToken.balanceOf(bob), bobBefore + tierExcess); + assertEq(bondToken.balanceOf(carol), carolBefore - tierExcess); + } + + function test_setOperator_clearOperator_refundsToOwner() public { + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); + + vm.prank(alice); + fleet.setOperator(UUID_1, bob); + + uint256 tierExcess = fleet.tierBond(2, false) - BASE_BOND; + uint256 aliceBefore = bondToken.balanceOf(alice); + uint256 bobBefore = bondToken.balanceOf(bob); + + // Clear operator (set to address(0)) + vm.prank(alice); + fleet.setOperator(UUID_1, address(0)); + + assertEq(bondToken.balanceOf(bob), bobBefore + tierExcess); + assertEq(bondToken.balanceOf(alice), aliceBefore - tierExcess); + assertEq(fleet.operatorOf(UUID_1), alice); // defaults to owner again + } + + function test_RevertIf_setOperator_notUuidOwner() public { + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.NotUuidOwner.selector); + fleet.setOperator(UUID_1, carol); + } + + function test_RevertIf_setOperator_ownedOnly() public { + vm.prank(alice); + fleet.claimUuid(UUID_1); + + vm.prank(alice); + vm.expectRevert(FleetIdentity.OperatorNotAllowedForOwnedOnly.selector); + fleet.setOperator(UUID_1, bob); + } + + function test_registerWithOperator_country() public { + uint256 aliceBefore = bondToken.balanceOf(alice); + uint256 bobBefore = bondToken.balanceOf(bob); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleetCountryWithOperator(UUID_1, US, 2, bob); + + // Alice pays BASE_BOND, bob pays tier excess + uint256 tierExcess = fleet.tierBond(2, true) - BASE_BOND; + assertEq(bondToken.balanceOf(alice), aliceBefore - BASE_BOND); + assertEq(bondToken.balanceOf(bob), bobBefore - tierExcess); + assertEq(fleet.operatorOf(UUID_1), bob); + assertEq(fleet.uuidOwner(UUID_1), alice); + } + + function test_registerWithOperator_local() public { + uint256 aliceBefore = bondToken.balanceOf(alice); + uint256 bobBefore = bondToken.balanceOf(bob); + + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocalWithOperator(UUID_1, US, ADMIN_CA, 2, bob); + + uint256 tierExcess = fleet.tierBond(2, false) - BASE_BOND; + assertEq(bondToken.balanceOf(alice), aliceBefore - BASE_BOND); + assertEq(bondToken.balanceOf(bob), bobBefore - tierExcess); + assertEq(fleet.operatorOf(UUID_1), bob); + } + + function test_registerWithOperator_emitsEventWithOperator() public { + // Verify registration with operator sets up correctly + vm.prank(alice); + uint256 tokenId = fleet.registerFleetCountryWithOperator(UUID_1, US, 0, bob); + + assertEq(fleet.ownerOf(tokenId), alice); + assertEq(fleet.operatorOf(UUID_1), bob); + assertEq(fleet.uuidOwner(UUID_1), alice); + } + + function test_operatorCanPromote() public { + vm.prank(alice); + fleet.registerFleetLocalWithOperator(UUID_1, US, ADMIN_CA, 0, bob); + + uint256 bobBefore = bondToken.balanceOf(bob); + uint256 tokenId = _tokenId(UUID_1, _makeAdminRegion(US, ADMIN_CA)); + + vm.prank(bob); + fleet.promote(tokenId); + + assertEq(fleet.fleetTier(tokenId), 1); + // Bob paid the tier difference + uint256 tierDiff = fleet.tierBond(1, false) - fleet.tierBond(0, false); + assertEq(bondToken.balanceOf(bob), bobBefore - tierDiff); + } + + function test_operatorCanDemote() public { + vm.prank(alice); + fleet.registerFleetLocalWithOperator(UUID_1, US, ADMIN_CA, 2, bob); + + uint256 bobBefore = bondToken.balanceOf(bob); + uint256 tokenId = _tokenId(UUID_1, _makeAdminRegion(US, ADMIN_CA)); + + vm.prank(bob); + fleet.reassignTier(tokenId, 0); + + assertEq(fleet.fleetTier(tokenId), 0); + // Bob gets tier difference refunded + uint256 tierDiff = fleet.tierBond(2, false) - fleet.tierBond(0, false); + assertEq(bondToken.balanceOf(bob), bobBefore + tierDiff); + } + + function test_RevertIf_ownerCannotPromoteWhenOperatorSet() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocalWithOperator(UUID_1, US, ADMIN_CA, 0, bob); + + vm.prank(alice); + vm.expectRevert(FleetIdentity.NotOperator.selector); + fleet.promote(tokenId); + } + + function test_ownerCanBurnEvenWithOperator() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocalWithOperator(UUID_1, US, ADMIN_CA, 2, bob); + + uint256 aliceBefore = bondToken.balanceOf(alice); + uint256 bobBefore = bondToken.balanceOf(bob); + + vm.prank(alice); + fleet.burn(tokenId); + + // Alice gets BASE_BOND, bob gets tier excess + assertEq(bondToken.balanceOf(alice), aliceBefore + BASE_BOND); + uint256 tierExcess = fleet.tierBond(2, false) - BASE_BOND; + assertEq(bondToken.balanceOf(bob), bobBefore + tierExcess); + } + + function test_RevertIf_operatorCannotBurn() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocalWithOperator(UUID_1, US, ADMIN_CA, 0, bob); + + vm.prank(bob); + vm.expectRevert(FleetIdentity.NotTokenOwner.selector); + fleet.burn(tokenId); + } + + function test_unregisterToOwned_refundsOperatorAndClearsOperator() public { + vm.prank(alice); + uint256 tokenId = fleet.registerFleetLocalWithOperator(UUID_1, US, ADMIN_CA, 2, bob); + + uint256 bobBefore = bondToken.balanceOf(bob); + + vm.prank(alice); + fleet.unregisterToOwned(tokenId); + + // Bob gets tier excess refunded + uint256 tierExcess = fleet.tierBond(2, false) - BASE_BOND; + assertEq(bondToken.balanceOf(bob), bobBefore + tierExcess); + // Operator is now cleared (defaults to owner) + assertEq(fleet.operatorOf(UUID_1), alice); + } + + function test_registerFromOwned_preservesOperator() public { + // Alice claims UUID with no operator + vm.prank(alice); + fleet.claimUuid(UUID_1); + + // She registers to local (which removes owned-only token and creates registered token) + vm.prank(alice); + uint256 registeredToken = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + assertEq(fleet.operatorOf(UUID_1), alice); + + // Now alice can set an operator + vm.prank(alice); + fleet.setOperator(UUID_1, bob); + assertEq(fleet.operatorOf(UUID_1), bob); + } + + function testFuzz_setOperator_tierExcessCalculation(uint8 tier1, uint8 tier2) public { + tier1 = uint8(bound(tier1, 0, 7)); + tier2 = uint8(bound(tier2, 0, 7)); + + // Register in two local regions + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, tier1); + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_NY, tier2); + + uint256 expectedTierExcess = + (fleet.tierBond(tier1, false) - BASE_BOND) + + (fleet.tierBond(tier2, false) - BASE_BOND); + + uint256 aliceBefore = bondToken.balanceOf(alice); + uint256 bobBefore = bondToken.balanceOf(bob); + + vm.prank(alice); + fleet.setOperator(UUID_1, bob); + + assertEq(bondToken.balanceOf(alice), aliceBefore + expectedTierExcess); + assertEq(bondToken.balanceOf(bob), bobBefore - expectedTierExcess); + } }