Trustless Swap
This guide is rated as advanced.
You can expect advanced guides to take 2 hours or more of dedicated time. The length of time necessary to fully understand some of the concepts raised in this guide might increase this estimate.
You can view the complete source code for this app example in the Sui repository.
This guide demonstrates how to make an app that performs atomic swaps on Sui. Atomic swaps are similar to escrows but without requiring a trusted third party.
There are three main sections to this guide:
- Smart Contracts: The Move code that holds the state and perform the swaps.
- Backend: A service that indexes chain state to discover trades, and an API service to read this data.
- Frontend: A UI that enables users to list objects for sale and to accept trades.
What the guide teaches​
- Shared objects: The guide teaches you how to use shared objects, in this case to act as the escrow between two Sui users wanting to trade. Shared objects are a unique concept to Sui. Any transaction and any signer can modify it, given the changes meet the requirements set forth by the package that defined the type.
- Composability: The guide teaches you how to design your Move code in a way that enables full composability. In this app, the Move code that handles trading is completely unaware of the code that defines the objects it is trading and vice versa.
The guide also shows how to build an app that:
- Is trustless: Users do not have to trust (or pay) any third parties; the chain manages the swap.
- Avoids rug-pulls: Guarantees that the object a user wants to trade for isn't tampered with after initiating the trade.
- Preserves liveness: Users are able to pull out of the trade and reclaim their object at any time, in case the other party stops responding.
What you need​
Before getting started, make sure you have:
- Installed the latest version of Sui.
- Configured a valid network environment, as the guide has you deploy the module on Testnet.
- Acquired Devnet or Testnet tokens for development purposes.
- Read the basics of shared versus owned objects.
Directory structure​
To begin, create a new folder on your system titled trading
that holds all your files. Inside that folder, create three more folders: api
, contracts
, and frontend
. It's important to keep this directory structure as some helper scripts in this example target these directories by name. Different projects have their own directory structure, but it's common to split code into functional groups to help with maintenance.
- You have the latest version of Sui installed. If you run
sui --version
in your terminal or console, it responds with the currently installed version. - Your active environment is pointing to the expected network. Run
sui client active-env
to make sure. If you receive a warning about a client and server API version mismatch, update Sui using the version in the relevant branch (mainnet
,testnet
,devent
) of the Sui repo. - Your active address has SUI. Run
sui client balance
in your terminal or console. If there is no balance, acquire SUI from the faucet (not available in Mainnet). - You have a directory to place the files you create in. The suggested names of the directories are important if you use the available helper functions later in the guide.
Smart contracts​
In this part of the guide, you write the Move contracts that perform the trustless swaps. The guide describes how to create the package from scratch, but you can use a fork or copy of the example code in the Sui repo to follow along instead. See Write a Move Package to learn more about package structure and how to use the Sui CLI to scaffold a new project.
Move.toml​
To begin writing your smart contracts, create an escrow
folder in your contracts
folder (if using recommended directory names). Create a file inside the folder named Move.toml
and copy the following code into it. This is the package manifest file. If you want to learn more about the structure of the file, see Package Manifest in The Move Book.
If you are targeting a network other than Testnet, be sure to update the rev
value for the Sui dependency.
[package]
name = "escrow"
version = "0.0.1"
edition = "2024.beta"
[dependencies]
Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/testnet" }
[addresses]
escrow = "0x0"
Locked and Key​
With your manifest file in place, it's time to start creating the Move assets for this project. In your escrow
folder, at the same level as your Move.toml
file, create a sources
folder. This is the common file structure of a package in Move. Create a new file inside sources
titled lock.move
. This file contains the logic that locks the object involved in a trade. The complete source code for this file follows and the sections that come after detail its components.
Click the titles at the top of codeblocks to open the relevant source file in GitHub.
lock.move
lock.move
module escrow::lock;
use sui::{dynamic_object_field as dof, event};
public struct LockedObjectKey has copy, store, drop {}
public struct Locked<phantom T: key + store> has key, store {
id: UID,
key: ID,
}
public struct Key has key, store { id: UID }
const ELockKeyMismatch: u64 = 0;
public fun lock<T: key + store>(obj: T, ctx: &mut TxContext): (Locked<T>, 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)
}
public fun unlock<T: key + store>(mut locked: Locked<T>, key: Key): T {
assert!(locked.key == object::id(&key), ELockKeyMismatch);
let Key { id } = key;
id.delete();
let obj = dof::remove<LockedObjectKey, T>(&mut locked.id, LockedObjectKey {});
event::emit(LockDestroyed { lock_id: object::id(&locked) });
let Locked { id, key: _ } = locked;
id.delete();
obj
}
public struct LockCreated has copy, drop {
lock_id: ID,
key_id: ID,
creator: address,
item_id: ID,
}
public struct LockDestroyed has copy, drop {
lock_id: ID,
}
#[test_only]
use sui::coin::{Self, Coin};
#[test_only]
use sui::sui::SUI;
#[test_only]
use sui::test_scenario::{Self as ts, Scenario};
#[test_only]
fun test_coin(ts: &mut Scenario): Coin<SUI> {
coin::mint_for_testing<SUI>(42, ts.ctx())
}
#[test]
fun test_lock_unlock() {
let mut ts = ts::begin(@0xA);
let coin = test_coin(&mut ts);
let (lock, key) = lock(coin, ts.ctx());
let coin = lock.unlock(key);
coin.burn_for_testing();
ts.end();
}
#[test]
#[expected_failure(abort_code = ELockKeyMismatch)]
fun test_lock_key_mismatch() {
let mut ts = ts::begin(@0xA);
let coin = test_coin(&mut ts);
let another_coin = test_coin(&mut ts);
let (l, _k) = lock(coin, ts.ctx());
let (_l, k) = lock(another_coin, ts.ctx());
let _key = l.unlock(k);
abort 1337
}
After a trade is initiated, you don't want the trading party to modify the object they agreed to trade. Imagine you're trading in-game items and you agree to trade a weapon with all its attachments, and its owner strips all its attachments just before the trade.
In a traditional trade, a third party typically holds the items in escrow to make sure they are not tampered with before the trade completes. This requires either trusting that the third party won't tamper with it themselves, paying the third party to ensure that doesn't happen, or both.
In a trustless swap, however, you can use the safety properties of Move to force an item's owner to prove that they have not tampered with the version of the object that you agreed to trade, without involving anyone else.
This is done by requiring that an object that is available for trading is locked with a single-use key, and asking the owner to supply the key when finalizing the trade.
To tamper with the object would require unlocking it, which consumes the key. Consequently, there would no longer be a key to finish the trade.
public struct Locked<phantom T: key + store> has key, store {
id: UID,
key: ID,
}
public struct Key has key, store { id: UID }
- The
Locked<T>
type stores theID
of the key that unlocks it, and its ownid
. The object being locked is added as a dynamic object field, so that it is still readable at its own ID off chain. - The corresponding
Key
type only stores its ownid
.
The lock and key are made single-use by the signatures of the lock
and unlock
functions. lock
accepts any object of type T: store
(the store
ability is necessary for storing it inside a Locked<T>
), and creates both the Locked<T>
and its corresponding Key
:
lock
function in lock.move
lock
function in lock.move
public fun lock<T: key + store>(obj: T, ctx: &mut TxContext): (Locked<T>, 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)
}
The unlock
function accepts the Locked<T>
and Key
by value (which consumes them), and returns the underlying T
as long as the correct key has been supplied for the lock:
unlock
function in lock.move
unlock
function in lock.move
const ELockKeyMismatch: u64 = 0;
public fun unlock<T: key + store>(mut locked: Locked<T>, key: Key): T {
assert!(locked.key == object::id(&key), ELockKeyMismatch);
let Key { id } = key;
id.delete();
let obj = dof::remove<LockedObjectKey, T>(&mut locked.id, LockedObjectKey {});
event::emit(LockDestroyed { lock_id: object::id(&locked) });
let Locked { id, key: _ } = locked;
id.delete();
obj
}
Together, they ensure that a lock and key cannot have existed before the lock operation, and will not exist after a successful unlock – it is single use.
- Move Package defined in The Move Book.
- Concepts: Wrapped Objects
Testing Locked and Key​
Move's type system guarantees that a given Key
cannot be re-used (because unlock
accepts it by value), but there are some properties that need to be confirmed with tests:
- A locked object can be unlocked with its key.
- Trying to unlock an object with the wrong key fails.
The test starts with a helper function for creating an object, it doesn't matter what kind of object it is, as long as it has the store
ability. The test uses Coin<SUI>
, because it comes with a #[test_only]
function for minting:
#[test_only]
fun test_coin(ts: &mut Scenario): Coin<SUI> {
coin::mint_for_testing<SUI>(42, ts.ctx())
}
- All test-related functions and imports are annotated with
#[test_only]
to make sure they don't show up in the published package. This can also be done by separating tests into their own module – e.g.lock_tests.move
– and marking that module as#[test_only]
. - The
test_scenario
module is used to provide access to a&mut TxContext
in the test (necessary for creating new objects). Tests that don't need to simulate multiple transactions but still need access to aTxContext
can usesui::tx_context::dummy
to create a test context instead.
The first test works by creating an object to test, locking it and unlocking it – this should finish executing without aborting.
The last two lines exist to keep the Move compiler happy by cleaning up the test coin and test scenario objects, because values in Move are not implicitly cleaned up unless they have the drop
ability.
#[test]
fun test_lock_unlock() {
let mut ts = ts::begin(@0xA);
let coin = test_coin(&mut ts);
let (lock, key) = lock(coin, ts.ctx());
let coin = lock.unlock(key);
coin.burn_for_testing();
ts.end();
}
The other test is testing a failure scenario – that an abort happens. It creates two locked objects (this time the values are just u64
s), and use the key from one to try and unlock the other, which should fail (specified using the expected_failure
attribute).
Unlike the previous test, the same clean up is not needed, because the code is expected to terminate. Instead, add another abort after the code that you expect to abort (making sure to use a different code for this second abort).
#[test]
#[expected_failure(abort_code = ELockKeyMismatch)]
fun test_lock_key_mismatch() {
let mut ts = ts::begin(@0xA);
let coin = test_coin(&mut ts);
let another_coin = test_coin(&mut ts);
let (l, _k) = lock(coin, ts.ctx());
let (_l, k) = lock(another_coin, ts.ctx());
let _key = l.unlock(k);
abort 1337
}
- Concepts: Test Scenario
- Drop ability defined in The Move Book.
- [Testing] Move code discussion in The Move Book.
At this point, you have
- A Move package consisting of a manifest file (
Move.toml
) - A
lock.move
file in yoursources
folder.
From your escrow
folder, run sui move test
in your terminal or console. If successful, you get a response similar to the following that confirms the package builds and your tests pass:
INCLUDING DEPENDENCY Sui
INCLUDING DEPENDENCY MoveStdlib
BUILDING escrow
Running Move unit tests
[ PASS ] escrow::lock::test_lock_key_mismatch
[ PASS ] escrow::lock::test_lock_unlock
Test result: OK. Total tests: 2; passed: 2; failed: 0
You might notice that the Move compiler creates a build
subfolder inside escrow
upon a successful build. This folder contains your package's compiled bytecode, code from your package's dependencies, and various other files necessary for the build. At this point, it's enough to just be aware of these files. You don't need to fully understand the contents in build
.
The Escrow protocol​
Create a new file in your escrow
folder titled shared.move
. The code in this file creates the shared Escrow
object and completes the trading logic. The complete source code for this file follows and the sections that come after detail its components.
shared.move
shared.move
module escrow::shared;
use escrow::lock::{Locked, Key};
use sui::{dynamic_object_field as dof, event};
public struct EscrowedObjectKey has copy, store, drop {}
public struct Escrow<phantom T: key + store> has key, store {
id: UID,
sender: address,
recipient: address,
exchange_key: ID,
}
const EMismatchedSenderRecipient: u64 = 0;
const EMismatchedExchangeObject: u64 = 1;
public fun create<T: key + store>(
escrowed: T,
exchange_key: ID,
recipient: address,
ctx: &mut TxContext,
) {
let mut escrow = Escrow<T> {
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);
}
public fun swap<T: key + store, U: key + store>(
mut escrow: Escrow<T>,
key: Key,
locked: Locked<U>,
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);
transfer::public_transfer(locked.unlock(key), sender);
event::emit(EscrowSwapped {
escrow_id: id.to_inner(),
});
id.delete();
escrowed
}
public fun return_to_sender<T: key + store>(mut escrow: Escrow<T>, 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
}
public struct EscrowCreated has copy, drop {
escrow_id: ID,
key_id: ID,
sender: address,
recipient: address,
item_id: ID,
}
public struct EscrowSwapped has copy, drop {
escrow_id: ID,
}
public struct EscrowCancelled has copy, drop {
escrow_id: ID,
}
#[test_only]
use sui::coin::{Self, Coin};
#[test_only]
use sui::sui::SUI;
#[test_only]
use sui::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<SUI> {
coin::mint_for_testing<SUI>(42, ts.ctx())
}
#[test]
fun test_successful_swap() {
let mut ts = ts::begin(@0x0);
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)
};
let i1 = {
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let cid = object::id(&c);
create(c, ik2, BOB, ts.ctx());
cid
};
{
ts.next_tx(BOB);
let escrow: Escrow<Coin<SUI>> = ts.take_shared();
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<SUI>> = ts.take_from_sender();
let c = escrow.swap(k2, l2, ts.ctx());
transfer::public_transfer(c, BOB);
};
ts.next_tx(@0x0);
{
let c: Coin<SUI> = ts.take_from_address_by_id(ALICE, i2);
ts::return_to_address(ALICE, c);
};
{
let c: Coin<SUI> = 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
};
{
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
create(c, ik2, BOB, ts.ctx());
};
{
ts.next_tx(DIANE);
let escrow: Escrow<Coin<SUI>> = ts.take_shared();
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<SUI>> = 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);
};
{
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let cid = object::id(&c);
create(c, cid, BOB, ts.ctx());
};
{
ts.next_tx(BOB);
let escrow: Escrow<Coin<SUI>> = ts.take_shared();
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<SUI>> = 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);
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
};
{
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
create(c, ik2, BOB, ts.ctx());
};
{
ts.next_tx(BOB);
let k: Key = ts.take_from_sender();
let l: Locked<Coin<SUI>> = 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<SUI>> = 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);
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
};
{
ts.next_tx(ALICE);
let escrow: Escrow<Coin<SUI>> = ts.take_shared();
let c = escrow.return_to_sender(ts.ctx());
transfer::public_transfer(c, ALICE);
};
ts.next_tx(@0x0);
{
let c: Coin<SUI> = 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);
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
};
{
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
create(c, ik2, BOB, ts.ctx());
};
{
ts.next_tx(ALICE);
let escrow: Escrow<Coin<SUI>> = ts.take_shared();
let c = escrow.return_to_sender(ts.ctx());
transfer::public_transfer(c, ALICE);
};
{
ts.next_tx(BOB);
let escrow: Escrow<Coin<SUI>> = ts.take_shared();
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<SUI>> = ts.take_from_sender();
let c = escrow.swap(k2, l2, ts.ctx());
transfer::public_transfer(c, BOB);
};
abort 1337
}
Trading proceeds in three steps:
- The first party locks the object they want to trade – this is already handled by the
lock
module you wrote earlier. - The second party puts their object up for escrow and registers their interest in the first party's object. This is handled by a new module –
escrow
. - The first party completes the trade by providing their locked object and the key to unlock it. Assuming all checks pass, this transfers their object to the second party and makes the second party's object available to them.
You can start by implementing steps two and three, by defining a new type to hold the escrowed object. It holds the escrowed
object and an id: UID
(because it's an object in its own right), but it also records the sender
and intended recipient
(to confirm they match when the trade happens), and it registers interest in the first party's object by recording the ID
of the key that unlocks the Locked<U>
that contains the object.
public struct Escrow<phantom T: key + store> has key, store {
id: UID,
sender: address,
recipient: address,
exchange_key: ID,
}
You also need to create a function for creating the Escrow
object. The object is shared because it needs to be accessed by the address that created it (in case the object needs to be returned) and by the intended recipient (to complete the swap).
create
function in shared.move
create
function in shared.move
public fun create<T: key + store>(
escrowed: T,
exchange_key: ID,
recipient: address,
ctx: &mut TxContext,
) {
let mut escrow = Escrow<T> {
id: object::new(ctx),
sender: ctx.sender(),
recipient,
exchange_key,
};
dof::add(&mut escrow.id, EscrowedObjectKey {}, escrowed);
transfer::public_share_object(escrow);
}
If the second party stops responding, the first party can unlock their object. You need to create a function so the second party can recover their object in the symmetric case as well.
- It needs to check that the caller matches
sender
, becauseEscrow
objects are shared and anybody can access them. - It accepts the
Escrow
by value so that it can clean it up after extracting the escrowed object, reclaiming the storage rebate for the sender and cleaning up an unused object on chain.
return_to_sender
function in shared.move
return_to_sender
function in shared.move
public fun return_to_sender<T: key + store>(mut escrow: Escrow<T>, 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
}
Finally, you need to add a function to allow the first party to complete the trade.
- This function also accepts the
Escrow
by value because it consumes it after the swap is complete. - It checks that the sender of the transaction is the intended recipient (the first party), and that the ID of the key that they provided matches the key specified when the object was escrowed. This ensures no tampering occurs, because this key can be provided only if it had not been used to unlock the object, which proves the object has not left its
Locked<U>
between the call tocreate
and toswap
. You can inspect thelock
module to see that it cannot be modified while in there. - The call to
unlock
further checks that the key matches the locked object that was provided. - Instead of transferring the escrowed object to the recipient address, it is returned by the
swap
function. You can do this because you checked that the transaction sender is the recipient, and it makes this API more composable. Programmable transaction blocks (PTBs) provide the flexibility to decide whether to transfer the object as it is received or do something else with it.
swap
function in shared.move
swap
function in shared.move
const EMismatchedSenderRecipient: u64 = 0;
const EMismatchedExchangeObject: u64 = 1;
public fun swap<T: key + store, U: key + store>(
mut escrow: Escrow<T>,
key: Key,
locked: Locked<U>,
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);
transfer::public_transfer(locked.unlock(key), sender);
event::emit(EscrowSwapped {
escrow_id: id.to_inner(),
});
id.delete();
escrowed
}
- Full source code
- Concepts: Shared Objects
- Concepts: Shared Object Deletion
- Concepts: PTBs
Testing​
Tests for the escrow
module are more involved than for lock
– as they take advantage of test_scenario
's ability to simulate multiple transactions from different senders, and interact with shared objects.
The guide focuses on the test for a successful swap, but you can find a link to all the tests later on.
As with the lock test, start by creating a function to mint a test coin. You also create some constants to represent our transaction senders, ALICE
, BOB
, and DIANE
.
#[test_only]
fun test_coin(ts: &mut Scenario): Coin<SUI> {
coin::mint_for_testing<SUI>(42, ts.ctx())
}
The test body starts with a call to test_scenario::begin
and ends with a call to test_scenario::end
. It doesn't matter which address you pass to begin
, because you pick one of ALICE
or BOB
at the start of each new transaction you write, so set it to @0x0
:
#[test]
fun test_successful_swap() {
let mut ts = ts::begin(@0x0);
// Rest of the test ...
ts::end(ts);
}
The first transaction is from BOB
who creates a coin and locks it. You must remember the ID of the coin and the ID of the key, which you will need later, and then you transfer the locked object and the key itself to BOB
, because this is what would happen in a real transaction: When simulating transactions in a test, you should only keep around primitive values, not whole objects, which would need to be written to chain between transactions.
Write these transactions inside the test_successful_swap
function, between the call to begin
and end
.
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)
};
Next, ALICE
comes along and sets up the Escrow
, which locks their coin. They register their interest for BOB'
s coin by referencing BOB
's key's ID (ik2
):
let i1 = {
ts.next_tx(ALICE);
let c = test_coin(&mut ts);
let cid = object::id(&c);
create(c, ik2, BOB, ts.ctx());
cid
};
Finally, BOB
completes the trade by calling swap
. The take_shared
function is used to simulate accepting a shared input. It uses type inference to know that the object must be an Escrow
, and finds the last object of this type that was shared (by ALICE
in the previous transaction). Similarly, use take_from_sender
to simulate accepting owned inputs (in this case, BOB
's lock and key). The coin returned by swap
is transferred back to BOB
, as if it was called as part of a PTB, followed by a transfer command.
{
ts.next_tx(BOB);
let escrow: Escrow<Coin<SUI>> = ts.take_shared();
let k2: Key = ts.take_from_sender();
let l2: Locked<Coin<SUI>> = ts.take_from_sender();
let c = escrow.swap(k2, l2, ts.ctx());
transfer::public_transfer(c, BOB);
};
The rest of the test is designed to check that ALICE
has BOB
's coin and vice versa. It starts by calling next_tx
to make sure the effects of the previous transaction have been committed, before running the necessary checks.
ts.next_tx(@0x0);
{
let c: Coin<SUI> = ts.take_from_address_by_id(ALICE, i2);
ts::return_to_address(ALICE, c);
};
{
let c: Coin<SUI> = ts.take_from_address_by_id(BOB, i1);
ts::return_to_address(BOB, c);
};
- Guides: Test Scenario
Observability​
The escrow
Move package is now functional: You could publish it on chain and perform trustless swaps by creating transactions. Creating those transactions requires knowing the IDs of Locked
, Key
, and Escrow
objects.
Locked
and Key
objects are typically owned by the transaction sender, and so can be queried through the Sui RPC, but Escrow
objects are shared, and it is useful to be able to query them by their sender and recipient (so that users can see the trades they have offered and received).
Querying Escrow
objects by their sender or recipient requires custom indexing, and to make it easy for the indexer to spot relevant transactions, add the following events to escrow.move
:
public struct EscrowCreated has copy, drop {
escrow_id: ID,
key_id: ID,
sender: address,
recipient: address,
item_id: ID,
}
public struct EscrowSwapped has copy, drop {
escrow_id: ID,
}
public struct EscrowCancelled has copy, drop {
escrow_id: ID,
}
Functions responsible for various aspects of the escrow's lifecycle emit these events. The custom indexer can then subscribe to transactions that emit these events and process only those, rather than the entire chain state:
emit
events included in functions from shared.move
emit
events included in functions from shared.move
use sui::{dynamic_object_field as dof, event};
public fun create<T: key + store>(
escrowed: T,
exchange_key: ID,
recipient: address,
ctx: &mut TxContext,
) {
let mut escrow = Escrow<T> {
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);
}
public fun swap<T: key + store, U: key + store>(
mut escrow: Escrow<T>,
key: Key,
locked: Locked<U>,
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);
transfer::public_transfer(locked.unlock(key), sender);
event::emit(EscrowSwapped {
escrow_id: id.to_inner(),
});
id.delete();
escrowed
}
public fun return_to_sender<T: key + store>(mut escrow: Escrow<T>, 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
}
- Concepts: Events in The Move Book
- Guide: Using Events
You now have shared.move
and locked.move
files in your sources
folder. From the parent escrow
folder, run sui move test
in your terminal or console. If successful, you get a response similar to the following that confirms the package builds and your tests pass:
INCLUDING DEPENDENCY Sui
INCLUDING DEPENDENCY MoveStdlib
BUILDING escrow
Running Move unit tests
[ PASS ] escrow::lock::test_lock_key_mismatch
[ PASS ] escrow::shared::test_mismatch_object
[ PASS ] escrow::lock::test_lock_unlock
[ PASS ] escrow::shared::test_mismatch_sender
[ PASS ] escrow::shared::test_object_tamper
[ PASS ] escrow::shared::test_return_to_sender
[ PASS ] escrow::shared::test_return_to_sender_failed_swap
[ PASS ] escrow::shared::test_successful_swap
Test result: OK. Total tests: 8; passed: 8; failed: 0
Next steps​
Well done. You have written the Move package! 🚀
To turn this into a complete dApp, you need to create a frontend. However, for the frontend to be updated, it has to listen to the blockchain as escrows are made and swaps are fulfilled.
To achieve this, in the next step you create an indexing service.
Backend indexer​
With the contract adapted to emit events, you can now write an indexer that keeps track of all active Escrow
objects and exposes an API for querying objects by sender or recipient.
The indexer is backed by a Prisma DB with the following schema:
schema.prisma
schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
/// We can setup the provider to our database
/// For this DEMO, we're using sqlite, which allows us to not
/// have external dependencies.
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
/// Our `Locked` objects list
model Locked {
// Keeping an ID so we can use as a pagination cursor
// There's an issue with BigInt for sqlite, so we're using a plain ID.
id Int @id @default(autoincrement())
objectId String @unique
keyId String?
creator String?
itemId String?
deleted Boolean @default(false)
@@index([creator])
@@index([deleted])
}
/// Our swap objects list
model Escrow {
// Keeping an ID so we can use as a pagination cursor
// There's an issue with BigInt for sqlite, so we're using a plain ID.
id Int @id @default(autoincrement())
objectId String @unique
sender String?
recipient String?
keyId String?
itemId String?
swapped Boolean @default(false)
cancelled Boolean @default(false)
@@index([recipient])
@@index([sender])
}
/// Saves the latest cursor for a given key.
model Cursor {
id String @id
eventSeq String
txDigest String
}
The core of the indexer is an event loop, initialized in a function called setupListeners
.
import { setupListeners } from './indexer/event-indexer';
setupListeners();
The indexer queries events related to the escrow
module, using a queryEvent
filter, and keeps track of a cursor representing the latest event it has processed so it can resume indexing from the right place even if it is restarted. The filter is looking for any events whose type is from the escrow
module of the Move package (see the event-indexer.ts
code that follows).
The core event job works by polling: It queries RPC for events following its latest cursor and sends them to a callback for processing. If it detects more than one page of new events, it immediately requests the next page. Otherwise, the job waits for the next polling interval before checking again.
event-indexer.ts
event-indexer.ts
import { EventId, SuiClient, SuiEvent, SuiEventFilter } from '@mysten/sui/client';
import { CONFIG } from '../config';
import { prisma } from '../db';
import { getClient } from '../sui-utils';
import { handleEscrowObjects } from './escrow-handler';
import { handleLockObjects } from './locked-handler';
type SuiEventsCursor = EventId | null | undefined;
type EventExecutionResult = {
cursor: SuiEventsCursor;
hasNextPage: boolean;
};
type EventTracker = {
// The module that defines the type, with format `package::module`
type: string;
filter: SuiEventFilter;
callback: (events: SuiEvent[], type: string) => any;
};
const EVENTS_TO_TRACK: EventTracker[] = [
{
type: `${CONFIG.SWAP_CONTRACT.packageId}::lock`,
filter: {
MoveEventModule: {
module: 'lock',
package: CONFIG.SWAP_CONTRACT.packageId,
},
},
callback: handleLockObjects,
},
{
type: `${CONFIG.SWAP_CONTRACT.packageId}::shared`,
filter: {
MoveEventModule: {
module: 'shared',
package: CONFIG.SWAP_CONTRACT.packageId,
},
},
callback: handleEscrowObjects,
},
];
const executeEventJob = async (
client: SuiClient,
tracker: EventTracker,
cursor: SuiEventsCursor,
): Promise<EventExecutionResult> => {
try {
// get the events from the chain.
// For this implementation, we are going from start to finish.
// This will also allow filling in a database from scratch!
const { data, hasNextPage, nextCursor } = await client.queryEvents({
query: tracker.filter,
cursor,
order: 'ascending',
});
// handle the data transformations defined for each event
await tracker.callback(data, tracker.type);
// We only update the cursor if we fetched extra data (which means there was a change).
if (nextCursor && data.length > 0) {
await saveLatestCursor(tracker, nextCursor);
return {
cursor: nextCursor,
hasNextPage,
};
}
} catch (e) {
console.error(e);
}
// By default, we return the same cursor as passed in.
return {
cursor,
hasNextPage: false,
};
};
const runEventJob = async (client: SuiClient, tracker: EventTracker, cursor: SuiEventsCursor) => {
const result = await executeEventJob(client, tracker, cursor);
// Trigger a timeout. Depending on the result, we either wait 0ms or the polling interval.
setTimeout(
() => {
runEventJob(client, tracker, result.cursor);
},
result.hasNextPage ? 0 : CONFIG.POLLING_INTERVAL_MS,
);
};
/**
* Gets the latest cursor for an event tracker, either from the DB (if it's undefined)
* or from the running cursors.
*/
const getLatestCursor = async (tracker: EventTracker) => {
const cursor = await prisma.cursor.findUnique({
where: {
id: tracker.type,
},
});
return cursor || undefined;
};
/**
* Saves the latest cursor for an event tracker to the db, so we can resume
* from there.
* */
const saveLatestCursor = async (tracker: EventTracker, cursor: EventId) => {
const data = {
eventSeq: cursor.eventSeq,
txDigest: cursor.txDigest,
};
return prisma.cursor.upsert({
where: {
id: tracker.type,
},
update: data,
create: { id: tracker.type, ...data },
});
};
/// Sets up all the listeners for the events we want to track.
/// They are polling the RPC endpoint every second.
export const setupListeners = async () => {
for (const event of EVENTS_TO_TRACK) {
runEventJob(getClient(CONFIG.NETWORK), event, await getLatestCursor(event));
}
};
The callback is responsible for reading the event and updating the database accordingly. For demo purposes, SQLite is being used, and so you need to issue a separate UPSERT
to the database for each escrowed object. In a production setting, however, you would want to batch requests to the database to optimize data flow.
escrow-handler.ts
escrow-handler.ts
import { SuiEvent } from '@mysten/sui/client';
import { Prisma } from '@prisma/client';
import { prisma } from '../db';
type EscrowEvent = EscrowCreated | EscrowCancelled | EscrowSwapped;
type EscrowCreated = {
sender: string;
recipient: string;
escrow_id: string;
key_id: string;
item_id: string;
};
type EscrowSwapped = {
escrow_id: string;
};
type EscrowCancelled = {
escrow_id: string;
};
/**
* Handles all events emitted by the `escrow` module.
* Data is modelled in a way that allows writing to the db in any order (DESC or ASC) without
* resulting in data incosistencies.
* We're constructing the updates to support multiple events involving a single record
* as part of the same batch of events (but using a single write/record to the DB).
* */
export const handleEscrowObjects = async (events: SuiEvent[], type: string) => {
const updates: Record<string, Prisma.EscrowCreateInput> = {};
for (const event of events) {
if (!event.type.startsWith(type)) throw new Error('Invalid event module origin');
const data = event.parsedJson as EscrowEvent;
if (!Object.hasOwn(updates, data.escrow_id)) {
updates[data.escrow_id] = {
objectId: data.escrow_id,
};
}
// Escrow cancellation case
if (event.type.endsWith('::EscrowCancelled')) {
const data = event.parsedJson as EscrowCancelled;
updates[data.escrow_id].cancelled = true;
continue;
}
// Escrow swap case
if (event.type.endsWith('::EscrowSwapped')) {
const data = event.parsedJson as EscrowSwapped;
updates[data.escrow_id].swapped = true;
continue;
}
const creationData = event.parsedJson as EscrowCreated;
// Handle creation event
updates[data.escrow_id].sender = creationData.sender;
updates[data.escrow_id].recipient = creationData.recipient;
updates[data.escrow_id].keyId = creationData.key_id;
updates[data.escrow_id].itemId = creationData.item_id;
}
// As part of the demo and to avoid having external dependencies, we use SQLite as our database.
// Prisma + SQLite does not support bulk insertion & conflict handling, so we have to insert these 1 by 1
// (resulting in multiple round-trips to the database).
// Always use a single `bulkInsert` query with proper `onConflict` handling in production databases (e.g Postgres)
const promises = Object.values(updates).map((update) =>
prisma.escrow.upsert({
where: {
objectId: update.objectId,
},
create: update,
update,
}),
);
await Promise.all(promises);
};
- Full source code
- Reference: JSON-RPC
API service​
The data that the indexer captures can then be served over an API, so that a frontend can read it. Follow the next section to implement the API in TypeScript, to run on Node, using Express.