b175dd43c8
* Add cspell configuration and custom dictionary The goal is to cut down on both incoming tyops, and well meaning but spammy tyop fix PRs. To run cspell locally install it and run: cspell '**/*.md' \ --config cspell.config.yaml \ --words-only \ --unique \ --quiet | sort --ignore-case * docs: cspell updates * wormchain: cspell updates * aptos: cspell updates * node: cspell updates * algorand: cspell updates * whitepapers: cspell updates * near: cspell updates * solana: cspell updates * terra: cspell updates * cosmwasm: cspell updates * ethereum: cspell updates * clients: cspell updates * cspell updates for DEVELOP document * github: run cspell github action * sdk: cspell updates * github: only run cspell on markdown files * algorand: EMMITTER --> EMITTER Suggested-by: @evan-gray * cspell: removed from dictionary Suggested-by: @evan-gray * aptos and node: cspell updates Suggested-by: @evan-gray * cosmowasm: doc updates for terra2 Suggested-by: @evan-gray * algorand: cspell updates Suggested-by: @evan-gray * algorand: cspell updates Suggested-by: @evan-gray * cspell: updated custom word dictionary This resorts the dictionary and adds a few new words from the algorand/MEMORY.md document around varints and integers. * cspell: sort the dictionary how vscode does it On macOS the sorting is locale dependent. To do this on macOS, you have to invert the case, do a character insensitive sort, and then invert the case again: LC_COLLATE="en_US.UTF-8" cspell '**/*.md' --config cspell.config.yaml \ --words-only \ --unique \ --no-progress \ --quiet \ | tr 'a-zA-Z' 'A-Za-z' \ | sort --ignore-case \ | tr 'a-zA-Z' 'A-Za-z' This requires the `LC_COLLATE` variable to be set to `en_US.UTF-8`, or it will not do the right thing. * docs: grammar clean up --------- Co-authored-by: Evan Gray <battledingo@gmail.com> |
||
---|---|---|
.. | ||
sources | ||
Makefile | ||
Move.toml | ||
README.md |
README.md
Aptos NFT Bridge
This contract is a reference implementation of the Wormhole NFT bridge specification 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 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 arbitrary string up to 128 bytes. Token names can also be arbitrary 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. 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. 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) 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
.