Skip to content

Latest commit

 

History

History
 
 

aptos

Wormhole on Aptos

This folder contains the reference implementation of the Wormhole cross-chain messaging protocol smart contracts on the Aptos blockchain, implemented in the Move programming language.

Project structure

The project is laid out as follows:

To see a minimal example of how to integrate with wormhole, check out sender.move.

Hacking

The project is under active development, and the development workflow is in constant flux, so these steps are subject to change.

Prerequisites

Install the aptos CLI. This tool is used to compile the contracts and run the tests.

$ git clone https://github.com/aptos-labs/aptos-core.git
$ cd aptos-core
aptos-core $ cargo build --package aptos --release
aptos-core $ mv target/release/aptos ~/.cargo/bin/aptos # move the binary to somewhere on your PATH

Next, install the worm CLI tool by running

wormhole/clients/js $ make install

worm is the swiss army knife for interacting with wormhole contracts on all supported chains, and generating signed messages (VAAs) for testing.

As an optional, but recommended step, install the move-analyzer Language Server (LSP):

cargo install --git https://github.com/move-language/move.git move-analyzer --branch main --features "address32"

Note the --features "address32" flag. This is important, because the default build only supports 16 byte addresses, but Aptos uses 32 bytes, so move-analyzer needs to be built with that feature flag to support 32 byte address literals.

This installs the LSP backend which is then supported by most popular editors such as emacs, vim, and even vscode.

For emacs, you may need to add the following to your config file:
;; Move
(define-derived-mode move-mode rust-mode "Move"
  :group 'move-mode)

(add-to-list 'auto-mode-alist '("\\.move\\'" . move-mode))

(with-eval-after-load 'lsp-mode
  (add-to-list 'lsp-language-id-configuration
    '(move-mode . "move"))

  (lsp-register-client
    (make-lsp-client :new-connection (lsp-stdio-connection "move-analyzer")
                     :activation-fn (lsp-activate-on "move")
                     :server-id 'move-analyzer)))

Building & running tests

The project uses a simple make-based build system for building and running tests. Running make test in this directory will run the tests for each contract. If you only want to run the tests for, say, the token bridge contract, then you can run make test in the token_bridge directory, or run make -C token_bridge test from this directory.

Additionally, make test-docker runs the tests in a docker container which is set up with all the necessary dependencies. This is the command that runs in CI.

Running a local validator and deploying the contracts to it

Simply run

worm aptos start-validator

which will start a local aptos validator with an RPC endpoint at 0.0.0.0:8080 and the faucet endpoint at 0.0.0.0:8081. Note that the faucet takes a few (~10) seconds to come up, so only proceed when you see the following:

Faucet is running.  Faucet endpoint: 0.0.0.0:8081

Once the validator is running, the contracts are ready to deploy. In the scripts directory, run

scripts $ ./deploy devnet

This will deploy the core contract, the token bridge, and an example contract for sending messages through wormhole.

When you make a change to the contract, you can simply restart the validator and run the deploy script again. However, a better way is to run one of the following scripts:

scripts $ ./upgrade devnet Core # for upgrading the wormhole contract
scripts $ ./upgrade devnet TokenBridge # for upgarding the token bridge contract
scripts $ ./upgrade devnet NFTBridge # for upgarding the NFT bridge contract

Behind the scenes, these scripts exercise the whole contract upgrade code path (see below), including generating and verifying a signed governance action, and the Move bytecode verifier checking ABI compatibility. If an upgrade here fails due to incompatibility, it will likely on mainnet too. (TODO: add CI action to simulate upgrades against main when there's a stable version)

Implementation notes / coding practices

In this section, we describe some of the implementation design decisions and coding practices we converged on along the way. Note that the coding guidelines are prescriptive rather than descriptive, and the goal is for the contracts to ultimately follow these, but they might not during earlier development phases.

Signers

In Move, each entry point function may take an optional first argument of type signer or &signer, as follows

public entry fun foo(user: &signer) {
  // do stuff
}

When a user signs a transaction that calls foo, their wallet will effectively be passed in as a signer to foo. This signer value can then be used to authorise arbitrary actions on behalf of the user, such as withdrawing their coins:

use aptos_framework::coin;
use aptos_framework::aptos_coin::AptosCoin;

public entry fun foo(user: &signer) {
    let coins = coin::withdraw<AptosCoin>(user, 100);

    // ...
}

The user value can even be passed on to other functions down the call stack, so the user has to fully trust foo and potentially understand its implementation to be sure that the transaction is safe to sign. Since the signer object can be passed arbitrarily deep into the call stack, tracing the exact path is onerous. This hurts composability too, because composing contracts that take signers now places additional burden on each caller to ensure that the callee contract is non-malicious and trust that it won't turn malicious in the future through an upgrade. Thus we consider taking signer arguments an anti-pattern, and avoid it wherever possible.

Here, foo requires the user's signer to be able to withdraw 100 aptos coins. A clearer and safer way to achieve this is by writing foo in the following way:

use aptos_framework::coin::{Self, Coin};
use aptos_framework::aptos_coin::AptosCoin;

public fun foo(coins: Coin<AptosCoin>) {
    assert!(coin::value(&coins) == 100, SOME_ERROR_CODE);

    // ...
}

Just the type of this version itself makes it extremely clear what foo really needs, and before calling this function the caller can just withdraw their coins themselves. As a convenience function, we may introduce a wrapper that does take a signer:

public entry fun foo_with_signer(user: &signer) {
    foo(coin::withdraw(user, 100))
}

which might be the preferred version for EOAs (externally owned accounts, aka user wallets), but never for other contracts.

The general rule of thumb is that a function that takes a signer should never pass that signer on to another function (except standard library functions like coin::withdraw). This way, deciding the safety of a function becomes much simpler for users and integrators.

Access control: fine-grained capabilities

signer objects can also be used for access control, because the existence of a signer value with a given address proves that the address authorised that transaction. The key observation is that a signer is an unforgable token of authority, also known as a capability. The issue is, as described in the above section, is that the signer capability is too general, as it can authorise arbitrary actions on behalf of the user. For this reason, we don't use signers to implement access control, and instead turn to more fine-grained capabilities.

Thanks to Move's module system and linear type system, it is possible to implement first-class capabilities, i.e. non-forgeable objects of authority. For example, when sending a message, the wormhole contract needs to record the identity of the message sender in a way that cannot be forged by malicious actors. A potential solution would be to simply take the sender's signer object and encode its address into the message:

public fun publish_message(
    sender: &signer,
    nonce: u64,
    payload: vector<u8>,
    message_fee: Coin<AptosCoin>
): u64 {
// ...
}

However, again, this is not a great solution because the sender now needs to fully trust publish_message. Instead, we define a capability called EmitterCapability and require that instead:

public fun publish_message(
    emitter_cap: &mut emitter::EmitterCapability,
    nonce: u64,
    payload: vector<u8>,
    message_fee: Coin<AptosCoin>
): u64 {

The EmitterCapability type is defined in emitter.move as

struct EmitterCapability has store {
    emitter: u128,
    sequence: u64
}

note that it has no drop or copy abilities, only store, which means that once created, the object cannot be destroyed or copied, but it can be stored in the storage space of a smart contract. Before being able to send messages through wormhole, integrators must obtain such an EmitterCapability by calling

public fun register_emitter(): emitter::EmitterCapability

in ./wormhole/sources/wormhole.move. Note that this function does not take any arguments (in particular no signer), and returns an EmitterCapability. The contract can then store this and use as a unique identifier in the future when sending messages through wormhole. Since the wormhole contract is the only entity that can create new EmitterCapability objects (protected by a similar capability mechanism, see the emitter.move module for more details), it can guarantee that the emitter field is globally unique for each new emitter.

An important safety property of Move is that structs (like EmitterCapability) are fully opaque outside of the module that defines them. This means that there's no way to introspect, modify, or transfer them outside of the defining module. In turn, the defining module may choose to expose an API that provides restricted access to the contents. For example, emitter.move defines a getter function for the emitter field:

public fun get_emitter(emitter_cap: &EmitterCapability): u128 {
    emitter_cap.emitter
}

notice the emitter_cap.emitter field accessor syntax, which is only legal in the defining module of the struct. The only way to access the sequence field is the following function:

public(friend) fun use_sequence(emitter_cap: &mut EmitterCapability): u64 {
    let sequence = emitter_cap.sequence;
    emitter_cap.sequence = sequence + 1;
    sequence
}

That is, the emitter capability's sequence counter can only be incremented outside of this module, but not modified arbitrarily. As a further security measure, this function is marked as public(friend), which means it's only accessible from modules that are declared as a "friend" of the emitter module.

In practice, the public_message function will call this function to get and increment the sequence number each time a message is sent.

The fact that the caller can produce a reference to an EmitterCapability is proof that either they have direct access to the storage that owns it, or they have been passed the reference from the actual owner. This pattern enables better composability: EmitterCapability objects can be transferred in case a non-upgradeable contract wants to migrate to a new version but still be able to reuse the same wormhole emitter identity. They can also be passed by reference down the callstack (through borrowing), which makes it possible for a contract to send a message on behalf of another contract with explicit permission, since the caller contract needs to pass in the reference. It is also possible for a single application to have multiple emitter identities at the same time, which uncovers new use cases that have not been easily possible in other chains.

Contract deployment/upgrades

In order to support security patches and new features, the wormhole contracts implement upgradeability through governance. For this to be possible without one single entity having full control over the contracts, the contracts need to be their own signing authority. For details on how this is implemented, see deployer.move and contract_upgrade.move.

Newtypes

TODO