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