wormhole/aptos/nft_bridge/README.md

157 lines
8.5 KiB
Markdown
Raw Normal View History

2022-12-12 22:35:12 -08:00
# Aptos NFT Bridge
This contract is a reference implementation of the [Wormhole NFT bridge
specification](../../whitepapers/0006_nft_bridge.md) on Aptos, written in the
Move programming language.
This document provides an overview of the design and structure of the program.
## NFTs on Aptos
The [Aptos Token
specification](https://aptos.dev/concepts/coin-and-token/aptos-token/) provides
a good overview of how Tokens are specified on Aptos, but we review the relevant
parts here.
First, it's important to mention that the Token specification is more general
than NFTs, as Tokens can be used to describe both fungible and non-fungible
tokens.
Tokens belong to collections, which in turn belong to their creators. Given a
creator address, the collection's name (string) uniquely identifies the
collection. Within a collection, a token's name uniquely identifies the token.
These tokens may be fungible however: it's possible to have multiple copies of
them which are fully interchangeable. Each type of token has a set of properties
of key-value pairs, that allow the creator to attach custom information to the
tokens, such as hair colour. For an example, see
https://www.topaz.so/assets/Aptos-Undead-5a4505c2e9/Aptos%20Undead%20%233085/0.
The base token (which is identified by `(creator, collection_name, token_name)`)
has a set of "default" properties. It is possible to create "editions" of these
base tokens, which turn them into unique variations with additional properties
relative to the base token. The modified editions are unique and non-fungible,
so they are NFTs. When such an edition is created, it gets assigned a version,
called the `property_version`, within the base token. Such editions are thus
identified by `(creator, collection_name, token_name, property_version)`. When
the `property_version` is 0, the token may be fungible, but when it is non-0,
only a single copy may exist.
From the documentation, it appears that this property versioning is more of an
optimisation to allow bulk-minting NFTs cheaply and later add properties in a
copy-on-write fashion. It's unclear if the fungibility is meaningful outside of
this optimisation, as fungible tokens are already better supported by the first
class `Coin` type.
## Wormhole NFT Bridge
The Wormhole NFT bridge specification (which is based on ERC721) uses 32 bytes
to identify collections (only 20 bytes of which are used on EVM chains, for the
contract address) and another 32 bytes to identify the token within the
collection. Neither of these fields are sufficient to pack the necessary
information on Aptos, since the creator address itself is already 32 bytes, and
the collection name can be an arbitary string up to 128 bytes. Token names can
also be arbitary 128 byte strings.
Thus, we store 32 byte hashes of these two fields respectively. The exact
details of how the hashes are computed are defined in
[token_hash.move](./sources/token_hash.move). The collection's hash is computed
from the creator and the collection name. The hash of the individual NFTs is
computed from the creator, the collection's name, the token name, and the
property version. Note that it would be sufficient to just take the token name
and the property version, but this way the token's hash can be used as a
globally unique identifier, which simplifies the implementation.
When transferring a native token out for the first time, we
(`state::set_native_asset_info`) store a mapping from its hash to the token's
`TokenId`, so it can be retrieved when transferring the token back
(`state::get_native_asset_info`).
### Wrapped asset creation
When transferring an NFT from a collection on a foreign chain to Aptos, a
corresponding "wrapped" collection is created. The module responsible for this
is [wrapped.move](sources/wrapped.move). The collection name is the the NFT name
field from the transfer VAA. To avoid collisions here, each NFT is minted into a
freshly created creator account, implemented as a resource account.
### Handling "fungible" tokens
As discussed above, tokens whose property version is 0 are technically fungible.
We could disallow tokens whose property version is 0, and only allow
transferring ones that are non-0. However, many real-world NFT projects (such as
[Aptos Undead](https://www.topaz.so/collection/Aptos-Undead-5a4505c2e9)) simply
mint all tokens as separate tokens with property version 0 (and don't
necessarily use editions). We could instead check that the supply of the token
is 1, but new tokens can always be minted after the check is performed anyway.
Also, the supply is only tracked if the token has a specified maximum supply,
which, again, real-world NFT projects may not specify.
Instead, we don't check the supply, and simply allow transferring a single copy
of `property_version = 0` tokens at a given time. What this means is that when a
token is transferred out, we check that only a single copy is sent at a given
time, and also that there is at most 1 token held by the NFT bridge contract.
This is the most general setup that supports existing NFT projects, but it does
mean there is an edge case where tokens that are legitimately fungible (but
decided to not use the `Coin` type for some reason) are transferrable through
the NFT bridge, although at most 1 can be locked at any given time, so this edge
case is not observable outside of Aptos.
An additional caveat: it is possible for the creator to mutate the properties of
an NFT by calling `token::mutate_one_token` (in fact this is the mechanism by
which property versions other than 0 are assigned). If the token already had a
non-0 property version, then this operation will simply mutate it in-place,
keeping the identity of the token. However, if the property version was 0, then
the token is burned and a new token with a non-0 property version is created in
its place. If this happens to a token held in custody by the NFT bridge, then
that token will be irredeemable. It does require the creator of the NFT to
explicitly mutate a token held by the NFT bridge.
### Handling Solana NFTs
Solana NFTs require special handling currently. This is because at the time the
Solana NFT bridge was first implemented, there was no notion of NFT collections,
and each NFT would simply be its own individual token. Due to the gas costs of
creating collections on Ethereum, the Solana NFT bridge simply puts all NFTs
into a single dummy collection, so when transferred to other chains, they end up
under the same collection. This means that storing the collection metadata in
the wrapped collection does not work due to the many-to-one mapping. Instead,
like on Ethereum, we implement a cache (the "SPL cache") to store the name and
symbol of these tokens separately in a mapping keyed by the solana token's
address. When transferring out, this cache is consulted to recover the metadata
needed in the outgoing VAA.
The `state::is_unified_solana_collection` implements the check to determine
whether this caching behaviour is needed. It not only checks for the source
chain (Solana) but also the dummy collection address. This allows smoothly
upgrading the Solana NFT bridge to use the real collection address, in which
case the collections will be preserved moving forward and the cache ignored.
The cache is set in `wrapped::create_or_find_wrapped_nft_collection` when
transferring in, and read by the `state::get_wrapped_asset_name_and_symbol`
function (used by `transfer_nft::lock_or_burn` on the way out).
## Governance
Outside of handling NFT transfers, the NFT bridge can perform two additional
operations, both of which require a VAA signed by the Wormhole guardians.
These are governance operations, as they alter the behaviour of the bridge.
Both of these governance operations are identical to the token bridge implementation.
### Registrations
Since sending messages through Wormhole is permissionless and message payloads
are arbitrary, any program could send messages that look like NFT transfers.
To ensure that such messages are accepted from a trusted set of contracts, the
NFT bridge maintains a set of known "emitters". These are stored in a table
`registered_emitters` in `state::State`, keyed by the chains' ids (i.e. at most
one emitter per chain). This mapping can be updated by submitting registration
VAAs (which are special VAAs that are signed manually by the guardians through a
governance ceremony), and handled in the `register_chain.move` module.
### Contract upgrades
Contract upgrades also require governance VAAs. In the case of Aptos, the VAA
will contain the hash of the bytecode we're upgrading to. This logic is
implemented in `contract_upgrade.move`.