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.