Developer Guide This document provides a guide for developers who want to connect to OneChain. This guide includes how to get started, and some concepts that are important to understand when working with OneChain.
Getting Started To get started with OneChain, this guide will walk you through the process of installing OneChain binaries, and introduce you to some key core concepts of OneChain technology.
Installing OneChain Binaries OneChain source code is available on GitHub. You can download the source code and build it yourself, or you can directly build the binaries by cargo. The following steps will guide you through the process of building OneChain binaries.
Prerequisites Before you can build OneChain binaries, you need to have the following prerequisites installed on your machine.
Rust and Cargo The suggested method to install Rust is with rustup using cURL.
Some other commands in the installation instructions also require cURL to run. If you can't run the cURL command to install Rust, see the instructions to install cURL for your operating system in the following section before you install Rust.
Use the following command to install Rust and Cargo on macOS or Linux:
$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh For additional installation options, see Install Rust.
OneChain uses the latest version of Cargo to build and manage dependencies. See the Cargo installation page on the Rust website for more information.
Use the following command to update Rust with rustup:
$ rustup update stable Additional prerequisites by operating system Select the appropriate tab to view the requirements for your system.
The prerequisites needed for the Linux operating system include:
cURL Rust and Cargo Git CLI CMake GCC libssl-dev libclang-dev libpq-dev (optional) build-essential INFO The Linux instructions assume a distribution that uses the APT package manager. You might need to adjust the instructions to use other package managers.
Install the prerequisites listed in this section. Use the following command to update apt-get:
$ sudo apt-get update All Linux prerequisites Reference the relevant sections that follow to install each prerequisite individually, or run the following to install them all at once:
cURL Install cURL with the following command:
$ sudo apt-get install curl Verify that cURL installed correctly with the following command:
$ curl --version Git CLI Run the following command to install Git, including the Git CLI:
$ sudo apt-get install git-all For more information, see Install Git on Linux on the GitHub website.
CMake Use the following command to install CMake.
$ sudo apt-get install cmake To customize the installation, see Installing CMake on the CMake website.
GCC Use the following command to install the GNU Compiler Collection, gcc:
$ sudo apt-get install gcc libssl-dev Use the following command to install libssl-dev:
$ sudo apt-get install libssl-dev If the version of Linux you use doesn't support libssl-dev, find an equivalent package for it on the ROS Index.
(Optional) If you have OpenSSL you might also need to also install pkg-config:
$ sudo apt-get install pkg-config libclang-dev Use the following command to install libclang-dev:
$ sudo apt-get install libclang-dev If the version of Linux you use doesn't support libclang-dev, find an equivalent package for it on the ROS Index.
libpq-dev (optional) Use the following command to install libpq-dev:
$ sudo apt-get install libpq-dev If the version of Linux you use doesn't support libpq-dev, find an equivalent package for it on the ROS Index.
build-essential Use the following command to install build-essential:
$ sudo apt-get install build-essential The prerequisites needed for the macOS operating system include:
Rust and Cargo Homebrew cURL CMake libpq (optional) Git CLI PostgreSQL macOS includes a version of cURL you can use to install Homebrew. Use Homebrew to install other tools, including a newer version of cURL.
Homebrew Use the following command to install Homebrew:
$ /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" INFO If you used the commands in the Install using Homebrew section, you do not need to install anything else.
All macOS prerequisites With Homebrew installed, you can install individual prerequisites from the following sections or install them all at once with this command:
cURL Use the following command to update the default cURL on macOS:
$ brew install curl CMake Use the following command to install CMake:
$ brew install cmake To customize the installation, see Installing CMake on the CMake website.
libpq (optional) Use the following command to install libpq:
$ brew install libpq Git CLI Use the following command to install Git:
$ brew install git After installing Git, download and install the Git command line interface.
PostgreSQL Visit the official PostgreSQL website for instructions on downloading PostgreSQL.
How to build OneChain binaries Once you have installed all the prerequisites, you can build OneChain binaries. The simplest and direct way to build OneChain binaries is to use the cargo command.
cargo install --locked --git https://github.com/one-chain-labs/onechain.git one --features tracing and then you can get the OneChain binary installed in $HOME/.cargo/bin/one, you can move it to any directory you want, make sure the directory is in your PATH.
If you want to run a node version of OneChain, you can use the following command to build it:
cargo install --locked --git https://github.com/one-chain-labs/onechain.git one-node then you can find the binary in $HOME/.cargo/bin/one-node. ask the customer service if you really want to run a node.
and, if you want to review the code, you can clone the OneChain repository from GitHub using the following command, then build from the source code:
git clone https://github.com/one-chain-labs/onechain.git Connect to OneChain OneChain has Mainnet, Devnet, and Testnet networks available. You can use one of the test networks, Devnet or Testnet, to experiment with the version of OneChain running on that network.
The OneChain Testnet and Devnet networks consist of several validator nodes to validate each network's transactions. Clients send transactions and read requests via this endpoint: https://rpc-.onelabs.cc:443 using JSON-RPC.
You can request test OCT tokens through the OneChain devnet-faucet and testnet-faucet, depending on which network you use. The coins on these networks have no financial value. There is no faucet service for Mainnet.
Configure OneChain client To connect the OneChain client to a network, run the following command:
one client If you receive the one client help output in the console, you already have a client.yaml file. See Connect to a custom RPC endpoint to add a new environment alias or to switch the currently active network.
The first time you start OneChain client without having a client.yaml file, the console displays the following message:
Config file ["/client.yaml"] doesn't exist, do you want to connect to a OneChain Full node server [y/N]? Press y and then press Enter. The process then requests the RPC server URL:
OneChain Full node server URL (Defaults to Testnet if not specified) : Press Enter to connect to OneChain Testnet. To use a custom RPC server, Devnet, or Mainnet, enter the URL to the correct RPC endpoint and then press Enter.
If you enter a URL, the process prompts for an alias for the environment:
Environment alias for [] : Type an alias name and press Enter.
Select key scheme to generate keypair (0 for ed25519, 1 for secp256k1, 2 for secp256r1): Press 0, 1, or 2 to select a key scheme and the press Enter.
OneChain returns a message similar to the following (depending on the key scheme you selected) that includes the address and 12-word recovery phrase for the address:
Generated new keypair for address with scheme "ed25519" [0xb9c83a8b40d3263c9ba40d551514fbac1f8c12e98a4005a0dac072d3549c2442] Secret Recovery Phrase : [cap wheat many line human lazy few solid bored proud speed grocery] Connect to a custom RPC endpoint If you previously installed a OneChain client that connected to a OneChain network, or created a local network, you can modify your existing client.yaml file to change the configured RPC endpoint. The one client commands that relate to environments read from and write to the client.yaml file.
To check currently available environment aliases, run the following command:
$ one client envs The command outputs the available environment aliases, with (active) denoting the currently active network.
╭─────────┬────────────────────────────────────┬────────╮ │ alias │ url │ active │ ├─────────┼────────────────────────────────────┼────────┤ │ testnet │ https://rpc-testnet.onelabs.cc:443 │ * │ ╰─────────┴────────────────────────────────────┴────────╯ To add a new alias for a custom RPC endpoint, run the following command. Replace values in < > with values for your installation:
$ one client new-env --alias --rpc To switch the active network, run the following command:
$ one client switch --env If you encounter an issue, delete the OneChain configuration directory (~/.one/one_config) and reinstall the latest binaries and then retry.
Get your OneChain address The OneChain address is unique, similarly to the way a social security number or a personal identification number is unique to one person. However, in OneChain you can create and own multiple addresses, all of which are unique.
In OneChain, an address is 32 bytes and is often encoded in hexadecimal with a 0x prefix. For example, this is a valid OneChain address: 0x02a212de6a9dfa3a69e22387acfbafbb1a9e591bd9d636e7895dcfc8de05f331. You can use a OneChain network explorer to find more information about this address and the objects it owns.
OneChain provides multiple ways to obtain a OneChain address. The following are the two most common.
OneChain Wallet One of the most straightforward ways to obtain a OneChain address for first-time users is through the OneChain Wallet, you can follow the on-screen instructions to create a new wallet. The wallet will generate a new OneChain address and a secret recovery phrase. Make sure you write down the secret recovery phrase and store it in a safe place.
Command line interface If you are using the OneChain command line interface (CLI) to interact with the OneChain network, you can use the one client command to generate a new address. By default, when the OneChain CLI runs for the first time it will prompt you to set up your local wallet, and then it generates one OneChain address and the associated secret recovery phrase. Make sure you write down the secret recovery phrase and store it in a safe place.
To generate a new OneChain address use one client new-address ed25519, which specifies the keypair scheme flag to be of type ed25519.
For more information, see the OneChain Client CLI documentation.
To see all the generated addresses in the local wallet on your machine, run one keytool list. For more information about the keytool options, see the OneChain Keytool CLI documentation.
DANGER The private keys associated with the OneChain addresses are stored locally on the machine where the CLI is installed, in the ~/.one/one_config/one.keystore file. Make sure you do not expose this to anyone, as they can use it to get access to your account.
Request test tokens To request test tokens, you can use the OneChain faucet. The faucet is a service that provides free test tokens to users for testing purposes. You can request test tokens for the Devnet and Testnet networks. There is no faucet for OneChain Mainnet...
through CLI If you are using the Devnet or Testnet networks, you can use the OneChain CLI to request tokens for your address. The one client faucet uses the active network and active address that is currently set in the OneChain CLI by default, but you can specify custom data through the following two arguments:
--address argument to provide a specific address (or its alias), --url argument to provide a custom faucet endpoint. through cURL Use the following cURL command to request tokens directly from the faucet server:
curl --location --request POST 'https://faucet-testnet.onelabs.cc/v1/gas'
--header 'Content-Type: application/json'
--data-raw '{
"FixedAmountRequest": {
"recipient": ""
}
}'
Your First dApp
dApp stands for "decentralized application", which is an application that runs on a blockchain or decentralized network instead of a centralized server. Typical dApps do not run solely on the blockchain, but instead are composed of different pieces, like a TypeScript frontend that interacts with code that lives on a blockchain. OneChain needs code written in Move for the pieces of a dApp that live on chain. These pieces are referred to as packages, modules, or smart contracts.
The instructions in this section walk you through writing a basic package, debugging and testing your code, and publishing. You need to follow these instructions in the order they appear to complete the exercise.
You use the move OneChain CLI command for some instructions.
Write a Move Package To begin, open a terminal or console at the location you plan to store your package. Use the one move new command to create an empty Move package with the name my_first_package:
$ one move new my_first_package Running the previous command creates a directory with the name you provide (my_first_package in this case). The command populates the new directory with a skeleton Move project that consists of a sources directory and a Move.toml manifest file. Open the manifest with a text editor to review its contents:
[package] name = "my_first_package" edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move
authors = ["..."] # e.g., ["Joe Smith (joesmith@noemail.com)", "John Snow (johnsnow@noemail.com)"]
[dependencies] One = { git = "https://github.com/one-chain-labs/onechain.git", subdir = "crates/sui-framework/packages/one-framework", rev = "main" }
MyRemotePackage = { git = "https://some.remote/host.git", subdir = "remote/path", rev = "main" }
[addresses] my_first_package = "0x0"
[dev-dependencies]
[dev-addresses]
The manifest file contents include available sections of the manifest and comments that provide additional information. In Move, you prepend the hash mark (#) to a line to denote a comment.
[package]: Contains metadata for the package. By default, the one move new command populates only the name value of the metadata. In this case, the example passes my_first_package to the command, which becomes the name of the package. You can delete the first # of subsequent lines of the [package] section to provide values for the other available metadata fields. [dependencies]: Lists the other packages that your package depends on to run. By default, the one move new command lists the One package on GitHub as the lone dependency. [addresses]: Declares named addresses that your package uses. By default, the section includes the package you create with the one move new command and an address of 0x0. This value can be left as-is and indicates that package addresses are automatically managed when published and upgraded. [dev-dependencies]: Includes only comments that describe the section. [dev-addresses]: Includes only comments that describe the section. Defining the package You have a package now but it doesn't do anything. To make your package useful, you must add logic contained in .move source files that define modules. The one move new command creates a .move file in the sources directory that defaults to the same name as your project (my_first_package.move in this case). For the purpose of this guide, rename the file to example.move and open it with a text editor.
Populate the example.move file with the following code:
module my_first_package::example;
// Part 1: These imports are provided by default // use one::object::{Self, UID}; // use one::transfer; // use one::tx_context::{Self, TxContext};
// Part 2: struct definitions public struct Sword has key, store { id: UID, magic: u64, strength: u64, }
public struct Forge has key { id: UID, swords_created: u64, }
// Part 3: Module initializer to be executed when this module is published fun init(ctx: &mut TxContext) { let admin = Forge { id: object::new(ctx), swords_created: 0, };
// Transfer the forge object to the module/package publisher
transfer::transfer(admin, ctx.sender());
}
// Part 4: Accessors required to read the struct fields public fun magic(self: &Sword): u64 { self.magic }
public fun strength(self: &Sword): u64 { self.strength }
public fun swords_created(self: &Forge): u64 { self.swords_created }
// Part 5: Public/entry functions (introduced later in the tutorial)
// Part 6: Tests The comments in the preceding code highlight different parts of a typical Move source file.
Part 1: Imports - Code reuse is a necessity in modern programming. Move supports this concept with use aliases that allow your module to refer to types and functions declared in other modules. In this example, the module imports from object, transfer, and tx_context modules, but it does not need to do so explicitly, because the compiler provides these use statements by default. These modules are available to the package because the Move.toml file defines the One dependency (along with the one named address) where they are defined.
Part 2: Struct declarations - Structs define types that a module can create or destroy. Struct definitions can include abilities provided with the has keyword. The structs in this example, for instance, have the key ability, which indicates that these structs are OneChain objects that you can transfer between addresses. The store ability on the structs provides the ability to appear in other struct fields and be transferred freely.
Part 3: Module initializer - A special function that is invoked exactly once when the module publishes.
Part 4: Accessor functions - These functions allow the fields of the module's structs to be read from other modules.
After you save the file, you have a complete Move package.
Build and Test Packages Building your package Make sure your terminal or console is in the directory that contains your package (my_first_package if you're following along). Use the following command to build your package:
$ one move build A successful build returns a response similar to the following:
UPDATING GIT DEPENDENCY https://github.com/one-chain-labs/onechain.git INCLUDING DEPENDENCY One INCLUDING DEPENDENCY MoveStdlib BUILDING my_first_package If the build fails, you can use the verbose error messaging in output to troubleshoot and resolve root issues.
Now that you have designed your asset and its accessor functions, it's time to test the package code before publishing.
Testing a package OneChain includes support for the Move testing framework. Using the framework, you can write unit tests that analyze Move code much like test frameworks for other languages, such as the built-in Rust testing framework or the JUnit framework for Java.
An individual Move unit test is encapsulated in a public function that has no parameters, no return values, and has the #[test] annotation. The testing framework executes such functions when you call the one move test command from the package root (my_move_package directory as per the current running example):
$ one move test If you execute this command for the package created in Write a Package, you see the following output. Unsurprisingly, the test result has an OK status because there are no tests written yet to fail.
BUILDING One BUILDING MoveStdlib BUILDING my_first_package Running Move unit tests Test result: OK. Total tests: 0; passed: 0; failed: 0 To actually test your code, you need to add test functions. Start with adding a basic test function to the example.move file, inside the module definition:
#[test] fun test_sword_create() { // Create a dummy TxContext for testing let mut ctx = tx_context::dummy();
// Create a sword
let sword = Sword {
id: object::new(&mut ctx),
magic: 42,
strength: 7,
};
// Check if accessor functions return correct values
assert!(sword.magic() == 42 && sword.strength() == 7, 1);
} As the code shows, the unit test function (test_sword_create()) creates a dummy instance of the TxContext struct and assigns it to ctx. The function then creates a sword object using ctx to create a unique identifier and assigns 42 to the magic parameter and 7 to strength. Finally, the test calls the magic and strength accessor functions to verify that they return correct values.
The function passes the dummy context, ctx, to the object::new function as a mutable reference argument (&mut), but passes sword to its accessor functions as a read-only reference argument, &sword.
Now that you have a test function, run the test command again:
$ one move test After running the test command, however, you get a compilation error instead of a test result:
error[E06001]: unused value without 'drop' ┌─ ./sources/example.move:59:65 │ 9 │ public struct Sword has key, store { │ ----- To satisfy the constraint, the 'drop' ability would need to be added here · 52 │ let sword = Sword { │ ----- The local variable 'sword' still contains a value. The value does not have the 'drop' ability and must be consumed before the function returns │ ╭─────────────────────' 53 │ │ id: object::new(&mut ctx), 54 │ │ magic: 42, 55 │ │ strength: 7, 56 │ │ }; │ ╰─────────' The type 'my_first_package::example::Sword' does not have the ability 'drop' · │ 59 │ assert!(sword.magic() == 42 && sword.strength() == 7, 1); │ ^ Invalid return The error message contains all the necessary information to debug the code. The faulty code is meant to highlight one of the Move language's safety features.
The Sword struct represents a game asset that digitally mimics a real-world item. Obviously, a real sword cannot simply disappear (though it can be explicitly destroyed), but there is no such restriction on a digital one. In fact, this is exactly what's happening in the test function - you create an instance of a Sword struct that simply disappears at the end of the function call. If you saw something disappear before your eyes, you'd be dumbfounded, too.
One of the solutions (as suggested in the error message), is to add the drop ability to the definition of the Sword struct, which would allow instances of this struct to disappear (be dropped). The ability to drop a valuable asset is not a desirable asset property in this case, so another solution is needed. Another way to solve this problem is to transfer ownership of the sword.
To get the test to work, we will need to use the transfer module, which is imported by default. Add the following lines to the end of the test function (after the assert! call) to transfer ownership of the sword to a freshly created dummy address:
let dummy_address = @0xCAFE; transfer::public_transfer(sword, dummy_address); Run the test command again. Now the output shows a single successful test has run:
BUILDING MoveStdlib BUILDING One BUILDING my_first_package Running Move unit tests [ PASS ] 0x0::example::test_sword_create Test result: OK. Total tests: 1; passed: 1; failed: 0 TIP Use a filter string to run only a matching subset of the unit tests. With a filter string provided, the one move test checks the fully qualified (
::<module_name>::<fn_name>) name for a match.Example:
$ one move test sword The previous command runs all tests whose name contains sword.
You can discover more testing options through:
$ one move test -h Sui-specific testing The previous testing example uses Move but isn't specific to Sui beyond using some Sui packages, such as one::tx_context and one::transfer. While this style of testing is already useful for writing Move code for Sui, you might also want to test additional Sui-specific features. In particular, a Move call in Sui is encapsulated in a Sui transaction, and you might want to test interactions between different transactions within a single test (for example, one transaction creating an object and the other one transferring it).
Sui-specific testing is supported through the test_scenario module that provides Sui-related testing functionality otherwise unavailable in pure Move and its testing framework.
The test_scenario module provides a scenario that emulates a series of Sui transactions, each with a potentially different user executing them. A test using this module typically starts the first transaction using the test_scenario::begin function. This function takes an address of the user executing the transaction as its argument and returns an instance of the Scenario struct representing a scenario.
An instance of the Scenario struct contains a per-address object pool emulating Sui object storage, with helper functions provided to manipulate objects in the pool. After the first transaction finishes, subsequent test transactions start with the test_scenario::next_tx function. This function takes an instance of the Scenario struct representing the current scenario and an address of a user as arguments.
Update your example.move file to include a function callable from Sui that implements sword creation. With this in place, you can then add a multi-transaction test that uses the test_scenario module to test these new capabilities. Put this functions after the accessors (Part 5 in comments).
public fun sword_create(magic: u64, strength: u64, ctx: &mut TxContext): Sword { Sword { id: object::new(ctx), magic: magic, strength: strength, } } The code of the new functions uses struct creation and Sui-internal modules (tx_context) in a way similar to what you have seen in the previous sections. The important part is for the function to have correct signatures.
With the new function included, add another test function to make sure it behaves as expected.
#[test] fun test_sword_transactions() { use one::test_scenario;
// Create test addresses representing users
let initial_owner = @0xCAFE;
let final_owner = @0xFACE;
// First transaction executed by initial owner to create the sword
let mut scenario = test_scenario::begin(initial_owner);
{
// Create the sword and transfer it to the initial owner
let sword = sword_create(42, 7, scenario.ctx());
transfer::public_transfer(sword, initial_owner);
};
// Second transaction executed by the initial sword owner
scenario.next_tx(initial_owner);
{
// Extract the sword owned by the initial owner
let sword = scenario.take_from_sender<Sword>();
// Transfer the sword to the final owner
transfer::public_transfer(sword, final_owner);
};
// Third transaction executed by the final sword owner
scenario.next_tx(final_owner);
{
// Extract the sword owned by the final owner
let sword = scenario.take_from_sender<Sword>();
// Verify that the sword has expected properties
assert!(sword.magic() == 42 && sword.strength() == 7, 1);
// Return the sword to the object pool (it cannot be simply "dropped")
scenario.return_to_sender(sword)
};
scenario.end();
} There are some details of the new testing function to pay attention to. The first thing the code does is create some addresses that represent users participating in the testing scenario. The test then creates a scenario by starting the first transaction on behalf of the initial sword owner.
The initial owner then executes the second transaction (passed as an argument to the test_scenario::next_tx function), who then transfers the sword they now own to the final owner. In pure Move there is no notion of OneChain storage; consequently, there is no easy way for the emulated OneChain transaction to retrieve it from storage. This is where the test_scenario module helps - its take_from_sender function allows an address-owned object of a given type (Sword) executing the current transaction to be available for Move code manipulation. For now, assume that there is only one such object. In this case, the test transfers the object it retrieves from storage to another address.
TIP Transaction effects, such as object creation and transfer become visible only after a given transaction completes. For example, if the second transaction in the running example created a sword and transferred it to the administrator's address, it would only become available for retrieval from the administrator's address (via test_scenario, take_from_sender, or take_from_address functions) in the third transaction.
The final owner executes the third and final transaction that retrieves the sword object from storage and checks if it has the expected properties. Remember, as described in Testing a package, in the pure Move testing scenario, after an object is available in Move code (after creation or retrieval from emulated storage), it cannot simply disappear.
In the pure Move testing function, the function transfers the sword object to the fake address to handle the disappearing problem. The test_scenario package provides a more elegant solution, however, which is closer to what happens when Move code actually executes in the context of OneChain - the package simply returns the sword to the object pool using the test_scenario::return_to_sender function. For scenarios where returning to the sender is not desirable or if you would like to simply destroy the object, the test_utils module also provides the generic destroy function, that can be used on any type T regardless of its ability. It is advisable to check out other useful functions in the test_scenario and test_utils modules as well.
Run the test command again to see two successful tests for our module:
BUILDING One BUILDING MoveStdlib BUILDING my_first_package Running Move unit tests [ PASS ] 0x0::example::test_sword_create [ PASS ] 0x0::example::test_sword_transactions Test result: OK. Total tests: 2; passed: 2; failed: 0 Module initializers Each module in a package can include a special initializer function that runs at publication time. The goal of an initializer function is to pre-initialize module-specific data (for example, to create singleton objects). The initializer function must have the following properties for it to execute at publication:
Function name must be init. The parameter list must end with either a &mut TxContext or a &TxContext type. No return values. Private visibility. Optionally, the parameter list starts by accepting the module's one-time witness by value. See One Time Witness in The Move Book for more information. For example, the following init functions are all valid:
fun init(ctx: &TxContext) fun init(ctx: &mut TxContext) fun init(otw: EXAMPLE, ctx: &TxContext) fun init(otw: EXAMPLE, ctx: &mut TxContext) While the one move command does not support publishing explicitly, you can still test module initializers using the testing framework by dedicating the first transaction to executing the initializer function.
The init function for the module in the running example creates a Forge object.
fun init(ctx: &mut TxContext) { let admin = Forge { id: object::new(ctx), swords_created: 0, };
transfer::transfer(admin, ctx.sender());
} The tests you have so far call the init function, but the initializer function itself isn't tested to ensure it properly creates a Forge object. To test this functionality, add a new_sword function to take the forge as a parameter and to update the number of created swords at the end of the function. If this were an actual module, you'd replace the swords_create function with new_sword. To keep the existing tests from failing, however, we will keep both functions.
public fun new_sword(forge: &mut Forge, magic: u64, strength: u64, ctx: &mut TxContext): Sword { forge.swords_created = forge.swords_created + 1; Sword { id: object::new(ctx), magic: magic, strength: strength, } } Now, create a function to test the module initialization:
#[test] fun test_module_init() { use one::test_scenario;
// Create test addresses representing users
let admin = @0xAD;
let initial_owner = @0xCAFE;
// First transaction to emulate module initialization
let mut scenario = test_scenario::begin(admin);
{
init(scenario.ctx());
};
// Second transaction to check if the forge has been created
// and has initial value of zero swords created
scenario.next_tx(admin);
{
// Extract the Forge object
let forge = scenario.take_from_sender<Forge>();
// Verify number of created swords
assert!(forge.swords_created() == 0, 1);
// Return the Forge object to the object pool
scenario.return_to_sender(forge);
};
// Third transaction executed by admin to create the sword
scenario.next_tx(admin);
{
let mut forge = scenario.take_from_sender<Forge>();
// Create the sword and transfer it to the initial owner
let sword = forge.new_sword(42, 7, scenario.ctx());
transfer::public_transfer(sword, initial_owner);
scenario.return_to_sender(forge);
};
scenario.end();
} As the new test function shows, the first transaction (explicitly) calls the initializer. The next transaction checks if the Forge object has been created and properly initialized. Finally, the admin uses the Forge to create a sword and transfer it to the initial owner.
You can refer to the source code for the package (with all the tests and functions properly adjusted) in the first_package module in the sui/examples directory. You can also use the following toggle to review the complete code.
module my_first_package::example;
// Part 1: These imports are provided by default // use one::object::{Self, UID}; // use one::transfer; // use one::tx_context::{Self, TxContext};
// Part 2: struct definitions public struct Sword has key, store { id: UID, magic: u64, strength: u64, }
public struct Forge has key { id: UID, swords_created: u64, }
// Part 3: Module initializer to be executed when this module is published fun init(ctx: &mut TxContext) { let admin = Forge { id: object::new(ctx), swords_created: 0, };
// Transfer the forge object to the module/package publisher
transfer::transfer(admin, ctx.sender());
}
// Part 4: Accessors required to read the struct fields public fun magic(self: &Sword): u64 { self.magic }
public fun strength(self: &Sword): u64 { self.strength }
public fun swords_created(self: &Forge): u64 { self.swords_created }
// Part 5: Public/entry functions (introduced later in the tutorial) public fun sword_create(magic: u64, strength: u64, ctx: &mut TxContext): Sword { // Create a sword Sword { id: object::new(ctx), magic: magic, strength: strength, } }
/// Constructor for creating swords public fun new_sword(forge: &mut Forge, magic: u64, strength: u64, ctx: &mut TxContext): Sword { forge.swords_created = forge.swords_created + 1; Sword { id: object::new(ctx), magic: magic, strength: strength, } } // Part 6: Tests #[test] fun test_sword_create() { // Create a dummy TxContext for testing let mut ctx = tx_context::dummy();
// Create a sword
let sword = Sword {
id: object::new(&mut ctx),
magic: 42,
strength: 7,
};
// Check if accessor functions return correct values
assert!(sword.magic() == 42 && sword.strength() == 7, 1);
// Create a dummy address and transfer the sword
let dummy_address = @0xCAFE;
transfer::public_transfer(sword, dummy_address);
}
#[test] fun test_sword_transactions() { use one::test_scenario;
// Create test addresses representing users
let initial_owner = @0xCAFE;
let final_owner = @0xFACE;
// First transaction executed by initial owner to create the sword
let mut scenario = test_scenario::begin(initial_owner);
{
// Create the sword and transfer it to the initial owner
let sword = sword_create(42, 7, scenario.ctx());
transfer::public_transfer(sword, initial_owner);
};
// Second transaction executed by the initial sword owner
scenario.next_tx(initial_owner);
{
// Extract the sword owned by the initial owner
let sword = scenario.take_from_sender<Sword>();
// Transfer the sword to the final owner
transfer::public_transfer(sword, final_owner);
};
// Third transaction executed by the final sword owner
scenario.next_tx(final_owner);
{
// Extract the sword owned by the final owner
let sword = scenario.take_from_sender<Sword>();
// Verify that the sword has expected properties
assert!(sword.magic() == 42 && sword.strength() == 7, 1);
// Return the sword to the object pool (it cannot be simply "dropped")
scenario.return_to_sender(sword)
};
scenario.end();
}
#[test] fun test_module_init() { use one::test_scenario;
// Create test addresses representing users
let admin = @0xAD;
let initial_owner = @0xCAFE;
// First transaction to emulate module initialization
let mut scenario = test_scenario::begin(admin);
{
init(scenario.ctx());
};
// Second transaction to check if the forge has been created
// and has initial value of zero swords created
scenario.next_tx(admin);
{
// Extract the Forge object
let forge = scenario.take_from_sender<Forge>();
// Verify number of created swords
assert!(forge.swords_created() == 0, 1);
// Return the Forge object to the object pool
scenario.return_to_sender(forge);
};
// Third transaction executed by admin to create the sword
scenario.next_tx(admin);
{
let mut forge = scenario.take_from_sender<Forge>();
// Create the sword and transfer it to the initial owner
let sword = forge.new_sword(42, 7, scenario.ctx());
transfer::public_transfer(sword, initial_owner);
scenario.return_to_sender(forge);
};
scenario.end();
} Publish a Package Before you can call functions in a Move package (beyond an emulated OneChain execution scenario), that package must be available on the OneChain network. When you publish a package, you are actually creating an immutable OneChain object on the network that anyone can access.
To publish your package to the OneChain network, use the publish CLI command in the root of your package. You can use the --gas-budget flag to set a value for the maximum amount of gas the transaction can cost. If the cost of the transaction is more than the budget you set, the transaction fails and your package doesn't publish, but it's not necessary.
$ one client publish --gas-budget 5000000 If the publish transaction is successful, your terminal or console responds with the details of the publish transaction separated into sections, including transaction data, transaction effects, transaction block events, object changes, and balance changes.
In the Object Changes table, you can find the information about the package you just published in the Published Objects section. Your response has the actual PackageID that identifies the package (instead of ) in the form 0x123...ABC.
╭─────────────────────────────────────────────────────────────────────╮ │ Object Changes │ ├─────────────────────────────────────────────────────────────────────┤ │ Created Objects: │ │ ... │ | | │ Mutated Objects: │ │ ... │ | | │ Published Objects: │ │ ┌── │ │ │ PackageID: │ │ │ Version: 1 │ │ │ Digest: │ │ │ Modules: my_module │ │ └── │ ╰─────────────────────────────────────────────────────────────────────╯ Your currently active address now has three objects (or more, if you had objects prior to this example). Assuming you are using a new address, running the objects command reveals what those objects are.
$ one client objects
╭───────────────────────────────────────────────────────────────────────────────────────╮ │ ╭────────────┬──────────────────────────────────────────────────────────────────────╮ │ │ │ objectId │ │ │ │ │ version │ 10 │ │ │ │ digest │ │ │ │ │ objectType │ ::example::Forge │ │ │ ╰────────────┴──────────────────────────────────────────────────────────────────────╯ │ │ ╭────────────┬──────────────────────────────────────────────────────────────────────╮ │ │ │ objectId │ │ │ │ │ version │ 10 │ │ │ │ digest │ │ │ │ │ objectType │ 0x0000..0002::coin::Coin │ │ │ ╰────────────┴──────────────────────────────────────────────────────────────────────╯ │ │ ╭────────────┬──────────────────────────────────────────────────────────────────────╮ │ │ │ objectId │ │ │ │ │ version │ 10 │ │ │ │ digest │ │ │ │ │ objectType │ 0x0000..0002::package::UpgradeCap │ │ │ ╰────────────┴──────────────────────────────────────────────────────────────────────╯ │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ The objectId field is the unique identifier of each object.
Coin object: You received the Coin object from the Testnet faucet. It's value is slightly less than when you received it because of the cost of gas for the publish transaction. Forge object: Recall that the init function runs when the package gets published. The init function for this example package creates a Forge object and transfers it to the publisher (you). UpgradeCap object: Each package you publish results in the receipt of an UpgradeCap object. You use this object to upgrade the package later or to burn it so the package cannot be upgraded. flowchart TB
subgraph OneChain Blockchain
direction TB
address --> Forge
address --> UpgradeCap
address --> Coin
end
Interact with the package Now that the package is on chain, you can call its functions to interact with the package. You can use the one client call command to make individual calls to package functions, or you can construct more advanced blocks of transactions using the one client ptb command. The ptb part of the command stands for programmable transaction blocks. In basic terms, PTBs allow you to group commands together in a single transaction for more efficient and cost-effective network activity.
flowchart TB my_module["example::new_sword(&Forge, strength, magic)"]
one_client["one client"]
subgraph OneChain Blockchain
my_module
my_module --Sword--> address
end
one_client --"PTB"--> my_module
For example, you can create a new Sword object defined in the package by calling the new_sword function in the my_module package, and then transfer the Sword object to any address:
$ one client ptb
--assign forge @
--assign to_address @
--move-call ::example::new_sword forge 3 3
--assign sword
--transfer-objects "[sword]" to_address
--gas-budget 20000000
INFO
You can pass literal addresses and objects IDs by prefixing them with '@'. This is needed to distinguish a hexadecimal value from an address in some situations.
For addresses that are in your local wallet, you can use their alias instead (passing them without '@', for example, --transfer-objects my_alias).
Depending on your shell and operating system, you might need to pass some values with quotes ("), for example: --assign "forge @".
Make sure to replace , , and with the actual objectId of the Forge object, the address of the recipient (your address in this case), and the packageID of the package, respectively.
After the transaction executes, you can check the status of the Sword object by using the one client objects command again. Provided you used your address as the , you should now see a total of four objects:
╭───────────────────────────────────────────────────────────────────────────────────────╮ │ ╭────────────┬──────────────────────────────────────────────────────────────────────╮ │ │ │ objectId │ │ │ │ │ version │ 11 │ │ │ │ digest │ │ │ │ │ objectType │ ::example::Forge │ │ │ ╰────────────┴──────────────────────────────────────────────────────────────────────╯ │ │ ╭────────────┬──────────────────────────────────────────────────────────────────────╮ │ │ │ objectId │ │ │ │ │ version │ 11 │ │ │ │ digest │ │ │ │ │ objectType │ 0x0000..0002::coin::Coin │ │ │ ╰────────────┴──────────────────────────────────────────────────────────────────────╯ │ │ ╭────────────┬──────────────────────────────────────────────────────────────────────╮ │ │ │ objectId │ │ │ │ │ version │ 11 │ │ │ │ digest │ │ │ │ │ objectType │ ::example::Sword │ │ │ ╰────────────┴──────────────────────────────────────────────────────────────────────╯ │ │ ╭────────────┬──────────────────────────────────────────────────────────────────────╮ │ │ │ objectId │ │ │ │ │ version │ 10 │ │ │ │ digest │ │ │ │ │ objectType │ 0x0000..0002::package::UpgradeCap │ │ │ ╰────────────┴──────────────────────────────────────────────────────────────────────╯ │ ╰───────────────────────────────────────────────────────────────────────────────────────╯ Congratulations! You have successfully published a package to the OneChain network and modified the blockchain state by using a programmable transaction block.
debugging Move does not currently have a native debugger. You can use the std::debug module, however, to print arbitrary values to the console. Monitoring variable values in this manner can provide insight into the logic of your modules. To do so, first declare an alias to the debug module in your source file for more concise access:
use std::debug; Then in places where you want to print out a value v, regardless of its type, add the following code:
debug::print(&v); or the following if v is already a reference:
debug::print(v); The debug module also provides a function to print out the current stacktrace:
debug::print_stack_trace(); Alternatively, any call to abort or assertion failure also prints the stacktrace at the point of failure.
Using debug in my_module To see the module in action, update your my_module code to include debug calls. Specifically, update the new_sword function so that you print the value of forge before and after updating swords_created. Also, include a print_stack_trace so that the function looks like the following:
public fun new_sword( forge: &mut Forge, magic: u64, strength: u64, ctx: &mut TxContext, ): Sword { debug::print(forge); forge.swords_created = forge.swords_created + 1; debug::print(forge); debug::print_stack_trace(); Sword { id: object::new(ctx), magic: magic, strength: strength, } } To see the results, run the module's tests.
$ one move test The response prints out the expected results as the test calls the new_sword function.
INCLUDING DEPENDENCY One INCLUDING DEPENDENCY MoveStdlib BUILDING my_first_package Running Move unit tests [ PASS ] 0x0::example::test_module_init [debug] 0x0::example::Forge { id: 0x2::object::UID { id: 0x2::object::ID { bytes: @0x34401905bebdf8c04f3cd5f04f442a39372c8dc321c29edfb4f9cb30b23ab96 } }, swords_created: 0 } [debug] 0x0::example::Forge { id: 0x2::object::UID { id: 0x2::object::ID { bytes: @0x34401905bebdf8c04f3cd5f04f442a39372c8dc321c29edfb4f9cb30b23ab96 } }, swords_created: 1 } Call Stack: [0] 0000000000000000000000000000000000000000000000000000000000000000::example::test_module_init
Code:
[35] LdU64(7)
[36] MutBorrowLoc(3)
[37] Call(15)
> [38] Call(5)
[39] LdConst(0)
[40] CallGeneric(2)
[41] ImmBorrowLoc(3)
Locals:
[0] -
[1] { { { <OBJECT-ID-WITHOUT-0x> } }, 1 }
[2] -
[3] { 2, { 00000000000000000000000000000000000000000000000000000000000000ad, [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 0, 0, 0 } }
Operand Stack:
[ PASS ] 0x0::example::test_sword_transactions Test result: OK. Total tests: 2; passed: 2; failed: 0 The output shows the value of the swords_created field of the Forge change after the increment. The stack trace shows the bytecode instructions that have been executed so far, and the next few instructions to execute.
INFO The specific bytecode offsets and the indices of the local variables might vary depending on the version of the OneChain toolchain.
OneChain 101 In many education systems, 101-level classes are those that teach core competencies to build a foundation for the more advanced topics that are to come. The topics in this section provide the same experience for the OneChain blockchain. They examine the core OneChain development concepts that you use to build apps on OneChain. As you start developing more advanced solutions, knowledge of these topics provides a good base for understanding the more advanced concepts you will employ.
Shared versus Owned Objects Objects on OneChain can be shared (accessible for reads and writes by any transaction) or owned (accessible for reads and writes by transactions signed by their owner). Many applications can be built using a solution that either uses shared objects or only owned objects, with trade-offs for each that need to be weighed.
Transactions that use only owned objects benefit from very low latency to finality, because they do not need to go through consensus. On the other hand, the fact that only the owner of the object can access it complicates processes that need to work with objects owned by multiple parties, and access to very hot objects needs to be coordinated off-chain.
Transactions that access one or more shared objects require consensus to sequence reads and writes to those objects, resulting in a slightly higher gas cost and increased latency.
Transactions that access multiple shared objects, or particularly popular objects, might have increases in latency due to contention. However, the advantage of using shared objects lies in the flexibility of allowing multiple addresses to access the same object in a coordinated manner.
To summarize, applications that are extremely sensitive to latency or gas costs, that do not need to handle complex multi-party transactions, or that already require an off-chain service could benefit from a design that only uses owned objects. Applications that require coordination between multiple parties typically benefit from using shared objects.
For more information on the types of objects that OneChain supports, see Object Ownership.
Example: Escrow The Escrow example demonstrates the trade-offs between shared objects and owned objects by implementing the same application in both styles. It implements a service that allows two addresses to perform a trustless swap of objects with each other (a "trade") with the service holding their objects in escrow.
Locked and Key // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0
/// The lock module offers an API for wrapping any object that has
/// store and protecting it with a single-use Key.
///
/// This is used to commit to swapping a particular object in a
/// particular, fixed state during escrow.
module escrow::lock {
use one::object::{Self, ID, UID};
use one::tx_context::TxContext;
/// A wrapper that protects access to `obj` by requiring access to a `Key`.
///
/// Used to ensure an object is not modified if it might be involved in a
/// swap.
struct Locked<T: store> has key, store {
id: UID,
key: ID,
obj: T,
}
/// Key to open a locked object (consuming the `Key`)
struct Key has key, store { id: UID }
// === Error codes ===
/// The key does not match this lock.
const ELockKeyMismatch: u64 = 0;
// === Public Functions ===
/// Lock `obj` and get a key that can be used to unlock it.
public fun lock<T: store>(
obj: T,
ctx: &mut TxContext,
): (Locked<T>, Key) {
let key = Key { id: object::new(ctx) };
let lock = Locked {
id: object::new(ctx),
key: object::id(&key),
obj,
};
(lock, key)
}
/// Unlock the object in `locked`, consuming the `key`. Fails if the wrong
/// `key` is passed in for the locked object.
public fun unlock<T: store>(locked: Locked<T>, key: Key): T {
assert!(locked.key == object::id(&key), ELockKeyMismatch);
let Key { id } = key;
object::delete(id);
let Locked { id, key: _, obj } = locked;
object::delete(id);
obj
}
// === Tests ===
#[test_only] use one::coin::{Self, Coin};
#[test_only] use one::oct::OCT;
#[test_only] use one::test_scenario::{Self as ts, Scenario};
#[test_only]
fun test_coin(ts: &mut Scenario): Coin<OCT> {
coin::mint_for_testing<OCT>(42, ts::ctx(ts))
}
#[test]
fun test_lock_unlock() {
let ts = ts::begin(@0xA);
let coin = test_coin(&mut ts);
let (lock, key) = lock(coin, ts::ctx(&mut ts));
let coin = unlock(lock, key);
coin::burn_for_testing(coin);
ts::end(ts);
}
#[test]
#[expected_failure(abort_code = ELockKeyMismatch)]
fun test_lock_key_mismatch() {
let ts = ts::begin(@0xA);
let (l, _k) = lock(42, ts::ctx(&mut ts));
let (_l, k) = lock(43, ts::ctx(&mut ts));
unlock(l, k);
abort 1337
}
} Both implementations use a primitive for locking values, which offers the following interface:
module escrow::lock { public fun lock<T: store>(obj: T, ctx: &mut TxContext): (Locked, Key); public fun unlock<T: store>(locked: Locked, key: Key): T } Any T: store can be locked, to get a Locked and a corresponding Key, and conversely, the locked value and its corresponding key can be consumed to get back the wrapped object.
The important property that this interface provides is that locked values cannot be modified except by unlocking them first (and later relocking them). Because unlocking consumes the key, tampering with a locked value can be detected by remembering the ID of the key that it was locked with. This prevents situations where one party in a swap changes the object they are offering to reduce its value.
Owned objects
/// An escrow for atomic swap of objects using single-owner transactions that
/// trusts a third party for liveness, but not safety.
///
/// Swap via Escrow proceeds in three phases:
///
/// 1. Both parties lock their objects, getting the Locked object and a
/// Key. Each party can unlock their object, to preserve liveness if the
/// other party stalls before completing the second stage.
///
/// 2. Both parties register an Escrow object with the custodian, this
/// requires passing the locked object and its key. The key is consumed to
/// unlock the object, but its ID is remembered so the custodian can ensure
/// the right objects being swapped. The custodian is trusted to preserve
/// liveness.
///
/// 3. The custodian swaps the locked objects as long as all conditions are met:
///
/// - The sender of one Escrow is the recipient of the other and vice versa.
/// If this is not true, the custodian has incorrectly paired together this
/// swap.
///
/// - The key of the desired object (exchange_key) matches the key the
/// other object was locked with (escrowed_key) and vice versa.
/// If this is not true, it means the wrong objects are being swapped,
/// either because the custodian paired the wrong escrows together, or
/// because one of the parties tampered with their object after locking it.
///
/// The key in question is the ID of the Key object that unlocked the
/// Locked object that the respective objects resided in immediately
/// before being sent to the custodian.
module escrow::owned;
use escrow::lock::{Locked, Key};
/// An object held in escrow
public struct Escrow<T: key + store> has key {
id: UID,
/// Owner of escrowed
sender: address,
/// Intended recipient
recipient: address,
/// The ID of the key that opens the lock on the object sender wants
/// from recipient.
exchange_key: ID,
/// The ID of the key that locked the escrowed object, before it was
/// escrowed.
escrowed_key: ID,
/// The escrowed object.
escrowed: T,
}
// === Error codes ===
/// The sender and recipient of the two escrowed objects do not match
const EMismatchedSenderRecipient: u64 = 0;
/// The exchange_key fields of the two escrowed objects do not match
const EMismatchedExchangeObject: u64 = 1;
// === Public Functions ===
/// ctx.sender() requests a swap with recipient of a locked
/// object locked in exchange for an object referred to by exchange_key.
/// The swap is performed by a third-party, custodian, that is trusted to
/// maintain liveness, but not safety (the only actions they can perform are
/// to successfully progress the swap).
///
/// locked will be unlocked with its corresponding key before being sent
/// to the custodian, but the underlying object is still not accessible
/// until after the swap has executed successfully, or the custodian returns
/// the object.
///
/// exchange_key is the ID of a Key that unlocks the sender's desired
/// object. Gating the swap on the key ensures that it will not succeed if
/// the desired object is tampered with after the sender's object is held in
/// escrow, because the recipient would have to consume the key to tamper
/// with the object, and if they re-locked the object it would be protected
/// by a different, incompatible key.
public fun create<T: key + store>(
key: Key,
locked: Locked,
exchange_key: ID,
recipient: address,
custodian: address,
ctx: &mut TxContext,
) {
let escrow = Escrow {
id: object::new(ctx),
sender: ctx.sender(),
recipient,
exchange_key,
escrowed_key: object::id(&key),
escrowed: locked.unlock(key),
};
transfer::transfer(escrow, custodian);
}
/// Function for custodian (trusted third-party) to perform a swap between /// two parties. Fails if their senders and recipients do not match, or if /// their respective desired objects do not match. public fun swap<T: key + store, U: key + store>(obj1: Escrow, obj2: Escrow) { let Escrow { id: id1, sender: sender1, recipient: recipient1, exchange_key: exchange_key1, escrowed_key: escrowed_key1, escrowed: escrowed1, } = obj1;
let Escrow {
id: id2,
sender: sender2,
recipient: recipient2,
exchange_key: exchange_key2,
escrowed_key: escrowed_key2,
escrowed: escrowed2,
} = obj2;
id1.delete();
id2.delete();
// Make sure the sender and recipient match each other
assert!(sender1 == recipient2, EMismatchedSenderRecipient);
assert!(sender2 == recipient1, EMismatchedSenderRecipient);
// Make sure the objects match each other and haven't been modified
// (they remain locked).
assert!(escrowed_key1 == exchange_key2, EMismatchedExchangeObject);
assert!(escrowed_key2 == exchange_key1, EMismatchedExchangeObject);
// Do the actual swap
transfer::public_transfer(escrowed1, recipient1);
transfer::public_transfer(escrowed2, recipient2);
}
/// The custodian can always return an escrowed object to its original /// owner. public fun return_to_sender<T: key + store>(obj: Escrow) { let Escrow { id, sender, recipient: _, exchange_key: _, escrowed_key: _, escrowed, } = obj; id.delete(); transfer::public_transfer(escrowed, sender); }
// === Tests === #[test_only] use one::coin::{Self, Coin}; #[test_only] use one::oct::OCT; #[test_only] use one::test_scenario::{Self as ts, Scenario};
#[test_only] use escrow::lock;
#[test_only] const ALICE: address = @0xA; #[test_only] const BOB: address = @0xB; #[test_only] const CUSTODIAN: address = @0xC; #[test_only] const DIANE: address = @0xD;
#[test_only] fun test_coin(ts: &mut Scenario): Coin { coin::mint_for_testing(42, ts::ctx(ts)) }
#[test] fun test_successful_swap() { let mut ts = ts::begin(@0x0);
// Alice locks the object they want to trade
let (i1, ik1) = {
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let cid = object::id(&c);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, ALICE);
transfer::public_transfer(k, ALICE);
(cid, kid)
};
// Bob locks their object as well.
let (i2, ik2) = {
ts.next_tx(BOB);
let c = test_coin(&mut ts);
let cid = object::id(&c);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, BOB);
transfer::public_transfer(k, BOB);
(cid, kid)
};
// Alice gives the custodian their object to hold in escrow.
{
ts.next_tx(ALICE);
let k1: Key = ts.take_from_sender();
let l1: Locked<Coin<OCT>> = ts.take_from_sender();
create(k1, l1, ik2, BOB, CUSTODIAN, ts.ctx());
};
// Bob does the same.
{
ts.next_tx(BOB);
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<OCT>> = ts.take_from_sender();
create(k2, l2, ik1, ALICE, CUSTODIAN, ts.ctx());
};
// The custodian makes the swap
{
ts.next_tx(CUSTODIAN);
swap<Coin<OCT>, Coin<OCT>>(
ts.take_from_sender(),
ts.take_from_sender(),
);
};
// Commit effects from the swap
ts.next_tx(@0x0);
// Alice gets the object from Bob
{
let c: Coin<OCT> = ts.take_from_address_by_id(ALICE, i2);
ts::return_to_address(ALICE, c);
};
// Bob gets the object from Alice
{
let c: Coin<OCT> = ts.take_from_address_by_id(BOB, i1);
ts::return_to_address(BOB, c);
};
ts.end();
}
#[test] #[expected_failure(abort_code = EMismatchedSenderRecipient)] fun test_mismatch_sender() { let mut ts = ts::begin(@0x0);
let ik1 = {
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, ALICE);
transfer::public_transfer(k, ALICE);
kid
};
let ik2 = {
ts.next_tx(BOB);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, BOB);
transfer::public_transfer(k, BOB);
kid
};
// Alice wants to trade with Bob.
{
ts.next_tx(ALICE);
let k1: Key = ts.take_from_sender();
let l1: Locked<Coin<OCT>> = ts.take_from_sender();
create(k1, l1, ik2, BOB, CUSTODIAN, ts.ctx());
};
// But Bob wants to trade with Diane.
{
ts.next_tx(BOB);
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<OCT>> = ts.take_from_sender();
create(k2, l2, ik1, DIANE, CUSTODIAN, ts.ctx());
};
// When the custodian tries to match up the swap, it will fail.
{
ts.next_tx(CUSTODIAN);
swap<Coin<OCT>, Coin<OCT>>(
ts.take_from_sender(),
ts.take_from_sender(),
);
};
abort 1337
}
#[test] #[expected_failure(abort_code = EMismatchedExchangeObject)] fun test_mismatch_object() { let mut ts = ts::begin(@0x0);
let ik1 = {
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, ALICE);
transfer::public_transfer(k, ALICE);
kid
};
{
ts.next_tx(BOB);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
transfer::public_transfer(l, BOB);
transfer::public_transfer(k, BOB);
};
// Alice wants to trade with Bob, but Alice has asked for an
// object (via its `exchange_key`) that Bob has not put up for
// the swap.
{
ts.next_tx(ALICE);
let k1: Key = ts.take_from_sender();
let l1: Locked<Coin<OCT>> = ts.take_from_sender();
create(k1, l1, ik1, BOB, CUSTODIAN, ts.ctx());
};
{
ts.next_tx(BOB);
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<OCT>> = ts.take_from_sender();
create(k2, l2, ik1, ALICE, CUSTODIAN, ts.ctx());
};
// When the custodian tries to match up the swap, it will fail.
{
ts.next_tx(CUSTODIAN);
swap<Coin<OCT>, Coin<OCT>>(
ts.take_from_sender(),
ts.take_from_sender(),
);
};
abort 1337
}
#[test] #[expected_failure(abort_code = EMismatchedExchangeObject)] fun test_object_tamper() { let mut ts = ts::begin(@0x0);
// Alice locks the object they want to trade
let ik1 = {
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, ALICE);
transfer::public_transfer(k, ALICE);
kid
};
// Bob locks their object as well.
let ik2 = {
ts.next_tx(BOB);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, BOB);
transfer::public_transfer(k, BOB);
kid
};
// Alice gives the custodian their object to hold in escrow.
{
ts.next_tx(ALICE);
let k1: Key = ts.take_from_sender();
let l1: Locked<Coin<OCT>> = ts.take_from_sender();
create(k1, l1, ik2, BOB, CUSTODIAN, ts.ctx());
};
// Bob has a change of heart, so they unlock the object and tamper
// with it.
{
ts.next_tx(BOB);
let k: Key = ts.take_from_sender();
let l: Locked<Coin<OCT>> = ts.take_from_sender();
let mut c = lock::unlock(l, k);
let _dust = coin::split(&mut c, 1, ts.ctx());
let (l, k) = lock::lock(c, ts.ctx());
create(k, l, ik1, ALICE, CUSTODIAN, ts.ctx());
};
// When the Custodian makes the swap, it detects Bob's nefarious
// behaviour.
{
ts.next_tx(CUSTODIAN);
swap<Coin<OCT>, Coin<OCT>>(
ts.take_from_sender(),
ts.take_from_sender(),
);
};
abort 1337
}
#[test] fun test_return_to_sender() { let mut ts = ts::begin(@0x0);
// Alice locks the object they want to trade
let cid = {
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let cid = object::id(&c);
let (l, k) = lock::lock(c, ts.ctx());
let i = object::id_from_address(@0x0);
create(k, l, i, BOB, CUSTODIAN, ts.ctx());
cid
};
// Custodian sends it back
{
ts.next_tx(CUSTODIAN);
return_to_sender<Coin<OCT>>(ts.take_from_sender());
};
ts.next_tx(@0x0);
// Alice can then access it.
{
let c: Coin<OCT> = ts.take_from_address_by_id(ALICE, cid);
ts::return_to_address(ALICE, c)
};
ts.end();
} The protocol for swapping via escrow implemented using owned objects starts with both parties locking their respective objects.
flowchart TD SR[Locked<fa:fa-wrench S>, fa:fa-key key_s] BR[Locked<fa:fa-coins B>, fa:fa-key key_b]
subgraph Seller
a2(fa:fa-wrench S)--escrow::lock-->SR
end
subgraph Buyer
a1(fa:fa-coins B)--escrow::lock-->BR
end
This is used to prove that the object has not been tampered with after the swap has been agreed to. If either party doesn't want to proceed at this stage, they just unlock their object.
Assuming both parties are happy to continue, the next step requires both parties to swap the keys.
flowchart LR Buyer--fa:fa-key key_b-->Seller Seller--fa:fa-key key_s-->Buyer A third party acts as custodian. The custodian holds objects that are waiting for their counterparts to arrive and when they arrive, it matches them up to complete the swap.
public fun create<T: key + store>( key: Key, locked: Locked, exchange_key: ID, recipient: address, custodian: address, ctx: &mut TxContext, ) { let escrow = Escrow { id: object::new(ctx), sender: ctx.sender(), recipient, exchange_key, escrowed_key: object::id(&key), escrowed: locked.unlock(key), };
transfer::transfer(escrow, custodian);
} flowchart TB S["fa:fa-key key_s, Locked<fa:fa-wrench S>, exchange_key: fa:fa-key key_b, recipient: Buyer "] B["fa:fa-key key_b, Locked<fa:fa-coins B>, exchange_key: fa:fa-key key_s, recipient: Seller "]
id1(Escrow<fa:fa-coins B>)-->Third_Party
id2(Escrow<fa:fa-wrench S>)-->Third_Party
subgraph Buyer
direction TB
B--create-->id1
end
subgraph Seller
direction TB
S--create-->id2
end
The create function prepares the Escrow request and sends it to the custodian. The object being offered by this party is passed in, locked, with its key, and the object being requested is identified by the ID of the key it was locked with. While preparing the request, the offered object is unlocked, while remembering the ID of its key.
Although the custodian is trusted to preserve liveness (to complete swaps if it owns both sides of a swap and to return objects if requested), all other correctness properties are maintained in Move: Even though the custodian owns both objects being swapped, the only valid action they are permitted to take is to match them up with their correct counterpart to finish the swap, or to return them:
flowchart TB
subgraph Third_Party
direction TB
id1(fa:fa-wrench S, fa:fa-coins B)
id2(Escrow<fa:fa-coins B>, Escrow<fa:fa-wrench S>)
id2--swap-->id1
end
Third_Party--fa:fa-wrench S-->Buyer
Third_Party--fa:fa-coins B-->Seller
/// Function for custodian (trusted third-party) to perform a swap between /// two parties. Fails if their senders and recipients do not match, or if /// their respective desired objects do not match. public fun swap<T: key + store, U: key + store>(obj1: Escrow, obj2: Escrow) { let Escrow { id: id1, sender: sender1, recipient: recipient1, exchange_key: exchange_key1, escrowed_key: escrowed_key1, escrowed: escrowed1, } = obj1;
let Escrow {
id: id2,
sender: sender2,
recipient: recipient2,
exchange_key: exchange_key2,
escrowed_key: escrowed_key2,
escrowed: escrowed2,
} = obj2;
id1.delete();
id2.delete();
// Make sure the sender and recipient match each other
assert!(sender1 == recipient2, EMismatchedSenderRecipient);
assert!(sender2 == recipient1, EMismatchedSenderRecipient);
// Make sure the objects match each other and haven't been modified
// (they remain locked).
assert!(escrowed_key1 == exchange_key2, EMismatchedExchangeObject);
assert!(escrowed_key2 == exchange_key1, EMismatchedExchangeObject);
// Do the actual swap
transfer::public_transfer(escrowed1, recipient1);
transfer::public_transfer(escrowed2, recipient2);
} The swap function checks that senders and recipients match and that each party wants the object that the other party is offering, by comparing their respective key IDs. If the custodian tried to match together two unrelated escrow requests to swap, the transaction would not succeed.
Shared objects
/// An escrow for atomic swap of objects using shared objects without a trusted
/// third party.
///
/// The protocol consists of three phases:
///
/// 1. One party locks their object, getting a Locked object and its Key.
/// This party can unlock their object to preserve livness if the other
/// party stalls before completing the second stage.
///
/// 2. The other party registers a publicly accessible, shared Escrow object.
/// This effectively locks their object at a particular version as well,
/// waiting for the first party to complete the swap. The second party is
/// able to request their object is returned to them, to preserve liveness as
/// well.
///
/// 3. The first party sends their locked object and its key to the shared
/// Escrow object. This completes the swap, as long as all conditions are
/// met:
///
/// - The sender of the swap transaction is the recipient of the Escrow.
///
/// - The key of the desired object (exchange_key) in the escrow matches
/// the key supplied in the swap.
///
/// - The key supplied in the swap unlocks the Locked<U>.
module escrow::shared;
use escrow::lock::{Locked, Key}; use one::{dynamic_object_field as dof, event};
/// The name of the DOF that holds the Escrowed object.
/// Allows easy discoverability for the escrowed object.
public struct EscrowedObjectKey has copy, store, drop {}
/// An object held in escrow
///
/// The escrowed object is added as a Dynamic Object Field so it can still be looked-up.
public struct Escrow<phantom T: key + store> has key, store {
id: UID,
/// Owner of escrowed
sender: address,
/// Intended recipient
recipient: address,
/// ID of the key that opens the lock on the object sender wants from
/// recipient.
exchange_key: ID,
}
// === Error codes ===
/// The sender and recipient of the two escrowed objects do not match
const EMismatchedSenderRecipient: u64 = 0;
/// The exchange_for fields of the two escrowed objects do not match
const EMismatchedExchangeObject: u64 = 1;
// === Public Functions === public fun create<T: key + store>( escrowed: T, exchange_key: ID, recipient: address, ctx: &mut TxContext, ) { let mut escrow = Escrow { id: object::new(ctx), sender: ctx.sender(), recipient, exchange_key, }; event::emit(EscrowCreated { escrow_id: object::id(&escrow), key_id: exchange_key, sender: escrow.sender, recipient, item_id: object::id(&escrowed), });
dof::add(&mut escrow.id, EscrowedObjectKey {}, escrowed);
transfer::public_share_object(escrow);
}
/// The recipient of the escrow can exchange obj with the escrowed item
public fun swap<T: key + store, U: key + store>(
mut escrow: Escrow,
key: Key,
locked: Locked,
ctx: &TxContext,
): T {
let escrowed = dof::remove<EscrowedObjectKey, T>(&mut escrow.id, EscrowedObjectKey {});
let Escrow {
id,
sender,
recipient,
exchange_key,
} = escrow;
assert!(recipient == ctx.sender(), EMismatchedSenderRecipient);
assert!(exchange_key == object::id(&key), EMismatchedExchangeObject);
// Do the actual swap
transfer::public_transfer(locked.unlock(key), sender);
event::emit(EscrowSwapped {
escrow_id: id.to_inner(),
});
id.delete();
escrowed
}
/// The creator can cancel the escrow and get back the escrowed item
public fun return_to_sender<T: key + store>(mut escrow: Escrow, ctx: &TxContext): T {
event::emit(EscrowCancelled {
escrow_id: object::id(&escrow),
});
let escrowed = dof::remove<EscrowedObjectKey, T>(&mut escrow.id, EscrowedObjectKey {});
let Escrow {
id,
sender,
recipient: _,
exchange_key: _,
} = escrow;
assert!(sender == ctx.sender(), EMismatchedSenderRecipient);
id.delete();
escrowed
}
// === Events ===
public struct EscrowCreated has copy, drop {
/// the ID of the escrow that was created
escrow_id: ID,
/// The ID of the Key that unlocks the requested object.
key_id: ID,
/// The id of the sender who'll receive T upon swap
sender: address,
/// The (original) recipient of the escrowed object
recipient: address,
/// The ID of the escrowed item
item_id: ID,
}
public struct EscrowSwapped has copy, drop { escrow_id: ID, }
public struct EscrowCancelled has copy, drop { escrow_id: ID, }
// === Tests === #[test_only] use one::coin::{Self, Coin}; #[test_only] use one::oct::OCT; #[test_only] use one::test_scenario::{Self as ts, Scenario};
#[test_only] use escrow::lock;
#[test_only] const ALICE: address = @0xA; #[test_only] const BOB: address = @0xB; #[test_only] const DIANE: address = @0xD;
#[test_only] fun test_coin(ts: &mut Scenario): Coin { coin::mint_for_testing(42, ts.ctx()) } #[test] fun test_successful_swap() { let mut ts = ts::begin(@0x0);
// Bob locks the object they want to trade.
let (i2, ik2) = {
ts.next_tx(BOB);
let c = test_coin(&mut ts);
let cid = object::id(&c);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, BOB);
transfer::public_transfer(k, BOB);
(cid, kid)
};
// Alice creates a public Escrow holding the object they are willing to
// share, and the object they want from Bob
let i1 = {
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let cid = object::id(&c);
create(c, ik2, BOB, ts.ctx());
cid
};
// Bob responds by offering their object, and gets Alice's object in
// return.
{
ts.next_tx(BOB);
let escrow: Escrow<Coin<OCT>> = ts.take_shared();
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<OCT>> = ts.take_from_sender();
let c = escrow.swap(k2, l2, ts.ctx());
transfer::public_transfer(c, BOB);
};
// Commit effects from the swap
ts.next_tx(@0x0);
// Alice gets the object from Bob
{
let c: Coin<OCT> = ts.take_from_address_by_id(ALICE, i2);
ts::return_to_address(ALICE, c);
};
// Bob gets the object from Alice
{
let c: Coin<OCT> = ts.take_from_address_by_id(BOB, i1);
ts::return_to_address(BOB, c);
};
ts::end(ts);
}
#[test] #[expected_failure(abort_code = EMismatchedSenderRecipient)] fun test_mismatch_sender() { let mut ts = ts::begin(@0x0);
let ik2 = {
ts.next_tx(DIANE);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, DIANE);
transfer::public_transfer(k, DIANE);
kid
};
// Alice wants to trade with Bob.
{
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
create(c, ik2, BOB, ts.ctx());
};
// But Diane is the one who attempts the swap
{
ts.next_tx(DIANE);
let escrow: Escrow<Coin<OCT>> = ts.take_shared();
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<OCT>> = ts.take_from_sender();
let c = escrow.swap(k2, l2, ts.ctx());
transfer::public_transfer(c, DIANE);
};
abort 1337
}
#[test] #[expected_failure(abort_code = EMismatchedExchangeObject)] fun test_mismatch_object() { let mut ts = ts::begin(@0x0);
{
ts.next_tx(BOB);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
transfer::public_transfer(l, BOB);
transfer::public_transfer(k, BOB);
};
// Alice wants to trade with Bob, but Alice has asked for an object (via
// its `exchange_key`) that Bob has not put up for the swap.
{
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let cid = object::id(&c);
create(c, cid, BOB, ts.ctx());
};
// When Bob tries to complete the swap, it will fail, because they
// cannot meet Alice's requirements.
{
ts.next_tx(BOB);
let escrow: Escrow<Coin<OCT>> = ts.take_shared();
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<OCT>> = ts.take_from_sender();
let c = escrow.swap(k2, l2, ts.ctx());
transfer::public_transfer(c, BOB);
};
abort 1337
}
#[test] #[expected_failure(abort_code = EMismatchedExchangeObject)] fun test_object_tamper() { let mut ts = ts::begin(@0x0);
// Bob locks their object.
let ik2 = {
ts.next_tx(BOB);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, BOB);
transfer::public_transfer(k, BOB);
kid
};
// Alice sets up the escrow
{
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
create(c, ik2, BOB, ts.ctx());
};
// Bob has a change of heart, so they unlock the object and tamper with
// it before initiating the swap, but it won't be possible for Bob to
// hide their tampering.
{
ts.next_tx(BOB);
let k: Key = ts.take_from_sender();
let l: Locked<Coin<OCT>> = ts.take_from_sender();
let mut c = lock::unlock(l, k);
let _dust = c.split(1, ts.ctx());
let (l, k) = lock::lock(c, ts.ctx());
let escrow: Escrow<Coin<OCT>> = ts.take_shared();
let c = escrow.swap(k, l, ts.ctx());
transfer::public_transfer(c, BOB);
};
abort 1337
}
#[test] fun test_return_to_sender() { let mut ts = ts::begin(@0x0);
// Alice puts up the object they want to trade
let cid = {
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let cid = object::id(&c);
let i = object::id_from_address(@0x0);
create(c, i, BOB, ts.ctx());
cid
};
// ...but has a change of heart and takes it back
{
ts.next_tx(ALICE);
let escrow: Escrow<Coin<OCT>> = ts.take_shared();
let c = escrow.return_to_sender(ts.ctx());
transfer::public_transfer(c, ALICE);
};
ts.next_tx(@0x0);
// Alice can then access it.
{
let c: Coin<OCT> = ts.take_from_address_by_id(ALICE, cid);
ts::return_to_address(ALICE, c)
};
ts::end(ts);
}
#[test] #[expected_failure] fun test_return_to_sender_failed_swap() { let mut ts = ts::begin(@0x0);
// Bob locks their object.
let ik2 = {
ts.next_tx(BOB);
let c = test_coin(&mut ts);
let (l, k) = lock::lock(c, ts.ctx());
let kid = object::id(&k);
transfer::public_transfer(l, BOB);
transfer::public_transfer(k, BOB);
kid
};
// Alice creates a public Escrow holding the object they are willing to
// share, and the object they want from Bob
{
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
create(c, ik2, BOB, ts.ctx());
};
// ...but then has a change of heart
{
ts.next_tx(ALICE);
let escrow: Escrow<Coin<OCT>> = ts.take_shared();
let c = escrow.return_to_sender(ts.ctx());
transfer::public_transfer(c, ALICE);
};
// Bob's attempt to complete the swap will now fail.
{
ts.next_tx(BOB);
let escrow: Escrow<Coin<OCT>> = ts.take_shared();
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<OCT>> = ts.take_from_sender();
let c = escrow.swap(k2, l2, ts.ctx());
transfer::public_transfer(c, BOB);
};
abort 1337
} The protocol in the shared object case is less symmetric, but still starts with the first party locking the object they want to swap.
flowchart TB B["Locked<fa:fa-coins B>, fa:fa-key key_b"]
subgraph Buyer
direction TB
a1(fa:fa-coins B)--escrow::lock-->B
end
The second party can then view the object that was locked, and if they decide they want to swap with it, they indicate their interest by creating a swap request:
flowchart TB S["fa:fa-wrench S, exchange_key: fa:fa-key key_b, recipient: Buyer "]
id1(Shared Object)-->id2(Escrow<fa:fa-wrench S>)
subgraph Seller
direction TB
S--create-->id2
end
public fun create<T: key + store>( escrowed: T, exchange_key: ID, recipient: address, ctx: &mut TxContext, ) { let mut escrow = Escrow { id: object::new(ctx), sender: ctx.sender(), recipient, exchange_key, }; event::emit(EscrowCreated { escrow_id: object::id(&escrow), key_id: exchange_key, sender: escrow.sender, recipient, item_id: object::id(&escrowed), });
dof::add(&mut escrow.id, EscrowedObjectKey {}, escrowed);
transfer::public_share_object(escrow);
} This time the create request accepts the object being escrowed directly (not locked), and creates a shared Escrow object. The request remembers the address that sent it (who is allowed to reclaim the object if the swap hasn't already happened), and the intended recipient, who is then expected to continue the swap by providing the object they initially locked:
flowchart TB
subgraph Buyer
direction TB
id1(Escrow<fa:fa-wrench S>,\n fa:fa-key key_b,\n Locked<fa:fa-coins B>)
id2(fa:fa-wrench S)
id1-->swap-->id2
end
swap--fa:fa-coins B-->Seller
/// The recipient of the escrow can exchange obj with the escrowed item
public fun swap<T: key + store, U: key + store>(
mut escrow: Escrow,
key: Key,
locked: Locked,
ctx: &TxContext,
): T {
let escrowed = dof::remove<EscrowedObjectKey, T>(&mut escrow.id, EscrowedObjectKey {});
let Escrow {
id,
sender,
recipient,
exchange_key,
} = escrow;
assert!(recipient == ctx.sender(), EMismatchedSenderRecipient);
assert!(exchange_key == object::id(&key), EMismatchedExchangeObject);
// Do the actual swap
transfer::public_transfer(locked.unlock(key), sender);
event::emit(EscrowSwapped {
escrow_id: id.to_inner(),
});
id.delete();
escrowed
} Even though the Escrow object is a shared object that is accessible by anyone, the Move interface ensures that only the original sender and the intended recipient can successfully interact with it. swap checks that the locked object matches the object that was requested when the Escrow was created (again, by comparing key IDs) and assumes that the intended recipient wants the escrowed object (if they did not, they would not have called swap).
Assuming all checks pass, the object held in Escrow is extracted, its wrapper is deleted and it is returned to the first party. The locked object offered by the first party is also unlocked and sent to the second party, completing the swap.
Comparison This topic explores two ways to implement a swap between two objects. In both cases there is a point at which one party has made a request and the other has not responded. At this point, both parties may want to access the Escrow object: One to cancel the swap, and the other to complete it.
In one case, the protocol uses only owned objects but requires a custodian to act as an intermediary. This has the advantage of avoiding the costs and latencies of consensus altogether, but involves more steps and requires trusting a third party for liveness.
In the other case, the object is custodied on chain in a shared object. This requires consensus but involves fewer steps, and no third party.
Using Events The OneChain network stores countless objects on chain where Move code can perform actions using those objects. Tracking this activity is often desired, for example, to discover how many times a module mints an NFT or to tally the amount of OCT in transactions that a smart contract generates.
To support activity monitoring, Move provides a structure to emit events on the OneChain network. You can then leverage a custom indexer to process checkpoint data that includes events that have been emitted. See the custom indexer topic in the Advanced section to learn how to stream checkpoints and filter events continuously.
If you don't want to run a custom indexer, you can poll the OneChain network to query for emitted events instead. This approach typically includes a database to store the data retrieved from these calls. The Poll events section provides an example of using this method.
Move event structure An event object in OneChain consists of the following attributes:
id: JSON object containing the transaction digest ID and event sequence. packageId: The object ID of the package that emits the event. transactionModule: The module that performs the transaction. sender: The OneChain network address that triggered the event. type: The type of event being emitted. parsedJson: JSON object describing the event. bcs: Binary canonical serialization value. timestampMs: Unix epoch timestamp in milliseconds. Emit events in Move To create an event in your Move modules, add the one::event dependency.
use one::event; With the dependency added, you can use the emit function to trigger an event whenever the action you want to monitor fires. For example, the following code is part of an example application that enables the locking of objects. The lock function handles the locking of objects and emits an event whenever the function is called.
public fun lock<T: key + store>(obj: T, ctx: &mut TxContext): (Locked, Key) { let key = Key { id: object::new(ctx) }; let mut lock = Locked { id: object::new(ctx), key: object::id(&key), };
event::emit(LockCreated {
lock_id: object::id(&lock),
key_id: object::id(&key),
creator: ctx.sender(),
item_id: object::id(&obj),
});
dof::add(&mut lock.id, LockedObjectKey {}, obj);
(lock, key)
} Query events with RPC The OneChain RPC provides a queryEvents method to query on-chain packages and return available events. As an example, the following curl command queries the Deepbook package on Mainnet for a specific type of event:
$ curl -X POST https://rpc-mainnet.onelabs.cc:443
-H "Content-Type: application/json"
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "suix_queryEvents",
"params": [
{
"MoveModule": {
"package": "0x158f2027f60c89bb91526d9bf08831d27f5a0fcb0f74e6698b9f0e1fb2be5d05",
"module": "deepbook_utils",
"type": "0xdee9::clob_v2::DepositAsset<0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN>"
}
},
null,
3,
false
]
}'
A successful curl return
{ "jsonrpc": "2.0", "result": { "data": [ { "id": { "txDigest": "8NB8sXb4m9PJhCyLB7eVH4onqQWoFFzVUrqPoYUhcQe2", "eventSeq": "0" }, "packageId": "0x158f2027f60c89bb91526d9bf08831d27f5a0fcb0f74e6698b9f0e1fb2be5d05", "transactionModule": "deepbook_utils", "sender": "0x8b35e67a519fffa11a9c74f169228ff1aa085f3a3d57710af08baab8c02211b9", "type": "0xdee9::clob_v2::WithdrawAsset<0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN>", "parsedJson": { "owner": "0x704c8c0d8052be7b5ca7174222a8980fb2ad3cd640f4482f931deb6436902627", "pool_id": "0x7f526b1263c4b91b43c9e646419b5696f424de28dda3c1e6658cc0a54558baa7", "quantity": "6956" }, "bcs": "2szz6igTRuGmD7YATo8BEg81VLaei4od62wehadwMXYJv63UzJE16USL9pHFYBAGbwNkDYLCk53d45eFj3tEZK1vDGqtXcqH5US", "timestampMs": "1691757698019" }, { "id": { "txDigest": "8NB8sXb4m9PJhCyLB7eVH4onqQWoFFzVUrqPoYUhcQe2", "eventSeq": "1" }, "packageId": "0x158f2027f60c89bb91526d9bf08831d27f5a0fcb0f74e6698b9f0e1fb2be5d05", "transactionModule": "deepbook_utils", "sender": "0x8b35e67a519fffa11a9c74f169228ff1aa085f3a3d57710af08baab8c02211b9", "type": "0xdee9::clob_v2::OrderFilled<0x2::oct::OCT, 0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN>", "parsedJson": { "base_asset_quantity_filled": "0", "base_asset_quantity_remaining": "1532800000000", "is_bid": false, "maker_address": "0x78a1ff467e9c15b56caa0dedfcfbdfe47c0c385f28b05fdc120b2de188cc8736", "maker_client_order_id": "1691757243084", "maker_rebates": "0", "order_id": "9223372036854839628", "original_quantity": "1614700000000", "pool_id": "0x7f526b1263c4b91b43c9e646419b5696f424de28dda3c1e6658cc0a54558baa7", "price": "605100", "taker_address": "0x704c8c0d8052be7b5ca7174222a8980fb2ad3cd640f4482f931deb6436902627", "taker_client_order_id": "20082022", "taker_commission": "0" }, "bcs": "DcVGz85dWTLU4S33N7VYrhgbkm79ENhHVnp5kBfENEWEeMxHQuvsczg94teh6WHdYtwPqdEsPWdvSJ7ne5qiMxxn3kBm36KLyuuzHV1QdzF45GN8ZU1MDGU4XppiaqcMeRpPPiW8JpUDyeQoobKEV8fMqcyYpDq6KWtZ1WMoGvEDxFKDgFvW9Q7bt1JAzQehRkEKEDZ6dTwfiHw92QuFqczmZ5MKJLYzeysUsSw", "timestampMs": "1691757698019" }, { "id": { "txDigest": "8b3byDuRojHXqmSz16PsyzfdXJEY5nZBGTM23gMsMAY8", "eventSeq": "0" }, "packageId": "0x158f2027f60c89bb91526d9bf08831d27f5a0fcb0f74e6698b9f0e1fb2be5d05", "transactionModule": "deepbook_utils", "sender": "0x8b35e67a519fffa11a9c74f169228ff1aa085f3a3d57710af08baab8c02211b9", "type": "0xdee9::clob_v2::OrderFilled<0x2::oct::OCT, 0x5d4b302506645c37ff133b98c4b50a5ae14841659738d6d733d59d0d217a93bf::coin::COIN>", "parsedJson": { "base_asset_quantity_filled": "700000000", "base_asset_quantity_remaining": "0", "is_bid": false, "maker_address": "0x03b86e93d80b27763ee1fc2c37e285465dff835769de9462d9ad4ebcf46ac6df", "maker_client_order_id": "20082022", "maker_rebates": "634", "order_id": "9223372036854839643", "original_quantity": "1000000000", "pool_id": "0x7f526b1263c4b91b43c9e646419b5696f424de28dda3c1e6658cc0a54558baa7", "price": "604100", "taker_address": "0x704c8c0d8052be7b5ca7174222a8980fb2ad3cd640f4482f931deb6436902627", "taker_client_order_id": "20082022", "taker_commission": "1058" }, "bcs": "DcVGz85dWTLU4S33N7VYrhgbkm79ENhHVnp5kBfENEWEjN45pa9U3AkNhxfTRZbaHTQLugLBXttE32hpJKRsbrZGdryXMPmNA8EpHJnVcnYMXZmWXkNXvY1XjEYnAKU4BnhyJ9BQuxRJDXLA4DEu5uWEpWjLPD2ZHuxqHCn7GpUxvxJjHkKjr9jVVfeR6sN2uRhUXkThEDjCekrqaqwidkyXNmTzmZG4fre3eoZ", "timestampMs": "1691758372427" } ], "nextCursor": { "txDigest": "8b3byDuRojHXqmSz16PsyzfdXJEY5nZBGTM23gMsMAY8", "eventSeq": "0" }, "hasNextPage": true }, "id": 1 } Query events in Rust use sui_sdk::{rpc_types::EventFilter, types::Identifier, SuiClientBuilder};
const PACKAGE_ID_CONST: &str = "0x279525274aa623ef31a25ad90e3b99f27c8dbbad636a6454918855c81d625abc";
#[tokio::main] async fn main() -> Result<(), anyhow::Error> { let testnet = SuiClientBuilder::default() .build("https://rpc-testnet.onelabs.cc:443") .await?;
let events = testnet
.event_api()
.query_events(
EventFilter::MoveModule {
package: PACKAGE_ID_CONST.parse()?,
module: Identifier::new("dev_trophy")?,
},
None,
None,
false,
)
.await?;
for event in events.data {
println!("Event: {:?}", event.parsed_json);
}
Ok(())
} Monitoring events Firing events is not very useful in a vacuum. You also need the ability to respond to those events. There are two methods from which to choose when you need to monitor on-chain events:
Incorporate a custom indexer to take advantage of OneChain's micro-data ingestion framework. Poll the OneChain network on a schedule to query events. Using a custom indexer provides a near-real time monitoring of events, so is most useful when your project requires immediate reaction to the firing of events. Polling the network is most useful when the events you're monitoring don't fire often or the need to act on those events are not immediate. The following section provides a polling example.
Poll events To monitor events, you need a database to store checkpoint data. The Trustless Swap example uses a Prisma database to store checkpoint data from the OneChain network. The database is populated from polling the network to retrieve emitted events.
event-indexer.ts from Trustless Swap
Trustless Swap incorporates handlers to process each event type that triggers. For the locked event, the handler in locked-handler.ts fires and updates the Prisma database accordingly.
locked-handler.ts from Trustless Swap
Access On-Chain Time You have options when needing to access network-based time for your transactions. If you need a near real-time measurement (within a few seconds), use the immutable reference of time provided by the Clock module in Move. The reference value from this module updates with every network checkpoint. If you don't need as current a time slice, use the epoch_timestamp_ms function to capture the precise moment the current epoch started.
The one::clock::Clock module To access a prompt timestamp, you must pass a read-only reference of one::clock::Clock as an entry function parameter in your transactions. An instance of Clock is provided at address 0x6, no new instances can be created.
Use the timestamp_ms function from the one::clock module to extract a unix timestamp in milliseconds.
public fun timestamp_ms(clock: &Clock): u64 { clock.timestamp_ms } The example below demonstrates an entry function that emits an event containing a timestamp from the Clock:
module basics::clock;
use one::{clock::Clock, event}; A call to the previous entry function takes the following form, passing 0x6 as the address for the Clock parameter:
$ one client call --package --module 'clock' --function 'access' --args '0x6' --gas-budget Expect the Clock timestamp to change at the rate the network generates checkpoints, which is about every 1/4 second with Mysticeti consensus. Find the current network checkpoint rate on this public dashboard.
Successive calls to one::clock::timestamp_ms in the same transaction always produce the same result (transactions are considered to take effect instantly), but timestamps from Clock are otherwise monotonic across transactions that touch the same shared objects: Successive transactions seeing a greater or equal timestamp to their predecessors.
Any transaction that requires access to a Clock must go through consensus because the only available instance is a shared object. As a result, this technique is not suitable for transactions that must use the single-owner fastpath (see Epoch timestamps for a single-owner-compatible source of timestamps).
Transactions that use the clock must accept it as an immutable reference (not a mutable reference or value). This prevents contention, as transactions that access the Clock can only read it, so do not need to be sequenced relative to each other. Validators refuse to sign transactions that do not meet this requirement and packages that include entry functions that accept a Clock or &mut Clock fail to publish.
The following functions test Clock-dependent code by manually creating a Clock object and manipulating its timestamp. This is possible only in test code:
#[test_only] public fun create_for_testing(ctx: &mut TxContext): Clock { Clock { id: object::new(ctx), timestamp_ms: 0, } }
#[test_only] public fun share_for_testing(clock: Clock) { transfer::share_object(clock) }
#[test_only] public fun increment_for_testing(clock: &mut Clock, tick: u64) { clock.timestamp_ms = clock.timestamp_ms + tick; }
#[test_only] public fun set_for_testing(clock: &mut Clock, timestamp_ms: u64) { assert!(timestamp_ms >= clock.timestamp_ms); clock.timestamp_ms = timestamp_ms; }
#[test_only] public fun destroy_for_testing(clock: Clock) { let Clock { id, timestamp_ms: _ } = clock; id.delete(); } The next example presents a basic test that creates a Clock, increments it, and then checks its value:
#[test_only] module one::clock_tests { use one::clock;
#[test]
fun creating_a_clock_and_incrementing_it() {
let mut ctx = tx_context::dummy();
let mut clock = clock::create_for_testing(&mut ctx);
clock.increment_for_testing(42);
assert!(clock.timestamp_ms() == 42);
clock.set_for_testing(50);
assert!(clock.timestamp_ms() == 50);
clock.destroy_for_testing();
}
} Epoch timestamps Use the following function from the one::tx_context module to access the timestamp for the start of the current epoch for all transactions (including ones that do not go through consensus):
public fun epoch_timestamp_ms(_self: &TxContext): u64 { native_epoch_timestamp_ms() } The preceding function returns the point in time when the current epoch started, as a millisecond granularity unix timestamp in a u64. This value changes roughly once every 24 hours, when the epoch changes.
Tests based on one::test_scenario can use later_epoch (following code), to exercise time-sensitive code that uses epoch_timestamp_ms (previous code):
public fun later_epoch( scenario: &mut Scenario, delta_ms: u64, sender: address, ): TransactionEffects { scenario.ctx.increment_epoch_timestamp(delta_ms); next_epoch(scenario, sender) } later_epoch behaves like one::test_scenario::next_epoch (finishes the current transaction and epoch in the test scenario), but also increments the timestamp by delta_ms milliseconds to simulate the progress of time.
Signing and Sending Transactions Transactions in OneChain represent calls to specific functionality (like calling a smart contract function) that execute on inputs to define the result of the transaction.
Inputs can either be an object reference (either to an owned object, an immutable object, or a shared object), or an encoded value (for example, a vector of bytes used as an argument to a Move call). After a transaction is constructed, usually through using programmable transaction blocks (PTBs), the user signs the transaction and submits it to be executed on chain.
The signature is provided with the private key owned by the wallet, and its public key must be consistent with the transaction sender's OneChain address.
OneChain uses a SuiKeyPair to produce the signature, which commits to the Blake2b hash digest of the intent message (intent || bcs bytes of tx_data). The signature schemes currently supported are Ed25519 Pure, ECDSA Secp256k1, ECDSA Secp256r1, Multisig, and zkLogin.
You can instantiate Ed25519 Pure, ECDSA Secp256k1, and ECDSA Secp256r1 using SuiKeyPair and use it to sign transactions. Note that this guide does not apply to Multisig and zkLogin, please refer to their own pages (Multisig and zkLogin respectively) for instructions.
With a signature and the transaction bytes, a transaction can be submitted to be executed.
Workflow The following high-level process describes the overall workflow for constructing, signing and executing an on-chain transaction:
Construct the transaction data by creating a Transaction where multiple transactions are chained. See Building Programmable Transaction Blocks for more information. The SDK's built-in gas estimation and coin selection picks the gas coin. Sign the transaction to generate a signature. Submit the Transaction and its signature for on-chain execution. INFO If you want to use a specific gas coin, first find the gas coin object ID to be used to pay for gas, and explicitly use that in the PTB. If there is no gas coin object, use the splitCoin transaction to create a gas coin object. The split coin transaction should be the first transaction call in the PTB.
Examples The following examples demonstrate how to sign and execute transactions using Rust, or the OneChain CLI.
The full code example below can be found under crates/sui-sdk.
There are various ways to instantiate a SuiKeyPair and to derive its public key and OneChain address using the OneChain Rust SDK.
// deterministically generate a keypair, testing only, do not use for mainnet, use the next section to randomly generate a keypair instead.
let skp_determ_0 =
SuiKeyPair::Ed25519(Ed25519KeyPair::generate(&mut StdRng::from_seed([0; 32])));
let _skp_determ_1 =
SuiKeyPair::Secp256k1(Secp256k1KeyPair::generate(&mut StdRng::from_seed([0; 32])));
let _skp_determ_2 =
SuiKeyPair::Secp256r1(Secp256r1KeyPair::generate(&mut StdRng::from_seed([0; 32])));
// randomly generate a keypair.
let _skp_rand_0 = SuiKeyPair::Ed25519(get_key_pair_from_rng(&mut rand::rngs::OsRng).1);
let _skp_rand_1 = SuiKeyPair::Secp256k1(get_key_pair_from_rng(&mut rand::rngs::OsRng).1);
let _skp_rand_2 = SuiKeyPair::Secp256r1(get_key_pair_from_rng(&mut rand::rngs::OsRng).1);
// import a keypair from a base64 encoded 32-byte `private key`.
let _skp_import_no_flag_0 = SuiKeyPair::Ed25519(Ed25519KeyPair::from_bytes(
&Base64::decode("1GPhHHkVlF6GrCty2IuBkM+tj/e0jn64ksJ1pc8KPoI=")
.map_err(|_| anyhow!("Invalid base64"))?,
)?);
let _skp_import_no_flag_1 = SuiKeyPair::Ed25519(Ed25519KeyPair::from_bytes(
&Base64::decode("1GPhHHkVlF6GrCty2IuBkM+tj/e0jn64ksJ1pc8KPoI=")
.map_err(|_| anyhow!("Invalid base64"))?,
)?);
let _skp_import_no_flag_2 = SuiKeyPair::Ed25519(Ed25519KeyPair::from_bytes(
&Base64::decode("1GPhHHkVlF6GrCty2IuBkM+tj/e0jn64ksJ1pc8KPoI=")
.map_err(|_| anyhow!("Invalid base64"))?,
)?);
// import a keypair from a base64 encoded 33-byte `flag || private key`. The signature scheme is determined by the flag.
let _skp_import_with_flag_0 =
SuiKeyPair::decode_base64("ANRj4Rx5FZRehqwrctiLgZDPrY/3tI5+uJLCdaXPCj6C")
.map_err(|_| anyhow!("Invalid base64"))?;
let _skp_import_with_flag_1 =
SuiKeyPair::decode_base64("AdRj4Rx5FZRehqwrctiLgZDPrY/3tI5+uJLCdaXPCj6C")
.map_err(|_| anyhow!("Invalid base64"))?;
let _skp_import_with_flag_2 =
SuiKeyPair::decode_base64("AtRj4Rx5FZRehqwrctiLgZDPrY/3tI5+uJLCdaXPCj6C")
.map_err(|_| anyhow!("Invalid base64"))?;
// replace `skp_determ_0` with the variable names above
let pk = skp_determ_0.public();
let sender = SuiAddress::from(&pk);
Next, sign transaction data constructed using an example programmable transaction block with default gas coin, gas budget, and gas price. See Building Programmable Transaction Blocks for more information.
// construct an example programmable transaction.
let pt = {
let mut builder = ProgrammableTransactionBuilder::new();
builder.pay_oct(vec![sender], vec![1])?;
builder.finish()
};
let gas_budget = 5_000_000;
let gas_price = sui_client.read_api().get_reference_gas_price().await?;
// create the transaction data that will be sent to the network.
let tx_data = TransactionData::new_programmable(
sender,
vec![gas_coin.object_ref()],
pt,
gas_budget,
gas_price,
);
Commit a signature to the Blake2b hash digest of the intent message (intent || bcs bytes of tx_data).
// derive the digest that the keypair should sign on, i.e. the blake2b hash of `intent || tx_data`.
let intent_msg = IntentMessage::new(Intent::sui_transaction(), tx_data);
let raw_tx = bcs::to_bytes(&intent_msg).expect("bcs should not fail");
let mut hasher = sui_types::crypto::DefaultHash::default();
hasher.update(raw_tx.clone());
let digest = hasher.finalize().digest;
// use SuiKeyPair to sign the digest.
let sui_sig = skp_determ_0.sign(&digest);
// if you would like to verify the signature locally before submission, use this function. if it fails to verify locally, the transaction will fail to execute in OneChain.
let res = sui_sig.verify_secure(
&intent_msg,
sender,
sui_types::crypto::SignatureScheme::ED25519,
);
assert!(res.is_ok());
Finally, submit the transaction with the signature.
let transaction_response = sui_client
.quorum_driver_api()
.execute_transaction_block(
sui_types::transaction::Transaction::from_generic_sig_data(
intent_msg.value,
Intent::sui_transaction(),
vec![GenericSignature::Signature(sui_sig)],
),
SuiTransactionBlockResponseOptions::default(),
None,
)
.await?;
When using the OneChain CLI for the first time, it creates a local file in ~/.one/keystore on your machine with a list of private keys (encoded as Base64 encoded flag || 32-byte-private-key). You can use any key to sign transactions by specifying its address. Use one keytool list to see a list of addresses.
There are three ways to initialize a key:
one client new-address ed25519 one client new-address secp256k1 one client new-address secp256r1
one keytool import "0xd463e11c7915945e86ac2b72d88b8190cfad8ff7b48e7eb892c275a5cf0a3e82" ed25519 one keytool import "0xd463e11c7915945e86ac2b72d88b8190cfad8ff7b48e7eb892c275a5cf0a3e82" secp256k1 one keytool import "0xd463e11c7915945e86ac2b72d88b8190cfad8ff7b48e7eb892c275a5cf0a3e82" secp256r1
$MNEMONICS refers to 12/15/18/21/24 words from the wordlist, e.g. "retire skin goose will hurry this field stadium drastic label husband venture cruel toe wire". Refer to Keys and Addresses for more.
one keytool import "$MNEMONICS" ed25519 one keytool import "$MNEMONICS" secp256k1 one keytool import "$MNEMONICS" secp256r1 Create a transfer transaction in the CLI. Set the $ONE_ADDRESS to the one corresponding to the keypair used to sign. $GAS_COIN_ID refers to the object ID that is owned by the sender to be used as gas. $GAS_BUDGET refers to the budget used to execute transaction. Then sign with the private key corresponding to the sender address. $MNEMONICS refers to 12/15/18/21/24 words from the wordlist, e.g. "retire skin goose will hurry this field stadium drastic label husband venture cruel toe wire". Refer to Keys and Addresses for more.
$ one client gas
$ one client transfer-oct --to $ONE_ADDRESS --coin-object-id $GAS_COIN_ID --gas-budget
Sponsored transactions are a primitive on the OneChain blockchain that enable the execution of a transaction without a user paying the gas. Sponsored transactions can reduce the onboarding friction for users because the feature streamlines the process for end users. Using sponsored transactions, you can execute a transaction without requiring the user to pay it themselves. Instead, you can act as a sponsor of the transaction, offering your own payment gas objects for the transaction.
Roles in sponsored transactions In a sponsored transaction there are three roles: the user, the gas station, and the sponsor.
The user is the entity who wants to execute a transaction. The gas station is the entity that fulfills the sponsorship request for the user's transaction by providing the gas payment they own. The sponsor is entity that funds the gas station for its operations. It's not uncommon for the gas station and the sponsor to be the same entity. For example, a web3 gaming studio could run its own gas station to sponsor users with real free-to-play gaming experiences at its user acquisition stage. Because it's not always trivial to maintain a gas station for teams of any size, that gaming studio could also leverage third-party gas stations to sponsor transactions they want to promote.
The remainder of this guide assumes the sponsor uses their own gas station.
Use cases The following sections describe some common scenarios where sponsored transactions offer an improved user experience.
App-specific sponsorship In this scenario, the sponsor has a specific set of applications they want to sponsor.
If the transaction is initialized by the user, the sponsor examines the transaction to make sure it's within the set of approved applications before agreeing to provide the gas payment. If the transaction is proposed by the sponsor, the user must examine the transaction and decide if they want to execute it. Examples of this type of transaction might include a rewards claim transaction of a campaign or a "try it out" advertisement transaction. Wildcard sponsorship In this scenario, the sponsor has few restrictions on the type of transactions the gas payment can be used for.
If the sponsor is a gasless wallet, it may agree to sponsor any valid transactions proposed by its users. In the form of a reward or discount, the sponsor could offer the user a wildcard gas payment, expressly promising to execute any transactions with that gas payment. A sponsored transaction is not restricted to these use cases. Essentially, a sponsored transaction is any transaction jointly made by the user and the sponsor. As long as the stakeholders can agree on the transaction details, then the number of possible ways to provide sponsored transactions is limited only by the imagination. Because at least two stakeholders are involved in a sponsored transaction, however, there are some additional risks that you should take steps to mitigate.
Sponsored transaction flow This section is mostly for developers who are interested in building a gas station or integrating with one.
The data structure of a transaction resembles the following:
pub struct SenderSignedTransaction { pub intent_message: IntentMessage, /// A list of signatures signed by all transaction participants. /// 1. non participant signature must not be present. /// 2. signature order does not matter. pub tx_signatures: Vec, }
pub struct TransactionDataV1 { // <-- A variant of TransactionData
pub kind: TransactionKind, // <-- This is the actual transaction details
pub sender: SuiAddress,
pub gas_data: GasData,
pub expiration: TransactionExpiration,
}
pub struct GasData { pub payment: Vec, pub owner: SuiAddress, pub price: u64, pub budget: u64, }
A few details of note for the preceding code:
sender in TransactionDataV1 (a variant of TransactionData) is the user address. gas_data in TransactionDataV1 is the gas payment. GasData allows a list of gas objects, but the same address must own them, namely the owner in GasData (the sponsor). When owner is equal to sender, then it is a regular/non-sponsored transaction. tx_signatures in SenderSignedTransaction is a list of signatures. For a sponsored transaction, the list needs to contain both signatures of the user and the sponsor in some order. The signatures are signed over the entire TransactionData, including GasData. So, to construct a correct sponsored transaction, you must first build a TransactionData object. If you are neither the user or the sponsor, you would then pass the transaction to both parties to sign. If you're the sponsor, you would sign the transaction and then pass it and the signature to the other party (in the form of SenderSignedTransaction) for them to sign. In practice, the latter is the more common scenario.
There are three flows of sponsored transaction.
User proposed transaction
(swimlane link)
Sponsor proposed transaction
(swimlane link)
Wildcard gas payment
(swimlane link)
Risk considerations Because at least two stakeholders are involved in a sponsored transaction, you should take steps to mitigate risk.
Client equivocation risk Client equivocation happens when more than one legit transaction that shares at least one owned object (such as a gas coin object) at a certain version are submitted to the network simultaneously. On OneChain, before a transaction is executed, owned objects in this transaction are locked on validators at specific versions. An honest validator only accepts one transaction and rejects others. Depending on the order validators receive these transactions, validators might accept different transactions. In the event of no single transaction getting accepted by at least 2/3rds of validators, the owned object is locked until end of the epoch.
Practically speaking, client equivocation is rare, mostly caused by buggy client software. After all, no one has incentives to lock their own objects. However, sponsored transactions come with counterparty risks. For example, a malicious user could equivocate the gas station's gas coin object by submitting another transaction that uses one owned object in the gas station signed transaction at the same version. Similarly, a Byzantine gas station could do the same to the user owned objects.
Although this risk might seem trivial, it is helpful to be aware of it. Your gas station should actively monitor user behavior and alert on anything abnormal. Whether you're a user taking advantage of sponsored transactions or a developer integrating with a gas station, consider your reputation to minimize the risk of client equivocation.
Both the user and the sponsor need to sign over the entire TransactionData, including GasData because otherwise a third party (such as a malicious Full node) could snip the partially signed data and cause client equivocation and locking of owned objects.
Censorship risk If you chooses to submit the dual-signed transaction to the sponsor or gas station rather than a Full node, the transaction might be subject to sponsor or gas station censorship. Namely, the sponsor might choose not to submit the transaction to the network, or delay the submission.
You can mitigate this risk by submitting the transaction directly to a Full node.
Avoiding Equivocation Equivocation is when validators send conflicting information about objects on the network. Sometimes this is because of bad actors trying to subvert the integrity of the OneChain network. The network has measures in place to punish validators that might engage in such behavior.
The other source of equivocation does not originate from bad intentions. There are logic traps that smart contract code can inadvertently trigger, like when dealing with sponsored transactions. The effects of this type of equivocation can lock the objects your code interacts with until the end of the current epoch. This can lead to frustration for you, and more importantly, your users.
To avoid double spending, validators lock objects as they validate transactions. An equivocation occurs when an owned object pair (ObjectId, SequenceNumber) is concurrently used in multiple non-finalized transactions.
Perhaps the most common source of equivocation stems from the attempt to pay for gas using the same coin object. This can be a problem when performing multiple transactions that originate from the same sender.
It's not just objects that can cause equivocation. Any object used in multiple transactions can be a source of equivocation if not handled properly. OneChain uses object versioning to track the status of objects across transactions and epochs. If a transaction modifies an object, then one of the results of that transaction is to update the version of that same object.
The versiong architecture is what supports paying the gas for a series of transactions using the same gas coin. Among other processing, the first transaction advances the version number of the coin and returns it to the sender. The next transaction can then use that same coin to pay for its gas. Equivocation is avoided because each transaction references a different version of the same coin.
Common pitfalls to avoid For most smart contracts, equivocation is not something that's an intended behavior. Perhaps the most common source of unintentional equivocation comes from multiple transactions performed by the same address. If you don't take care to handle the gas fees properly, you could lock up your smart contract by trying to use the same coin (and version) for more than one transaction.
If using a single thread, serialize transactions that use the same owned object. PTBs allow your transactions to use multiple operations against the same owned object. A PTB is essentially a single, serialized transaction, which can prevent SequenceNumber errors.
You should always take advantage of the inherent batching that PTBs provide. For example, consider an airdrop scenario where you want to mint and transfer an object to many users. Because PTBs allow up to 1,024 operations in a single PTB, you can airdrop your object to 512 users in a single transaction. This approach is much more cost efficient than looping over 512 individual transactions that mint and transfer to a single user each time. Batching transactions might remove the need for parallel execution, but you must consider the atomic nature of PTBs; if one instruction fails, the whole PTB fails. Consequently, parallel transactions might be preferred for some use cases.
Parallel transactions from multiple threads can cause unintentional equivocation errors if not managed properly. One way to avoid owned object equivocation is to create a separate owned object for each transaction thread. This ensures that each thread uses the correct version of its object input.
In cases where creating multiple owned objects is not practical or desired, you can create a wrapper around an object used across threads. The wrapper is a shared object that authorizes access to its object through an allowlist. Any time a transaction needs to access the wrapped object, it gets permission from the wrapper in the same PTB. When authorization needs transferring, the allowlist for the wrapper gets updated accordingly. This approach can create a latency bottleneck as the object wrapper creates sequentialized transactions that rely on its object for input. The OneChain TypeScript SDK provides an executor class, ParallelTransactionExecutor, to process parallel transactions efficiently.
OneChain SDK The OneChain SDK offers transaction executors to help process multiple transactions from the same address.
SerialTransactionExecutor Use the SerialTransactionExecutor when processing transactions one after another. The executor grabs all the coins from the sender and combines them into a single coin that is used for all transactions.
Using the SerialTransactionExecutor prevents SequenceNumber errors by handling the versioning of object inputs across a PTB.
ParallelTransactionExecutor Use the ParallelTransactionExecutor when you want to process transactions with the same sender at the same time. This class creates a pool of gas coins that it manages to ensure parallel transactions don't equivocate those coins. The class tracks objects used across transactions and orders their processing so that the object inputs are also not equivocated.
Debug tools If you find your smart contracts unintentionally locking objects, there are some tools you can use to help fix the issue.
one-tool You can install the one-tool utility and use the locked-object command to check the locked status of the passed asset on a specific RPC network (--fullnode-rpc-url value). If you provide an address, locked-object checks if all the gas objects owned by that address are locked. Pass an object ID to check if that specific object is locked.
$ cargo run --bin one-tool $ one-tool locked-object --address 0x02a212de6a9dfa3a69e22387acfbafbb1a9e591bd9d636e7895dcfc8de05f331 --fullnode-rpc-url https://rpc-mainnet.onelabs.cc:443 $ one-tool locked-object --id 0xd4c3ecf5eaa211da58c36495613899e70349f6048baaeca99596f1682e89c837 --fullnode-rpc-url https://rpc-mainnet.onelabs.cc:443 TIP Include the --rescue flag to try and unlock the object the command targets. Rescue is possible if the object isn't already locked by a majority of validators.
Working with PTBs Building Programmable Transaction Blocks This guide explores creating a programmable transaction block (PTB) on OneChain using the TypeScript SDK. For an overview of what a PTB is, see Programmable Transaction Blocks in the Concepts section. If you don't already have the OneChain TypeScript SDK, follow the install instructions on the OneChain TypeScript SDK site.
This example starts by constructing a PTB to send OCT. If you are familiar with the legacy OneChain transaction types, this is similar to a payOct transaction. To construct transactions, import the Transaction class, and construct it:
import { Transaction } from '@mysten/sui/transactions';
const tx = new Transaction(); Using this, you can then add transactions to this PTB.
// Create a new coin with balance 100, based on the coins used as gas payment. // You can define any balance here. const [coin] = tx.splitCoins(tx.gas, [tx.pure(100)]);
// Transfer the split coin to a specific address. tx.transferObjects([coin], tx.pure('0xSomeOneChainAddress')); You can attach multiple transaction commands of the same type to a PTB as well. For example, to get a list of transfers, and iterate over them to transfer coins to each of them:
interface Transfer { to: string; amount: number; }
// Procure a list of some OCT transfers to make: const transfers: Transfer[] = getTransfers();
const tx = new Transaction();
// First, split the gas coin into multiple coins: const coins = tx.splitCoins( tx.gas, transfers.map((transfer) => tx.pure(transfer.amount)), );
// Next, create a transfer transaction for each coin: transfers.forEach((transfer, index) => { tx.transferObjects([coins[index]], tx.pure(transfer.to)); }); After you have the Transaction defined, you can directly execute it with a SuiClient and KeyPair using client.signAndExecuteTransaction.
client.signAndExecuteTransaction({ signer: keypair, transaction: tx }); Constructing inputs Inputs are how you provide external values to PTBs. For example, defining an amount of OCT to transfer, or which object to pass into a Move call, or a shared object.
There are currently two ways to define inputs:
For objects: the tx.object(objectId) function is used to construct an input that contains an object reference. For pure values: the tx.pure(value, type?) function is used to construct an input for a non-object input. If value is a Uint8Array, then the value is assumed to be raw bytes and is used directly. If type is provided, it's used to generate the BCS serialization layout for the value. If not provided, the type is automatically determined based on the value. Available transactions OneChain supports following transaction commands:
tx.splitCoins(coin, amounts): Creates new coins with the defined amounts, split from the provided coin. Returns the coins so that it can be used in subsequent transactions. Example: tx.splitCoins(tx.gas, [tx.pure(100), tx.pure(200)]) tx.mergeCoins(destinationCoin, sourceCoins): Merges the sourceCoins into the destinationCoin. Example: tx.mergeCoins(tx.object(coin1), [tx.object(coin2), tx.object(coin3)]) tx.transferObjects(objects, address): Transfers a list of objects to the specified address. Example: tx.transferObjects([tx.object(thing1), tx.object(thing2)], tx.pure(myAddress)) tx.moveCall({ target, arguments, typeArguments }): Executes a Move call. Returns whatever the OneChain Move call returns. Example: tx.moveCall({ target: '0x2::devnet_nft::mint', arguments: [tx.pure(name), tx.pure(description), tx.pure(image)] }) tx.makeMoveVec({ type, elements }): Constructs a vector of objects that can be passed into a moveCall. This is required as there's no other way to define a vector as an input. Example: tx.makeMoveVec({ elements: [tx.object(id1), tx.object(id2)] }) tx.publish(modules, dependencies): Publishes a Move package. Returns the upgrade capability object. Passing transaction results as arguments You can use the result of a transaction command as an argument in subsequent transaction commands. Each transaction command method on the transaction builder returns a reference to the transaction result.
// Split a coin object off of the gas object: const [coin] = tx.splitCoins(tx.gas, [tx.pure(100)]); // Transfer the resulting coin object: tx.transferObjects([coin], tx.pure(address)); When a transaction command returns multiple results, you can access the result at a specific index either using destructuring, or array indexes.
// Destructuring (preferred, as it gives you logical local names): const [nft1, nft2] = tx.moveCall({ target: '0x2::nft::mint_many' }); tx.transferObjects([nft1, nft2], tx.pure(address));
// Array indexes: const mintMany = tx.moveCall({ target: '0x2::nft::mint_many' }); tx.transferObjects([mintMany[0], mintMany[1]], tx.pure(address)); Use the gas coin With PTBs, you can use the gas payment coin to construct coins with a set balance using splitCoin. This is useful for OneChain payments, and avoids the need for up-front coin selection. You can use tx.gas to access the gas coin in a PTB, and it is valid as input for any arguments; with the exception of transferObjects, tx.gas must be used by-reference. Practically speaking, this means you can also add to the gas coin with mergeCoins or borrow it for Move functions with moveCall.
You can also transfer the gas coin using transferObjects, in the event that you want to transfer all of your coin balance to another address.
Of course, you can also transfer other coins in your wallet using their Object ID. For example,
const otherCoin = tx.object('0xCoinObjectId'); const coin = tx.splitCoins(otherCoin, [tx.pure(100)]); tx.transferObjects([coin], tx.pure(address)); Get PTB bytes If you need the PTB bytes, instead of signing or executing the PTB, you can use the build method on the transaction builder itself.
TIP You might need to explicitly call setSender() on the PTB to ensure that the sender field is populated. This is normally done by the signer before signing the transaction, but will not be done automatically if you're building the PTB bytes yourself.
const tx = new Transaction();
// ... add some transactions...
await tx.build({ provider }); In most cases, building requires your JSON RPC provider to fully resolve input values.
If you have PTB bytes, you can also convert them back into a Transaction class:
const bytes = getTransactionBytesFromSomewhere(); const tx = Transaction.from(bytes); Building offline In the event that you want to build a PTB offline (as in with no provider required), you need to fully define all of your input values, and gas configuration (see the following example). For pure values, you can provide a Uint8Array which is used directly in the transaction. For objects, you can use the Inputs helper to construct an object reference.
import { Inputs } from '@mysten/sui/transactions';
// For pure values: tx.pure(pureValueAsBytes);
// For owned or immutable objects: tx.object(Inputs.ObjectRef({ digest, objectId, version }));
// For shared objects: tx.object(Inputs.SharedObjectRef({ objectId, initialSharedVersion, mutable })); You can then omit the provider object when calling build on the transaction. If there is any required data that is missing, this will throw an error.
Gas configuration The new transaction builder comes with default behavior for all gas logic, including automatically setting the gas price, budget, and selecting coins to be used as gas. This behavior can be customized.
Gas price By default, the gas price is set to the reference gas price of the network. You can also explicitly set the gas price of the PTB by calling setGasPrice on the transaction builder.
tx.setGasPrice(gasPrice); Budget By default, the gas budget is automatically derived by executing a dry-run of the PTB beforehand. The dry run gas consumption is then used to determine a balance for the transaction. You can override this behavior by explicitly setting a gas budget for the transaction, by calling setGasBudget on the transaction builder.
INFO The gas budget is represented in OneChain, and should take the gas price of the PTB into account.
tx.setGasBudget(gasBudgetAmount); Gas payment By default, the gas payment is automatically determined by the SDK. The SDK selects all coins at the provided address that are not used as inputs in the PTB.
The list of coins used as gas payment will be merged down into a single gas coin before executing the PTB, and all but one of the gas objects will be deleted. The gas coin at the 0-index will be the coin that all others are merged into.
// NOTE: You need to ensure that the coins do not overlap with any // of the input objects for the PTB. tx.setGasPayment([coin1, coin2]); Gas coins should be objects containing the coins objectId, version, and digest (ie { objectId: string, version: string | number, digest: string }).
dApp / Wallet integration The Wallet Standard interface has been updated to support the Transaction kind directly. All signTransaction and signAndExecuteTransaction calls from dApps into wallets is expected to provide a Transaction class. This PTB class can then be serialized and sent to your wallet for execution.
To serialize a PTB for sending to a wallet, OneChain recommends using the tx.serialize() function, which returns an opaque string representation of the PTB that can be passed from the wallet standard dApp context to your wallet. This can then be converted back into a Transaction using Transaction.from().
TIP You should not build the PTB from bytes in the dApp code. Using serialize instead of build allows you to build the PTB bytes within the wallet itself. This allows the wallet to perform gas logic and coin selection as needed.
// Within a dApp const tx = new Transaction(); wallet.signTransaction({ transaction: tx });
// Your wallet standard code: function handleSignTransaction(input) { sendToWalletContext({ transaction: input.transaction.serialize() }); }
// Within your wallet context: function handleSignRequest(input) { const userTx = Transaction.from(input.transaction); } Sponsored PTBs The PTB builder can support sponsored PTBs by using the onlyTransactionKind flag when building the PTB.
const tx = new Transaction(); // ... add some transactions...
const kindBytes = await tx.build({ provider, onlyTransactionKind: true });
// Construct a sponsored transaction from the kind bytes: const sponsoredTx = Transaction.fromKind(kindBytes);
// You can now set the sponsored transaction data that is required: sponsoredTx.setSender(sender); sponsoredTx.setGasOwner(sponsor); sponsoredTx.setGasPayment(sponsorCoins); Coin management A key concept when programming on OneChain is that of owned objects. Address-owned objects are important in that they allow for highly parallelizable transactions. And they also logically map to assets or resources that someone exclusively owns. Coins are a typical case of owned object usage, with cash being a real-life reference. The owned objects paradigm, however, and particularly as related to coins, is somewhat of a divergence from other blockchains which have a concept of balance. In other words, in other systems, especially account based systems, coins are held in a single location (field) which can be thought of as a balance in a bank account.
Because OneChain uses owned objects instead of a balance, it is common to own a number of coins, at times even a significant number of them. Some scenarios necessitate merging some or all of those coins into a single object. At times, merging coins together might even be required because the amount necessary to execute a transaction is more than any single coin the sender owns, thus making merging an inevitable step.
SDK usage The OneChain SDKs manage coins on your behalf, removing the overhead of having to deal with coin management manually. The SDKs attempt to merge coins whenever possible and assume that transactions are executed in sequence. That's a reasonable assumption with wallet-based transactions and for common scenarios in general. OneChain recommends relying on this feature if you do not have a need for heavy parallel or concurrent execution.
Gas Smashing When executing a transaction OneChain allows you to provide a number of coins as payment. In other words, the payment can be a vector of coins rather than a single coin. That feature, known as gas smashing, performs merging of coins automatically, and presents the PTBs you write with a single gas coin that can be used for other purposes besides just gas.
Basically, you can provide as many coins as you want (with a max limit defined in the protocol configuration) and have all of them merged (smashed) into the first coin provided as payment. That coin, minus the gas budget, is then available inside the transaction and can be used in any command. If the coin is unused it is returned to the user.
Gas smashing is an important feature - and a key concept to understand - to have for the optimal management of coins. See Gas Smashing for more details.
Generic coins Gas smashing works well for Coin objects, which is the only coin type that can be used for gas payment.
Any other coin type requires explicit management from users. PTBs offer a mergeCoins command that you can use to combine multiple coins into a single one. And a splitCoins as the complementary operation to break them up.
From a cost perspective, those are very cheap transactions, however they require a user to be aware of their coin distribution and their own needs.
Concurrency Merging coins, and particularly Coin, into a single coin or a very small number of coins might prove problematic in scenarios where heavy or high concurrency is required.
If you merge all Coin into a single one, you would need to sequentially submit every transaction. The coin - being an owned object - would have to be provided with a version and it would be locked by the system when signing a transaction, effectively making it impossible to use it in any other transaction until the one that locked it was executed. Moreover, an attempt to sign multiple transactions with the same coin might result in equivocation and the coin being unusable and locked until the end of the epoch.
So when you require heavy concurrency, you should first split a coin into as many coins as the number of transactions to execute concurrently. Alternatively, you could provide multiple and different coins (gas smashing) to the different transactions. It is critically important that the set of coins you use in the different transactions has no intersection at all.
The possible pitfalls in dealing with heavy concurrency are many. Concurrency in transaction execution is not the only performance bottleneck. In creating and submitting a transaction, several round trips with a Full node might be required to discover and fetch the right objects, and to dry run a transaction. Those round trips might affect performance significantly.
Concurrency is a difficult subject and is beyond the scope of this documentation. You must take maximum care when dealing with coin management in the face of concurrency, and the right strategy is often tied to the specific scenario, rather than universally available.
Simulating References Everything on the OneChain blockchain is an object. When you develop Move packages for the OneChain network, you're typically manipulating or using on-chain objects in some way through functionality available in the OneChain API. For most API functions, you provide an object by reference.
References are a key construct when programming in Move and on OneChain. Most of the functionality available in the OneChain API takes objects by reference.
There are two ways to use an object:
by value: When you use an object by value, you have full control over that object. You can destroy it (if the functionality is available), wrap it (if it has the store ability), or transfer it to an address. by reference: When you use an object by reference, operations over that object are determined by the logic the module that defines the object provides because you are using a reference to its data rather than having ownership of the object itself. The restrictions of references allow you to develop smart contracts with a high level of security and safety around assets. There are two types of references: Mutable reference (&mut): You can alter the object (according to the API) but you can't destroy or transfer it. Immutable reference (&): Further restricts the set of operations and the guarantees/invariants over the referenced object. You have read-only access to the object's data. Programmable transaction blocks (PTBs) do not currently allow the use of object references returned from one of its transaction commands. You can use input objects to the PTB, objects created by the PTB (like MakeMoveVec), or returned from a transaction command by value as references in subsequent transaction commands. If a transaction command returns a reference, however, you can't use that reference in any call, significantly limiting certain common patterns in Move.
The borrow module The OneChain framework includes a borrow module that offers a solution to the reference problem. The module provides access to an object by value but builds a model that makes it impossible to destroy, transfer, or wrap the object retrieved. The borrow module exposes a Referent object that wraps another object (the object you want to reference). The module uses the hot potato pattern (via a Borrow instance) to allow retrieval of the wrapped object by value. Within the same PTB, the module then forces the object to be returned to the Referent. The Borrow instance guarantees that the object returned is the same that was retrieved.
As an example, consider the following module stub that exposes an object (Asset) and a function (use_asset) to use that object.
module a_module { struct Asset has key, store { … // some data }
public fun use_asset(asset: &Asset) {
…. // some code
}
} The function use_asset takes an immutable reference to the asset (&Asset), which is a common pattern in an API definition.
Now consider another module that uses this asset.
module another_module { struct AssetManager has key { asset: Asset, }
public fun get_asset(manager: &AssetManager): &Asset {
&manager.asset
}
} This module creates an object (AssetManager) that references the object (Asset) created in the previous module (a_module).
You could then write a Move function that retrieves an object by reference and passes it to the use_asset function.
fun do_something(manager: &AssetManager) { let asset = another_module::get_asset(manager); a_module::use_asset(asset); } The two functions in do_something are not valid within a PTB, however, because PTBs do not support a reference returned by a function and passed to another function.
To make this operation valid within a PTB, you would need to include functionality from the borrow module. Consequently, you could change the another_module code to the following:
module another_module { struct AssetManager has key { asset: Referent, }
public fun get_asset(manager: &mut AssetManager): (Asset, Borrow) {
borrow::borrow(&mut manager.asset)
}
public fun return_asset(
manager: &mut AssetManager,
asset: Asset,
b: Borrow) {
borrow::put_back(&mut manager.asset, asset, b)
}
} Now the PTB can retrieve the asset, use it in a call to use_asset, and return the asset.
Considerations The Borrow object is the key to the guarantees the borrow module offers. The definition of Borrow is struct Borrow { ref: address, obj: ID } which makes it such that you cannot drop or save its instance anywhere, so it must be consumed in the same transaction that retrieves it (hot potato). Moreover, fields in the Borrow struct make sure that the object returned is for the same Referent and the object that was originally held by the Referent instance. In other words, there is no way to either keep the object retrieved or to swap it with another object in a different Referent.
CAUTION Using a Referent is a very explicit and intrusive change. That has to be taken into consideration when designing a solution.
Support for references in a PTB is planned, which is a much more natural and proper pattern for APIs.
You must consider the implications of using the borrow module and whether you have a mechanism to later move to a more natural, reference pattern.
Finally, the Referent model forces the usage of a mutable reference and returns an object by value. Both have significant implications when designing an API. You must be careful in what logic your modules provide and how objects are exposed.
Example Extending the previous example, a PTB that calls use_asset is written as follows:
// initialize the PTB const txb = new TransactionBlock(); // load the assetManager const assetManager = txb.object(assetManagerId); // retrieve the asset const [asset, borrow] = txb.moveCall({ target: "0xaddr1::another_module::get_asset", arguments: [ assetManager ], }); // use the asset txb.moveCall({ target: "0xaddr2::a_module::use_asset", arguments: [ asset ], }); // return the asset txb.moveCall({ target: "0xaddr1::another_module::return_asset", arguments: [ assetManager, asset, borrow ], }); ...