Skip to content

feat: Base Token Holder contract #1992

Open
Raid5594 wants to merge 74 commits intodraft-v31from
ra/base-token-holder
Open

feat: Base Token Holder contract #1992
Raid5594 wants to merge 74 commits intodraft-v31from
ra/base-token-holder

Conversation

@Raid5594
Copy link
Collaborator

@Raid5594 Raid5594 commented Jan 23, 2026

What ❔

Adds Base Token Holder contract to L2 in order to manage minting / burning.

Why ❔

Better support for existing tooling. Former approach resulted in harder debugging of transactions

Checklist

  • PR title corresponds to the body of PR (we generate changelog entries from PRs).
  • Tests for the changes have been added / updated.
  • Documentation comments have been added / updated.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3cd2430c09

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Copy link
Collaborator

@StanislavBreadless StanislavBreadless left a comment

Choose a reason for hiding this comment

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

As discussed in DM, the BaseTokenHolder is not used rn inside the l1-contracts folder, while on the contrary every time we try to use L2BaseToken there we should use BaseTokenHolder (the only situation where it is still okay to use L2BaseToken is inside bootloader as it is basically the same "manual" manipulation as we'll do inside zksync os)

/// @dev then transfers all tokens to BaseTokenHolder.
/// @dev Can only be called by the ComplexUpgrader contract.
function initializeBaseTokenHolderBalance() external {
if (msg.sender != L2_COMPLEX_UPGRADER_ADDR) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: this if statement can be moved to a single modifier to reuse the logic

function setZkosPreV31TotalSupply(
uint256 _totalSupply
) external onlyAdmin onlyL1 returns (bytes32 canonicalTxHash) {
if (!s.zksyncOS) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

this method should not be calleable for new zksync os chains, their totalSupply effectively starts from zero.

also, IMO this method should not be calleable more than once as it is a permanent backdoor.

I added the corresponding mocks in this PR #2041, but we'll have to adapt the PR to use those.

L2_BASE_TOKEN_SYSTEM_CONTRACT_ADDR,
abi.encodeCall(IL2BaseTokenZKOS.setZkosPreV31TotalSupply, (_totalSupply))
);
emit SetZkosPreV31TotalSupply(_totalSupply);
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: better to call it ZKsyncOS. This is the right naming, though I do know we dont use it everywhere, but let's introduce the right one one step at a time

Copy link
Collaborator

@0xValera 0xValera left a comment

Choose a reason for hiding this comment

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

Not much issues found on my side, however I would like to quickly rereview once suggestions are applied, I've seen that there is a lot of outstanding comments (maybe most of them are resolved already, haven't checked conversations content).

One more thing to take into account is testing. I see that the new code is pretty covered already, but

  • L2BaseTokenEra is at 0% coverage, please add testing for it
  • I would suggest running claude with prompt asking to scan the PR, diff, and testing added, and to ensure that (at least) all happy path are covered, with sufficient asserts/checking of effects of interactions, not just calling functions.
    I would then rereview tests separately (doesn't take that much time), if these suggestions will be applied.

Comment on lines +43 to +50
* ## Selfdestruct Caveat
*
* The implicit meaning of this contract's balance is "funds that the chain can still mint".
* On Era, totalSupply is computed as INITIAL_BASE_TOKEN_HOLDER_BALANCE - eraAccountBalance[BaseTokenHolder].
* On ZK OS, totalSupply is computed as _zkosPreV31TotalSupply + (INITIAL - holder.balance).
* If funds were sent to this contract via selfdestruct (bypassing access controls), the holder balance
* would increase, causing totalSupply() to undercount. However, selfdestruct is not supported on Era.
* On ZK OS, selfdestruct would increase native balance, effectively returning tokens to the reserve.
Copy link
Collaborator

Choose a reason for hiding this comment

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

On ZK OS, selfdestruct would increase native balance, effectively returning tokens to the reserve.

This sentence confuses me. on ZKsync OS selfdestruct works, and thus it'll be possible for totalSupply to undercount, right?
What exactly is meant by "increasing native balance", and "returning tokens to the reserve"?

/// @param _account The address which to mint the funds to.
/// @param _amount The amount of ETH in wei to be minted.
function mint(address _account, uint256 _amount) external override onlyBootloader {
L2_ASSET_TRACKER.handleFinalizeBaseTokenBridgingOnL2(_amount);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I would follow CEI here just in case, and move call to L2AT after changes on L85-86

/// @notice Sets the pre-V31 total supply for ZKOS chains.
/// @dev Can only be called via a service transaction (triggered by the chain admin on L1).
/// @param _totalSupply The total supply that existed before the V31 upgrade.
function setZkosPreV31TotalSupply(uint256 _totalSupply) external {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I suggest adding event emission here (like the one in Admin.sol when calling this, we can think of it as follows:

  • event in Admin.sol is confirmation of intention on SL
  • event in L2BaseTokenZKOS is confirmation of action being done on L2

/// @dev L2BaseToken: returns burned tokens during withdrawals
modifier onlyBridgingCaller() {
if (
msg.sender != address(L2_INTEROP_HANDLER) &&
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we use L2_INTEROP_HANDLER_ADDR? For consistency.
No strong opinion here

*/
abstract contract L2BaseTokenBase is IL2BaseTokenBase {
/// @notice The L1Messenger contract for sending messages to L1
IL2ToL1Messenger internal constant L1_MESSENGER = IL2ToL1Messenger(L2_TO_L1_MESSENGER_SYSTEM_CONTRACT_ADDR);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Would it be possible to reuse the constant from l2contractinterfaces.sol?


/// @notice The pre-V31 total supply for ZKOS chains, set by chain admin via service transaction.
/// @dev On ZKOS chains, pre-V31 total supply was never tracked on-chain. This value is set after the V31 upgrade so that totalSupply() can be computed correctly.
/// @dev Only used by the ZKOS implementation. Declared in the base contract so that L2BaseTokenEra and L2BaseTokenZKOS share the same storage layout.
Copy link
Collaborator

Choose a reason for hiding this comment

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

they dont have to share the same storage layout. The reason why we have gap in this contract is so that if there is a variable that needs to be used in both places, we could insert it here, but if there are variables tied to only specific implementation, we should use it only there.

eraAccountBalance and __DEPRECATED_totalSupply are the exceptions only because they existed prior to v31 upgrade

}

/// @inheritdoc IAdmin
function setZkosPreV31TotalSupply(
Copy link
Collaborator

Choose a reason for hiding this comment

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

could you please mark baseTokenHasTotalSupply as true in this function? add a comment that we can just change this value, since we assume that the service transaction wont fail

function setZkosPreV31TotalSupply(
uint256 _totalSupply
) external onlyAdmin onlyL1 returns (bytes32 canonicalTxHash) {
if (!s.zksyncOS) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

please do not allow it to call the second time use baseTokenHasTotalSupply

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

As s.baseTokenHasTotalSupply is true for new chains, so it prevents it from being successfully called for new ZKsync OS chains

/// @dev The delta (INITIAL - holder.balance) tracks tokens minted after V31 via the BaseTokenHolder pattern.
/// @dev WARNING: totalSupply() will return an incorrect value until the chain admin sets the pre-V31 total supply via setZkosPreV31TotalSupply(). This is done as a separate post-upgrade step.
function totalSupply() external view returns (uint256) {
return _zkosPreV31TotalSupply + (INITIAL_BASE_TOKEN_HOLDER_BALANCE - L2_BASE_TOKEN_HOLDER_ADDR.balance);
Copy link
Collaborator

Choose a reason for hiding this comment

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

the following might happen:

  • After the upgrade, _zkosPreV31TotalSupply = 0.
  • But we know for a fact that on the chain some users have balances.

Someone might send 1 wei to L2_BASE_TOKEN_HOLDER_ADDR via selfdestruct.

Now: _zkosPreV31TotalSupply + (INITIAL_BASE_TOKEN_HOLDER_BALANCE - L2_BASE_TOKEN_HOLDER_ADDR.balance) would underflow leading to revert.
I think it is better to just totally revert unless the value has been set up. We have L2AssetTracker.needBaseTokenTotalSupplyBackfill variable for that, you can either reuse this variable or move it to this contract.
But please double check that it would not brick the chain.

@Raid5594 Raid5594 force-pushed the ra/base-token-holder branch 2 times, most recently from 6efe37b to 378b3bf Compare February 27, 2026 15:45
@Raid5594 Raid5594 force-pushed the ra/base-token-holder branch from 378b3bf to e15a710 Compare February 27, 2026 15:51
/// @dev Executes a bundle with value > 0 through InteropHandler, which calls
/// BaseTokenHolder.give(). Asserts that handleFinalizeBaseTokenBridgingOnL2 is called
/// on L2AssetTracker to track the inbound base token bridging.
function test_give_inboundFlow_notifiesAssetTracker() public {
Copy link
Collaborator

Choose a reason for hiding this comment

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

@0xValera tbh this test checks almost nothing: just that the function gets called. It does not check whether it succeeds, how the storage is updated, etc.

Probably it is easier to amend it as part of the bigger refactoring of our unit tests

function test_give_notifiesAssetTracker() public {
uint256 amount = 1 ether;

vm.mockCall(
Copy link
Collaborator

Choose a reason for hiding this comment

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

Same here @0xValera too many mocks, but I think it is easier to rewrite everything in one go

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

this is part of unit tests, or shouldn't we have mocks there?

/// @dev Formula: eraAccountBalance[holder] = INITIAL_BASE_TOKEN_HOLDER_BALANCE - __DEPRECATED_totalSupply + eraAccountBalance[holder]
/// @dev Can only be called by the ComplexUpgrader contract.
function initializeBaseTokenHolderBalance() external override onlyComplexUpgrader {
if (baseTokenHolderInitialized) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

baseTokenHolderInitialized or baseTokenHolderBalanceInitialized for precision?

Copy link
Collaborator

Choose a reason for hiding this comment

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

baseTokenHolderBalanceInitialized

this one is better I think


// We expect that for all registered tokens, the zero `totalPreV31TotalSupply` should be saved.

// For genesis chains, the base token is registered during _finalizeDeployments() via
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: could you please add require isAssetRegistered? this require should never get triggered, but it better to have invariants explicitly enforced

/// are truly new), this function is for upgrading existing chains where the base
/// token already exists on-chain but the asset tracker is deployed during the
/// current upgrade. The base token originates on L1 (non-native to this chain).
/// Returns without changes if the base token is already registered.
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: Since it is called first thing during the upgrade, it probably makes sense to just assert this as an invariant

/// @notice Emitted when base tokens are given out (minted) from the holder to a recipient.
/// @param to The address that received the base tokens.
/// @param amount The amount of base tokens given out.
event BaseTokenMinted(address indexed to, uint256 amount);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Maybe makes sense to rename to BaseTokenMintedInterop (or something like that). I am just a bit afraid of people overrelying on this event and e.g. expecting that the total sum of emitted from BaseTokenBurnt is <= the total sum from BaseTokenMinted.

I.e. I think this event is helpful, but we should clearly signify that it is not for all mints. We should also add the corresponding comment to the BaseTokenBurnt

/// @dev This function is specifically for the chain's native base token used for gas payments.
/// @param _amount The amount of base tokens being bridged into this chain.
function handleFinalizeBaseTokenBridgingOnL2(uint256 _amount) external onlyL2BaseTokenSystemContract {
function handleFinalizeBaseTokenBridgingOnL2(uint256 _amount) external onlyBaseTokenHolderOrL2BaseToken {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This mistake was not introduced in this PR, but this function is called both for L1-deposit and "interop" mints, but it always passes _fromChainId: L1_CHAIN_ID which is wrong, please amend the function to accept fromChainId


Address.sendValue(payable(_to), _amount);
L2_ASSET_TRACKER.handleFinalizeBaseTokenBridgingOnL2(_amount);
emit BaseTokenMinted(_to, _amount);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Need to coordinate with the explorer team how they will use these events at all.

Starting from this PR we will have two events for minting base token:

  • one inside the l2basetokenera contract
  • one inside this contract.

they would have to track the sum of those or smth (if they care about it at all).

I would personally not make it a blocker for merging, this discussion will probably be async and lengthy, but we can always adapt a bit later as part of the fix review


/// @dev Storage slot for totalPreV31TotalSupply mapping from
/// `forge inspect L2AssetTracker storage-layout`.
uint256 private constant TOTAL_PRE_V31_TOTAL_SUPPLY_SLOT = 157;
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit: maybe it is possible to use stdstore for variables related to it, like we do above?

/// InteropL2Info struct layout:
/// offset 0: totalWithdrawalsToL1
/// offset 1: totalSuccessfulDepositsFromL1
uint256 internal constant INTEROP_INFO_MAPPING_SLOT = 156;
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe makes sense to make the field public?

@github-actions
Copy link
Contributor

github-actions bot commented Mar 6, 2026

Coverage after merging ra/base-token-holder into draft-v31 will be

90.90%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
contracts/bridge
   BridgeHelper.sol100%100%100%100%
   BridgedStandardERC20.sol96.25%100%92.31%97.01%231–232
   L1ERC20Bridge.sol97.78%100%100%97.30%267
   L1Nullifier.sol96.12%100%100%95.40%446–447, 450, 476, 729, 732, 734, 750
   UpgradeableBeaconDeployer.sol100%100%100%100%
contracts/bridge/asset-router
   AssetRouterBase.sol98.53%100%100%98.21%132
   L1AssetRouter.sol91.62%100%86.67%92.70%116, 331, 342, 424–425, 444, 602, 613, 627, 632
contracts/bridge/asset-tracker
   AssetTrackerBase.sol93.33%100%88.89%95.24%88
   GWAssetTracker.sol92.11%100%92.50%92.05%135–137, 158, 208–210, 212–214, 219–220, 348–350, 356, 369, 504, 694
   L1AssetTracker.sol91.75%100%100%90.61%113, 131–132, 145, 150, 162, 326, 328–329, 332, 566, 573, 579, 585, 591, 619, 84
   LegacySharedBridgeAddresses.sol83.33%100%100%81.82%39, 41
contracts/bridge/interfaces
   AssetHandlerModifiers.sol75%100%100%66.67%13
contracts/bridge/ntv
   L1NativeTokenVault.sol98.17%100%100%97.70%191, 193
   NativeTokenVaultBase.sol98.94%100%100%98.73%164, 170
contracts/common
   MessageVerification.sol88.24%100%87.50%88.46%34, 41–42
   ReentrancyGuard.sol100%100%100%100%
contracts/common/l2-helpers
   L2ContractHelper.sol98.11%100%100%97.78%102
   SystemContractsCaller.sol52.50%100%60%51.43%42–43, 45, 47, 49, 51, 64, 67, 70, 73, 76, 81, 87, 89, 91, 94, 96
contracts/common/libraries
   DataEncoding.sol95.90%100%95.83%95.92%193, 283, 301, 307
   DynamicIncrementalMerkle.sol100%100%100%100%
   DynamicIncrementalMerkleMemory.sol98.96%100%100%98.84%196
   FullMerkle.sol98.28%100%100%98.11%109
   FullMerkleMemory.sol93.81%100%100%93.33%114, 131, 149, 163, 194, 90
   Merkle.sol100%100%100%100%
   MessageHashing.sol98.67%100%100%98.46%154
   SemVer.sol100%100%100%100%
   UncheckedMath.sol100%100%100%100%
   UnsafeBytes.sol100%100%100%100%
   ZKSyncOSBytecodeInfo.sol100%100%100%100%
contracts/common/libraries/TransientPrimitives
   TransientPrimitives.sol100%100%100%100%
contracts/core/bridgehub
   BridgehubBase.sol96.86%100%100%96.23%155, 304, 320, 580, 584, 587
   L1Bridgehub.sol91.92%100%100%90.91%137, 218, 293, 297–298, 301, 311, 96
   L2Bridgehub.sol66.67%100%60%68.57%102–103, 111, 113–114, 123, 128–129, 131–132, 75
contracts/core/chain-asset-handler
   ChainAssetHandlerBase.sol85.86%100%92.31%84.88%114, 121–122, 140–142, 191, 194, 203–204, 340, 344, 366
   L1ChainAssetHandler.sol88.07%100%82.35%89.13%182, 271, 273–275, 314, 87–88, 92–93
   L2ChainAssetHandler.sol84.21%100%80%85.71%120, 124, 69, 93
contracts/core/chain-registration
   ChainRegistrationSender.sol89.13%100%100%86.84%104, 127, 47, 94, 98
contracts/core/ctm-deployment
   CTMDeploymentTracker.sol100%100%100%100%
contracts/core/message-root
   L1MessageRoot.sol95.24%100%91.67%96.08%170–171
   L2MessageRoot.sol62.22%100%45.45%67.65%109, 116, 120–121, 42–43, 52–53, 57, 64, 80
   MessageRootBase.sol90%100%94.12%89.16%110, 130, 134, 211, 282, 301, 345, 353–354
contracts/governance
   AccessControlRestriction.sol100%100%100%100%
   ChainAdmin.sol97.87%100%100%97.30%44
   ChainAdminOwnable.sol100%100%100%100%
   Governance.sol100%100%100%100%
   L2ProxyAdminDeployer.sol100%100%100%100%
   PermanentRestriction.sol100%100%100%100%
   ServerNotifier.sol100%100%100%100%
   TransitionaryOwner.sol100%100%100%100%
contracts/governance/restriction
   Restriction.sol100%100%100%100%
   RestrictionValidator.sol100%100%100%100%
contracts/interop
   AttributesDecoder.sol100%100%100%100%
   InteropCenter.sol95.56%100%88.46%96.48%120–121, 132, 176–177, 575, 659
   InteropDataEncoding.sol100%100%100%100%
   InteropHandler.sol95.87%100%100%95.33%347, 369, 374, 447, 64
   L2InteropRootStorage.sol0%100%0%0%20–22, 41, 46, 52–53, 58, 60–62, 71, 73–74, 76–77, 81–82, 86, 88
   L2MessageVerification.sol100%100%100%100%
contracts/l2-system
   BaseTokenHolder.sol100%100%100%100%
   L2BaseTokenBase.sol100%100%100%100%
contracts/l2-system/era
   L2BaseTokenEra.sol100%100%100%100%
contracts/l2-system/zksync-os
   L1MessageGasLib.sol100%100%100%100%
   L1MessengerZKOS.sol94.12%100%100%92.86%29
   L2BaseTokenZKOS.sol100%100%100%100%
   SystemContext.sol100%100%100%100%
   ZKOSContractDeployer.sol0%100%0%0%15–17, 23, 29, 33–34
contracts/l2-upgrades
   L2ComplexUpgrader.sol0%100%0%0%23–25, 39, 44, 46, 56, 62–63, 70, 79–81, 84, 86–87
   L2GenesisForceDeploymentsHelper.sol93.83%100%100%93.29%120, 153, 199, 201, 205, 207–208, 238, 244, 463
   L2GenesisUpgrade.sol0%100%0%0%30, 37, 39–40, 43, 47–50, 53, 55, 63
   L2V30TestnetSystemProxiesUpgrade.sol0%100%0%0%101, 107, 30, 35–36, 38, 49, 55, 60, 64, 69, 74, 79, 84, 89, 94
   L2V31Upgrade.sol0%100%0%0%17–18, 23, 26
   SystemContractProxy.sol88%100%66.67%90.91%23–24
   SystemContractProxyAdmin.sol60%100%66.67%57.14%12, 18–19
   V31AcrossRecovery.sol0%100%0%0%46–48, 67–68, 70–71, 75, 80–81, 96
contracts/state-transition
   AccessControlEnumerablePerChainAddressUpgradeable.sol98.25%100%100%97.83%179
   ChainTypeManagerBase.sol96.19%100%100%95.29%206, 209, 383, 473, 478, 620, 683, 721, 797
   EraChainTypeManager.sol94.12%100%100%92.86%39
   ZKsyncOSChainTypeManager.sol80%100%66.67%83.33%26–27
contracts/state-transition/chain-deps
   DiamondInit.sol94.23%100%100%94%72, 76, 80
   DiamondProxy.sol100%100%100%100%
   StoredBatchHashing.sol100%100%100%100%
contracts/state-transition/chain-deps/facets
   Admin.sol92.04%100%93.94%91.71%100–102, 104–105, 116–118, 203, 207,

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants