From 6fb178b72c8a9d02837b0d8e6c42604abe773b4c Mon Sep 17 00:00:00 2001 From: vonMangoldt <61846574+vonMangoldt@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:54:45 -0400 Subject: [PATCH] Add QueueForecastFacet and InterestAdminFacet Two deploy-slim facets for post-launch selector registration. Instead of inheriting the full declaration chain they mirror only the storage slots they touch, padded to the exact positions of the deployed diamond layout (23.2KB to 2.7KB and 13.6KB to 0.9KB deploy bytecode). QueueForecastFacet.solveForAmountAfterFulfill(x, y) quotes a solve for y against the queue as it would look after a solver-optimal fill of x, and additionally returns the per-order amounts and the partial take, which a UI cannot read from chain state that does not exist yet. Verified by a differential fuzz: the forecast quoted before equals solveForAmount(y) quoted after a real fulfillOrderBulk(x) executed. InterestAdminFacet.setCashedInterest(user, amount) is the master-only setter for the settled interest bucket (lost-key rescues, grants, write-offs). It keeps the totalCashedInterest accumulator in lockstep with the delta in both directions, touches only the settled bucket, and sits behind the same supplyChangeByOwnerNotAllowed latch as mintSupply and burnSupply. check_storage_layout.sh gains a pinned-subset phase: every non-filler entry of a slim facet must match the committed layout snapshot by label, slot, offset and type, so pinning drift fails CI. Both facets are live and registered on all nine vaults via the timelocked selector proposals. --- script/check_storage_layout.sh | 45 +- .../WiseTelecomNodesDiamondSelectors.sol | 24 +- .../vault/WiseTelecomNodesDiamondEvents.sol | 6 + .../vault/facets/InterestAdminFacet.sol | 123 +++++ .../vault/facets/QueueForecastFacet.sol | 238 +++++++++ .../WiseTelecomNodesInterestAdminFacet.t.sol | 422 +++++++++++++++ .../WiseTelecomNodesQueueForecastFacet.t.sol | 484 ++++++++++++++++++ 7 files changed, 1340 insertions(+), 2 deletions(-) create mode 100644 src/diamond/vault/facets/InterestAdminFacet.sol create mode 100644 src/diamond/vault/facets/QueueForecastFacet.sol create mode 100644 test/diamond/WiseTelecomNodesInterestAdminFacet.t.sol create mode 100644 test/diamond/WiseTelecomNodesQueueForecastFacet.t.sol diff --git a/script/check_storage_layout.sh b/script/check_storage_layout.sh index e5b9f6f..5eab36a 100755 --- a/script/check_storage_layout.sh +++ b/script/check_storage_layout.sh @@ -77,7 +77,50 @@ if ! diff -u "$tmp/snapshot.txt" "$tmp/diamond.txt" > "$tmp/snapshot.diff"; then fail=1 fi +# Deploy-slim facets mirror only the slots they touch, padded into place +# with __gap*/__pad* fillers. Every pinned (non-filler) entry must sit at +# exactly the slot/offset/type the diamond assigns to the same label, or +# the facet reads garbage through the delegatecall. +PINNED_FACETS=( + InterestAdminFacet + QueueForecastFacet +) + +for name in "${PINNED_FACETS[@]}"; do + forge inspect "src/diamond/vault/facets/$name.sol:$name" storage-layout --json \ + | normalize > "$tmp/$name.pinned.txt" + if ! python3 -c ' +import json, sys +diamond = {} +for line in open(sys.argv[1]): + e = json.loads(line) + diamond[e["label"]] = (e["slot"], e["offset"], e["type"]) +bad = 0 +pinned = 0 +for line in open(sys.argv[2]): + e = json.loads(line) + if e["label"].startswith("__gap") or e["label"].startswith("__pad"): + continue + pinned += 1 + want = diamond.get(e["label"]) + got = (e["slot"], e["offset"], e["type"]) + if want is None: + print(f" {e['\''label'\'']}: not present in the diamond layout") + bad = 1 + elif want != got: + print(f" {e['\''label'\'']}: facet has slot={got[0]} offset={got[1]} type={got[2]}, diamond has slot={want[0]} offset={want[1]} type={want[2]}") + bad = 1 +if pinned == 0: + print(" no pinned entries found (unexpected)") + bad = 1 +sys.exit(bad) +' "$tmp/diamond.txt" "$tmp/$name.pinned.txt"; then + echo "PINNED SLOT MISMATCH: $name does not line up with the diamond layout." + fail=1 + fi +done + if [ "$fail" -eq 0 ]; then - echo "storage layout OK: diamond + ${#FACETS[@]} facets identical, snapshot matches ($(wc -l < "$tmp/diamond.txt") entries)" + echo "storage layout OK: diamond + ${#FACETS[@]} facets identical, ${#PINNED_FACETS[@]} slim facets pinned correctly, snapshot matches ($(wc -l < "$tmp/diamond.txt") entries)" fi exit "$fail" diff --git a/script/diamond/WiseTelecomNodesDiamondSelectors.sol b/script/diamond/WiseTelecomNodesDiamondSelectors.sol index 7c17a8b..64d7eec 100644 --- a/script/diamond/WiseTelecomNodesDiamondSelectors.sol +++ b/script/diamond/WiseTelecomNodesDiamondSelectors.sol @@ -15,6 +15,8 @@ import {MulticallFacet} from "../../src/diamond/vault/facets/MulticallFacet.sol" import {QueueAdminFacet} from "../../src/diamond/vault/facets/QueueAdminFacet.sol"; import {QueueJoinLeaveFacet} from "../../src/diamond/vault/facets/QueueJoinLeaveFacet.sol"; import {QueueFulfillFacet} from "../../src/diamond/vault/facets/QueueFulfillFacet.sol"; +import {QueueForecastFacet} from "../../src/diamond/vault/facets/QueueForecastFacet.sol"; +import {InterestAdminFacet} from "../../src/diamond/vault/facets/InterestAdminFacet.sol"; import {WiseTelecomNodesQueueUIHelper} from "../../src/diamond/vault/helpers/WiseTelecomNodesQueueUIHelper.sol"; import {WiseTelecomNodesQueueHelper} from "../../src/diamond/vault/helpers/WiseTelecomNodesQueueHelper.sol"; @@ -26,7 +28,9 @@ import {WiseTelecomNodesQueueHelper} from "../../src/diamond/vault/helpers/WiseT * Counts: admin=27, proxy=3, user=8, sweep=2, cashedInterest=1, * burnWise=3, move=7, bridge=14, permit2=3, multicall=1, * queueAdmin=2, queueJoinLeave=5, queueFulfill=4, queueView=10 — - * total 90. + * total 90. Post-launch additions (registered via the timelocked + * selector proposals, not part of the genesis 90): queueForecast=1, + * interestAdmin=1. */ library WiseTelecomNodesDiamondSelectors { @@ -111,6 +115,24 @@ library WiseTelecomNodesDiamondSelectors { sels[0] = CashedInterestFacet.getTotalCashedInterest.selector; } + function queueForecastSelectors() + internal + pure + returns (bytes4[] memory sels) + { + sels = new bytes4[](1); + sels[0] = QueueForecastFacet.solveForAmountAfterFulfill.selector; + } + + function interestAdminSelectors() + internal + pure + returns (bytes4[] memory sels) + { + sels = new bytes4[](1); + sels[0] = InterestAdminFacet.setCashedInterest.selector; + } + function burnWiseSelectors() internal pure diff --git a/src/diamond/vault/WiseTelecomNodesDiamondEvents.sol b/src/diamond/vault/WiseTelecomNodesDiamondEvents.sol index c92baa3..72e0b8f 100644 --- a/src/diamond/vault/WiseTelecomNodesDiamondEvents.sol +++ b/src/diamond/vault/WiseTelecomNodesDiamondEvents.sol @@ -194,6 +194,12 @@ abstract contract WiseTelecomNodesDiamondEvents { uint256 totalCashedInterest ); + event CashedInterestSet( + address indexed user, + uint256 previousAmount, + uint256 newAmount + ); + event SweeperSet( address indexed sweeper, bool allowed diff --git a/src/diamond/vault/facets/InterestAdminFacet.sol b/src/diamond/vault/facets/InterestAdminFacet.sol new file mode 100644 index 0000000..0ac7579 --- /dev/null +++ b/src/diamond/vault/facets/InterestAdminFacet.sol @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: -- WISE -- + +pragma solidity =0.8.36; + +import {WiseTelecomNodesDiamondErrors} from "../WiseTelecomNodesDiamondErrors.sol"; +import {WiseTelecomNodesDiamondEvents} from "../WiseTelecomNodesDiamondEvents.sol"; + +import {NotMaster} from "../../shared/OwnableMaster.sol"; +import {OnlyDelegateCall} from "../../shared/DiamondErrors.sol"; + +/** + * @dev Master-only setter for a wallet's settled interest bucket, + * for positions whose owner can no longer sign (lost keys) and for + * deliberate grants or write-offs. Writes `cashedInterest[user]` + * directly and keeps the `totalCashedInterest` accumulator in + * lockstep with the delta, exactly like every organic write site, + * so the INT-7 sum law and the sweep-buffer reservation hold in + * both directions. + * + * The setter touches ONLY the settled bucket: pending accrual is + * not banked first and keeps accruing on top for a wallet that + * still holds shares. A wallet-to-wallet rescue is two calls, read + * the source bucket, set it to zero, set the destination to the + * read value; the accumulator nets out unchanged. + * + * Gated by the same one-way `supplyChangeByOwnerNotAllowed` latch + * as `mintSupply`/`burnSupply`: throwing the latch renounces every + * master balance-surgery power, this one included. No reentrancy + * guard: the function makes no external calls. + * + * DEPLOY-SLIM STORAGE MIRROR: instead of inheriting the full + * declaration chain, only the four slots this facet touches are + * pinned, padded to their exact positions in the deployed diamond + * layout (master 0, supplyChangeByOwnerNotAllowed 12/20, + * cashedInterest 15, totalCashedInterest 60). The pinned entries + * are asserted label-for-label against the diamond's committed + * layout snapshot by script/check_storage_layout.sh, so any drift + * fails CI before it can ship. + */ +contract InterestAdminFacet is + WiseTelecomNodesDiamondErrors, + WiseTelecomNodesDiamondEvents +{ + + address internal master; + + uint256[11] private __gap1; + + address private __pad12; + bool internal supplyChangeByOwnerNotAllowed; + + uint256[2] private __gap13; + + mapping(address => uint256) internal cashedInterest; + + uint256[44] private __gap16; + + uint256 internal totalCashedInterest; + + address internal immutable _self; + + constructor() { + _self = address(this); + } + + modifier onlyDelegateCall() { + require( + address(this) != _self, + OnlyDelegateCall() + ); + _; + } + + modifier onlyMaster() { + require( + msg.sender == master, + NotMaster() + ); + _; + } + + modifier supplyChangeAllowed() { + require( + supplyChangeByOwnerNotAllowed == false, + SupplyChangeNotAllowed() + ); + _; + } + + function setCashedInterest( + address _user, + uint256 _amount + ) + external + onlyDelegateCall + onlyMaster + supplyChangeAllowed + { + require( + _user != address(0) + && _user != address(this), + InvalidValue() + ); + + uint256 previous = cashedInterest[_user]; + + cashedInterest[_user] = _amount; + + totalCashedInterest = totalCashedInterest + + _amount + - previous; + + emit CashedInterestSet( + _user, + previous, + _amount + ); + + emit TotalCashedInterestChanged( + totalCashedInterest + ); + } +} diff --git a/src/diamond/vault/facets/QueueForecastFacet.sol b/src/diamond/vault/facets/QueueForecastFacet.sol new file mode 100644 index 0000000..ccdf0ab --- /dev/null +++ b/src/diamond/vault/facets/QueueForecastFacet.sol @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: -- WISE -- + +pragma solidity =0.8.36; + +import {WiseTelecomNodesQueueStructs} from "../WiseTelecomNodesQueueStructs.sol"; + +struct ForecastVars { + uint256 priorLeft; + uint256 amountLeft; + uint256 orderIndex; + bool hasPartial; + uint256 partialId; + int256 partialIncentive; + uint256 partialAmount; + int256[] tempIncentives; + uint256[] tempOrders; + uint256[] tempAmounts; +} + +/** + * @dev Read-only queue forecasting on its own facet. Quotes a solve + * against the queue state as it WOULD look after a solver-optimal + * fill of a prior amount, so a UI can price a second fill while a + * first is still pending (or its own earlier leg in a batch). + * + * A solver-optimal fill always consumes a best-tier-first prefix of + * the queue and stops inside at most one order, so the simulated + * post-fill state is the current state with the walk cursor advanced + * past that prefix and possibly one order reduced in place. The + * forecast therefore runs one combined traversal per lane, in the + * exact tier and list order `_solveForAmount` uses: the prior amount + * is consumed silently first, then the quoted amount is recorded + * from wherever the prior fill stopped, against the reduced + * remainder where they meet inside one order. + * + * Unlike `solveForAmount`, the full-order amounts and the partial + * take are returned as well: the UI cannot read them from chain + * state because the state being quoted does not exist yet. + * + * DEPLOY-SLIM STORAGE MIRROR: instead of inheriting the full + * declaration chain, only the four slots this view reads are + * pinned, padded to their exact positions in the deployed diamond + * layout (QueMemberByIdAndIncentive 27, earliestValid 28, + * currentOrderId 29, totalActiveOrders 32). The pinned entries are + * asserted label-for-label against the diamond's committed layout + * snapshot by script/check_storage_layout.sh, so any drift fails + * CI before it can ship. The tier arrays are verbatim copies of + * the low-level helper's; the shared struct source guarantees the + * QueMember field order. + */ +contract QueueForecastFacet is WiseTelecomNodesQueueStructs { + + uint256[27] private __gap0; + + mapping(uint256 => mapping(int256 => QueMember)) internal QueMemberByIdAndIncentive; + mapping(int256 => uint256) internal earliestValidQueMemberByIncentive; + mapping(int256 => uint256) internal currentOrderIdByIncentive; + + uint256[2] private __gap30; + + uint256 internal totalActiveOrders; + + function solveForAmountAfterFulfill( + uint256 _priorAmount, + uint256 _amount + ) + public + view + returns ( + int256[] memory incentives, + uint256[] memory orders, + uint256[] memory orderAmounts, + uint256[] memory partials, + uint256 partialAmount + ) + { + ForecastVars memory v = _initializeForecastVars( + _priorAmount, + _amount + ); + + int16[9] memory pos = _positiveIncentives(); + + for (uint256 i; i < pos.length && v.amountLeft > 0; ++i) { + _forecastLane( + v, + pos[i] + ); + } + + if (v.amountLeft > 0) { + int16[8] memory neg = _negativeIncentives(); + + for (uint256 j; j < neg.length && v.amountLeft > 0; ++j) { + _forecastLane( + v, + neg[j] + ); + } + } + + return _buildForecastResults( + v + ); + } + + function _initializeForecastVars( + uint256 _priorAmount, + uint256 _amount + ) + internal + view + returns (ForecastVars memory v) + { + v.priorLeft = _priorAmount; + v.amountLeft = _amount; + v.tempIncentives = new int256[](totalActiveOrders); + v.tempOrders = new uint256[](totalActiveOrders); + v.tempAmounts = new uint256[](totalActiveOrders); + } + + function _forecastLane( + ForecastVars memory _v, + int256 _incentive + ) + internal + view + { + uint256 cursor = currentOrderIdByIncentive[_incentive]; + uint256 laneEnd = earliestValidQueMemberByIncentive[_incentive]; + + while (cursor < laneEnd && _v.amountLeft > 0) { + + QueMember storage entry = QueMemberByIdAndIncentive[cursor][_incentive]; + uint256 available = entry.amount; + + if (available == 0) { + cursor = entry.headPointer; + continue; + } + + if (_v.priorLeft >= available) { + _v.priorLeft -= available; + cursor = entry.headPointer; + continue; + } + + available -= _v.priorLeft; + _v.priorLeft = 0; + + if (_v.amountLeft >= available) { + _v.tempIncentives[_v.orderIndex] = _incentive; + _v.tempOrders[_v.orderIndex] = cursor; + _v.tempAmounts[_v.orderIndex] = available; + _v.orderIndex++; + _v.amountLeft -= available; + cursor = entry.headPointer; + continue; + } + + _v.hasPartial = true; + _v.partialId = cursor; + _v.partialIncentive = _incentive; + _v.partialAmount = _v.amountLeft; + _v.amountLeft = 0; + } + } + + function _buildForecastResults( + ForecastVars memory _v + ) + internal + pure + returns ( + int256[] memory incentives, + uint256[] memory orders, + uint256[] memory orderAmounts, + uint256[] memory partials, + uint256 partialAmount + ) + { + uint256 partialCount = _v.hasPartial + ? 1 + : 0; + + incentives = new int256[](_v.orderIndex + partialCount); + orders = new uint256[](_v.orderIndex); + orderAmounts = new uint256[](_v.orderIndex); + partials = new uint256[](partialCount); + + for (uint256 k; k < _v.orderIndex; k++) { + incentives[k] = _v.tempIncentives[k]; + orders[k] = _v.tempOrders[k]; + orderAmounts[k] = _v.tempAmounts[k]; + } + + if (_v.hasPartial) { + incentives[_v.orderIndex] = _v.partialIncentive; + partials[0] = _v.partialId; + partialAmount = _v.partialAmount; + } + } + + function _positiveIncentives() + internal + pure + returns (int16[9] memory) + { + return [ + int16(5000), + int16(2500), + int16(1500), + int16(1000), + int16(500), + int16(300), + int16(200), + int16(100), + int16(0) + ]; + } + + function _negativeIncentives() + internal + pure + returns (int16[8] memory) + { + return [ + int16(-100), + int16(-200), + int16(-300), + int16(-500), + int16(-1000), + int16(-1500), + int16(-2500), + int16(-5000) + ]; + } +} diff --git a/test/diamond/WiseTelecomNodesInterestAdminFacet.t.sol b/test/diamond/WiseTelecomNodesInterestAdminFacet.t.sol new file mode 100644 index 0000000..ecea647 --- /dev/null +++ b/test/diamond/WiseTelecomNodesInterestAdminFacet.t.sol @@ -0,0 +1,422 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity =0.8.36; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {DiamondTestHarness} from "./utils/DiamondTestHarness.sol"; + +import {WiseTelecomNodesDiamond} from "../../src/diamond/vault/WiseTelecomNodesDiamond.sol"; +import {AdminFacet} from "../../src/diamond/vault/facets/AdminFacet.sol"; +import {UserFacet} from "../../src/diamond/vault/facets/UserFacet.sol"; +import {CashedInterestFacet} from "../../src/diamond/vault/facets/CashedInterestFacet.sol"; +import {InterestAdminFacet} from "../../src/diamond/vault/facets/InterestAdminFacet.sol"; + +import {WiseTelecomNodesDiamondErrors} from "../../src/diamond/vault/WiseTelecomNodesDiamondErrors.sol"; +import {NotMaster} from "../../src/diamond/shared/OwnableMaster.sol"; + +import {WiseTelecomNodesDiamondSelectors} from "../../script/diamond/WiseTelecomNodesDiamondSelectors.sol"; + +contract MockUSD is ERC20 { + + constructor() + ERC20("Mock USD", "MUSD") + {} + + function decimals() + public + pure + override + returns (uint8) + { + return 6; + } + + function mint( + address _to, + uint256 _amount + ) + external + { + _mint( + _to, + _amount + ); + } +} + +/** + * @dev Exercises {InterestAdminFacet}. The load-bearing properties: + * every set keeps `totalCashedInterest` in lockstep with the delta + * in both directions (INT-7 and the sweep-buffer reservation), the + * two-call rescue flow nets the accumulator to zero, a granted + * bucket is genuinely claimable, the setter touches only the + * settled bucket (pending accrual survives a zero-out), and the + * master/latch/address gates reject. + */ +contract WiseTelecomNodesInterestAdminFacetTest is DiamondTestHarness { + + event CashedInterestSet( + address indexed user, + uint256 previousAmount, + uint256 newAmount + ); + + uint256 internal constant PRINCIPAL = 10_000 * 1e6; + uint256 internal constant SECONDS_IN_YEAR = 31_540_000; + + address internal lostWallet = address(0xA1); + address internal newWallet = address(0xA2); + address internal stranger = address(0xBEEF); + + WiseTelecomNodesDiamond internal diamond; + MockUSD internal usd; + + function setUp() + public + { + vm.warp( + 1_700_000_000 + ); + + usd = new MockUSD(); + + diamond = _newDiamond( + address(usd) + ); + + _wireAllFacets( + diamond + ); + + _wireOne( + diamond, + address(new InterestAdminFacet()), + WiseTelecomNodesDiamondSelectors.interestAdminSelectors() + ); + + diamond.finalizeSetup(); + + AdminFacet(address(diamond)).mintSupply( + lostWallet, + PRINCIPAL + ); + + vm.warp( + block.timestamp + SECONDS_IN_YEAR + ); + + usd.mint( + address(diamond), + 1_000_000 * 1e6 + ); + } + + function _set( + address _user, + uint256 _amount + ) + internal + { + InterestAdminFacet(address(diamond)).setCashedInterest( + _user, + _amount + ); + } + + function _total() + internal + view + returns (uint256) + { + return CashedInterestFacet(address(diamond)).getTotalCashedInterest(); + } + + function _bankPendingOf( + address _user + ) + internal + { + vm.prank( + _user + ); + + diamond.transfer( + _user, + 1 + ); + } + + function test_set_increase_movesAccumulatorUp() + public + { + uint256 totalBefore = _total(); + + _set( + newWallet, + 500 * 1e6 + ); + + assertEq( + diamond.cashedInterest(newWallet), + 500 * 1e6 + ); + + assertEq( + _total(), + totalBefore + 500 * 1e6 + ); + } + + function test_set_decrease_movesAccumulatorDown() + public + { + _set( + newWallet, + 500 * 1e6 + ); + + uint256 totalBefore = _total(); + + _set( + newWallet, + 200 * 1e6 + ); + + assertEq( + diamond.cashedInterest(newWallet), + 200 * 1e6 + ); + + assertEq( + _total(), + totalBefore - 300 * 1e6 + ); + } + + function test_rescueFlow_netsAccumulatorToZero() + public + { + _bankPendingOf( + lostWallet + ); + + uint256 frozen = diamond.cashedInterest( + lostWallet + ); + + assertGt( + frozen, + 0 + ); + + uint256 totalBefore = _total(); + + _set( + lostWallet, + 0 + ); + + _set( + newWallet, + frozen + ); + + assertEq( + diamond.cashedInterest(lostWallet), + 0 + ); + + assertEq( + diamond.cashedInterest(newWallet), + frozen + ); + + assertEq( + _total(), + totalBefore, + "two-call rescue must net the accumulator to zero" + ); + } + + function test_set_grantIsClaimable() + public + { + _set( + newWallet, + 777 * 1e6 + ); + + vm.prank( + newWallet + ); + + uint256 paid = UserFacet(address(diamond)).claimInterest(); + + assertEq( + paid, + 777 * 1e6 + ); + + assertEq( + usd.balanceOf(newWallet), + 777 * 1e6 + ); + } + + function test_set_touchesOnlySettledBucket() + public + { + uint256 pendingBefore = diamond.getTotalInterestUser( + lostWallet + ); + + assertGt( + pendingBefore, + 0 + ); + + _set( + lostWallet, + 0 + ); + + assertEq( + diamond.cashedInterest(lostWallet), + 0 + ); + + assertEq( + diamond.getTotalInterestUser(lostWallet), + pendingBefore, + "unbanked pending accrual must survive a zero-out" + ); + } + + function test_set_emitsBothEvents() + public + { + _set( + newWallet, + 100 * 1e6 + ); + + vm.expectEmit( + true, + false, + false, + true + ); + + emit CashedInterestSet( + newWallet, + 100 * 1e6, + 40 * 1e6 + ); + + _set( + newWallet, + 40 * 1e6 + ); + } + + function test_set_zeroOrDiamondAddress_reverts() + public + { + vm.expectRevert( + WiseTelecomNodesDiamondErrors.InvalidValue.selector + ); + + _set( + address(0), + 1 + ); + + vm.expectRevert( + WiseTelecomNodesDiamondErrors.InvalidValue.selector + ); + + _set( + address(diamond), + 1 + ); + } + + function test_set_nonMaster_reverts() + public + { + vm.prank( + stranger + ); + + vm.expectRevert( + NotMaster.selector + ); + + _set( + newWallet, + 1 + ); + } + + function test_set_latchThrown_reverts() + public + { + AdminFacet(address(diamond)).disAllowSupplyChangeByOwner(); + + vm.expectRevert( + WiseTelecomNodesDiamondErrors.SupplyChangeNotAllowed.selector + ); + + _set( + newWallet, + 1 + ); + } + + function testFuzz_set_lockstepBothDirections( + uint256 _first, + uint256 _second + ) + public + { + _first = bound( + _first, + 0, + 1_000_000 * 1e6 + ); + + _second = bound( + _second, + 0, + 1_000_000 * 1e6 + ); + + uint256 totalBefore = _total(); + + _set( + newWallet, + _first + ); + + assertEq( + _total(), + totalBefore + _first + ); + + _set( + newWallet, + _second + ); + + assertEq( + _total(), + totalBefore + _second + ); + + assertEq( + diamond.cashedInterest(newWallet), + _second + ); + } +} diff --git a/test/diamond/WiseTelecomNodesQueueForecastFacet.t.sol b/test/diamond/WiseTelecomNodesQueueForecastFacet.t.sol new file mode 100644 index 0000000..8fd9315 --- /dev/null +++ b/test/diamond/WiseTelecomNodesQueueForecastFacet.t.sol @@ -0,0 +1,484 @@ +// SPDX-License-Identifier: UNLICENSED + +pragma solidity =0.8.36; + +import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +import {DiamondTestHarness} from "./utils/DiamondTestHarness.sol"; + +import {WiseTelecomNodesDiamond} from "../../src/diamond/vault/WiseTelecomNodesDiamond.sol"; +import {AdminFacet} from "../../src/diamond/vault/facets/AdminFacet.sol"; +import {QueueJoinLeaveFacet} from "../../src/diamond/vault/facets/QueueJoinLeaveFacet.sol"; +import {QueueFulfillFacet} from "../../src/diamond/vault/facets/QueueFulfillFacet.sol"; +import {QueueViewFacet} from "../../src/diamond/vault/facets/QueueViewFacet.sol"; +import {QueueForecastFacet} from "../../src/diamond/vault/facets/QueueForecastFacet.sol"; + +import {WiseTelecomNodesDiamondSelectors} from "../../script/diamond/WiseTelecomNodesDiamondSelectors.sol"; + +contract MockUSD is ERC20 { + + constructor() + ERC20("Mock USD", "MUSD") + {} + + function decimals() + public + pure + override + returns (uint8) + { + return 6; + } + + function mint( + address _to, + uint256 _amount + ) + external + { + _mint( + _to, + _amount + ); + } +} + +/** + * @dev Exercises {QueueForecastFacet}. The load-bearing property is + * differential: `solveForAmountAfterFulfill(x, y)` quoted BEFORE any + * fill must equal `solveForAmount(y)` quoted AFTER a real + * solver-optimal fill of x has executed, including the full-order + * amounts and the partial take the plain solver does not report. + * Fuzzed across fill sizes that land on order boundaries, inside + * orders, across lanes, and beyond the whole book. + */ +contract WiseTelecomNodesQueueForecastFacetTest is DiamondTestHarness { + + address internal seller1 = address(0xA1); + address internal seller2 = address(0xA2); + address internal seller3 = address(0xA3); + address internal filler = address(0xF1); + + WiseTelecomNodesDiamond internal diamond; + MockUSD internal usd; + + uint256 internal totalQueued; + + function setUp() + public + { + vm.warp( + 1_700_000_000 + ); + + usd = new MockUSD(); + + diamond = _newDiamond( + address(usd) + ); + + _wireAllFacets( + diamond + ); + + _wireQueueFacets( + diamond + ); + + _wireOne( + diamond, + address(new QueueForecastFacet()), + WiseTelecomNodesDiamondSelectors.queueForecastSelectors() + ); + + diamond.finalizeSetup(); + + _seedOrder(seller1, 300 * 1e6, 500); + _seedOrder(seller2, 200 * 1e6, 500); + _seedOrder(seller1, 150 * 1e6, 100); + _seedOrder(seller3, 450 * 1e6, 100); + _seedOrder(seller2, 120 * 1e6, 0); + _seedOrder(seller3, 80 * 1e6, 0); + + totalQueued = 1_300 * 1e6; + } + + function _seedOrder( + address _seller, + uint256 _amount, + int256 _incentive + ) + internal + { + AdminFacet(address(diamond)).mintSupply( + _seller, + _amount + ); + + vm.prank( + _seller + ); + + QueueJoinLeaveFacet(address(diamond)).joinQue( + _amount, + _incentive + ); + } + + function _forecast( + uint256 _x, + uint256 _y + ) + internal + view + returns ( + int256[] memory incentives, + uint256[] memory orders, + uint256[] memory orderAmounts, + uint256[] memory partials, + uint256 partialAmount + ) + { + return QueueForecastFacet(address(diamond)).solveForAmountAfterFulfill( + _x, + _y + ); + } + + function _executeRealFill( + uint256 _x + ) + internal + { + ( + int256[] memory incs, + uint256[] memory orders, + uint256[] memory partials + ) = QueueViewFacet(address(diamond)).solveForAmount( + _x + ); + + uint256 covered; + + for (uint256 i; i < orders.length; i++) { + ( + , + uint256 amt, + , + ) = diamond.QueMemberByIdAndIncentive( + orders[i], + incs[i] + ); + + covered += amt; + } + + uint256 partialTake = partials.length > 0 + ? _x - covered + : 0; + + if (orders.length == 0 && partialTake == 0) { + return; + } + + usd.mint( + filler, + _x + ); + + vm.prank( + filler + ); + + usd.approve( + address(diamond), + _x + ); + + vm.prank( + filler + ); + + QueueFulfillFacet(address(diamond)).fulfillOrderBulk( + incs, + orders, + partials, + partialTake, + 0, + type(uint256).max + ); + } + + function _realSolveWithAmounts( + uint256 _y + ) + internal + view + returns ( + int256[] memory incentives, + uint256[] memory orders, + uint256[] memory orderAmounts, + uint256[] memory partials, + uint256 partialAmount + ) + { + ( + incentives, + orders, + partials + ) = QueueViewFacet(address(diamond)).solveForAmount( + _y + ); + + orderAmounts = new uint256[](orders.length); + + uint256 covered; + + for (uint256 i; i < orders.length; i++) { + ( + , + uint256 amt, + , + ) = diamond.QueMemberByIdAndIncentive( + orders[i], + incentives[i] + ); + + orderAmounts[i] = amt; + covered += amt; + } + + partialAmount = partials.length > 0 + ? _y - covered + : 0; + } + + function _assertForecastMatchesReality( + uint256 _x, + uint256 _y + ) + internal + { + ( + int256[] memory fIncs, + uint256[] memory fOrders, + uint256[] memory fAmounts, + uint256[] memory fPartials, + uint256 fPartialAmount + ) = _forecast( + _x, + _y + ); + + _executeRealFill( + _x + ); + + ( + int256[] memory rIncs, + uint256[] memory rOrders, + uint256[] memory rAmounts, + uint256[] memory rPartials, + uint256 rPartialAmount + ) = _realSolveWithAmounts( + _y + ); + + assertEq( + abi.encode(fIncs), + abi.encode(rIncs), + "incentives diverge" + ); + + assertEq( + abi.encode(fOrders), + abi.encode(rOrders), + "orders diverge" + ); + + assertEq( + abi.encode(fAmounts), + abi.encode(rAmounts), + "order amounts diverge" + ); + + assertEq( + abi.encode(fPartials), + abi.encode(rPartials), + "partials diverge" + ); + + assertEq( + fPartialAmount, + rPartialAmount, + "partial amount diverges" + ); + } + + function test_zeroPrior_equalsPlainSolve() + public + view + { + ( + int256[] memory fIncs, + uint256[] memory fOrders, + , + uint256[] memory fPartials, + ) = _forecast( + 0, + 333 * 1e6 + ); + + ( + int256[] memory pIncs, + uint256[] memory pOrders, + uint256[] memory pPartials + ) = QueueViewFacet(address(diamond)).solveForAmount( + 333 * 1e6 + ); + + assertEq( + abi.encode(fIncs), + abi.encode(pIncs) + ); + + assertEq( + abi.encode(fOrders), + abi.encode(pOrders) + ); + + assertEq( + abi.encode(fPartials), + abi.encode(pPartials) + ); + } + + function test_priorStopsInsideOrder_secondContinuesThere() + public + { + _assertForecastMatchesReality( + 250 * 1e6, + 400 * 1e6 + ); + } + + function test_priorEndsOnExactBoundary() + public + { + _assertForecastMatchesReality( + 300 * 1e6, + 200 * 1e6 + ); + } + + function test_priorSpansLanes() + public + { + _assertForecastMatchesReality( + 550 * 1e6, + 300 * 1e6 + ); + } + + function test_priorExceedsWholeBook() + public + { + ( + , + uint256[] memory fOrders, + , + uint256[] memory fPartials, + uint256 fPartialAmount + ) = _forecast( + totalQueued + 1, + 100 * 1e6 + ); + + assertEq( + fOrders.length, + 0 + ); + + assertEq( + fPartials.length, + 0 + ); + + assertEq( + fPartialAmount, + 0 + ); + } + + function test_secondExceedsRemainder() + public + { + _assertForecastMatchesReality( + 1_000 * 1e6, + totalQueued + ); + } + + function test_emptyQueue_returnsEmpty() + public + { + _executeRealFill( + totalQueued + ); + + ( + int256[] memory fIncs, + uint256[] memory fOrders, + , + uint256[] memory fPartials, + uint256 fPartialAmount + ) = _forecast( + 5 * 1e6, + 5 * 1e6 + ); + + assertEq( + fIncs.length, + 0 + ); + + assertEq( + fOrders.length, + 0 + ); + + assertEq( + fPartials.length, + 0 + ); + + assertEq( + fPartialAmount, + 0 + ); + } + + function testFuzz_forecastMatchesReality( + uint256 _x, + uint256 _y + ) + public + { + _x = bound( + _x, + 0, + totalQueued + 50 * 1e6 + ); + + _y = bound( + _y, + 1, + totalQueued + 50 * 1e6 + ); + + _assertForecastMatchesReality( + _x, + _y + ); + } +}