Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions src/abstract/RebaseMigratable.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: LicenseRef-DCL-1.0
// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd
pragma solidity ^0.8.25;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail
rg -n '^pragma solidity ' src

Repository: S01-Issuer/st0x.deploy

Length of output: 2998


🏁 Script executed:

head -20 src/abstract/RebaseMigratable.sol

Repository: S01-Issuer/st0x.deploy

Length of output: 1099


🏁 Script executed:

sed -n '25,35p' src/abstract/RebaseMigratable.sol

Repository: S01-Issuer/st0x.deploy

Length of output: 791


🏁 Script executed:

grep -n "^abstract contract\|^contract\|^library" src/abstract/RebaseMigratable.sol

Repository: S01-Issuer/st0x.deploy

Length of output: 106


Pin this abstract contract to =0.8.25.

This file is a contract under src/**/*.sol, so leaving it on ^0.8.25 weakens the repo's exact-compiler guarantee for the shared migration path.

Suggested fix
-pragma solidity ^0.8.25;
+pragma solidity =0.8.25;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pragma solidity ^0.8.25;
pragma solidity =0.8.25;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/abstract/RebaseMigratable.sol` at line 3, The pragma in the
RebaseMigratable abstract contract is using a caret range (^0.8.25) which
relaxes the repo's exact-compiler guarantee; update the pragma in
src/abstract/RebaseMigratable.sol to pin the compiler version exactly by
changing the pragma to =0.8.25 so the contract (RebaseMigratable) compiles only
with the intended compiler version.


/// @title RebaseMigratable
/// @notice Shared skeleton for the lazy rebase-migration algorithm. Both
/// `StoxReceiptVault` (share side) and `StoxReceipt` (receipt side) lazily
/// rasterize each holder's stored balance to the latest completed corporate
/// action on first touch in `_update`. The orchestration is identical:
///
/// 1. Zero-address short-circuit (mint `from`, burn `to`).
/// 2. Read the holder's current cursor + stored balance.
/// 3. Walk the rebase from that cursor forward, returning the rasterized
/// balance and the advanced cursor.
/// 4. Early-return if the cursor didn't advance (no completed action newer
/// than the holder's last touch).
/// 5. Write the new cursor.
/// 6. If the balance changed (it can stay equal under the identity bootstrap
/// or after a truncate-to-zero), write the new stored balance.
/// 7. Emit the migration event.
/// 8. Run the post-migrate hook (share side updates the totalSupply pots;
/// receipt side is a no-op).
///
/// The differences are entirely plug-points (which storage to read/write,
/// which rebase walk to invoke, which event to emit, whether the post-hook
/// touches additional state). This abstract owns the orchestration; the
/// concrete contracts implement the plug-points and the shape stays a
/// single-source-of-truth instead of being mirrored across two files.
///
/// **`id` parameter.** The receipt side is keyed by `(holder, id)` for the
/// ERC-1155 token id. The share side has no id concept — its overrides
/// ignore the parameter, and its `_emitMigrated` hook drops the field
/// from the event signature. The slight smell of an always-zero `id` on
/// the share side is the cost of keeping one orchestration; the
/// alternative (separate `_migrateAccount` / `_migrateHolderId` shapes)
/// is exactly the duplication this base eliminates.
abstract contract RebaseMigratable {
/// @dev Read the holder's current migration cursor.
/// @param account The holder.
/// @param id The ERC-1155 token id (ignored by share-side overrides).
function _readCursor(address account, uint256 id) internal view virtual returns (uint256);

/// @dev Write the holder's new migration cursor.
function _writeCursor(address account, uint256 id, uint256 cursor) internal virtual;

/// @dev Read the holder's raw stored balance (pre-rebase, before any
/// pending multipliers are applied).
function _readStoredBalance(address account, uint256 id) internal view virtual returns (uint256);

/// @dev Write the holder's rasterized stored balance (post-rebase).
function _writeStoredBalance(address account, uint256 id, uint256 balance) internal virtual;

/// @dev Walk the rebase from `cursor` forward, returning the rasterized
/// balance and the advanced cursor.
function _walkRebase(uint256 storedBalance, uint256 cursor) internal view virtual returns (uint256, uint256);

/// @dev Emit the migration event with the override's preferred shape.
/// Share-side overrides drop `id`; receipt-side keeps it.
function _emitMigrated(
address account,
uint256 id,
uint256 fromActionId,
uint256 toActionId,
uint256 oldBalance,
uint256 newBalance
) internal virtual;

/// @dev Hook called after the migration is fully written. Share side
/// updates the per-cursor totalSupply pots via
/// `LibTotalSupply.onAccountMigrated`; receipt side is a no-op.
function _postMigrate(uint256 fromActionId, uint256 toActionId, uint256 oldBalance, uint256 newBalance)
internal
virtual;

/// @dev Run the lazy rebase migration for `(account, id)`. Idempotent —
/// calling on an account already at the latest cursor is a no-op.
function _migrate(address account, uint256 id) internal {
if (account == address(0)) return;

uint256 currentCursor = _readCursor(account, id);
uint256 storedBalance = _readStoredBalance(account, id);

(uint256 newBalance, uint256 newCursor) = _walkRebase(storedBalance, currentCursor);

if (newCursor == currentCursor) return;

_writeCursor(account, id, newCursor);

// Skip the SSTORE when the rasterized balance is unchanged. Equal
// balances arise under the identity bootstrap (no real multiplier
// applied) and when fractional truncation lands on the same
// integer (e.g. a 1-wei balance at any multiplier). The cursor
// advancement above is what matters for these cases.
if (newBalance != storedBalance) {
_writeStoredBalance(account, id, newBalance);
}

_emitMigrated(account, id, currentCursor, newCursor, storedBalance, newBalance);

_postMigrate(currentCursor, newCursor, storedBalance, newBalance);
}
}
85 changes: 53 additions & 32 deletions src/concrete/StoxReceipt.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pragma solidity =0.8.25;
import {Receipt} from "rain.vats/concrete/receipt/Receipt.sol";
import {ERC1155Upgradeable} from "openzeppelin-contracts-upgradeable/contracts/token/ERC1155/ERC1155Upgradeable.sol";
import {IERC1155} from "openzeppelin-contracts/contracts/token/ERC1155/IERC1155.sol";
import {RebaseMigratable} from "../abstract/RebaseMigratable.sol";
import {ICorporateActionsV1} from "../interface/ICorporateActionsV1.sol";
import {LibCorporateActionReceipt} from "../lib/LibCorporateActionReceipt.sol";
import {LibERC1155Storage} from "../lib/LibERC1155Storage.sol";
Expand Down Expand Up @@ -60,7 +61,7 @@ import {LibReceiptRebase} from "../lib/LibReceiptRebase.sol";
/// written post-rebase balance, silently inflating the position. See the
/// `testZeroBalanceAdvancesCursor` regression in both `LibRebase.t.sol` and
/// `LibReceiptRebase.t.sol`.
contract StoxReceipt is Receipt {
contract StoxReceipt is Receipt, RebaseMigratable {
/// @notice Emitted whenever `_migrateHolderId` advances a `(account, id)`
/// pair's migration cursor. The cursor itself is storage state, so the
/// event fires on every cursor advance regardless of whether
Expand Down Expand Up @@ -163,17 +164,12 @@ contract StoxReceipt is Receipt {
virtual
override
{
// Snapshot the vault once so we don't pay the external self-call
// per iteration. `manager()` is the external view on Receipt that
// returns the configured vault address from Receipt7201Storage.
ICorporateActionsV1 vault = _vault();

// Migrate each (account, id) pair before the transfer executes.
// `_migrateHolderId` short-circuits on `address(0)` so mint (from ==
// 0) and burn (to == 0) pass straight through to super._update.
// `_migrate` short-circuits on `address(0)` so mint (from == 0)
// and burn (to == 0) pass straight through to super._update.
for (uint256 i = 0; i < ids.length; i++) {
_migrateHolderId(from, ids[i], vault);
_migrateHolderId(to, ids[i], vault);
_migrate(from, ids[i]);
_migrate(to, ids[i]);
}

// Now that both sides are rasterized to the current cursor, run
Expand All @@ -182,35 +178,60 @@ contract StoxReceipt is Receipt {
super._update(from, to, ids, amounts);
}

/// @dev Migrate a single `(account, id)` pair through every completed
/// stock split the pair has not yet been migrated through. Both the
/// balance rasterization and the cursor advancement happen here; for
/// zero-balance pairs the rewrite is a no-op but the cursor advancement
/// still matters — see contract-level NatSpec for the inflation bug this
/// prevents.
///
/// `internal` so test harnesses derived from this contract can exercise
/// the migration logic in isolation.
function _migrateHolderId(address account, uint256 id, ICorporateActionsV1 vault) internal {
if (account == address(0)) return;
// -----------------------------------------------------------------------
// RebaseMigratable plug-points (receipt-side)
// -----------------------------------------------------------------------

LibCorporateActionReceipt.CorporateActionReceiptStorage storage s = LibCorporateActionReceipt.getStorage();
uint256 currentCursor = s.accountIdCursor[account][id];
uint256 storedBalance = LibERC1155Storage.underlyingBalance(account, id);
/// @inheritdoc RebaseMigratable
function _readCursor(address account, uint256 id) internal view override returns (uint256) {
return LibCorporateActionReceipt.getStorage().accountIdCursor[account][id];
}

(uint256 newBalance, uint256 newCursor) = LibReceiptRebase.migratedBalance(storedBalance, currentCursor, vault);
/// @inheritdoc RebaseMigratable
function _writeCursor(address account, uint256 id, uint256 cursor) internal override {
LibCorporateActionReceipt.getStorage().accountIdCursor[account][id] = cursor;
}

if (newCursor == currentCursor) return;
/// @inheritdoc RebaseMigratable
function _readStoredBalance(address account, uint256 id) internal view override returns (uint256) {
return LibERC1155Storage.underlyingBalance(account, id);
}

s.accountIdCursor[account][id] = newCursor;
/// @inheritdoc RebaseMigratable
function _writeStoredBalance(address account, uint256 id, uint256 balance) internal override {
LibERC1155Storage.setUnderlyingBalance(account, id, balance);
}

// Skip the SSTORE when the rasterized balance is unchanged.
if (newBalance != storedBalance) {
LibERC1155Storage.setUnderlyingBalance(account, id, newBalance);
}
emit ReceiptAccountMigrated(account, id, currentCursor, newCursor, storedBalance, newBalance);
/// @inheritdoc RebaseMigratable
/// @dev `_vault()` is an external self-call (`this.manager()`); the
/// previous shape cached the result once per `_update` and passed it
/// down. Under the unified `RebaseMigratable._migrate` shape, the
/// vault is fetched per pair instead. Per-call cost is one extra
/// CALL opcode against `address(this).manager()`, paid for each
/// `_migrate` invocation in the batch.
function _walkRebase(uint256 storedBalance, uint256 cursor) internal view override returns (uint256, uint256) {
return LibReceiptRebase.migratedBalance(storedBalance, cursor, _vault());
}

/// @inheritdoc RebaseMigratable
function _emitMigrated(
address account,
uint256 id,
uint256 fromActionId,
uint256 toActionId,
uint256 oldBalance,
uint256 newBalance
) internal override {
emit ReceiptAccountMigrated(account, id, fromActionId, toActionId, oldBalance, newBalance);
}

/// @inheritdoc RebaseMigratable
/// @dev Receipt side has no per-cursor pot accounting; the share side
/// (`StoxReceiptVault`) drives the totalSupply pots via
/// `LibTotalSupply.onAccountMigrated` from its own override. This
/// hook is intentionally empty.
function _postMigrate(uint256, uint256, uint256, uint256) internal override {}

/// @dev Cached / fresh read of the configured vault address, cast to
/// the corporate-actions read interface. Uses `this.manager()` (an
/// external self-call) to avoid hardcoding the rain.vats
Expand Down
78 changes: 49 additions & 29 deletions src/concrete/StoxReceiptVault.sol
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
pragma solidity =0.8.25;

import {OffchainAssetReceiptVault} from "rain.vats/concrete/vault/OffchainAssetReceiptVault.sol";
import {RebaseMigratable} from "../abstract/RebaseMigratable.sol";
import {LibCorporateAction} from "../lib/LibCorporateAction.sol";
import {LibRebase} from "../lib/LibRebase.sol";
import {LibTotalSupply} from "../lib/LibTotalSupply.sol";
Expand Down Expand Up @@ -35,7 +36,7 @@ import {LibProdDeployV3} from "../lib/LibProdDeployV3.sol";
/// a balance that was already written at the post-rebase basis, silently
/// inflating the recipient's balance. See `LibRebase.migratedBalance` and
/// its zero-balance regression tests.
contract StoxReceiptVault is OffchainAssetReceiptVault {
contract StoxReceiptVault is OffchainAssetReceiptVault, RebaseMigratable {
/// @notice Emitted whenever `_migrateAccount` advances an account's
/// migration cursor. The cursor itself is storage state, so the event
/// fires on every cursor advance regardless of whether
Expand Down Expand Up @@ -106,8 +107,12 @@ contract StoxReceiptVault is OffchainAssetReceiptVault {
function _update(address from, address to, uint256 amount) internal virtual override {
LibTotalSupply.fold();

_migrateAccount(from);
_migrateAccount(to);
// Share-side migration uses `id = 0` — the share has no ERC-1155
// token id concept; the parameter is part of the unified
// `RebaseMigratable._migrate` shape and the share-side overrides
// ignore it. See `RebaseMigratable` NatSpec.
_migrate(from, 0);
_migrate(to, 0);

super._update(from, to, amount);

Expand All @@ -118,38 +123,53 @@ contract StoxReceiptVault is OffchainAssetReceiptVault {
}
}

/// @dev Migrate a single account through every completed split that has
/// not yet been applied to it (i.e. completed split nodes whose index is
/// past the account's current `accountMigrationCursor`). This both
/// rasterizes the account's stored balance to the post-rebase basis and
/// advances the cursor; for zero-balance accounts the balance rewrite is
/// a no-op but the cursor advancement still matters — see
/// `LibRebase.migratedBalance` and its zero-balance regression tests for
/// the bug this prevents.
///
/// `internal` (rather than `private`) so test harnesses derived from this
/// contract can exercise the migration logic in isolation. The function is
/// only ever called from this contract's `_update` override.
function _migrateAccount(address account) internal {
if (account == address(0)) return;
// -----------------------------------------------------------------------
// RebaseMigratable plug-points (share-side)
// -----------------------------------------------------------------------

LibCorporateAction.CorporateActionStorage storage s = LibCorporateAction.getStorage();
uint256 currentCursor = s.accountMigrationCursor[account];
uint256 storedBalance = LibERC20Storage.underlyingBalance(account);
/// @inheritdoc RebaseMigratable
function _readCursor(address account, uint256) internal view override returns (uint256) {
return LibCorporateAction.getStorage().accountMigrationCursor[account];
}

/// @inheritdoc RebaseMigratable
function _writeCursor(address account, uint256, uint256 cursor) internal override {
LibCorporateAction.getStorage().accountMigrationCursor[account] = cursor;
}

(uint256 newBalance, uint256 newCursor) = LibRebase.migratedBalance(storedBalance, currentCursor);
/// @inheritdoc RebaseMigratable
function _readStoredBalance(address account, uint256) internal view override returns (uint256) {
return LibERC20Storage.underlyingBalance(account);
}

if (newCursor == currentCursor) return;
/// @inheritdoc RebaseMigratable
function _writeStoredBalance(address account, uint256, uint256 balance) internal override {
LibERC20Storage.setUnderlyingBalance(account, balance);
}

s.accountMigrationCursor[account] = newCursor;
/// @inheritdoc RebaseMigratable
function _walkRebase(uint256 storedBalance, uint256 cursor) internal view override returns (uint256, uint256) {
return LibRebase.migratedBalance(storedBalance, cursor);
}

// Skip the SSTORE when the rasterized balance is unchanged.
if (newBalance != storedBalance) {
LibERC20Storage.setUnderlyingBalance(account, newBalance);
}
emit AccountMigrated(account, currentCursor, newCursor, storedBalance, newBalance);
/// @inheritdoc RebaseMigratable
function _emitMigrated(
address account,
uint256,
uint256 fromActionId,
uint256 toActionId,
uint256 oldBalance,
uint256 newBalance
) internal override {
emit AccountMigrated(account, fromActionId, toActionId, oldBalance, newBalance);
}

LibTotalSupply.onAccountMigrated(currentCursor, storedBalance, newCursor, newBalance);
/// @inheritdoc RebaseMigratable
function _postMigrate(uint256 fromActionId, uint256 toActionId, uint256 oldBalance, uint256 newBalance)
internal
override
{
LibTotalSupply.onAccountMigrated(fromActionId, oldBalance, toActionId, newBalance);
}

/// @notice Routes calls with non-matching selectors to the corporate actions
Expand Down
Loading
Loading