From d444147a403215799702b5748ee1dabf569e6057 Mon Sep 17 00:00:00 2001 From: lukasz Date: Fri, 15 May 2026 16:14:37 +0200 Subject: [PATCH 1/3] Add multiplierUpdates history to token contract --- .../BackedAutoFeeTokenImplementation.sol | 112 +++++++-- contracts/interfaces/IBackedAutoFeeToken.sol | 101 +++++++++ test/BackedAutoFeeTokenImplementation.ts | 212 ++++++++++++++++++ 3 files changed, 402 insertions(+), 23 deletions(-) diff --git a/contracts/BackedAutoFeeTokenImplementation.sol b/contracts/BackedAutoFeeTokenImplementation.sol index c44d4f9..3a29c20 100644 --- a/contracts/BackedAutoFeeTokenImplementation.sol +++ b/contracts/BackedAutoFeeTokenImplementation.sol @@ -89,35 +89,28 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedA uint256 public newMultiplier; uint256 public newMultiplierActivationTime; + /** + * @dev Append-only log of *explicit* multiplier updates submitted via + * `updateMultiplierValue` / `updateMultiplierWithNonce`. + * + * Index 0 is a genesis sentinel `{1e18, 1e18, 0}` so that + * `_storeScheduledMultiplierUpdate` can safely read `length - 1`. + * + * Pending future-dated entries are overridden in place: when a new + * explicit update is submitted while the previous one is still + * scheduled (activationTime > block.timestamp), the previous entry is + * popped before the new one is appended. The corresponding + * `MultiplierScheduled` event from the popped entry remains on chain; + * the array does not retain it. + */ + MultiplierUpdate[] public multiplierUpdates; + function multiplierNonce() external view returns (uint256) { if(block.timestamp >= newMultiplierActivationTime) { return newMultiplierNonce; } return lastMultiplierNonce; } - // Events: - - /** - * @dev Emitted when multiplier updater is changed - */ - event NewMultiplierUpdater(address indexed newMultiplierUpdater); - - /** - * @dev Emitted when `value` token shares are moved from one account (`from`) to - * another (`to`). - * - * Note that `value` may be zero. - */ - event TransferShares( - address indexed from, - address indexed to, - uint256 value - ); - - /** - * @dev Emitted when multiplier value is updated - */ - event MultiplierUpdated(uint256 value); // Modifiers: @@ -198,6 +191,25 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedA newMultiplierActivationTime = 0; } + function initialize_v4( + MultiplierUpdate [] calldata _pastMultipliersUpdates + ) external virtual { + require(multiplierUpdates.length == 0, "BackedAutoFeeTokenImplementation v4 already initialized"); + multiplierUpdates.push(MultiplierUpdate({ + previousMultiplier: 1e18, + newMultiplier: 1e18, + activationTime: 0 + })); + for (uint i = 0; i < _pastMultipliersUpdates.length; i++) { + require(_pastMultipliersUpdates[i].previousMultiplier > 0, "BackedAutoFeeTokenImplementation: previousMultiplier cannot be zero"); + multiplierUpdates.push(MultiplierUpdate({ + previousMultiplier: _pastMultipliersUpdates[i].previousMultiplier, + newMultiplier: _pastMultipliersUpdates[i].newMultiplier, + activationTime: _pastMultipliersUpdates[i].activationTime + })); + } + } + function _initialize_auto_fee( uint256 _periodLength, uint256 _lastTimeFeeApplied, @@ -214,6 +226,11 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedA periodLength = _periodLength; lastTimeFeeApplied = _lastTimeFeeApplied; feePerPeriod = _feePerPeriod; + multiplierUpdates.push(MultiplierUpdate({ + previousMultiplier: 1e18, + newMultiplier: 1e18, + activationTime: 0 + })); } /** @@ -292,6 +309,13 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedA return _getUnderlyingAmountByShares(_sharesAmount, currentMultiplier); } + /** + * @return Length of scheduled multipliers updates array + */ + function multiplierUpdatesLength() external view returns (uint256) { + return multiplierUpdates.length; + } + /** * @dev Delegated Transfer Shares, transfer shares via a sign message, using erc712. */ @@ -424,6 +448,7 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedA uint256 pendingNewMultiplierActivationTime ) public onlyMultiplierUpdater updateMultiplier onlyUpdatedMultiplier(oldMultiplier) { _updateMultiplier(pendingNewMultiplier, lastMultiplierNonce + 1, pendingNewMultiplierActivationTime); + _storeScheduledMultiplierUpdate(oldMultiplier, pendingNewMultiplier, pendingNewMultiplierActivationTime); } /** @@ -441,6 +466,46 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedA uint256 pendingNewMultiplierActivationTime ) external onlyMultiplierUpdater updateMultiplier onlyUpdatedMultiplier(oldMultiplier) onlyNewerMultiplierNonce(newMultiplierNonce){ _updateMultiplier(newMultiplier, newMultiplierNonce, pendingNewMultiplierActivationTime); + _storeScheduledMultiplierUpdate(oldMultiplier, newMultiplier, pendingNewMultiplierActivationTime); + } + + /** + * @dev Stores an explicit multiplier update in `multiplierUpdates`. + * + * If the previously stored entry is still pending (activationTime in the + * future), it is overwritten in place — only one scheduled update can be + * pending at a time — and a `MultiplierScheduleOverridden` event is + * emitted so off-chain consumers can reconcile the now-stale + * `MultiplierScheduled` event for the discarded entry. + * + * Tolerates an empty array (the not-yet-migrated state between a v4 + * implementation upgrade and an `initialize_v4` call) by falling through + * to the append path. + */ + function _storeScheduledMultiplierUpdate( + uint256 _previousMultiplierValue, + uint256 _multiplierValue, + uint256 _multiplierActivationTime + ) internal { + MultiplierUpdate memory entry = MultiplierUpdate({ + previousMultiplier: _previousMultiplierValue, + newMultiplier: _multiplierValue, + activationTime: _multiplierActivationTime > block.timestamp ? _multiplierActivationTime : block.timestamp + }); + + uint256 len = multiplierUpdates.length; + if (len > 0 && multiplierUpdates[len - 1].activationTime > block.timestamp) { + MultiplierUpdate memory overridden = multiplierUpdates[len - 1]; + multiplierUpdates[len - 1] = entry; + emit MultiplierScheduleOverridden( + overridden.newMultiplier, + overridden.activationTime, + entry.newMultiplier, + entry.activationTime + ); + } else { + multiplierUpdates.push(entry); + } } /** @@ -624,6 +689,7 @@ contract BackedAutoFeeTokenImplementation is BackedTokenImplementation, IBackedA if(pendingNewMultiplierActivationTime > block.timestamp) { newMultiplierActivationTime = pendingNewMultiplierActivationTime; + emit MultiplierScheduled(pendingNewMultiplier, pendingNewMultiplierActivationTime); // We don't need to update lastMultiplier and lastMultiplierNonce here, as they will be updated in updateMultiplier modifier when calling updateMultiplier method } else { newMultiplierActivationTime = 0; diff --git a/contracts/interfaces/IBackedAutoFeeToken.sol b/contracts/interfaces/IBackedAutoFeeToken.sol index 62a33a1..0dac24b 100644 --- a/contracts/interfaces/IBackedAutoFeeToken.sol +++ b/contracts/interfaces/IBackedAutoFeeToken.sol @@ -15,6 +15,69 @@ import "./IBackedToken.sol"; * - The multiplier can be updated by an authorized multiplierUpdater address */ interface IBackedAutoFeeToken is IBackedToken { + /** + * @dev Struct representing multiplier update + * @param previousMultiplier The multiplier value before this update + * @param newMultiplier The multiplier value after this update + * @param activationTime The Unix timestamp when this update was/will be activated + */ + struct MultiplierUpdate { + uint256 previousMultiplier; + uint256 newMultiplier; + uint256 activationTime; + } + + // Events + + /** + * @dev Emitted when the multiplier updater address is changed + * @param newMultiplierUpdater The address of the new multiplier updater + */ + event NewMultiplierUpdater(address indexed newMultiplierUpdater); + + /** + * @dev Emitted when shares are transferred between addresses + * @param from The address shares are transferred from + * @param to The address shares are transferred to + * @param value The amount of shares transferred + */ + event TransferShares(address indexed from, address indexed to, uint256 value); + + /** + * @dev Emitted when the multiplier value is updated and activated + * @param value The new multiplier value (in 1e18 precision) + */ + event MultiplierUpdated(uint256 value); + + /** + * @dev Emitted when a multiplier is scheduled for future activation + * @param newMultiplier The new multiplier value that will be activated (in 1e18 precision) + * @param activationTime The Unix timestamp when the multiplier will become active + */ + event MultiplierScheduled(uint256 newMultiplier, uint256 activationTime); + + /** + * @dev Emitted when a previously scheduled multiplier update is replaced + * before its activationTime is reached. The overridden entry is removed + * from `multiplierUpdates` in place and replaced by the new one. + * + * Off-chain consumers reconstructing state from events should drop any + * earlier `MultiplierScheduled(overriddenMultiplier, overriddenActivationTime)` + * upon seeing this event. + * + * @param overriddenMultiplier The newMultiplier of the pending entry that + * was discarded (the one previously announced via MultiplierScheduled) + * @param overriddenActivationTime The activationTime of the discarded entry + * @param newMultiplier The newMultiplier that replaces it (already announced + * in this same transaction via MultiplierScheduled or MultiplierUpdated) + * @param newActivationTime The activationTime stored for the replacement + */ + event MultiplierScheduleOverridden( + uint256 overriddenMultiplier, + uint256 overriddenActivationTime, + uint256 newMultiplier, + uint256 newActivationTime + ); // View functions - EIP-712 and Roles @@ -119,6 +182,44 @@ interface IBackedAutoFeeToken is IBackedToken { */ function getUnderlyingAmountByShares(uint256 _sharesAmount) external view returns (uint256); + /** + * @dev Returns the length of the multiplierUpdates array. + * + * The array records *explicit* multiplier updates only (those submitted + * via `updateMultiplierValue` / `updateMultiplierWithNonce`). Automatic + * per-period fee decay is NOT appended. See `multiplierUpdates` for the + * full semantics. + * + * @return The number of explicit multiplier updates stored + * (including the genesis sentinel at index 0). + */ + function multiplierUpdatesLength() external view returns (uint256); + + /** + * @dev Returns a specific explicit multiplier update by index. + * + * This array is an append-only log of *explicit* multiplier updates only; + * automatic per-period fee decay is applied lazily to `lastMultiplier` + * without appending here. As a result `previousMultiplier` at index `i` + * is the fee-decayed value at the time of the i-th explicit update and + * is typically less than `newMultiplier` at index `i-1` — the gap is the + * accrual that happened in between. + * + * Index 0 is a genesis sentinel `{1e18, 1e18, 0}`. A future-dated entry + * that is overridden before activation is popped from this array; the + * `MultiplierScheduled` event for the popped entry remains on chain. + * + * @param index The index in the multiplierUpdates array + * @return previousMultiplier The (possibly fee-decayed) multiplier value + * immediately before this explicit update was applied + * @return newMultiplier The multiplier value after this update + * @return activationTime The Unix timestamp when this update was/will be + * activated; equals `block.timestamp` at recording time for + * immediate updates, or the requested future timestamp for + * scheduled ones + */ + function multiplierUpdates(uint256 index) external view returns (uint256 previousMultiplier, uint256 newMultiplier, uint256 activationTime); + // State-changing functions - Share Transfers /** diff --git a/test/BackedAutoFeeTokenImplementation.ts b/test/BackedAutoFeeTokenImplementation.ts index 972af5b..ece4b0c 100644 --- a/test/BackedAutoFeeTokenImplementation.ts +++ b/test/BackedAutoFeeTokenImplementation.ts @@ -2019,6 +2019,218 @@ describe("BackedAutoFeeTokenImplementation", function () { }); }); + describe('#multiplierUpdates', () => { + describe('Initial state', () => { + it('Should have one initial entry after initialization', async () => { + expect(await token.multiplierUpdatesLength()).to.be.equal(1); + }); + + it('Should have correct initial values', async () => { + const initialUpdate = await token.multiplierUpdates(0); + expect(initialUpdate.previousMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18)); + expect(initialUpdate.newMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18)); + expect(initialUpdate.activationTime).to.be.equal(0); + }); + }); + + describe('When updating multiplier immediately', () => { + cacheBeforeEach(async () => { + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(95).div(100); // 5% decrease + await token.updateMultiplierValue(newMult, currentMult, 0); + }); + + it('Should add new entry to multiplierUpdates', async () => { + expect(await token.multiplierUpdatesLength()).to.be.equal(2); + }); + + it('Should store correct values in new entry', async () => { + const update = await token.multiplierUpdates(1); + const currentTime = await helpers.time.latest(); + expect(update.previousMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18)); + expect(update.newMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18).mul(95).div(100)); + expect(update.activationTime).to.be.equal(currentTime); + }); + }); + + describe('When updating multiplier with future activation', () => { + const futureTime = baseTime + accrualPeriodLength / 2; + + cacheBeforeEach(async () => { + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(110).div(100); + await token.updateMultiplierValue(newMult, currentMult, futureTime); + }); + + it('Should add new entry with future activation time', async () => { + expect(await token.multiplierUpdatesLength()).to.be.equal(2); + const update = await token.multiplierUpdates(1); + expect(update.activationTime).to.be.equal(futureTime); + }); + + it('Should emit MultiplierScheduled event', async () => { + // This test is within a nested context where futureTime is already set + // and multiplier update was already done in the cacheBeforeEach + // Let's verify the event was already emitted in that transaction + const update = await token.multiplierUpdates(1); + expect(update.activationTime).to.be.equal(futureTime); + + // The MultiplierScheduled event was emitted in the cacheBeforeEach + // We can verify by checking that a scheduled update exists + expect(await token.newMultiplierActivationTime()).to.be.equal(futureTime); + }); + + describe('And then updating again before activation', () => { + it('Should overwrite previous pending update', async () => { + const currentMult = await token.multiplier(); + const newerMult = currentMult.mul(120).div(100); + const newerTime = baseTime + accrualPeriodLength / 4; + + await token.updateMultiplierValue(newerMult, currentMult, newerTime); + + // Should still have 2 entries (initial + current), previous pending was overwritten + expect(await token.multiplierUpdatesLength()).to.be.equal(2); + + const lastUpdate = await token.multiplierUpdates(1); + expect(lastUpdate.newMultiplier).to.be.equal(newerMult); + expect(lastUpdate.activationTime).to.be.equal(newerTime); + }); + }); + + describe('And then updating after activation', () => { + it('Should add new entry after previous one activates', async () => { + // Move time to activation + await helpers.time.setNextBlockTimestamp(futureTime); + await token.transfer(actor.address, 1); + + const currentMult = await token.multiplier(); + const anotherNewMult = currentMult.mul(105).div(100); + await token.updateMultiplierValue(anotherNewMult, currentMult, 0); + + // Should have 3 entries now + expect(await token.multiplierUpdatesLength()).to.be.equal(3); + }); + }); + }); + + describe('Multiple sequential updates', () => { + it('Should track history of all activated multiplier updates', async () => { + // Mint some tokens first to have balance for transfers + await token.connect(minter.signer).mint(owner.address, ethers.BigNumber.from(10).pow(20)); + + let currentMult = await token.multiplier(); + + // First update + const newMult1 = currentMult.mul(95).div(100); + await token.updateMultiplierValue(newMult1, currentMult, 0); + + // Move time forward + await helpers.time.setNextBlockTimestamp(baseTime + accrualPeriodLength); + await token.transfer(actor.address, ethers.BigNumber.from(10).pow(18)); + + // Second update + currentMult = await token.multiplier(); + const newMult2 = currentMult.mul(98).div(100); + await token.updateMultiplierValue(newMult2, currentMult, 0); + + // Should have 3 entries total + expect(await token.multiplierUpdatesLength()).to.be.equal(3); + + // Verify entries + const update1 = await token.multiplierUpdates(1); + const update2 = await token.multiplierUpdates(2); + + expect(update1.previousMultiplier).to.be.equal(ethers.BigNumber.from(10).pow(18)); + expect(update2.previousMultiplier).to.not.equal(ethers.BigNumber.from(10).pow(18)); + }); + }); + }); + + describe('#initialize_v4', () => { + describe('When called on already initialized v4 contract', () => { + it('Should revert', async () => { + // Current token already has multiplierUpdates initialized + await expect( + token.initialize_v4([]) + ).to.be.revertedWith("BackedAutoFeeTokenImplementation v4 already initialized"); + }); + }); + + describe('Initialize_v4 behavior validation', () => { + it('Should only allow initialization when array is empty', async () => { + // The initialize_v4 check: multiplierUpdates.length == 0 + // This means it's designed for contracts that were deployed before v4 + const currentLength = await token.multiplierUpdatesLength(); + expect(currentLength).to.be.equal(1); // Already initialized with one entry + + // Attempting to call it when array is not empty should revert + await expect(token.initialize_v4([])).to.be.revertedWith( + "BackedAutoFeeTokenImplementation v4 already initialized" + ); + }); + }); + }); + + describe('#multiplierUpdatesLength', () => { + it('Should return correct length', async () => { + const length = await token.multiplierUpdatesLength(); + expect(length).to.be.equal(1); + }); + + it('Should increment after each multiplier update', async () => { + const initialLength = await token.multiplierUpdatesLength(); + + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(98).div(100); + await token.updateMultiplierValue(newMult, currentMult, 0); + + const newLength = await token.multiplierUpdatesLength(); + expect(newLength).to.be.equal(initialLength.add(1)); + }); + }); + + describe('#MultiplierScheduled event', () => { + it('Should emit MultiplierScheduled when setting future activation', async () => { + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(105).div(100); + const futureTime = baseTime + accrualPeriodLength / 2; + + const tx = token.updateMultiplierValue(newMult, currentMult, futureTime); + + await expect(tx) + .to.emit(token, "MultiplierScheduled") + .withArgs(newMult, futureTime); + }); + + it('Should emit MultiplierUpdated (not MultiplierScheduled) when activating immediately', async () => { + const currentMult = await token.multiplier(); + const newMult = currentMult.mul(105).div(100); + + const tx = token.updateMultiplierValue(newMult, currentMult, 0); + + await expect(tx) + .to.emit(token, "MultiplierUpdated") + .withArgs(newMult); + + await expect(tx) + .to.not.emit(token, "MultiplierScheduled"); + }); + + it('Should emit MultiplierScheduled when using updateMultiplierWithNonce', async () => { + const currentMult = await token.multiplier(); + const currentNonce = await token.multiplierNonce(); + const newMult = currentMult.mul(105).div(100); + const newNonce = currentNonce.add(10); + const futureTime = baseTime + accrualPeriodLength / 2; + + const tx = token.updateMultiplierWithNonce(newMult, currentMult, newNonce, futureTime); + + await expect(tx) + .to.emit(token, "MultiplierScheduled") + .withArgs(newMult, futureTime); + }); + }); + }); function nthRoot(annualFee: number, n: number) { return Decimal.pow(1 - annualFee, new Decimal(1).div(n)); From a3702ad5e713ebb2b8be8f059c57eac1f613baa5 Mon Sep 17 00:00:00 2001 From: Jakub Biernaczyk Date: Sat, 16 May 2026 23:36:50 +0200 Subject: [PATCH 2/3] test: Missing tests added to BackedAutoFeeTokenImplementation.ts, improved tests to shorten runtime and make sure they pass even on slower machines --- test/BackedAutoFeeTokenImplementation.ts | 158 ++++++++++++++++++++++- 1 file changed, 156 insertions(+), 2 deletions(-) diff --git a/test/BackedAutoFeeTokenImplementation.ts b/test/BackedAutoFeeTokenImplementation.ts index ece4b0c..9192490 100644 --- a/test/BackedAutoFeeTokenImplementation.ts +++ b/test/BackedAutoFeeTokenImplementation.ts @@ -638,7 +638,10 @@ describe("BackedAutoFeeTokenImplementation", function () { cacheBeforeEach(async () => { userBalance = await token.getUnderlyingAmountByShares(sharesToTransfer); - deadline = baseTime * 2; + // Far enough ahead that the "time moved forward" tests stay within it, + // but close enough that crossing it only elapses a handful of fee + // periods (the updateMultiplier modifier loops once per period). + deadline = baseTime + 8 * accrualPeriodLength; nonce = await token.nonces(owner.address); const domain = { name: await token.name(), @@ -1886,7 +1889,7 @@ describe("BackedAutoFeeTokenImplementation", function () { // Use a deadline only slightly in the future so we can pass it without // letting many fee periods elapse (which would cause the multiplier to // decay and revert before the deadline check is reached). - deadline = baseTime + 100; + deadline = baseTime + 300; const nonce = await token.nonces(owner.address); const domain = { name: await token.name(), @@ -1926,6 +1929,72 @@ describe("BackedAutoFeeTokenImplementation", function () { }); }); + describe('#delegatedTransferShares - updateMultiplier branch', () => { + // Exercises both sides of the `if (lastMultiplier != currentMultiplier)` + // branch inside the updateMultiplier modifier as reached via + // delegatedTransferShares. + const baseMintedAmount = ethers.BigNumber.from(10).pow(18); + const sharesToTransfer = ethers.BigNumber.from(10).pow(18).div(4); + let signature: string; + let deadline: number; + + const buildSignature = async () => { + deadline = baseTime + 100 * accrualPeriodLength; + const nonce = await token.nonces(owner.address); + const domain = { + name: await token.name(), + version: "1", + chainId: await owner.signer.getChainId(), + verifyingContract: token.address + }; + const types = { + DELEGATED_TRANSFER_SHARES: [ + { type: 'address', name: 'owner' }, + { type: 'address', name: 'to' }, + { type: 'uint256', name: 'value' }, + { type: 'uint256', name: 'nonce' }, + { type: 'uint256', name: 'deadline' } + ] + }; + const msg = { + owner: owner.address, + to: actor.address, + value: sharesToTransfer, + nonce: nonce, + deadline: deadline + }; + const signer = await ethers.getSigner(owner.address); + signature = await signer._signTypedData(domain, types, msg); + }; + + cacheBeforeEach(async () => { + await token.connect(minter.signer).mint(owner.address, baseMintedAmount); + await token.setDelegateWhitelist(actor.address, true); + }); + + it('Should not touch the multiplier when no fee period has elapsed', async () => { + // No full period elapsed since lastTimeFeeApplied: getCurrentMultiplier + // returns the stored value, so the modifier skips _updateMultiplier. + await buildSignature(); + const sig = ethers.utils.splitSignature(signature); + await token.connect(actor.signer).delegatedTransferShares( + owner.address, actor.address, sharesToTransfer, deadline, sig.v, sig.r, sig.s + ); + expect(await token.lastMultiplier()).to.be.equal(await token.multiplier()); + }); + + it('Should update the multiplier when fee periods have elapsed', async () => { + await buildSignature(); + const multiplierBefore = await token.lastMultiplier(); + await helpers.time.setNextBlockTimestamp(baseTime + 10 * accrualPeriodLength); + const sig = ethers.utils.splitSignature(signature); + await token.connect(actor.signer).delegatedTransferShares( + owner.address, actor.address, sharesToTransfer, deadline, sig.v, sig.r, sig.s + ); + expect(await token.lastMultiplier()).to.be.lt(multiplierBefore); + }); + }); + describe('#updateMultiplier modifier - multiplier decays to zero', () => { // After raising the fee to (1e18 - 1) and letting a few periods pass, // the multiplier rounds down to zero in the modifier's update loop. @@ -1987,6 +2056,18 @@ describe("BackedAutoFeeTokenImplementation", function () { token.connect(minter.signer).mint(owner.address, 1) ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); }); + + it('delegatedTransferShares should revert', async () => { + // The updateMultiplier modifier runs (and reverts) before the function + // body, so the signature is never reached and can be a dummy value. + await token.setDelegateWhitelist(actor.address, true); + const dummy = ethers.utils.hexZeroPad("0x01", 32); + await expect( + token.connect(actor.signer).delegatedTransferShares( + owner.address, tmpAccount.address, 1, baseTime * 2, 27, dummy, dummy + ) + ).to.be.revertedWith("BackedToken: Multiplier cannot be zero"); + }); }); describe('#decimals', () => { @@ -2169,6 +2250,79 @@ describe("BackedAutoFeeTokenImplementation", function () { ); }); }); + + describe('When called on a proxy without multiplierUpdates populated', () => { + // Simulate a real pre-v4 -> v4 upgrade by upgrading a v1 proxy directly to + // the v4 implementation, leaving the multiplierUpdates array empty. + let tokenFreshV4: BackedAutoFeeTokenImplementation; + const oneE18 = ethers.BigNumber.from(10).pow(18); + + cacheBeforeEach(async () => { + const v1Implementation = await new BackedTokenImplementation__factory(owner.signer).deploy(); + const tokenProxy = await new BackedTokenProxy__factory(owner.signer).deploy( + v1Implementation.address, + proxyAdmin.address, + v1Implementation.interface.encodeFunctionData('initialize', [tokenName, tokenSymbol]) + ); + + const v4Implementation = await new BackedAutoFeeTokenImplementation__factory(owner.signer).deploy(); + await proxyAdmin.upgrade(tokenProxy.address, v4Implementation.address); + + tokenFreshV4 = BackedAutoFeeTokenImplementation__factory.connect(tokenProxy.address, owner.signer); + }); + + it('Should store only the genesis sentinel when no past updates are provided', async () => { + expect(await tokenFreshV4.multiplierUpdatesLength()).to.be.equal(0); + + await tokenFreshV4.initialize_v4([]); + + expect(await tokenFreshV4.multiplierUpdatesLength()).to.be.equal(1); + const genesis = await tokenFreshV4.multiplierUpdates(0); + expect(genesis.previousMultiplier).to.be.equal(oneE18); + expect(genesis.newMultiplier).to.be.equal(oneE18); + expect(genesis.activationTime).to.be.equal(0); + }); + + it('Should backfill provided past multiplier updates after the genesis sentinel', async () => { + const pastUpdates = [ + { + previousMultiplier: oneE18, + newMultiplier: ethers.BigNumber.from('990000000000000000'), + activationTime: 1_000, + }, + { + previousMultiplier: ethers.BigNumber.from('990000000000000000'), + newMultiplier: ethers.BigNumber.from('980000000000000000'), + activationTime: 2_000, + }, + ]; + + await tokenFreshV4.initialize_v4(pastUpdates); + + expect(await tokenFreshV4.multiplierUpdatesLength()).to.be.equal(3); // genesis + 2 + + for (let i = 0; i < pastUpdates.length; i++) { + const stored = await tokenFreshV4.multiplierUpdates(i + 1); + expect(stored.previousMultiplier).to.be.equal(pastUpdates[i].previousMultiplier); + expect(stored.newMultiplier).to.be.equal(pastUpdates[i].newMultiplier); + expect(stored.activationTime).to.be.equal(pastUpdates[i].activationTime); + } + }); + + it('Should revert when a past update has a zero previousMultiplier', async () => { + const pastUpdates = [ + { + previousMultiplier: 0, + newMultiplier: ethers.BigNumber.from('990000000000000000'), + activationTime: 1_000, + }, + ]; + + await expect(tokenFreshV4.initialize_v4(pastUpdates)).to.be.revertedWith( + "BackedAutoFeeTokenImplementation: previousMultiplier cannot be zero" + ); + }); + }); }); describe('#multiplierUpdatesLength', () => { From 3e39bdd4596f26de4a56a2a32b6fe02719d69c2c Mon Sep 17 00:00:00 2001 From: George Thoppil Date: Tue, 16 Jun 2026 16:18:36 -0400 Subject: [PATCH 3/3] add atomic v4 migration script and upgrade tests --- scripts/helpers/upgradeToV4.ts | 27 ++++++++ test/Upgradability.ts | 110 +++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 scripts/helpers/upgradeToV4.ts diff --git a/scripts/helpers/upgradeToV4.ts b/scripts/helpers/upgradeToV4.ts new file mode 100644 index 0000000..955316c --- /dev/null +++ b/scripts/helpers/upgradeToV4.ts @@ -0,0 +1,27 @@ +import { ethers } from "hardhat"; +import fs from "fs"; + +async function main() { + const PROXY_ADMIN = process.env.PROXY_ADMIN!; + const TOKEN_PROXY = process.env.TOKEN_PROXY!; + + type RawUpdate = { previousMultiplier: string; newMultiplier: string; activationTime: number }; + const pastUpdates: RawUpdate[] = JSON.parse(fs.readFileSync(process.env.PAST_UPDATES_FILE!, "utf8")); + + const v4Impl = await ( + await ethers.getContractFactory("BackedAutoFeeTokenImplementation") + ).deploy(); + await v4Impl.deployed(); + + const proxyAdmin = await ethers.getContractAt("ProxyAdmin", PROXY_ADMIN); + + const tx = await proxyAdmin.upgradeAndCall( + TOKEN_PROXY, + v4Impl.address, + v4Impl.interface.encodeFunctionData("initialize_v4", [pastUpdates]) + ); + const receipt = await tx.wait(); + console.log(`Upgrade + initialize_v4 mined in block ${receipt.blockNumber} (tx: ${tx.hash})`); +} + +main().catch((e) => { console.error(e); process.exit(1); }); \ No newline at end of file diff --git a/test/Upgradability.ts b/test/Upgradability.ts index 94ac90b..1d523df 100644 --- a/test/Upgradability.ts +++ b/test/Upgradability.ts @@ -280,3 +280,113 @@ describe("Upgrade from v1.1.0 to auto fee", () => { expect(await tokenV2.balanceOf(tmpAccount.address)).to.equal(150); }); }); + +describe("Upgrade to v4 (multiplier history migration)", () => { + let v4Impl: BackedAutoFeeTokenImplementation; + let token: BackedAutoFeeTokenImplementation; + let tokenV1: BackedTokenImplementation; + let v1Factory: BackedFactory; + + let owner: SignerWithAddress; + let minter: SignerWithAddress; + let burner: SignerWithAddress; + let pauser: SignerWithAddress; + let blacklister: SignerWithAddress; + let attacker: SignerWithAddress; + let sanctionsList: SanctionsListMock; + + const tokenName = "Wrapped Apple"; + const tokenSymbol = "WAAPL"; + + const past = [ + { previousMultiplier: "1000000000000000000", newMultiplier: "999000000000000000", activationTime: 1700000000 }, + { previousMultiplier: "999000000000000000", newMultiplier: "998000000000000000", activationTime: 1700086400 }, + ]; + + beforeEach(async () => { + owner = await getSigner(0); + minter = await getSigner(1); + burner = await getSigner(2); + pauser = await getSigner(3); + blacklister = await getSigner(4); + attacker = await getSigner(5); + + // Deploy a v1.1.0 token proxy (its multiplierUpdates array is empty, + // which is the precondition initialize_v4 requires). + v1Factory = await ( + await ethers.getContractFactory("BackedFactory") + ).deploy(owner.address); + + sanctionsList = await ( + await ethers.getContractFactory("SanctionsListMock", blacklister.signer) + ).deploy(); + + const receipt = await ( + await v1Factory.deployToken( + tokenName, + tokenSymbol, + owner.address, + minter.address, + burner.address, + pauser.address, + sanctionsList.address + ) + ).wait(); + + const deployedTokenAddress = receipt.events?.find( + (event: any) => event.event === "NewToken" + )?.args?.newToken; + + tokenV1 = await ethers.getContractAt( + "BackedTokenImplementation", + deployedTokenAddress + ); + + v4Impl = await ( + await ethers.getContractFactory("BackedAutoFeeTokenImplementation") + ).deploy(); + await v4Impl.deployed(); + }); + + it("atomic upgradeAndCall backfills history and is one-shot", async () => { + const proxyAdmin = await ethers.getContractAt( + "ProxyAdmin", + await v1Factory.proxyAdmin() + ); + + // Atomic: flip implementation AND run initialize_v4 in a single tx. + await proxyAdmin.upgradeAndCall( + tokenV1.address, + v4Impl.address, + v4Impl.interface.encodeFunctionData('initialize_v4', [past]) + ); + + token = await ethers.getContractAt( + "BackedAutoFeeTokenImplementation", + tokenV1.address + ); + + // Genesis sentinel at index 0 + the two backfilled entries. + expect(await token.multiplierUpdatesLength()).to.equal(3); + + const sentinel = await token.multiplierUpdates(0); + expect(sentinel.previousMultiplier).to.equal("1000000000000000000"); + expect(sentinel.newMultiplier).to.equal("1000000000000000000"); + expect(sentinel.activationTime).to.equal(0); + + const first = await token.multiplierUpdates(1); + expect(first.previousMultiplier).to.equal(past[0].previousMultiplier); + expect(first.newMultiplier).to.equal(past[0].newMultiplier); + expect(first.activationTime).to.equal(past[0].activationTime); + + const second = await token.multiplierUpdates(2); + expect(second.previousMultiplier).to.equal(past[1].previousMultiplier); + expect(second.newMultiplier).to.equal(past[1].newMultiplier); + expect(second.activationTime).to.equal(past[1].activationTime); + + // One-shot: any later call (any caller) reverts. + await expect(token.initialize_v4([])).to.be.revertedWith( + "BackedAutoFeeTokenImplementation v4 already initialized" + ); + }); +}); \ No newline at end of file