Skip to content
Draft
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
11 changes: 10 additions & 1 deletion src/DefifaGovernor.sol
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
/// @notice Thrown when revoking an attestation that the account never made for the scorecard.
error DefifaGovernor_NotAttested(uint256 gameId, uint256 scorecardId, address account);

/// @notice Thrown when a scorecard is submitted in the same block timestamp as a reserve mint.
error DefifaGovernor_ReserveMintedInSubmissionBlock(uint256 gameId, uint256 timestamp);

/// @notice Thrown when a timestamp or duration value exceeds the maximum that fits in a uint48.
error DefifaGovernor_Uint48Overflow(uint256 value, uint256 max);

Expand Down Expand Up @@ -337,7 +340,13 @@ contract DefifaGovernor is Ownable, IDefifaGovernor {
}

// Cache the hook store to avoid repeated external calls.
IJB721TiersHookStore hookStore = IDefifaHook(metadata.dataHook).store();
IDefifaHook hook = IDefifaHook(metadata.dataHook);
// forge-lint: disable-next-line(block-timestamp)
if (hook.lastReserveMintTimestamp() == block.timestamp) {
revert DefifaGovernor_ReserveMintedInSubmissionBlock({gameId: gameId, timestamp: block.timestamp});
}

IJB721TiersHookStore hookStore = hook.store();

// Run the same structural validation the hook will apply at ratification time so malformed
// scorecards fail on submission instead of reaching a misleading SUCCEEDED state first.
Expand Down
4 changes: 4 additions & 0 deletions src/DefifaHook.sol
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {
/// @custom:param tokenId The ID of the token whose reserve-mint status is stored.
mapping(uint256 tokenId => bool) public override isReserveMint;

/// @notice The timestamp when reserves were last minted.
uint256 public override lastReserveMintTimestamp;

/// @notice The currency that is accepted when minting tier NFTs.
uint256 public override pricingCurrency;

Expand Down Expand Up @@ -632,6 +635,7 @@ contract DefifaHook is JB721Hook, Ownable, IDefifaHook {

// Record the minted reserves for the tier.
uint256[] memory tokenIds = hookStore.recordMintReservesFor({tierId: tierId, count: count});
if (count != 0) lastReserveMintTimestamp = block.timestamp;

// Keep a reference to the token ID being iterated on.
uint256 tokenId;
Expand Down
4 changes: 4 additions & 0 deletions src/interfaces/IDefifaHook.sol
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,10 @@ interface IDefifaHook is IJB721Hook {
/// @return True if the token was minted as a reserve.
function isReserveMint(uint256 tokenId) external view returns (bool);

/// @notice The timestamp when reserves were last minted.
/// @return The last reserve mint timestamp, or 0 if no reserves have been minted.
function lastReserveMintTimestamp() external view returns (uint256);

/// @notice The pricing currency used by this hook.
/// @return The currency identifier.
function pricingCurrency() external view returns (uint256);
Expand Down
80 changes: 67 additions & 13 deletions test/regression/FixPendingReserveDilution.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,9 @@ contract FixPendingReserveDilutionTest is JBTest, TestBaseWorkflow {
);
}

/// @notice A reserve minted in the SAME block as submission is excluded from the scorecard's attestation
/// snapshot. The BWA snapshot is frozen one second before submission, so a same-block `mintReservesFor` (which
/// shares the submission timestamp and would otherwise overwrite the equally-keyed checkpoint) grants the
/// reserve beneficiary no attestation power. To attest, a reserve must be minted in a block before submission.
function test_sameTimestampReserveMintCannotAttestAfterImmediateSubmission() external {
/// @notice A reserve minted in the same timestamp as submission blocks immediate submission. Submitting in the next
/// timestamp lets the reserve beneficiary participate normally.
function test_sameTimestampReserveMintBlocksImmediateSubmission() external {
(_pid, _nft, _gov) = _launch(_launchData());

// Mint phase: tier 1 creates one pending reserve, tier 2 gives the scorecard a live opposing tier.
Expand All @@ -295,6 +293,14 @@ contract FixPendingReserveDilutionTest is JBTest, TestBaseWorkflow {
sc[1] = DefifaTierCashOutWeight({id: 2, cashOutWeight: _nft.TOTAL_CASHOUT_WEIGHT()});
sc[2] = DefifaTierCashOutWeight({id: 3, cashOutWeight: 0});
sc[3] = DefifaTierCashOutWeight({id: 4, cashOutWeight: 0});
vm.expectRevert(
abi.encodeWithSelector(
DefifaGovernor.DefifaGovernor_ReserveMintedInSubmissionBlock.selector, _gameId, block.timestamp
)
);
_gov.submitScorecardFor(_gameId, sc);

vm.warp(block.timestamp + 1);
uint256 proposalId = _gov.submitScorecardFor(_gameId, sc);

assertEq(
Expand All @@ -304,16 +310,16 @@ contract FixPendingReserveDilutionTest is JBTest, TestBaseWorkflow {
);

vm.warp(block.timestamp + 1);
assertEq(
uint256(_gov.stateOf(_gameId, proposalId)),
uint256(DefifaScorecardState.ACTIVE),
"delayed scorecard should be attestable after attestations begin"
);

// The reserve was minted in the submission block, so the frozen snapshot gives it zero power and attesting
// reverts on the zero-weight guard.
// The reserve was minted before the submission timestamp, so it keeps normal attestation power.
vm.prank(reserveBeneficiary);
vm.expectRevert(
abi.encodeWithSelector(
DefifaGovernor.DefifaGovernor_NotAllowed.selector, _gameId, proposalId, reserveBeneficiary
)
);
_gov.attestToScorecardFrom(_gameId, proposalId);
uint256 reserveWeight = _gov.attestToScorecardFrom(_gameId, proposalId);
assertGt(reserveWeight, 0, "previous-timestamp reserve holder can attest");

// A holder that minted in an earlier block (tier-1 paid holder, on the 0-weight tier so BWA leaves it full
// power) keeps its pre-submission attestation power — only same-block activity is excluded.
Expand All @@ -322,6 +328,54 @@ contract FixPendingReserveDilutionTest is JBTest, TestBaseWorkflow {
assertGt(playerWeight, 0, "pre-submission holder retains its frozen attestation power");
}

/// @notice Documents the remaining same-block ordering gap: minting reserves before scorecard submission in the
/// same block removes the live pending-reserve count, while the scorecard's historical checkpoint excludes the
/// newly minted reserve. The paid holder's BWA denominator therefore omits the reserve.
function test_sameBlockReserveMintBeforeSubmissionIsRejected() external {
(_pid, _nft, _gov) = _launch(_launchData());

vm.warp(block.timestamp + 1 days + 1);
_mint(player, 1, 1 ether);
_delegateSelf(player, 1);
vm.warp(block.timestamp + 1);
_mint(disinterested1, 2, 1 ether);
_delegateSelf(disinterested1, 2);
vm.warp(block.timestamp + 1);
_mint(disinterested2, 3, 1 ether);
_delegateSelf(disinterested2, 3);
vm.warp(block.timestamp + 1);
_mint(disinterested3, 4, 1 ether);
_delegateSelf(disinterested3, 4);

assertEq(_nft.store().numberOfPendingReservesFor(address(_nft), 1), 1, "tier 1 starts with a pending reserve");

vm.warp(block.timestamp + 2 days + 1);

JB721TiersMintReservesConfig[] memory reserveConfigs = new JB721TiersMintReservesConfig[](1);
reserveConfigs[0] = JB721TiersMintReservesConfig({tierId: 1, count: 1});
_nft.mintReservesFor(reserveConfigs);

DefifaTierCashOutWeight[] memory sc = new DefifaTierCashOutWeight[](4);
uint256 perTier = _nft.TOTAL_CASHOUT_WEIGHT() / 4;
sc[0] = DefifaTierCashOutWeight({id: 1, cashOutWeight: perTier});
sc[1] = DefifaTierCashOutWeight({id: 2, cashOutWeight: perTier});
sc[2] = DefifaTierCashOutWeight({id: 3, cashOutWeight: perTier});
sc[3] = DefifaTierCashOutWeight({id: 4, cashOutWeight: _nft.TOTAL_CASHOUT_WEIGHT() - perTier * 3});

vm.expectRevert(
abi.encodeWithSelector(
DefifaGovernor.DefifaGovernor_ReserveMintedInSubmissionBlock.selector, _gameId, block.timestamp
)
);
_gov.submitScorecardFor(_gameId, sc);

vm.warp(block.timestamp + 1);
uint256 proposalId = _gov.submitScorecardFor(_gameId, sc);
uint256 playerBwa = _gov.getBWAAttestationWeight(_gameId, proposalId, player, uint48(block.timestamp - 1));

assertEq(playerBwa, 375_000_000, "next-block submission preserves pending-reserve denominator dilution");
}

// ---- helpers ----

function _launchData() internal view returns (DefifaLaunchProjectData memory) {
Expand Down
Loading