Cross-rollup NFT wrapper and migration ideas

It may be possible to use the fact that rollups have a single source of chronological order (Ethereum L1), move some consensus logic to the wallet, and avoid L1 transactions altogether.

The wrapper should be created by the same key that owns the NFT (or is creating it, if it doesn’t exist yet), and should include the current rollup chainid for that NFT and the L2 block number during the wrapper creation.

On L1, the NFT would not be assigned to a specific rollup, but point to a list of rollups it could reside in (or be minted on, in the case of a pre-created “stem cell” NFT). The NFT could later be modified on L1 to point to a different list, but the default list for all NFTs would include all the major rollups so most NFT owners will never have to modify it.

When a client/wallet looks at an NFT, it will always check for the wrapper of that NFT on all the rollups in its list (Arbitrum and Optimism in the current example). Normally, only one rollup will have a valid live wrapper. Other rollups may have stale wrappers pointing to the rollup to which the NFT was migrated. (valid means signed by the current owner’s key, and live means that it doesn’t point to another rollup).

If conflicting (live) wrappers are found on multiple rollups, then the one that was created first is the valid one, and all others are ignored. In case of doubt, the client can check the L2 block number in each receipt, find the corresponding transactions on L1, and pick the earliest one. Different rollups cannot be in the same L1 transaction, so transactions that happened on rollups are guaranteed to have a canonical order.

The client uses the following logic (which needs to interact with the rpc of multiple rollups as well as L1)

def get_nft_wrapper(nft_ref):
    nft = L1.getNft(nft_ref)
    rollups = nft.getSupportedRollups()
    wrappers = []
    for L2 in rollups:
        if L2.hasNft(nft):
            wrapper = L2.getWrapper(nft)
            if wrapper and wrapper.rollup == L2 and check_sig(nft,wrapper):    # Valid and claims to be alive
                wrappers.append(wrapper)
    if not wrappers:
        return None
    if len(wrappers) == 1:
        return wrappers[0]
    return get_first_wrapper(wrappers)

def get_first_wrapper(wrappers):
    for wrapper in wrappers:
        wrapper.sortkey = get_wrapper_l1_blocknum(wrapper)
    sorted_wrappers = sorted(wrappers, key=lambda w: w.sortkey)
    if sorted_wrappers[0].sortkey < sorted_wrappers[1].sortkey:
        return sorted_wrappers[0]
    for wrapper in sorted_wrappers:
        blocknum = wrapper.sort_key
        wrapper.sortkey = (wrapper.sortkey << 128) + get_wrapper_tx_pos(wrapper,blocknum)
    return sorted(sorted_wrappers, key=lambda w: w.sortkey)[0]

def get_wrapper_l1_blocknum(wrapper):
    # Per-rollup logic.  Every rollup must have a way to match L2 block to an L1 block.
    if is_arbitrum(wrapper.rollup):
        # Arbitrum exposes l1BlockNumber for each L2 block via rpc:
        return int(web3.eth.get_block(wrapper.l2_blocknum)['l1BlockNumber']`,16)
    elif is_optimism(wrapper.rollup):
        # Optimism doesn't expose it through rpc but on L1 OVM_CanonicalTransactionChain emits TransactionBatchAppended event with the L2 batch number.
        topics = [web3.sha3('TransactionBatchAppended(uint256,bytes32,uint256,uint256,bytes)')]
        topics.append(wrapper.l2_blocknum)
        filt = {'fromBlock':0,'toBlock':'latest','address':OVM_CanonicalTransactionChain,'topics':topics}
        return web3.eth.get_logs(filt).blockNumber
    # Add logic for each supported rollup

def get_wrapper_tx_pos(wrapper,blocknum):
    # Per-rollup logic.  Every rollup emits an L1 event when an L2 block is submitted.
    if is_arbitrum(wrapper.rollup):
        # Arbitrum's Rollup contract emits NodeCreated(uint256...)
        topics = [web3.sha3('NodeCreated(uint256,...)'),wrapper.l2_blocknum] # the event where this was added
        filt = { 'fromBlock':blocknum,'toBlock':blocknum,'address':Rollup,'topics':topics}
        return web3.eth.get_logs(filt).transactionIndex
    elif is_optimism(wrapper.rollup):
        # Optimism's CTC contract emits TransactionBatchAppended(uint256...)
        topics = [web3.sha3('TransactionBatchAppended(uint256,...)'),wrapper.l2_blocknum] # the event where this was added
        filt = { 'fromBlock':blocknum,'toBlock':blocknum,'address':OVM_CanonicalTransactionChain,'topics':topics}
        return web3.eth.get_logs(filt).transactionIndex
    # Add logic for each supported rollup

If the NFT is created on a rollup using a “stem-cell” from L1, the creator should wait for the rollup’s next L1 block to be mined, and call get_nft_wrapper() to ensure that the same stem-cell hasn’t been claimed simultaneously anywhere. The creator now has ownership of the new NFT. If the NFT previously existed on L1, its current owner can claim it on any rollup.

To migrate the NFT to another rollup after it is claimed/created on one rollup, the current owner (verified by get_nft_wrapper(nft_ref).owner ) can migrate it by sending setting wrapper.rollup=B on rollup A, and then claiming a new wrapper for it on rollup B.

When a buyer wants to verify the ownership on an NFT that traversed multiple rollups, the client traverses the cross-rollup transfers back to the initial creation. First it finds the earliest valid (but not necessarily live) wrapper where the NFT was first claimed. If the wrapper is also live, then wrapper.owner is correct. If wrapper is not live (wrapper.rollup points to another rollup), it finds the wrapper for that nft on the 2nd rollup and repeats the process, until it reaches the live wrapper.

This protocol surely requires the client to perform a lot of work, but it demonstrates the feasibility of moving NFTs between rollups without ever touching L1.

1 Like

This is great, but it still doesn’t allow smart contracts to assess the “genuinity” of NFT. Frequent checkpointing could help, but it is a much weaker security assumption.

Right. A smart contract would have no way to check if the NFT is genuine. Only users will. If the smart contract is a market for trading NFTs, then user verification is probably good enough. Clients will disregard the fake wrapper and therefore not buy it through the contract.

But if the contract needs to do more complicated stuff with the NFT, such as use it as a collateral, then this method won’t work and we need canonical ownership. That would require occasional L1 transactions, although it could be O(1) for any number of NFTs by using a version of what I demonstrated here.

1 Like

We definitely need a generic exit bridge between rollups.

I demonstrated a similar O(1) message bridge between two rollups a while ago: https://github.com/yoavw/cross-rollup-bridge

The current implementation uses the rollup bridges to occasionally transfer a merkle root (can be triggered by anyone), and then users can claim the messages on the destination rollup. See the demo video demo in the repo.

A better (future) implementation would avoid using L1 transactions, if rollups would expose a recent L1 blockhash through an L2 precompile. That would allow proving L1 state on any rollup, and by extension, it would allow proving the state of another rollup by fetching and proving the relevant part of the other rollup’s storage (i.e. deploy Optimism’s contracts on Arbitrum with an modification that gets L1 state with proofs). These blockhashes can be covered by fraud proofs since they are known to EVM on L1.

An optimization I discussed with devs at Optimism and Arbitrum is to have precompiles that actually return pieces of current L1 state, so that the user doesn’t need to provide a merkle proof in calldata. The batch would only include the L1 blockhash from which the state was fetched. A fraud proof against bad L1 state would require that merkle proof against the L1 blockhash, which makes fraud proofs more expensive, but normal access becomes cheaper so it seems like a good trade-off.

As links to this post spread across Twitter, I can see that the discussion will probably just devolve into people promoting their own L2 projects.

The main question here is should we kick NFTs off of Ethereum Mainnet because they are using too much gas? And still the answer is no that would make everything worse.

What we actually need here is a Steve Jobs style solution~~Ethereum Mainnet just needs to get twice as good every year, twice as much block size, half as much delay, half as much carbon footprint. Every year. I don’t care whether this is possible or not, it is stupid to discuss what is possible, just go do it.

This has nothing to do with ETH and everything to do with the design of Opensea. We most definitely can do better and places like Mintable do a better job in terms of number of transactions needed to do anything.

The problem of a specific opcode that returns the “Global Exit Tree” root is that this opcode will go together with a proof. And it’s possible that this root changes since the user prepared the TX, and the TX would become invalid.

So I think it is better that the rollup just appends the “Global Exit Tree” root to an array of “global State trees” in the L2 state, keeping the old ones. So Users that want to withdraw tokens can refer to any “Global Exit Tree” root from the past. (This array may be limited to a maximum number of “old exit Tree” roots, so that the state does not grow forever).

I agree that would be good to have a universal locking contract to be used by bridges and L2s. I propose to start writing an EIP.

1 Like

What is your design?

My cross-rollup bridge addresses it by adding roots rather than updating them. So old proofs remained valid until they’re consumed at the destination rollup. See addRemoteRoot.

remoteRoots[keccak256(abi.encode(root,chainID))] = true;

The functionality is equivalent to my implementation, except that I use a boolean mapping rather than a list. If you keep it in a list, you also need to keep the chain ID.

Sounds good. We can do that. Do you think the interface of my bridge can be a start of it, or do we need something different?

Is anyone working on an implementation of a cross-rollup NFT wrapper besides @yoavw (hi @yoavw! :wave:) ?

1 Like

After seeing Vitalik’s post I was thinking why L2 rollups doesn’t have a read-only state of L1 in userspace of L2.

Then I saw yoavw’s post and his cross-rollup-bridge implementation which he already talks about this.

So my question is, why current rollup implementations doesn’t have this feature? It would simplify a lot of use cases.

For example contracts living in L1 can freeze it’s state for an L2 and can start with it’s full state in L1 on L2. If it needs to move back to L1 or to another rollup it can reconstruct it’s L1 state by reconstructing from L2’s state proofs? Am I being too naive thinking this is doable?

Our NFT project would be very excited to dedicate a public goods bounty for your work @zhew2013. I can also contribute in regards to onboarding projects to this thesis.

I’m sure this new projet will be helpful for NFTs ,it is a new revolution .
We can be informed for investing on this project .

vbuterin, isn’t this exaxctly what Immutable X (coop with Starkware) solves?
As you mentioned, zk-rollups solve the problem with high transaction fees.
Immutable X already uses a zk-rollup and integration of OpenSea is already on its roadmap.
Isn’t this a temporary solution?

I agree with this. It really would be much simpler to have everything on chain. If only.

Thanks so much for the kind onboarding offer! I will contact you once it is ready for some NFT project onboarding.

Quick question: after step 2 in @vbuterin proposal

Why don’t we add a step to create a receipt that wrapped NFT is created on B which can then be used to go back to A and destroy the original NFT and the proof of destruction is then provided again on B to re-mint the NFT on B?

@zhew2013 Hey Zhe. I’m working on onboarding NFTs to a new platform and would love to hear more. I’d love to contribute to the topic as well if you’d like. Reached out to you on github (https://github.com/zhew2013/Leetcode-Py/issues/12). Thanks.

Hello everybody I know this thread is long stale but I was inspired by it alongside some other community contributions when I was drafting a new proposed ERC (7611) that allows NFTs to be migrated across rollup platforms in a way that does not cede sovereignty to an external interoperability network.

I wanted to post here in case anybody is still actively thinking about this problem in the event that you would be interested in reviewing my proposal and giving me feedback on how I can improve it.

You can find it on the Ethereum Magicians forum under the title “ERC-7611: Sovereign Bridged NFTs” (can’t link directly on this forum)