diff --git a/test/src/concrete/BatchRecordingReceiver.sol b/test/src/concrete/BatchRecordingReceiver.sol new file mode 100644 index 00000000..d5e40658 --- /dev/null +++ b/test/src/concrete/BatchRecordingReceiver.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {StoxReceipt} from "../../../src/concrete/StoxReceipt.sol"; + +/// @dev Contract recipient that records its own balance per id observed +/// during `onERC1155BatchReceived`. Mirrors `RecordingReceiver` for the +/// batch path. +contract BatchRecordingReceiver { + StoxReceipt public immutable RECEIPT; + mapping(uint256 => uint256) public observedBalance; + + constructor(StoxReceipt receipt_) { + RECEIPT = receipt_; + } + + function onERC1155BatchReceived(address, address, uint256[] calldata ids, uint256[] calldata, bytes calldata) + external + returns (bytes4) + { + for (uint256 i = 0; i < ids.length; i++) { + observedBalance[ids[i]] = RECEIPT.balanceOf(address(this), ids[i]); + } + return this.onERC1155BatchReceived.selector; + } +} diff --git a/test/src/concrete/CorporateActionHarness.sol b/test/src/concrete/CorporateActionHarness.sol new file mode 100644 index 00000000..dab728ae --- /dev/null +++ b/test/src/concrete/CorporateActionHarness.sol @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibCorporateAction} from "../../../src/lib/LibCorporateAction.sol"; +import { + CorporateActionNode, + CompletionFilter, + LibCorporateActionNode +} from "../../../src/lib/LibCorporateActionNode.sol"; +import {LibTestCorporateAction} from "../../lib/LibTestCorporateAction.sol"; + +/// @dev Harness to test library functions directly. +contract CorporateActionHarness { + uint8 public constant decimals = 18; + + function resolveActionType(bytes32 typeHash, bytes calldata parameters) external returns (uint256) { + return LibCorporateAction.resolveActionType(typeHash, parameters); + } + + function schedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { + return LibCorporateAction.schedule(actionType, effectiveTime, parameters); + } + + function cancel(uint256 actionIndex) external { + LibCorporateAction.cancel(actionIndex); + } + + function countCompleted() external view returns (uint256) { + return LibCorporateAction.countCompleted(); + } + + function nextOfType(uint256 cursor, uint256 mask, CompletionFilter filter) external view returns (uint256) { + return LibCorporateActionNode.nextOfType(cursor, mask, filter); + } + + function prevOfType(uint256 cursor, uint256 mask, CompletionFilter filter) external view returns (uint256) { + return LibCorporateActionNode.prevOfType(cursor, mask, filter); + } + + function getNode(uint256 actionIndex) external view returns (CorporateActionNode memory) { + return LibCorporateAction.getStorage().nodes[actionIndex]; + } + + function head() external view returns (uint256) { + return LibTestCorporateAction.head(); + } + + function tail() external view returns (uint256) { + return LibTestCorporateAction.tail(); + } + + /// Library-path readers — all go through `LibCorporateAction.getStorage()`, + /// so `testStorageLayoutPin` can prove a value written at a specific slot + /// is actually the slot the library reads from. + function accountMigrationCursor(address account) external view returns (uint256) { + return LibCorporateAction.getStorage().accountMigrationCursor[account]; + } + + function unmigrated(uint256 cursor) external view returns (uint256) { + return LibCorporateAction.getStorage().unmigrated[cursor]; + } + + function totalSupplyLatestCursor() external view returns (uint256) { + return LibCorporateAction.getStorage().totalSupplyLatestCursor; + } + + /// Direct call to `ensureBootstrap` so tests can observe the + /// post-bootstrap pre-user-action state (`bootstrap.prev` / + /// `bootstrap.next` both `NODE_NONE`). The production `schedule` call + /// fires `ensureBootstrap` and then immediately splices in the user + /// action, mutating `bootstrap.next` away from `NODE_NONE` — so the + /// only way to pin the in-between state is to invoke + /// `ensureBootstrap` standalone. + function ensureBootstrap() external { + LibCorporateAction.ensureBootstrap(LibCorporateAction.getStorage()); + } +} diff --git a/test/src/concrete/DelegatecallHarness.sol b/test/src/concrete/DelegatecallHarness.sol new file mode 100644 index 00000000..f0b547ac --- /dev/null +++ b/test/src/concrete/DelegatecallHarness.sol @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibCorporateAction} from "../../../src/lib/LibCorporateAction.sol"; +import {IAuthorizeV1} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; + +/// @dev Minimal harness that delegates calls to a facet. Also exposes an +/// `authorizer()` function so the facet's `OffchainAssetReceiptVault(address +/// (this)).authorizer()` lookup resolves to a test-controlled mock instead of +/// a real rain.vats authorizer. +contract DelegatecallHarness { + address public immutable FACET; + IAuthorizeV1 public authorizer; + uint8 public constant decimals = 18; + + constructor(address facet_) { + FACET = facet_; + } + + function setAuthorizer(IAuthorizeV1 authorizer_) external { + authorizer = authorizer_; + } + + /// @dev Schedule directly via the library, bypassing auth and + /// `resolveActionType` validation. Runs in this harness's storage so that + /// a subsequent delegatecalled `getActionParameters` (which also runs in + /// this harness's storage) observes the write. Exists purely to support + /// fuzzing arbitrary `bytes` payloads that the normal facet path would + /// reject. + function scheduleRaw(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { + return LibCorporateAction.schedule(actionType, effectiveTime, parameters); + } + + fallback() external payable { + address target = FACET; + assembly { + calldatacopy(0, 0, calldatasize()) + let success := delegatecall(gas(), target, 0, calldatasize(), 0, 0) + returndatacopy(0, 0, returndatasize()) + switch success + case 0 { revert(0, returndatasize()) } + default { return(0, returndatasize()) } + } + } + + receive() external payable {} +} diff --git a/test/src/concrete/InvariantReceipt.sol b/test/src/concrete/InvariantReceipt.sol new file mode 100644 index 00000000..919b92ab --- /dev/null +++ b/test/src/concrete/InvariantReceipt.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {StoxReceipt} from "../../../src/concrete/StoxReceipt.sol"; +import {LibCorporateActionReceipt} from "../../../src/lib/LibCorporateActionReceipt.sol"; +import {LibERC1155Storage} from "../../../src/lib/LibERC1155Storage.sol"; + +/// @dev Receipt harness used by the invariant suite. Initializes `manager` +/// to the invariant vault via direct slot write (bypassing the Receipt +/// base's `initializer` lock — we're in a fresh deployment, not a proxy +/// upgrade path). Exposes the raw stored balance and cursor for +/// assertions. +contract InvariantReceipt is StoxReceipt { + function testInit(address vaultAddr) external { + bytes32 slot = 0xe5444a702a2f437387f4eb075af275e349f1dba9a68923d27352f035d01dc200; + assembly { + sstore(slot, vaultAddr) + } + } + + function rawReceiptBalance(address account, uint256 id) external view returns (uint256) { + return LibERC1155Storage.underlyingBalance(account, id); + } + + function holderIdCursor(address account, uint256 id) external view returns (uint256) { + return LibCorporateActionReceipt.getStorage().accountIdCursor[account][id]; + } +} diff --git a/test/src/concrete/InvariantVault.sol b/test/src/concrete/InvariantVault.sol new file mode 100644 index 00000000..c0fe9b58 --- /dev/null +++ b/test/src/concrete/InvariantVault.sol @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {StoxReceiptVault} from "../../../src/concrete/StoxReceiptVault.sol"; +import {ERC20Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC20/ERC20Upgradeable.sol"; +import {LibCorporateAction} from "../../../src/lib/LibCorporateAction.sol"; +import {ACTION_TYPE_STOCK_SPLIT_V1} from "../../../src/interface/ICorporateActionsV1.sol"; +import {LibERC20Storage} from "../../../src/lib/LibERC20Storage.sol"; +import {LibTotalSupply} from "../../../src/lib/LibTotalSupply.sol"; +import { + CorporateActionNode, + CompletionFilter, + LibCorporateActionNode, + NODE_NONE +} from "../../../src/lib/LibCorporateActionNode.sol"; +import {LibTestCorporateAction} from "../../lib/LibTestCorporateAction.sol"; + +/// @dev Auth-bypassed vault subclass used by the invariant harness. Mirrors +/// the production `StoxReceiptVault._update` flow exactly, only skipping the +/// `OffchainAssetReceiptVault` authorizer/freeze middle layer — the migration +/// semantics under test live entirely in `StoxReceiptVault` and the libraries +/// it calls. Also exposes the internal cursor / split state so invariants can +/// read it. +contract InvariantVault is StoxReceiptVault { + function _update(address from, address to, uint256 amount) internal override { + LibTotalSupply.fold(); + + migrateAccount(from); + migrateAccount(to); + + ERC20Upgradeable._update(from, to, amount); + + if (from == address(0)) { + LibTotalSupply.onMint(amount); + } else if (to == address(0)) { + LibTotalSupply.onBurn(amount); + } + } + + function publicUpdate(address from, address to, uint256 amount) external { + _update(from, to, amount); + } + + function publicSchedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) + external + returns (uint256) + { + return LibCorporateAction.schedule(actionType, effectiveTime, parameters); + } + + function publicCancel(uint256 actionIndex) external { + LibCorporateAction.cancel(actionIndex); + } + + function migrationCursor(address account) external view returns (uint256) { + return LibCorporateAction.getStorage().accountMigrationCursor[account]; + } + + function totalSupplyLatestCursor() external view returns (uint256) { + return LibCorporateAction.getStorage().totalSupplyLatestCursor; + } + + function listHead() external view returns (uint256) { + return LibTestCorporateAction.head(); + } + + function listTail() external view returns (uint256) { + return LibTestCorporateAction.tail(); + } + + function getNode(uint256 index) external view returns (CorporateActionNode memory) { + return LibCorporateAction.getStorage().nodes[index]; + } + + function nodesLength() external view returns (uint256) { + return LibCorporateAction.getStorage().nodes.length; + } + + function rawStoredBalance(address account) external view returns (uint256) { + return LibERC20Storage.underlyingBalance(account); + } + + /// @dev Whether any stock split in the list has reached its effective + /// time. `effectiveTotalSupply` applies multipliers once this is true + /// even if `fold()` has not yet been called to update + /// `totalSupplyLatestCursor`, so invariants that depend on the + /// no-multiplier regime must gate on this rather than + /// `totalSupplyLatestCursor == 0`. + function hasCompletedSplit() external view returns (bool) { + return LibCorporateActionNode.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED) + != NODE_NONE; + } + + // ----------------------------------------------------------------------- + // ICorporateActionsV1 read surface — forwarded directly to the libraries + // so the receipt contract can read stock split multipliers cross-contract + // without a facet delegatecall router. + // + // Only the subset LibReceiptRebase actually consumes is implemented; the + // other traversal getters are omitted because the receipt never calls + // them. These are view-only; no migration or authorization logic needed. + + function nextOfType(uint256 cursor, uint256 mask, CompletionFilter filter) + external + view + returns (uint256, uint256, uint64) + { + uint256 nextCursor = LibCorporateActionNode.nextOfType(cursor, mask, filter); + if (nextCursor == NODE_NONE) { + return (nextCursor, 0, 0); + } + CorporateActionNode storage node = LibCorporateAction.getStorage().nodes[nextCursor]; + return (nextCursor, node.actionType, node.effectiveTime); + } + + function getActionParameters(uint256 cursor) external view returns (bytes memory) { + LibCorporateAction.CorporateActionStorage storage s = LibCorporateAction.getStorage(); + require(cursor != NODE_NONE && cursor < s.nodes.length, "InvariantVault: action does not exist"); + return s.nodes[cursor].parameters; + } + + // ----------------------------------------------------------------------- + // IReceiptManagerV2.authorizeReceiptTransfer3 — no-op override (always + // allows the transfer) so the receipt's base `_update` path can run in + // the invariant harness without needing an ethgild authorizer wired in. + // The real ethgild vault derives a multi-layered auth decision here; + // our invariant test doesn't care about auth correctness, only the + // rebase math. + + function authorizeReceiptTransfer3(address, address, address, uint256[] memory, uint256[] memory) + public + pure + override + {} +} diff --git a/test/src/concrete/MockAuthorizer.sol b/test/src/concrete/MockAuthorizer.sol new file mode 100644 index 00000000..43f94504 --- /dev/null +++ b/test/src/concrete/MockAuthorizer.sol @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IAuthorizeV1, Unauthorized} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; + +/// @dev Mock authorizer used by the facet tests. Records the most recent +/// `authorize` call so tests can assert the per-action context that the facet +/// passes through. When `denyMode` is true, every `authorize` call reverts +/// with `Unauthorized`, exercising the auth-denial code path. +contract MockAuthorizer is IAuthorizeV1 { + bool public denyMode; + address public lastUser; + bytes32 public lastPermission; + bytes public lastData; + uint256 public callCount; + + function setDenyMode(bool deny) external { + denyMode = deny; + } + + function authorize(address user, bytes32 permission, bytes memory data) external override { + callCount++; + lastUser = user; + lastPermission = permission; + lastData = data; + if (denyMode) { + revert Unauthorized(user, permission, data); + } + } +} diff --git a/test/src/concrete/MockVault.sol b/test/src/concrete/MockVault.sol new file mode 100644 index 00000000..d3601519 --- /dev/null +++ b/test/src/concrete/MockVault.sol @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Float} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import { + ICorporateActionsV1, + ACTION_TYPE_STOCK_SPLIT_V1, + BALANCE_MIGRATION_TYPES_MASK +} from "../../../src/interface/ICorporateActionsV1.sol"; +import {CompletionFilter, NODE_NONE} from "../../../src/lib/LibCorporateActionNode.sol"; +import {IReceiptManagerV2} from "rain-vats-0.1.6/src/interface/IReceiptManagerV2.sol"; + +/// @dev Mock vault combining `ICorporateActionsV1` (corporate-action read +/// surface) and `IReceiptManagerV2` (receipt transfer authorizer). The +/// receipt's base `_update` calls `s.manager.authorizeReceiptTransfer3(...)` +/// before applying the transfer, and our override reads multipliers via +/// `this.manager()` cast to `ICorporateActionsV1`. A single mock serving +/// both interfaces matches the real topology where the vault is a single +/// contract implementing both. +/// +/// `IReceiptManagerV2` also requires `symbol()`, `decimals()` etc. via the +/// Receipt's `getVaultShareSymbol` helper. In tests we only call `balanceOf` +/// and `_update` paths that don't hit `uri()`, so the stub implementations +/// below are minimal. +contract MockVault is ICorporateActionsV1, IReceiptManagerV2 { + error ReceiptTransferDenied(); + + bytes[] internal splits; // splits[i-1] is the parameters blob for cursor i + bool public denyTransfers; + + /// Authorize hook — allows or denies based on `denyTransfers`. + function authorizeReceiptTransfer3(address, address, address, uint256[] memory, uint256[] memory) + external + view + override + { + if (denyTransfers) revert ReceiptTransferDenied(); + } + + function setDenyTransfers(bool deny) external { + denyTransfers = deny; + } + + function addSplit(Float multiplier) external { + splits.push(abi.encode(multiplier)); + } + + // ICorporateActionsV1 + + function nextOfType(uint256 cursor, uint256 mask, CompletionFilter filter) + external + view + override + returns (uint256, uint256, uint64) + { + // Receipt rebase walks `BALANCE_MIGRATION_TYPES_MASK` (init | + // stock-split). This mock holds only splits — no init node — so + // walking that mask returns the same sequence as walking the + // stock-split bit alone. + require(mask == BALANCE_MIGRATION_TYPES_MASK, "mock: unexpected mask"); + require(filter == CompletionFilter.COMPLETED, "mock: unexpected filter"); + // Cursor 0 is the vault's bootstrap (identity); splits live at + // 1..splits.length. The "no more nodes" sentinel is `NODE_NONE`, + // matching the real vault's contract. + if (cursor == NODE_NONE) { + // Empty splits → no-more-nodes shape (NODE_NONE, 0, 0), matching + // the cursor-walked-past-end branch below. Returning a non-zero + // actionType for a NODE_NONE cursor is a contract violation + // CodeRabbit caught — would let traversal tests pass against + // states the real vault never produces. + if (splits.length == 0) return (NODE_NONE, 0, 0); + return (1, ACTION_TYPE_STOCK_SPLIT_V1, 1); + } + uint256 candidate = cursor + 1; + if (candidate > splits.length) { + return (NODE_NONE, 0, 0); + } + return (candidate, ACTION_TYPE_STOCK_SPLIT_V1, 1); + } + + function getActionParameters(uint256 cursor) external view override returns (bytes memory) { + require(cursor >= 1 && cursor <= splits.length, "mock: cursor out of range"); + return splits[cursor - 1]; + } + + function scheduleCorporateAction(bytes32, uint64, bytes calldata) external pure override returns (uint256) { + revert("mock"); + } + + function cancelCorporateAction(uint256) external pure override { + revert("mock"); + } + + function completedActionCount() external view override returns (uint256) { + return splits.length; + } + + function latestActionOfType(uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { + revert("mock"); + } + + function earliestActionOfType(uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { + revert("mock"); + } + + function prevOfType(uint256, uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { + revert("mock"); + } + + /// Expose minimal IERC20Metadata surface that `Receipt.getVaultShareSymbol` + /// calls via `IERC20Metadata(address(manager)).symbol()`. Not actually + /// used in our tests (we never hit `uri()`), but the `_update` path may + /// touch it if anything inspects name/symbol. Stubbed for safety. + function symbol() external pure returns (string memory) { + return "TEST"; + } + + function decimals() external pure returns (uint8) { + return 18; + } + + function asset() external view returns (address) { + return address(this); + } +} diff --git a/test/src/concrete/OwnedStoxReceiptVault.sol b/test/src/concrete/OwnedStoxReceiptVault.sol new file mode 100644 index 00000000..2aa50cdb --- /dev/null +++ b/test/src/concrete/OwnedStoxReceiptVault.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {StoxReceiptVault} from "../../../src/concrete/StoxReceiptVault.sol"; + +/// Minimal subclass that transfers ownership to a known address in its +/// constructor so the test can pose as the vault owner without running +/// the full Zoltu-deployer-and-initialize flow. The guard under test is +/// `setAuthorizer`, which only depends on `OwnableUpgradeable`'s owner +/// being set — not on the rest of vault initialization. +contract OwnedStoxReceiptVault is StoxReceiptVault { + constructor(address owner) { + _transferOwnership(owner); + } +} diff --git a/test/src/concrete/PermissiveAuthorizer.sol b/test/src/concrete/PermissiveAuthorizer.sol new file mode 100644 index 00000000..99ce320b --- /dev/null +++ b/test/src/concrete/PermissiveAuthorizer.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {IAuthorizeV1, Unauthorized} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; + +/// @dev Permissive authorizer used by the fallback routing tests. Records the +/// most recent call and allows every permission by default so we can exercise +/// the forward-to-facet path without reproducing the full ethgild auth setup. +contract PermissiveAuthorizer is IAuthorizeV1 { + address public lastUser; + bytes32 public lastPermission; + bytes public lastData; + uint256 public callCount; + bool public denyMode; + + function setDenyMode(bool deny) external { + denyMode = deny; + } + + function authorize(address user, bytes32 permission, bytes memory data) external override { + callCount++; + lastUser = user; + lastPermission = permission; + lastData = data; + if (denyMode) { + revert Unauthorized(user, permission, data); + } + } +} diff --git a/test/src/concrete/RecordingReceiver.sol b/test/src/concrete/RecordingReceiver.sol new file mode 100644 index 00000000..50ec571b --- /dev/null +++ b/test/src/concrete/RecordingReceiver.sol @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {StoxReceipt} from "../../../src/concrete/StoxReceipt.sol"; + +/// @dev Contract recipient that records the sender and receiver balances +/// observed during its `onERC1155Received` callback. Used to pin the +/// invariant that receive hooks fire post-migration, post-transfer. +contract RecordingReceiver { + StoxReceipt public immutable RECEIPT; + address public immutable ALICE; + uint256 public observedAliceBalance; + uint256 public observedRecvBalance; + + constructor(StoxReceipt receipt_, address alice_) { + RECEIPT = receipt_; + ALICE = alice_; + } + + function onERC1155Received(address, address, uint256 id, uint256, bytes calldata) external returns (bytes4) { + observedAliceBalance = RECEIPT.balanceOf(ALICE, id); + observedRecvBalance = RECEIPT.balanceOf(address(this), id); + return this.onERC1155Received.selector; + } +} diff --git a/test/src/concrete/StoxCorporateActionsFacet.t.sol b/test/src/concrete/StoxCorporateActionsFacet.t.sol index 26d87946..d7696cb3 100644 --- a/test/src/concrete/StoxCorporateActionsFacet.t.sol +++ b/test/src/concrete/StoxCorporateActionsFacet.t.sol @@ -37,141 +37,9 @@ import {LibStockSplit} from "../../../src/lib/LibStockSplit.sol"; import {InvalidSplitMultiplier} from "../../../src/error/ErrStockSplit.sol"; import {LibTestTofu} from "../../lib/LibTestTofu.sol"; import {LibTestCorporateAction} from "../../lib/LibTestCorporateAction.sol"; - -/// @dev Mock authorizer used by the facet tests. Records the most recent -/// `authorize` call so tests can assert the per-action context that the facet -/// passes through. When `denyMode` is true, every `authorize` call reverts -/// with `Unauthorized`, exercising the auth-denial code path. -contract MockAuthorizer is IAuthorizeV1 { - bool public denyMode; - address public lastUser; - bytes32 public lastPermission; - bytes public lastData; - uint256 public callCount; - - function setDenyMode(bool deny) external { - denyMode = deny; - } - - function authorize(address user, bytes32 permission, bytes memory data) external override { - callCount++; - lastUser = user; - lastPermission = permission; - lastData = data; - if (denyMode) { - revert Unauthorized(user, permission, data); - } - } -} - -/// @dev Minimal harness that delegates calls to a facet. Also exposes an -/// `authorizer()` function so the facet's `OffchainAssetReceiptVault(address -/// (this)).authorizer()` lookup resolves to a test-controlled mock instead of -/// a real rain.vats authorizer. -contract DelegatecallHarness { - address public immutable FACET; - IAuthorizeV1 public authorizer; - uint8 public constant decimals = 18; - - constructor(address facet_) { - FACET = facet_; - } - - function setAuthorizer(IAuthorizeV1 authorizer_) external { - authorizer = authorizer_; - } - - /// @dev Schedule directly via the library, bypassing auth and - /// `resolveActionType` validation. Runs in this harness's storage so that - /// a subsequent delegatecalled `getActionParameters` (which also runs in - /// this harness's storage) observes the write. Exists purely to support - /// fuzzing arbitrary `bytes` payloads that the normal facet path would - /// reject. - function scheduleRaw(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { - return LibCorporateAction.schedule(actionType, effectiveTime, parameters); - } - - fallback() external payable { - address target = FACET; - assembly { - calldatacopy(0, 0, calldatasize()) - let success := delegatecall(gas(), target, 0, calldatasize(), 0, 0) - returndatacopy(0, 0, returndatasize()) - switch success - case 0 { revert(0, returndatasize()) } - default { return(0, returndatasize()) } - } - } - - receive() external payable {} -} - -/// @dev Harness to test library functions directly. -contract CorporateActionHarness { - uint8 public constant decimals = 18; - - function resolveActionType(bytes32 typeHash, bytes calldata parameters) external returns (uint256) { - return LibCorporateAction.resolveActionType(typeHash, parameters); - } - - function schedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { - return LibCorporateAction.schedule(actionType, effectiveTime, parameters); - } - - function cancel(uint256 actionIndex) external { - LibCorporateAction.cancel(actionIndex); - } - - function countCompleted() external view returns (uint256) { - return LibCorporateAction.countCompleted(); - } - - function nextOfType(uint256 cursor, uint256 mask, CompletionFilter filter) external view returns (uint256) { - return LibCorporateActionNode.nextOfType(cursor, mask, filter); - } - - function prevOfType(uint256 cursor, uint256 mask, CompletionFilter filter) external view returns (uint256) { - return LibCorporateActionNode.prevOfType(cursor, mask, filter); - } - - function getNode(uint256 actionIndex) external view returns (CorporateActionNode memory) { - return LibCorporateAction.getStorage().nodes[actionIndex]; - } - - function head() external view returns (uint256) { - return LibTestCorporateAction.head(); - } - - function tail() external view returns (uint256) { - return LibTestCorporateAction.tail(); - } - - /// Library-path readers — all go through `LibCorporateAction.getStorage()`, - /// so `testStorageLayoutPin` can prove a value written at a specific slot - /// is actually the slot the library reads from. - function accountMigrationCursor(address account) external view returns (uint256) { - return LibCorporateAction.getStorage().accountMigrationCursor[account]; - } - - function unmigrated(uint256 cursor) external view returns (uint256) { - return LibCorporateAction.getStorage().unmigrated[cursor]; - } - - function totalSupplyLatestCursor() external view returns (uint256) { - return LibCorporateAction.getStorage().totalSupplyLatestCursor; - } - - /// Direct call to `ensureBootstrap` so tests can observe the - /// post-bootstrap pre-user-action state (`bootstrap.prev` / - /// `bootstrap.next` both `NODE_NONE`). The production `schedule` call - /// fires `ensureBootstrap` and then immediately splices in the user - /// action, mutating `bootstrap.next` away from `NODE_NONE` — so the - /// only way to pin the in-between state is to invoke - /// `ensureBootstrap` standalone. - function ensureBootstrap() external { - LibCorporateAction.ensureBootstrap(LibCorporateAction.getStorage()); - } -} +import {MockAuthorizer} from "./MockAuthorizer.sol"; +import {DelegatecallHarness} from "./DelegatecallHarness.sol"; +import {CorporateActionHarness} from "./CorporateActionHarness.sol"; contract StoxCorporateActionsFacetTest is Test { StoxCorporateActionsFacet internal facetImpl; diff --git a/test/src/concrete/StoxCorporateActionsHandler.sol b/test/src/concrete/StoxCorporateActionsHandler.sol new file mode 100644 index 00000000..64f4cad2 --- /dev/null +++ b/test/src/concrete/StoxCorporateActionsHandler.sol @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import {ACTION_TYPE_STOCK_SPLIT_V1} from "../../../src/interface/ICorporateActionsV1.sol"; +import {NODE_NONE} from "../../../src/lib/LibCorporateActionNode.sol"; +import {LibStockSplit} from "../../../src/lib/LibStockSplit.sol"; +import {InvariantVault} from "./InvariantVault.sol"; +import {InvariantReceipt} from "./InvariantReceipt.sol"; + +/// @dev Handler for the corporate-actions invariant suite. Foundry's invariant +/// fuzzer targets this contract's external functions; each call drives one +/// operation against the vault with bounded, fuzzer-supplied inputs. After +/// every operation that migrates an account, the handler asserts the cursor- +/// equality invariant inline so violations surface at the offending call +/// rather than at the periodic invariant sweep. +/// +/// Actors: a fixed set of 5 addresses plus the zero address (for mint/burn). +/// Multipliers: bounded to {1, 2, 3} (and their reciprocals via fractional +/// form) to keep Float precision inside well-tested territory without losing +/// the fractional vs integer distinction. +/// Amounts: bounded per-op against current balances to avoid OZ underflow +/// reverts that would mask real bugs. +contract StoxCorporateActionsHandler is Test { + InvariantVault public immutable VAULT; + InvariantReceipt public immutable RECEIPT; + + /// @dev Fixed set of actors the handler cycles through. Each actor is + /// paired with a fixed receipt id (`i + 1`) for the proportionality + /// invariant: `deposit` and `withdraw` create / destroy matching amounts + /// of both the share balance and the receipt at that actor's id, so + /// `vault.balanceOf(actor_i) == receipt.balanceOf(actor_i, i+1)` holds + /// at every post-handler-call checkpoint. Share-only transfers would + /// break this proportionality (shares fungible, receipts per-id), so + /// the handler deliberately does not expose them. + address[5] public actors; + + /// @dev Total number of mints executed (for ghost-variable assertions). + uint256 public totalMinted; + /// @dev Total number of burns executed. + uint256 public totalBurned; + /// @dev Track the most recent share-side cursor value per actor, to + /// verify monotonicity (invariant 3). + mapping(address => uint256) public lastSeenCursor; + /// @dev Track the most recent receipt-side cursor per (actor, id) pair + /// for the receipt cursor monotonicity invariant. + mapping(address => mapping(uint256 => uint256)) public lastSeenReceiptCursor; + + constructor(InvariantVault vault_, InvariantReceipt receipt_) { + VAULT = vault_; + RECEIPT = receipt_; + actors[0] = address(0xA11CE); + actors[1] = address(0xB0B); + actors[2] = address(0xCA401); + actors[3] = address(0xDAFE); + actors[4] = address(0xEFFE); + // Start time past 0 so `block.timestamp > 0` and fresh effectiveTime + // values can land strictly in the future. + vm.warp(1000); + } + + /// @dev Each actor has a fixed receipt id = index + 1. Id 0 is avoided + /// (reserved for "no id" semantics in various tests). + function _actorId(uint256 actorIndex) internal pure returns (uint256) { + return actorIndex + 1; + } + + function _actor(uint256 seed) internal view returns (address) { + return actors[seed % actors.length]; + } + + function _asFloat(int256 coefficient, int256 exponent) internal pure returns (Float) { + return LibDecimalFloat.packLossless(coefficient, exponent); + } + + /// @dev Bounded set of stock split multipliers used by schedule. Fractional + /// reverse splits (1/3, 1/2) are produced by `div(packLossless(1,0), + /// packLossless(n,0))`. + function _multiplier(uint256 seed) internal pure returns (bytes memory) { + uint256 bucket = seed % 6; + if (bucket == 0) return LibStockSplit.encodeParametersV1(_asFloat(2, 0)); + if (bucket == 1) return LibStockSplit.encodeParametersV1(_asFloat(3, 0)); + if (bucket == 2) return LibStockSplit.encodeParametersV1(_asFloat(1, 0)); // 1x — a no-op split (valid) + if (bucket == 3) { + return LibStockSplit.encodeParametersV1(LibDecimalFloat.div(_asFloat(1, 0), _asFloat(2, 0))); + } + if (bucket == 4) { + return LibStockSplit.encodeParametersV1(LibDecimalFloat.div(_asFloat(1, 0), _asFloat(3, 0))); + } + // bucket 5: another 2x (slight weighting toward common cases) + return LibStockSplit.encodeParametersV1(_asFloat(2, 0)); + } + + /// @dev Schedule a stock split at a bounded future time. Time is drawn + /// from a small window ahead of `block.timestamp` so multiple scheduled + /// actions end up in mixed order inside the list. + function schedule(uint256 multiplierSeed, uint8 timeDelta) external { + // time in [1, 256] seconds in the future. + uint64 effectiveTime = uint64(block.timestamp + 1 + (uint256(timeDelta) % 256)); + bytes memory parameters = _multiplier(multiplierSeed); + VAULT.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, parameters); + } + + /// @dev Cancel a scheduled action. Bounded to the current nodes array + /// length so it hits legitimate indices most of the time; indices that + /// point at already-cancelled or already-completed nodes revert cleanly + /// (caught by the inline try / ignore pattern). + function cancel(uint256 indexSeed) external { + uint256 len = VAULT.nodesLength(); + if (len <= 1) return; // only sentinel; nothing to cancel + uint256 actionIndex = (indexSeed % (len - 1)) + 1; // in [1, len-1] + // forge-lint: disable-next-line(unchecked-call) + try VAULT.publicCancel(actionIndex) {} catch {} + } + + /// @dev Advance block.timestamp by a bounded delta so scheduled actions + /// cross into the past and `fold()` picks them up on the next _update. + function warp(uint8 delta) external { + vm.warp(block.timestamp + 1 + uint256(delta)); + } + + /// @dev Deposit — mints matching amounts of share and receipt to an + /// actor at their assigned id. Models the real vault flow where a + /// deposit creates both a share balance and a receipt in lockstep. + /// This is the only mint path in the handler so that the + /// share-receipt proportionality invariant holds at every checkpoint. + function deposit(uint256 actorSeed, uint64 amountSeed) external { + uint256 actorIndex = actorSeed % actors.length; + address to = actors[actorIndex]; + uint256 id = _actorId(actorIndex); + uint256 amount = uint256(amountSeed) % 1e24 + 1; + + // Share side. + VAULT.publicUpdate(address(0), to, amount); + + // Receipt side — manager-authorized mint of the same amount at the + // actor's assigned id. `managerMint(sender, account, id, amount, data)`. + vm.prank(address(VAULT)); + RECEIPT.managerMint(address(VAULT), to, id, amount, ""); + + totalMinted += amount; + _assertCursorInvariant(to); + _assertReceiptCursorInvariant(to, id); + _recordCursor(to); + _recordReceiptCursor(to, id); + } + + /// @dev Withdraw — burns matching amounts of share and receipt from an + /// actor at their assigned id. Capped at the actor's current effective + /// balance (which must match the receipt balance by proportionality) + /// so OZ does not underflow. + function withdraw(uint256 actorSeed, uint64 amountSeed) external { + uint256 actorIndex = actorSeed % actors.length; + address from = actors[actorIndex]; + uint256 id = _actorId(actorIndex); + + uint256 effective = VAULT.balanceOf(from); + if (effective == 0) return; + uint256 amount = uint256(amountSeed) % (effective + 1); + if (amount == 0) return; + + // Share side. + VAULT.publicUpdate(from, address(0), amount); + + // Receipt side. + vm.prank(address(VAULT)); + RECEIPT.managerBurn(address(VAULT), from, id, amount, ""); + + totalBurned += amount; + _assertCursorInvariant(from); + _assertReceiptCursorInvariant(from, id); + _recordCursor(from); + _recordReceiptCursor(from, id); + } + + /// @dev Touch an actor by performing a zero-value self-deposit-equivalent + /// on both sides. Exercises cursor-only advancement paths (both share + /// and receipt). + function touch(uint256 actorSeed) external { + uint256 actorIndex = actorSeed % actors.length; + address a = actors[actorIndex]; + uint256 id = _actorId(actorIndex); + + // Share side — zero-value self-send. + VAULT.publicUpdate(a, a, 0); + + // Receipt side — zero-value self-transfer via manager path. + vm.prank(address(VAULT)); + RECEIPT.managerTransferFrom(address(VAULT), a, a, id, 0, ""); + + _assertCursorInvariant(a); + _assertReceiptCursorInvariant(a, id); + _recordCursor(a); + _recordReceiptCursor(a, id); + } + + // ----------------------------------------------------------------------- + // Ghost assertions / recording + + /// @dev Assert cursor invariant #4: after any migration, the actor's + /// cursor equals the global `totalSupplyLatestCursor`. + function _assertCursorInvariant(address a) internal view { + assertEq( + VAULT.migrationCursor(a), + VAULT.totalSupplyLatestCursor(), + "invariant 4: cursor(actor) == totalSupplyLatestCursor after migrateAccount" + ); + } + + /// @dev Record the actor's cursor to check monotonicity (invariant 3). + /// Cursor IDs are allocation indices, not chronological positions — a + /// later-scheduled action with an earlier effectiveTime is inserted + /// before older actions in the list, so a valid migration can move a + /// cursor to a numerically lower id. Monotonicity is therefore defined + /// in LIST order (forward along `next` pointers), not numeric order. + function _recordCursor(address a) internal { + uint256 current = VAULT.migrationCursor(a); + uint256 last = lastSeenCursor[a]; + assertTrue( + cursorReachableForward(last, current), "invariant 3: per-actor cursor must advance forward in list order" + ); + lastSeenCursor[a] = current; + } + + /// @dev After any migration on the receipt, the (holder, id) cursor + /// equals the vault's `totalSupplyLatestCursor`. A receipt cursor that + /// drifted behind would cause `LibReceiptRebase.migratedBalance` to + /// silently re-apply multipliers to an already-rasterized stored + /// balance on the next read. + function _assertReceiptCursorInvariant(address a, uint256 id) internal view { + assertEq( + RECEIPT.holderIdCursor(a, id), + VAULT.totalSupplyLatestCursor(), + "receipt invariant: holderIdCursor == totalSupplyLatestCursor after migrateHolderId" + ); + } + + /// @dev Record the receipt-side cursor to check per-(holder, id) + /// monotonicity. Same list-order semantics as the share side: a + /// later-scheduled earlier-effective split can land at a numerically + /// smaller node id but still be reachable forward from `last`, so a + /// raw `assertGe` would falsely flag a valid schedule. Walk forward + /// via `next` pointers instead. + function _recordReceiptCursor(address a, uint256 id) internal { + uint256 current = RECEIPT.holderIdCursor(a, id); + uint256 last = lastSeenReceiptCursor[a][id]; + assertTrue( + cursorReachableForward(last, current), + "receipt invariant: per-(holder, id) cursor must advance forward in list order" + ); + lastSeenReceiptCursor[a][id] = current; + } + + /// @dev True iff `current` is `last` itself or reachable by walking + /// `next` pointers starting from `last`. Walk is bounded by + /// `nodesLength` so a cycle (violation of invariant 1) does not hang. + /// External so framework-level invariants can call it too. + function cursorReachableForward(uint256 last, uint256 current) public view returns (bool) { + if (last == current) return true; + + uint256 len = VAULT.nodesLength(); + uint256 cursor = last; + for (uint256 i = 0; i < len && cursor != NODE_NONE; i++) { + cursor = VAULT.getNode(cursor).next; + if (cursor == current) return true; + } + return false; + } + + function actorCount() external pure returns (uint256) { + return 5; + } + + function actor(uint256 i) external view returns (address) { + return actors[i]; + } +} diff --git a/test/src/concrete/StoxCorporateActionsInvariant.t.sol b/test/src/concrete/StoxCorporateActionsInvariant.t.sol index 77165bd5..7db8c1a4 100644 --- a/test/src/concrete/StoxCorporateActionsInvariant.t.sol +++ b/test/src/concrete/StoxCorporateActionsInvariant.t.sol @@ -3,433 +3,11 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; -import {StoxReceiptVault} from "../../../src/concrete/StoxReceiptVault.sol"; -import {StoxReceipt} from "../../../src/concrete/StoxReceipt.sol"; -import {ERC20Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC20/ERC20Upgradeable.sol"; -import {LibCorporateAction} from "../../../src/lib/LibCorporateAction.sol"; -import {ACTION_TYPE_STOCK_SPLIT_V1, BALANCE_MIGRATION_TYPES_MASK} from "../../../src/interface/ICorporateActionsV1.sol"; -import {LibCorporateActionReceipt} from "../../../src/lib/LibCorporateActionReceipt.sol"; -import {LibERC20Storage} from "../../../src/lib/LibERC20Storage.sol"; -import {LibERC1155Storage} from "../../../src/lib/LibERC1155Storage.sol"; -import {LibTotalSupply} from "../../../src/lib/LibTotalSupply.sol"; -import { - CorporateActionNode, - CompletionFilter, - LibCorporateActionNode, - NODE_NONE -} from "../../../src/lib/LibCorporateActionNode.sol"; -import {LibTestCorporateAction} from "../../lib/LibTestCorporateAction.sol"; -import {LibStockSplit} from "../../../src/lib/LibStockSplit.sol"; - -/// @dev Auth-bypassed vault subclass used by the invariant harness. Mirrors -/// the production `StoxReceiptVault._update` flow exactly, only skipping the -/// `OffchainAssetReceiptVault` authorizer/freeze middle layer — the migration -/// semantics under test live entirely in `StoxReceiptVault` and the libraries -/// it calls. Also exposes the internal cursor / split state so invariants can -/// read it. -contract InvariantVault is StoxReceiptVault { - function _update(address from, address to, uint256 amount) internal override { - LibTotalSupply.fold(); - - migrateAccount(from); - migrateAccount(to); - - ERC20Upgradeable._update(from, to, amount); - - if (from == address(0)) { - LibTotalSupply.onMint(amount); - } else if (to == address(0)) { - LibTotalSupply.onBurn(amount); - } - } - - function publicUpdate(address from, address to, uint256 amount) external { - _update(from, to, amount); - } - - function publicSchedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) - external - returns (uint256) - { - return LibCorporateAction.schedule(actionType, effectiveTime, parameters); - } - - function publicCancel(uint256 actionIndex) external { - LibCorporateAction.cancel(actionIndex); - } - - function migrationCursor(address account) external view returns (uint256) { - return LibCorporateAction.getStorage().accountMigrationCursor[account]; - } - - function totalSupplyLatestCursor() external view returns (uint256) { - return LibCorporateAction.getStorage().totalSupplyLatestCursor; - } - - function listHead() external view returns (uint256) { - return LibTestCorporateAction.head(); - } - - function listTail() external view returns (uint256) { - return LibTestCorporateAction.tail(); - } - - function getNode(uint256 index) external view returns (CorporateActionNode memory) { - return LibCorporateAction.getStorage().nodes[index]; - } - - function nodesLength() external view returns (uint256) { - return LibCorporateAction.getStorage().nodes.length; - } - - function rawStoredBalance(address account) external view returns (uint256) { - return LibERC20Storage.underlyingBalance(account); - } - - /// @dev Whether any stock split in the list has reached its effective - /// time. `effectiveTotalSupply` applies multipliers once this is true - /// even if `fold()` has not yet been called to update - /// `totalSupplyLatestCursor`, so invariants that depend on the - /// no-multiplier regime must gate on this rather than - /// `totalSupplyLatestCursor == 0`. - function hasCompletedSplit() external view returns (bool) { - return LibCorporateActionNode.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED) - != NODE_NONE; - } - - // ----------------------------------------------------------------------- - // ICorporateActionsV1 read surface — forwarded directly to the libraries - // so the receipt contract can read stock split multipliers cross-contract - // without a facet delegatecall router. - // - // Only the subset LibReceiptRebase actually consumes is implemented; the - // other traversal getters are omitted because the receipt never calls - // them. These are view-only; no migration or authorization logic needed. - - function nextOfType(uint256 cursor, uint256 mask, CompletionFilter filter) - external - view - returns (uint256, uint256, uint64) - { - uint256 nextCursor = LibCorporateActionNode.nextOfType(cursor, mask, filter); - if (nextCursor == NODE_NONE) { - return (nextCursor, 0, 0); - } - CorporateActionNode storage node = LibCorporateAction.getStorage().nodes[nextCursor]; - return (nextCursor, node.actionType, node.effectiveTime); - } - - function getActionParameters(uint256 cursor) external view returns (bytes memory) { - LibCorporateAction.CorporateActionStorage storage s = LibCorporateAction.getStorage(); - require(cursor != NODE_NONE && cursor < s.nodes.length, "InvariantVault: action does not exist"); - return s.nodes[cursor].parameters; - } - - // ----------------------------------------------------------------------- - // IReceiptManagerV2.authorizeReceiptTransfer3 — no-op override (always - // allows the transfer) so the receipt's base `_update` path can run in - // the invariant harness without needing an ethgild authorizer wired in. - // The real ethgild vault derives a multi-layered auth decision here; - // our invariant test doesn't care about auth correctness, only the - // rebase math. - - function authorizeReceiptTransfer3(address, address, address, uint256[] memory, uint256[] memory) - public - pure - override - {} -} - -/// @dev Receipt harness used by the invariant suite. Initializes `manager` -/// to the invariant vault via direct slot write (bypassing the Receipt -/// base's `initializer` lock — we're in a fresh deployment, not a proxy -/// upgrade path). Exposes the raw stored balance and cursor for -/// assertions. -contract InvariantReceipt is StoxReceipt { - function testInit(address vaultAddr) external { - bytes32 slot = 0xe5444a702a2f437387f4eb075af275e349f1dba9a68923d27352f035d01dc200; - assembly { - sstore(slot, vaultAddr) - } - } - - function rawReceiptBalance(address account, uint256 id) external view returns (uint256) { - return LibERC1155Storage.underlyingBalance(account, id); - } - - function holderIdCursor(address account, uint256 id) external view returns (uint256) { - return LibCorporateActionReceipt.getStorage().accountIdCursor[account][id]; - } -} - -/// @dev Handler for the corporate-actions invariant suite. Foundry's invariant -/// fuzzer targets this contract's external functions; each call drives one -/// operation against the vault with bounded, fuzzer-supplied inputs. After -/// every operation that migrates an account, the handler asserts the cursor- -/// equality invariant inline so violations surface at the offending call -/// rather than at the periodic invariant sweep. -/// -/// Actors: a fixed set of 5 addresses plus the zero address (for mint/burn). -/// Multipliers: bounded to {1, 2, 3} (and their reciprocals via fractional -/// form) to keep Float precision inside well-tested territory without losing -/// the fractional vs integer distinction. -/// Amounts: bounded per-op against current balances to avoid OZ underflow -/// reverts that would mask real bugs. -contract StoxCorporateActionsHandler is Test { - InvariantVault public immutable VAULT; - InvariantReceipt public immutable RECEIPT; - - /// @dev Fixed set of actors the handler cycles through. Each actor is - /// paired with a fixed receipt id (`i + 1`) for the proportionality - /// invariant: `deposit` and `withdraw` create / destroy matching amounts - /// of both the share balance and the receipt at that actor's id, so - /// `vault.balanceOf(actor_i) == receipt.balanceOf(actor_i, i+1)` holds - /// at every post-handler-call checkpoint. Share-only transfers would - /// break this proportionality (shares fungible, receipts per-id), so - /// the handler deliberately does not expose them. - address[5] public actors; - - /// @dev Total number of mints executed (for ghost-variable assertions). - uint256 public totalMinted; - /// @dev Total number of burns executed. - uint256 public totalBurned; - /// @dev Track the most recent share-side cursor value per actor, to - /// verify monotonicity (invariant 3). - mapping(address => uint256) public lastSeenCursor; - /// @dev Track the most recent receipt-side cursor per (actor, id) pair - /// for the receipt cursor monotonicity invariant. - mapping(address => mapping(uint256 => uint256)) public lastSeenReceiptCursor; - - constructor(InvariantVault vault_, InvariantReceipt receipt_) { - VAULT = vault_; - RECEIPT = receipt_; - actors[0] = address(0xA11CE); - actors[1] = address(0xB0B); - actors[2] = address(0xCA401); - actors[3] = address(0xDAFE); - actors[4] = address(0xEFFE); - // Start time past 0 so `block.timestamp > 0` and fresh effectiveTime - // values can land strictly in the future. - vm.warp(1000); - } - - /// @dev Each actor has a fixed receipt id = index + 1. Id 0 is avoided - /// (reserved for "no id" semantics in various tests). - function _actorId(uint256 actorIndex) internal pure returns (uint256) { - return actorIndex + 1; - } - - function _actor(uint256 seed) internal view returns (address) { - return actors[seed % actors.length]; - } - - function _asFloat(int256 coefficient, int256 exponent) internal pure returns (Float) { - return LibDecimalFloat.packLossless(coefficient, exponent); - } - - /// @dev Bounded set of stock split multipliers used by schedule. Fractional - /// reverse splits (1/3, 1/2) are produced by `div(packLossless(1,0), - /// packLossless(n,0))`. - function _multiplier(uint256 seed) internal pure returns (bytes memory) { - uint256 bucket = seed % 6; - if (bucket == 0) return LibStockSplit.encodeParametersV1(_asFloat(2, 0)); - if (bucket == 1) return LibStockSplit.encodeParametersV1(_asFloat(3, 0)); - if (bucket == 2) return LibStockSplit.encodeParametersV1(_asFloat(1, 0)); // 1x — a no-op split (valid) - if (bucket == 3) { - return LibStockSplit.encodeParametersV1(LibDecimalFloat.div(_asFloat(1, 0), _asFloat(2, 0))); - } - if (bucket == 4) { - return LibStockSplit.encodeParametersV1(LibDecimalFloat.div(_asFloat(1, 0), _asFloat(3, 0))); - } - // bucket 5: another 2x (slight weighting toward common cases) - return LibStockSplit.encodeParametersV1(_asFloat(2, 0)); - } - - /// @dev Schedule a stock split at a bounded future time. Time is drawn - /// from a small window ahead of `block.timestamp` so multiple scheduled - /// actions end up in mixed order inside the list. - function schedule(uint256 multiplierSeed, uint8 timeDelta) external { - // time in [1, 256] seconds in the future. - uint64 effectiveTime = uint64(block.timestamp + 1 + (uint256(timeDelta) % 256)); - bytes memory parameters = _multiplier(multiplierSeed); - VAULT.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, parameters); - } - - /// @dev Cancel a scheduled action. Bounded to the current nodes array - /// length so it hits legitimate indices most of the time; indices that - /// point at already-cancelled or already-completed nodes revert cleanly - /// (caught by the inline try / ignore pattern). - function cancel(uint256 indexSeed) external { - uint256 len = VAULT.nodesLength(); - if (len <= 1) return; // only sentinel; nothing to cancel - uint256 actionIndex = (indexSeed % (len - 1)) + 1; // in [1, len-1] - // forge-lint: disable-next-line(unchecked-call) - try VAULT.publicCancel(actionIndex) {} catch {} - } - - /// @dev Advance block.timestamp by a bounded delta so scheduled actions - /// cross into the past and `fold()` picks them up on the next _update. - function warp(uint8 delta) external { - vm.warp(block.timestamp + 1 + uint256(delta)); - } - - /// @dev Deposit — mints matching amounts of share and receipt to an - /// actor at their assigned id. Models the real vault flow where a - /// deposit creates both a share balance and a receipt in lockstep. - /// This is the only mint path in the handler so that the - /// share-receipt proportionality invariant holds at every checkpoint. - function deposit(uint256 actorSeed, uint64 amountSeed) external { - uint256 actorIndex = actorSeed % actors.length; - address to = actors[actorIndex]; - uint256 id = _actorId(actorIndex); - uint256 amount = uint256(amountSeed) % 1e24 + 1; - - // Share side. - VAULT.publicUpdate(address(0), to, amount); - - // Receipt side — manager-authorized mint of the same amount at the - // actor's assigned id. `managerMint(sender, account, id, amount, data)`. - vm.prank(address(VAULT)); - RECEIPT.managerMint(address(VAULT), to, id, amount, ""); - - totalMinted += amount; - _assertCursorInvariant(to); - _assertReceiptCursorInvariant(to, id); - _recordCursor(to); - _recordReceiptCursor(to, id); - } - - /// @dev Withdraw — burns matching amounts of share and receipt from an - /// actor at their assigned id. Capped at the actor's current effective - /// balance (which must match the receipt balance by proportionality) - /// so OZ does not underflow. - function withdraw(uint256 actorSeed, uint64 amountSeed) external { - uint256 actorIndex = actorSeed % actors.length; - address from = actors[actorIndex]; - uint256 id = _actorId(actorIndex); - - uint256 effective = VAULT.balanceOf(from); - if (effective == 0) return; - uint256 amount = uint256(amountSeed) % (effective + 1); - if (amount == 0) return; - - // Share side. - VAULT.publicUpdate(from, address(0), amount); - - // Receipt side. - vm.prank(address(VAULT)); - RECEIPT.managerBurn(address(VAULT), from, id, amount, ""); - - totalBurned += amount; - _assertCursorInvariant(from); - _assertReceiptCursorInvariant(from, id); - _recordCursor(from); - _recordReceiptCursor(from, id); - } - - /// @dev Touch an actor by performing a zero-value self-deposit-equivalent - /// on both sides. Exercises cursor-only advancement paths (both share - /// and receipt). - function touch(uint256 actorSeed) external { - uint256 actorIndex = actorSeed % actors.length; - address a = actors[actorIndex]; - uint256 id = _actorId(actorIndex); - - // Share side — zero-value self-send. - VAULT.publicUpdate(a, a, 0); - - // Receipt side — zero-value self-transfer via manager path. - vm.prank(address(VAULT)); - RECEIPT.managerTransferFrom(address(VAULT), a, a, id, 0, ""); - - _assertCursorInvariant(a); - _assertReceiptCursorInvariant(a, id); - _recordCursor(a); - _recordReceiptCursor(a, id); - } - - // ----------------------------------------------------------------------- - // Ghost assertions / recording - - /// @dev Assert cursor invariant #4: after any migration, the actor's - /// cursor equals the global `totalSupplyLatestCursor`. - function _assertCursorInvariant(address a) internal view { - assertEq( - VAULT.migrationCursor(a), - VAULT.totalSupplyLatestCursor(), - "invariant 4: cursor(actor) == totalSupplyLatestCursor after migrateAccount" - ); - } - - /// @dev Record the actor's cursor to check monotonicity (invariant 3). - /// Cursor IDs are allocation indices, not chronological positions — a - /// later-scheduled action with an earlier effectiveTime is inserted - /// before older actions in the list, so a valid migration can move a - /// cursor to a numerically lower id. Monotonicity is therefore defined - /// in LIST order (forward along `next` pointers), not numeric order. - function _recordCursor(address a) internal { - uint256 current = VAULT.migrationCursor(a); - uint256 last = lastSeenCursor[a]; - assertTrue( - cursorReachableForward(last, current), "invariant 3: per-actor cursor must advance forward in list order" - ); - lastSeenCursor[a] = current; - } - - /// @dev After any migration on the receipt, the (holder, id) cursor - /// equals the vault's `totalSupplyLatestCursor`. A receipt cursor that - /// drifted behind would cause `LibReceiptRebase.migratedBalance` to - /// silently re-apply multipliers to an already-rasterized stored - /// balance on the next read. - function _assertReceiptCursorInvariant(address a, uint256 id) internal view { - assertEq( - RECEIPT.holderIdCursor(a, id), - VAULT.totalSupplyLatestCursor(), - "receipt invariant: holderIdCursor == totalSupplyLatestCursor after migrateHolderId" - ); - } - - /// @dev Record the receipt-side cursor to check per-(holder, id) - /// monotonicity. Same list-order semantics as the share side: a - /// later-scheduled earlier-effective split can land at a numerically - /// smaller node id but still be reachable forward from `last`, so a - /// raw `assertGe` would falsely flag a valid schedule. Walk forward - /// via `next` pointers instead. - function _recordReceiptCursor(address a, uint256 id) internal { - uint256 current = RECEIPT.holderIdCursor(a, id); - uint256 last = lastSeenReceiptCursor[a][id]; - assertTrue( - cursorReachableForward(last, current), - "receipt invariant: per-(holder, id) cursor must advance forward in list order" - ); - lastSeenReceiptCursor[a][id] = current; - } - - /// @dev True iff `current` is `last` itself or reachable by walking - /// `next` pointers starting from `last`. Walk is bounded by - /// `nodesLength` so a cycle (violation of invariant 1) does not hang. - /// External so framework-level invariants can call it too. - function cursorReachableForward(uint256 last, uint256 current) public view returns (bool) { - if (last == current) return true; - - uint256 len = VAULT.nodesLength(); - uint256 cursor = last; - for (uint256 i = 0; i < len && cursor != NODE_NONE; i++) { - cursor = VAULT.getNode(cursor).next; - if (cursor == current) return true; - } - return false; - } - - function actorCount() external pure returns (uint256) { - return 5; - } - - function actor(uint256 i) external view returns (address) { - return actors[i]; - } -} +import {BALANCE_MIGRATION_TYPES_MASK} from "../../../src/interface/ICorporateActionsV1.sol"; +import {CorporateActionNode, CompletionFilter, NODE_NONE} from "../../../src/lib/LibCorporateActionNode.sol"; +import {InvariantVault} from "./InvariantVault.sol"; +import {InvariantReceipt} from "./InvariantReceipt.sol"; +import {StoxCorporateActionsHandler} from "./StoxCorporateActionsHandler.sol"; /// @title StoxCorporateActionsInvariantTest /// @notice Stateful invariant suite for the corporate-actions system. A diff --git a/test/src/concrete/StoxReceipt.t.sol b/test/src/concrete/StoxReceipt.t.sol index a984df9c..942f0e9a 100644 --- a/test/src/concrete/StoxReceipt.t.sol +++ b/test/src/concrete/StoxReceipt.t.sol @@ -2,23 +2,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; +import {Test} from "forge-std-1.16.1/src/Test.sol"; import {StoxReceipt} from "../../../src/concrete/StoxReceipt.sol"; import {Initializable} from "@openzeppelin-contracts-upgradeable-5.6.1/proxy/utils/Initializable.sol"; -import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; -import { - ICorporateActionsV1, - ACTION_TYPE_STOCK_SPLIT_V1, - BALANCE_MIGRATION_TYPES_MASK -} from "../../../src/interface/ICorporateActionsV1.sol"; -import {CompletionFilter, NODE_NONE} from "../../../src/lib/LibCorporateActionNode.sol"; -import { - LibCorporateActionReceipt, - CORPORATE_ACTION_RECEIPT_STORAGE_LOCATION -} from "../../../src/lib/LibCorporateActionReceipt.sol"; -import {LibERC1155Storage} from "../../../src/lib/LibERC1155Storage.sol"; -import {IReceiptManagerV2} from "rain-vats-0.1.6/src/interface/IReceiptManagerV2.sol"; -import {IERC1155Errors} from "@openzeppelin-contracts-5.6.1/interfaces/draft-IERC6093.sol"; contract StoxReceiptTest is Test { /// Constructor disables initializers on the implementation. @@ -28,1110 +14,3 @@ contract StoxReceiptTest is Test { impl.initialize(abi.encode(address(1))); } } - -/// @dev Mock vault combining `ICorporateActionsV1` (corporate-action read -/// surface) and `IReceiptManagerV2` (receipt transfer authorizer). The -/// receipt's base `_update` calls `s.manager.authorizeReceiptTransfer3(...)` -/// before applying the transfer, and our override reads multipliers via -/// `this.manager()` cast to `ICorporateActionsV1`. A single mock serving -/// both interfaces matches the real topology where the vault is a single -/// contract implementing both. -/// -/// `IReceiptManagerV2` also requires `symbol()`, `decimals()` etc. via the -/// Receipt's `getVaultShareSymbol` helper. In tests we only call `balanceOf` -/// and `_update` paths that don't hit `uri()`, so the stub implementations -/// below are minimal. -contract MockVault is ICorporateActionsV1, IReceiptManagerV2 { - error ReceiptTransferDenied(); - - bytes[] internal splits; // splits[i-1] is the parameters blob for cursor i - bool public denyTransfers; - - /// Authorize hook — allows or denies based on `denyTransfers`. - function authorizeReceiptTransfer3(address, address, address, uint256[] memory, uint256[] memory) - external - view - override - { - if (denyTransfers) revert ReceiptTransferDenied(); - } - - function setDenyTransfers(bool deny) external { - denyTransfers = deny; - } - - function addSplit(Float multiplier) external { - splits.push(abi.encode(multiplier)); - } - - // ICorporateActionsV1 - - function nextOfType(uint256 cursor, uint256 mask, CompletionFilter filter) - external - view - override - returns (uint256, uint256, uint64) - { - // Receipt rebase walks `BALANCE_MIGRATION_TYPES_MASK` (init | - // stock-split). This mock holds only splits — no init node — so - // walking that mask returns the same sequence as walking the - // stock-split bit alone. - require(mask == BALANCE_MIGRATION_TYPES_MASK, "mock: unexpected mask"); - require(filter == CompletionFilter.COMPLETED, "mock: unexpected filter"); - // Cursor 0 is the vault's bootstrap (identity); splits live at - // 1..splits.length. The "no more nodes" sentinel is `NODE_NONE`, - // matching the real vault's contract. - if (cursor == NODE_NONE) { - // Empty splits → no-more-nodes shape (NODE_NONE, 0, 0), matching - // the cursor-walked-past-end branch below. Returning a non-zero - // actionType for a NODE_NONE cursor is a contract violation - // CodeRabbit caught — would let traversal tests pass against - // states the real vault never produces. - if (splits.length == 0) return (NODE_NONE, 0, 0); - return (1, ACTION_TYPE_STOCK_SPLIT_V1, 1); - } - uint256 candidate = cursor + 1; - if (candidate > splits.length) { - return (NODE_NONE, 0, 0); - } - return (candidate, ACTION_TYPE_STOCK_SPLIT_V1, 1); - } - - function getActionParameters(uint256 cursor) external view override returns (bytes memory) { - require(cursor >= 1 && cursor <= splits.length, "mock: cursor out of range"); - return splits[cursor - 1]; - } - - function scheduleCorporateAction(bytes32, uint64, bytes calldata) external pure override returns (uint256) { - revert("mock"); - } - - function cancelCorporateAction(uint256) external pure override { - revert("mock"); - } - - function completedActionCount() external view override returns (uint256) { - return splits.length; - } - - function latestActionOfType(uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { - revert("mock"); - } - - function earliestActionOfType(uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { - revert("mock"); - } - - function prevOfType(uint256, uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { - revert("mock"); - } - - /// Expose minimal IERC20Metadata surface that `Receipt.getVaultShareSymbol` - /// calls via `IERC20Metadata(address(manager)).symbol()`. Not actually - /// used in our tests (we never hit `uri()`), but the `_update` path may - /// touch it if anything inspects name/symbol. Stubbed for safety. - function symbol() external pure returns (string memory) { - return "TEST"; - } - - function decimals() external pure returns (uint8) { - return 18; - } - - function asset() external view returns (address) { - return address(this); - } -} - -/// @dev Test-only subclass that exposes an `initialize` path bypassing the -/// `initializer` modifier of the real `Receipt`, so tests can directly drive -/// a `StoxReceipt` against our mock. `publicManagerMint` / `publicManagerBurn` -/// go through the vault-as-manager path. -contract TestStoxReceipt is StoxReceipt { - function testInit(address vaultAddr) external { - // Bypass ethgild's `initializer` lock by writing the manager slot - // directly. We're initializing a fresh deployment in-test, so the - // one-shot initializer guard is irrelevant for our purposes. - bytes32 slot = 0xe5444a702a2f437387f4eb075af275e349f1dba9a68923d27352f035d01dc200; - assembly { - sstore(slot, vaultAddr) - } - } - - /// Expose direct storage read so tests can inspect the raw stored - /// balance (pre-rebase) without going through the `balanceOf` override. - function rawStoredBalance(address account, uint256 id) external view returns (uint256) { - return LibERC1155Storage.underlyingBalance(account, id); - } - - /// Expose the cursor for assertions. - function holderIdCursor(address account, uint256 id) external view returns (uint256) { - return LibCorporateActionReceipt.getStorage().accountIdCursor[account][id]; - } - - /// Expose internal migration so tests can exercise the zero-address - /// short-circuit directly. - function publicMigrateHolderId(address account, uint256 id) external { - migrateHolderId(account, id, ICorporateActionsV1(this.manager())); - } -} - -contract StoxReceiptRebaseIntegrationTest is Test { - TestStoxReceipt internal receipt; - MockVault internal vault; - - address internal constant ALICE = address(0xA11CE); - address internal constant BOB = address(0xB0B); - - uint256 internal constant ID_A = 1; - uint256 internal constant ID_B = 2; - - function setUp() public { - vault = new MockVault(); - receipt = new TestStoxReceipt(); - receipt.testInit(address(vault)); - } - - function _splitParams(int256 multiplier) internal { - vault.addSplit(LibDecimalFloat.packLossless(multiplier, 0)); - } - - function _fractionalParams(int256 num, int256 denom) internal { - vault.addSplit( - LibDecimalFloat.div(LibDecimalFloat.packLossless(num, 0), LibDecimalFloat.packLossless(denom, 0)) - ); - } - - // ----------------------------------------------------------------------- - // Default cursor pin - - /// Pre-mint receipt-side fresh-pair cursor pin: an `(account, id)` - /// pair that has never been touched returns `holderIdCursor == 0` - /// from the `accountIdCursor` mapping. The 0-based scheme leans on - /// this default — 0 is the vault's bootstrap node, so a fresh pair - /// is implicitly "at bootstrap" and the first migration walk advances - /// from after-bootstrap. A regression that changed the namespace, - /// mapping shape, or initialised cursors to anything other than 0 - /// surfaces here. - function testReceiptCursorDefaultsToBootstrapForFreshPair() external view { - address fresh = address(0xCAFE); - uint256 freshId = 999; - assertEq(receipt.holderIdCursor(fresh, freshId), 0, "fresh (account, id) cursor defaults to 0 (= bootstrap)"); - } - - // ----------------------------------------------------------------------- - // Storage-slot pin tests - - /// The hardcoded ERC-7201 slot constant for LibCorporateActionReceipt - /// matches its documented derivation formula. - function testReceiptCorporateActionSlotMatchesDerivation() external pure { - bytes32 expected = keccak256(abi.encode(uint256(keccak256("rain.storage.corporate-action-receipt.1")) - 1)) - & ~bytes32(uint256(0xff)); - assertEq( - CORPORATE_ACTION_RECEIPT_STORAGE_LOCATION, - expected, - "receipt corporate-action storage slot must match derivation" - ); - } - - /// Layout pin: each field of `CorporateActionReceiptStorage` lives at - /// its expected offset from the namespace base. Must be extended for - /// every later PR that appends a new field. See the DO NOT REORDER - /// comment on the struct. - function testReceiptStorageLayoutPin() external { - // accountIdCursor is at offset 0 within the struct. Poke a key via - // the library accessor (indirectly by setting a cursor through a - // full mint+split+touch path) and assert the entry lives at the - // expected derived slot. - bytes32 base = CORPORATE_ACTION_RECEIPT_STORAGE_LOCATION; - - // Use a sentinel holder + id. - address holder = address(0xBEEF); - uint256 id = 0xCAFE; - - // Write a sentinel directly to the outer mapping slot at offset 0, - // then read through the library accessor to verify that offset 0 - // is the accountIdCursor mapping base. - bytes32 outerSlot = keccak256(abi.encode(holder, base)); - bytes32 entrySlot = keccak256(abi.encode(id, outerSlot)); - vm.store(address(receipt), entrySlot, bytes32(uint256(0x12345))); - - assertEq( - receipt.holderIdCursor(holder, id), - 0x12345, - "accountIdCursor mapping must be at offset 0 in CorporateActionReceiptStorage" - ); - } - - // ----------------------------------------------------------------------- - // Rebase integration — happy path - - /// Before any splits, balanceOf returns the raw stored balance. - function testBalanceOfNoSplits() external { - // Mint directly to Alice via vault-as-manager path. - _mint(ALICE, ID_A, 100); - assertEq(receipt.balanceOf(ALICE, ID_A), 100); - } - - /// After a 2x split, balanceOf returns the rebased balance even before - /// migration actually runs (view-only multiplier application). - function testBalanceOfAfterSplitPreMigration() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - assertEq(receipt.balanceOf(ALICE, ID_A), 200, "view-only rebase must reflect the split"); - // Stored balance hasn't actually changed yet (no touch). - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 100, "rasterize is lazy"); - } - - /// A touch via zero-value manager transfer migrates the stored balance. - function testMigrationOnTransferRasterizesStoredBalance() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - // Self-transfer of 0 — triggers _update on (Alice, ID_A) from both - // sides and rasterizes Alice's stored balance. - _transfer(ALICE, ALICE, ID_A, 0); - - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 200, "stored balance rasterized"); - assertEq(receipt.balanceOf(ALICE, ID_A), 200); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "cursor advanced to first split"); - } - - /// Mint to a fresh recipient after a completed split credits exactly - /// the minted amount, not multiplied by the split. Without the - /// zero-balance cursor-advancement guard, the recipient's freshly- - /// written post-rebase balance would be re-multiplied on the next - /// `balanceOf` read. - function testMintToFreshRecipientAfterSplitDoesNotInflate() external { - // Pre-existing supply so the split has something to rebase. - _mint(BOB, ID_A, 1000); - - _splitParams(2); - - // Alice mints 100 AFTER the split. Should receive exactly 100. - _mint(ALICE, ID_A, 100); - assertEq(receipt.balanceOf(ALICE, ID_A), 100, "fresh recipient must not over-multiply on mint"); - } - - /// Transfer to a fresh recipient after a completed split: recipient - /// receives the transferred amount exactly, not multiplied. - function testTransferToFreshRecipientAfterSplitDoesNotInflate() external { - _mint(BOB, ID_A, 50); - _splitParams(2); - - // Bob's effective balance is now 100. Transfer all 100 to Alice. - _transfer(BOB, ALICE, ID_A, 100); - - assertEq(receipt.balanceOf(ALICE, ID_A), 100, "recipient got exactly the transferred amount"); - assertEq(receipt.balanceOf(BOB, ID_A), 0, "Bob is now empty"); - } - - /// Per-(holder, id) cursor independence: Alice's cursor for ID_A - /// advances without touching her cursor for ID_B or anyone else's. - function testPerHolderIdCursorIndependence() external { - _mint(ALICE, ID_A, 100); - _mint(ALICE, ID_B, 200); - _mint(BOB, ID_A, 300); - - _splitParams(2); - - // Touch only (Alice, ID_A) via a zero-value manager transfer for - // that specific id. - _transfer(ALICE, ALICE, ID_A, 0); - - // (Alice, ID_A) is at cursor 1 and rasterized. - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 200); - - // (Alice, ID_B) and (Bob, ID_A) are untouched — cursor 0, raw - // balance unchanged, but view balanceOf still reflects the split. - assertEq(receipt.holderIdCursor(ALICE, ID_B), 0); - assertEq(receipt.rawStoredBalance(ALICE, ID_B), 200); - assertEq(receipt.balanceOf(ALICE, ID_B), 400, "view override still applies the split"); - - assertEq(receipt.holderIdCursor(BOB, ID_A), 0); - assertEq(receipt.rawStoredBalance(BOB, ID_A), 300); - assertEq(receipt.balanceOf(BOB, ID_A), 600); - } - - /// A fresh recipient whose stored balance is 0 at the time of a split - /// has their cursor advanced on first touch, so a subsequent mint or - /// transfer-in does not re-apply the multiplier on top of an - /// already-rebased raw balance. - function testZeroBalanceCursorAdvancesOnFreshRecipient() external { - _mint(BOB, ID_A, 1000); - _splitParams(2); - - // Touch Alice (who has 0 balance) with a 0-value transfer. - _transfer(ALICE, ALICE, ID_A, 0); - - // Alice's cursor must now point at the split, even though her raw - // balance was never rewritten. - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "zero-balance cursor must advance"); - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 0); - - // A subsequent mint lands at cursor 1 (post-split basis). The - // stored balance write of 100 must NOT be re-multiplied by the - // split on a later balanceOf read. - _mint(ALICE, ID_A, 100); - assertEq(receipt.balanceOf(ALICE, ID_A), 100, "post-touch mint must not re-inflate"); - } - - /// Sequential precision — receipt side must match share side exactly. - /// 1/3 × 3 × 1/3 × 3 applied to 100 = 96. - function testSequentialPrecisionMatchesShareSide() external { - _mint(ALICE, ID_A, 100); - - _fractionalParams(1, 3); - _splitParams(3); - _fractionalParams(1, 3); - _splitParams(3); - - // Touch to rasterize. - _transfer(ALICE, ALICE, ID_A, 0); - - assertEq(receipt.balanceOf(ALICE, ID_A), 96, "receipt side must match share-side sequential precision"); - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 96); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 4); - } - - /// Batch update: a batch transfer touching multiple ids migrates all of - /// them independently. - function testBatchUpdateMigratesEachIdIndependently() external { - _mint(ALICE, ID_A, 100); - _mint(ALICE, ID_B, 200); - _splitParams(2); - - uint256[] memory ids = new uint256[](2); - ids[0] = ID_A; - ids[1] = ID_B; - uint256[] memory amounts = new uint256[](2); - amounts[0] = 0; // zero-value batch transfer just to touch both ids - amounts[1] = 0; - - // Alice calls safeBatchTransferFrom herself — sender == from so no - // operator approval is required. - vm.prank(ALICE); - receipt.safeBatchTransferFrom(ALICE, ALICE, ids, amounts, ""); - - // Both (Alice, ID_A) and (Alice, ID_B) must be at cursor 1 and - // rasterized. - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); - assertEq(receipt.holderIdCursor(ALICE, ID_B), 1); - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 200); - assertEq(receipt.rawStoredBalance(ALICE, ID_B), 400); - } - - /// After burning the full post-rebase balance to zero, a subsequent - /// transfer-in credits exactly the transferred amount. The zero-balance - /// cursor advancement on burn keeps the holder at the latest cursor, - /// so the next write is not re-multiplied on the next `balanceOf`. - function testTransferInAfterBurnToZeroCreditsExact() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - _burn(ALICE, ID_A, 200); - - _mint(BOB, ID_A, 50); - _transfer(BOB, ALICE, ID_A, 50); - - assertEq(receipt.balanceOf(ALICE, ID_A), 50); - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 50); - } - - /// `migrateHolderId(address(0), ...)` short-circuits. After a - /// completed split, calling it for address(0) must leave address(0)'s - /// cursor and balance at their zero-initialized values (no state - /// pollution on the zero address). - function testMigrateHolderIdZeroAddressIsNoOp() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - receipt.publicMigrateHolderId(address(0), ID_A); - - assertEq(receipt.holderIdCursor(address(0), ID_A), 0, "zero address cursor must not advance"); - assertEq(receipt.rawStoredBalance(address(0), ID_A), 0, "zero address balance must stay zero"); - } - - /// When the manager's `authorizeReceiptTransfer3` reverts, the - /// transfer reverts and no migration state persists. Migration runs - /// before `super._update` so the authorizer denial inside - /// `super._update` would roll back whatever migration wrote if the - /// revert propagated. Verify Alice's cursor and stored balance are - /// unchanged after the denied call. - function testAuthorizerDeniedTransferRollsBackMigration() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - vault.setDenyTransfers(true); - - vm.prank(ALICE); - vm.expectRevert(MockVault.ReceiptTransferDenied.selector); - receipt.safeTransferFrom(ALICE, BOB, ID_A, 50, ""); - - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 100, "raw stored balance unchanged after revert"); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 0, "cursor unchanged after revert"); - } - - /// A non-owner, non-approved caller cannot transfer someone else's - /// balance. OZ's approval check runs inside `super._update`, which - /// migration precedes. Test asserts the approval revert fires with - /// the exact operator/owner pair. - function testUnauthorizedTransferRevertsMissingApproval() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - vm.prank(BOB); - vm.expectRevert(abi.encodeWithSelector(IERC1155Errors.ERC1155MissingApprovalForAll.selector, BOB, ALICE)); - receipt.safeTransferFrom(ALICE, BOB, ID_A, 50, ""); - } - - /// Holder-initiated `safeTransferFrom` (no operator approval) moves up - /// to the post-rebase balance. Distinct from the operator path — the - /// holder's own `msg.sender == from` call bypasses the approval check - /// but should still see the rebased ceiling. - function testFuzzHolderDirectTransferUsesPostRebaseBalance(uint64 deposit, uint128 amount, uint8 mulSeed) external { - // mulSeed bound to [2,5] so int256 cast cannot overflow. - // forge-lint: disable-next-line(unsafe-typecast) - int256 multiplier = int256(uint256(bound(mulSeed, 2, 5))); - // bound result is < type(uint64).max so uint64 cast is safe; - // multiplier is in [2,5] so uint64(uint256(multiplier)) cannot truncate. - // forge-lint: disable-next-line(unsafe-typecast) - deposit = uint64(bound(deposit, 1, type(uint64).max / uint64(uint256(multiplier)))); - // multiplier is bounded to [2,5] so the int256 → uint256 cast is safe. - // forge-lint: disable-next-line(unsafe-typecast) - uint256 postRebase = uint256(deposit) * uint256(multiplier); - uint256 xfer = bound(amount, 0, postRebase); - - _mint(ALICE, ID_A, deposit); - _splitParams(multiplier); - - vm.prank(ALICE); - receipt.safeTransferFrom(ALICE, BOB, ID_A, xfer, ""); - - assertEq(receipt.balanceOf(ALICE, ID_A), postRebase - xfer); - assertEq(receipt.balanceOf(BOB, ID_A), xfer); - } - - /// Minting additional balance to a holder who already has a pre-split - /// position rasterizes first, then adds the mint amount. The mint is - /// denominated in post-rebase units — so the holder ends up with - /// `oldBalance * multiplier + mintAmount`, not `(oldBalance + - /// mintAmount) * multiplier`. - function testMintToExistingHolderAfterSplitRasterizesFirst() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - _mint(ALICE, ID_A, 50); - - assertEq(receipt.balanceOf(ALICE, ID_A), 250, "200 post-rebase + 50 minted"); - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 250); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); - } - - /// An approved operator can move up to the post-rebase balance for - /// any transfer amount in `[0, postRebase]`. Approval semantics are - /// unchanged by the rebase — the operator sees the rebased ceiling, - /// not the raw stored value. - function testFuzzApprovedOperatorTransfersPostRebaseBalance(uint64 deposit, uint128 transferAmount, uint8 mulSeed) - external - { - // mulSeed bound to [2,5] so int256 cast cannot overflow. - // forge-lint: disable-next-line(unsafe-typecast) - int256 multiplier = int256(uint256(bound(mulSeed, 2, 5))); - // bound result is < type(uint64).max; multiplier in [2,5] so - // uint64(uint256(multiplier)) cannot truncate. - // forge-lint: disable-next-line(unsafe-typecast) - deposit = uint64(bound(deposit, 1, type(uint64).max / uint64(uint256(multiplier)))); - // multiplier is bounded to [2,5] so the int256 → uint256 cast is safe. - // forge-lint: disable-next-line(unsafe-typecast) - uint256 postRebase = uint256(deposit) * uint256(multiplier); - uint256 amount = bound(transferAmount, 0, postRebase); - - _mint(ALICE, ID_A, deposit); - _splitParams(multiplier); - - vm.prank(ALICE); - receipt.setApprovalForAll(BOB, true); - - vm.prank(BOB); - receipt.safeTransferFrom(ALICE, BOB, ID_A, amount, ""); - - assertEq(receipt.balanceOf(ALICE, ID_A), postRebase - amount); - assertEq(receipt.balanceOf(BOB, ID_A), amount); - } - - /// `balanceOfBatch` returns an empty array when called with empty - /// inputs, matching OZ behavior. Does not revert despite the override. - function testBalanceOfBatchEmptyInputsReturnsEmpty() external view { - address[] memory accounts = new address[](0); - uint256[] memory ids = new uint256[](0); - uint256[] memory result = receipt.balanceOfBatch(accounts, ids); - assertEq(result.length, 0); - } - - /// A reverse split that truncates the balance to zero followed by a - /// forward split does not re-inflate. For any balance strictly less - /// than `denom`, `balance * 1/denom` truncates to 0, and every - /// subsequent multiplier applied to 0 stays at 0. - function testFuzzFractionalSplitTruncatingToZeroDoesNotReInflate(uint8 balanceSeed, uint8 denomSeed, uint8 mulSeed) - external - { - int256 denom = int256(uint256(bound(denomSeed, 2, 100))); - // forge-lint: disable-next-line(unsafe-typecast) - uint256 balance = bound(uint256(balanceSeed), 1, uint256(denom) - 1); - int256 multiplier = int256(uint256(bound(mulSeed, 2, 100))); - - _mint(ALICE, ID_A, balance); - _fractionalParams(1, denom); - _splitParams(multiplier); - - assertEq(receipt.balanceOf(ALICE, ID_A), 0, "view path sees truncated zero"); - - _transfer(ALICE, ALICE, ID_A, 0); - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 0); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 2); - assertEq(receipt.balanceOf(ALICE, ID_A), 0, "post-migration balance remains zero"); - } - - /// Empty batch `safeBatchTransferFrom` is a no-op — the migration - /// loop runs zero iterations, OZ's super._update accepts empty - /// arrays, and no state changes. - function testBatchUpdateEmptyArraysIsNoOp() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - uint256[] memory ids = new uint256[](0); - uint256[] memory amounts = new uint256[](0); - - vm.prank(ALICE); - receipt.safeBatchTransferFrom(ALICE, BOB, ids, amounts, ""); - - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 100, "no migration means no rasterization"); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 0, "no migration means cursor stays"); - } - - /// In a multi-id batch transfer, `ReceiptAccountMigrated` events fire - /// in `(from, ids[0]), (to, ids[0]), (from, ids[1]), (to, ids[1])` - /// order, all before the `TransferBatch` event. Bob's zero-balance - /// migrations also emit (per #81 — every cursor advance fires). - /// Indexers rely on this interleaving: for each event emitted for - /// `ids[i]`, the balance at that cursor for that id is the rasterized - /// value, not yet touched by the transfer. - function testBatchUpdateEventOrderingAcrossIds() external { - _mint(ALICE, ID_A, 100); - _mint(ALICE, ID_B, 200); - _splitParams(2); - - uint256[] memory ids = new uint256[](2); - ids[0] = ID_A; - ids[1] = ID_B; - uint256[] memory amounts = new uint256[](2); - amounts[0] = 10; - amounts[1] = 20; - - vm.recordLogs(); - vm.prank(ALICE); - receipt.safeBatchTransferFrom(ALICE, BOB, ids, amounts, ""); - - Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 migratedSig = StoxReceipt.ReceiptAccountMigrated.selector; - bytes32 batchSig = keccak256("TransferBatch(address,address,address,uint256[],uint256[])"); - - uint256[] memory migratedOrder = new uint256[](4); - uint256 migratedIdx = 0; - uint256 batchIdx = type(uint256).max; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics.length == 0) continue; - if (logs[i].topics[0] == migratedSig && migratedIdx < 4) { - address who = address(uint160(uint256(logs[i].topics[1]))); - uint256 id = uint256(logs[i].topics[2]); - // forge-lint: disable-next-line(unsafe-typecast) - migratedOrder[migratedIdx++] = uint256(uint160(who)) << 96 | uint256(uint96(id)); - } else if (logs[i].topics[0] == batchSig) { - batchIdx = i; - } - } - - assertEq(migratedIdx, 4, "every (holder, id) cursor advance emits: alice ID_A/ID_B + bob ID_A/ID_B"); - // forge-lint: disable-next-line(unsafe-typecast) - assertEq(migratedOrder[0], uint256(uint160(ALICE)) << 96 | uint96(ID_A), "first emit is (alice, ID_A)"); - // forge-lint: disable-next-line(unsafe-typecast) - assertEq(migratedOrder[1], uint256(uint160(BOB)) << 96 | uint96(ID_A), "second emit is (bob, ID_A)"); - // forge-lint: disable-next-line(unsafe-typecast) - assertEq(migratedOrder[2], uint256(uint160(ALICE)) << 96 | uint96(ID_B), "third emit is (alice, ID_B)"); - // forge-lint: disable-next-line(unsafe-typecast) - assertEq(migratedOrder[3], uint256(uint160(BOB)) << 96 | uint96(ID_B), "fourth emit is (bob, ID_B)"); - assertLt(batchIdx, type(uint256).max, "TransferBatch must be emitted"); - } - - /// A batch with a duplicate id migrates the (holder, id) pair once - /// and subtracts the sum of all amount entries from the sender. Holds - /// for any pair of amounts whose sum is at most the post-rebase - /// ceiling. - function testFuzzBatchUpdateWithDuplicateIdTransfersFullSum(uint32 deposit, uint64 a, uint64 b) external { - deposit = uint32(bound(deposit, 1, type(uint32).max)); - uint256 postRebase = uint256(deposit) * 2; - a = uint64(bound(a, 0, postRebase)); - b = uint64(bound(b, 0, postRebase - a)); - - _mint(ALICE, ID_A, deposit); - _splitParams(2); - - uint256[] memory ids = new uint256[](2); - ids[0] = ID_A; - ids[1] = ID_A; - uint256[] memory amounts = new uint256[](2); - amounts[0] = a; - amounts[1] = b; - - vm.prank(ALICE); - receipt.safeBatchTransferFrom(ALICE, BOB, ids, amounts, ""); - - assertEq(receipt.balanceOf(ALICE, ID_A), postRebase - uint256(a) - uint256(b)); - assertEq(receipt.balanceOf(BOB, ID_A), uint256(a) + uint256(b)); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); - assertEq(receipt.holderIdCursor(BOB, ID_A), 1); - } - - /// ReceiptAccountMigrated event is emitted for non-trivial migrations. - function testReceiptAccountMigratedEventEmitted() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - vm.expectEmit(true, true, false, true, address(receipt)); - emit StoxReceipt.ReceiptAccountMigrated(ALICE, ID_A, 0, 1, 100, 200); - - _transfer(ALICE, ALICE, ID_A, 0); - } - - /// A dormant `(holder, id)` touched after multiple completed splits - /// emits exactly one `ReceiptAccountMigrated` with aggregated fields: - /// fromCursor is the pre-migration cursor, toCursor is the latest - /// completed split, oldBalance is the raw stored value, newBalance is - /// the fully-rasterized value after all multipliers have been applied. - function testReceiptAccountMigratedAggregatesAcrossMultipleSplits() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - _splitParams(3); - - vm.recordLogs(); - _transfer(ALICE, ALICE, ID_A, 0); - - bytes32 sig = StoxReceipt.ReceiptAccountMigrated.selector; - Vm.Log[] memory logs = vm.getRecordedLogs(); - uint256 count = 0; - uint256 fromCursor; - uint256 toCursor; - uint256 oldBalance; - uint256 newBalance; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics.length > 0 && logs[i].topics[0] == sig) { - count++; - assertEq(address(uint160(uint256(logs[i].topics[1]))), ALICE, "indexed account is ALICE"); - assertEq(uint256(logs[i].topics[2]), ID_A, "indexed id is ID_A"); - (fromCursor, toCursor, oldBalance, newBalance) = - abi.decode(logs[i].data, (uint256, uint256, uint256, uint256)); - } - } - assertEq(count, 1, "exactly one ReceiptAccountMigrated per multi-split migration"); - assertEq(fromCursor, 0, "fromCursor is pre-migration cursor"); - assertEq(toCursor, 2, "toCursor is latest completed split index"); - assertEq(oldBalance, 100, "oldBalance is pre-rasterization stored value"); - assertEq(newBalance, 600, "newBalance is fully rasterized (100 * 2 * 3)"); - } - - // ----------------------------------------------------------------------- - // Issue #81: receipt-side always-emit semantics. - // - // Mirrors the share-side test matrix on `StoxReceiptVault.t.sol`. - // `migrateHolderId` must emit `ReceiptAccountMigrated` on every - // cursor advance, regardless of whether the rasterized balance equals - // the pre-rebase balance. Four phenomena drive a balance-equal - // cursor advance: zero balance, single-step truncation collision, - // multi-step round-trip, and balance-specific identity. - - /// Receipt phenomenon 1 (zero balance): a fresh `(holder, id)` pair - /// touched after a completed split advances its cursor; the event - /// fires with `oldBalance == newBalance == 0`. - function testReceiptAccountMigratedFiresOnZeroBalanceCursorAdvance() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - // Transfer zero from ALICE to BOB at ID_A — BOB's (BOB, ID_A) - // pair is fresh, but his cursor still advances. - vm.expectEmit(true, true, false, true, address(receipt)); - emit StoxReceipt.ReceiptAccountMigrated(BOB, ID_A, 0, 1, 0, 0); - - _transfer(ALICE, BOB, ID_A, 0); - } - - /// Receipt phenomenon 2 (single-step truncation collision): a stored - /// 1 through a 1.5x multiplier rasterizes to `trunc(1.5) == 1`. The - /// cursor advances; the stored balance is unchanged; the event fires. - function testReceiptAccountMigratedFiresOnTruncationCollision() external { - _mint(ALICE, ID_A, 1); - _fractionalParams(3, 2); - - vm.expectEmit(true, true, false, true, address(receipt)); - emit StoxReceipt.ReceiptAccountMigrated(ALICE, ID_A, 0, 1, 1, 1); - - _transfer(ALICE, ALICE, ID_A, 0); - - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 1, "stored balance unchanged after truncation collision"); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "alice cursor advanced past the split"); - } - - /// Receipt phenomenon 3 (multi-step round-trip): a balance of 4 - /// through `[2x, 1/2x]` rasterizes `4 -> 8 -> 4`. Float represents - /// 1/2 exactly in base 10, so this round-trips deterministically. - /// Cursor jumps two splits in a single `_update`; the event fires - /// once with `oldBalance == newBalance == 4`. - function testReceiptAccountMigratedFiresOnMultiStepRoundTrip() external { - _mint(ALICE, ID_A, 4); - _splitParams(2); - _fractionalParams(1, 2); - - vm.expectEmit(true, true, false, true, address(receipt)); - emit StoxReceipt.ReceiptAccountMigrated(ALICE, ID_A, 0, 2, 4, 4); - - _transfer(ALICE, ALICE, ID_A, 0); - - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 4, "stored balance round-tripped to itself"); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 2, "alice cursor advanced past both splits"); - } - - /// Receipt phenomenon 4 (balance-specific identity): a balance of 10 - /// through a 1.09x multiplier rasterizes to `trunc(10.9) == 10`. The - /// same multiplier on a larger balance produces a real change; the - /// no-op here is balance-specific. The event fires. - function testReceiptAccountMigratedFiresOnBalanceSpecificIdentity() external { - _mint(ALICE, ID_A, 10); - _fractionalParams(109, 100); - - vm.expectEmit(true, true, false, true, address(receipt)); - emit StoxReceipt.ReceiptAccountMigrated(ALICE, ID_A, 0, 1, 10, 10); - - _transfer(ALICE, ALICE, ID_A, 0); - - assertEq( - receipt.rawStoredBalance(ALICE, ID_A), 10, "stored balance unchanged for this specific balance / multiplier" - ); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "alice cursor advanced"); - } - - /// Already-migrated complement: a `(holder, id)` pair at the latest - /// cursor that gets touched again with no new completed splits in - /// between must NOT re-emit `ReceiptAccountMigrated`. Pins the - /// `newCursor == currentCursor` early return in `migrateHolderId`. - function testReceiptAccountMigratedDoesNotReEmitWhenAlreadyAtLatest() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - // First touch migrates Alice — event fires. - _transfer(ALICE, ALICE, ID_A, 0); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "alice migrated to cursor 1"); - - // Second touch with no new splits — event must NOT fire. - vm.recordLogs(); - _transfer(ALICE, ALICE, ID_A, 0); - Vm.Log[] memory logs = vm.getRecordedLogs(); - - bytes32 sig = StoxReceipt.ReceiptAccountMigrated.selector; - for (uint256 i = 0; i < logs.length; i++) { - if ( - logs[i].topics.length > 0 && logs[i].topics[0] == sig - && address(uint160(uint256(logs[i].topics[1]))) == ALICE && uint256(logs[i].topics[2]) == ID_A - ) { - fail(); - } - } - } - - /// Event ordering pin: `ReceiptAccountMigrated` must fire BEFORE the - /// corresponding ERC-1155 `TransferSingle` event in the same `_update` - /// call, because `migrateHolderId` runs before the receipt's base - /// `_update`. Indexers rely on this ordering to compute pre-transfer - /// rasterized balances from the migration log. - function testReceiptAccountMigratedOrderedBeforeTransferSingle() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - vm.recordLogs(); - _transfer(ALICE, BOB, ID_A, 50); - Vm.Log[] memory logs = vm.getRecordedLogs(); - - bytes32 migratedSig = StoxReceipt.ReceiptAccountMigrated.selector; - bytes32 transferSig = keccak256("TransferSingle(address,address,address,uint256,uint256)"); - uint256 firstMigrated = type(uint256).max; - uint256 firstTransfer = type(uint256).max; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics.length == 0) continue; - if (logs[i].topics[0] == migratedSig && firstMigrated == type(uint256).max) { - firstMigrated = i; - } else if (logs[i].topics[0] == transferSig && firstTransfer == type(uint256).max) { - firstTransfer = i; - } - } - assertLt(firstMigrated, firstTransfer, "ReceiptAccountMigrated must precede TransferSingle"); - } - - /// Global receipt-side invariant: across a random balance and a - /// random sequence of stock-split multipliers, every `(holder, id)` - /// cursor advance is matched by exactly one `ReceiptAccountMigrated` - /// log, and the log's `oldBalance / newBalance` pair always equals - /// the actual pre/post stored balance. - function testFuzzReceiptAccountMigratedFiresOnEveryCursorAdvance( - uint64 startBalance, - uint8 splitSeed, - uint8 splitCount - ) external { - startBalance = uint64(bound(startBalance, 0, type(uint32).max)); - splitCount = uint8(bound(splitCount, 1, 5)); - - if (startBalance > 0) { - _mint(ALICE, ID_A, uint256(startBalance)); - } - uint256 storedBefore = receipt.rawStoredBalance(ALICE, ID_A); - uint256 cursorBefore = receipt.holderIdCursor(ALICE, ID_A); - - for (uint256 i = 0; i < splitCount; i++) { - uint8 pick = uint8((uint256(splitSeed) >> (i * 2)) & 0x3); - if (pick == 0) { - _splitParams(2); - } else if (pick == 1) { - _splitParams(3); - } else if (pick == 2) { - _fractionalParams(1, 2); - } else { - _fractionalParams(1, 3); - } - } - - vm.recordLogs(); - _transfer(ALICE, ALICE, ID_A, 0); - - bytes32 sig = StoxReceipt.ReceiptAccountMigrated.selector; - Vm.Log[] memory logs = vm.getRecordedLogs(); - uint256 count; - uint256 emittedFromCursor; - uint256 emittedToCursor; - uint256 emittedOld; - uint256 emittedNew; - for (uint256 i = 0; i < logs.length; i++) { - if ( - logs[i].topics.length > 0 && logs[i].topics[0] == sig - && address(uint160(uint256(logs[i].topics[1]))) == ALICE && uint256(logs[i].topics[2]) == ID_A - ) { - count++; - (emittedFromCursor, emittedToCursor, emittedOld, emittedNew) = - abi.decode(logs[i].data, (uint256, uint256, uint256, uint256)); - } - } - - uint256 cursorAfter = receipt.holderIdCursor(ALICE, ID_A); - if (cursorAfter == cursorBefore) { - assertEq(count, 0, "no event when cursor did not advance"); - } else { - assertEq(count, 1, "exactly one ReceiptAccountMigrated event per cursor advance"); - assertEq(emittedFromCursor, cursorBefore, "fromCursor matches pre-migrate cursor"); - assertEq(emittedToCursor, cursorAfter, "toCursor matches post-migrate cursor"); - assertEq(emittedOld, storedBefore, "oldBalance matches the pre-migrate stored balance"); - assertEq( - emittedNew, receipt.rawStoredBalance(ALICE, ID_A), "newBalance matches the post-migrate stored balance" - ); - } - } - - // ----------------------------------------------------------------------- - // balanceOfBatch consistency - - /// balanceOfBatch must return the same rebased values as calling - /// balanceOf on each (account, id) individually. Without the override - /// OZ's default balanceOfBatch reads _balances directly and bypasses - /// the rebase. - function testBalanceOfBatchRebaseConsistency() external { - _mint(ALICE, ID_A, 100); - _mint(BOB, ID_B, 200); - _splitParams(2); - - // Neither account has touched since the split — raw stored balances - // are stale, but balanceOf should return rebased values. - address[] memory accounts = new address[](3); - accounts[0] = ALICE; - accounts[1] = BOB; - accounts[2] = ALICE; - uint256[] memory ids = new uint256[](3); - ids[0] = ID_A; - ids[1] = ID_B; - ids[2] = ID_B; // Alice has 0 of ID_B - - uint256[] memory batch = receipt.balanceOfBatch(accounts, ids); - - assertEq(batch[0], receipt.balanceOf(ALICE, ID_A), "batch[0] must match balanceOf(ALICE, ID_A)"); - assertEq(batch[1], receipt.balanceOf(BOB, ID_B), "batch[1] must match balanceOf(BOB, ID_B)"); - assertEq(batch[2], receipt.balanceOf(ALICE, ID_B), "batch[2] must match balanceOf(ALICE, ID_B)"); - - // Concrete values: 2× split on 100 and 200. - assertEq(batch[0], 200); - assertEq(batch[1], 400); - assertEq(batch[2], 0); - } - - /// balanceOfBatch with mismatched array lengths reverts. - function testBalanceOfBatchMismatchedLengthsReverts() external { - address[] memory accounts = new address[](2); - uint256[] memory ids = new uint256[](1); - vm.expectRevert(); - receipt.balanceOfBatch(accounts, ids); - } - - /// By the time OZ's `_doSafeTransferAcceptanceCheck` fires - /// `onERC1155Received` on a contract recipient, migration has - /// completed and balances are rasterized. A receive hook that reads - /// `balanceOf` observes the post-migration, post-transfer values. - function testReceiveHookObservesPostMigrationState() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - // Use a contract receiver that records the state observed during - // the onERC1155Received callback. - RecordingReceiver recv = new RecordingReceiver(receipt, ALICE); - _transfer(ALICE, address(recv), ID_A, 50); - - // Inside the callback, alice's pre-transfer effective balance was - // 200 (rebased from stored 100). The hook fires AFTER migration - // (alice stored becomes 200) and AFTER the transfer (alice raw: - // 200 - 50 = 150, recv raw: 0 + 50 = 50). - assertEq(recv.observedAliceBalance(), 150, "hook must see post-transfer alice balance"); - assertEq(recv.observedRecvBalance(), 50, "hook must see post-transfer recv balance"); - } - - /// The batch-receive hook `onERC1155BatchReceived` fires after - /// migration and after the batch transfer has executed. A receiver - /// that reads balances from inside the callback observes the same - /// post-migration, post-transfer state as `onERC1155Received` does - /// for single transfers. - function testBatchReceiveHookObservesPostMigrationState() external { - _mint(ALICE, ID_A, 100); - _mint(ALICE, ID_B, 200); - _splitParams(2); - - BatchRecordingReceiver recv = new BatchRecordingReceiver(receipt); - - uint256[] memory ids = new uint256[](2); - ids[0] = ID_A; - ids[1] = ID_B; - uint256[] memory amounts = new uint256[](2); - amounts[0] = 50; - amounts[1] = 100; - - vm.prank(ALICE); - receipt.safeBatchTransferFrom(ALICE, address(recv), ids, amounts, ""); - - assertEq(recv.observedBalance(ID_A), 50, "hook sees post-transfer recv ID_A"); - assertEq(recv.observedBalance(ID_B), 100, "hook sees post-transfer recv ID_B"); - } - - /// Burn from a (holder, id) with a pending rebase subtracts from the - /// post-rebase balance. The migration runs first so OZ's - /// `_balances[id][holder]` is rasterized before `_burn` decrements it. - function testBurnAfterSplitSubtractsFromRebasedBalance() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - _burn(ALICE, ID_A, 50); - - assertEq(receipt.balanceOf(ALICE, ID_A), 150, "post-rebase 200 minus 50 burn"); - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 150, "raw stored equals post-rebase minus burn"); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); - } - - /// Burning the full post-rebase balance zeroes the raw stored value - /// while leaving the cursor at the latest completed split. - function testBurnToZeroAfterSplitLeavesCursorAtLatest() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - _burn(ALICE, ID_A, 200); - - assertEq(receipt.balanceOf(ALICE, ID_A), 0); - assertEq(receipt.rawStoredBalance(ALICE, ID_A), 0); - assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "cursor still at latest despite zero balance"); - } - - /// Burning more than the post-rebase balance reverts via OZ's - /// `ERC1155InsufficientBalance`. Migration rasterizes the raw value - /// first, so the burner's raw balance at the point of `_burn` is the - /// same value OZ's check sees. - function testOverBurnAfterSplitReverts() external { - _mint(ALICE, ID_A, 100); - _splitParams(2); - - // After migration, Alice's raw stored balance is 200. OZ's check - // sees raw 200 vs burn 201 and reverts with the exact amounts. - vm.expectRevert( - abi.encodeWithSelector(IERC1155Errors.ERC1155InsufficientBalance.selector, ALICE, 200, 201, ID_A) - ); - _burn(ALICE, ID_A, 201); - } - - // ----------------------------------------------------------------------- - // Helpers - - function _mint(address to, uint256 id, uint256 amount) internal { - vm.prank(address(vault)); - receipt.managerMint(address(vault), to, id, amount, ""); - } - - function _burn(address from, uint256 id, uint256 amount) internal { - vm.prank(address(vault)); - receipt.managerBurn(address(vault), from, id, amount, ""); - } - - function _transfer(address from, address to, uint256 id, uint256 amount) internal { - vm.prank(address(vault)); - receipt.managerTransferFrom(address(vault), from, to, id, amount, ""); - } -} - -/// @dev Contract recipient that records the sender and receiver balances -/// observed during its `onERC1155Received` callback. Used to pin the -/// invariant that receive hooks fire post-migration, post-transfer. -contract RecordingReceiver { - StoxReceipt public immutable RECEIPT; - address public immutable ALICE; - uint256 public observedAliceBalance; - uint256 public observedRecvBalance; - - constructor(StoxReceipt receipt_, address alice_) { - RECEIPT = receipt_; - ALICE = alice_; - } - - function onERC1155Received(address, address, uint256 id, uint256, bytes calldata) external returns (bytes4) { - observedAliceBalance = RECEIPT.balanceOf(ALICE, id); - observedRecvBalance = RECEIPT.balanceOf(address(this), id); - return this.onERC1155Received.selector; - } -} - -/// @dev Contract recipient that records its own balance per id observed -/// during `onERC1155BatchReceived`. Mirrors `RecordingReceiver` for the -/// batch path. -contract BatchRecordingReceiver { - StoxReceipt public immutable RECEIPT; - mapping(uint256 => uint256) public observedBalance; - - constructor(StoxReceipt receipt_) { - RECEIPT = receipt_; - } - - function onERC1155BatchReceived(address, address, uint256[] calldata ids, uint256[] calldata, bytes calldata) - external - returns (bytes4) - { - for (uint256 i = 0; i < ids.length; i++) { - observedBalance[ids[i]] = RECEIPT.balanceOf(address(this), ids[i]); - } - return this.onERC1155BatchReceived.selector; - } -} diff --git a/test/src/concrete/StoxReceiptRebaseIntegrationTest.t.sol b/test/src/concrete/StoxReceiptRebaseIntegrationTest.t.sol new file mode 100644 index 00000000..629716b8 --- /dev/null +++ b/test/src/concrete/StoxReceiptRebaseIntegrationTest.t.sol @@ -0,0 +1,936 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; +import {StoxReceipt} from "../../../src/concrete/StoxReceipt.sol"; +import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import {ICorporateActionsV1} from "../../../src/interface/ICorporateActionsV1.sol"; +import {CompletionFilter, NODE_NONE} from "../../../src/lib/LibCorporateActionNode.sol"; +import { + LibCorporateActionReceipt, + CORPORATE_ACTION_RECEIPT_STORAGE_LOCATION +} from "../../../src/lib/LibCorporateActionReceipt.sol"; +import {IReceiptManagerV2} from "rain-vats-0.1.6/src/interface/IReceiptManagerV2.sol"; +import {IERC1155Errors} from "@openzeppelin-contracts-5.6.1/interfaces/draft-IERC6093.sol"; +import {MockVault} from "./MockVault.sol"; +import {TestStoxReceipt} from "./TestStoxReceipt.sol"; +import {RecordingReceiver} from "./RecordingReceiver.sol"; +import {BatchRecordingReceiver} from "./BatchRecordingReceiver.sol"; + +contract StoxReceiptRebaseIntegrationTest is Test { + TestStoxReceipt internal receipt; + MockVault internal vault; + + address internal constant ALICE = address(0xA11CE); + address internal constant BOB = address(0xB0B); + + uint256 internal constant ID_A = 1; + uint256 internal constant ID_B = 2; + + function setUp() public { + vault = new MockVault(); + receipt = new TestStoxReceipt(); + receipt.testInit(address(vault)); + } + + function _splitParams(int256 multiplier) internal { + vault.addSplit(LibDecimalFloat.packLossless(multiplier, 0)); + } + + function _fractionalParams(int256 num, int256 denom) internal { + vault.addSplit( + LibDecimalFloat.div(LibDecimalFloat.packLossless(num, 0), LibDecimalFloat.packLossless(denom, 0)) + ); + } + + // ----------------------------------------------------------------------- + // Default cursor pin + + /// Pre-mint receipt-side fresh-pair cursor pin: an `(account, id)` + /// pair that has never been touched returns `holderIdCursor == 0` + /// from the `accountIdCursor` mapping. The 0-based scheme leans on + /// this default — 0 is the vault's bootstrap node, so a fresh pair + /// is implicitly "at bootstrap" and the first migration walk advances + /// from after-bootstrap. A regression that changed the namespace, + /// mapping shape, or initialised cursors to anything other than 0 + /// surfaces here. + function testReceiptCursorDefaultsToBootstrapForFreshPair() external view { + address fresh = address(0xCAFE); + uint256 freshId = 999; + assertEq(receipt.holderIdCursor(fresh, freshId), 0, "fresh (account, id) cursor defaults to 0 (= bootstrap)"); + } + + // ----------------------------------------------------------------------- + // Storage-slot pin tests + + /// The hardcoded ERC-7201 slot constant for LibCorporateActionReceipt + /// matches its documented derivation formula. + function testReceiptCorporateActionSlotMatchesDerivation() external pure { + bytes32 expected = keccak256(abi.encode(uint256(keccak256("rain.storage.corporate-action-receipt.1")) - 1)) + & ~bytes32(uint256(0xff)); + assertEq( + CORPORATE_ACTION_RECEIPT_STORAGE_LOCATION, + expected, + "receipt corporate-action storage slot must match derivation" + ); + } + + /// Layout pin: each field of `CorporateActionReceiptStorage` lives at + /// its expected offset from the namespace base. Must be extended for + /// every later PR that appends a new field. See the DO NOT REORDER + /// comment on the struct. + function testReceiptStorageLayoutPin() external { + // accountIdCursor is at offset 0 within the struct. Poke a key via + // the library accessor (indirectly by setting a cursor through a + // full mint+split+touch path) and assert the entry lives at the + // expected derived slot. + bytes32 base = CORPORATE_ACTION_RECEIPT_STORAGE_LOCATION; + + // Use a sentinel holder + id. + address holder = address(0xBEEF); + uint256 id = 0xCAFE; + + // Write a sentinel directly to the outer mapping slot at offset 0, + // then read through the library accessor to verify that offset 0 + // is the accountIdCursor mapping base. + bytes32 outerSlot = keccak256(abi.encode(holder, base)); + bytes32 entrySlot = keccak256(abi.encode(id, outerSlot)); + vm.store(address(receipt), entrySlot, bytes32(uint256(0x12345))); + + assertEq( + receipt.holderIdCursor(holder, id), + 0x12345, + "accountIdCursor mapping must be at offset 0 in CorporateActionReceiptStorage" + ); + } + + // ----------------------------------------------------------------------- + // Rebase integration — happy path + + /// Before any splits, balanceOf returns the raw stored balance. + function testBalanceOfNoSplits() external { + // Mint directly to Alice via vault-as-manager path. + _mint(ALICE, ID_A, 100); + assertEq(receipt.balanceOf(ALICE, ID_A), 100); + } + + /// After a 2x split, balanceOf returns the rebased balance even before + /// migration actually runs (view-only multiplier application). + function testBalanceOfAfterSplitPreMigration() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + assertEq(receipt.balanceOf(ALICE, ID_A), 200, "view-only rebase must reflect the split"); + // Stored balance hasn't actually changed yet (no touch). + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 100, "rasterize is lazy"); + } + + /// A touch via zero-value manager transfer migrates the stored balance. + function testMigrationOnTransferRasterizesStoredBalance() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + // Self-transfer of 0 — triggers _update on (Alice, ID_A) from both + // sides and rasterizes Alice's stored balance. + _transfer(ALICE, ALICE, ID_A, 0); + + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 200, "stored balance rasterized"); + assertEq(receipt.balanceOf(ALICE, ID_A), 200); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "cursor advanced to first split"); + } + + /// Mint to a fresh recipient after a completed split credits exactly + /// the minted amount, not multiplied by the split. Without the + /// zero-balance cursor-advancement guard, the recipient's freshly- + /// written post-rebase balance would be re-multiplied on the next + /// `balanceOf` read. + function testMintToFreshRecipientAfterSplitDoesNotInflate() external { + // Pre-existing supply so the split has something to rebase. + _mint(BOB, ID_A, 1000); + + _splitParams(2); + + // Alice mints 100 AFTER the split. Should receive exactly 100. + _mint(ALICE, ID_A, 100); + assertEq(receipt.balanceOf(ALICE, ID_A), 100, "fresh recipient must not over-multiply on mint"); + } + + /// Transfer to a fresh recipient after a completed split: recipient + /// receives the transferred amount exactly, not multiplied. + function testTransferToFreshRecipientAfterSplitDoesNotInflate() external { + _mint(BOB, ID_A, 50); + _splitParams(2); + + // Bob's effective balance is now 100. Transfer all 100 to Alice. + _transfer(BOB, ALICE, ID_A, 100); + + assertEq(receipt.balanceOf(ALICE, ID_A), 100, "recipient got exactly the transferred amount"); + assertEq(receipt.balanceOf(BOB, ID_A), 0, "Bob is now empty"); + } + + /// Per-(holder, id) cursor independence: Alice's cursor for ID_A + /// advances without touching her cursor for ID_B or anyone else's. + function testPerHolderIdCursorIndependence() external { + _mint(ALICE, ID_A, 100); + _mint(ALICE, ID_B, 200); + _mint(BOB, ID_A, 300); + + _splitParams(2); + + // Touch only (Alice, ID_A) via a zero-value manager transfer for + // that specific id. + _transfer(ALICE, ALICE, ID_A, 0); + + // (Alice, ID_A) is at cursor 1 and rasterized. + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 200); + + // (Alice, ID_B) and (Bob, ID_A) are untouched — cursor 0, raw + // balance unchanged, but view balanceOf still reflects the split. + assertEq(receipt.holderIdCursor(ALICE, ID_B), 0); + assertEq(receipt.rawStoredBalance(ALICE, ID_B), 200); + assertEq(receipt.balanceOf(ALICE, ID_B), 400, "view override still applies the split"); + + assertEq(receipt.holderIdCursor(BOB, ID_A), 0); + assertEq(receipt.rawStoredBalance(BOB, ID_A), 300); + assertEq(receipt.balanceOf(BOB, ID_A), 600); + } + + /// A fresh recipient whose stored balance is 0 at the time of a split + /// has their cursor advanced on first touch, so a subsequent mint or + /// transfer-in does not re-apply the multiplier on top of an + /// already-rebased raw balance. + function testZeroBalanceCursorAdvancesOnFreshRecipient() external { + _mint(BOB, ID_A, 1000); + _splitParams(2); + + // Touch Alice (who has 0 balance) with a 0-value transfer. + _transfer(ALICE, ALICE, ID_A, 0); + + // Alice's cursor must now point at the split, even though her raw + // balance was never rewritten. + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "zero-balance cursor must advance"); + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 0); + + // A subsequent mint lands at cursor 1 (post-split basis). The + // stored balance write of 100 must NOT be re-multiplied by the + // split on a later balanceOf read. + _mint(ALICE, ID_A, 100); + assertEq(receipt.balanceOf(ALICE, ID_A), 100, "post-touch mint must not re-inflate"); + } + + /// Sequential precision — receipt side must match share side exactly. + /// 1/3 × 3 × 1/3 × 3 applied to 100 = 96. + function testSequentialPrecisionMatchesShareSide() external { + _mint(ALICE, ID_A, 100); + + _fractionalParams(1, 3); + _splitParams(3); + _fractionalParams(1, 3); + _splitParams(3); + + // Touch to rasterize. + _transfer(ALICE, ALICE, ID_A, 0); + + assertEq(receipt.balanceOf(ALICE, ID_A), 96, "receipt side must match share-side sequential precision"); + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 96); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 4); + } + + /// Batch update: a batch transfer touching multiple ids migrates all of + /// them independently. + function testBatchUpdateMigratesEachIdIndependently() external { + _mint(ALICE, ID_A, 100); + _mint(ALICE, ID_B, 200); + _splitParams(2); + + uint256[] memory ids = new uint256[](2); + ids[0] = ID_A; + ids[1] = ID_B; + uint256[] memory amounts = new uint256[](2); + amounts[0] = 0; // zero-value batch transfer just to touch both ids + amounts[1] = 0; + + // Alice calls safeBatchTransferFrom herself — sender == from so no + // operator approval is required. + vm.prank(ALICE); + receipt.safeBatchTransferFrom(ALICE, ALICE, ids, amounts, ""); + + // Both (Alice, ID_A) and (Alice, ID_B) must be at cursor 1 and + // rasterized. + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); + assertEq(receipt.holderIdCursor(ALICE, ID_B), 1); + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 200); + assertEq(receipt.rawStoredBalance(ALICE, ID_B), 400); + } + + /// After burning the full post-rebase balance to zero, a subsequent + /// transfer-in credits exactly the transferred amount. The zero-balance + /// cursor advancement on burn keeps the holder at the latest cursor, + /// so the next write is not re-multiplied on the next `balanceOf`. + function testTransferInAfterBurnToZeroCreditsExact() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + _burn(ALICE, ID_A, 200); + + _mint(BOB, ID_A, 50); + _transfer(BOB, ALICE, ID_A, 50); + + assertEq(receipt.balanceOf(ALICE, ID_A), 50); + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 50); + } + + /// `migrateHolderId(address(0), ...)` short-circuits. After a + /// completed split, calling it for address(0) must leave address(0)'s + /// cursor and balance at their zero-initialized values (no state + /// pollution on the zero address). + function testMigrateHolderIdZeroAddressIsNoOp() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + receipt.publicMigrateHolderId(address(0), ID_A); + + assertEq(receipt.holderIdCursor(address(0), ID_A), 0, "zero address cursor must not advance"); + assertEq(receipt.rawStoredBalance(address(0), ID_A), 0, "zero address balance must stay zero"); + } + + /// When the manager's `authorizeReceiptTransfer3` reverts, the + /// transfer reverts and no migration state persists. Migration runs + /// before `super._update` so the authorizer denial inside + /// `super._update` would roll back whatever migration wrote if the + /// revert propagated. Verify Alice's cursor and stored balance are + /// unchanged after the denied call. + function testAuthorizerDeniedTransferRollsBackMigration() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + vault.setDenyTransfers(true); + + vm.prank(ALICE); + vm.expectRevert(MockVault.ReceiptTransferDenied.selector); + receipt.safeTransferFrom(ALICE, BOB, ID_A, 50, ""); + + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 100, "raw stored balance unchanged after revert"); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 0, "cursor unchanged after revert"); + } + + /// A non-owner, non-approved caller cannot transfer someone else's + /// balance. OZ's approval check runs inside `super._update`, which + /// migration precedes. Test asserts the approval revert fires with + /// the exact operator/owner pair. + function testUnauthorizedTransferRevertsMissingApproval() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + vm.prank(BOB); + vm.expectRevert(abi.encodeWithSelector(IERC1155Errors.ERC1155MissingApprovalForAll.selector, BOB, ALICE)); + receipt.safeTransferFrom(ALICE, BOB, ID_A, 50, ""); + } + + /// Holder-initiated `safeTransferFrom` (no operator approval) moves up + /// to the post-rebase balance. Distinct from the operator path — the + /// holder's own `msg.sender == from` call bypasses the approval check + /// but should still see the rebased ceiling. + function testFuzzHolderDirectTransferUsesPostRebaseBalance(uint64 deposit, uint128 amount, uint8 mulSeed) external { + // mulSeed bound to [2,5] so int256 cast cannot overflow. + // forge-lint: disable-next-line(unsafe-typecast) + int256 multiplier = int256(uint256(bound(mulSeed, 2, 5))); + // bound result is < type(uint64).max so uint64 cast is safe; + // multiplier is in [2,5] so uint64(uint256(multiplier)) cannot truncate. + // forge-lint: disable-next-line(unsafe-typecast) + deposit = uint64(bound(deposit, 1, type(uint64).max / uint64(uint256(multiplier)))); + // multiplier is bounded to [2,5] so the int256 → uint256 cast is safe. + // forge-lint: disable-next-line(unsafe-typecast) + uint256 postRebase = uint256(deposit) * uint256(multiplier); + uint256 xfer = bound(amount, 0, postRebase); + + _mint(ALICE, ID_A, deposit); + _splitParams(multiplier); + + vm.prank(ALICE); + receipt.safeTransferFrom(ALICE, BOB, ID_A, xfer, ""); + + assertEq(receipt.balanceOf(ALICE, ID_A), postRebase - xfer); + assertEq(receipt.balanceOf(BOB, ID_A), xfer); + } + + /// Minting additional balance to a holder who already has a pre-split + /// position rasterizes first, then adds the mint amount. The mint is + /// denominated in post-rebase units — so the holder ends up with + /// `oldBalance * multiplier + mintAmount`, not `(oldBalance + + /// mintAmount) * multiplier`. + function testMintToExistingHolderAfterSplitRasterizesFirst() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + _mint(ALICE, ID_A, 50); + + assertEq(receipt.balanceOf(ALICE, ID_A), 250, "200 post-rebase + 50 minted"); + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 250); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); + } + + /// An approved operator can move up to the post-rebase balance for + /// any transfer amount in `[0, postRebase]`. Approval semantics are + /// unchanged by the rebase — the operator sees the rebased ceiling, + /// not the raw stored value. + function testFuzzApprovedOperatorTransfersPostRebaseBalance(uint64 deposit, uint128 transferAmount, uint8 mulSeed) + external + { + // mulSeed bound to [2,5] so int256 cast cannot overflow. + // forge-lint: disable-next-line(unsafe-typecast) + int256 multiplier = int256(uint256(bound(mulSeed, 2, 5))); + // bound result is < type(uint64).max; multiplier in [2,5] so + // uint64(uint256(multiplier)) cannot truncate. + // forge-lint: disable-next-line(unsafe-typecast) + deposit = uint64(bound(deposit, 1, type(uint64).max / uint64(uint256(multiplier)))); + // multiplier is bounded to [2,5] so the int256 → uint256 cast is safe. + // forge-lint: disable-next-line(unsafe-typecast) + uint256 postRebase = uint256(deposit) * uint256(multiplier); + uint256 amount = bound(transferAmount, 0, postRebase); + + _mint(ALICE, ID_A, deposit); + _splitParams(multiplier); + + vm.prank(ALICE); + receipt.setApprovalForAll(BOB, true); + + vm.prank(BOB); + receipt.safeTransferFrom(ALICE, BOB, ID_A, amount, ""); + + assertEq(receipt.balanceOf(ALICE, ID_A), postRebase - amount); + assertEq(receipt.balanceOf(BOB, ID_A), amount); + } + + /// `balanceOfBatch` returns an empty array when called with empty + /// inputs, matching OZ behavior. Does not revert despite the override. + function testBalanceOfBatchEmptyInputsReturnsEmpty() external view { + address[] memory accounts = new address[](0); + uint256[] memory ids = new uint256[](0); + uint256[] memory result = receipt.balanceOfBatch(accounts, ids); + assertEq(result.length, 0); + } + + /// A reverse split that truncates the balance to zero followed by a + /// forward split does not re-inflate. For any balance strictly less + /// than `denom`, `balance * 1/denom` truncates to 0, and every + /// subsequent multiplier applied to 0 stays at 0. + function testFuzzFractionalSplitTruncatingToZeroDoesNotReInflate(uint8 balanceSeed, uint8 denomSeed, uint8 mulSeed) + external + { + int256 denom = int256(uint256(bound(denomSeed, 2, 100))); + // forge-lint: disable-next-line(unsafe-typecast) + uint256 balance = bound(uint256(balanceSeed), 1, uint256(denom) - 1); + int256 multiplier = int256(uint256(bound(mulSeed, 2, 100))); + + _mint(ALICE, ID_A, balance); + _fractionalParams(1, denom); + _splitParams(multiplier); + + assertEq(receipt.balanceOf(ALICE, ID_A), 0, "view path sees truncated zero"); + + _transfer(ALICE, ALICE, ID_A, 0); + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 0); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 2); + assertEq(receipt.balanceOf(ALICE, ID_A), 0, "post-migration balance remains zero"); + } + + /// Empty batch `safeBatchTransferFrom` is a no-op — the migration + /// loop runs zero iterations, OZ's super._update accepts empty + /// arrays, and no state changes. + function testBatchUpdateEmptyArraysIsNoOp() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + uint256[] memory ids = new uint256[](0); + uint256[] memory amounts = new uint256[](0); + + vm.prank(ALICE); + receipt.safeBatchTransferFrom(ALICE, BOB, ids, amounts, ""); + + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 100, "no migration means no rasterization"); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 0, "no migration means cursor stays"); + } + + /// In a multi-id batch transfer, `ReceiptAccountMigrated` events fire + /// in `(from, ids[0]), (to, ids[0]), (from, ids[1]), (to, ids[1])` + /// order, all before the `TransferBatch` event. Bob's zero-balance + /// migrations also emit (per #81 — every cursor advance fires). + /// Indexers rely on this interleaving: for each event emitted for + /// `ids[i]`, the balance at that cursor for that id is the rasterized + /// value, not yet touched by the transfer. + function testBatchUpdateEventOrderingAcrossIds() external { + _mint(ALICE, ID_A, 100); + _mint(ALICE, ID_B, 200); + _splitParams(2); + + uint256[] memory ids = new uint256[](2); + ids[0] = ID_A; + ids[1] = ID_B; + uint256[] memory amounts = new uint256[](2); + amounts[0] = 10; + amounts[1] = 20; + + vm.recordLogs(); + vm.prank(ALICE); + receipt.safeBatchTransferFrom(ALICE, BOB, ids, amounts, ""); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 migratedSig = StoxReceipt.ReceiptAccountMigrated.selector; + bytes32 batchSig = keccak256("TransferBatch(address,address,address,uint256[],uint256[])"); + + uint256[] memory migratedOrder = new uint256[](4); + uint256 migratedIdx = 0; + uint256 batchIdx = type(uint256).max; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length == 0) continue; + if (logs[i].topics[0] == migratedSig && migratedIdx < 4) { + address who = address(uint160(uint256(logs[i].topics[1]))); + uint256 id = uint256(logs[i].topics[2]); + // forge-lint: disable-next-line(unsafe-typecast) + migratedOrder[migratedIdx++] = uint256(uint160(who)) << 96 | uint256(uint96(id)); + } else if (logs[i].topics[0] == batchSig) { + batchIdx = i; + } + } + + assertEq(migratedIdx, 4, "every (holder, id) cursor advance emits: alice ID_A/ID_B + bob ID_A/ID_B"); + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(migratedOrder[0], uint256(uint160(ALICE)) << 96 | uint96(ID_A), "first emit is (alice, ID_A)"); + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(migratedOrder[1], uint256(uint160(BOB)) << 96 | uint96(ID_A), "second emit is (bob, ID_A)"); + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(migratedOrder[2], uint256(uint160(ALICE)) << 96 | uint96(ID_B), "third emit is (alice, ID_B)"); + // forge-lint: disable-next-line(unsafe-typecast) + assertEq(migratedOrder[3], uint256(uint160(BOB)) << 96 | uint96(ID_B), "fourth emit is (bob, ID_B)"); + assertLt(batchIdx, type(uint256).max, "TransferBatch must be emitted"); + } + + /// A batch with a duplicate id migrates the (holder, id) pair once + /// and subtracts the sum of all amount entries from the sender. Holds + /// for any pair of amounts whose sum is at most the post-rebase + /// ceiling. + function testFuzzBatchUpdateWithDuplicateIdTransfersFullSum(uint32 deposit, uint64 a, uint64 b) external { + deposit = uint32(bound(deposit, 1, type(uint32).max)); + uint256 postRebase = uint256(deposit) * 2; + a = uint64(bound(a, 0, postRebase)); + b = uint64(bound(b, 0, postRebase - a)); + + _mint(ALICE, ID_A, deposit); + _splitParams(2); + + uint256[] memory ids = new uint256[](2); + ids[0] = ID_A; + ids[1] = ID_A; + uint256[] memory amounts = new uint256[](2); + amounts[0] = a; + amounts[1] = b; + + vm.prank(ALICE); + receipt.safeBatchTransferFrom(ALICE, BOB, ids, amounts, ""); + + assertEq(receipt.balanceOf(ALICE, ID_A), postRebase - uint256(a) - uint256(b)); + assertEq(receipt.balanceOf(BOB, ID_A), uint256(a) + uint256(b)); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); + assertEq(receipt.holderIdCursor(BOB, ID_A), 1); + } + + /// ReceiptAccountMigrated event is emitted for non-trivial migrations. + function testReceiptAccountMigratedEventEmitted() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + vm.expectEmit(true, true, false, true, address(receipt)); + emit StoxReceipt.ReceiptAccountMigrated(ALICE, ID_A, 0, 1, 100, 200); + + _transfer(ALICE, ALICE, ID_A, 0); + } + + /// A dormant `(holder, id)` touched after multiple completed splits + /// emits exactly one `ReceiptAccountMigrated` with aggregated fields: + /// fromCursor is the pre-migration cursor, toCursor is the latest + /// completed split, oldBalance is the raw stored value, newBalance is + /// the fully-rasterized value after all multipliers have been applied. + function testReceiptAccountMigratedAggregatesAcrossMultipleSplits() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + _splitParams(3); + + vm.recordLogs(); + _transfer(ALICE, ALICE, ID_A, 0); + + bytes32 sig = StoxReceipt.ReceiptAccountMigrated.selector; + Vm.Log[] memory logs = vm.getRecordedLogs(); + uint256 count = 0; + uint256 fromCursor; + uint256 toCursor; + uint256 oldBalance; + uint256 newBalance; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == sig) { + count++; + assertEq(address(uint160(uint256(logs[i].topics[1]))), ALICE, "indexed account is ALICE"); + assertEq(uint256(logs[i].topics[2]), ID_A, "indexed id is ID_A"); + (fromCursor, toCursor, oldBalance, newBalance) = + abi.decode(logs[i].data, (uint256, uint256, uint256, uint256)); + } + } + assertEq(count, 1, "exactly one ReceiptAccountMigrated per multi-split migration"); + assertEq(fromCursor, 0, "fromCursor is pre-migration cursor"); + assertEq(toCursor, 2, "toCursor is latest completed split index"); + assertEq(oldBalance, 100, "oldBalance is pre-rasterization stored value"); + assertEq(newBalance, 600, "newBalance is fully rasterized (100 * 2 * 3)"); + } + + // ----------------------------------------------------------------------- + // Issue #81: receipt-side always-emit semantics. + // + // Mirrors the share-side test matrix on `StoxReceiptVault.t.sol`. + // `migrateHolderId` must emit `ReceiptAccountMigrated` on every + // cursor advance, regardless of whether the rasterized balance equals + // the pre-rebase balance. Four phenomena drive a balance-equal + // cursor advance: zero balance, single-step truncation collision, + // multi-step round-trip, and balance-specific identity. + + /// Receipt phenomenon 1 (zero balance): a fresh `(holder, id)` pair + /// touched after a completed split advances its cursor; the event + /// fires with `oldBalance == newBalance == 0`. + function testReceiptAccountMigratedFiresOnZeroBalanceCursorAdvance() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + // Transfer zero from ALICE to BOB at ID_A — BOB's (BOB, ID_A) + // pair is fresh, but his cursor still advances. + vm.expectEmit(true, true, false, true, address(receipt)); + emit StoxReceipt.ReceiptAccountMigrated(BOB, ID_A, 0, 1, 0, 0); + + _transfer(ALICE, BOB, ID_A, 0); + } + + /// Receipt phenomenon 2 (single-step truncation collision): a stored + /// 1 through a 1.5x multiplier rasterizes to `trunc(1.5) == 1`. The + /// cursor advances; the stored balance is unchanged; the event fires. + function testReceiptAccountMigratedFiresOnTruncationCollision() external { + _mint(ALICE, ID_A, 1); + _fractionalParams(3, 2); + + vm.expectEmit(true, true, false, true, address(receipt)); + emit StoxReceipt.ReceiptAccountMigrated(ALICE, ID_A, 0, 1, 1, 1); + + _transfer(ALICE, ALICE, ID_A, 0); + + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 1, "stored balance unchanged after truncation collision"); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "alice cursor advanced past the split"); + } + + /// Receipt phenomenon 3 (multi-step round-trip): a balance of 4 + /// through `[2x, 1/2x]` rasterizes `4 -> 8 -> 4`. Float represents + /// 1/2 exactly in base 10, so this round-trips deterministically. + /// Cursor jumps two splits in a single `_update`; the event fires + /// once with `oldBalance == newBalance == 4`. + function testReceiptAccountMigratedFiresOnMultiStepRoundTrip() external { + _mint(ALICE, ID_A, 4); + _splitParams(2); + _fractionalParams(1, 2); + + vm.expectEmit(true, true, false, true, address(receipt)); + emit StoxReceipt.ReceiptAccountMigrated(ALICE, ID_A, 0, 2, 4, 4); + + _transfer(ALICE, ALICE, ID_A, 0); + + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 4, "stored balance round-tripped to itself"); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 2, "alice cursor advanced past both splits"); + } + + /// Receipt phenomenon 4 (balance-specific identity): a balance of 10 + /// through a 1.09x multiplier rasterizes to `trunc(10.9) == 10`. The + /// same multiplier on a larger balance produces a real change; the + /// no-op here is balance-specific. The event fires. + function testReceiptAccountMigratedFiresOnBalanceSpecificIdentity() external { + _mint(ALICE, ID_A, 10); + _fractionalParams(109, 100); + + vm.expectEmit(true, true, false, true, address(receipt)); + emit StoxReceipt.ReceiptAccountMigrated(ALICE, ID_A, 0, 1, 10, 10); + + _transfer(ALICE, ALICE, ID_A, 0); + + assertEq( + receipt.rawStoredBalance(ALICE, ID_A), 10, "stored balance unchanged for this specific balance / multiplier" + ); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "alice cursor advanced"); + } + + /// Already-migrated complement: a `(holder, id)` pair at the latest + /// cursor that gets touched again with no new completed splits in + /// between must NOT re-emit `ReceiptAccountMigrated`. Pins the + /// `newCursor == currentCursor` early return in `migrateHolderId`. + function testReceiptAccountMigratedDoesNotReEmitWhenAlreadyAtLatest() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + // First touch migrates Alice — event fires. + _transfer(ALICE, ALICE, ID_A, 0); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "alice migrated to cursor 1"); + + // Second touch with no new splits — event must NOT fire. + vm.recordLogs(); + _transfer(ALICE, ALICE, ID_A, 0); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 sig = StoxReceipt.ReceiptAccountMigrated.selector; + for (uint256 i = 0; i < logs.length; i++) { + if ( + logs[i].topics.length > 0 && logs[i].topics[0] == sig + && address(uint160(uint256(logs[i].topics[1]))) == ALICE && uint256(logs[i].topics[2]) == ID_A + ) { + fail(); + } + } + } + + /// Event ordering pin: `ReceiptAccountMigrated` must fire BEFORE the + /// corresponding ERC-1155 `TransferSingle` event in the same `_update` + /// call, because `migrateHolderId` runs before the receipt's base + /// `_update`. Indexers rely on this ordering to compute pre-transfer + /// rasterized balances from the migration log. + function testReceiptAccountMigratedOrderedBeforeTransferSingle() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + vm.recordLogs(); + _transfer(ALICE, BOB, ID_A, 50); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 migratedSig = StoxReceipt.ReceiptAccountMigrated.selector; + bytes32 transferSig = keccak256("TransferSingle(address,address,address,uint256,uint256)"); + uint256 firstMigrated = type(uint256).max; + uint256 firstTransfer = type(uint256).max; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length == 0) continue; + if (logs[i].topics[0] == migratedSig && firstMigrated == type(uint256).max) { + firstMigrated = i; + } else if (logs[i].topics[0] == transferSig && firstTransfer == type(uint256).max) { + firstTransfer = i; + } + } + assertLt(firstMigrated, firstTransfer, "ReceiptAccountMigrated must precede TransferSingle"); + } + + /// Global receipt-side invariant: across a random balance and a + /// random sequence of stock-split multipliers, every `(holder, id)` + /// cursor advance is matched by exactly one `ReceiptAccountMigrated` + /// log, and the log's `oldBalance / newBalance` pair always equals + /// the actual pre/post stored balance. + function testFuzzReceiptAccountMigratedFiresOnEveryCursorAdvance( + uint64 startBalance, + uint8 splitSeed, + uint8 splitCount + ) external { + startBalance = uint64(bound(startBalance, 0, type(uint32).max)); + splitCount = uint8(bound(splitCount, 1, 5)); + + if (startBalance > 0) { + _mint(ALICE, ID_A, uint256(startBalance)); + } + uint256 storedBefore = receipt.rawStoredBalance(ALICE, ID_A); + uint256 cursorBefore = receipt.holderIdCursor(ALICE, ID_A); + + for (uint256 i = 0; i < splitCount; i++) { + uint8 pick = uint8((uint256(splitSeed) >> (i * 2)) & 0x3); + if (pick == 0) { + _splitParams(2); + } else if (pick == 1) { + _splitParams(3); + } else if (pick == 2) { + _fractionalParams(1, 2); + } else { + _fractionalParams(1, 3); + } + } + + vm.recordLogs(); + _transfer(ALICE, ALICE, ID_A, 0); + + bytes32 sig = StoxReceipt.ReceiptAccountMigrated.selector; + Vm.Log[] memory logs = vm.getRecordedLogs(); + uint256 count; + uint256 emittedFromCursor; + uint256 emittedToCursor; + uint256 emittedOld; + uint256 emittedNew; + for (uint256 i = 0; i < logs.length; i++) { + if ( + logs[i].topics.length > 0 && logs[i].topics[0] == sig + && address(uint160(uint256(logs[i].topics[1]))) == ALICE && uint256(logs[i].topics[2]) == ID_A + ) { + count++; + (emittedFromCursor, emittedToCursor, emittedOld, emittedNew) = + abi.decode(logs[i].data, (uint256, uint256, uint256, uint256)); + } + } + + uint256 cursorAfter = receipt.holderIdCursor(ALICE, ID_A); + if (cursorAfter == cursorBefore) { + assertEq(count, 0, "no event when cursor did not advance"); + } else { + assertEq(count, 1, "exactly one ReceiptAccountMigrated event per cursor advance"); + assertEq(emittedFromCursor, cursorBefore, "fromCursor matches pre-migrate cursor"); + assertEq(emittedToCursor, cursorAfter, "toCursor matches post-migrate cursor"); + assertEq(emittedOld, storedBefore, "oldBalance matches the pre-migrate stored balance"); + assertEq( + emittedNew, receipt.rawStoredBalance(ALICE, ID_A), "newBalance matches the post-migrate stored balance" + ); + } + } + + // ----------------------------------------------------------------------- + // balanceOfBatch consistency + + /// balanceOfBatch must return the same rebased values as calling + /// balanceOf on each (account, id) individually. Without the override + /// OZ's default balanceOfBatch reads _balances directly and bypasses + /// the rebase. + function testBalanceOfBatchRebaseConsistency() external { + _mint(ALICE, ID_A, 100); + _mint(BOB, ID_B, 200); + _splitParams(2); + + // Neither account has touched since the split — raw stored balances + // are stale, but balanceOf should return rebased values. + address[] memory accounts = new address[](3); + accounts[0] = ALICE; + accounts[1] = BOB; + accounts[2] = ALICE; + uint256[] memory ids = new uint256[](3); + ids[0] = ID_A; + ids[1] = ID_B; + ids[2] = ID_B; // Alice has 0 of ID_B + + uint256[] memory batch = receipt.balanceOfBatch(accounts, ids); + + assertEq(batch[0], receipt.balanceOf(ALICE, ID_A), "batch[0] must match balanceOf(ALICE, ID_A)"); + assertEq(batch[1], receipt.balanceOf(BOB, ID_B), "batch[1] must match balanceOf(BOB, ID_B)"); + assertEq(batch[2], receipt.balanceOf(ALICE, ID_B), "batch[2] must match balanceOf(ALICE, ID_B)"); + + // Concrete values: 2× split on 100 and 200. + assertEq(batch[0], 200); + assertEq(batch[1], 400); + assertEq(batch[2], 0); + } + + /// balanceOfBatch with mismatched array lengths reverts. + function testBalanceOfBatchMismatchedLengthsReverts() external { + address[] memory accounts = new address[](2); + uint256[] memory ids = new uint256[](1); + vm.expectRevert(); + receipt.balanceOfBatch(accounts, ids); + } + + /// By the time OZ's `_doSafeTransferAcceptanceCheck` fires + /// `onERC1155Received` on a contract recipient, migration has + /// completed and balances are rasterized. A receive hook that reads + /// `balanceOf` observes the post-migration, post-transfer values. + function testReceiveHookObservesPostMigrationState() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + // Use a contract receiver that records the state observed during + // the onERC1155Received callback. + RecordingReceiver recv = new RecordingReceiver(receipt, ALICE); + _transfer(ALICE, address(recv), ID_A, 50); + + // Inside the callback, alice's pre-transfer effective balance was + // 200 (rebased from stored 100). The hook fires AFTER migration + // (alice stored becomes 200) and AFTER the transfer (alice raw: + // 200 - 50 = 150, recv raw: 0 + 50 = 50). + assertEq(recv.observedAliceBalance(), 150, "hook must see post-transfer alice balance"); + assertEq(recv.observedRecvBalance(), 50, "hook must see post-transfer recv balance"); + } + + /// The batch-receive hook `onERC1155BatchReceived` fires after + /// migration and after the batch transfer has executed. A receiver + /// that reads balances from inside the callback observes the same + /// post-migration, post-transfer state as `onERC1155Received` does + /// for single transfers. + function testBatchReceiveHookObservesPostMigrationState() external { + _mint(ALICE, ID_A, 100); + _mint(ALICE, ID_B, 200); + _splitParams(2); + + BatchRecordingReceiver recv = new BatchRecordingReceiver(receipt); + + uint256[] memory ids = new uint256[](2); + ids[0] = ID_A; + ids[1] = ID_B; + uint256[] memory amounts = new uint256[](2); + amounts[0] = 50; + amounts[1] = 100; + + vm.prank(ALICE); + receipt.safeBatchTransferFrom(ALICE, address(recv), ids, amounts, ""); + + assertEq(recv.observedBalance(ID_A), 50, "hook sees post-transfer recv ID_A"); + assertEq(recv.observedBalance(ID_B), 100, "hook sees post-transfer recv ID_B"); + } + + /// Burn from a (holder, id) with a pending rebase subtracts from the + /// post-rebase balance. The migration runs first so OZ's + /// `_balances[id][holder]` is rasterized before `_burn` decrements it. + function testBurnAfterSplitSubtractsFromRebasedBalance() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + _burn(ALICE, ID_A, 50); + + assertEq(receipt.balanceOf(ALICE, ID_A), 150, "post-rebase 200 minus 50 burn"); + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 150, "raw stored equals post-rebase minus burn"); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1); + } + + /// Burning the full post-rebase balance zeroes the raw stored value + /// while leaving the cursor at the latest completed split. + function testBurnToZeroAfterSplitLeavesCursorAtLatest() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + _burn(ALICE, ID_A, 200); + + assertEq(receipt.balanceOf(ALICE, ID_A), 0); + assertEq(receipt.rawStoredBalance(ALICE, ID_A), 0); + assertEq(receipt.holderIdCursor(ALICE, ID_A), 1, "cursor still at latest despite zero balance"); + } + + /// Burning more than the post-rebase balance reverts via OZ's + /// `ERC1155InsufficientBalance`. Migration rasterizes the raw value + /// first, so the burner's raw balance at the point of `_burn` is the + /// same value OZ's check sees. + function testOverBurnAfterSplitReverts() external { + _mint(ALICE, ID_A, 100); + _splitParams(2); + + // After migration, Alice's raw stored balance is 200. OZ's check + // sees raw 200 vs burn 201 and reverts with the exact amounts. + vm.expectRevert( + abi.encodeWithSelector(IERC1155Errors.ERC1155InsufficientBalance.selector, ALICE, 200, 201, ID_A) + ); + _burn(ALICE, ID_A, 201); + } + + // ----------------------------------------------------------------------- + // Helpers + + function _mint(address to, uint256 id, uint256 amount) internal { + vm.prank(address(vault)); + receipt.managerMint(address(vault), to, id, amount, ""); + } + + function _burn(address from, uint256 id, uint256 amount) internal { + vm.prank(address(vault)); + receipt.managerBurn(address(vault), from, id, amount, ""); + } + + function _transfer(address from, address to, uint256 id, uint256 amount) internal { + vm.prank(address(vault)); + receipt.managerTransferFrom(address(vault), from, to, id, amount, ""); + } +} diff --git a/test/src/concrete/StoxReceiptVault.setAuthorizerGuard.t.sol b/test/src/concrete/StoxReceiptVault.setAuthorizerGuard.t.sol index 6f8f925d..8f794a45 100644 --- a/test/src/concrete/StoxReceiptVault.setAuthorizerGuard.t.sol +++ b/test/src/concrete/StoxReceiptVault.setAuthorizerGuard.t.sol @@ -4,6 +4,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {StoxReceiptVault} from "../../../src/concrete/StoxReceiptVault.sol"; +import {OwnedStoxReceiptVault} from "./OwnedStoxReceiptVault.sol"; import { StoxOffchainAssetReceiptVaultAuthorizerV1 } from "../../../src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.sol"; @@ -30,17 +31,6 @@ import {SCHEDULE_CORPORATE_ACTION, CANCEL_CORPORATE_ACTION} from "../../../src/l import {MockERC20} from "../../concrete/MockERC20.sol"; import {OwnableUpgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/access/OwnableUpgradeable.sol"; -/// Minimal subclass that transfers ownership to a known address in its -/// constructor so the test can pose as the vault owner without running -/// the full Zoltu-deployer-and-initialize flow. The guard under test is -/// `setAuthorizer`, which only depends on `OwnableUpgradeable`'s owner -/// being set — not on the rest of vault initialization. -contract OwnedStoxReceiptVault is StoxReceiptVault { - constructor(address owner) { - _transferOwnership(owner); - } -} - /// @title StoxReceiptVault setAuthorizer guard /// @notice Pins that `StoxReceiptVault.setAuthorizer` rejects authorizers /// that lack admin hierarchy for either corporate-action role, surfacing diff --git a/test/src/concrete/StoxReceiptVault.t.sol b/test/src/concrete/StoxReceiptVault.t.sol index 05137613..94e75253 100644 --- a/test/src/concrete/StoxReceiptVault.t.sol +++ b/test/src/concrete/StoxReceiptVault.t.sol @@ -2,89 +2,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd pragma solidity =0.8.25; -import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; -import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import {Test} from "forge-std-1.16.1/src/Test.sol"; import {StoxReceiptVault} from "../../../src/concrete/StoxReceiptVault.sol"; import {Initializable} from "@openzeppelin-contracts-upgradeable-5.6.1/proxy/utils/Initializable.sol"; -import {ERC20Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC20/ERC20Upgradeable.sol"; -import {IERC20Errors} from "@openzeppelin-contracts-5.6.1/interfaces/draft-IERC6093.sol"; -import {LibCorporateAction} from "../../../src/lib/LibCorporateAction.sol"; -import {NODE_NONE} from "../../../src/lib/LibCorporateActionNode.sol"; -import { - ACTION_TYPE_STOCK_SPLIT_V1, - ACTION_TYPE_STABLES_DIVIDEND_V1 -} from "../../../src/interface/ICorporateActionsV1.sol"; -import {LibERC20Storage} from "../../../src/lib/LibERC20Storage.sol"; -import {LibStockSplit} from "../../../src/lib/LibStockSplit.sol"; -import {LibTotalSupply} from "../../../src/lib/LibTotalSupply.sol"; - -/// @dev Test-only subclass of StoxReceiptVault that bypasses -/// `OffchainAssetReceiptVault._update`'s authorizer / freeze checks. This lets -/// us exercise `StoxReceiptVault`'s migration logic in isolation without -/// standing up the full rain.vats auth/freeze infrastructure (admin grants, -/// authorizer wiring, certify state, etc). -/// -/// The test class re-overrides `_update` to call `migrateAccount` for both -/// sides and then call `ERC20Upgradeable._update` directly, skipping the -/// `OffchainAssetReceiptVault._update` middle layer. The migration semantics -/// being tested live entirely in `StoxReceiptVault` and the libraries it calls, -/// so the bypass is faithful for the purpose of these tests. -contract TestStoxReceiptVault is StoxReceiptVault { - function _update(address from, address to, uint256 amount) internal override { - // Mirror the production StoxReceiptVault._update flow exactly, only - // bypassing the OffchainAssetReceiptVault authorizer/freeze layer. - LibTotalSupply.fold(); - - migrateAccount(from); - migrateAccount(to); - - ERC20Upgradeable._update(from, to, amount); - - if (from == address(0)) { - LibTotalSupply.onMint(amount); - } else if (to == address(0)) { - LibTotalSupply.onBurn(amount); - } - } - - /// Expose ERC20 _update so tests can drive mints/burns/transfers without - /// going through the vault's deposit/withdraw flow (which has its own - /// initialization requirements). - function publicUpdate(address from, address to, uint256 amount) external { - _update(from, to, amount); - } - - /// Expose corporate-action scheduling so tests can set up split state - /// using this vault's storage namespace. - function publicSchedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) - external - returns (uint256) - { - return LibCorporateAction.schedule(actionType, effectiveTime, parameters); - } - - /// Expose corporate-action cancellation for tests that need to remove a - /// pending split before its effective time. - function publicCancel(uint256 actionIndex) external { - LibCorporateAction.cancel(actionIndex); - } - - function rawStoredBalance(address account) external view returns (uint256) { - return LibERC20Storage.underlyingBalance(account); - } - - function migrationCursor(address account) external view returns (uint256) { - return LibCorporateAction.getStorage().accountMigrationCursor[account]; - } - - function totalSupplyLatestCursor() external view returns (uint256) { - return LibCorporateAction.getStorage().totalSupplyLatestCursor; - } - - function unmigrated(uint256 cursor) external view returns (uint256) { - return LibCorporateAction.getStorage().unmigrated[cursor]; - } -} contract StoxReceiptVaultTest is Test { /// Constructor disables initializers on the implementation. @@ -94,1655 +14,3 @@ contract StoxReceiptVaultTest is Test { impl.initialize(abi.encode(address(1))); } } - -/// Integration tests for the corporate-actions rebase hooks. -/// -/// These tests are the regression guards for the CRITICAL inflation bug -/// where mint or transfer to a fresh recipient after a completed split -/// would over-multiply -/// the recipient's balance, minting tokens out of thin air. -contract StoxReceiptVaultMigrationIntegrationTest is Test { - TestStoxReceiptVault internal vault; - - address internal constant ALICE = address(0xA11CE); - address internal constant BOB = address(0xB0B); - address internal constant CAROL = address(0xCA401); - - function setUp() public { - vault = new TestStoxReceiptVault(); - vm.warp(1000); - } - - function _splitParams(int256 multiplier) internal pure returns (bytes memory) { - return LibStockSplit.encodeParametersV1(LibDecimalFloat.packLossless(multiplier, 0)); - } - - function _fractionalParams(int256 num, int256 denom) internal pure returns (bytes memory) { - Float result = LibDecimalFloat.div(LibDecimalFloat.packLossless(num, 0), LibDecimalFloat.packLossless(denom, 0)); - return LibStockSplit.encodeParametersV1(result); - } - - /// Mint to a fresh account after a completed 2x split credits exactly - /// the minted amount, not 2x the minted amount. Without the - /// zero-balance cursor-advancement guard, the recipient's freshly- - /// written post-rebase balance would be re-multiplied on the next - /// `balanceOf` read — an inflation bug. - function testMintToFreshAccountAfterCompletedSplitDoesNotInflate() external { - // Pre-existing supply so the split has something to rebase. - vault.publicUpdate(address(0), BOB, 1000); - - // Schedule and complete a 2x stock split. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // Mint 100 to Alice — a brand new account. - vault.publicUpdate(address(0), ALICE, 100); - - // Alice should have exactly 100, not 200. - assertEq(vault.balanceOf(ALICE), 100, "fresh recipient must not over-multiply on mint"); - } - - /// Transfer to a fresh recipient after a completed split credits - /// exactly the transferred amount, not multiplied by the split. - function testTransferToFreshRecipientAfterCompletedSplitDoesNotInflate() external { - // Bob has a pre-existing balance. - vault.publicUpdate(address(0), BOB, 50); - - // Schedule and complete a 2x stock split. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // After the split, Bob's balance should be 100. - assertEq(vault.balanceOf(BOB), 100, "Bob's balance should rebase to 100"); - - // Bob transfers 100 to Alice (a brand new account). - vault.publicUpdate(BOB, ALICE, 100); - - // Alice received 100, not 200. - assertEq(vault.balanceOf(ALICE), 100, "fresh recipient must not over-multiply on transfer"); - // Bob is now empty. - assertEq(vault.balanceOf(BOB), 0, "Bob should be empty after sending all"); - } - - /// Mint to a fresh account before any splits — sanity check. - function testMintBeforeAnySplit() external { - vault.publicUpdate(address(0), ALICE, 100); - assertEq(vault.balanceOf(ALICE), 100); - } - - /// Pre-existing holder's balance correctly reflects a completed split. - function testBalanceOfRebaseOnExistingHolder() external { - vault.publicUpdate(address(0), BOB, 50); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - assertEq(vault.balanceOf(BOB), 100); - } - - /// After A03-1's fix, a fresh account that gets touched by a zero-amount - /// transfer (or any interaction) should have its cursor advanced to the - /// latest completed split. - function testFreshAccountCursorAdvancesAfterMigration() external { - vault.publicUpdate(address(0), BOB, 1000); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // Touch Alice via a 0-amount mint. (The publicUpdate path via mint=0 - // doesn't trigger OZ's mint-amount check at this layer.) - vault.publicUpdate(address(0), ALICE, 0); - - // Alice's cursor should now be 1 — bootstrap is at idx 0 (default - // cursor) and the completed split lands at idx 1. - assertEq(vault.migrationCursor(ALICE), 1, "fresh account cursor must advance"); - } - - /// Pre-schedule fresh-account cursor pin: an address that has never - /// interacted with the vault returns `migrationCursor == 0` from the - /// `accountMigrationCursor` mapping. The 0-based scheme leans on this - /// default — 0 is the bootstrap node, so "no migration applied" and - /// "migrated through identity bootstrap" are the same state. A - /// regression that changed the namespace base, mapping shape, or - /// initialised cursors to anything other than 0 surfaces here. - function testMigrationCursorDefaultsToBootstrapForFreshAccount() external view { - address fresh = address(0xCAFE); - assertEq(vault.migrationCursor(fresh), 0, "fresh account cursor defaults to 0 (= bootstrap)"); - } - - /// `migrateAccount` is a complete no-op when no completed splits - /// exist past the holder's cursor. Fresh holder (cursor 0 = bootstrap), - /// only bootstrap fired, no user splits completed: a touch must not - /// emit `AccountMigrated` and must not write to `accountMigrationCursor` - /// (it stays at the default 0). The cursor-advance early-return inside - /// `migrateAccount` (`if (newCursor == currentCursor) return;`) is what - /// suppresses both. A regression that emitted unconditionally or that - /// wrote the cursor before the early-return would surface here. - function testMigrateAccountNoOpWhenAtLatest() external { - // Schedule a future user split so `ensureBootstrap` fires. - // The split is pending; only bootstrap is completed. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 5000, _splitParams(2)); - - vm.recordLogs(); - vault.publicUpdate(address(0), ALICE, 0); // touch ALICE without minting - Vm.Log[] memory logs = vm.getRecordedLogs(); - - bytes32 sig = StoxReceiptVault.AccountMigrated.selector; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics.length > 0 && logs[i].topics[0] == sig) { - fail(); - } - } - assertEq(vault.migrationCursor(ALICE), 0, "cursor stays at bootstrap default for no-op migration"); - assertEq(vault.rawStoredBalance(ALICE), 0, "stored balance stays at default for no-op migration"); - } - - /// Two consecutive splits, then mint to a fresh account: still no inflation. - function testMintFreshAccountAfterTwoCompletedSplits() external { - vault.publicUpdate(address(0), BOB, 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); - vm.warp(3000); - - vault.publicUpdate(address(0), ALICE, 100); - - assertEq(vault.balanceOf(ALICE), 100); - } - - /// Bob existed before the splits and never interacted; his eventual - /// migration produces the correct rebased balance. - function testDormantHolderMigratesCorrectly() external { - vault.publicUpdate(address(0), BOB, 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); - vm.warp(3000); - - // Force a migration via a touch. - vault.publicUpdate(BOB, BOB, 0); - - assertEq(vault.balanceOf(BOB), 600, "100 * 2 * 3 = 600"); - assertEq(vault.rawStoredBalance(BOB), 600, "stored balance is rasterized to post-rebase"); - assertEq(vault.migrationCursor(BOB), 2, "cursor advanced to latest split (idx 2; bootstrap at idx 0)"); - } - - /// Burn from a holder works correctly after a split. - function testBurnAfterSplit() external { - vault.publicUpdate(address(0), BOB, 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // Bob's effective balance is 200 after the split. - assertEq(vault.balanceOf(BOB), 200); - - // Burn 50 from Bob. - vault.publicUpdate(BOB, address(0), 50); - assertEq(vault.balanceOf(BOB), 150); - } - - /// AccountMigrated event fires with correct values when a non-zero - /// account is migrated through a completed split. - function testAccountMigratedEventEmitted() external { - vault.publicUpdate(address(0), BOB, 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - vm.expectEmit(true, false, false, true, address(vault)); - emit StoxReceiptVault.AccountMigrated(BOB, 0, 1, 100, 200); - // Touch Bob to trigger migration. fromCursor=0 (= bootstrap idx 0), - // toCursor=1 (the completed split). - vault.publicUpdate(BOB, BOB, 0); - } - - /// `AccountMigrated` must fire exactly once per `_update`, aggregating - /// the full multi-split migration into a single event with the aggregate - /// `fromCursor → toCursor` and `oldBalance → newBalance`. Pins that the - /// emit is not per-split and that the post-rasterization fields reflect - /// the end state after all completed splits are applied, not an - /// intermediate state. - function testAccountMigratedEventAggregatesAcrossMultipleSplits() external { - vault.publicUpdate(address(0), BOB, 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); - vm.warp(3000); - - vm.recordLogs(); - vault.publicUpdate(BOB, BOB, 0); - - bytes32 sig = StoxReceiptVault.AccountMigrated.selector; - Vm.Log[] memory logs = vm.getRecordedLogs(); - uint256 count = 0; - uint256 fromCursor; - uint256 toCursor; - uint256 oldBalance; - uint256 newBalance; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics.length > 0 && logs[i].topics[0] == sig) { - count++; - assertEq(address(uint160(uint256(logs[i].topics[1]))), BOB, "indexed account is BOB"); - (fromCursor, toCursor, oldBalance, newBalance) = - abi.decode(logs[i].data, (uint256, uint256, uint256, uint256)); - } - } - assertEq(count, 1, "exactly one AccountMigrated event per multi-split migration"); - assertEq(fromCursor, 0, "fromCursor is pre-migration cursor (default = bootstrap idx 0)"); - // Bootstrap (idx 0) + two splits (idx 1, 2); migration walks past - // the bootstrap to the second split. - assertEq(toCursor, 2, "toCursor is latest completed split index"); - assertEq(oldBalance, 100, "oldBalance is pre-rasterization stored value"); - assertEq(newBalance, 600, "newBalance is fully rasterized (100 * 2 * 3)"); - } - - /// Phenomenon 1 (zero balance): `AccountMigrated` fires when a - /// zero-balance account's cursor advances. `oldBalance == newBalance == 0`, - /// the cursor moves from 0 to the latest completed split. Pins issue - /// #81 resolution: every cursor advance emits. - function testAccountMigratedFiresOnZeroBalanceCursorAdvance() external { - // Pre-existing holder so bootstrap has something to read. - vault.publicUpdate(address(0), BOB, 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // Touch Alice (zero balance, fresh recipient). - vm.expectEmit(true, false, false, true, address(vault)); - emit StoxReceiptVault.AccountMigrated(ALICE, 0, 1, 0, 0); - vault.publicUpdate(address(0), ALICE, 0); - - // Confirm the cursor actually advanced. - assertEq(vault.migrationCursor(ALICE), 1, "alice cursor must have advanced"); - } - - /// Phenomenon 2 (single-step truncation collision): a stored balance of - /// 1 through a 1.5x multiplier rasterizes to `trunc(1.5) == 1`. The - /// cursor advances; the stored balance is unchanged; the event still - /// fires. - function testAccountMigratedFiresOnTruncationCollision() external { - vault.publicUpdate(address(0), ALICE, 1); - // Multiplier 3/2 = 1.5 — `trunc(1 * 1.5) == 1`. - Float oneAndAHalf = LibDecimalFloat.div(LibDecimalFloat.packLossless(3, 0), LibDecimalFloat.packLossless(2, 0)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(oneAndAHalf)); - vm.warp(2000); - - vm.expectEmit(true, false, false, true, address(vault)); - emit StoxReceiptVault.AccountMigrated(ALICE, 0, 1, 1, 1); - vault.publicUpdate(ALICE, ALICE, 0); - - assertEq(vault.rawStoredBalance(ALICE), 1, "stored balance unchanged after truncation collision"); - assertEq(vault.migrationCursor(ALICE), 1, "alice cursor advanced past the split"); - } - - /// Phenomenon 3 (multi-step round-trip): a balance of 4 through `[2x, - /// 1/2x]` rasterizes `4 -> 8 -> 4`. The intermediate value differs - /// from the start but the final equals it. (Rain Float represents 1/2 - /// exactly in base 10, so this sequence round-trips for any even - /// balance — `1/3` would not, since the Float representation of 1/3 - /// is slightly less than exact 1/3 and `trunc(3 * 1/3_float) = 0`.) - /// The cursor jumps two splits in a single `_update`; the event fires - /// once with `oldBalance == newBalance == 4`. - function testAccountMigratedFiresOnMultiStepRoundTrip() external { - vault.publicUpdate(address(0), ALICE, 4); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _fractionalParams(1, 2)); - vm.warp(3000); - - vm.expectEmit(true, false, false, true, address(vault)); - emit StoxReceiptVault.AccountMigrated(ALICE, 0, 2, 4, 4); - vault.publicUpdate(ALICE, ALICE, 0); - - assertEq(vault.rawStoredBalance(ALICE), 4, "stored balance round-tripped to itself"); - // Bootstrap at idx 0, splits at idx 1 and 2 — alice walks past both. - assertEq(vault.migrationCursor(ALICE), 2, "alice cursor advanced past both splits"); - } - - /// Phenomenon 4 (balance-specific identity): a balance of 10 through a - /// 1.09x multiplier rasterizes to `trunc(10.9) == 10`. Same multiplier - /// applied to a larger balance produces a real change; the no-op here - /// is balance-specific. The event still fires. - function testAccountMigratedFiresOnBalanceSpecificIdentity() external { - vault.publicUpdate(address(0), ALICE, 10); - // 1.09 = 109/100 — `trunc(10 * 1.09) = trunc(10.9) == 10`. - Float oneOhNine = - LibDecimalFloat.div(LibDecimalFloat.packLossless(109, 0), LibDecimalFloat.packLossless(100, 0)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(oneOhNine)); - vm.warp(2000); - - vm.expectEmit(true, false, false, true, address(vault)); - emit StoxReceiptVault.AccountMigrated(ALICE, 0, 1, 10, 10); - vault.publicUpdate(ALICE, ALICE, 0); - - assertEq(vault.rawStoredBalance(ALICE), 10, "stored balance unchanged for this specific balance / multiplier"); - assertEq(vault.migrationCursor(ALICE), 1, "alice cursor advanced"); - } - - /// Transfer path with both `from` and `to` stale: both ends migrate - /// during `_update`, so two `AccountMigrated` events fire — one per - /// account — both before the ERC-20 `Transfer`. Bob's balance is - /// non-zero pre-split, so his pre-rebase value is rasterized and - /// his event has `oldBalance != newBalance`; Alice's same. - function testAccountMigratedFiresForBothEndsOfTransfer() external { - vault.publicUpdate(address(0), ALICE, 100); - vault.publicUpdate(address(0), BOB, 200); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - vm.recordLogs(); - vault.publicUpdate(ALICE, BOB, 50); - Vm.Log[] memory logs = vm.getRecordedLogs(); - - bytes32 sig = StoxReceiptVault.AccountMigrated.selector; - uint256 aliceCount; - uint256 bobCount; - uint256 aliceLogIdx = type(uint256).max; - uint256 bobLogIdx = type(uint256).max; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics.length == 0 || logs[i].topics[0] != sig) continue; - address who = address(uint160(uint256(logs[i].topics[1]))); - if (who == ALICE) { - aliceCount++; - aliceLogIdx = i; - } else if (who == BOB) { - bobCount++; - bobLogIdx = i; - } - } - assertEq(aliceCount, 1, "exactly one AccountMigrated for ALICE"); - assertEq(bobCount, 1, "exactly one AccountMigrated for BOB"); - // ordering: ALICE migrates first (the `from` side), then BOB. - assertLt(aliceLogIdx, bobLogIdx, "ALICE (from) migrates before BOB (to)"); - - // Decode payloads and check each rasterized balance. - (, uint256 aliceTo, uint256 aliceOld, uint256 aliceNew) = - abi.decode(logs[aliceLogIdx].data, (uint256, uint256, uint256, uint256)); - (, uint256 bobTo, uint256 bobOld, uint256 bobNew) = - abi.decode(logs[bobLogIdx].data, (uint256, uint256, uint256, uint256)); - assertEq(aliceTo, 1, "alice cursor advanced to split"); - assertEq(aliceOld, 100); - assertEq(aliceNew, 200); - assertEq(bobTo, 1, "bob cursor advanced to split"); - assertEq(bobOld, 200); - assertEq(bobNew, 400); - } - - /// Already-migrated complement of #81's always-emit semantics: an - /// account at the latest cursor that gets touched again (no new - /// completed splits in between) must NOT re-emit `AccountMigrated`. - /// The `newCursor == currentCursor` early return in `migrateAccount` - /// suppresses the spurious event. Pins that "every cursor advance - /// emits" reads as "iff cursor advances". - function testAccountMigratedDoesNotReEmitWhenAlreadyAtLatest() external { - vault.publicUpdate(address(0), ALICE, 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // First touch migrates Alice — event fires. - vault.publicUpdate(ALICE, ALICE, 0); - assertEq(vault.migrationCursor(ALICE), 1, "alice migrated to cursor 1"); - - // Second touch with no new splits — event must NOT fire. - vm.recordLogs(); - vault.publicUpdate(ALICE, ALICE, 0); - Vm.Log[] memory logs = vm.getRecordedLogs(); - - bytes32 sig = StoxReceiptVault.AccountMigrated.selector; - for (uint256 i = 0; i < logs.length; i++) { - if ( - logs[i].topics.length > 0 && logs[i].topics[0] == sig - && address(uint160(uint256(logs[i].topics[1]))) == ALICE - ) { - fail(); - } - } - } - - /// Event ordering pin: `AccountMigrated` must fire BEFORE the - /// corresponding ERC-20 `Transfer` event in the same `_update` call, - /// because `migrateAccount` runs before `super._update`. Indexers - /// rely on this ordering to compute pre-transfer rasterized balances - /// from the migration log before applying the transfer delta. - function testAccountMigratedOrderedBeforeTransfer() external { - vault.publicUpdate(address(0), ALICE, 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - vm.recordLogs(); - vault.publicUpdate(ALICE, BOB, 50); - Vm.Log[] memory logs = vm.getRecordedLogs(); - - bytes32 migratedSig = StoxReceiptVault.AccountMigrated.selector; - bytes32 transferSig = keccak256("Transfer(address,address,uint256)"); - uint256 firstMigrated = type(uint256).max; - uint256 firstTransfer = type(uint256).max; - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics.length == 0) continue; - if (logs[i].topics[0] == migratedSig && firstMigrated == type(uint256).max) { - firstMigrated = i; - } else if (logs[i].topics[0] == transferSig && firstTransfer == type(uint256).max) { - firstTransfer = i; - } - } - assertLt(firstMigrated, firstTransfer, "AccountMigrated must precede Transfer"); - } - - /// Global invariant: across a random balance and a random sequence of - /// stock-split multipliers, every cursor advance is matched by exactly - /// one `AccountMigrated` log, and the log's `oldBalance / newBalance` - /// pair always equals the actual pre/post stored balance — never a - /// stale or skipped value. - function testFuzzAccountMigratedFiresOnEveryCursorAdvance(uint64 startBalance, uint8 splitSeed, uint8 splitCount) - external - { - startBalance = uint64(bound(startBalance, 0, type(uint32).max)); - splitCount = uint8(bound(splitCount, 1, 5)); - - vault.publicUpdate(address(0), ALICE, startBalance); - uint256 storedBefore = vault.rawStoredBalance(ALICE); - uint256 cursorBefore = vault.migrationCursor(ALICE); - - // Schedule N splits with multipliers drawn from a small fixed - // palette (2x, 3x, 1/2x, 1/3x) seeded by `splitSeed`. The point is - // to drive a variety of rasterization outcomes — not to be - // exhaustive over the multiplier space. - for (uint256 i = 0; i < splitCount; i++) { - uint8 pick = uint8((uint256(splitSeed) >> (i * 2)) & 0x3); - // forge-lint: disable-next-line(unsafe-typecast) - uint64 effectiveTime = uint64(1500 + i * 1000); - if (pick == 0) { - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, _splitParams(2)); - } else if (pick == 1) { - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, _splitParams(3)); - } else if (pick == 2) { - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, _fractionalParams(1, 2)); - } else { - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, _fractionalParams(1, 3)); - } - } - vm.warp(uint64(1500 + uint256(splitCount) * 1000)); - - // Touch Alice. Capture every log emitted by `_update`. - vm.recordLogs(); - vault.publicUpdate(ALICE, ALICE, 0); - - bytes32 sig = StoxReceiptVault.AccountMigrated.selector; - Vm.Log[] memory logs = vm.getRecordedLogs(); - uint256 count; - uint256 emittedFromCursor; - uint256 emittedToCursor; - uint256 emittedOld; - uint256 emittedNew; - for (uint256 i = 0; i < logs.length; i++) { - if ( - logs[i].topics.length > 0 && logs[i].topics[0] == sig - && address(uint160(uint256(logs[i].topics[1]))) == ALICE - ) { - count++; - (emittedFromCursor, emittedToCursor, emittedOld, emittedNew) = - abi.decode(logs[i].data, (uint256, uint256, uint256, uint256)); - } - } - - uint256 cursorAfter = vault.migrationCursor(ALICE); - if (cursorAfter == cursorBefore) { - // No cursor advance → no event. Defensively pin this branch - // even though the bounded splitCount makes it unreachable. - assertEq(count, 0, "no event when cursor did not advance"); - } else { - assertEq(count, 1, "exactly one AccountMigrated event per cursor advance"); - assertEq(emittedFromCursor, cursorBefore, "fromCursor matches pre-migrate cursor"); - assertEq(emittedToCursor, cursorAfter, "toCursor matches post-migrate cursor"); - assertEq(emittedOld, storedBefore, "oldBalance matches the pre-migrate stored balance"); - assertEq(emittedNew, vault.rawStoredBalance(ALICE), "newBalance matches the post-migrate stored balance"); - } - } - - /// Transfer attempt after a reverse split truncates the sender's - /// balance to zero. Migration runs first, writing the post-truncation - /// value to storage. OZ's `_update` then sees `_balances[from] == 0` - /// and reverts with `ERC20InsufficientBalance` for any non-zero - /// transfer amount. - function testTransferRevertsWhenMigrationTruncatesBalanceToZero() external { - // Alice has stored 1 pre-split. A 1/2x split truncates her to 0. - vault.publicUpdate(address(0), ALICE, 1); - Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(halfX)); - vm.warp(2000); - - // View already reflects the truncation. - assertEq(vault.balanceOf(ALICE), 0, "balanceOf reflects truncation pre-migration"); - - // Any non-zero transfer reverts with OZ's insufficient-balance error — - // migration writes stored = 0 before the transfer arithmetic runs. - vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, ALICE, 0, 1)); - vault.publicUpdate(ALICE, BOB, 1); - } - - /// Partial truncation: balance is reduced but non-zero, transfer succeeds - /// up to the migrated amount. - function testTransferSucceedsUpToMigratedBalanceAfterPartialTruncation() external { - // Alice has stored 3 pre-split. 1/2x truncates 3 → 1. - vault.publicUpdate(address(0), ALICE, 3); - Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(halfX)); - vm.warp(2000); - assertEq(vault.balanceOf(ALICE), 1, "alice migrated balance = trunc(3 * 0.5) = 1"); - - // Transfer exactly the migrated amount. - vault.publicUpdate(ALICE, BOB, 1); - assertEq(vault.balanceOf(ALICE), 0, "alice drained"); - assertEq(vault.balanceOf(BOB), 1, "bob received the full 1 unit"); - } - - /// Boundary on `effectiveTime`: a split whose effective time equals the - /// current block timestamp must be treated as completed (via the `<=` - /// comparison in `LibCorporateActionNode.nextOfType`). One second before - /// its effective time it must NOT be completed. Pins the exact threshold - /// so a future refactor flipping `<=` to `<` trips this test. - function testEffectiveTimeBoundaryExactlyAtCompletesSplit() external { - vault.publicUpdate(address(0), ALICE, 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - - // One second before the split's effective time: the split is NOT - // completed and bootstrap is at idx 0 (the default cursor). With - // no completed splits past bootstrap, migration is a no-op — - // cursor stays at 0, balance unchanged. - vm.warp(1499); - vault.publicUpdate(ALICE, ALICE, 0); - assertEq(vault.migrationCursor(ALICE), 0, "no completed split: cursor stays at bootstrap (idx 0)"); - assertEq(vault.balanceOf(ALICE), 100, "balance must not rebase before effective time"); - - // Exactly at the split's effective time: split is now completed. - // Migration fires, cursor advances to the split (idx 1), balance - // rebases. - vm.warp(1500); - vault.publicUpdate(ALICE, ALICE, 0); - assertEq(vault.migrationCursor(ALICE), 1, "cursor must advance at exact effective time"); - assertEq(vault.balanceOf(ALICE), 200, "balance must rebase at exact effective time"); - } - - /// Fuzzed no-split accounting: for any sequence of mints and burns - /// applied before the first split completes (the default state of any - /// token the moment it is deployed), `totalSupply()` equals the plain - /// `Σmints − Σburns` sum. The corporate-actions override must be a - /// straight passthrough of OZ's `_totalSupply` in this regime and - /// introduce zero drift. - function testFuzzNoSplitSupplyEqualsNetMinted(uint64[8] memory mints, uint8[8] memory burnsRaw) external { - address[3] memory actors = [ALICE, BOB, CAROL]; - uint256 netMinted; - - for (uint256 i = 0; i < 8; i++) { - address to = actors[i % 3]; - uint256 mintAmount = uint256(mints[i]) % 1e18; - if (mintAmount > 0) { - vault.publicUpdate(address(0), to, mintAmount); - netMinted += mintAmount; - } - - address from = actors[(i + 1) % 3]; - uint256 available = vault.balanceOf(from); - if (available > 0 && burnsRaw[i] > 0) { - uint256 burnAmount = (uint256(burnsRaw[i]) * available) / 255; - if (burnAmount > 0) { - vault.publicUpdate(from, address(0), burnAmount); - netMinted -= burnAmount; - } - } - - assertEq(vault.totalSupply(), netMinted, "totalSupply drifted from net mint/burn sum without splits"); - assertEq(vault.totalSupplyLatestCursor(), 0, "bootstrap must not have fired: no split has completed"); - } - } - - /// Pre-bootstrap regime: until the first `_update` after a completed - /// split, `fold()` must not bootstrap, `onMint`/`onBurn` must be no-ops, - /// and `totalSupply()` must return OZ's raw `_totalSupply`. A pending - /// split that has not yet reached its effective time must not trigger - /// any of these. - function testPreBootstrapIsNoOpUntilCompletedSplit() external { - // Mint pre-any-schedule. No pot update expected — bootstrap has - // not fired, `nodes.length == 0`, `onMint` is a no-op. - vault.publicUpdate(address(0), BOB, 200); - assertEq(vault.totalSupplyLatestCursor(), 0, "no split tracked pre-schedule"); - assertEq(vault.totalSupply(), 200, "totalSupply matches OZ pre-any-schedule"); - assertEq(vault.unmigrated(0), 0, "pot 0 untouched pre-bootstrap"); - - // Burn pre-any-schedule. Also a no-op on pots. - vault.publicUpdate(BOB, address(0), 50); - assertEq(vault.totalSupply(), 150, "totalSupply reflects burn via OZ"); - assertEq(vault.unmigrated(0), 0, "pot 0 still untouched"); - - // Schedule a split with a future effective time. `ensureBootstrap` - // fires here: pushes the bootstrap node at idx 0 with `effectiveTime - // = block.timestamp` (immediately completed), captures `unmigrated[0] - // = OZ.totalSupply` (= 150 after the prior mint/burn), and writes - // `totalSupplyLatestCursor = NODE_NONE` as the "no fold has run yet" - // sentinel. The user split lands at idx 1 and is pending until warp. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 5000, _splitParams(2)); - // `totalSupplyLatestCursor` only moves inside `fold` (called from - // `_update`), so it stays at the `NODE_NONE` sentinel `ensureBootstrap` - // wrote until the next _update. - assertEq(vault.totalSupplyLatestCursor(), NODE_NONE, "schedule alone does not advance latest cursor"); - assertEq(vault.unmigrated(0), 150, "ensureBootstrap snapshotted OZ total supply into pot 0"); - - // Mint again with the pending split scheduled. The bootstrap node - // is at idx 0 and completed at schedule time, so `fold()` advances - // `totalSupplyLatestCursor` to the bootstrap (idx 0). BOB's cursor - // default is already 0 (= bootstrap), and there are no completed - // splits past it, so `migrateAccount` is a no-op for him. Then - // super._update mints 100 into _balances[BOB], and onMint adds - // 100 to `unmigrated[0]` (the latest pot). - vault.publicUpdate(address(0), BOB, 100); - assertEq(vault.totalSupplyLatestCursor(), 0, "fold advances to the bootstrap on first _update"); - assertEq(vault.totalSupply(), 250, "totalSupply matches OZ while only bootstrap is completed"); - assertEq(vault.unmigrated(0), 250, "bootstrap pot holds the snapshot plus the new mint"); - assertEq(vault.unmigrated(1), 0, "split has not landed; its pot is empty"); - - // balanceOf returns the stored balance (identity migration leaves - // it unchanged at 250). - assertEq(vault.balanceOf(BOB), 250, "balanceOf ignores pending splits"); - } - - /// totalSupply equals the sum of all per-account balanceOf values after a - /// completed split, even when some accounts are still unmigrated. This is - /// the integration-level invariant that A28-1 says must hold and that the - /// pre-fix code violated for fresh-recipient pathways. - function testTotalSupplyMatchesSumOfBalanceOfAfterMixedActivity() external { - // Pre-existing holders. - vault.publicUpdate(address(0), BOB, 100); - vault.publicUpdate(address(0), CAROL, 200); - - // Split. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // Mint to a fresh account after the split. - vault.publicUpdate(address(0), ALICE, 100); - - // Transfer from Bob to a brand new account (DAVE). - address dave = address(0xDAFE); - // Bob currently has effective balance 200; transfer 50 to Dave. - vault.publicUpdate(BOB, dave, 50); - - // Burn 30 from Carol. - vault.publicUpdate(CAROL, address(0), 30); - - // Now: sum(balanceOf) == totalSupply. - uint256 sumBalances = - vault.balanceOf(BOB) + vault.balanceOf(CAROL) + vault.balanceOf(ALICE) + vault.balanceOf(dave); - assertEq(sumBalances, vault.totalSupply(), "totalSupply must equal sum of balanceOf"); - } - - /// Fractional / reverse splits cannot maintain `totalSupply == sum(balanceOf)` - /// exactly while multiple accounts share a pre-split pot: the walk applies - /// the multiplier to the aggregate pot, but per-account `balanceOf` applies - /// it to each account individually, so `trunc(Σ aᵢ * m) ≥ Σ trunc(aᵢ * m)`. - /// The difference is the per-account truncation dust. The gap closes once - /// every account has migrated through the split and `unmigrated[0]` is 0. - function testTotalSupplyFractionalDustConvergesAfterMigration() external { - Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - - vault.publicUpdate(address(0), BOB, 1); - vault.publicUpdate(address(0), CAROL, 1); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(halfX)); - vm.warp(2000); - - // Aggregate pot: trunc((1 + 1) * 0.5) == 1. Per-account: trunc(1 * 0.5) - // + trunc(1 * 0.5) == 0. totalSupply is the upper bound here. - assertEq(vault.totalSupply(), 1, "aggregate pot keeps rounding dust pre-migration"); - assertEq(vault.balanceOf(BOB) + vault.balanceOf(CAROL), 0, "per-account truncates individually"); - - // Migrate both accounts out of the shared pot. - vault.publicUpdate(BOB, BOB, 0); - vault.publicUpdate(CAROL, CAROL, 0); - - assertEq( - vault.totalSupply(), - vault.balanceOf(BOB) + vault.balanceOf(CAROL), - "totalSupply converges to sum(balanceOf) post-migration" - ); - assertEq(vault.totalSupply(), 0, "dust resolves to 0 once both migrate"); - } - - /// In the mint-after-split scenario, `totalSupply()` equals the sum of - /// every holder's `balanceOf` — the share-side integration invariant - /// that justifies the per-cursor pot bookkeeping. - function testTotalSupplyConsistentWithBalanceOfAfterMintFreshPostSplit() external { - vault.publicUpdate(address(0), BOB, 1000); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - vault.publicUpdate(address(0), ALICE, 100); - - assertEq(vault.balanceOf(ALICE), 100); - // Bob has 1000 stored at cursor 0; after rebase his effective balance is 2000. - assertEq(vault.balanceOf(BOB), 2000); - // Total supply: Bob's 2000 + Alice's 100 = 2100. - assertEq(vault.totalSupply(), 2100); - } - - // ----------------------------------------------------------------------- - // Cursor / totalSupplyLatestCursor invariant. - // - // After `migrateAccount(account)` returns inside `_update` (which runs - // after `fold()`), `accountMigrationCursor[account]` equals - // `s.totalSupplyLatestCursor`. `LibTotalSupply.onBurn` subtracts the - // burn amount from `unmigrated[totalSupplyLatestCursor]`. If the - // burner's migrated balance had landed in a different pot (cursor != - // latest), onBurn would subtract from a pot that never received the - // balance, and the subtraction would underflow. - - /// Deterministic pin for the exact path onBurn's safety relies on: - /// schedule a split → mint pre-split → warp past it → schedule another - /// split → warp past it → burn from the pre-split holder. After every - /// migrating `publicUpdate` call, `migrationCursor(bob)` must equal - /// `totalSupplyLatestCursor()`, and the burn must succeed (no panic). - function testCursorEqualsTotalSupplyLatestSplitAcrossBurnPath() external { - // Split 1: 2x at t=1500. Mint Bob before it lands so he starts at - // cursor 0 (default = bootstrap). - vault.publicUpdate(address(0), BOB, 1000); - assertEq(vault.migrationCursor(BOB), 0, "fresh mint lands at cursor 0 (= bootstrap)"); - assertEq(vault.totalSupplyLatestCursor(), 0, "no schedule yet, latestCursor at default 0"); - - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // Touch Bob — migration should land his cursor on 1 (bootstrap at - // idx 0, split at idx 1) AND totalSupplyLatestCursor should also be - // 1 (fold advanced through both). - vault.publicUpdate(BOB, BOB, 0); - assertEq(vault.migrationCursor(BOB), vault.totalSupplyLatestCursor(), "post-migrate: cursor == latest"); - assertEq(vault.migrationCursor(BOB), 1, "cursor advanced through bootstrap and the first split"); - assertEq(vault.balanceOf(BOB), 2000, "Bob rebased to 2000"); - - // Split 2: 3x at t=2500. Schedule and warp. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); - vm.warp(3000); - - // Now burn some from Bob. Inside `_update`, fold() advances - // totalSupplyLatestCursor to 2 (the second split), then - // migrateAccount(BOB) walks Bob's cursor from 1 to 2, rasterizing - // the balance to 6000. Then onBurn(500) subtracts 500 from - // unmigrated[2]. If the cursor invariant held, this succeeds; if - // it didn't, onBurn would underflow. - vault.publicUpdate(BOB, address(0), 500); - assertEq(vault.migrationCursor(BOB), vault.totalSupplyLatestCursor(), "post-burn: cursor == latest"); - assertEq(vault.migrationCursor(BOB), 2, "cursor advanced through second split"); - // 1000 → 2x → 2000 → 3x → 6000 → -500 → 5500. - assertEq(vault.balanceOf(BOB), 5500, "Bob's final balance reflects both splits and the burn"); - // Bob is the only holder, so totalSupply == balanceOf(Bob). - assertEq(vault.totalSupply(), 5500, "totalSupply must equal sum of balances"); - } - - /// Fuzz: run a sequence of mint / transfer / burn operations interleaved - /// with stock splits, and assert after EVERY `publicUpdate` touching an - /// account that `migrationCursor(account) == totalSupplyLatestCursor()`. - /// This is the strongest form of the cursor invariant at the per-PR - /// level — any input shape that violates it fails the assertion - /// immediately, and the underlying `onBurn` subtraction cannot panic - /// under the same preconditions that keep the invariant true. - /// - /// Inputs are bounded to keep the float library inside its conservative - /// operating range and to keep the test fast. The point is breadth of - /// sequences, not exhaustive amount ranges. - function testFuzzCursorEqualsLatestAfterEveryMigration( - uint8 split1Raw, - uint8 split2Raw, - uint128 mintBob, - uint128 mintCarol, - uint128 transferAmtRaw, - uint128 burnAmtRaw - ) external { - // Multipliers in [1, 5]. Zero would violate LibStockSplit's - // positive-coefficient rule; values >5 risk compounding into - // overflow territory across two splits + mints. - int256 m1 = int256(uint256(uint8(split1Raw % 5) + 1)); - int256 m2 = int256(uint256(uint8(split2Raw % 5) + 1)); - - // Keep mints within a safe range so two sequential splits up to 5x - // each don't exceed uint256 when summed across accounts. - uint256 bobMint = uint256(mintBob) % 1e24 + 1; - uint256 carolMint = uint256(mintCarol) % 1e24 + 1; - - // Initial state: mint Bob and Carol pre-split. Both land at cursor 0, - // totalSupplyLatestCursor == 0. Neither mint should have migrated - // state (nothing to migrate). - vault.publicUpdate(address(0), BOB, bobMint); - _assertCursorInvariant(BOB); - vault.publicUpdate(address(0), CAROL, carolMint); - _assertCursorInvariant(CAROL); - - // Split 1 lands. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(m1)); - vm.warp(2000); - - // Transfer from Bob to Carol — migrates both, asserts both have - // cursor == latest afterwards. - uint256 bobEffective = vault.balanceOf(BOB); - uint256 transferAmt = uint256(transferAmtRaw) % (bobEffective + 1); - vault.publicUpdate(BOB, CAROL, transferAmt); - _assertCursorInvariant(BOB); - _assertCursorInvariant(CAROL); - assertEq(vault.migrationCursor(BOB), 1, "Bob cursor advanced through split 1 (idx 1; bootstrap is idx 0)"); - assertEq(vault.migrationCursor(CAROL), 1, "Carol's cursor advanced through split 1"); - - // Split 2 lands. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(m2)); - vm.warp(3000); - - // Burn from Carol — migrates Carol (from cursor 1 through split 2 - // to cursor 2), then onBurn subtracts from the latest pot. Without - // the invariant, this would underflow. With the invariant, it - // succeeds cleanly. - uint256 carolEffective = vault.balanceOf(CAROL); - uint256 burnAmt = uint256(burnAmtRaw) % (carolEffective + 1); - vault.publicUpdate(CAROL, address(0), burnAmt); - _assertCursorInvariant(CAROL); - assertEq(vault.migrationCursor(CAROL), 2, "Carol cursor advanced through split 2 (idx 2)"); - - // Touch Bob to migrate him too. - vault.publicUpdate(BOB, BOB, 0); - _assertCursorInvariant(BOB); - assertEq(vault.migrationCursor(BOB), 2, "Bob's cursor advanced through both splits"); - - // Final sum-of-balances check. Both accounts have migrated through - // the full chain; totalSupply must equal their combined balance. - assertEq( - vault.balanceOf(BOB) + vault.balanceOf(CAROL), - vault.totalSupply(), - "sum(balanceOf) must equal totalSupply after full migration" - ); - } - - /// @dev Assert that the account's migration cursor equals the global - /// `totalSupplyLatestCursor`. This is the invariant `onBurn` depends on. - function _assertCursorInvariant(address account) internal view { - assertEq( - vault.migrationCursor(account), - vault.totalSupplyLatestCursor(), - "migrationCursor must equal totalSupplyLatestCursor after migrateAccount" - ); - } - - /// Structural coupling test between `LibRebase.migratedBalance` and - /// `LibTotalSupply.fold()`. - /// - /// Both functions currently filter the corporate-action linked list with - /// `ACTION_TYPE_STOCK_SPLIT_V1`. That coupling keeps - /// `accountMigrationCursor` and `totalSupplyLatestCursor` in lockstep. - /// - /// INTENT: pin the current behaviour that a completed non-stock-split node - /// advances *neither* cursor. When a future action type (dividends, rights - /// issues, etc.) starts participating in migration, whoever adds that - /// support MUST update both `LibRebase` and `LibTotalSupply` together, or - /// they'll diverge and the pot accounting will break silently. This test - /// fails fast in that scenario: - /// - /// - If `migrateAccount` starts walking the new type without - /// `LibTotalSupply.fold()` doing the same, the first assertion here - /// fails because `migrationCursor` advances past the synthetic node - /// but `totalSupplyLatestCursor` does not. - /// - If `fold()` starts walking the new type without `migrateAccount` - /// doing the same, the inverse failure mode triggers. - /// - /// When that happens, DO NOT just update the assertions — the failure is - /// signalling that the pot model now needs per-action-type accounting. - /// Revisit `LibTotalSupply` with the new action type's rebase semantics - /// before touching this test. - function testNonStockSplitNodeAdvancesNeitherCursor() external { - // Schedule a completed dividend node. `publicSchedule` bypasses - // `resolveActionType`, so the dividend's parameters blob doesn't - // need to match any validator — we only care that the node lives - // in the list with a non-stock-split bitmap. The first schedule - // also creates the bootstrap (init) node at idx 1; the dividend - // lands at idx 2. - vault.publicSchedule(ACTION_TYPE_STABLES_DIVIDEND_V1, 1500, abi.encode(uint256(0))); - - // Give Bob a pre-existing balance so migrateAccount has something - // to rasterize if it ever starts walking the dividend node. - vault.publicUpdate(address(0), BOB, 1000); - - // Warp past the dividend's effective time so it counts as completed. - vm.warp(2000); - - // Touch Bob to drive fold() + migrateAccount. - vault.publicUpdate(BOB, BOB, 0); - - // Bootstrap (idx 0, type INIT) is the default cursor and IS in the - // migration mask, but Bob's already there. Dividend (idx 1) is NOT - // in the migration mask, so the walk yields nothing past bootstrap. - // Net effect: cursor stays at 0, balance unchanged. - assertEq(vault.migrationCursor(BOB), 0, "cursor stays at bootstrap; dividend must not advance it"); - assertEq( - vault.totalSupplyLatestCursor(), - 0, - "fold() lands on bootstrap; dividend must not advance latestCursor past idx 0" - ); - assertEq(vault.balanceOf(BOB), 1000, "Bob's balance must be unaffected (bootstrap is identity)"); - - // Now schedule a real stock split, complete it, and confirm BOTH - // cursors advance together. This half of the test pins the - // positive-case behaviour: stock splits move both, non-splits move - // neither. The split lands at idx 2 (after bootstrap and dividend). - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(2)); - vm.warp(3000); - vault.publicUpdate(BOB, BOB, 0); - - assertEq(vault.migrationCursor(BOB), 2, "cursor must advance to the stock-split node (idx 2)"); - assertEq(vault.totalSupplyLatestCursor(), 2, "latest must advance to the stock-split node (idx 2)"); - assertEq(vault.balanceOf(BOB), 2000, "Bob's balance must reflect only the 2x split, not the dividend"); - } - - /// Pre-bootstrap `effectiveTotalSupply()` walks ALL completed multipliers - /// starting from OZ's raw `_totalSupply`, not just the first one. - /// `testTotalSupplyDuringBootstrapDeferredWindow` covers the single-split - /// case; this covers multi-split to pin that the walk continues past the - /// first completed node without a `fold()` having bootstrapped any pot. - function testTotalSupplyMultiSplitPreBootstrap() external { - vault.publicUpdate(address(0), BOB, 100); - - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 3500, _splitParams(5)); - vm.warp(4000); - - // Critically: no `_update` between the warp and the read, so - // `fold()` hasn't run; `totalSupplyLatestCursor` is still the - // `NODE_NONE` sentinel `ensureBootstrap` set even though it - // populated `unmigrated[0]` from the schedule. The view's walk - // reads `unmigrated[0]` directly. - assertEq(vault.totalSupplyLatestCursor(), NODE_NONE, "no _update yet => latestCursor unchanged"); - assertEq(vault.totalSupply(), 3000, "100 * 2 * 3 * 5 via pot-0 multi-multiplier walk"); - } - - /// `effectiveTotalSupply()` called between split completion and the first - /// post-split `_update` reads `unmigrated[0]` (snapshotted by - /// `ensureBootstrap` at schedule time) and walks every completed - /// multiplier. No state mutation in the view path. - /// - /// INTENT: pin the behaviour of the view during the post-schedule, - /// pre-`fold` window. If the branch shape changes — e.g. if `fold()` is - /// ever moved into the view — this test fails and forces the author to - /// re-evaluate the state-mutation rules for view functions. - function testTotalSupplyDuringBootstrapDeferredWindow() external { - // Mint supply before any split is scheduled. No bootstrap yet. - vault.publicUpdate(address(0), BOB, 100); - assertEq(vault.totalSupplyLatestCursor(), 0, "no split tracked yet (default storage)"); - - // Schedule a 2x split and warp past its effective time. Schedule - // runs `ensureBootstrap`, which snapshots `unmigrated[0] = 100` - // and sets `totalSupplyLatestCursor = NODE_NONE` as the "no fold - // has run yet" sentinel. Critically, do NOT call any `_update` - // after warping — so `fold()` hasn't run. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // The view must still report the rebased supply by walking the - // completed init+split nodes from `unmigrated[0]`. - assertEq(vault.totalSupply(), 200, "view must apply the 2x multiplier without fold"); - - // `totalSupplyLatestCursor` is still the `NODE_NONE` sentinel — - // view reads are side-effect-free. - assertEq(vault.totalSupplyLatestCursor(), NODE_NONE, "view must not advance latest cursor"); - } - - /// Multiple completed splits land between `_update` calls and no - /// account migrates in between. Exercises the view walk across all - /// three multipliers while pot 0 still holds mass — the critical - /// case that catches a dropped-multiplier regression. - /// - /// INTENT: pin the behaviour of `effectiveTotalSupply` walking - /// through intermediate pots. Asserting totalSupply *before* the - /// mass migrates out of pot 0 forces the walk to perform real - /// multiplier applications on non-zero running values. If a future - /// change ever dropped a multiplier step or tried to "skip empty - /// pots" without carrying the multiplication forward, this test - /// would fail at the pre-migration assertion. - /// - /// The post-migration assertion is a second-order check: once all - /// mass is in the final pot, the walk's multiplier applications - /// operate on zero intermediates and are invisible to the result. - /// That assertion only catches pot-accounting bugs, not - /// multiplier-walk bugs. - function testEffectiveTotalSupplyAcrossGapWithZeroIntermediatePots() external { - // Seed: Bob holds 100 pre-split. - vault.publicUpdate(address(0), BOB, 100); - - // Three splits complete before anybody touches the vault. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 3500, _splitParams(5)); - vm.warp(4000); - - // Force `fold()` to advance `totalSupplyLatestCursor` through - // bootstrap + every completed split without moving Bob's mass out - // of pot 0. CAROL is a fresh zero-balance account — her - // "migration" advances only her cursor, not any pot value. After - // this, pot 0 still holds 100. Bootstrap is at idx 0, splits at - // idx 1/2/3, so latest cursor lands on 3. - vault.publicUpdate(address(0), CAROL, 0); - assertEq(vault.totalSupplyLatestCursor(), 3, "fold must advance to the latest split (idx 3)"); - - // CRITICAL ASSERTION: pot 0 holds 100, pots 1..3 are empty. The - // view must walk `100 -> identity (init) -> trunc(100*2) -> trunc(200*3) - // -> trunc(600*5) = 3000`. A dropped multiplier in the walk - // collapses to 100. - assertEq(vault.totalSupply(), 3000, "view must apply every multiplier while pot 0 holds mass"); - - // Now migrate Bob. His mass leaves pot 0 and lands in pot 3 (the - // latest split). - vault.publicUpdate(BOB, BOB, 0); - assertEq(vault.rawStoredBalance(BOB), 3000, "bob stored = 100 * 2 * 3 * 5"); - assertEq(vault.migrationCursor(BOB), 3, "bob cursor at latest split (idx 3)"); - - // Post-migration: same total, but now all mass lives in pot 4. - // Note: the walk here trivially produces 3000 because the - // multiplier applications all operate on zero intermediates. - // This assertion catches pot-accounting bugs but not walk bugs - // — the pre-migration assertion above is the walk guarantee. - assertEq(vault.totalSupply(), 3000, "totalSupply stable across migration"); - } - - /// Burn-to-zero after a completed split: an account with a non-zero - /// pre-split balance migrates through the split and then burns its - /// entire post-migration balance. Checks all four pieces of state - /// simultaneously to pin the migrate+burn interaction in one test. - /// - /// INTENT: a future regression that, for example, decoupled - /// `onBurn` from `migrateAccount`'s ordering would leave one of - /// the pots or the stored balance inconsistent with the others. - /// Asserting all four quantities at once makes such a regression - /// visible in a single test failure rather than having to correlate - /// individual assertions across separate tests. - function testBurnToZeroPostSplitInvariants() external { - // Alice holds 50 pre-split. - vault.publicUpdate(address(0), ALICE, 50); - - // 2x split completes. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // Alice's view is 100 (migrated). Burn all of it. - assertEq(vault.balanceOf(ALICE), 100, "alice sees 2x post-split"); - vault.publicUpdate(ALICE, address(0), 100); - - // All four state pieces after burn-to-zero: - // (1) stored balance is 0 - // (2) migration cursor advanced to the split (idx 1; bootstrap is idx 0) - // (3) unmigrated[0] drained of alice's pre-split balance - // (4) unmigrated[1] = migrated balance - burn = 100 - 100 = 0 - // - // `effectiveTotalSupply` walks: running = 0 from bootstrap pot - // (drained); trunc(0 * 2) + unmigrated[1] = 0 + 0 = 0. - assertEq(vault.rawStoredBalance(ALICE), 0, "alice stored = 0"); - assertEq(vault.migrationCursor(ALICE), 1, "alice cursor at split (idx 1)"); - assertEq(vault.totalSupply(), 0, "totalSupply collapses to 0 after full burn"); - } - - /// Over-burn by a lone holder at `totalSupplyLatestCursor` must surface - /// OZ's `ERC20InsufficientBalance` error, not a raw arithmetic panic - /// from the pot subtraction. - function testOverBurnSurfacesOzInsufficientBalanceNotPanic() external { - vault.publicUpdate(address(0), ALICE, 50); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, ALICE, 100, 101)); - vault.publicUpdate(ALICE, address(0), 101); - } - - /// @dev Asserts `LibTotalSupply`'s pot invariant I(k) directly at a - /// given cursor: the pot value equals the sum of stored balances for - /// every account at that cursor, and no other accounts are at that - /// cursor. - function _assertPotInvariant(uint256 cursor, address[] memory accountsAtCursor) internal view { - uint256 sum; - for (uint256 i = 0; i < accountsAtCursor.length; i++) { - assertEq(vault.migrationCursor(accountsAtCursor[i]), cursor, "I(k): account must be at the expected cursor"); - sum += vault.rawStoredBalance(accountsAtCursor[i]); - } - assertEq(vault.unmigrated(cursor), sum, "I(k): pot must equal sum of stored balances at cursor"); - } - - /// @dev Build a one-element address array inline. - function _single(address a) internal pure returns (address[] memory) { - address[] memory arr = new address[](1); - arr[0] = a; - return arr; - } - - /// @dev Build a two-element address array inline. - function _pair(address a, address b) internal pure returns (address[] memory) { - address[] memory arr = new address[](2); - arr[0] = a; - arr[1] = b; - return arr; - } - - /// @dev Build a three-element address array inline. - function _triple(address a, address b, address c) internal pure returns (address[] memory) { - address[] memory arr = new address[](3); - arr[0] = a; - arr[1] = b; - arr[2] = c; - return arr; - } - - /// Direct assertion of the LibTotalSupply pot invariant I(k) across a - /// mixed activity sequence: two pre-split mints, a split, a post-split - /// mint, a transfer, a burn. After each `_update` (post-bootstrap) the - /// invariant must hold for every cursor that has touched accounts. - function testPotInvariantDirectAfterMixedActivity() external { - address[] memory empty = new address[](0); - - // Pre-split mints. Alice and Bob at cursor 0. Bootstrap hasn't - // fired yet, so the pot invariant does not apply here. - vault.publicUpdate(address(0), ALICE, 50); - vault.publicUpdate(address(0), BOB, 100); - - // Schedule and complete a 2x split. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - // Alice touches: `ensureBootstrap` already snapshotted - // `unmigrated[0] = 150` at schedule time; the user split is at - // idx 1 (bootstrap is at idx 0). Alice migrates 50 → 100, shifting - // 50 out of pot 0 and 100 into pot 1. - vault.publicUpdate(ALICE, ALICE, 0); - _assertPotInvariant(0, _single(BOB)); - _assertPotInvariant(1, _single(ALICE)); - - // Mint Carol 40 post-split. Fresh recipient cursor advances 0 → 1 - // with zero balance (no pot delta from migrate). Then super._update - // adds 40 to _balances[CAROL], then onMint adds 40 to pot 1. - vault.publicUpdate(address(0), CAROL, 40); - _assertPotInvariant(0, _single(BOB)); - _assertPotInvariant(1, _pair(ALICE, CAROL)); - - // Alice transfers 30 to Bob. Both migrate first: Alice already at - // cursor 1 (no-op), Bob 0 → 1 with stored 100 → migrated 200. Pot - // transitions: pot 0 -= 100 (Bob leaves), pot 1 += 200 (Bob arrives). - // Then transfer moves 30 between their balances, both at cursor 1 - // — Σ at cursor 1 unchanged, no pot write. - vault.publicUpdate(ALICE, BOB, 30); - _assertPotInvariant(0, empty); - _assertPotInvariant(1, _triple(ALICE, BOB, CAROL)); - - // Burn 25 from Bob. super._update first: _balances[BOB] -= 25, - // _totalSupply -= 25. Then onBurn subtracts 25 from pot 1. - vault.publicUpdate(BOB, address(0), 25); - _assertPotInvariant(0, empty); - _assertPotInvariant(1, _triple(ALICE, BOB, CAROL)); - } - - /// Fuzzed direct assertion of I(k): drive a fixed sequence of - /// parameter-randomised operations across Alice, Bob, Carol and - /// two stock splits, asserting the pot invariant for every - /// touched cursor after each state transition. - function testFuzzPotInvariantAcrossRandomActivity( - uint8 m1Raw, - uint8 m2Raw, - uint64 aliceMintRaw, - uint64 bobMintRaw, - uint64 carolMintRaw, - uint64 transferRaw, - uint64 burnRaw - ) external { - int256 m1 = int256(uint256(m1Raw % 4) + 1); - int256 m2 = int256(uint256(m2Raw % 4) + 1); - uint256 aliceMint = uint256(aliceMintRaw % 1e18) + 1; - uint256 bobMint = uint256(bobMintRaw % 1e18) + 1; - uint256 carolMint = uint256(carolMintRaw % 1e18); - - address[] memory empty = new address[](0); - - // Pre-split mints (bootstrap has not fired; invariant doesn't apply). - vault.publicUpdate(address(0), ALICE, aliceMint); - vault.publicUpdate(address(0), BOB, bobMint); - - // Split 1 lands at idx 1 (bootstrap is at idx 0); Alice migrates. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(m1)); - vm.warp(2000); - vault.publicUpdate(ALICE, ALICE, 0); - _assertPotInvariant(0, _single(BOB)); - _assertPotInvariant(1, _single(ALICE)); - - // Mint Carol post-split-1. - vault.publicUpdate(address(0), CAROL, carolMint); - _assertPotInvariant(0, _single(BOB)); - _assertPotInvariant(1, _pair(ALICE, CAROL)); - - // Split 2 lands at idx 2; Alice migrates again. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(m2)); - vm.warp(3000); - vault.publicUpdate(ALICE, ALICE, 0); - _assertPotInvariant(0, _single(BOB)); - _assertPotInvariant(1, _single(CAROL)); - _assertPotInvariant(2, _single(ALICE)); - - // Alice -> Bob transfer. Both migrate: Alice at 2 (no-op), Bob 0 -> 2. - uint256 aliceView = vault.balanceOf(ALICE); - uint256 transferAmt = aliceView == 0 ? 0 : uint256(transferRaw) % (aliceView + 1); - vault.publicUpdate(ALICE, BOB, transferAmt); - _assertPotInvariant(0, empty); - _assertPotInvariant(1, _single(CAROL)); - _assertPotInvariant(2, _pair(ALICE, BOB)); - - // Burn from Bob. - uint256 bobView = vault.balanceOf(BOB); - uint256 burnAmt = bobView == 0 ? 0 : uint256(burnRaw) % (bobView + 1); - vault.publicUpdate(BOB, address(0), burnAmt); - _assertPotInvariant(0, empty); - _assertPotInvariant(1, _single(CAROL)); - _assertPotInvariant(2, _pair(ALICE, BOB)); - } - - /// Fuzzed convergence invariant: for any initial balance and any sequence - /// of stock splits, the view `balanceOf` (pre-migration) must equal the - /// rasterized stored balance after `_update`-driven migration. This - /// guards the core property that external reads and post-rasterization - /// stored state agree — if they diverge, transfers could use different - /// balances than what the view reports. - function testFuzzConvergenceViewMatchesStoredAfterMigration(uint64 initialBalance, uint8 numSplits, uint8 seed) - external - { - initialBalance = uint64(bound(initialBalance, 1, type(uint32).max)); - numSplits = uint8(bound(numSplits, 0, 8)); - - vault.publicUpdate(address(0), BOB, uint256(initialBalance)); - - // Schedule alternating forward and reverse splits. - for (uint256 i = 0; i < numSplits; i++) { - // Alternate 2x and 1/2x based on i bit 0; use seed to vary starting - // direction across fuzz runs. - bool forward = ((i ^ seed) & 1) == 0; - Float multiplier = forward - ? LibDecimalFloat.packLossless(2, 0) - : LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - bytes memory params = LibStockSplit.encodeParametersV1(multiplier); - // forge-lint: disable-next-line(unsafe-typecast) - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, uint64(1001 + i * 100), params); - } - - if (numSplits > 0) { - // forge-lint: disable-next-line(unsafe-typecast) - vm.warp(uint64(1001 + uint256(numSplits) * 100 + 1)); - } - - // Snapshot view balance BEFORE migration. - uint256 viewBefore = vault.balanceOf(BOB); - - // Force migration via a self-touch. - vault.publicUpdate(BOB, BOB, 0); - - // View AFTER migration must still match (idempotence). - uint256 viewAfter = vault.balanceOf(BOB); - uint256 stored = vault.rawStoredBalance(BOB); - - assertEq(viewBefore, viewAfter, "view balance must not change across migration"); - assertEq(viewAfter, stored, "view and stored balance must converge"); - } - - /// After every holder has migrated through every completed split, the - /// aggregate pot overestimate from fractional-multiplier truncation - /// fully resolves: `totalSupply() == sum(balanceOf over all holders)` - /// exactly. Before full migration the equality holds as an upper bound - /// (tested elsewhere); this test pins the exact-equality convergence. - function testFuzzMultiAccountConvergenceAfterFullMigration( - uint32 aliceInit, - uint32 bobInit, - uint32 carolInit, - uint8 numSplits, - uint8 seed - ) external { - aliceInit = uint32(bound(aliceInit, 1, type(uint32).max / 256)); - bobInit = uint32(bound(bobInit, 1, type(uint32).max / 256)); - carolInit = uint32(bound(carolInit, 0, type(uint32).max / 256)); - numSplits = uint8(bound(numSplits, 0, 6)); - - vault.publicUpdate(address(0), ALICE, uint256(aliceInit)); - vault.publicUpdate(address(0), BOB, uint256(bobInit)); - if (carolInit > 0) vault.publicUpdate(address(0), CAROL, uint256(carolInit)); - - for (uint256 i = 0; i < numSplits; i++) { - bool forward = ((i ^ seed) & 1) == 0; - Float multiplier = forward - ? LibDecimalFloat.packLossless(2, 0) - : LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - // numSplits is bounded so 1001 + i * 100 fits easily in uint64. - // forge-lint: disable-next-line(unsafe-typecast) - uint64 effTime = uint64(1001 + i * 100); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effTime, LibStockSplit.encodeParametersV1(multiplier)); - } - - if (numSplits > 0) { - // forge-lint: disable-next-line(unsafe-typecast) - vm.warp(uint64(1001 + uint256(numSplits) * 100 + 1)); - } - - // Migrate every holder through every completed split via self-touches. - vault.publicUpdate(ALICE, ALICE, 0); - vault.publicUpdate(BOB, BOB, 0); - if (carolInit > 0) vault.publicUpdate(CAROL, CAROL, 0); - - uint256 sum = vault.balanceOf(ALICE) + vault.balanceOf(BOB) + vault.balanceOf(CAROL); - assertEq(vault.totalSupply(), sum, "post-full-migration: totalSupply must equal sum(balanceOf) exactly"); - } - - /// Fuzzed convergence across two accounts migrated at different points: - /// Alice migrates after split 1, Bob migrates after splits 1 and 2. Their - /// balances computed from different migration paths must still match - /// their view values and their stored values after full migration. - function testFuzzTwoAccountDifferentMigrationPathsConverge(uint32 aliceInit, uint32 bobInit) external { - aliceInit = uint32(bound(aliceInit, 1, type(uint32).max)); - bobInit = uint32(bound(bobInit, 1, type(uint32).max)); - - vault.publicUpdate(address(0), ALICE, uint256(aliceInit)); - vault.publicUpdate(address(0), BOB, uint256(bobInit)); - - // Split 1: 2x at t=1500. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(1600); - - // Alice touches after split 1 (migrates partially). - vault.publicUpdate(ALICE, ALICE, 0); - - // Split 2: 1/2x at t=2000. - Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2000, LibStockSplit.encodeParametersV1(halfX)); - vm.warp(2100); - - // Both view balances before final migration. - uint256 aliceView = vault.balanceOf(ALICE); - uint256 bobView = vault.balanceOf(BOB); - - // Both touched — full migration. - vault.publicUpdate(ALICE, ALICE, 0); - vault.publicUpdate(BOB, BOB, 0); - - // Convergence: view matches stored for both accounts. - assertEq(aliceView, vault.rawStoredBalance(ALICE), "alice view matches stored"); - assertEq(bobView, vault.rawStoredBalance(BOB), "bob view matches stored"); - // And the post-migration view equals the stored (idempotence). - assertEq(vault.balanceOf(ALICE), vault.rawStoredBalance(ALICE)); - assertEq(vault.balanceOf(BOB), vault.rawStoredBalance(BOB)); - } - - /// Fuzzed idempotency: after an initial migration through an arbitrary - /// split chain, repeated touches with no new splits must not change - /// cursor, stored balance, or view balance, and must not re-emit - /// `AccountMigrated`. Guards the `newCursor == currentCursor` early - /// return in `migrateAccount`. - function testFuzzMigrationIdempotentAcrossRepeatedTouches( - uint32 initialBalance, - uint8 numSplits, - uint8 seed, - uint8 touchCount - ) external { - initialBalance = uint32(bound(initialBalance, 1, type(uint32).max)); - numSplits = uint8(bound(numSplits, 0, 8)); - touchCount = uint8(bound(touchCount, 1, 10)); - - vault.publicUpdate(address(0), BOB, uint256(initialBalance)); - - for (uint256 i = 0; i < numSplits; i++) { - bool forward = ((i ^ seed) & 1) == 0; - Float multiplier = forward - ? LibDecimalFloat.packLossless(2, 0) - : LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - bytes memory params = LibStockSplit.encodeParametersV1(multiplier); - // forge-lint: disable-next-line(unsafe-typecast) - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, uint64(1001 + i * 100), params); - } - - if (numSplits > 0) { - // forge-lint: disable-next-line(unsafe-typecast) - vm.warp(uint64(1001 + uint256(numSplits) * 100 + 1)); - } - - // First touch triggers the migration. - vault.publicUpdate(BOB, BOB, 0); - - uint256 cursorAfterFirst = vault.migrationCursor(BOB); - uint256 storedAfterFirst = vault.rawStoredBalance(BOB); - uint256 viewAfterFirst = vault.balanceOf(BOB); - - // Repeated touches must be idempotent AND must not re-emit. - bytes32 migratedSig = StoxReceiptVault.AccountMigrated.selector; - for (uint256 i = 0; i < touchCount; i++) { - vm.recordLogs(); - vault.publicUpdate(BOB, BOB, 0); - Vm.Log[] memory logs = vm.getRecordedLogs(); - for (uint256 j = 0; j < logs.length; j++) { - if (logs[j].topics.length > 0 && logs[j].topics[0] == migratedSig) { - fail(); - } - } - assertEq(vault.migrationCursor(BOB), cursorAfterFirst, "cursor unchanged across idempotent touches"); - assertEq(vault.rawStoredBalance(BOB), storedAfterFirst, "stored unchanged across idempotent touches"); - assertEq(vault.balanceOf(BOB), viewAfterFirst, "view unchanged across idempotent touches"); - } - } - - /// Transfers between two distinct accounts, interleaved with multiple - /// forward and reverse splits in a single flow. At each transfer, both - /// `from` and `to` must migrate before the raw balance change lands, - /// otherwise the transfer arithmetic operates on pre-rebase balances and - /// inflates / deflates incorrectly. Numbers below are exact. - function testInterleavedTransfersAndSplits() external { - // Initial: Alice 100, Bob 50 (pre-split basis). - vault.publicUpdate(address(0), ALICE, 100); - vault.publicUpdate(address(0), BOB, 50); - - // Split 1: 2x at t=1500. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(1600); - - // Transfer Alice → Bob, 30. Both migrate first: - // Alice: 100 → 200, then -30 = 170 - // Bob: 50 → 100, then +30 = 130 - vault.publicUpdate(ALICE, BOB, 30); - assertEq(vault.balanceOf(ALICE), 170, "alice after t1"); - assertEq(vault.balanceOf(BOB), 130, "bob after t1"); - - // Split 2: 1/2x at t=2000. - Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2000, LibStockSplit.encodeParametersV1(halfX)); - vm.warp(2100); - - // Transfer Bob → Alice, 40. Both migrate first: - // Bob: 130 → 65, then -40 = 25 - // Alice: 170 → 85, then +40 = 125 - vault.publicUpdate(BOB, ALICE, 40); - assertEq(vault.balanceOf(ALICE), 125, "alice after t2"); - assertEq(vault.balanceOf(BOB), 25, "bob after t2"); - - // Split 3: 3x at t=2500. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); - vm.warp(2600); - - // Transfer Alice → Bob, 60. Both migrate first: - // Alice: 125 → 375, then -60 = 315 - // Bob: 25 → 75, then +60 = 135 - vault.publicUpdate(ALICE, BOB, 60); - assertEq(vault.balanceOf(ALICE), 315, "alice after t3"); - assertEq(vault.balanceOf(BOB), 135, "bob after t3"); - - // Stored balances equal view balances (post-migration invariant). - assertEq(vault.rawStoredBalance(ALICE), 315, "alice stored = view"); - assertEq(vault.rawStoredBalance(BOB), 135, "bob stored = view"); - } - - /// Fuzzed interleaved transfers + splits. After each transfer, stored - /// balances must equal view balances for both parties (migration is - /// eager on both `from` and `to`), and the pairwise conservation - /// invariant must hold: the net balance delta equals the transfer - /// amount minus any truncation from the rebase that landed between - /// transfers. - function testFuzzInterleavedTransfersAndSplits( - uint32 aliceInit, - uint32 bobInit, - uint64 amount1, - uint64 amount2, - uint64 amount3 - ) external { - aliceInit = uint32(bound(aliceInit, 1000, type(uint32).max)); - bobInit = uint32(bound(bobInit, 1000, type(uint32).max)); - - vault.publicUpdate(address(0), ALICE, uint256(aliceInit)); - vault.publicUpdate(address(0), BOB, uint256(bobInit)); - - // Split 1: 2x. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(1600); - - // Transfer Alice → Bob. `balanceOf` already returns the post-rebase - // value, which is exactly what migration will write for Alice's - // stored balance inside `_update`. Bound the amount by that. - amount1 = uint64(bound(amount1, 0, vault.balanceOf(ALICE))); - vault.publicUpdate(ALICE, BOB, uint256(amount1)); - assertEq(vault.balanceOf(ALICE), vault.rawStoredBalance(ALICE), "alice view=stored after t1"); - assertEq(vault.balanceOf(BOB), vault.rawStoredBalance(BOB), "bob view=stored after t1"); - - // Split 2: 1/2x. - Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2000, LibStockSplit.encodeParametersV1(halfX)); - vm.warp(2100); - - // Transfer Bob → Alice. - amount2 = uint64(bound(amount2, 0, vault.balanceOf(BOB))); - vault.publicUpdate(BOB, ALICE, uint256(amount2)); - assertEq(vault.balanceOf(ALICE), vault.rawStoredBalance(ALICE), "alice view=stored after t2"); - assertEq(vault.balanceOf(BOB), vault.rawStoredBalance(BOB), "bob view=stored after t2"); - - // Split 3: 3x. - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); - vm.warp(2600); - - // Transfer Alice → Bob. - amount3 = uint64(bound(amount3, 0, vault.balanceOf(ALICE))); - vault.publicUpdate(ALICE, BOB, uint256(amount3)); - assertEq(vault.balanceOf(ALICE), vault.rawStoredBalance(ALICE), "alice view=stored after t3"); - assertEq(vault.balanceOf(BOB), vault.rawStoredBalance(BOB), "bob view=stored after t3"); - - // Final convergence: repeated idempotent touches don't change anything. - uint256 aliceFinal = vault.balanceOf(ALICE); - uint256 bobFinal = vault.balanceOf(BOB); - vault.publicUpdate(ALICE, ALICE, 0); - vault.publicUpdate(BOB, BOB, 0); - assertEq(vault.balanceOf(ALICE), aliceFinal, "alice idempotent after final"); - assertEq(vault.balanceOf(BOB), bobFinal, "bob idempotent after final"); - } - - uint256 internal constant OP_MINT = 0; - uint256 internal constant OP_BURN = 1; - uint256 internal constant OP_TRANSFER_OUT = 2; - uint256 internal constant OP_SELF_TRANSFER = 3; - uint256 internal constant OP_COUNT = 4; - - /// Three deterministic regression tests below pin the invariants that - /// `StoxReceiptVault.migrateAccount`'s `if (account == address(0)) return;` - /// short-circuit preserves. The skip is sound only because OZ - /// `ERC20Upgradeable` routes mints/burns through `_totalSupply`, never - /// through `_balances[address(0)]` — if a future refactor (or a new facet) - /// writes to that slot, advances the zero-address cursor, or emits a - /// migration event for it, the corresponding test below fires. - /// - /// Each invariant lives in its own test so a mutation maps 1:1 to a - /// failing test — combining them would mean a single failure could mask - /// which property was actually broken. - /// - /// All three drive the same fixed mint/burn/transfer/split sequence so - /// the path under mutation is identical across the three. - - function testZeroAddressBalanceSlotStaysZero() external { - _driveZeroAddressSequence(); - assertEq(vault.rawStoredBalance(address(0)), 0, "address(0) slot non-zero"); - } - - function testZeroAddressCursorStaysZero() external { - _driveZeroAddressSequence(); - assertEq(vault.migrationCursor(address(0)), 0, "address(0) cursor advanced"); - } - - function testNoAccountMigratedEventForZeroAddress() external { - vm.recordLogs(); - _driveZeroAddressSequence(); - - Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 sig = keccak256("AccountMigrated(address,uint256,uint256,uint256,uint256)"); - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics.length > 1 && logs[i].topics[0] == sig) { - address account = address(uint160(uint256(logs[i].topics[1]))); - assertTrue(account != address(0), "AccountMigrated for address(0)"); - } - } - } - - /// Fixed mint/burn/transfer/split sequence shared by the three - /// deterministic invariant tests above. Touches every `_update` path that - /// could plausibly interact with the zero address: mint, burn, - /// post-bootstrap mint, post-bootstrap burn, post-second-split transfer, - /// final burn. - function _driveZeroAddressSequence() internal { - vault.publicUpdate(address(0), ALICE, 1000); - vault.publicUpdate(ALICE, address(0), 300); - - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); - vm.warp(2000); - - vault.publicUpdate(address(0), BOB, 500); - vault.publicUpdate(BOB, address(0), 200); - - vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); - vm.warp(3000); - - vault.publicUpdate(ALICE, BOB, 100); - vault.publicUpdate(BOB, address(0), 50); - } - - /// Fuzz coverage: random mint / burn / transfer / self-transfer ops - /// preserve all three zero-address invariants. Wider than the - /// deterministic tests but path-dependent — not the mutation-test target. - function testFuzzZeroAddressInvariantsHold(uint8 actionCount, uint256 seed) external { - actionCount = uint8(bound(actionCount, 1, 32)); - vm.recordLogs(); - - // Pre-seed Alice and Bob with enough headroom that random burns and - // transfers don't trivially revert. `ERC20InsufficientBalance` reverts - // are caught and treated as no-ops — the invariants are about - // address(0)'s slot, cursor, and event surface, all of which a revert - // leaves untouched. - vault.publicUpdate(address(0), ALICE, 1_000_000); - vault.publicUpdate(address(0), BOB, 1_000_000); - - for (uint256 i = 0; i < actionCount; i++) { - seed = uint256(keccak256(abi.encode(seed, i))); - uint256 op = seed % OP_COUNT; - uint256 amount = bound(seed >> 8, 1, 10_000); - address actor = (seed >> 16) & 1 == 0 ? ALICE : BOB; - address other = actor == ALICE ? BOB : ALICE; - - try this.driveUpdate(op, actor, other, amount) {} catch {} - - assertEq(vault.rawStoredBalance(address(0)), 0, "address(0) slot non-zero"); - assertEq(vault.migrationCursor(address(0)), 0, "address(0) cursor advanced"); - } - - Vm.Log[] memory logs = vm.getRecordedLogs(); - bytes32 sig = keccak256("AccountMigrated(address,uint256,uint256,uint256,uint256)"); - for (uint256 i = 0; i < logs.length; i++) { - if (logs[i].topics.length > 1 && logs[i].topics[0] == sig) { - address account = address(uint160(uint256(logs[i].topics[1]))); - assertTrue(account != address(0), "AccountMigrated for address(0)"); - } - } - } - - /// External wrapper so the fuzz loop can swallow `try`/`catch` reverts - /// (e.g. `ERC20InsufficientBalance` when a random burn exceeds balance). - function driveUpdate(uint256 op, address actor, address other, uint256 amount) external { - if (op == OP_MINT) { - vault.publicUpdate(address(0), actor, amount); - } else if (op == OP_BURN) { - vault.publicUpdate(actor, address(0), amount); - } else if (op == OP_TRANSFER_OUT) { - vault.publicUpdate(actor, other, amount); - } else if (op == OP_SELF_TRANSFER) { - vault.publicUpdate(actor, actor, amount); - } - } -} diff --git a/test/src/concrete/StoxReceiptVaultFallbackRouting.t.sol b/test/src/concrete/StoxReceiptVaultFallbackRouting.t.sol index 861348c1..b9d5ad13 100644 --- a/test/src/concrete/StoxReceiptVaultFallbackRouting.t.sol +++ b/test/src/concrete/StoxReceiptVaultFallbackRouting.t.sol @@ -16,6 +16,7 @@ import {CompletionFilter, NODE_NONE} from "../../../src/lib/LibCorporateActionNo import {IAuthorizeV1, Unauthorized} from "rain-vats-0.1.6/src/interface/IAuthorizeV1.sol"; import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; import {LibTestTofu} from "../../lib/LibTestTofu.sol"; +import {PermissiveAuthorizer} from "./PermissiveAuthorizer.sol"; /// @dev Slot 0 of the OffchainAssetReceiptVault ERC-7201 namespace /// ("rain.storage.offchain-asset-receipt-vault.1") holds the authorizer @@ -24,31 +25,6 @@ import {LibTestTofu} from "../../lib/LibTestTofu.sol"; bytes32 constant OFFCHAIN_ASSET_RECEIPT_VAULT_STORAGE_LOCATION = 0xba9f160a0257aef2aa878e698d5363429ea67cc3c427f23f7cb9c3069b67bd00; -/// @dev Permissive authorizer used by the fallback routing tests. Records the -/// most recent call and allows every permission by default so we can exercise -/// the forward-to-facet path without reproducing the full ethgild auth setup. -contract PermissiveAuthorizer is IAuthorizeV1 { - address public lastUser; - bytes32 public lastPermission; - bytes public lastData; - uint256 public callCount; - bool public denyMode; - - function setDenyMode(bool deny) external { - denyMode = deny; - } - - function authorize(address user, bytes32 permission, bytes memory data) external override { - callCount++; - lastUser = user; - lastPermission = permission; - lastData = data; - if (denyMode) { - revert Unauthorized(user, permission, data); - } - } -} - /// Fallback routing tests for `StoxReceiptVault`. The vault's `fallback()` /// override delegatecalls into `StoxCorporateActionsFacet` at the deterministic /// `LibProdDeployV3.STOX_CORPORATE_ACTIONS_FACET` address. diff --git a/test/src/concrete/StoxReceiptVaultMigrationIntegrationTest.t.sol b/test/src/concrete/StoxReceiptVaultMigrationIntegrationTest.t.sol new file mode 100644 index 00000000..cf0d6eea --- /dev/null +++ b/test/src/concrete/StoxReceiptVaultMigrationIntegrationTest.t.sol @@ -0,0 +1,1668 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; +import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import {StoxReceiptVault} from "../../../src/concrete/StoxReceiptVault.sol"; +import {ERC20Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC20/ERC20Upgradeable.sol"; +import {IERC20Errors} from "@openzeppelin-contracts-5.6.1/interfaces/draft-IERC6093.sol"; +import {NODE_NONE} from "../../../src/lib/LibCorporateActionNode.sol"; +import { + ACTION_TYPE_STOCK_SPLIT_V1, + ACTION_TYPE_STABLES_DIVIDEND_V1 +} from "../../../src/interface/ICorporateActionsV1.sol"; +import {LibStockSplit} from "../../../src/lib/LibStockSplit.sol"; +import {TestStoxReceiptVault} from "./TestStoxReceiptVault.sol"; + +/// Integration tests for the corporate-actions rebase hooks. +/// +/// These tests are the regression guards for the CRITICAL inflation bug +/// where mint or transfer to a fresh recipient after a completed split +/// would over-multiply +/// the recipient's balance, minting tokens out of thin air. +contract StoxReceiptVaultMigrationIntegrationTest is Test { + TestStoxReceiptVault internal vault; + + address internal constant ALICE = address(0xA11CE); + address internal constant BOB = address(0xB0B); + address internal constant CAROL = address(0xCA401); + + function setUp() public { + vault = new TestStoxReceiptVault(); + vm.warp(1000); + } + + function _splitParams(int256 multiplier) internal pure returns (bytes memory) { + return LibStockSplit.encodeParametersV1(LibDecimalFloat.packLossless(multiplier, 0)); + } + + function _fractionalParams(int256 num, int256 denom) internal pure returns (bytes memory) { + Float result = LibDecimalFloat.div(LibDecimalFloat.packLossless(num, 0), LibDecimalFloat.packLossless(denom, 0)); + return LibStockSplit.encodeParametersV1(result); + } + + /// Mint to a fresh account after a completed 2x split credits exactly + /// the minted amount, not 2x the minted amount. Without the + /// zero-balance cursor-advancement guard, the recipient's freshly- + /// written post-rebase balance would be re-multiplied on the next + /// `balanceOf` read — an inflation bug. + function testMintToFreshAccountAfterCompletedSplitDoesNotInflate() external { + // Pre-existing supply so the split has something to rebase. + vault.publicUpdate(address(0), BOB, 1000); + + // Schedule and complete a 2x stock split. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // Mint 100 to Alice — a brand new account. + vault.publicUpdate(address(0), ALICE, 100); + + // Alice should have exactly 100, not 200. + assertEq(vault.balanceOf(ALICE), 100, "fresh recipient must not over-multiply on mint"); + } + + /// Transfer to a fresh recipient after a completed split credits + /// exactly the transferred amount, not multiplied by the split. + function testTransferToFreshRecipientAfterCompletedSplitDoesNotInflate() external { + // Bob has a pre-existing balance. + vault.publicUpdate(address(0), BOB, 50); + + // Schedule and complete a 2x stock split. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // After the split, Bob's balance should be 100. + assertEq(vault.balanceOf(BOB), 100, "Bob's balance should rebase to 100"); + + // Bob transfers 100 to Alice (a brand new account). + vault.publicUpdate(BOB, ALICE, 100); + + // Alice received 100, not 200. + assertEq(vault.balanceOf(ALICE), 100, "fresh recipient must not over-multiply on transfer"); + // Bob is now empty. + assertEq(vault.balanceOf(BOB), 0, "Bob should be empty after sending all"); + } + + /// Mint to a fresh account before any splits — sanity check. + function testMintBeforeAnySplit() external { + vault.publicUpdate(address(0), ALICE, 100); + assertEq(vault.balanceOf(ALICE), 100); + } + + /// Pre-existing holder's balance correctly reflects a completed split. + function testBalanceOfRebaseOnExistingHolder() external { + vault.publicUpdate(address(0), BOB, 50); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + assertEq(vault.balanceOf(BOB), 100); + } + + /// After A03-1's fix, a fresh account that gets touched by a zero-amount + /// transfer (or any interaction) should have its cursor advanced to the + /// latest completed split. + function testFreshAccountCursorAdvancesAfterMigration() external { + vault.publicUpdate(address(0), BOB, 1000); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // Touch Alice via a 0-amount mint. (The publicUpdate path via mint=0 + // doesn't trigger OZ's mint-amount check at this layer.) + vault.publicUpdate(address(0), ALICE, 0); + + // Alice's cursor should now be 1 — bootstrap is at idx 0 (default + // cursor) and the completed split lands at idx 1. + assertEq(vault.migrationCursor(ALICE), 1, "fresh account cursor must advance"); + } + + /// Pre-schedule fresh-account cursor pin: an address that has never + /// interacted with the vault returns `migrationCursor == 0` from the + /// `accountMigrationCursor` mapping. The 0-based scheme leans on this + /// default — 0 is the bootstrap node, so "no migration applied" and + /// "migrated through identity bootstrap" are the same state. A + /// regression that changed the namespace base, mapping shape, or + /// initialised cursors to anything other than 0 surfaces here. + function testMigrationCursorDefaultsToBootstrapForFreshAccount() external view { + address fresh = address(0xCAFE); + assertEq(vault.migrationCursor(fresh), 0, "fresh account cursor defaults to 0 (= bootstrap)"); + } + + /// `migrateAccount` is a complete no-op when no completed splits + /// exist past the holder's cursor. Fresh holder (cursor 0 = bootstrap), + /// only bootstrap fired, no user splits completed: a touch must not + /// emit `AccountMigrated` and must not write to `accountMigrationCursor` + /// (it stays at the default 0). The cursor-advance early-return inside + /// `migrateAccount` (`if (newCursor == currentCursor) return;`) is what + /// suppresses both. A regression that emitted unconditionally or that + /// wrote the cursor before the early-return would surface here. + function testMigrateAccountNoOpWhenAtLatest() external { + // Schedule a future user split so `ensureBootstrap` fires. + // The split is pending; only bootstrap is completed. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 5000, _splitParams(2)); + + vm.recordLogs(); + vault.publicUpdate(address(0), ALICE, 0); // touch ALICE without minting + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 sig = StoxReceiptVault.AccountMigrated.selector; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == sig) { + fail(); + } + } + assertEq(vault.migrationCursor(ALICE), 0, "cursor stays at bootstrap default for no-op migration"); + assertEq(vault.rawStoredBalance(ALICE), 0, "stored balance stays at default for no-op migration"); + } + + /// Two consecutive splits, then mint to a fresh account: still no inflation. + function testMintFreshAccountAfterTwoCompletedSplits() external { + vault.publicUpdate(address(0), BOB, 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); + vm.warp(3000); + + vault.publicUpdate(address(0), ALICE, 100); + + assertEq(vault.balanceOf(ALICE), 100); + } + + /// Bob existed before the splits and never interacted; his eventual + /// migration produces the correct rebased balance. + function testDormantHolderMigratesCorrectly() external { + vault.publicUpdate(address(0), BOB, 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); + vm.warp(3000); + + // Force a migration via a touch. + vault.publicUpdate(BOB, BOB, 0); + + assertEq(vault.balanceOf(BOB), 600, "100 * 2 * 3 = 600"); + assertEq(vault.rawStoredBalance(BOB), 600, "stored balance is rasterized to post-rebase"); + assertEq(vault.migrationCursor(BOB), 2, "cursor advanced to latest split (idx 2; bootstrap at idx 0)"); + } + + /// Burn from a holder works correctly after a split. + function testBurnAfterSplit() external { + vault.publicUpdate(address(0), BOB, 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // Bob's effective balance is 200 after the split. + assertEq(vault.balanceOf(BOB), 200); + + // Burn 50 from Bob. + vault.publicUpdate(BOB, address(0), 50); + assertEq(vault.balanceOf(BOB), 150); + } + + /// AccountMigrated event fires with correct values when a non-zero + /// account is migrated through a completed split. + function testAccountMigratedEventEmitted() external { + vault.publicUpdate(address(0), BOB, 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + vm.expectEmit(true, false, false, true, address(vault)); + emit StoxReceiptVault.AccountMigrated(BOB, 0, 1, 100, 200); + // Touch Bob to trigger migration. fromCursor=0 (= bootstrap idx 0), + // toCursor=1 (the completed split). + vault.publicUpdate(BOB, BOB, 0); + } + + /// `AccountMigrated` must fire exactly once per `_update`, aggregating + /// the full multi-split migration into a single event with the aggregate + /// `fromCursor → toCursor` and `oldBalance → newBalance`. Pins that the + /// emit is not per-split and that the post-rasterization fields reflect + /// the end state after all completed splits are applied, not an + /// intermediate state. + function testAccountMigratedEventAggregatesAcrossMultipleSplits() external { + vault.publicUpdate(address(0), BOB, 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); + vm.warp(3000); + + vm.recordLogs(); + vault.publicUpdate(BOB, BOB, 0); + + bytes32 sig = StoxReceiptVault.AccountMigrated.selector; + Vm.Log[] memory logs = vm.getRecordedLogs(); + uint256 count = 0; + uint256 fromCursor; + uint256 toCursor; + uint256 oldBalance; + uint256 newBalance; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length > 0 && logs[i].topics[0] == sig) { + count++; + assertEq(address(uint160(uint256(logs[i].topics[1]))), BOB, "indexed account is BOB"); + (fromCursor, toCursor, oldBalance, newBalance) = + abi.decode(logs[i].data, (uint256, uint256, uint256, uint256)); + } + } + assertEq(count, 1, "exactly one AccountMigrated event per multi-split migration"); + assertEq(fromCursor, 0, "fromCursor is pre-migration cursor (default = bootstrap idx 0)"); + // Bootstrap (idx 0) + two splits (idx 1, 2); migration walks past + // the bootstrap to the second split. + assertEq(toCursor, 2, "toCursor is latest completed split index"); + assertEq(oldBalance, 100, "oldBalance is pre-rasterization stored value"); + assertEq(newBalance, 600, "newBalance is fully rasterized (100 * 2 * 3)"); + } + + /// Phenomenon 1 (zero balance): `AccountMigrated` fires when a + /// zero-balance account's cursor advances. `oldBalance == newBalance == 0`, + /// the cursor moves from 0 to the latest completed split. Pins issue + /// #81 resolution: every cursor advance emits. + function testAccountMigratedFiresOnZeroBalanceCursorAdvance() external { + // Pre-existing holder so bootstrap has something to read. + vault.publicUpdate(address(0), BOB, 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // Touch Alice (zero balance, fresh recipient). + vm.expectEmit(true, false, false, true, address(vault)); + emit StoxReceiptVault.AccountMigrated(ALICE, 0, 1, 0, 0); + vault.publicUpdate(address(0), ALICE, 0); + + // Confirm the cursor actually advanced. + assertEq(vault.migrationCursor(ALICE), 1, "alice cursor must have advanced"); + } + + /// Phenomenon 2 (single-step truncation collision): a stored balance of + /// 1 through a 1.5x multiplier rasterizes to `trunc(1.5) == 1`. The + /// cursor advances; the stored balance is unchanged; the event still + /// fires. + function testAccountMigratedFiresOnTruncationCollision() external { + vault.publicUpdate(address(0), ALICE, 1); + // Multiplier 3/2 = 1.5 — `trunc(1 * 1.5) == 1`. + Float oneAndAHalf = LibDecimalFloat.div(LibDecimalFloat.packLossless(3, 0), LibDecimalFloat.packLossless(2, 0)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(oneAndAHalf)); + vm.warp(2000); + + vm.expectEmit(true, false, false, true, address(vault)); + emit StoxReceiptVault.AccountMigrated(ALICE, 0, 1, 1, 1); + vault.publicUpdate(ALICE, ALICE, 0); + + assertEq(vault.rawStoredBalance(ALICE), 1, "stored balance unchanged after truncation collision"); + assertEq(vault.migrationCursor(ALICE), 1, "alice cursor advanced past the split"); + } + + /// Phenomenon 3 (multi-step round-trip): a balance of 4 through `[2x, + /// 1/2x]` rasterizes `4 -> 8 -> 4`. The intermediate value differs + /// from the start but the final equals it. (Rain Float represents 1/2 + /// exactly in base 10, so this sequence round-trips for any even + /// balance — `1/3` would not, since the Float representation of 1/3 + /// is slightly less than exact 1/3 and `trunc(3 * 1/3_float) = 0`.) + /// The cursor jumps two splits in a single `_update`; the event fires + /// once with `oldBalance == newBalance == 4`. + function testAccountMigratedFiresOnMultiStepRoundTrip() external { + vault.publicUpdate(address(0), ALICE, 4); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _fractionalParams(1, 2)); + vm.warp(3000); + + vm.expectEmit(true, false, false, true, address(vault)); + emit StoxReceiptVault.AccountMigrated(ALICE, 0, 2, 4, 4); + vault.publicUpdate(ALICE, ALICE, 0); + + assertEq(vault.rawStoredBalance(ALICE), 4, "stored balance round-tripped to itself"); + // Bootstrap at idx 0, splits at idx 1 and 2 — alice walks past both. + assertEq(vault.migrationCursor(ALICE), 2, "alice cursor advanced past both splits"); + } + + /// Phenomenon 4 (balance-specific identity): a balance of 10 through a + /// 1.09x multiplier rasterizes to `trunc(10.9) == 10`. Same multiplier + /// applied to a larger balance produces a real change; the no-op here + /// is balance-specific. The event still fires. + function testAccountMigratedFiresOnBalanceSpecificIdentity() external { + vault.publicUpdate(address(0), ALICE, 10); + // 1.09 = 109/100 — `trunc(10 * 1.09) = trunc(10.9) == 10`. + Float oneOhNine = + LibDecimalFloat.div(LibDecimalFloat.packLossless(109, 0), LibDecimalFloat.packLossless(100, 0)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(oneOhNine)); + vm.warp(2000); + + vm.expectEmit(true, false, false, true, address(vault)); + emit StoxReceiptVault.AccountMigrated(ALICE, 0, 1, 10, 10); + vault.publicUpdate(ALICE, ALICE, 0); + + assertEq(vault.rawStoredBalance(ALICE), 10, "stored balance unchanged for this specific balance / multiplier"); + assertEq(vault.migrationCursor(ALICE), 1, "alice cursor advanced"); + } + + /// Transfer path with both `from` and `to` stale: both ends migrate + /// during `_update`, so two `AccountMigrated` events fire — one per + /// account — both before the ERC-20 `Transfer`. Bob's balance is + /// non-zero pre-split, so his pre-rebase value is rasterized and + /// his event has `oldBalance != newBalance`; Alice's same. + function testAccountMigratedFiresForBothEndsOfTransfer() external { + vault.publicUpdate(address(0), ALICE, 100); + vault.publicUpdate(address(0), BOB, 200); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + vm.recordLogs(); + vault.publicUpdate(ALICE, BOB, 50); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 sig = StoxReceiptVault.AccountMigrated.selector; + uint256 aliceCount; + uint256 bobCount; + uint256 aliceLogIdx = type(uint256).max; + uint256 bobLogIdx = type(uint256).max; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length == 0 || logs[i].topics[0] != sig) continue; + address who = address(uint160(uint256(logs[i].topics[1]))); + if (who == ALICE) { + aliceCount++; + aliceLogIdx = i; + } else if (who == BOB) { + bobCount++; + bobLogIdx = i; + } + } + assertEq(aliceCount, 1, "exactly one AccountMigrated for ALICE"); + assertEq(bobCount, 1, "exactly one AccountMigrated for BOB"); + // ordering: ALICE migrates first (the `from` side), then BOB. + assertLt(aliceLogIdx, bobLogIdx, "ALICE (from) migrates before BOB (to)"); + + // Decode payloads and check each rasterized balance. + (, uint256 aliceTo, uint256 aliceOld, uint256 aliceNew) = + abi.decode(logs[aliceLogIdx].data, (uint256, uint256, uint256, uint256)); + (, uint256 bobTo, uint256 bobOld, uint256 bobNew) = + abi.decode(logs[bobLogIdx].data, (uint256, uint256, uint256, uint256)); + assertEq(aliceTo, 1, "alice cursor advanced to split"); + assertEq(aliceOld, 100); + assertEq(aliceNew, 200); + assertEq(bobTo, 1, "bob cursor advanced to split"); + assertEq(bobOld, 200); + assertEq(bobNew, 400); + } + + /// Already-migrated complement of #81's always-emit semantics: an + /// account at the latest cursor that gets touched again (no new + /// completed splits in between) must NOT re-emit `AccountMigrated`. + /// The `newCursor == currentCursor` early return in `migrateAccount` + /// suppresses the spurious event. Pins that "every cursor advance + /// emits" reads as "iff cursor advances". + function testAccountMigratedDoesNotReEmitWhenAlreadyAtLatest() external { + vault.publicUpdate(address(0), ALICE, 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // First touch migrates Alice — event fires. + vault.publicUpdate(ALICE, ALICE, 0); + assertEq(vault.migrationCursor(ALICE), 1, "alice migrated to cursor 1"); + + // Second touch with no new splits — event must NOT fire. + vm.recordLogs(); + vault.publicUpdate(ALICE, ALICE, 0); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 sig = StoxReceiptVault.AccountMigrated.selector; + for (uint256 i = 0; i < logs.length; i++) { + if ( + logs[i].topics.length > 0 && logs[i].topics[0] == sig + && address(uint160(uint256(logs[i].topics[1]))) == ALICE + ) { + fail(); + } + } + } + + /// Event ordering pin: `AccountMigrated` must fire BEFORE the + /// corresponding ERC-20 `Transfer` event in the same `_update` call, + /// because `migrateAccount` runs before `super._update`. Indexers + /// rely on this ordering to compute pre-transfer rasterized balances + /// from the migration log before applying the transfer delta. + function testAccountMigratedOrderedBeforeTransfer() external { + vault.publicUpdate(address(0), ALICE, 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + vm.recordLogs(); + vault.publicUpdate(ALICE, BOB, 50); + Vm.Log[] memory logs = vm.getRecordedLogs(); + + bytes32 migratedSig = StoxReceiptVault.AccountMigrated.selector; + bytes32 transferSig = keccak256("Transfer(address,address,uint256)"); + uint256 firstMigrated = type(uint256).max; + uint256 firstTransfer = type(uint256).max; + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length == 0) continue; + if (logs[i].topics[0] == migratedSig && firstMigrated == type(uint256).max) { + firstMigrated = i; + } else if (logs[i].topics[0] == transferSig && firstTransfer == type(uint256).max) { + firstTransfer = i; + } + } + assertLt(firstMigrated, firstTransfer, "AccountMigrated must precede Transfer"); + } + + /// Global invariant: across a random balance and a random sequence of + /// stock-split multipliers, every cursor advance is matched by exactly + /// one `AccountMigrated` log, and the log's `oldBalance / newBalance` + /// pair always equals the actual pre/post stored balance — never a + /// stale or skipped value. + function testFuzzAccountMigratedFiresOnEveryCursorAdvance(uint64 startBalance, uint8 splitSeed, uint8 splitCount) + external + { + startBalance = uint64(bound(startBalance, 0, type(uint32).max)); + splitCount = uint8(bound(splitCount, 1, 5)); + + vault.publicUpdate(address(0), ALICE, startBalance); + uint256 storedBefore = vault.rawStoredBalance(ALICE); + uint256 cursorBefore = vault.migrationCursor(ALICE); + + // Schedule N splits with multipliers drawn from a small fixed + // palette (2x, 3x, 1/2x, 1/3x) seeded by `splitSeed`. The point is + // to drive a variety of rasterization outcomes — not to be + // exhaustive over the multiplier space. + for (uint256 i = 0; i < splitCount; i++) { + uint8 pick = uint8((uint256(splitSeed) >> (i * 2)) & 0x3); + // forge-lint: disable-next-line(unsafe-typecast) + uint64 effectiveTime = uint64(1500 + i * 1000); + if (pick == 0) { + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, _splitParams(2)); + } else if (pick == 1) { + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, _splitParams(3)); + } else if (pick == 2) { + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, _fractionalParams(1, 2)); + } else { + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effectiveTime, _fractionalParams(1, 3)); + } + } + vm.warp(uint64(1500 + uint256(splitCount) * 1000)); + + // Touch Alice. Capture every log emitted by `_update`. + vm.recordLogs(); + vault.publicUpdate(ALICE, ALICE, 0); + + bytes32 sig = StoxReceiptVault.AccountMigrated.selector; + Vm.Log[] memory logs = vm.getRecordedLogs(); + uint256 count; + uint256 emittedFromCursor; + uint256 emittedToCursor; + uint256 emittedOld; + uint256 emittedNew; + for (uint256 i = 0; i < logs.length; i++) { + if ( + logs[i].topics.length > 0 && logs[i].topics[0] == sig + && address(uint160(uint256(logs[i].topics[1]))) == ALICE + ) { + count++; + (emittedFromCursor, emittedToCursor, emittedOld, emittedNew) = + abi.decode(logs[i].data, (uint256, uint256, uint256, uint256)); + } + } + + uint256 cursorAfter = vault.migrationCursor(ALICE); + if (cursorAfter == cursorBefore) { + // No cursor advance → no event. Defensively pin this branch + // even though the bounded splitCount makes it unreachable. + assertEq(count, 0, "no event when cursor did not advance"); + } else { + assertEq(count, 1, "exactly one AccountMigrated event per cursor advance"); + assertEq(emittedFromCursor, cursorBefore, "fromCursor matches pre-migrate cursor"); + assertEq(emittedToCursor, cursorAfter, "toCursor matches post-migrate cursor"); + assertEq(emittedOld, storedBefore, "oldBalance matches the pre-migrate stored balance"); + assertEq(emittedNew, vault.rawStoredBalance(ALICE), "newBalance matches the post-migrate stored balance"); + } + } + + /// Transfer attempt after a reverse split truncates the sender's + /// balance to zero. Migration runs first, writing the post-truncation + /// value to storage. OZ's `_update` then sees `_balances[from] == 0` + /// and reverts with `ERC20InsufficientBalance` for any non-zero + /// transfer amount. + function testTransferRevertsWhenMigrationTruncatesBalanceToZero() external { + // Alice has stored 1 pre-split. A 1/2x split truncates her to 0. + vault.publicUpdate(address(0), ALICE, 1); + Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(halfX)); + vm.warp(2000); + + // View already reflects the truncation. + assertEq(vault.balanceOf(ALICE), 0, "balanceOf reflects truncation pre-migration"); + + // Any non-zero transfer reverts with OZ's insufficient-balance error — + // migration writes stored = 0 before the transfer arithmetic runs. + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, ALICE, 0, 1)); + vault.publicUpdate(ALICE, BOB, 1); + } + + /// Partial truncation: balance is reduced but non-zero, transfer succeeds + /// up to the migrated amount. + function testTransferSucceedsUpToMigratedBalanceAfterPartialTruncation() external { + // Alice has stored 3 pre-split. 1/2x truncates 3 → 1. + vault.publicUpdate(address(0), ALICE, 3); + Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(halfX)); + vm.warp(2000); + assertEq(vault.balanceOf(ALICE), 1, "alice migrated balance = trunc(3 * 0.5) = 1"); + + // Transfer exactly the migrated amount. + vault.publicUpdate(ALICE, BOB, 1); + assertEq(vault.balanceOf(ALICE), 0, "alice drained"); + assertEq(vault.balanceOf(BOB), 1, "bob received the full 1 unit"); + } + + /// Boundary on `effectiveTime`: a split whose effective time equals the + /// current block timestamp must be treated as completed (via the `<=` + /// comparison in `LibCorporateActionNode.nextOfType`). One second before + /// its effective time it must NOT be completed. Pins the exact threshold + /// so a future refactor flipping `<=` to `<` trips this test. + function testEffectiveTimeBoundaryExactlyAtCompletesSplit() external { + vault.publicUpdate(address(0), ALICE, 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + + // One second before the split's effective time: the split is NOT + // completed and bootstrap is at idx 0 (the default cursor). With + // no completed splits past bootstrap, migration is a no-op — + // cursor stays at 0, balance unchanged. + vm.warp(1499); + vault.publicUpdate(ALICE, ALICE, 0); + assertEq(vault.migrationCursor(ALICE), 0, "no completed split: cursor stays at bootstrap (idx 0)"); + assertEq(vault.balanceOf(ALICE), 100, "balance must not rebase before effective time"); + + // Exactly at the split's effective time: split is now completed. + // Migration fires, cursor advances to the split (idx 1), balance + // rebases. + vm.warp(1500); + vault.publicUpdate(ALICE, ALICE, 0); + assertEq(vault.migrationCursor(ALICE), 1, "cursor must advance at exact effective time"); + assertEq(vault.balanceOf(ALICE), 200, "balance must rebase at exact effective time"); + } + + /// Fuzzed no-split accounting: for any sequence of mints and burns + /// applied before the first split completes (the default state of any + /// token the moment it is deployed), `totalSupply()` equals the plain + /// `Σmints − Σburns` sum. The corporate-actions override must be a + /// straight passthrough of OZ's `_totalSupply` in this regime and + /// introduce zero drift. + function testFuzzNoSplitSupplyEqualsNetMinted(uint64[8] memory mints, uint8[8] memory burnsRaw) external { + address[3] memory actors = [ALICE, BOB, CAROL]; + uint256 netMinted; + + for (uint256 i = 0; i < 8; i++) { + address to = actors[i % 3]; + uint256 mintAmount = uint256(mints[i]) % 1e18; + if (mintAmount > 0) { + vault.publicUpdate(address(0), to, mintAmount); + netMinted += mintAmount; + } + + address from = actors[(i + 1) % 3]; + uint256 available = vault.balanceOf(from); + if (available > 0 && burnsRaw[i] > 0) { + uint256 burnAmount = (uint256(burnsRaw[i]) * available) / 255; + if (burnAmount > 0) { + vault.publicUpdate(from, address(0), burnAmount); + netMinted -= burnAmount; + } + } + + assertEq(vault.totalSupply(), netMinted, "totalSupply drifted from net mint/burn sum without splits"); + assertEq(vault.totalSupplyLatestCursor(), 0, "bootstrap must not have fired: no split has completed"); + } + } + + /// Pre-bootstrap regime: until the first `_update` after a completed + /// split, `fold()` must not bootstrap, `onMint`/`onBurn` must be no-ops, + /// and `totalSupply()` must return OZ's raw `_totalSupply`. A pending + /// split that has not yet reached its effective time must not trigger + /// any of these. + function testPreBootstrapIsNoOpUntilCompletedSplit() external { + // Mint pre-any-schedule. No pot update expected — bootstrap has + // not fired, `nodes.length == 0`, `onMint` is a no-op. + vault.publicUpdate(address(0), BOB, 200); + assertEq(vault.totalSupplyLatestCursor(), 0, "no split tracked pre-schedule"); + assertEq(vault.totalSupply(), 200, "totalSupply matches OZ pre-any-schedule"); + assertEq(vault.unmigrated(0), 0, "pot 0 untouched pre-bootstrap"); + + // Burn pre-any-schedule. Also a no-op on pots. + vault.publicUpdate(BOB, address(0), 50); + assertEq(vault.totalSupply(), 150, "totalSupply reflects burn via OZ"); + assertEq(vault.unmigrated(0), 0, "pot 0 still untouched"); + + // Schedule a split with a future effective time. `ensureBootstrap` + // fires here: pushes the bootstrap node at idx 0 with `effectiveTime + // = block.timestamp` (immediately completed), captures `unmigrated[0] + // = OZ.totalSupply` (= 150 after the prior mint/burn), and writes + // `totalSupplyLatestCursor = NODE_NONE` as the "no fold has run yet" + // sentinel. The user split lands at idx 1 and is pending until warp. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 5000, _splitParams(2)); + // `totalSupplyLatestCursor` only moves inside `fold` (called from + // `_update`), so it stays at the `NODE_NONE` sentinel `ensureBootstrap` + // wrote until the next _update. + assertEq(vault.totalSupplyLatestCursor(), NODE_NONE, "schedule alone does not advance latest cursor"); + assertEq(vault.unmigrated(0), 150, "ensureBootstrap snapshotted OZ total supply into pot 0"); + + // Mint again with the pending split scheduled. The bootstrap node + // is at idx 0 and completed at schedule time, so `fold()` advances + // `totalSupplyLatestCursor` to the bootstrap (idx 0). BOB's cursor + // default is already 0 (= bootstrap), and there are no completed + // splits past it, so `migrateAccount` is a no-op for him. Then + // super._update mints 100 into _balances[BOB], and onMint adds + // 100 to `unmigrated[0]` (the latest pot). + vault.publicUpdate(address(0), BOB, 100); + assertEq(vault.totalSupplyLatestCursor(), 0, "fold advances to the bootstrap on first _update"); + assertEq(vault.totalSupply(), 250, "totalSupply matches OZ while only bootstrap is completed"); + assertEq(vault.unmigrated(0), 250, "bootstrap pot holds the snapshot plus the new mint"); + assertEq(vault.unmigrated(1), 0, "split has not landed; its pot is empty"); + + // balanceOf returns the stored balance (identity migration leaves + // it unchanged at 250). + assertEq(vault.balanceOf(BOB), 250, "balanceOf ignores pending splits"); + } + + /// totalSupply equals the sum of all per-account balanceOf values after a + /// completed split, even when some accounts are still unmigrated. This is + /// the integration-level invariant that A28-1 says must hold and that the + /// pre-fix code violated for fresh-recipient pathways. + function testTotalSupplyMatchesSumOfBalanceOfAfterMixedActivity() external { + // Pre-existing holders. + vault.publicUpdate(address(0), BOB, 100); + vault.publicUpdate(address(0), CAROL, 200); + + // Split. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // Mint to a fresh account after the split. + vault.publicUpdate(address(0), ALICE, 100); + + // Transfer from Bob to a brand new account (DAVE). + address dave = address(0xDAFE); + // Bob currently has effective balance 200; transfer 50 to Dave. + vault.publicUpdate(BOB, dave, 50); + + // Burn 30 from Carol. + vault.publicUpdate(CAROL, address(0), 30); + + // Now: sum(balanceOf) == totalSupply. + uint256 sumBalances = + vault.balanceOf(BOB) + vault.balanceOf(CAROL) + vault.balanceOf(ALICE) + vault.balanceOf(dave); + assertEq(sumBalances, vault.totalSupply(), "totalSupply must equal sum of balanceOf"); + } + + /// Fractional / reverse splits cannot maintain `totalSupply == sum(balanceOf)` + /// exactly while multiple accounts share a pre-split pot: the walk applies + /// the multiplier to the aggregate pot, but per-account `balanceOf` applies + /// it to each account individually, so `trunc(Σ aᵢ * m) ≥ Σ trunc(aᵢ * m)`. + /// The difference is the per-account truncation dust. The gap closes once + /// every account has migrated through the split and `unmigrated[0]` is 0. + function testTotalSupplyFractionalDustConvergesAfterMigration() external { + Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + + vault.publicUpdate(address(0), BOB, 1); + vault.publicUpdate(address(0), CAROL, 1); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, LibStockSplit.encodeParametersV1(halfX)); + vm.warp(2000); + + // Aggregate pot: trunc((1 + 1) * 0.5) == 1. Per-account: trunc(1 * 0.5) + // + trunc(1 * 0.5) == 0. totalSupply is the upper bound here. + assertEq(vault.totalSupply(), 1, "aggregate pot keeps rounding dust pre-migration"); + assertEq(vault.balanceOf(BOB) + vault.balanceOf(CAROL), 0, "per-account truncates individually"); + + // Migrate both accounts out of the shared pot. + vault.publicUpdate(BOB, BOB, 0); + vault.publicUpdate(CAROL, CAROL, 0); + + assertEq( + vault.totalSupply(), + vault.balanceOf(BOB) + vault.balanceOf(CAROL), + "totalSupply converges to sum(balanceOf) post-migration" + ); + assertEq(vault.totalSupply(), 0, "dust resolves to 0 once both migrate"); + } + + /// In the mint-after-split scenario, `totalSupply()` equals the sum of + /// every holder's `balanceOf` — the share-side integration invariant + /// that justifies the per-cursor pot bookkeeping. + function testTotalSupplyConsistentWithBalanceOfAfterMintFreshPostSplit() external { + vault.publicUpdate(address(0), BOB, 1000); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + vault.publicUpdate(address(0), ALICE, 100); + + assertEq(vault.balanceOf(ALICE), 100); + // Bob has 1000 stored at cursor 0; after rebase his effective balance is 2000. + assertEq(vault.balanceOf(BOB), 2000); + // Total supply: Bob's 2000 + Alice's 100 = 2100. + assertEq(vault.totalSupply(), 2100); + } + + // ----------------------------------------------------------------------- + // Cursor / totalSupplyLatestCursor invariant. + // + // After `migrateAccount(account)` returns inside `_update` (which runs + // after `fold()`), `accountMigrationCursor[account]` equals + // `s.totalSupplyLatestCursor`. `LibTotalSupply.onBurn` subtracts the + // burn amount from `unmigrated[totalSupplyLatestCursor]`. If the + // burner's migrated balance had landed in a different pot (cursor != + // latest), onBurn would subtract from a pot that never received the + // balance, and the subtraction would underflow. + + /// Deterministic pin for the exact path onBurn's safety relies on: + /// schedule a split → mint pre-split → warp past it → schedule another + /// split → warp past it → burn from the pre-split holder. After every + /// migrating `publicUpdate` call, `migrationCursor(bob)` must equal + /// `totalSupplyLatestCursor()`, and the burn must succeed (no panic). + function testCursorEqualsTotalSupplyLatestSplitAcrossBurnPath() external { + // Split 1: 2x at t=1500. Mint Bob before it lands so he starts at + // cursor 0 (default = bootstrap). + vault.publicUpdate(address(0), BOB, 1000); + assertEq(vault.migrationCursor(BOB), 0, "fresh mint lands at cursor 0 (= bootstrap)"); + assertEq(vault.totalSupplyLatestCursor(), 0, "no schedule yet, latestCursor at default 0"); + + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // Touch Bob — migration should land his cursor on 1 (bootstrap at + // idx 0, split at idx 1) AND totalSupplyLatestCursor should also be + // 1 (fold advanced through both). + vault.publicUpdate(BOB, BOB, 0); + assertEq(vault.migrationCursor(BOB), vault.totalSupplyLatestCursor(), "post-migrate: cursor == latest"); + assertEq(vault.migrationCursor(BOB), 1, "cursor advanced through bootstrap and the first split"); + assertEq(vault.balanceOf(BOB), 2000, "Bob rebased to 2000"); + + // Split 2: 3x at t=2500. Schedule and warp. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); + vm.warp(3000); + + // Now burn some from Bob. Inside `_update`, fold() advances + // totalSupplyLatestCursor to 2 (the second split), then + // migrateAccount(BOB) walks Bob's cursor from 1 to 2, rasterizing + // the balance to 6000. Then onBurn(500) subtracts 500 from + // unmigrated[2]. If the cursor invariant held, this succeeds; if + // it didn't, onBurn would underflow. + vault.publicUpdate(BOB, address(0), 500); + assertEq(vault.migrationCursor(BOB), vault.totalSupplyLatestCursor(), "post-burn: cursor == latest"); + assertEq(vault.migrationCursor(BOB), 2, "cursor advanced through second split"); + // 1000 → 2x → 2000 → 3x → 6000 → -500 → 5500. + assertEq(vault.balanceOf(BOB), 5500, "Bob's final balance reflects both splits and the burn"); + // Bob is the only holder, so totalSupply == balanceOf(Bob). + assertEq(vault.totalSupply(), 5500, "totalSupply must equal sum of balances"); + } + + /// Fuzz: run a sequence of mint / transfer / burn operations interleaved + /// with stock splits, and assert after EVERY `publicUpdate` touching an + /// account that `migrationCursor(account) == totalSupplyLatestCursor()`. + /// This is the strongest form of the cursor invariant at the per-PR + /// level — any input shape that violates it fails the assertion + /// immediately, and the underlying `onBurn` subtraction cannot panic + /// under the same preconditions that keep the invariant true. + /// + /// Inputs are bounded to keep the float library inside its conservative + /// operating range and to keep the test fast. The point is breadth of + /// sequences, not exhaustive amount ranges. + function testFuzzCursorEqualsLatestAfterEveryMigration( + uint8 split1Raw, + uint8 split2Raw, + uint128 mintBob, + uint128 mintCarol, + uint128 transferAmtRaw, + uint128 burnAmtRaw + ) external { + // Multipliers in [1, 5]. Zero would violate LibStockSplit's + // positive-coefficient rule; values >5 risk compounding into + // overflow territory across two splits + mints. + int256 m1 = int256(uint256(uint8(split1Raw % 5) + 1)); + int256 m2 = int256(uint256(uint8(split2Raw % 5) + 1)); + + // Keep mints within a safe range so two sequential splits up to 5x + // each don't exceed uint256 when summed across accounts. + uint256 bobMint = uint256(mintBob) % 1e24 + 1; + uint256 carolMint = uint256(mintCarol) % 1e24 + 1; + + // Initial state: mint Bob and Carol pre-split. Both land at cursor 0, + // totalSupplyLatestCursor == 0. Neither mint should have migrated + // state (nothing to migrate). + vault.publicUpdate(address(0), BOB, bobMint); + _assertCursorInvariant(BOB); + vault.publicUpdate(address(0), CAROL, carolMint); + _assertCursorInvariant(CAROL); + + // Split 1 lands. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(m1)); + vm.warp(2000); + + // Transfer from Bob to Carol — migrates both, asserts both have + // cursor == latest afterwards. + uint256 bobEffective = vault.balanceOf(BOB); + uint256 transferAmt = uint256(transferAmtRaw) % (bobEffective + 1); + vault.publicUpdate(BOB, CAROL, transferAmt); + _assertCursorInvariant(BOB); + _assertCursorInvariant(CAROL); + assertEq(vault.migrationCursor(BOB), 1, "Bob cursor advanced through split 1 (idx 1; bootstrap is idx 0)"); + assertEq(vault.migrationCursor(CAROL), 1, "Carol's cursor advanced through split 1"); + + // Split 2 lands. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(m2)); + vm.warp(3000); + + // Burn from Carol — migrates Carol (from cursor 1 through split 2 + // to cursor 2), then onBurn subtracts from the latest pot. Without + // the invariant, this would underflow. With the invariant, it + // succeeds cleanly. + uint256 carolEffective = vault.balanceOf(CAROL); + uint256 burnAmt = uint256(burnAmtRaw) % (carolEffective + 1); + vault.publicUpdate(CAROL, address(0), burnAmt); + _assertCursorInvariant(CAROL); + assertEq(vault.migrationCursor(CAROL), 2, "Carol cursor advanced through split 2 (idx 2)"); + + // Touch Bob to migrate him too. + vault.publicUpdate(BOB, BOB, 0); + _assertCursorInvariant(BOB); + assertEq(vault.migrationCursor(BOB), 2, "Bob's cursor advanced through both splits"); + + // Final sum-of-balances check. Both accounts have migrated through + // the full chain; totalSupply must equal their combined balance. + assertEq( + vault.balanceOf(BOB) + vault.balanceOf(CAROL), + vault.totalSupply(), + "sum(balanceOf) must equal totalSupply after full migration" + ); + } + + /// @dev Assert that the account's migration cursor equals the global + /// `totalSupplyLatestCursor`. This is the invariant `onBurn` depends on. + function _assertCursorInvariant(address account) internal view { + assertEq( + vault.migrationCursor(account), + vault.totalSupplyLatestCursor(), + "migrationCursor must equal totalSupplyLatestCursor after migrateAccount" + ); + } + + /// Structural coupling test between `LibRebase.migratedBalance` and + /// `LibTotalSupply.fold()`. + /// + /// Both functions currently filter the corporate-action linked list with + /// `ACTION_TYPE_STOCK_SPLIT_V1`. That coupling keeps + /// `accountMigrationCursor` and `totalSupplyLatestCursor` in lockstep. + /// + /// INTENT: pin the current behaviour that a completed non-stock-split node + /// advances *neither* cursor. When a future action type (dividends, rights + /// issues, etc.) starts participating in migration, whoever adds that + /// support MUST update both `LibRebase` and `LibTotalSupply` together, or + /// they'll diverge and the pot accounting will break silently. This test + /// fails fast in that scenario: + /// + /// - If `migrateAccount` starts walking the new type without + /// `LibTotalSupply.fold()` doing the same, the first assertion here + /// fails because `migrationCursor` advances past the synthetic node + /// but `totalSupplyLatestCursor` does not. + /// - If `fold()` starts walking the new type without `migrateAccount` + /// doing the same, the inverse failure mode triggers. + /// + /// When that happens, DO NOT just update the assertions — the failure is + /// signalling that the pot model now needs per-action-type accounting. + /// Revisit `LibTotalSupply` with the new action type's rebase semantics + /// before touching this test. + function testNonStockSplitNodeAdvancesNeitherCursor() external { + // Schedule a completed dividend node. `publicSchedule` bypasses + // `resolveActionType`, so the dividend's parameters blob doesn't + // need to match any validator — we only care that the node lives + // in the list with a non-stock-split bitmap. The first schedule + // also creates the bootstrap (init) node at idx 1; the dividend + // lands at idx 2. + vault.publicSchedule(ACTION_TYPE_STABLES_DIVIDEND_V1, 1500, abi.encode(uint256(0))); + + // Give Bob a pre-existing balance so migrateAccount has something + // to rasterize if it ever starts walking the dividend node. + vault.publicUpdate(address(0), BOB, 1000); + + // Warp past the dividend's effective time so it counts as completed. + vm.warp(2000); + + // Touch Bob to drive fold() + migrateAccount. + vault.publicUpdate(BOB, BOB, 0); + + // Bootstrap (idx 0, type INIT) is the default cursor and IS in the + // migration mask, but Bob's already there. Dividend (idx 1) is NOT + // in the migration mask, so the walk yields nothing past bootstrap. + // Net effect: cursor stays at 0, balance unchanged. + assertEq(vault.migrationCursor(BOB), 0, "cursor stays at bootstrap; dividend must not advance it"); + assertEq( + vault.totalSupplyLatestCursor(), + 0, + "fold() lands on bootstrap; dividend must not advance latestCursor past idx 0" + ); + assertEq(vault.balanceOf(BOB), 1000, "Bob's balance must be unaffected (bootstrap is identity)"); + + // Now schedule a real stock split, complete it, and confirm BOTH + // cursors advance together. This half of the test pins the + // positive-case behaviour: stock splits move both, non-splits move + // neither. The split lands at idx 2 (after bootstrap and dividend). + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(2)); + vm.warp(3000); + vault.publicUpdate(BOB, BOB, 0); + + assertEq(vault.migrationCursor(BOB), 2, "cursor must advance to the stock-split node (idx 2)"); + assertEq(vault.totalSupplyLatestCursor(), 2, "latest must advance to the stock-split node (idx 2)"); + assertEq(vault.balanceOf(BOB), 2000, "Bob's balance must reflect only the 2x split, not the dividend"); + } + + /// Pre-bootstrap `effectiveTotalSupply()` walks ALL completed multipliers + /// starting from OZ's raw `_totalSupply`, not just the first one. + /// `testTotalSupplyDuringBootstrapDeferredWindow` covers the single-split + /// case; this covers multi-split to pin that the walk continues past the + /// first completed node without a `fold()` having bootstrapped any pot. + function testTotalSupplyMultiSplitPreBootstrap() external { + vault.publicUpdate(address(0), BOB, 100); + + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 3500, _splitParams(5)); + vm.warp(4000); + + // Critically: no `_update` between the warp and the read, so + // `fold()` hasn't run; `totalSupplyLatestCursor` is still the + // `NODE_NONE` sentinel `ensureBootstrap` set even though it + // populated `unmigrated[0]` from the schedule. The view's walk + // reads `unmigrated[0]` directly. + assertEq(vault.totalSupplyLatestCursor(), NODE_NONE, "no _update yet => latestCursor unchanged"); + assertEq(vault.totalSupply(), 3000, "100 * 2 * 3 * 5 via pot-0 multi-multiplier walk"); + } + + /// `effectiveTotalSupply()` called between split completion and the first + /// post-split `_update` reads `unmigrated[0]` (snapshotted by + /// `ensureBootstrap` at schedule time) and walks every completed + /// multiplier. No state mutation in the view path. + /// + /// INTENT: pin the behaviour of the view during the post-schedule, + /// pre-`fold` window. If the branch shape changes — e.g. if `fold()` is + /// ever moved into the view — this test fails and forces the author to + /// re-evaluate the state-mutation rules for view functions. + function testTotalSupplyDuringBootstrapDeferredWindow() external { + // Mint supply before any split is scheduled. No bootstrap yet. + vault.publicUpdate(address(0), BOB, 100); + assertEq(vault.totalSupplyLatestCursor(), 0, "no split tracked yet (default storage)"); + + // Schedule a 2x split and warp past its effective time. Schedule + // runs `ensureBootstrap`, which snapshots `unmigrated[0] = 100` + // and sets `totalSupplyLatestCursor = NODE_NONE` as the "no fold + // has run yet" sentinel. Critically, do NOT call any `_update` + // after warping — so `fold()` hasn't run. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // The view must still report the rebased supply by walking the + // completed init+split nodes from `unmigrated[0]`. + assertEq(vault.totalSupply(), 200, "view must apply the 2x multiplier without fold"); + + // `totalSupplyLatestCursor` is still the `NODE_NONE` sentinel — + // view reads are side-effect-free. + assertEq(vault.totalSupplyLatestCursor(), NODE_NONE, "view must not advance latest cursor"); + } + + /// Multiple completed splits land between `_update` calls and no + /// account migrates in between. Exercises the view walk across all + /// three multipliers while pot 0 still holds mass — the critical + /// case that catches a dropped-multiplier regression. + /// + /// INTENT: pin the behaviour of `effectiveTotalSupply` walking + /// through intermediate pots. Asserting totalSupply *before* the + /// mass migrates out of pot 0 forces the walk to perform real + /// multiplier applications on non-zero running values. If a future + /// change ever dropped a multiplier step or tried to "skip empty + /// pots" without carrying the multiplication forward, this test + /// would fail at the pre-migration assertion. + /// + /// The post-migration assertion is a second-order check: once all + /// mass is in the final pot, the walk's multiplier applications + /// operate on zero intermediates and are invisible to the result. + /// That assertion only catches pot-accounting bugs, not + /// multiplier-walk bugs. + function testEffectiveTotalSupplyAcrossGapWithZeroIntermediatePots() external { + // Seed: Bob holds 100 pre-split. + vault.publicUpdate(address(0), BOB, 100); + + // Three splits complete before anybody touches the vault. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 3500, _splitParams(5)); + vm.warp(4000); + + // Force `fold()` to advance `totalSupplyLatestCursor` through + // bootstrap + every completed split without moving Bob's mass out + // of pot 0. CAROL is a fresh zero-balance account — her + // "migration" advances only her cursor, not any pot value. After + // this, pot 0 still holds 100. Bootstrap is at idx 0, splits at + // idx 1/2/3, so latest cursor lands on 3. + vault.publicUpdate(address(0), CAROL, 0); + assertEq(vault.totalSupplyLatestCursor(), 3, "fold must advance to the latest split (idx 3)"); + + // CRITICAL ASSERTION: pot 0 holds 100, pots 1..3 are empty. The + // view must walk `100 -> identity (init) -> trunc(100*2) -> trunc(200*3) + // -> trunc(600*5) = 3000`. A dropped multiplier in the walk + // collapses to 100. + assertEq(vault.totalSupply(), 3000, "view must apply every multiplier while pot 0 holds mass"); + + // Now migrate Bob. His mass leaves pot 0 and lands in pot 3 (the + // latest split). + vault.publicUpdate(BOB, BOB, 0); + assertEq(vault.rawStoredBalance(BOB), 3000, "bob stored = 100 * 2 * 3 * 5"); + assertEq(vault.migrationCursor(BOB), 3, "bob cursor at latest split (idx 3)"); + + // Post-migration: same total, but now all mass lives in pot 4. + // Note: the walk here trivially produces 3000 because the + // multiplier applications all operate on zero intermediates. + // This assertion catches pot-accounting bugs but not walk bugs + // — the pre-migration assertion above is the walk guarantee. + assertEq(vault.totalSupply(), 3000, "totalSupply stable across migration"); + } + + /// Burn-to-zero after a completed split: an account with a non-zero + /// pre-split balance migrates through the split and then burns its + /// entire post-migration balance. Checks all four pieces of state + /// simultaneously to pin the migrate+burn interaction in one test. + /// + /// INTENT: a future regression that, for example, decoupled + /// `onBurn` from `migrateAccount`'s ordering would leave one of + /// the pots or the stored balance inconsistent with the others. + /// Asserting all four quantities at once makes such a regression + /// visible in a single test failure rather than having to correlate + /// individual assertions across separate tests. + function testBurnToZeroPostSplitInvariants() external { + // Alice holds 50 pre-split. + vault.publicUpdate(address(0), ALICE, 50); + + // 2x split completes. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // Alice's view is 100 (migrated). Burn all of it. + assertEq(vault.balanceOf(ALICE), 100, "alice sees 2x post-split"); + vault.publicUpdate(ALICE, address(0), 100); + + // All four state pieces after burn-to-zero: + // (1) stored balance is 0 + // (2) migration cursor advanced to the split (idx 1; bootstrap is idx 0) + // (3) unmigrated[0] drained of alice's pre-split balance + // (4) unmigrated[1] = migrated balance - burn = 100 - 100 = 0 + // + // `effectiveTotalSupply` walks: running = 0 from bootstrap pot + // (drained); trunc(0 * 2) + unmigrated[1] = 0 + 0 = 0. + assertEq(vault.rawStoredBalance(ALICE), 0, "alice stored = 0"); + assertEq(vault.migrationCursor(ALICE), 1, "alice cursor at split (idx 1)"); + assertEq(vault.totalSupply(), 0, "totalSupply collapses to 0 after full burn"); + } + + /// Over-burn by a lone holder at `totalSupplyLatestCursor` must surface + /// OZ's `ERC20InsufficientBalance` error, not a raw arithmetic panic + /// from the pot subtraction. + function testOverBurnSurfacesOzInsufficientBalanceNotPanic() external { + vault.publicUpdate(address(0), ALICE, 50); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + vm.expectRevert(abi.encodeWithSelector(IERC20Errors.ERC20InsufficientBalance.selector, ALICE, 100, 101)); + vault.publicUpdate(ALICE, address(0), 101); + } + + /// @dev Asserts `LibTotalSupply`'s pot invariant I(k) directly at a + /// given cursor: the pot value equals the sum of stored balances for + /// every account at that cursor, and no other accounts are at that + /// cursor. + function _assertPotInvariant(uint256 cursor, address[] memory accountsAtCursor) internal view { + uint256 sum; + for (uint256 i = 0; i < accountsAtCursor.length; i++) { + assertEq(vault.migrationCursor(accountsAtCursor[i]), cursor, "I(k): account must be at the expected cursor"); + sum += vault.rawStoredBalance(accountsAtCursor[i]); + } + assertEq(vault.unmigrated(cursor), sum, "I(k): pot must equal sum of stored balances at cursor"); + } + + /// @dev Build a one-element address array inline. + function _single(address a) internal pure returns (address[] memory) { + address[] memory arr = new address[](1); + arr[0] = a; + return arr; + } + + /// @dev Build a two-element address array inline. + function _pair(address a, address b) internal pure returns (address[] memory) { + address[] memory arr = new address[](2); + arr[0] = a; + arr[1] = b; + return arr; + } + + /// @dev Build a three-element address array inline. + function _triple(address a, address b, address c) internal pure returns (address[] memory) { + address[] memory arr = new address[](3); + arr[0] = a; + arr[1] = b; + arr[2] = c; + return arr; + } + + /// Direct assertion of the LibTotalSupply pot invariant I(k) across a + /// mixed activity sequence: two pre-split mints, a split, a post-split + /// mint, a transfer, a burn. After each `_update` (post-bootstrap) the + /// invariant must hold for every cursor that has touched accounts. + function testPotInvariantDirectAfterMixedActivity() external { + address[] memory empty = new address[](0); + + // Pre-split mints. Alice and Bob at cursor 0. Bootstrap hasn't + // fired yet, so the pot invariant does not apply here. + vault.publicUpdate(address(0), ALICE, 50); + vault.publicUpdate(address(0), BOB, 100); + + // Schedule and complete a 2x split. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + // Alice touches: `ensureBootstrap` already snapshotted + // `unmigrated[0] = 150` at schedule time; the user split is at + // idx 1 (bootstrap is at idx 0). Alice migrates 50 → 100, shifting + // 50 out of pot 0 and 100 into pot 1. + vault.publicUpdate(ALICE, ALICE, 0); + _assertPotInvariant(0, _single(BOB)); + _assertPotInvariant(1, _single(ALICE)); + + // Mint Carol 40 post-split. Fresh recipient cursor advances 0 → 1 + // with zero balance (no pot delta from migrate). Then super._update + // adds 40 to _balances[CAROL], then onMint adds 40 to pot 1. + vault.publicUpdate(address(0), CAROL, 40); + _assertPotInvariant(0, _single(BOB)); + _assertPotInvariant(1, _pair(ALICE, CAROL)); + + // Alice transfers 30 to Bob. Both migrate first: Alice already at + // cursor 1 (no-op), Bob 0 → 1 with stored 100 → migrated 200. Pot + // transitions: pot 0 -= 100 (Bob leaves), pot 1 += 200 (Bob arrives). + // Then transfer moves 30 between their balances, both at cursor 1 + // — Σ at cursor 1 unchanged, no pot write. + vault.publicUpdate(ALICE, BOB, 30); + _assertPotInvariant(0, empty); + _assertPotInvariant(1, _triple(ALICE, BOB, CAROL)); + + // Burn 25 from Bob. super._update first: _balances[BOB] -= 25, + // _totalSupply -= 25. Then onBurn subtracts 25 from pot 1. + vault.publicUpdate(BOB, address(0), 25); + _assertPotInvariant(0, empty); + _assertPotInvariant(1, _triple(ALICE, BOB, CAROL)); + } + + /// Fuzzed direct assertion of I(k): drive a fixed sequence of + /// parameter-randomised operations across Alice, Bob, Carol and + /// two stock splits, asserting the pot invariant for every + /// touched cursor after each state transition. + function testFuzzPotInvariantAcrossRandomActivity( + uint8 m1Raw, + uint8 m2Raw, + uint64 aliceMintRaw, + uint64 bobMintRaw, + uint64 carolMintRaw, + uint64 transferRaw, + uint64 burnRaw + ) external { + int256 m1 = int256(uint256(m1Raw % 4) + 1); + int256 m2 = int256(uint256(m2Raw % 4) + 1); + uint256 aliceMint = uint256(aliceMintRaw % 1e18) + 1; + uint256 bobMint = uint256(bobMintRaw % 1e18) + 1; + uint256 carolMint = uint256(carolMintRaw % 1e18); + + address[] memory empty = new address[](0); + + // Pre-split mints (bootstrap has not fired; invariant doesn't apply). + vault.publicUpdate(address(0), ALICE, aliceMint); + vault.publicUpdate(address(0), BOB, bobMint); + + // Split 1 lands at idx 1 (bootstrap is at idx 0); Alice migrates. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(m1)); + vm.warp(2000); + vault.publicUpdate(ALICE, ALICE, 0); + _assertPotInvariant(0, _single(BOB)); + _assertPotInvariant(1, _single(ALICE)); + + // Mint Carol post-split-1. + vault.publicUpdate(address(0), CAROL, carolMint); + _assertPotInvariant(0, _single(BOB)); + _assertPotInvariant(1, _pair(ALICE, CAROL)); + + // Split 2 lands at idx 2; Alice migrates again. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(m2)); + vm.warp(3000); + vault.publicUpdate(ALICE, ALICE, 0); + _assertPotInvariant(0, _single(BOB)); + _assertPotInvariant(1, _single(CAROL)); + _assertPotInvariant(2, _single(ALICE)); + + // Alice -> Bob transfer. Both migrate: Alice at 2 (no-op), Bob 0 -> 2. + uint256 aliceView = vault.balanceOf(ALICE); + uint256 transferAmt = aliceView == 0 ? 0 : uint256(transferRaw) % (aliceView + 1); + vault.publicUpdate(ALICE, BOB, transferAmt); + _assertPotInvariant(0, empty); + _assertPotInvariant(1, _single(CAROL)); + _assertPotInvariant(2, _pair(ALICE, BOB)); + + // Burn from Bob. + uint256 bobView = vault.balanceOf(BOB); + uint256 burnAmt = bobView == 0 ? 0 : uint256(burnRaw) % (bobView + 1); + vault.publicUpdate(BOB, address(0), burnAmt); + _assertPotInvariant(0, empty); + _assertPotInvariant(1, _single(CAROL)); + _assertPotInvariant(2, _pair(ALICE, BOB)); + } + + /// Fuzzed convergence invariant: for any initial balance and any sequence + /// of stock splits, the view `balanceOf` (pre-migration) must equal the + /// rasterized stored balance after `_update`-driven migration. This + /// guards the core property that external reads and post-rasterization + /// stored state agree — if they diverge, transfers could use different + /// balances than what the view reports. + function testFuzzConvergenceViewMatchesStoredAfterMigration(uint64 initialBalance, uint8 numSplits, uint8 seed) + external + { + initialBalance = uint64(bound(initialBalance, 1, type(uint32).max)); + numSplits = uint8(bound(numSplits, 0, 8)); + + vault.publicUpdate(address(0), BOB, uint256(initialBalance)); + + // Schedule alternating forward and reverse splits. + for (uint256 i = 0; i < numSplits; i++) { + // Alternate 2x and 1/2x based on i bit 0; use seed to vary starting + // direction across fuzz runs. + bool forward = ((i ^ seed) & 1) == 0; + Float multiplier = forward + ? LibDecimalFloat.packLossless(2, 0) + : LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + bytes memory params = LibStockSplit.encodeParametersV1(multiplier); + // forge-lint: disable-next-line(unsafe-typecast) + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, uint64(1001 + i * 100), params); + } + + if (numSplits > 0) { + // forge-lint: disable-next-line(unsafe-typecast) + vm.warp(uint64(1001 + uint256(numSplits) * 100 + 1)); + } + + // Snapshot view balance BEFORE migration. + uint256 viewBefore = vault.balanceOf(BOB); + + // Force migration via a self-touch. + vault.publicUpdate(BOB, BOB, 0); + + // View AFTER migration must still match (idempotence). + uint256 viewAfter = vault.balanceOf(BOB); + uint256 stored = vault.rawStoredBalance(BOB); + + assertEq(viewBefore, viewAfter, "view balance must not change across migration"); + assertEq(viewAfter, stored, "view and stored balance must converge"); + } + + /// After every holder has migrated through every completed split, the + /// aggregate pot overestimate from fractional-multiplier truncation + /// fully resolves: `totalSupply() == sum(balanceOf over all holders)` + /// exactly. Before full migration the equality holds as an upper bound + /// (tested elsewhere); this test pins the exact-equality convergence. + function testFuzzMultiAccountConvergenceAfterFullMigration( + uint32 aliceInit, + uint32 bobInit, + uint32 carolInit, + uint8 numSplits, + uint8 seed + ) external { + aliceInit = uint32(bound(aliceInit, 1, type(uint32).max / 256)); + bobInit = uint32(bound(bobInit, 1, type(uint32).max / 256)); + carolInit = uint32(bound(carolInit, 0, type(uint32).max / 256)); + numSplits = uint8(bound(numSplits, 0, 6)); + + vault.publicUpdate(address(0), ALICE, uint256(aliceInit)); + vault.publicUpdate(address(0), BOB, uint256(bobInit)); + if (carolInit > 0) vault.publicUpdate(address(0), CAROL, uint256(carolInit)); + + for (uint256 i = 0; i < numSplits; i++) { + bool forward = ((i ^ seed) & 1) == 0; + Float multiplier = forward + ? LibDecimalFloat.packLossless(2, 0) + : LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + // numSplits is bounded so 1001 + i * 100 fits easily in uint64. + // forge-lint: disable-next-line(unsafe-typecast) + uint64 effTime = uint64(1001 + i * 100); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, effTime, LibStockSplit.encodeParametersV1(multiplier)); + } + + if (numSplits > 0) { + // forge-lint: disable-next-line(unsafe-typecast) + vm.warp(uint64(1001 + uint256(numSplits) * 100 + 1)); + } + + // Migrate every holder through every completed split via self-touches. + vault.publicUpdate(ALICE, ALICE, 0); + vault.publicUpdate(BOB, BOB, 0); + if (carolInit > 0) vault.publicUpdate(CAROL, CAROL, 0); + + uint256 sum = vault.balanceOf(ALICE) + vault.balanceOf(BOB) + vault.balanceOf(CAROL); + assertEq(vault.totalSupply(), sum, "post-full-migration: totalSupply must equal sum(balanceOf) exactly"); + } + + /// Fuzzed convergence across two accounts migrated at different points: + /// Alice migrates after split 1, Bob migrates after splits 1 and 2. Their + /// balances computed from different migration paths must still match + /// their view values and their stored values after full migration. + function testFuzzTwoAccountDifferentMigrationPathsConverge(uint32 aliceInit, uint32 bobInit) external { + aliceInit = uint32(bound(aliceInit, 1, type(uint32).max)); + bobInit = uint32(bound(bobInit, 1, type(uint32).max)); + + vault.publicUpdate(address(0), ALICE, uint256(aliceInit)); + vault.publicUpdate(address(0), BOB, uint256(bobInit)); + + // Split 1: 2x at t=1500. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(1600); + + // Alice touches after split 1 (migrates partially). + vault.publicUpdate(ALICE, ALICE, 0); + + // Split 2: 1/2x at t=2000. + Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2000, LibStockSplit.encodeParametersV1(halfX)); + vm.warp(2100); + + // Both view balances before final migration. + uint256 aliceView = vault.balanceOf(ALICE); + uint256 bobView = vault.balanceOf(BOB); + + // Both touched — full migration. + vault.publicUpdate(ALICE, ALICE, 0); + vault.publicUpdate(BOB, BOB, 0); + + // Convergence: view matches stored for both accounts. + assertEq(aliceView, vault.rawStoredBalance(ALICE), "alice view matches stored"); + assertEq(bobView, vault.rawStoredBalance(BOB), "bob view matches stored"); + // And the post-migration view equals the stored (idempotence). + assertEq(vault.balanceOf(ALICE), vault.rawStoredBalance(ALICE)); + assertEq(vault.balanceOf(BOB), vault.rawStoredBalance(BOB)); + } + + /// Fuzzed idempotency: after an initial migration through an arbitrary + /// split chain, repeated touches with no new splits must not change + /// cursor, stored balance, or view balance, and must not re-emit + /// `AccountMigrated`. Guards the `newCursor == currentCursor` early + /// return in `migrateAccount`. + function testFuzzMigrationIdempotentAcrossRepeatedTouches( + uint32 initialBalance, + uint8 numSplits, + uint8 seed, + uint8 touchCount + ) external { + initialBalance = uint32(bound(initialBalance, 1, type(uint32).max)); + numSplits = uint8(bound(numSplits, 0, 8)); + touchCount = uint8(bound(touchCount, 1, 10)); + + vault.publicUpdate(address(0), BOB, uint256(initialBalance)); + + for (uint256 i = 0; i < numSplits; i++) { + bool forward = ((i ^ seed) & 1) == 0; + Float multiplier = forward + ? LibDecimalFloat.packLossless(2, 0) + : LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + bytes memory params = LibStockSplit.encodeParametersV1(multiplier); + // forge-lint: disable-next-line(unsafe-typecast) + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, uint64(1001 + i * 100), params); + } + + if (numSplits > 0) { + // forge-lint: disable-next-line(unsafe-typecast) + vm.warp(uint64(1001 + uint256(numSplits) * 100 + 1)); + } + + // First touch triggers the migration. + vault.publicUpdate(BOB, BOB, 0); + + uint256 cursorAfterFirst = vault.migrationCursor(BOB); + uint256 storedAfterFirst = vault.rawStoredBalance(BOB); + uint256 viewAfterFirst = vault.balanceOf(BOB); + + // Repeated touches must be idempotent AND must not re-emit. + bytes32 migratedSig = StoxReceiptVault.AccountMigrated.selector; + for (uint256 i = 0; i < touchCount; i++) { + vm.recordLogs(); + vault.publicUpdate(BOB, BOB, 0); + Vm.Log[] memory logs = vm.getRecordedLogs(); + for (uint256 j = 0; j < logs.length; j++) { + if (logs[j].topics.length > 0 && logs[j].topics[0] == migratedSig) { + fail(); + } + } + assertEq(vault.migrationCursor(BOB), cursorAfterFirst, "cursor unchanged across idempotent touches"); + assertEq(vault.rawStoredBalance(BOB), storedAfterFirst, "stored unchanged across idempotent touches"); + assertEq(vault.balanceOf(BOB), viewAfterFirst, "view unchanged across idempotent touches"); + } + } + + /// Transfers between two distinct accounts, interleaved with multiple + /// forward and reverse splits in a single flow. At each transfer, both + /// `from` and `to` must migrate before the raw balance change lands, + /// otherwise the transfer arithmetic operates on pre-rebase balances and + /// inflates / deflates incorrectly. Numbers below are exact. + function testInterleavedTransfersAndSplits() external { + // Initial: Alice 100, Bob 50 (pre-split basis). + vault.publicUpdate(address(0), ALICE, 100); + vault.publicUpdate(address(0), BOB, 50); + + // Split 1: 2x at t=1500. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(1600); + + // Transfer Alice → Bob, 30. Both migrate first: + // Alice: 100 → 200, then -30 = 170 + // Bob: 50 → 100, then +30 = 130 + vault.publicUpdate(ALICE, BOB, 30); + assertEq(vault.balanceOf(ALICE), 170, "alice after t1"); + assertEq(vault.balanceOf(BOB), 130, "bob after t1"); + + // Split 2: 1/2x at t=2000. + Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2000, LibStockSplit.encodeParametersV1(halfX)); + vm.warp(2100); + + // Transfer Bob → Alice, 40. Both migrate first: + // Bob: 130 → 65, then -40 = 25 + // Alice: 170 → 85, then +40 = 125 + vault.publicUpdate(BOB, ALICE, 40); + assertEq(vault.balanceOf(ALICE), 125, "alice after t2"); + assertEq(vault.balanceOf(BOB), 25, "bob after t2"); + + // Split 3: 3x at t=2500. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); + vm.warp(2600); + + // Transfer Alice → Bob, 60. Both migrate first: + // Alice: 125 → 375, then -60 = 315 + // Bob: 25 → 75, then +60 = 135 + vault.publicUpdate(ALICE, BOB, 60); + assertEq(vault.balanceOf(ALICE), 315, "alice after t3"); + assertEq(vault.balanceOf(BOB), 135, "bob after t3"); + + // Stored balances equal view balances (post-migration invariant). + assertEq(vault.rawStoredBalance(ALICE), 315, "alice stored = view"); + assertEq(vault.rawStoredBalance(BOB), 135, "bob stored = view"); + } + + /// Fuzzed interleaved transfers + splits. After each transfer, stored + /// balances must equal view balances for both parties (migration is + /// eager on both `from` and `to`), and the pairwise conservation + /// invariant must hold: the net balance delta equals the transfer + /// amount minus any truncation from the rebase that landed between + /// transfers. + function testFuzzInterleavedTransfersAndSplits( + uint32 aliceInit, + uint32 bobInit, + uint64 amount1, + uint64 amount2, + uint64 amount3 + ) external { + aliceInit = uint32(bound(aliceInit, 1000, type(uint32).max)); + bobInit = uint32(bound(bobInit, 1000, type(uint32).max)); + + vault.publicUpdate(address(0), ALICE, uint256(aliceInit)); + vault.publicUpdate(address(0), BOB, uint256(bobInit)); + + // Split 1: 2x. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(1600); + + // Transfer Alice → Bob. `balanceOf` already returns the post-rebase + // value, which is exactly what migration will write for Alice's + // stored balance inside `_update`. Bound the amount by that. + amount1 = uint64(bound(amount1, 0, vault.balanceOf(ALICE))); + vault.publicUpdate(ALICE, BOB, uint256(amount1)); + assertEq(vault.balanceOf(ALICE), vault.rawStoredBalance(ALICE), "alice view=stored after t1"); + assertEq(vault.balanceOf(BOB), vault.rawStoredBalance(BOB), "bob view=stored after t1"); + + // Split 2: 1/2x. + Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2000, LibStockSplit.encodeParametersV1(halfX)); + vm.warp(2100); + + // Transfer Bob → Alice. + amount2 = uint64(bound(amount2, 0, vault.balanceOf(BOB))); + vault.publicUpdate(BOB, ALICE, uint256(amount2)); + assertEq(vault.balanceOf(ALICE), vault.rawStoredBalance(ALICE), "alice view=stored after t2"); + assertEq(vault.balanceOf(BOB), vault.rawStoredBalance(BOB), "bob view=stored after t2"); + + // Split 3: 3x. + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); + vm.warp(2600); + + // Transfer Alice → Bob. + amount3 = uint64(bound(amount3, 0, vault.balanceOf(ALICE))); + vault.publicUpdate(ALICE, BOB, uint256(amount3)); + assertEq(vault.balanceOf(ALICE), vault.rawStoredBalance(ALICE), "alice view=stored after t3"); + assertEq(vault.balanceOf(BOB), vault.rawStoredBalance(BOB), "bob view=stored after t3"); + + // Final convergence: repeated idempotent touches don't change anything. + uint256 aliceFinal = vault.balanceOf(ALICE); + uint256 bobFinal = vault.balanceOf(BOB); + vault.publicUpdate(ALICE, ALICE, 0); + vault.publicUpdate(BOB, BOB, 0); + assertEq(vault.balanceOf(ALICE), aliceFinal, "alice idempotent after final"); + assertEq(vault.balanceOf(BOB), bobFinal, "bob idempotent after final"); + } + + uint256 internal constant OP_MINT = 0; + uint256 internal constant OP_BURN = 1; + uint256 internal constant OP_TRANSFER_OUT = 2; + uint256 internal constant OP_SELF_TRANSFER = 3; + uint256 internal constant OP_COUNT = 4; + + /// Three deterministic regression tests below pin the invariants that + /// `StoxReceiptVault.migrateAccount`'s `if (account == address(0)) return;` + /// short-circuit preserves. The skip is sound only because OZ + /// `ERC20Upgradeable` routes mints/burns through `_totalSupply`, never + /// through `_balances[address(0)]` — if a future refactor (or a new facet) + /// writes to that slot, advances the zero-address cursor, or emits a + /// migration event for it, the corresponding test below fires. + /// + /// Each invariant lives in its own test so a mutation maps 1:1 to a + /// failing test — combining them would mean a single failure could mask + /// which property was actually broken. + /// + /// All three drive the same fixed mint/burn/transfer/split sequence so + /// the path under mutation is identical across the three. + + function testZeroAddressBalanceSlotStaysZero() external { + _driveZeroAddressSequence(); + assertEq(vault.rawStoredBalance(address(0)), 0, "address(0) slot non-zero"); + } + + function testZeroAddressCursorStaysZero() external { + _driveZeroAddressSequence(); + assertEq(vault.migrationCursor(address(0)), 0, "address(0) cursor advanced"); + } + + function testNoAccountMigratedEventForZeroAddress() external { + vm.recordLogs(); + _driveZeroAddressSequence(); + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 sig = keccak256("AccountMigrated(address,uint256,uint256,uint256,uint256)"); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length > 1 && logs[i].topics[0] == sig) { + address account = address(uint160(uint256(logs[i].topics[1]))); + assertTrue(account != address(0), "AccountMigrated for address(0)"); + } + } + } + + /// Fixed mint/burn/transfer/split sequence shared by the three + /// deterministic invariant tests above. Touches every `_update` path that + /// could plausibly interact with the zero address: mint, burn, + /// post-bootstrap mint, post-bootstrap burn, post-second-split transfer, + /// final burn. + function _driveZeroAddressSequence() internal { + vault.publicUpdate(address(0), ALICE, 1000); + vault.publicUpdate(ALICE, address(0), 300); + + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 1500, _splitParams(2)); + vm.warp(2000); + + vault.publicUpdate(address(0), BOB, 500); + vault.publicUpdate(BOB, address(0), 200); + + vault.publicSchedule(ACTION_TYPE_STOCK_SPLIT_V1, 2500, _splitParams(3)); + vm.warp(3000); + + vault.publicUpdate(ALICE, BOB, 100); + vault.publicUpdate(BOB, address(0), 50); + } + + /// Fuzz coverage: random mint / burn / transfer / self-transfer ops + /// preserve all three zero-address invariants. Wider than the + /// deterministic tests but path-dependent — not the mutation-test target. + function testFuzzZeroAddressInvariantsHold(uint8 actionCount, uint256 seed) external { + actionCount = uint8(bound(actionCount, 1, 32)); + vm.recordLogs(); + + // Pre-seed Alice and Bob with enough headroom that random burns and + // transfers don't trivially revert. `ERC20InsufficientBalance` reverts + // are caught and treated as no-ops — the invariants are about + // address(0)'s slot, cursor, and event surface, all of which a revert + // leaves untouched. + vault.publicUpdate(address(0), ALICE, 1_000_000); + vault.publicUpdate(address(0), BOB, 1_000_000); + + for (uint256 i = 0; i < actionCount; i++) { + seed = uint256(keccak256(abi.encode(seed, i))); + uint256 op = seed % OP_COUNT; + uint256 amount = bound(seed >> 8, 1, 10_000); + address actor = (seed >> 16) & 1 == 0 ? ALICE : BOB; + address other = actor == ALICE ? BOB : ALICE; + + try this.driveUpdate(op, actor, other, amount) {} catch {} + + assertEq(vault.rawStoredBalance(address(0)), 0, "address(0) slot non-zero"); + assertEq(vault.migrationCursor(address(0)), 0, "address(0) cursor advanced"); + } + + Vm.Log[] memory logs = vm.getRecordedLogs(); + bytes32 sig = keccak256("AccountMigrated(address,uint256,uint256,uint256,uint256)"); + for (uint256 i = 0; i < logs.length; i++) { + if (logs[i].topics.length > 1 && logs[i].topics[0] == sig) { + address account = address(uint160(uint256(logs[i].topics[1]))); + assertTrue(account != address(0), "AccountMigrated for address(0)"); + } + } + } + + /// External wrapper so the fuzz loop can swallow `try`/`catch` reverts + /// (e.g. `ERC20InsufficientBalance` when a random burn exceeds balance). + function driveUpdate(uint256 op, address actor, address other, uint256 amount) external { + if (op == OP_MINT) { + vault.publicUpdate(address(0), actor, amount); + } else if (op == OP_BURN) { + vault.publicUpdate(actor, address(0), amount); + } else if (op == OP_TRANSFER_OUT) { + vault.publicUpdate(actor, other, amount); + } else if (op == OP_SELF_TRANSFER) { + vault.publicUpdate(actor, actor, amount); + } + } +} diff --git a/test/src/concrete/TestStoxReceipt.sol b/test/src/concrete/TestStoxReceipt.sol new file mode 100644 index 00000000..9217fc8f --- /dev/null +++ b/test/src/concrete/TestStoxReceipt.sol @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {StoxReceipt} from "../../../src/concrete/StoxReceipt.sol"; +import {ICorporateActionsV1} from "../../../src/interface/ICorporateActionsV1.sol"; +import {LibCorporateActionReceipt} from "../../../src/lib/LibCorporateActionReceipt.sol"; +import {LibERC1155Storage} from "../../../src/lib/LibERC1155Storage.sol"; + +/// @dev Test-only subclass that exposes an `initialize` path bypassing the +/// `initializer` modifier of the real `Receipt`, so tests can directly drive +/// a `StoxReceipt` against our mock. `publicManagerMint` / `publicManagerBurn` +/// go through the vault-as-manager path. +contract TestStoxReceipt is StoxReceipt { + function testInit(address vaultAddr) external { + // Bypass ethgild's `initializer` lock by writing the manager slot + // directly. We're initializing a fresh deployment in-test, so the + // one-shot initializer guard is irrelevant for our purposes. + bytes32 slot = 0xe5444a702a2f437387f4eb075af275e349f1dba9a68923d27352f035d01dc200; + assembly { + sstore(slot, vaultAddr) + } + } + + /// Expose direct storage read so tests can inspect the raw stored + /// balance (pre-rebase) without going through the `balanceOf` override. + function rawStoredBalance(address account, uint256 id) external view returns (uint256) { + return LibERC1155Storage.underlyingBalance(account, id); + } + + /// Expose the cursor for assertions. + function holderIdCursor(address account, uint256 id) external view returns (uint256) { + return LibCorporateActionReceipt.getStorage().accountIdCursor[account][id]; + } + + /// Expose internal migration so tests can exercise the zero-address + /// short-circuit directly. + function publicMigrateHolderId(address account, uint256 id) external { + migrateHolderId(account, id, ICorporateActionsV1(this.manager())); + } +} diff --git a/test/src/concrete/TestStoxReceiptVault.sol b/test/src/concrete/TestStoxReceiptVault.sol new file mode 100644 index 00000000..edbed5fa --- /dev/null +++ b/test/src/concrete/TestStoxReceiptVault.sol @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {StoxReceiptVault} from "../../../src/concrete/StoxReceiptVault.sol"; +import {ERC20Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC20/ERC20Upgradeable.sol"; +import {LibCorporateAction} from "../../../src/lib/LibCorporateAction.sol"; +import {LibERC20Storage} from "../../../src/lib/LibERC20Storage.sol"; +import {LibTotalSupply} from "../../../src/lib/LibTotalSupply.sol"; + +/// @dev Test-only subclass of StoxReceiptVault that bypasses +/// `OffchainAssetReceiptVault._update`'s authorizer / freeze checks. This lets +/// us exercise `StoxReceiptVault`'s migration logic in isolation without +/// standing up the full rain.vats auth/freeze infrastructure (admin grants, +/// authorizer wiring, certify state, etc). +/// +/// The test class re-overrides `_update` to call `migrateAccount` for both +/// sides and then call `ERC20Upgradeable._update` directly, skipping the +/// `OffchainAssetReceiptVault._update` middle layer. The migration semantics +/// being tested live entirely in `StoxReceiptVault` and the libraries it calls, +/// so the bypass is faithful for the purpose of these tests. +contract TestStoxReceiptVault is StoxReceiptVault { + function _update(address from, address to, uint256 amount) internal override { + // Mirror the production StoxReceiptVault._update flow exactly, only + // bypassing the OffchainAssetReceiptVault authorizer/freeze layer. + LibTotalSupply.fold(); + + migrateAccount(from); + migrateAccount(to); + + ERC20Upgradeable._update(from, to, amount); + + if (from == address(0)) { + LibTotalSupply.onMint(amount); + } else if (to == address(0)) { + LibTotalSupply.onBurn(amount); + } + } + + /// Expose ERC20 _update so tests can drive mints/burns/transfers without + /// going through the vault's deposit/withdraw flow (which has its own + /// initialization requirements). + function publicUpdate(address from, address to, uint256 amount) external { + _update(from, to, amount); + } + + /// Expose corporate-action scheduling so tests can set up split state + /// using this vault's storage namespace. + function publicSchedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) + external + returns (uint256) + { + return LibCorporateAction.schedule(actionType, effectiveTime, parameters); + } + + /// Expose corporate-action cancellation for tests that need to remove a + /// pending split before its effective time. + function publicCancel(uint256 actionIndex) external { + LibCorporateAction.cancel(actionIndex); + } + + function rawStoredBalance(address account) external view returns (uint256) { + return LibERC20Storage.underlyingBalance(account); + } + + function migrationCursor(address account) external view returns (uint256) { + return LibCorporateAction.getStorage().accountMigrationCursor[account]; + } + + function totalSupplyLatestCursor() external view returns (uint256) { + return LibCorporateAction.getStorage().totalSupplyLatestCursor; + } + + function unmigrated(uint256 cursor) external view returns (uint256) { + return LibCorporateAction.getStorage().unmigrated[cursor]; + } +} diff --git a/test/src/concrete/authorize/FailingSuperInitAuthorizer.sol b/test/src/concrete/authorize/FailingSuperInitAuthorizer.sol new file mode 100644 index 00000000..9c9d03ed --- /dev/null +++ b/test/src/concrete/authorize/FailingSuperInitAuthorizer.sol @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import { + OffchainAssetReceiptVaultAuthorizerV1Config +} from "rain-vats-0.1.6/src/concrete/authorize/OffchainAssetReceiptVaultAuthorizerV1.sol"; +import { + StoxOffchainAssetReceiptVaultAuthorizerV1 +} from "../../../../src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.sol"; + +/// @dev Overrides _initialize to return a non-success value, simulating +/// a parent initialization failure. +contract FailingSuperInitAuthorizer is StoxOffchainAssetReceiptVaultAuthorizerV1 { + bytes32 public constant FAILURE_SENTINEL = bytes32(uint256(1)); + + function _initialize(OffchainAssetReceiptVaultAuthorizerV1Config memory) internal pure override returns (bytes32) { + return FAILURE_SENTINEL; + } +} diff --git a/test/src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.initializeGuard.t.sol b/test/src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.initializeGuard.t.sol index 38f9db98..d5fe91d4 100644 --- a/test/src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.initializeGuard.t.sol +++ b/test/src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.initializeGuard.t.sol @@ -9,22 +9,12 @@ import { OffchainAssetReceiptVaultAuthorizerV1Config } from "rain-vats-0.1.6/src/concrete/authorize/OffchainAssetReceiptVaultAuthorizerV1.sol"; import { - StoxOffchainAssetReceiptVaultAuthorizerV1, SCHEDULE_CORPORATE_ACTION_ADMIN, CANCEL_CORPORATE_ACTION_ADMIN } from "../../../../src/concrete/authorize/StoxOffchainAssetReceiptVaultAuthorizerV1.sol"; +import {FailingSuperInitAuthorizer} from "./FailingSuperInitAuthorizer.sol"; import {ICLONEABLE_V2_SUCCESS} from "rain-factory-0.1.1/src/interface/ICloneableV2.sol"; -/// @dev Overrides _initialize to return a non-success value, simulating -/// a parent initialization failure. -contract FailingSuperInitAuthorizer is StoxOffchainAssetReceiptVaultAuthorizerV1 { - bytes32 public constant FAILURE_SENTINEL = bytes32(uint256(1)); - - function _initialize(OffchainAssetReceiptVaultAuthorizerV1Config memory) internal pure override returns (bytes32) { - return FAILURE_SENTINEL; - } -} - contract StoxOffchainAssetReceiptVaultAuthorizerV1InitializeGuardTest is Test { address constant ADMIN = address(uint160(uint256(keccak256("ADMIN")))); diff --git a/test/src/lib/CallerRecorder.sol b/test/src/lib/CallerRecorder.sol new file mode 100644 index 00000000..3de08955 --- /dev/null +++ b/test/src/lib/CallerRecorder.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +/// @notice Stub contract used by `testSimulateExternalCallPrankRoutes` to +/// capture the caller address of an external call. Kept inline because it +/// is single-use and trivially small. +contract CallerRecorder { + /// @notice The most recent `msg.sender` to call `ping`. + address public lastCaller; + + /// @notice Records the caller address. No return value; the recording + /// is the side effect under test. + function ping() external { + lastCaller = msg.sender; + } +} diff --git a/test/src/lib/LibCorporateActionNode.t.sol b/test/src/lib/LibCorporateActionNode.t.sol index b7a27490..b152809f 100644 --- a/test/src/lib/LibCorporateActionNode.t.sol +++ b/test/src/lib/LibCorporateActionNode.t.sol @@ -3,20 +3,14 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {LibCorporateAction} from "src/lib/LibCorporateAction.sol"; +import {TraversalHarness} from "./TraversalHarness.sol"; import { - ACTION_TYPE_INIT_V1, ACTION_TYPE_STOCK_SPLIT_V1, ACTION_TYPE_STABLES_DIVIDEND_V1, VALID_ACTION_TYPES_MASK } from "src/interface/ICorporateActionsV1.sol"; -import { - CompletionFilter, - CorporateActionNode, - LibCorporateActionNode, - NODE_NONE -} from "src/lib/LibCorporateActionNode.sol"; +import {CompletionFilter, NODE_NONE} from "src/lib/LibCorporateActionNode.sol"; import {InvalidMask} from "src/error/ErrCorporateAction.sol"; /// @dev Mask covering every test-scheduled action type — `STOCK_SPLIT_V1` and @@ -29,49 +23,6 @@ import {InvalidMask} from "src/error/ErrCorporateAction.sol"; /// do exercise the bootstrap node via `BALANCE_MIGRATION_TYPES_MASK`. uint256 constant USER_TYPES_TEST_MASK = ACTION_TYPE_STOCK_SPLIT_V1 | ACTION_TYPE_STABLES_DIVIDEND_V1; -/// @dev Thin harness: exposes the four tuple-returning traversal getters via -/// external calls so the library functions can be exercised directly (not -/// through the facet). Also schedules actions into the harness's own storage -/// namespace so there is no ambient state between tests. -contract TraversalHarness { - function schedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { - return LibCorporateAction.schedule(actionType, effectiveTime, parameters); - } - - function cancel(uint256 actionIndex) external { - LibCorporateAction.cancel(actionIndex); - } - - function latest(uint256 mask, CompletionFilter filter) external view returns (uint256, uint256, uint64) { - return LibCorporateActionNode.latestActionOfType(mask, filter); - } - - function earliest(uint256 mask, CompletionFilter filter) external view returns (uint256, uint256, uint64) { - return LibCorporateActionNode.earliestActionOfType(mask, filter); - } - - function nextOf(uint256 cursor, uint256 mask, CompletionFilter filter) - external - view - returns (uint256, uint256, uint64) - { - return LibCorporateActionNode.nextActionOfType(cursor, mask, filter); - } - - function prevOf(uint256 cursor, uint256 mask, CompletionFilter filter) - external - view - returns (uint256, uint256, uint64) - { - return LibCorporateActionNode.prevActionOfType(cursor, mask, filter); - } - - function nodeAt(uint256 index) external view returns (uint256, uint64) { - CorporateActionNode storage node = LibCorporateAction.getStorage().nodes[index]; - return (node.actionType, node.effectiveTime); - } -} - contract LibCorporateActionNodeTest is Test { TraversalHarness internal h; diff --git a/test/src/lib/LibERC1155Storage.t.sol b/test/src/lib/LibERC1155Storage.t.sol index 496b324d..ead20951 100644 --- a/test/src/lib/LibERC1155Storage.t.sol +++ b/test/src/lib/LibERC1155Storage.t.sol @@ -3,35 +3,8 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {ERC1155Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC1155/ERC1155Upgradeable.sol"; -import {LibERC1155Storage, ERC1155_STORAGE_LOCATION} from "src/lib/LibERC1155Storage.sol"; - -/// @dev A minimal `ERC1155Upgradeable` subclass that exposes `_mint` / `_burn` -/// and the `LibERC1155Storage` helpers as external methods. The library uses -/// `internal` functions so they get inlined into this contract and read / -/// write its own storage at the OZ ERC-7201 namespaced slot — exactly the -/// invariant being tested. -contract TestERC1155 is ERC1155Upgradeable { - constructor() initializer { - __ERC1155_init(""); - } - - function mint(address to, uint256 id, uint256 amount) external { - _mint(to, id, amount, ""); - } - - function burn(address from, uint256 id, uint256 amount) external { - _burn(from, id, amount); - } - - function libBalanceOf(address account, uint256 id) external view returns (uint256) { - return LibERC1155Storage.underlyingBalance(account, id); - } - - function libSetBalance(address account, uint256 id, uint256 newBalance) external { - LibERC1155Storage.setUnderlyingBalance(account, id, newBalance); - } -} +import {TestERC1155} from "./TestERC1155.sol"; +import {ERC1155_STORAGE_LOCATION} from "src/lib/LibERC1155Storage.sol"; /// @dev Drift-detection tests for `LibERC1155Storage`. Every assertion is /// grounded against `ERC1155Upgradeable` running on the same test contract: diff --git a/test/src/lib/LibERC20Storage.t.sol b/test/src/lib/LibERC20Storage.t.sol index 3aa01107..fd02f4ba 100644 --- a/test/src/lib/LibERC20Storage.t.sol +++ b/test/src/lib/LibERC20Storage.t.sol @@ -3,39 +3,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {ERC20Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC20/ERC20Upgradeable.sol"; -import {LibERC20Storage} from "src/lib/LibERC20Storage.sol"; - -/// @dev A minimal `ERC20Upgradeable` subclass that exposes `_mint` / `_burn` -/// and the `LibERC20Storage` helpers as external methods. The library uses -/// `internal` functions so they get inlined into this contract and read / -/// write its own storage at the OZ ERC-7201 namespaced slot — exactly the -/// invariant being tested. -contract TestERC20 is ERC20Upgradeable { - constructor() initializer { - __ERC20_init("Test", "TST"); - } - - function mint(address to, uint256 amount) external { - _mint(to, amount); - } - - function burn(address from, uint256 amount) external { - _burn(from, amount); - } - - function libBalanceOf(address account) external view returns (uint256) { - return LibERC20Storage.underlyingBalance(account); - } - - function libTotalSupply() external view returns (uint256) { - return LibERC20Storage.underlyingTotalSupply(); - } - - function libSetBalance(address account, uint256 newBalance) external { - LibERC20Storage.setUnderlyingBalance(account, newBalance); - } -} +import {TestERC20} from "./TestERC20.sol"; /// @dev Regression / drift-detection tests for `LibERC20Storage`. The library /// reads and writes OZ `ERC20Upgradeable` storage at hardcoded ERC-7201 slot diff --git a/test/src/lib/LibRebase.t.sol b/test/src/lib/LibRebase.t.sol index d7507f8d..11e5103d 100644 --- a/test/src/lib/LibRebase.t.sol +++ b/test/src/lib/LibRebase.t.sol @@ -4,21 +4,10 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; -import {LibRebase} from "src/lib/LibRebase.sol"; -import {LibCorporateAction} from "src/lib/LibCorporateAction.sol"; +import {LibRebaseHarness} from "./LibRebaseHarness.sol"; import {ACTION_TYPE_STOCK_SPLIT_V1} from "src/interface/ICorporateActionsV1.sol"; import {LibStockSplit} from "src/lib/LibStockSplit.sol"; -contract LibRebaseHarness { - function schedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { - return LibCorporateAction.schedule(actionType, effectiveTime, parameters); - } - - function migratedBalance(uint256 storedBalance, uint256 cursor) external view returns (uint256, uint256) { - return LibRebase.migratedBalance(storedBalance, cursor); - } -} - contract LibRebaseTest is Test { LibRebaseHarness internal h; diff --git a/test/src/lib/LibRebaseHarness.sol b/test/src/lib/LibRebaseHarness.sol new file mode 100644 index 00000000..ecbd6e90 --- /dev/null +++ b/test/src/lib/LibRebaseHarness.sol @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibRebase} from "src/lib/LibRebase.sol"; +import {LibCorporateAction} from "src/lib/LibCorporateAction.sol"; + +contract LibRebaseHarness { + function schedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { + return LibCorporateAction.schedule(actionType, effectiveTime, parameters); + } + + function migratedBalance(uint256 storedBalance, uint256 cursor) external view returns (uint256, uint256) { + return LibRebase.migratedBalance(storedBalance, cursor); + } +} diff --git a/test/src/lib/LibRebaseMath.t.sol b/test/src/lib/LibRebaseMath.t.sol index ac513cdb..400c1417 100644 --- a/test/src/lib/LibRebaseMath.t.sol +++ b/test/src/lib/LibRebaseMath.t.sol @@ -4,15 +4,9 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; -import {LibRebaseMath} from "src/lib/LibRebaseMath.sol"; +import {LibRebaseMathHarness} from "./LibRebaseMathHarness.sol"; import {BalanceExceedsInt256Max} from "src/error/ErrRebase.sol"; -contract LibRebaseMathHarness { - function applyMultiplier(uint256 balance, Float multiplier) external pure returns (uint256) { - return LibRebaseMath.applyMultiplier(balance, multiplier); - } -} - contract LibRebaseMathTest is Test { LibRebaseMathHarness internal h; diff --git a/test/src/lib/LibRebaseMathHarness.sol b/test/src/lib/LibRebaseMathHarness.sol new file mode 100644 index 00000000..ed925b7c --- /dev/null +++ b/test/src/lib/LibRebaseMathHarness.sol @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Float} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import {LibRebaseMath} from "src/lib/LibRebaseMath.sol"; + +contract LibRebaseMathHarness { + function applyMultiplier(uint256 balance, Float multiplier) external pure returns (uint256) { + return LibRebaseMath.applyMultiplier(balance, multiplier); + } +} diff --git a/test/src/lib/LibReceiptRebase.t.sol b/test/src/lib/LibReceiptRebase.t.sol index 67d3a823..2613049a 100644 --- a/test/src/lib/LibReceiptRebase.t.sol +++ b/test/src/lib/LibReceiptRebase.t.sol @@ -5,104 +5,8 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; import {LibReceiptRebase} from "src/lib/LibReceiptRebase.sol"; -import { - ICorporateActionsV1, - ACTION_TYPE_STOCK_SPLIT_V1, - BALANCE_MIGRATION_TYPES_MASK -} from "src/interface/ICorporateActionsV1.sol"; -import {CompletionFilter, NODE_NONE} from "src/lib/LibCorporateActionNode.sol"; - -/// @dev Mock vault exposing only the subset of `ICorporateActionsV1` that -/// `LibReceiptRebase` consumes (`nextOfType` + `getActionParameters`). Tests -/// preload the mock with a list of completed stock split multipliers, and -/// the receipt rebase walks it exactly as if it were a real vault. -/// -/// The mock assigns cursor indices 1..n to the preloaded multipliers, -/// mirroring the vault's storage layout post-bootstrap: index 0 is the -/// real bootstrap node (which the mock does not model — receipt rebase -/// treats it as identity), indices 1..n are the stock splits in -/// effective-time order. `nextOfType` returns the next index; -/// `getActionParameters` returns the stored bytes. `NODE_NONE` is the -/// "no more nodes" sentinel matching the real vault's contract. -contract MockCorporateActionsVault is ICorporateActionsV1 { - bytes[] internal splits; // splits[i-1] is the parameters blob for cursor i - - function addSplit(Float multiplier) external { - splits.push(abi.encode(multiplier)); - } - - function addSplitRaw(bytes memory parameters) external { - splits.push(parameters); - } - - function splitCount() external view returns (uint256) { - return splits.length; - } - - // ----------------------------------------------------------------------- - // ICorporateActionsV1 — only the bits LibReceiptRebase calls - - function nextOfType(uint256 cursor, uint256 mask, CompletionFilter filter) - external - view - override - returns (uint256, uint256, uint64) - { - // Receipt rebase walks `BALANCE_MIGRATION_TYPES_MASK` (init | - // stock-split). This mock holds only splits — no init node — so - // walking that mask returns the same sequence as walking the - // stock-split bit alone. Pin the mask to the production mask; - // any other request fails loud. - require(mask == BALANCE_MIGRATION_TYPES_MASK, "mock: unexpected mask"); - require(filter == CompletionFilter.COMPLETED, "mock: unexpected filter"); - - // Cursor convention matches the real vault post-bootstrap: 0 is - // the bootstrap node (identity, not modelled by this mock); splits - // live at 1..splits.length. The walk hops from `cursor` to - // `cursor + 1`, returning `NODE_NONE` once the next index would - // run off the end. - if (cursor == NODE_NONE) { - // Receipt rebase callers never pass NODE_NONE — they always - // pass the receipt-side cursor — but keep the contract honest. - return (splits.length == 0 ? NODE_NONE : 1, ACTION_TYPE_STOCK_SPLIT_V1, 1); - } - uint256 candidate = cursor + 1; - if (candidate > splits.length) { - return (NODE_NONE, 0, 0); - } - return (candidate, ACTION_TYPE_STOCK_SPLIT_V1, 1); - } - - function getActionParameters(uint256 cursor) external view override returns (bytes memory) { - require(cursor >= 1 && cursor <= splits.length, "mock: cursor out of range"); - return splits[cursor - 1]; - } - - // Unused ICorporateActionsV1 surface — revert to surface misuse. - function scheduleCorporateAction(bytes32, uint64, bytes calldata) external pure override returns (uint256) { - revert("mock: not implemented"); - } - - function cancelCorporateAction(uint256) external pure override { - revert("mock: not implemented"); - } - - function completedActionCount() external view override returns (uint256) { - return splits.length; - } - - function latestActionOfType(uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { - revert("mock: not implemented"); - } - - function earliestActionOfType(uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { - revert("mock: not implemented"); - } - - function prevOfType(uint256, uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { - revert("mock: not implemented"); - } -} +import {ICorporateActionsV1} from "src/interface/ICorporateActionsV1.sol"; +import {MockCorporateActionsVault} from "./MockCorporateActionsVault.sol"; /// @dev Test suite for `LibReceiptRebase.migratedBalance`. Mirrors /// `LibRebase.t.sol` structure so the two sides stay in lockstep. diff --git a/test/src/lib/LibSafeInvariants.t.sol b/test/src/lib/LibSafeInvariants.t.sol index 93c4a1f0..4c970e1a 100644 --- a/test/src/lib/LibSafeInvariants.t.sol +++ b/test/src/lib/LibSafeInvariants.t.sol @@ -4,6 +4,7 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; +import {LibSafeInvariantsHarness} from "./LibSafeInvariantsHarness.sol"; import {LibProdSafes} from "../../../src/lib/LibProdSafes.sol"; import {LibProdTokensBase} from "../../../src/lib/LibProdTokensBase.sol"; import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; @@ -23,33 +24,6 @@ import { SafeThresholdMismatch } from "../../../src/lib/LibSafeInvariants.sol"; -/// @title LibSafeInvariantsHarness -/// @notice External-call shim around the internal library so -/// `vm.expectRevert` can intercept the typed errors. `vm.expectRevert` only -/// catches reverts from external calls; library `internal` functions inline -/// and would fail the depth check otherwise. -contract LibSafeInvariantsHarness { - function callAssertImmutableInvariants(IGnosisSafe safe) external view { - LibSafeInvariants.assertImmutableInvariants(safe); - } - - function callAssertOwnerSet(IGnosisSafe safe, address[] memory expected) external view { - LibSafeInvariants.assertOwnerSet(safe, expected); - } - - function callAssertThreshold(IGnosisSafe safe, uint256 expected) external view { - LibSafeInvariants.assertThreshold(safe, expected); - } - - function callAssertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwners) external view { - LibSafeInvariants.assertAll(safe, expectedThreshold, expectedOwners); - } - - function callAssertAllDefaults(IGnosisSafe safe) external view { - LibSafeInvariants.assertAll(safe); - } -} - /// @title LibSafeInvariantsTest /// @notice Inverted fork tests that exercise each invariant in /// `LibSafeInvariants` by injecting drift via `vm.etch` / `vm.mockCall` / diff --git a/test/src/lib/LibSafeInvariantsHarness.sol b/test/src/lib/LibSafeInvariantsHarness.sol new file mode 100644 index 00000000..f4251553 --- /dev/null +++ b/test/src/lib/LibSafeInvariantsHarness.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibSafeInvariants} from "../../../src/lib/LibSafeInvariants.sol"; +import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; + +/// @title LibSafeInvariantsHarness +/// @notice External-call shim around the internal library so +/// `vm.expectRevert` can intercept the typed errors. `vm.expectRevert` only +/// catches reverts from external calls; library `internal` functions inline +/// and would fail the depth check otherwise. +contract LibSafeInvariantsHarness { + function callAssertImmutableInvariants(IGnosisSafe safe) external view { + LibSafeInvariants.assertImmutableInvariants(safe); + } + + function callAssertOwnerSet(IGnosisSafe safe, address[] memory expected) external view { + LibSafeInvariants.assertOwnerSet(safe, expected); + } + + function callAssertThreshold(IGnosisSafe safe, uint256 expected) external view { + LibSafeInvariants.assertThreshold(safe, expected); + } + + function callAssertAll(IGnosisSafe safe, uint256 expectedThreshold, address[] memory expectedOwners) external view { + LibSafeInvariants.assertAll(safe, expectedThreshold, expectedOwners); + } + + function callAssertAllDefaults(IGnosisSafe safe) external view { + LibSafeInvariants.assertAll(safe); + } +} diff --git a/test/src/lib/LibSafeOps.t.sol b/test/src/lib/LibSafeOps.t.sol index 7b2e8aeb..604af265 100644 --- a/test/src/lib/LibSafeOps.t.sol +++ b/test/src/lib/LibSafeOps.t.sol @@ -7,6 +7,10 @@ import {LibSafeOps, SafeTx, TxBuilderJsonNoTransactions} from "../../../src/lib/ import {LibProdSafes} from "../../../src/lib/LibProdSafes.sol"; import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; import {LibRainDeploy} from "rain-deploy-0.1.3/src/lib/LibRainDeploy.sol"; +import {CallerRecorder} from "./CallerRecorder.sol"; +import {ParseHarness} from "./ParseHarness.sol"; +import {NPlus1Harness} from "./NPlus1Harness.sol"; +import {PackHarness} from "./PackHarness.sol"; /// @title LibSafeOpsTest /// @notice Live fork tests for `LibSafeOps`: cross-checks the local hash @@ -288,46 +292,3 @@ contract LibSafeOpsTest is Test { } } -/// @notice Stub contract used by `testSimulateExternalCallPrankRoutes` to -/// capture the caller address of an external call. Kept inline because it -/// is single-use and trivially small. -contract CallerRecorder { - /// @notice The most recent `msg.sender` to call `ping`. - address public lastCaller; - - /// @notice Records the caller address. No return value; the recording - /// is the side effect under test. - function ping() external { - lastCaller = msg.sender; - } -} - -/// @notice External-call harness around `LibSafeOps.parseTxBuilderJson` so -/// `vm.expectRevert` can catch the typed error. `expectRevert` only sees -/// reverts that bubble from a lower call depth than the cheatcode itself, -/// and library-internal reverts inline. -contract ParseHarness { - function callParse(string calldata jsonPath) external view { - LibSafeOps.parseTxBuilderJson(jsonPath); - } -} - -/// @notice External-call harness around `LibSafeOps.simulateNPlus1Reversal` -/// for cases where the helper itself is expected to revert (e.g. the -/// "not enough owners" require). `vm.expectRevert` needs the revert to -/// originate from a deeper call frame than the cheatcode call, which a -/// direct library invocation from the test does not produce. -contract NPlus1Harness { - function callSimulateNPlus1Reversal(IGnosisSafe safe, uint256 oldThreshold, uint256 newThreshold) external { - LibSafeOps.simulateNPlus1Reversal(safe, oldThreshold, newThreshold); - } -} - -/// @notice External-call harness around `LibSafeOps.packApprovedHashSignatures` -/// so the pure-function's overflow `require` can be caught by -/// `vm.expectRevert`. -contract PackHarness { - function callPack(address[] calldata sortedSigners, uint256 count) external pure returns (bytes memory) { - return LibSafeOps.packApprovedHashSignatures(sortedSigners, count); - } -} diff --git a/test/src/lib/LibStockSplit.t.sol b/test/src/lib/LibStockSplit.t.sol index d90e7bbe..7310cee3 100644 --- a/test/src/lib/LibStockSplit.t.sol +++ b/test/src/lib/LibStockSplit.t.sol @@ -4,24 +4,16 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.1/src/Test.sol"; import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; -import {LibCorporateAction, STOCK_SPLIT_V1_TYPE_HASH} from "../../../src/lib/LibCorporateAction.sol"; +import {STOCK_SPLIT_V1_TYPE_HASH} from "../../../src/lib/LibCorporateAction.sol"; import { ACTION_TYPE_INIT_V1, ACTION_TYPE_STOCK_SPLIT_V1, ACTION_TYPE_STABLES_DIVIDEND_V1, VALID_ACTION_TYPES_MASK } from "../../../src/interface/ICorporateActionsV1.sol"; -import {UnknownActionType} from "../../../src/error/ErrCorporateAction.sol"; -import { - CorporateActionNode, - CompletionFilter, - LibCorporateActionNode, - NODE_NONE -} from "../../../src/lib/LibCorporateActionNode.sol"; import {LibStockSplit} from "../../../src/lib/LibStockSplit.sol"; import {InvalidSplitMultiplier, MultiplierTooSmall, MultiplierTooLarge} from "../../../src/error/ErrStockSplit.sol"; import {LibTestTofu} from "../../lib/LibTestTofu.sol"; -import {StockSplitHarness} from "../../concrete/StockSplitHarness.sol"; import {StockSplitValidationHarness as ValidationHarness} from "../../concrete/StockSplitValidationHarness.sol"; contract LibStockSplitValidationTest is Test { @@ -238,235 +230,3 @@ contract LibStockSplitValidationTest is Test { v.validate(multiplier); } } - -/// @dev Validation tests with a 6-decimal harness (USDC-like) to verify -/// the bounds scale with the vault's decimals. -contract LibStockSplitValidation6DecimalsTest is Test { - ValidationHarness internal v; - - function setUp() public { - LibTestTofu.deployTofu(vm); - v = new ValidationHarness(6); - } - - /// Floor for 6 decimals: 1e-6 passes. - function testFloorBoundaryPasses() external { - Float boundary = LibDecimalFloat.packLossless(1, -6); - v.validate(boundary); - } - - /// Below floor for 6 decimals: 1e-7 reverts. - function testBelowFloorReverts() external { - Float tooSmall = LibDecimalFloat.packLossless(1, -7); - vm.expectRevert(abi.encodeWithSelector(MultiplierTooSmall.selector, tooSmall)); - v.validate(tooSmall); - } - - /// Ceiling for 6 decimals: 1e6 passes. - function testCeilingBoundaryPasses() external { - Float ceiling = LibDecimalFloat.packLossless(1, 6); - v.validate(ceiling); - } - - /// Above ceiling for 6 decimals: 1e7 reverts. - function testAboveCeilingReverts() external { - Float tooLarge = LibDecimalFloat.packLossless(1, 7); - vm.expectRevert(abi.encodeWithSelector(MultiplierTooLarge.selector, tooLarge)); - v.validate(tooLarge); - } - - /// A multiplier that would pass for 18-decimals (1e-18) must revert for - /// a 6-decimal vault because it's below the per-token floor. - function testEighteenDecimalFloorRejectedForSixDecimals() external { - Float eighteenFloor = LibDecimalFloat.packLossless(1, -18); - vm.expectRevert(abi.encodeWithSelector(MultiplierTooSmall.selector, eighteenFloor)); - v.validate(eighteenFloor); - } - - /// A realistic 2x split still passes for 6-decimal tokens. - function testRealisticSplitPasses() external { - Float twoX = LibDecimalFloat.packLossless(2, 0); - v.validate(twoX); - } -} - -/// @dev Fuzz tests that parameterize over the vault's decimals. -contract LibStockSplitValidationFuzzDecimalsTest is Test { - function setUp() public { - LibTestTofu.deployTofu(vm); - } - - /// For any decimals in a realistic range, the floor boundary - /// (10^-decimals) passes and just below it (10^-(decimals+1)) reverts. - function testFuzzFloorBoundary(uint8 decimals) external { - decimals = uint8(bound(decimals, 1, 36)); - ValidationHarness v = new ValidationHarness(decimals); - - // forge-lint: disable-next-line(unsafe-typecast) - int256 decimalsSigned = int256(uint256(decimals)); - - // Floor boundary passes. - Float floor = LibDecimalFloat.packLossless(1, -decimalsSigned); - v.validate(floor); - - // Just below floor reverts. - Float belowFloor = LibDecimalFloat.packLossless(1, -(decimalsSigned + 1)); - vm.expectRevert(abi.encodeWithSelector(MultiplierTooSmall.selector, belowFloor)); - v.validate(belowFloor); - } - - /// For any decimals in a realistic range, the ceiling boundary - /// (10^decimals) passes and just above it (10^(decimals+1)) reverts. - function testFuzzCeilingBoundary(uint8 decimals) external { - decimals = uint8(bound(decimals, 1, 36)); - ValidationHarness v = new ValidationHarness(decimals); - - // forge-lint: disable-next-line(unsafe-typecast) - int256 decimalsSigned = int256(uint256(decimals)); - - // Ceiling boundary passes. - Float ceiling = LibDecimalFloat.packLossless(1, decimalsSigned); - v.validate(ceiling); - - // Just above ceiling reverts. - Float aboveCeiling = LibDecimalFloat.packLossless(1, decimalsSigned + 1); - vm.expectRevert(abi.encodeWithSelector(MultiplierTooLarge.selector, aboveCeiling)); - v.validate(aboveCeiling); - } - - /// A realistic multiplier (2x) passes for any realistic decimals value. - function testFuzzRealisticMultiplierPasses(uint8 decimals) external { - decimals = uint8(bound(decimals, 1, 36)); - ValidationHarness v = new ValidationHarness(decimals); - - Float twoX = LibDecimalFloat.packLossless(2, 0); - v.validate(twoX); - } -} - -contract LibStockSplitResolveTest is Test { - StockSplitHarness internal h; - - function setUp() public { - LibTestTofu.deployTofu(vm); - h = new StockSplitHarness(18); - } - - /// STOCK_SPLIT_V1_TYPE_HASH resolves to ACTION_TYPE_STOCK_SPLIT_V1. - function testResolveStockSplit() external { - Float twoX = LibDecimalFloat.packLossless(2, 0); - uint256 bitmap = h.resolveActionType(STOCK_SPLIT_V1_TYPE_HASH, LibStockSplit.encodeParametersV1(twoX)); - assertEq(bitmap, ACTION_TYPE_STOCK_SPLIT_V1); - } - - /// Resolve with invalid parameters reverts during validation. - function testResolveStockSplitZeroMultiplierReverts() external { - Float zero = LibDecimalFloat.packLossless(0, 0); - vm.expectRevert(InvalidSplitMultiplier.selector); - h.resolveActionType(STOCK_SPLIT_V1_TYPE_HASH, LibStockSplit.encodeParametersV1(zero)); - } - - /// Unknown type hash reverts. - function testResolveUnknownTypeReverts() external { - bytes32 unknown = keccak256("Dividend"); - vm.expectRevert(abi.encodeWithSelector(UnknownActionType.selector, unknown)); - h.resolveActionType(unknown, ""); - } -} - -contract LibStockSplitLifecycleTest is Test { - StockSplitHarness internal h; - - function setUp() public { - LibTestTofu.deployTofu(vm); - h = new StockSplitHarness(18); - vm.warp(1000); - } - - /// Stock split full lifecycle: resolve, schedule, complete, walk, read multiplier. - function testStockSplitLifecycle() external { - Float threeX = LibDecimalFloat.packLossless(3, 0); - // Bootstrap takes idx 0 on first schedule, so the user split lands - // at idx 1. - uint256 id = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 1500, LibStockSplit.encodeParametersV1(threeX)); - assertEq(id, 1); - assertEq(h.countCompleted(), 0); - - vm.warp(2000); - assertEq(h.countCompleted(), 1); - - uint256 completed = h.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED); - assertEq(completed, id); - - CorporateActionNode memory node = h.getNode(id); - Float stored = abi.decode(node.parameters, (Float)); - assertEq(Float.unwrap(stored), Float.unwrap(threeX)); - } - - /// Multiple stock splits: schedule 3, complete 2, verify filtering. - function testMultipleStockSplitsFiltered() external { - Float twoX = LibDecimalFloat.packLossless(2, 0); - Float threeX = LibDecimalFloat.packLossless(3, 0); - Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); - - uint256 id1 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 1500, LibStockSplit.encodeParametersV1(twoX)); - uint256 id2 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 2000, LibStockSplit.encodeParametersV1(threeX)); - uint256 id3 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 3000, LibStockSplit.encodeParametersV1(halfX)); - - // Complete first two. - vm.warp(2500); - - // COMPLETED filter returns id1 then id2. - uint256 c1 = h.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED); - assertEq(c1, id1); - uint256 c2 = h.nextOfType(c1, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED); - assertEq(c2, id2); - assertEq(h.nextOfType(c2, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED), NODE_NONE); - - // PENDING filter returns only id3. - uint256 p1 = h.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.PENDING); - assertEq(p1, id3); - assertEq(h.nextOfType(p1, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.PENDING), NODE_NONE); - - // ALL walks all three in time order. - uint256 a1 = h.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.ALL); - assertEq(a1, id1); - uint256 a2 = h.nextOfType(a1, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.ALL); - assertEq(a2, id2); - uint256 a3 = h.nextOfType(a2, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.ALL); - assertEq(a3, id3); - assertEq(h.nextOfType(a3, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.ALL), NODE_NONE); - - assertEq(h.countCompleted(), 2); - } - - /// Stored node has correct actionType bitmap and decodable parameters - /// after scheduling through resolveAndSchedule. - function testStoredNodeDataAfterSchedule() external { - Float fiveX = LibDecimalFloat.packLossless(5, 0); - uint256 id = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 1500, abi.encode(fiveX)); - - CorporateActionNode memory node = h.getNode(id); - assertEq(node.actionType, ACTION_TYPE_STOCK_SPLIT_V1, "bitmap is stock split"); - assertEq(node.effectiveTime, 1500, "effectiveTime stored"); - - Float stored = abi.decode(node.parameters, (Float)); - assertEq(Float.unwrap(stored), Float.unwrap(fiveX), "multiplier round-trips"); - } - - /// Two stock splits with different multipliers store independently. - function testTwoSplitsDifferentMultipliersStoreIndependently() external { - Float twoX = LibDecimalFloat.packLossless(2, 0); - Float oneThird = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(3, 0)); - - uint256 id1 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 1500, LibStockSplit.encodeParametersV1(twoX)); - uint256 id2 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 2000, abi.encode(oneThird)); - - Float stored1 = abi.decode(h.getNode(id1).parameters, (Float)); - Float stored2 = abi.decode(h.getNode(id2).parameters, (Float)); - - assertEq(Float.unwrap(stored1), Float.unwrap(twoX)); - assertEq(Float.unwrap(stored2), Float.unwrap(oneThird)); - assertTrue(Float.unwrap(stored1) != Float.unwrap(stored2), "different multipliers stored"); - } -} diff --git a/test/src/lib/LibStockSplitLifecycleTest.t.sol b/test/src/lib/LibStockSplitLifecycleTest.t.sol new file mode 100644 index 00000000..c10adf7e --- /dev/null +++ b/test/src/lib/LibStockSplitLifecycleTest.t.sol @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import {STOCK_SPLIT_V1_TYPE_HASH} from "../../../src/lib/LibCorporateAction.sol"; +import {ACTION_TYPE_STOCK_SPLIT_V1} from "../../../src/interface/ICorporateActionsV1.sol"; +import {CorporateActionNode, CompletionFilter, NODE_NONE} from "../../../src/lib/LibCorporateActionNode.sol"; +import {LibStockSplit} from "../../../src/lib/LibStockSplit.sol"; +import {LibTestTofu} from "../../lib/LibTestTofu.sol"; +import {StockSplitHarness} from "../../concrete/StockSplitHarness.sol"; + +contract LibStockSplitLifecycleTest is Test { + StockSplitHarness internal h; + + function setUp() public { + LibTestTofu.deployTofu(vm); + h = new StockSplitHarness(18); + vm.warp(1000); + } + + /// Stock split full lifecycle: resolve, schedule, complete, walk, read multiplier. + function testStockSplitLifecycle() external { + Float threeX = LibDecimalFloat.packLossless(3, 0); + // Bootstrap takes idx 0 on first schedule, so the user split lands + // at idx 1. + uint256 id = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 1500, LibStockSplit.encodeParametersV1(threeX)); + assertEq(id, 1); + assertEq(h.countCompleted(), 0); + + vm.warp(2000); + assertEq(h.countCompleted(), 1); + + uint256 completed = h.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED); + assertEq(completed, id); + + CorporateActionNode memory node = h.getNode(id); + Float stored = abi.decode(node.parameters, (Float)); + assertEq(Float.unwrap(stored), Float.unwrap(threeX)); + } + + /// Multiple stock splits: schedule 3, complete 2, verify filtering. + function testMultipleStockSplitsFiltered() external { + Float twoX = LibDecimalFloat.packLossless(2, 0); + Float threeX = LibDecimalFloat.packLossless(3, 0); + Float halfX = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(2, 0)); + + uint256 id1 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 1500, LibStockSplit.encodeParametersV1(twoX)); + uint256 id2 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 2000, LibStockSplit.encodeParametersV1(threeX)); + uint256 id3 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 3000, LibStockSplit.encodeParametersV1(halfX)); + + // Complete first two. + vm.warp(2500); + + // COMPLETED filter returns id1 then id2. + uint256 c1 = h.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED); + assertEq(c1, id1); + uint256 c2 = h.nextOfType(c1, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED); + assertEq(c2, id2); + assertEq(h.nextOfType(c2, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.COMPLETED), NODE_NONE); + + // PENDING filter returns only id3. + uint256 p1 = h.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.PENDING); + assertEq(p1, id3); + assertEq(h.nextOfType(p1, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.PENDING), NODE_NONE); + + // ALL walks all three in time order. + uint256 a1 = h.nextOfType(NODE_NONE, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.ALL); + assertEq(a1, id1); + uint256 a2 = h.nextOfType(a1, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.ALL); + assertEq(a2, id2); + uint256 a3 = h.nextOfType(a2, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.ALL); + assertEq(a3, id3); + assertEq(h.nextOfType(a3, ACTION_TYPE_STOCK_SPLIT_V1, CompletionFilter.ALL), NODE_NONE); + + assertEq(h.countCompleted(), 2); + } + + /// Stored node has correct actionType bitmap and decodable parameters + /// after scheduling through resolveAndSchedule. + function testStoredNodeDataAfterSchedule() external { + Float fiveX = LibDecimalFloat.packLossless(5, 0); + uint256 id = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 1500, abi.encode(fiveX)); + + CorporateActionNode memory node = h.getNode(id); + assertEq(node.actionType, ACTION_TYPE_STOCK_SPLIT_V1, "bitmap is stock split"); + assertEq(node.effectiveTime, 1500, "effectiveTime stored"); + + Float stored = abi.decode(node.parameters, (Float)); + assertEq(Float.unwrap(stored), Float.unwrap(fiveX), "multiplier round-trips"); + } + + /// Two stock splits with different multipliers store independently. + function testTwoSplitsDifferentMultipliersStoreIndependently() external { + Float twoX = LibDecimalFloat.packLossless(2, 0); + Float oneThird = LibDecimalFloat.div(LibDecimalFloat.packLossless(1, 0), LibDecimalFloat.packLossless(3, 0)); + + uint256 id1 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 1500, LibStockSplit.encodeParametersV1(twoX)); + uint256 id2 = h.resolveAndSchedule(STOCK_SPLIT_V1_TYPE_HASH, 2000, abi.encode(oneThird)); + + Float stored1 = abi.decode(h.getNode(id1).parameters, (Float)); + Float stored2 = abi.decode(h.getNode(id2).parameters, (Float)); + + assertEq(Float.unwrap(stored1), Float.unwrap(twoX)); + assertEq(Float.unwrap(stored2), Float.unwrap(oneThird)); + assertTrue(Float.unwrap(stored1) != Float.unwrap(stored2), "different multipliers stored"); + } +} diff --git a/test/src/lib/LibStockSplitResolveTest.t.sol b/test/src/lib/LibStockSplitResolveTest.t.sol new file mode 100644 index 00000000..24184ab5 --- /dev/null +++ b/test/src/lib/LibStockSplitResolveTest.t.sol @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import {STOCK_SPLIT_V1_TYPE_HASH} from "../../../src/lib/LibCorporateAction.sol"; +import {ACTION_TYPE_STOCK_SPLIT_V1} from "../../../src/interface/ICorporateActionsV1.sol"; +import {UnknownActionType} from "../../../src/error/ErrCorporateAction.sol"; +import {LibStockSplit} from "../../../src/lib/LibStockSplit.sol"; +import {InvalidSplitMultiplier} from "../../../src/error/ErrStockSplit.sol"; +import {LibTestTofu} from "../../lib/LibTestTofu.sol"; +import {StockSplitHarness} from "../../concrete/StockSplitHarness.sol"; + +contract LibStockSplitResolveTest is Test { + StockSplitHarness internal h; + + function setUp() public { + LibTestTofu.deployTofu(vm); + h = new StockSplitHarness(18); + } + + /// STOCK_SPLIT_V1_TYPE_HASH resolves to ACTION_TYPE_STOCK_SPLIT_V1. + function testResolveStockSplit() external { + Float twoX = LibDecimalFloat.packLossless(2, 0); + uint256 bitmap = h.resolveActionType(STOCK_SPLIT_V1_TYPE_HASH, LibStockSplit.encodeParametersV1(twoX)); + assertEq(bitmap, ACTION_TYPE_STOCK_SPLIT_V1); + } + + /// Resolve with invalid parameters reverts during validation. + function testResolveStockSplitZeroMultiplierReverts() external { + Float zero = LibDecimalFloat.packLossless(0, 0); + vm.expectRevert(InvalidSplitMultiplier.selector); + h.resolveActionType(STOCK_SPLIT_V1_TYPE_HASH, LibStockSplit.encodeParametersV1(zero)); + } + + /// Unknown type hash reverts. + function testResolveUnknownTypeReverts() external { + bytes32 unknown = keccak256("Dividend"); + vm.expectRevert(abi.encodeWithSelector(UnknownActionType.selector, unknown)); + h.resolveActionType(unknown, ""); + } +} diff --git a/test/src/lib/LibStockSplitValidation6DecimalsTest.t.sol b/test/src/lib/LibStockSplitValidation6DecimalsTest.t.sol new file mode 100644 index 00000000..ba2fbf6e --- /dev/null +++ b/test/src/lib/LibStockSplitValidation6DecimalsTest.t.sol @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import {MultiplierTooSmall, MultiplierTooLarge} from "../../../src/error/ErrStockSplit.sol"; +import {LibTestTofu} from "../../lib/LibTestTofu.sol"; +import {StockSplitValidationHarness as ValidationHarness} from "../../concrete/StockSplitValidationHarness.sol"; + +/// @dev Validation tests with a 6-decimal harness (USDC-like) to verify +/// the bounds scale with the vault's decimals. +contract LibStockSplitValidation6DecimalsTest is Test { + ValidationHarness internal v; + + function setUp() public { + LibTestTofu.deployTofu(vm); + v = new ValidationHarness(6); + } + + /// Floor for 6 decimals: 1e-6 passes. + function testFloorBoundaryPasses() external { + Float boundary = LibDecimalFloat.packLossless(1, -6); + v.validate(boundary); + } + + /// Below floor for 6 decimals: 1e-7 reverts. + function testBelowFloorReverts() external { + Float tooSmall = LibDecimalFloat.packLossless(1, -7); + vm.expectRevert(abi.encodeWithSelector(MultiplierTooSmall.selector, tooSmall)); + v.validate(tooSmall); + } + + /// Ceiling for 6 decimals: 1e6 passes. + function testCeilingBoundaryPasses() external { + Float ceiling = LibDecimalFloat.packLossless(1, 6); + v.validate(ceiling); + } + + /// Above ceiling for 6 decimals: 1e7 reverts. + function testAboveCeilingReverts() external { + Float tooLarge = LibDecimalFloat.packLossless(1, 7); + vm.expectRevert(abi.encodeWithSelector(MultiplierTooLarge.selector, tooLarge)); + v.validate(tooLarge); + } + + /// A multiplier that would pass for 18-decimals (1e-18) must revert for + /// a 6-decimal vault because it's below the per-token floor. + function testEighteenDecimalFloorRejectedForSixDecimals() external { + Float eighteenFloor = LibDecimalFloat.packLossless(1, -18); + vm.expectRevert(abi.encodeWithSelector(MultiplierTooSmall.selector, eighteenFloor)); + v.validate(eighteenFloor); + } + + /// A realistic 2x split still passes for 6-decimal tokens. + function testRealisticSplitPasses() external { + Float twoX = LibDecimalFloat.packLossless(2, 0); + v.validate(twoX); + } +} diff --git a/test/src/lib/LibStockSplitValidationFuzzDecimalsTest.t.sol b/test/src/lib/LibStockSplitValidationFuzzDecimalsTest.t.sol new file mode 100644 index 00000000..d1bc1f46 --- /dev/null +++ b/test/src/lib/LibStockSplitValidationFuzzDecimalsTest.t.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import {MultiplierTooSmall, MultiplierTooLarge} from "../../../src/error/ErrStockSplit.sol"; +import {LibTestTofu} from "../../lib/LibTestTofu.sol"; +import {StockSplitValidationHarness as ValidationHarness} from "../../concrete/StockSplitValidationHarness.sol"; + +/// @dev Fuzz tests that parameterize over the vault's decimals. +contract LibStockSplitValidationFuzzDecimalsTest is Test { + function setUp() public { + LibTestTofu.deployTofu(vm); + } + + /// For any decimals in a realistic range, the floor boundary + /// (10^-decimals) passes and just below it (10^-(decimals+1)) reverts. + function testFuzzFloorBoundary(uint8 decimals) external { + decimals = uint8(bound(decimals, 1, 36)); + ValidationHarness v = new ValidationHarness(decimals); + + // forge-lint: disable-next-line(unsafe-typecast) + int256 decimalsSigned = int256(uint256(decimals)); + + // Floor boundary passes. + Float floor = LibDecimalFloat.packLossless(1, -decimalsSigned); + v.validate(floor); + + // Just below floor reverts. + Float belowFloor = LibDecimalFloat.packLossless(1, -(decimalsSigned + 1)); + vm.expectRevert(abi.encodeWithSelector(MultiplierTooSmall.selector, belowFloor)); + v.validate(belowFloor); + } + + /// For any decimals in a realistic range, the ceiling boundary + /// (10^decimals) passes and just above it (10^(decimals+1)) reverts. + function testFuzzCeilingBoundary(uint8 decimals) external { + decimals = uint8(bound(decimals, 1, 36)); + ValidationHarness v = new ValidationHarness(decimals); + + // forge-lint: disable-next-line(unsafe-typecast) + int256 decimalsSigned = int256(uint256(decimals)); + + // Ceiling boundary passes. + Float ceiling = LibDecimalFloat.packLossless(1, decimalsSigned); + v.validate(ceiling); + + // Just above ceiling reverts. + Float aboveCeiling = LibDecimalFloat.packLossless(1, decimalsSigned + 1); + vm.expectRevert(abi.encodeWithSelector(MultiplierTooLarge.selector, aboveCeiling)); + v.validate(aboveCeiling); + } + + /// A realistic multiplier (2x) passes for any realistic decimals value. + function testFuzzRealisticMultiplierPasses(uint8 decimals) external { + decimals = uint8(bound(decimals, 1, 36)); + ValidationHarness v = new ValidationHarness(decimals); + + Float twoX = LibDecimalFloat.packLossless(2, 0); + v.validate(twoX); + } +} diff --git a/test/src/lib/LibTotalSupply.t.sol b/test/src/lib/LibTotalSupply.t.sol index 907958ee..30114ba0 100644 --- a/test/src/lib/LibTotalSupply.t.sol +++ b/test/src/lib/LibTotalSupply.t.sol @@ -4,68 +4,10 @@ pragma solidity =0.8.25; import {Test, stdError} from "forge-std-1.16.1/src/Test.sol"; import {Float, LibDecimalFloat} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; -import {LibTotalSupply} from "src/lib/LibTotalSupply.sol"; -import {LibCorporateAction} from "src/lib/LibCorporateAction.sol"; +import {LibTotalSupplyHarness} from "./LibTotalSupplyHarness.sol"; import {ACTION_TYPE_STOCK_SPLIT_V1} from "src/interface/ICorporateActionsV1.sol"; -import {LibERC20Storage, ERC20_STORAGE_LOCATION} from "src/lib/LibERC20Storage.sol"; import {LibStockSplit} from "src/lib/LibStockSplit.sol"; -contract LibTotalSupplyHarness { - function schedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { - return LibCorporateAction.schedule(actionType, effectiveTime, parameters); - } - - function cancel(uint256 actionIndex) external { - LibCorporateAction.cancel(actionIndex); - } - - function effectiveTotalSupply() external view returns (uint256) { - return LibTotalSupply.effectiveTotalSupply(); - } - - function fold() external { - LibTotalSupply.fold(); - } - - function onAccountMigrated(uint256 fromCursor, uint256 storedBalance, uint256 toCursor, uint256 newBalance) - external - { - LibTotalSupply.onAccountMigrated(fromCursor, storedBalance, toCursor, newBalance); - } - - function onMint(uint256 amount) external { - LibTotalSupply.onMint(amount); - } - - function onBurn(uint256 amount) external { - LibTotalSupply.onBurn(amount); - } - - /// @dev Test-only helper: write directly to OZ's `_totalSupply` slot to - /// seed the harness with a starting totalSupply. `LibERC20Storage` no - /// longer exposes a setter (production code must not write this slot — - /// `LibTotalSupply` per-cursor pots own the effective supply), so we do - /// the slot write inline here. - function setOzTotalSupply(uint256 supply) external { - // Bind to a local — inline assembly only accepts literal number - // constants, and `ERC20_STORAGE_LOCATION` is now derived in-source. - bytes32 slot = ERC20_STORAGE_LOCATION; - assembly ("memory-safe") { - sstore(add(slot, 2), supply) - } - } - - function unmigrated(uint256 cursor) external view returns (uint256) { - LibCorporateAction.CorporateActionStorage storage s = LibCorporateAction.getStorage(); - return s.unmigrated[cursor]; - } - - function totalSupplyLatestCursor() external view returns (uint256) { - LibCorporateAction.CorporateActionStorage storage s = LibCorporateAction.getStorage(); - return s.totalSupplyLatestCursor; - } -} - contract LibTotalSupplyTest is Test { LibTotalSupplyHarness internal h; diff --git a/test/src/lib/LibTotalSupplyHarness.sol b/test/src/lib/LibTotalSupplyHarness.sol new file mode 100644 index 00000000..26a4d96b --- /dev/null +++ b/test/src/lib/LibTotalSupplyHarness.sol @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibTotalSupply} from "src/lib/LibTotalSupply.sol"; +import {LibCorporateAction} from "src/lib/LibCorporateAction.sol"; +import {ERC20_STORAGE_LOCATION} from "src/lib/LibERC20Storage.sol"; + +contract LibTotalSupplyHarness { + function schedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { + return LibCorporateAction.schedule(actionType, effectiveTime, parameters); + } + + function cancel(uint256 actionIndex) external { + LibCorporateAction.cancel(actionIndex); + } + + function effectiveTotalSupply() external view returns (uint256) { + return LibTotalSupply.effectiveTotalSupply(); + } + + function fold() external { + LibTotalSupply.fold(); + } + + function onAccountMigrated(uint256 fromCursor, uint256 storedBalance, uint256 toCursor, uint256 newBalance) + external + { + LibTotalSupply.onAccountMigrated(fromCursor, storedBalance, toCursor, newBalance); + } + + function onMint(uint256 amount) external { + LibTotalSupply.onMint(amount); + } + + function onBurn(uint256 amount) external { + LibTotalSupply.onBurn(amount); + } + + /// @dev Test-only helper: write directly to OZ's `_totalSupply` slot to + /// seed the harness with a starting totalSupply. `LibERC20Storage` no + /// longer exposes a setter (production code must not write this slot — + /// `LibTotalSupply` per-cursor pots own the effective supply), so we do + /// the slot write inline here. + function setOzTotalSupply(uint256 supply) external { + // Bind to a local — inline assembly only accepts literal number + // constants, and `ERC20_STORAGE_LOCATION` is now derived in-source. + bytes32 slot = ERC20_STORAGE_LOCATION; + assembly ("memory-safe") { + sstore(add(slot, 2), supply) + } + } + + function unmigrated(uint256 cursor) external view returns (uint256) { + LibCorporateAction.CorporateActionStorage storage s = LibCorporateAction.getStorage(); + return s.unmigrated[cursor]; + } + + function totalSupplyLatestCursor() external view returns (uint256) { + LibCorporateAction.CorporateActionStorage storage s = LibCorporateAction.getStorage(); + return s.totalSupplyLatestCursor; + } +} diff --git a/test/src/lib/MockCorporateActionsVault.sol b/test/src/lib/MockCorporateActionsVault.sol new file mode 100644 index 00000000..46e030ca --- /dev/null +++ b/test/src/lib/MockCorporateActionsVault.sol @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Float} from "rain-math-float-0.1.1/src/lib/LibDecimalFloat.sol"; +import { + ICorporateActionsV1, + ACTION_TYPE_STOCK_SPLIT_V1, + BALANCE_MIGRATION_TYPES_MASK +} from "src/interface/ICorporateActionsV1.sol"; +import {CompletionFilter, NODE_NONE} from "src/lib/LibCorporateActionNode.sol"; + +/// @dev Mock vault exposing only the subset of `ICorporateActionsV1` that +/// `LibReceiptRebase` consumes (`nextOfType` + `getActionParameters`). Tests +/// preload the mock with a list of completed stock split multipliers, and +/// the receipt rebase walks it exactly as if it were a real vault. +/// +/// The mock assigns cursor indices 1..n to the preloaded multipliers, +/// mirroring the vault's storage layout post-bootstrap: index 0 is the +/// real bootstrap node (which the mock does not model — receipt rebase +/// treats it as identity), indices 1..n are the stock splits in +/// effective-time order. `nextOfType` returns the next index; +/// `getActionParameters` returns the stored bytes. `NODE_NONE` is the +/// "no more nodes" sentinel matching the real vault's contract. +contract MockCorporateActionsVault is ICorporateActionsV1 { + bytes[] internal splits; // splits[i-1] is the parameters blob for cursor i + + function addSplit(Float multiplier) external { + splits.push(abi.encode(multiplier)); + } + + function addSplitRaw(bytes memory parameters) external { + splits.push(parameters); + } + + function splitCount() external view returns (uint256) { + return splits.length; + } + + // ----------------------------------------------------------------------- + // ICorporateActionsV1 — only the bits LibReceiptRebase calls + + function nextOfType(uint256 cursor, uint256 mask, CompletionFilter filter) + external + view + override + returns (uint256, uint256, uint64) + { + // Receipt rebase walks `BALANCE_MIGRATION_TYPES_MASK` (init | + // stock-split). This mock holds only splits — no init node — so + // walking that mask returns the same sequence as walking the + // stock-split bit alone. Pin the mask to the production mask; + // any other request fails loud. + require(mask == BALANCE_MIGRATION_TYPES_MASK, "mock: unexpected mask"); + require(filter == CompletionFilter.COMPLETED, "mock: unexpected filter"); + + // Cursor convention matches the real vault post-bootstrap: 0 is + // the bootstrap node (identity, not modelled by this mock); splits + // live at 1..splits.length. The walk hops from `cursor` to + // `cursor + 1`, returning `NODE_NONE` once the next index would + // run off the end. + if (cursor == NODE_NONE) { + // Receipt rebase callers never pass NODE_NONE — they always + // pass the receipt-side cursor — but keep the contract honest. + return (splits.length == 0 ? NODE_NONE : 1, ACTION_TYPE_STOCK_SPLIT_V1, 1); + } + uint256 candidate = cursor + 1; + if (candidate > splits.length) { + return (NODE_NONE, 0, 0); + } + return (candidate, ACTION_TYPE_STOCK_SPLIT_V1, 1); + } + + function getActionParameters(uint256 cursor) external view override returns (bytes memory) { + require(cursor >= 1 && cursor <= splits.length, "mock: cursor out of range"); + return splits[cursor - 1]; + } + + // Unused ICorporateActionsV1 surface — revert to surface misuse. + function scheduleCorporateAction(bytes32, uint64, bytes calldata) external pure override returns (uint256) { + revert("mock: not implemented"); + } + + function cancelCorporateAction(uint256) external pure override { + revert("mock: not implemented"); + } + + function completedActionCount() external view override returns (uint256) { + return splits.length; + } + + function latestActionOfType(uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { + revert("mock: not implemented"); + } + + function earliestActionOfType(uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { + revert("mock: not implemented"); + } + + function prevOfType(uint256, uint256, CompletionFilter) external pure override returns (uint256, uint256, uint64) { + revert("mock: not implemented"); + } +} diff --git a/test/src/lib/NPlus1Harness.sol b/test/src/lib/NPlus1Harness.sol new file mode 100644 index 00000000..deed35a0 --- /dev/null +++ b/test/src/lib/NPlus1Harness.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibSafeOps} from "../../../src/lib/LibSafeOps.sol"; +import {IGnosisSafe} from "../../../src/interface/IGnosisSafe.sol"; + +/// @notice External-call harness around `LibSafeOps.simulateNPlus1Reversal` +/// for cases where the helper itself is expected to revert (e.g. the +/// "not enough owners" require). `vm.expectRevert` needs the revert to +/// originate from a deeper call frame than the cheatcode call, which a +/// direct library invocation from the test does not produce. +contract NPlus1Harness { + function callSimulateNPlus1Reversal(IGnosisSafe safe, uint256 oldThreshold, uint256 newThreshold) external { + LibSafeOps.simulateNPlus1Reversal(safe, oldThreshold, newThreshold); + } +} diff --git a/test/src/lib/PackHarness.sol b/test/src/lib/PackHarness.sol new file mode 100644 index 00000000..19814613 --- /dev/null +++ b/test/src/lib/PackHarness.sol @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibSafeOps} from "../../../src/lib/LibSafeOps.sol"; + +/// @notice External-call harness around `LibSafeOps.packApprovedHashSignatures` +/// so the pure-function's overflow `require` can be caught by +/// `vm.expectRevert`. +contract PackHarness { + function callPack(address[] calldata sortedSigners, uint256 count) external pure returns (bytes memory) { + return LibSafeOps.packApprovedHashSignatures(sortedSigners, count); + } +} diff --git a/test/src/lib/ParseHarness.sol b/test/src/lib/ParseHarness.sol new file mode 100644 index 00000000..41998cd0 --- /dev/null +++ b/test/src/lib/ParseHarness.sol @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibSafeOps} from "../../../src/lib/LibSafeOps.sol"; + +/// @notice External-call harness around `LibSafeOps.parseTxBuilderJson` so +/// `vm.expectRevert` can catch the typed error. `expectRevert` only sees +/// reverts that bubble from a lower call depth than the cheatcode itself, +/// and library-internal reverts inline. +contract ParseHarness { + function callParse(string calldata jsonPath) external view { + LibSafeOps.parseTxBuilderJson(jsonPath); + } +} diff --git a/test/src/lib/TestERC1155.sol b/test/src/lib/TestERC1155.sol new file mode 100644 index 00000000..8ac0a649 --- /dev/null +++ b/test/src/lib/TestERC1155.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {ERC1155Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC1155/ERC1155Upgradeable.sol"; +import {LibERC1155Storage} from "src/lib/LibERC1155Storage.sol"; + +/// @dev A minimal `ERC1155Upgradeable` subclass that exposes `_mint` / `_burn` +/// and the `LibERC1155Storage` helpers as external methods. The library uses +/// `internal` functions so they get inlined into this contract and read / +/// write its own storage at the OZ ERC-7201 namespaced slot — exactly the +/// invariant being tested. +contract TestERC1155 is ERC1155Upgradeable { + constructor() initializer { + __ERC1155_init(""); + } + + function mint(address to, uint256 id, uint256 amount) external { + _mint(to, id, amount, ""); + } + + function burn(address from, uint256 id, uint256 amount) external { + _burn(from, id, amount); + } + + function libBalanceOf(address account, uint256 id) external view returns (uint256) { + return LibERC1155Storage.underlyingBalance(account, id); + } + + function libSetBalance(address account, uint256 id, uint256 newBalance) external { + LibERC1155Storage.setUnderlyingBalance(account, id, newBalance); + } +} diff --git a/test/src/lib/TestERC20.sol b/test/src/lib/TestERC20.sol new file mode 100644 index 00000000..5ec61072 --- /dev/null +++ b/test/src/lib/TestERC20.sol @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {ERC20Upgradeable} from "@openzeppelin-contracts-upgradeable-5.6.1/token/ERC20/ERC20Upgradeable.sol"; +import {LibERC20Storage} from "src/lib/LibERC20Storage.sol"; + +/// @dev A minimal `ERC20Upgradeable` subclass that exposes `_mint` / `_burn` +/// and the `LibERC20Storage` helpers as external methods. The library uses +/// `internal` functions so they get inlined into this contract and read / +/// write its own storage at the OZ ERC-7201 namespaced slot — exactly the +/// invariant being tested. +contract TestERC20 is ERC20Upgradeable { + constructor() initializer { + __ERC20_init("Test", "TST"); + } + + function mint(address to, uint256 amount) external { + _mint(to, amount); + } + + function burn(address from, uint256 amount) external { + _burn(from, amount); + } + + function libBalanceOf(address account) external view returns (uint256) { + return LibERC20Storage.underlyingBalance(account); + } + + function libTotalSupply() external view returns (uint256) { + return LibERC20Storage.underlyingTotalSupply(); + } + + function libSetBalance(address account, uint256 newBalance) external { + LibERC20Storage.setUnderlyingBalance(account, newBalance); + } +} diff --git a/test/src/lib/TraversalHarness.sol b/test/src/lib/TraversalHarness.sol new file mode 100644 index 00000000..eadf2d6f --- /dev/null +++ b/test/src/lib/TraversalHarness.sol @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {LibCorporateAction} from "src/lib/LibCorporateAction.sol"; +import {CompletionFilter, CorporateActionNode, LibCorporateActionNode} from "src/lib/LibCorporateActionNode.sol"; + +/// @dev Thin harness: exposes the four tuple-returning traversal getters via +/// external calls so the library functions can be exercised directly (not +/// through the facet). Also schedules actions into the harness's own storage +/// namespace so there is no ambient state between tests. +contract TraversalHarness { + function schedule(uint256 actionType, uint64 effectiveTime, bytes memory parameters) external returns (uint256) { + return LibCorporateAction.schedule(actionType, effectiveTime, parameters); + } + + function cancel(uint256 actionIndex) external { + LibCorporateAction.cancel(actionIndex); + } + + function latest(uint256 mask, CompletionFilter filter) external view returns (uint256, uint256, uint64) { + return LibCorporateActionNode.latestActionOfType(mask, filter); + } + + function earliest(uint256 mask, CompletionFilter filter) external view returns (uint256, uint256, uint64) { + return LibCorporateActionNode.earliestActionOfType(mask, filter); + } + + function nextOf(uint256 cursor, uint256 mask, CompletionFilter filter) + external + view + returns (uint256, uint256, uint64) + { + return LibCorporateActionNode.nextActionOfType(cursor, mask, filter); + } + + function prevOf(uint256 cursor, uint256 mask, CompletionFilter filter) + external + view + returns (uint256, uint256, uint64) + { + return LibCorporateActionNode.prevActionOfType(cursor, mask, filter); + } + + function nodeAt(uint256 index) external view returns (uint256, uint64) { + CorporateActionNode storage node = LibCorporateAction.getStorage().nodes[index]; + return (node.actionType, node.effectiveTime); + } +}