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.
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.
- Prerequisites
-
Set up your Sui account and CLI environment.
Create Sui account and setup CLI environment
$ sui client
If this is the first time running the sui client CLI tool, it asks you to provide a Sui full node server URL and a meaningful environment alias. It also generates an address with a random key pair in sui.keystore and a config client.yaml.
By default, the client.yaml and sui.keystore files are located in ~/.sui/sui_config. For more information, refer to the Sui client CLI tutorial.
If this is not your first time running sui client, then you already have a client.yaml file in your local environment. If you'd like to create a new address for this tutorial, use the command:
$ sui client new-address ed25519
- Obtain test tokens.
How to obtain tokens
If you are connected to the Devnet or Testnet networks, use the Faucet UI to request tokens.
If you are connected to a local full node, learn how to get local network tokens.
You can view the complete source code for this app example in the Sui repository.
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.
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 --versionin your terminal or console, it responds with the currently installed version. - Your active environment is pointing to the expected network. Run
sui client active-envto 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 balancein 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]
[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/// 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 sui::dynamic_object_field as dof;
use sui::event;
/// The `name` of the DOF that holds the Locked object.
/// Allows better discoverability for the locked object.
public struct LockedObjectKey has copy, drop, store {}
/// 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.
///
/// Object is added as a Dynamic Object Field so that it can still be looked-up.
public struct Locked<phantom T: key + store> has key, store {
id: UID,
key: ID,
}
/// Key to open a locked object (consuming the `Key`)
public 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: 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),
});
// Adds the `object` as a DOF for the `lock` object
dof::add(&mut lock.id, LockedObjectKey {}, 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: 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
}
// === Events ===
public struct LockCreated has copy, drop {
/// The ID of the `Locked` object.
lock_id: ID,
/// The ID of the key that unlocks a locked object in a `Locked`.
key_id: ID,
/// The creator of the locked object.
creator: address,
/// The ID of the item that is locked.
item_id: ID,
}
public struct LockDestroyed has copy, drop {
/// The ID of the `Locked` object.
lock_id: ID,
}
// === Tests ===
#[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 theIDof 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
Keytype 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.movepublic 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.moveconst 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_scenariomodule is used to provide access to a&mut TxContextin the test (necessary for creating new objects). Tests that don't need to simulate multiple transactions but still need access to aTxContextcan usesui::tx_context::dummyto 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 u64s), 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.movefile in yoursourcesfolder.
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.movemodule escrow::shared;
use escrow::lock::{Locked, Key};
use sui::dynamic_object_field as dof;
use sui::event;
public struct EscrowedObjectKey has copy, drop, store {}
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
lockmodule 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.movepublic 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, becauseEscrowobjects are shared and anybody can access them. - It accepts the
Escrowby 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.movepublic 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
Escrowby 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 tocreateand toswap. You can inspect thelockmodule to see that it cannot be modified while in there. - The call to
unlockfurther 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
swapfunction. 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.moveconst 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);
// 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<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);
};
// Commit effects from the swap
ts.next_tx(@0x0);
// Alice gets the object from Bob
{
let c: Coin<SUI> = ts.take_from_address_by_id(ALICE, i2);
ts::return_to_address(ALICE, c);
};
// Bob gets the object from Alice
{
let c: Coin<SUI> = ts.take_from_address_by_id(BOB, i1);
ts::return_to_address(BOB, c);
};
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