Back to Sui

Trustless Swap

docs/content/guides/developer/app-examples/trustless-swap.mdx

latest76.2 KB
Original Source

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:

  1. Smart Contracts: The Move code that holds the state and performs the swaps.
  2. Backend: A service that indexes chain state to discover trades, and an API service to read this data.
  3. Frontend: A UI that enables users to list objects for sale and to accept trades.
<ImportContent source="prerequisites.mdx" mode="snippet" /> <ImportContent source="app-examples-swap-source.mdx" mode="snippet" />

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 is not 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. 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.

:::checkpoint

  • 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 {#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 Hello, World! 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.

:::info

If you are targeting a network other than Testnet, be sure to update the rev value for the Sui dependency.

:::

<ImportContent source="examples/trading/contracts/escrow/Move.toml" mode="code" />

Locked and Key

With your manifest file in place, 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.

:::tip

Click the titles at the top of codeblocks to open the relevant source file in GitHub.

:::

<details> <summary>

lock.move

</summary> <ImportContent source="examples/trading/contracts/escrow/sources/lock.move" mode="code" /> </details>

After a trade is initiated, you do not want the trading party to modify the object they agreed to trade. Imagine you are 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 does not tamper with it themselves, paying the third party to ensure that does not 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.

You do this 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.

<ImportContent source="examples/trading/contracts/escrow/sources/lock.move" mode="code" struct="Locked,Key" noComments />

  • The Locked<T> type stores the ID of the key that unlocks it, and its own id. 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 own id.

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:

<details> <summary>

lock function in lock.move

</summary>

<ImportContent source="examples/trading/contracts/escrow/sources/lock.move" mode="code" fun="lock" noComments />

</details>

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:

<details> <summary>

unlock function in lock.move

</summary>

<ImportContent source="examples/trading/contracts/escrow/sources/lock.move" mode="code" variable="ELockKeyMismatch" noComments />

<ImportContent source="examples/trading/contracts/escrow/sources/lock.move" mode="code" fun="unlock" noTitle noComments />

</details>

Together, they ensure that a lock and key cannot have existed before the lock operation, and do not exist after a successful unlock. It is single use.

:::tip Additional resources

:::

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. The object type does not matter, as long as it has the store ability. The test uses Coin<SUI>, because it comes with a #[test_only] function for minting:

<ImportContent source="examples/trading/contracts/escrow/sources/lock.move" mode="code" fun="test_coin" noComments />

  • All test-related functions and imports are annotated with #[test_only] to make sure they do not show up in the published package. You can also do this by separating tests into their own module (for example, 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 do not need to simulate multiple transactions but still need access to a TxContext can use sui::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.

<ImportContent source="examples/trading/contracts/escrow/sources/lock.move" mode="code" fun="test_lock_unlock" noComments />

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).

<ImportContent source="examples/trading/contracts/escrow/sources/lock.move" mode="code" fun="test_lock_key_mismatch" noComments />

:::tip Additional resources

:::

:::checkpoint

At this point, you have

  • A Move package consisting of a manifest file (Move.toml)
  • A lock.move file in your sources 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, be aware of these files. You do not need to fully understand the contents in build.

:::

The Escrow protocol {#escrow}

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.

<details> <summary>

shared.move

</summary>

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" noComments />

</details>

Trading proceeds in three steps:

  1. The first party locks the object they want to trade – this is already handled by the lock module you wrote earlier.
  2. 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 called escrow.
  3. 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 is 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.

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" struct="Escrow" noComments singleSpace />

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).

<details> <summary>

create function in shared.move

</summary>

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" tag="noemit" noComments />

</details>

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, because Escrow 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.
<details> <summary>

return_to_sender function in shared.move

</summary>

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" fun="return_to_sender" noComments />

</details>

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 to create and to swap. You can inspect the lock 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.
<details> <summary>

swap function in shared.move

</summary>

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" variable="EMismatchedSenderRecipient,EMismatchedExchangeObject" singleSpace noComments />

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" fun="swap" noComments noTitle />

</details>

:::tip Additional resources

:::

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.

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" fun="test_coin" noComments />

The test body starts with a call to test_scenario::begin and ends with a call to test_scenario::end. It does not 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:

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" tag="test" />

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 need later. 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.

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" variable="i2" noComments />

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):

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" variable="i1" noComments />

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.

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" tag="bob" noComments />

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.

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" tag="finish" noComments />

:::tip Additional resources

:::

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:

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" struct="EscrowCreated,EscrowSwapped,EscrowCancelled" noComments />

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:

<details> <summary>

emit events included in functions from shared.move

</summary>

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" dep="sui::event" />

<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" fun="create,swap,return_to_sender" noTitle noComments />

</details>

:::tip Additional resources

:::

:::checkpoint

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

You have written the Move package. 🎉

To turn this into a complete app, 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 {#backend}

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:

<details> <summary>

schema.prisma

</summary> <ImportContent source="examples/trading/api/prisma/schema.prisma" mode="code" /> </details>

The core of the indexer is an event loop, initialized in a function called setupListeners.

<ImportContent source="examples/trading/api/indexer.ts" mode="code" />

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.

<details> <summary>

event-indexer.ts

</summary> <ImportContent source="examples/trading/api/indexer/event-indexer.ts" mode="code" /> </details>

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.

<details> <summary>

escrow-handler.ts

</summary> <ImportContent source="examples/trading/api/indexer/escrow-handler.ts" mode="code" /> </details>

:::tip Additional resources

:::

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.

Query parameters

You want your API to accept the query string in the URL as the parameters for database WHERE query. Hence, you want a utility that can extract and parse the URL query string into valid query parameters for Prisma. With the parseWhereStatement() function, the callers filter the set of keys from the URL query string and transforms those corresponding key-value pairs into the correct format for Prisma.

<details> <summary>

parseWhereStatement in api-queries.ts

</summary>

<ImportContent source="examples/trading/api/utils/api-queries.ts" mode="code" enumeration="WhereParamTypes" />

<ImportContent source="examples/trading/api/utils/api-queries.ts" mode="code" type="WhereParam" noTitle />

<ImportContent source="examples/trading/api/utils/api-queries.ts" mode="code" variable="parseWhereStatement" noTitle />

</details>

Query pagination

Pagination is another crucial part to ensure your API returns sufficient and ordered chunks of information instead of all the data that might be the vector for a DDOS attack. Similar to WHERE parameters, define a set of keys in the URL query string to be accepted as valid pagination parameters. The parsePaginationForQuery() utility function helps to achieve this by filtering the pre-determined keys sort, limit, cursor and parsing corresponding key-value pairs into ApiPagination that Prisma can consume.

In this example, the id field of the model in the database as the cursor that allows clients to continue subsequent queries with the next page.

<details> <summary>

parsePaginationForQuery in api-queries.ts

</summary>

<ImportContent source="examples/trading/api/utils/api-queries.ts" mode="code" type="ApiPagination" />

<ImportContent source="examples/trading/api/utils/api-queries.ts" mode="code" variable="parsePaginationForQuery" noTitle />

</details>

API endpoints

All the endpoints are defined in server.ts. There are two endpoints:

  • /locked to query Locked objects. Valid query keys: - deleted: Boolean - creator: String - keyId: String - objectId: String

  • /escrows to query Escrow objects. Valid query keys: - cancelled: Boolean - swapped: Boolean - recipient: String - sender: String

Pass the URL query string into the pre-defined utilities to output the correct parameters that Prisma can use.

<details> <summary>

server.ts

</summary> <ImportContent source="examples/trading/api/server.ts" mode="code" /> </details>

Deployment {#deployment}

Now that you have an indexer and an API service, you can deploy your move package and start the indexer and API service.

  1. Install dependencies by running pnpm install --ignore-workspace or yarn install --ignore-workspace.

  2. Setup the database by running pnpm db:setup:dev or yarn db:setup:dev.

  3. Deploy the Sui package

<details> <summary>

Deployment instructions

</summary> <ImportContent source="initialize-sui-client-cli.mdx" mode="snippet" />

Next, configure the Sui CLI to use testnet as the active environment.

Use the following command to list your available environments:

sh
$ sui client envs

If you have not already set up a testnet environment, do so by running the following command in a terminal or console:

sh
$ sui client new-env --alias testnet --rpc https://fullnode.testnet.sui.io:443

Run the following command to activate the testnet environment:

sh
$ sui client switch --env testnet

Before being able to publish your package to Testnet, you need Testnet SUI tokens. To get some, run the following command:

sh
$ sui client faucet

For other ways to get SUI in your Testnet account, see Get SUI Tokens.

Now that you have an account with some Testnet SUI, you can deploy your contracts.

There are some helper functions to publish the smart contracts so you can create some demo data (for Testnet). The helper function to publish the smart contracts expects built smart contracts in both the escrow and demo directories. Run sui move build in both directories, if necessary. Be sure to update the Sui dependency in the manifest to point to the correct source based on your environment.

To publish the smart contracts and produce demo data:

  1. Publish the smart contracts by running the following command from your api folder:
sh
$ npx ts-node helpers/publish-contracts.ts

If successful, demo-contract.json and escrow-contract.json are created in the backend root directory. These files contain the contract addresses and are used by the backend and frontend to interact with the contracts.

  1. Produce demo non-locked and locked objects
sh
$ npx ts-node helpers/create-demo-data.ts
  1. Produce demo escrows
sh
$ npx ts-node helpers/create-demo-escrows.ts

If you want to reset the database (for a clean demo, for example), run pnpm db:reset:dev && pnpm db:setup:dev or yarn db:reset:dev && yarn db:setup:dev.

</details>
  1. Run both the API and the indexer by running pnpm dev or yarn dev.

  2. Visit http://localhost:3000/escrows or http://localhost:3000/locked

:::checkpoint

You should now have an indexer running.

  • If you visit localhost:3000, you get a message that the service is running: {"message":"🚀 API is functional 🚀"}.
  • If you visit localhost:3000/escrows, you see the demo escrow data the helper scripts created for you. Likewise, visiting http://localhost:3000/locked displays the raw JSON the script created for demo objects.

:::

Next steps

With the code successfully deployed on Testnet, you can now create a frontend to display the trading data and to allow users to interact with the Move modules.

Frontend {#frontend}

In this final part of the app example, you build a frontend (UI) that allows end users to discover trades and interact with listed escrows.

<Tabs className="tabsHeadingCentered--small"> <TabItem value="prereq" label="Prerequisites"> </TabItem> </Tabs> <ImportContent source="app-examples-swap-source.mdx" mode="snippet" />

:::tip Additional resources

  • Sui TypeScript SDK. For basic usage on how to interact with Sui with TypeScript.
  • Sui dApp Kit. To learn basic building blocks for developing an app in the Sui ecosystem with React.js.
  • @mysten/dapp. This is used within this project to quickly scaffold a React-based Sui app.

:::

Overview

The UI design consists of three parts:

  • A header containing the button allowing users to connect their wallet and navigate to other pages.
  • A place for users to manage their owned objects to be ready for escrow trading called Manage Objects.
  • A place for users to discover, create, and execute trades called Escrows.

Scaffold a new app

The first step is to set up the client app. Run the following command to scaffold a new app from your frontend folder.

<Tabs groupId="packagemanager"> <TabItem label="PNPM" value="pnpm">
sh
$ pnpm create @mysten/dapp --template react-client-dapp
</TabItem> <TabItem label="Yarn" value="yarn">
sh
$ yarn create @mysten/dapp --template react-client-dapp
</TabItem> </Tabs>

When asked for a name for your app, provide one of your liking. The app scaffold gets created in a new directory with the name you provide. This is convenient to keep your working code separate from the example source code that might already populate this folder. The codeblocks that follow point to the code in the default example location. Be aware the path to your own code includes the app name you provide.

Setting up import aliases

First, set up import aliases to make the code more readable and maintainable. This allows you to import files using @/ instead of relative paths.

<details> <summary>

Replace the content of tsconfig.json with the following:

</summary> <ImportContent source="examples/trading/frontend/tsconfig.json" mode="code" /> </details>

The paths option under compilerOptions is what defines the aliasing for TypeScript. Here, the alias @/* is mapped to the ./src/* directory, meaning that any time you use @/, TypeScript resolves it as a reference to the src folder. This setup reduces the need for lengthy relative paths when importing files in your project.

<details> <summary>

Replace the content of vite.config.ts with the following:

</summary> <ImportContent source="examples/trading/frontend/vite.config.ts" mode="code" /> </details>

Vite also needs to be aware of the aliasing to resolve imports correctly during the build process. In the resolve.alias configuration of vite.config.ts, map the alias @ to the /src directory.

Adding Tailwind CSS

To streamline the styling process and keep the codebase clean and maintainable, this guide uses Tailwind CSS, which provides utility-first CSS classes to rapidly build custom designs. Run the following command from the base of your app project to add Tailwind CSS and its dependencies:

<Tabs groupId="packagemanager"> <TabItem label="PNPM" value="pnpm">
sh
$ pnpm add tailwindcss@latest postcss@latest autoprefixer@latest
</TabItem> <TabItem label="Yarn" value="yarn">
sh
$ yarn add tailwindcss@latest postcss@latest autoprefixer@latest
</TabItem> </Tabs>

Next, generate the Tailwind CSS configuration file by running the following:

sh
$ npx tailwindcss init -p
<details> <summary>

Replace the content of tailwind.config.js with the following:

</summary> <ImportContent source="examples/trading/frontend/tailwind.config.js" mode="code" /> </details> <details> <summary>

Add the src/styles/ directory and add base.css:

</summary> <ImportContent source="examples/trading/frontend/src/styles/base.css" mode="code" /> </details>

Connecting your deployed package

First, deploy your package through the scripts in the api directory.

<details> <summary>

Then, create a src/constants.ts file and fill it with the following:

</summary> <ImportContent source="examples/trading/frontend/src/constants.ts" mode="code" /> </details>

:::warning

If you create an app using a project name so that your src files are in a subfolder of frontend, be sure to add another nesting level (../) to the import statements.

:::

Add helper functions and UI components

<details> <summary>

Create a src/utils/ directory and add the following file:

</summary> <ImportContent source="examples/trading/frontend/src/utils/helpers.ts" mode="code" /> </details>

Create a src/components/ directory and add the following components:

<details> <summary>

ExplorerLink.tsx

</summary> <ImportContent source="examples/trading/frontend/src/components/ExplorerLink.tsx" mode="code" /> </details> <details> <summary>

InfiniteScrollArea.tsx

</summary>

<ImportContent source="examples/trading/frontend/src/components/InfiniteScrollArea.tsx" mode="code" />

</details> <details> <summary>

Loading.tsx

</summary> <ImportContent source="examples/trading/frontend/src/components/Loading.tsx" mode="code" /> </details> <details> <summary>

SuiObjectDisplay.tsx

</summary> <ImportContent source="examples/trading/frontend/src/components/SuiObjectDisplay.tsx" mode="code" /> </details>

Install the necessary dependencies:

<Tabs groupId="packagemanager"> <TabItem label="PNPM" value="pnpm">
sh
$ pnpm add react-hot-toast
</TabItem> <TabItem label="Yarn" value="yarn">
sh
$ yarn add react-hot-toast
</TabItem> </Tabs>

Set up routing {#routing}

The imported template only has a single page. To add more pages, you need to set up routing.

First, install the necessary dependencies:

<Tabs groupId="packagemanager"> <TabItem label="PNPM" value="pnpm">
sh
$ pnpm add react-router-dom
</TabItem> <TabItem label="Yarn" value="yarn">
sh
$ yarn add react-router-dom
</TabItem> </Tabs> <details> <summary>

Then, create a src/routes/ directory and add index.tsx. This file contains the routing configuration:

</summary> <ImportContent source="examples/trading/frontend/src/routes/index.tsx" mode="code" /> </details>

Add the following respective files to the src/routes/ directory:

<details> <summary>

root.tsx. This file contains the root component that is rendered on every page:

</summary> <ImportContent source="examples/trading/frontend/src/routes/root.tsx" mode="code" /> </details> <details> <summary>

LockedDashboard.tsx. This file contains the component for the Manage Objects page.

</summary>
tsx
export function LockedDashboard() {
	return (
		<div>
			<h1>Locked Dashboard</h1>
		</div>
	);
}
</details> <details> <summary>

EscrowDashboard.tsx. This file contains the component for the Escrows page.

</summary>
tsx
export function EscrowDashboard() {
	return (
		<div>
			<h1>Escrow Dashboard</h1>
		</div>
	);
}
</details> <details> <summary>

Update src/main.tsx by replacing the App component with the RouterProvider and replace "dark" with "light" in the Theme component:

</summary> <ImportContent source="examples/trading/frontend/src/main.tsx" mode="code" /> </details>

The dApp Kit provides a set of hooks for making query and mutation calls to the Sui blockchain. These hooks are thin wrappers around query and mutation hooks from @tanstack/react-query.

:::tip Additional resources

:::

<details> <summary>

Create src/components/Header.tsx. This file contains the navigation links and the connect wallet button:

</summary>
tsx
import { ConnectButton } from '@mysten/dapp-kit-react';
import { SizeIcon } from '@radix-ui/react-icons';
import { Box, Container, Flex, Heading } from '@radix-ui/themes';
import { NavLink } from 'react-router-dom';

const menu = [
	{
		title: 'Escrows',
		link: '/escrows',
	},
	{
		title: 'Manage Objects',
		link: '/locked',
	},
];

export function Header() {
	return (
		<Container>
			<Flex position="sticky" px="4" py="2" justify="between" className="border-b flex flex-wrap">
				<Box>
					<Heading className="flex items-center gap-3">
						<SizeIcon width={24} height={24} />
						Trading Demo
					</Heading>
				</Box>

				<Box className="flex gap-5 items-center">
					{menu.map((item) => (
						<NavLink
							key={item.link}
							to={item.link}
							className={({ isActive, isPending }) =>
								`cursor-pointer flex items-center gap-2 ${
									isPending ? 'pending' : isActive ? 'font-bold text-blue-600' : ''
								}`
							}
						>
							{item.title}
						</NavLink>
					))}
				</Box>

				<Box className="connect-wallet-wrapper">
					<ConnectButton />
				</Box>
			</Flex>
		</Container>
	);
}
</details>

The dApp Kit comes with a pre-built React.js component called ConnectButton displaying a button to connect and disconnect a wallet. The connecting and disconnecting wallet logic is handled seamlessly so you do not need to worry about repeating yourself doing the same logic all over again.

:::checkpoint

At this point, you have a basic routing setup. Run your app and ensure you can:

  • Navigate between the Manage Objects and Escrows pages.
  • Connect and disconnect your wallet.

The styles should be applied. The Header component should look like this:

:::

Type definitions

<details> <summary>

All the type definitions are in src/types/types.ts. Create this file and add the following:

</summary> <ImportContent source="examples/trading/frontend/src/types/types.ts" mode="code" /> </details>

ApiLockedObject and ApiEscrowObject represent the Locked and Escrow indexed data model the indexing and API service return.

EscrowListingQuery and LockedListingQuery are the query parameters model to provide to the API service to fetch from the endpoints /escrow and /locked accordingly.

Display owned objects

Now, display the objects owned by the connected wallet address. This is the Manage Objects page.

<details> <summary>

First add this file src/components/locked/LockOwnedObjects.tsx:

</summary>
tsx
import { useCurrentAccount, useCurrentClient } from '@mysten/dapp-kit-react';
import { useInfiniteQuery } from '@tanstack/react-query';

import { InfiniteScrollArea } from '@/components/InfiniteScrollArea';
import { SuiObjectDisplay } from '@/components/SuiObjectDisplay';

/**
 * A component that fetches all the objects owned by the connected wallet address
 * and allows the user to lock them, so they can be used in escrow.
 */
export function LockOwnedObjects() {
	const account = useCurrentAccount();
	const client = useCurrentClient();

	const { data, fetchNextPage, isFetchingNextPage, hasNextPage, refetch } = useInfiniteQuery({
		queryKey: ['listOwnedObjects', account?.address],
		queryFn: async ({ pageParam }) => {
			const result = await client.core.listOwnedObjects({
				owner: account?.address!,
				cursor: pageParam ?? undefined,
			});
			return result;
		},
		initialPageParam: null as string | null,
		getNextPageParam: (lastPage) => (lastPage.hasNextPage ? lastPage.cursor : null),
		enabled: !!account,
		select: (data) => data.pages.flatMap((page) => page.objects),
	});

	return (
		<InfiniteScrollArea
			loadMore={() => fetchNextPage()}
			hasNextPage={hasNextPage}
			loading={isFetchingNextPage}
		>
			{data?.map((obj) => (
				<SuiObjectDisplay key={obj.objectId} object={obj}></SuiObjectDisplay>
			))}
		</InfiniteScrollArea>
	);
}
</details>

Fetch the owned objects directly from the Sui blockchain using useInfiniteQuery from TanStack Query with the useCurrentClient() hook from dApp Kit. The useCurrentClient() hook returns the configured Sui client, and you use its core.listOwnedObjects() method to fetch paginated owned objects. Supply the connected wallet account as the owner. The returned data is stored inside the cache at query key getOwnedObjects. In a future step you invalidate this cache after a mutation succeeds, so the data is re-fetched automatically.

<details> <summary>

Next, update src/routes/LockedDashboard.tsx to include the LockOwnedObjects component:

</summary>
tsx
import { Tabs } from '@radix-ui/themes';
import { useState } from 'react';

import { LockOwnedObjects } from '@/components/locked/LockOwnedObjects';

export function LockedDashboard() {
	const tabs = [
		{
			name: 'Lock Owned objects',
			component: () => <LockOwnedObjects />,
		},
	];

	const [tab, setTab] = useState(tabs[0].name);

	return (
		<Tabs.Root value={tab} onValueChange={setTab}>
			<Tabs.List>
				{tabs.map((tab, index) => {
					return (
						<Tabs.Trigger key={index} value={tab.name} className="cursor-pointer">
							{tab.name}
						</Tabs.Trigger>
					);
				})}
			</Tabs.List>
			{tabs.map((tab, index) => {
				return (
					<Tabs.Content key={index} value={tab.name}>
						{tab.component()}
					</Tabs.Content>
				);
			})}
		</Tabs.Root>
	);
}
</details>

:::checkpoint

Run your app and ensure you can:

  • View the owned objects of the connected wallet account.

If you do not see any objects, you might need to create some demo data or connect your wallet. You can mint objects after completing the next steps.

:::

Execute transaction hook {#execute-transaction-hook}

In the frontend, you might need to execute a transaction in multiple places. Extract the transaction execution logic and reuse it everywhere. Create and examine the execute transaction hook.

<details> <summary>

Create src/hooks/useTransactionExecution.ts:

</summary>

<ImportContent source="examples/trading/frontend/src/hooks/useTransactionExecution.ts" mode="code" />

</details>

A Transaction is the input. Sign it with the current connected wallet account, execute the transaction, return the execution result, and finally display a basic toast message to indicate whether the transaction is successful or not.

Use the useCurrentClient() hook from dApp Kit to retrieve the Sui client instance configured in src/main.tsx. The useSignTransaction() function is another hook from dApp kit that helps to sign the transaction using the currently connected wallet. It displays the UI for users to review and sign their transactions with their selected wallet. To execute a transaction, use the executeTransaction() on the client instance of the Sui TypeScript SDK.

Generate demo data

:::info

The full source code of the demo bear smart contract is available at Trading Contracts Demo directory

:::

You need a utility function to create a dummy object representing a real world asset so you can use it to test and demonstrate escrow users flow on the UI directly.

<details> <summary>

Create src/mutations/demo.ts:

</summary> <ImportContent source="examples/trading/frontend/src/mutations/demo.ts" mode="code" /> </details>

As previously mentioned, this example uses @tanstack/react-query to query, cache, and mutate server state. Server state is data only available on remote servers, and the only way to retrieve or update this data is by interacting with these remote servers. In this case, it could be from an API or directly from Sui blockchain RPC.

When you execute a transaction call to mutate data on the Sui blockchain, use the useMutation() hook. The useMutation() hook accepts several inputs. However, you only need 2 of them for this example. The first parameter, mutationFn, accepts the function to execute the main mutating logic, while the second parameter, onSuccess, is a callback that runs when the mutating logic succeeds.

The main mutating logic includes executing a Move call of a package named demo_bear::new to create a dummy bear object and transferring it to the connected wallet account, all within the same Transaction. This example reuses the executeTransaction() hook from the Execute Transaction Hook step to execute the transaction.

Another benefit of wrapping the main mutating logic inside useMutation() is that you can access and manipulate the cache storing server state. This example fetches the cache from remote servers by using query call in an appropriate callback. In this case, it is the onSuccess callback. When the transaction succeeds, invalidate the cache data at the cache key called getOwnedObjects, then @tanstack/react-query handles the re-fetching mechanism for the invalidated data automatically. Do this by using invalidateQueries() on the @tanstack/react-query configured client instance retrieved by useQueryClient() hook in the Set up Routing step.

Now the logic to create a dummy bear object exists. You just need to attach it into the button in the header.

<details> <summary>

Header.tsx

</summary> <ImportContent source="examples/trading/frontend/src/components/Header.tsx" mode="code" /> </details>

:::checkpoint

Run your app and ensure you can:

  • Mint a demo bear object and view it in the Manage Objects tab.

:::

Locking owned objects

To lock the object, execute the lock Move function identified by {PACKAGE_ID}::lock::lock. The implementation is similar to what's in previous mutation functions. Use useMutation() from @tanstack/react-query to wrap the main logic inside it. The lock function requires an object to be locked and its type because the smart contract lock function is generic and requires type parameters. After creating a Locked object and its Key object, transfer them to the connected wallet account within the same transaction.

Extract logic of locking owned objects into a separated mutating function to enhance discoverability and encapsulation.

<details> <summary>

Create src/mutations/locked.ts:

</summary>
tsx
import { useCurrentAccount } from '@mysten/dapp-kit-react';
import { SuiObjectData } from '@mysten/sui/jsonRpc';
import { Transaction } from '@mysten/sui/transactions';
import { useMutation } from '@tanstack/react-query';

import { CONSTANTS } from '@/constants';
import { useTransactionExecution } from '@/hooks/useTransactionExecution';

/**
 * Builds and executes the PTB to lock an object.
 */
export function useLockObjectMutation() {
	const account = useCurrentAccount();
	const executeTransaction = useTransactionExecution();

	return useMutation({
		mutationFn: async ({ object }: { object: SuiObjectData }) => {
			if (!account?.address) throw new Error('You need to connect your wallet!');
			const txb = new Transaction();

			const [locked, key] = txb.moveCall({
				target: `${CONSTANTS.escrowContract.packageId}::lock::lock`,
				arguments: [txb.object(object.objectId)],
				typeArguments: [object.type!],
			});

			txb.transferObjects([locked, key], txb.pure.address(account.address));

			return executeTransaction(txb);
		},
	});
}
</details>

Update src/components/locked/LockOwnedObjects.tsx to include the useLockObjectMutation hook:

<details> <summary>

LockOwnedObjects.tsx

</summary>

<ImportContent source="examples/trading/frontend/src/components/locked/LockOwnedObjects.tsx" mode="code" />

</details>

:::checkpoint

Run your app and ensure you can:

  • Lock an owned object.

The object should disappear from the list of owned objects. You view and unlock locked objects in later steps.

:::

Display owned locked objects

Take a look at the My Locked Objects tab by examining src/components/locked/OwnedLockedList.tsx. Focus on the logic on how to retrieve this list.

<details> <summary>

OwnedLockedList.tsx

</summary>

<ImportContent source="examples/trading/frontend/src/components/locked/OwnedLockedList.tsx" mode="code" />

</details>

This query pattern is similar to the one in the LockOwnedObjects component. The difference is that it fetches the locked objects instead of the owned objects. The Locked object is a struct type in the smart contract, so you need to supply the struct type to the query call as a filter. The struct type is usually identified by the format of {PACKAGE_ID}::{MODULE_NAME}::{STRUCT_TYPE}.

LockedObject and Locked component

The <LockedObject /> (src/components/locked/LockedObject.tsx) component is mainly responsible for mapping an on-chain SuiObjectData Locked object to its corresponding ApiLockedObject, which is finally delegated to the <Locked /> component for rendering. The <LockedObject /> fetches the locked item object ID if the prop itemId is not supplied by using TanStack Query's useQuery hook with the useCurrentClient() hook to call the getDynamicFieldObject RPC endpoint. Recall that in this smart contract, the locked item is put into a dynamic object field.

<details> <summary>

LockedObject.tsx

</summary>

<ImportContent source="examples/trading/frontend/src/components/locked/LockedObject.tsx" mode="code" />

</details>

The <Locked /> (src/components/locked/partials/Locked.tsx) component is mainly responsible for rendering the ApiLockedObject. Later on, it also consists of several on-chain interactions: unlock the locked objects and create an escrow out of the locked object.

<details> <summary>

Locked.tsx

</summary>
tsx
import { useCurrentAccount, useCurrentClient } from '@mysten/dapp-kit-react';
import { useQuery } from '@tanstack/react-query';

import { SuiObjectDisplay } from '@/components/SuiObjectDisplay';
import { ApiLockedObject } from '@/types/types';

import { ExplorerLink } from '../../ExplorerLink';

/**
 * Prefer to use the `Locked` component only through `LockedObject`.
 *
 * This can also render data directly from the API, but we prefer
 * to also validate ownership from on-chain state (as objects are transferable)
 * and the API cannot track all the ownership changes.
 */
export function Locked({
	locked,
	hideControls,
}: {
	locked: ApiLockedObject;
	hideControls?: boolean;
}) {
	const account = useCurrentAccount();
	const client = useCurrentClient();

	const suiObject = useQuery({
		queryKey: ['getObject', locked.itemId],
		queryFn: async () => {
			const { object } = await client.core.getObject({
				objectId: locked.itemId,
			});
			return object;
		},
	});

	const getLabel = () => {
		if (locked.deleted) return 'Deleted';
		if (hideControls) {
			if (locked.creator === account?.address) return 'You offer this';
			return "You'll receive this if accepted";
		}
		return undefined;
	};

	const getLabelClasses = () => {
		if (locked.deleted) return 'bg-red-50 rounded px-3 py-1 text-sm text-red-500';
		if (hideControls) {
			if (!!locked.creator && locked.creator === account?.address)
				return 'bg-blue-50 rounded px-3 py-1 text-sm text-blue-500';
			return 'bg-green-50 rounded px-3 py-1 text-sm text-green-700';
		}
		return undefined;
	};

	return (
		<SuiObjectDisplay object={suiObject.data!} label={getLabel()} labelClasses={getLabelClasses()}>
			<div className="p-4 pt-1 text-right flex flex-wrap items-center justify-between">
				{
					<p className="text-sm flex-shrink-0 flex items-center gap-2">
						<ExplorerLink id={locked.objectId} isAddress={false} />
					</p>
				}
			</div>
		</SuiObjectDisplay>
	);
}
</details>

Update src/routes/LockedDashboard.tsx to include the OwnedLockedList component:

<details> <summary>

LockedDashboard.tsx

</summary> <ImportContent source="examples/trading/frontend/src/routes/LockedDashboard.tsx" mode="code" /> </details>

:::checkpoint

Run your app and ensure you can:

  • View the locked objects of the connected wallet account.

:::

Unlocking owned objects

To unlock the object, execute the unlock Move function identified by {PACKAGE_ID}::lock::unlock. Call the unlock function supplying the Locked object, its corresponding Key, the struct type of the original object, and transfer the unlocked object to the current connected wallet account. Also, implement the onSuccess callback to invalidate the cache data at query key locked after one second to force react-query to re-fetch the data at corresponding query key automatically.

Unlocking owned objects is another crucial and complex on-chain action in this application. Extract its logic into separated mutating functions to enhance discoverability and encapsulation.

<details> <summary>

src/mutations/locked.ts

</summary> <ImportContent source="examples/trading/frontend/src/mutations/locked.ts" mode="code" /> </details>

Update src/components/locked/partials/Locked.tsx to include the useUnlockObjectMutation hook:

<details> <summary>

Locked.tsx

</summary>
tsx
import { useCurrentAccount, useCurrentClient } from '@mysten/dapp-kit-react';
import { ArrowDownIcon, ArrowUpIcon, LockOpen1Icon } from '@radix-ui/react-icons';
import { Button } from '@radix-ui/themes';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';

import { SuiObjectDisplay } from '@/components/SuiObjectDisplay';
import { useUnlockMutation } from '@/mutations/locked';
import { ApiLockedObject } from '@/types/types';

import { ExplorerLink } from '../../ExplorerLink';

/**
 * Prefer to use the `Locked` component only through `LockedObject`.
 *
 * This can also render data directly from the API, but we prefer
 * to also validate ownership from on-chain state (as objects are transferable)
 * and the API cannot track all the ownership changes.
 */
export function Locked({
	locked,
	hideControls,
}: {
	locked: ApiLockedObject;
	hideControls?: boolean;
}) {
	const [isToggled, setIsToggled] = useState(false);
	const account = useCurrentAccount();
	const client = useCurrentClient();
	const { mutate: unlockMutation, isPending } = useUnlockMutation();

	const suiObject = useQuery({
		queryKey: ['getObject', locked.itemId],
		queryFn: async () => {
			const { object } = await client.core.getObject({
				objectId: locked.itemId,
			});
			return object;
		},
	});

	const isOwner = () => {
		return !!locked.creator && account?.address === locked.creator;
	};

	const getLabel = () => {
		if (locked.deleted) return 'Deleted';
		if (hideControls) {
			if (locked.creator === account?.address) return 'You offer this';
			return "You'll receive this if accepted";
		}
		return undefined;
	};

	const getLabelClasses = () => {
		if (locked.deleted) return 'bg-red-50 rounded px-3 py-1 text-sm text-red-500';
		if (hideControls) {
			if (!!locked.creator && locked.creator === account?.address)
				return 'bg-blue-50 rounded px-3 py-1 text-sm text-blue-500';
			return 'bg-green-50 rounded px-3 py-1 text-sm text-green-700';
		}
		return undefined;
	};

	return (
		<SuiObjectDisplay object={suiObject.data!} label={getLabel()} labelClasses={getLabelClasses()}>
			<div className="p-4 pt-1 text-right flex flex-wrap items-center justify-between">
				{
					<p className="text-sm flex-shrink-0 flex items-center gap-2">
						<ExplorerLink id={locked.objectId} isAddress={false} />
					</p>
				}
				{!hideControls && isOwner() && (
					<Button
						className="ml-auto cursor-pointer"
						disabled={isPending}
						onClick={() => {
							unlockMutation({
								lockedId: locked.objectId,
								keyId: locked.keyId,
								suiObject: suiObject.data!,
							});
						}}
					>
						<LockOpen1Icon /> Unlock
					</Button>
				)}
			</div>
		</SuiObjectDisplay>
	);
}
</details>

:::checkpoint

Run your app and ensure you can:

  • Unlock a locked object.

:::

Display locked objects to escrow

Update src/routes/EscrowDashboard.tsx to include the LockedList component:

<details> <summary>

EscrowDashboard.tsx

</summary>
tsx
import { InfoCircledIcon } from '@radix-ui/react-icons';
import { Tabs, Tooltip } from '@radix-ui/themes';
import { useState } from 'react';

import { LockedList } from '../components/locked/ApiLockedList';

export function EscrowDashboard() {
	const tabs = [
		{
			name: 'Browse Locked Objects',
			component: () => (
				<LockedList
					params={{
						deleted: 'false',
					}}
					enableSearch
				/>
			),
			tooltip: 'Browse locked objects you can trade for.',
		},
	];

	const [tab, setTab] = useState(tabs[0].name);

	return (
		<Tabs.Root value={tab} onValueChange={setTab}>
			<Tabs.List>
				{tabs.map((tab, index) => {
					return (
						<Tabs.Trigger key={index} value={tab.name} className="cursor-pointer">
							{tab.name}
							<Tooltip content={tab.tooltip}>
								<InfoCircledIcon className="ml-3" />
							</Tooltip>
						</Tabs.Trigger>
					);
				})}
			</Tabs.List>
			{tabs.map((tab, index) => {
				return (
					<Tabs.Content key={index} value={tab.name}>
						{tab.component()}
					</Tabs.Content>
				);
			})}
		</Tabs.Root>
	);
}
</details>

Add src/components/locked/ApiLockedList.tsx:

<details> <summary>

ApiLockedList.tsx

</summary>

<ImportContent source="examples/trading/frontend/src/components/locked/ApiLockedList.tsx" mode="code" />

</details>

This hook fetches all the non-deleted system Locked objects from the API in a paginated fashion. Then, it proceeds into fetching the on-chain state, to ensure the latest state of the object is displayed.

<details> <summary>

Add src/hooks/useGetLockedObject.ts

</summary> <ImportContent source="examples/trading/frontend/src/hooks/useGetLockedObject.ts" mode="code" /> </details>

:::checkpoint

Run your app and ensure you can:

  • View the locked objects in the Browse Locked Objects tab in the Escrows page.

:::

Create escrows

To create escrows, include a mutating function through the useCreateEscrowMutation hook in src/mutations/escrow.ts. It accepts the escrowed item to be traded and the ApiLockedObject from another party as parameters. Then, call the {PACKAGE_ID}::shared::create Move function and provide the escrowed item, the key ID of the locked object to exchange, and the recipient of the escrow (locked object's owner).

<details> <summary>

escrow.ts

</summary>
tsx
import { useCurrentAccount } from '@mysten/dapp-kit-react';
import { SuiObjectData } from '@mysten/sui/jsonRpc';
import { Transaction } from '@mysten/sui/transactions';
import { useMutation } from '@tanstack/react-query';

import { CONSTANTS } from '@/constants';
import { useTransactionExecution } from '@/hooks/useTransactionExecution';
import { ApiLockedObject } from '@/types/types';

/**
 * Builds and executes the PTB to create an escrow.
 */
export function useCreateEscrowMutation() {
	const currentAccount = useCurrentAccount();
	const executeTransaction = useTransactionExecution();

	return useMutation({
		mutationFn: async ({ object, locked }: { object: SuiObjectData; locked: ApiLockedObject }) => {
			if (!currentAccount?.address) throw new Error('You need to connect your wallet!');

			const txb = new Transaction();
			txb.moveCall({
				target: `${CONSTANTS.escrowContract.packageId}::shared::create`,
				arguments: [
					txb.object(object.objectId!),
					txb.pure.id(locked.keyId),
					txb.pure.address(locked.creator!),
				],
				typeArguments: [object.type!],
			});

			return executeTransaction(txb);
		},
	});
}
</details> <details> <summary>

Update src/components/locked/partials/Locked.tsx to include the useCreateEscrowMutation hook

</summary>

<ImportContent source="examples/trading/frontend/src/components/locked/partials/Locked.tsx" mode="code" />

</details> <details> <summary>

Add src/components/escrows/CreateEscrow.tsx

</summary>

<ImportContent source="examples/trading/frontend/src/components/escrows/CreateEscrow.tsx" mode="code" />

</details>

:::checkpoint

Run your app and ensure you can:

  • Create an escrow.

The object should disappear from the list of locked objects in the Browse Locked Objects tab in the Escrows page. You view and accept or cancel escrows in later steps.

:::

Cancel escrows

To cancel the escrow, create a mutation through the useCancelEscrowMutation hook in src/mutations/escrow.ts. The cancel function accepts the escrow ApiEscrowObject and its on-chain data. The {PACKAGE_ID}::shared::return_to_sender Move call is generic, thus it requires the type parameters of the escrowed object. Next, execute {PACKAGE_ID}::shared::return_to_sender and transfer the returned escrowed object to the creator of the escrow.

<details> <summary>

escrow.ts

</summary>
tsx
import { useCurrentAccount } from '@mysten/dapp-kit-react';
import { SuiObjectData } from '@mysten/sui/jsonRpc';
import { Transaction } from '@mysten/sui/transactions';
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { CONSTANTS, QueryKey } from '@/constants';
import { useTransactionExecution } from '@/hooks/useTransactionExecution';
import { ApiEscrowObject, ApiLockedObject } from '@/types/types';

/**
 * Builds and executes the PTB to create an escrow.
 */
export function useCreateEscrowMutation() {
	const currentAccount = useCurrentAccount();
	const executeTransaction = useTransactionExecution();

	return useMutation({
		mutationFn: async ({ object, locked }: { object: SuiObjectData; locked: ApiLockedObject }) => {
			if (!currentAccount?.address) throw new Error('You need to connect your wallet!');

			const txb = new Transaction();
			txb.moveCall({
				target: `${CONSTANTS.escrowContract.packageId}::shared::create`,
				arguments: [
					txb.object(object.objectId!),
					txb.pure.id(locked.keyId),
					txb.pure.address(locked.creator!),
				],
				typeArguments: [object.type!],
			});

			return executeTransaction(txb);
		},
	});
}

/**
 * Builds and executes the PTB to cancel an escrow.
 */
export function useCancelEscrowMutation() {
	const currentAccount = useCurrentAccount();
	const executeTransaction = useTransactionExecution();
	const queryClient = useQueryClient();

	return useMutation({
		mutationFn: async ({
			escrow,
			suiObject,
		}: {
			escrow: ApiEscrowObject;
			suiObject: SuiObjectData;
		}) => {
			if (!currentAccount?.address) throw new Error('You need to connect your wallet!');
			const txb = new Transaction();

			const item = txb.moveCall({
				target: `${CONSTANTS.escrowContract.packageId}::shared::return_to_sender`,
				arguments: [txb.object(escrow.objectId)],
				typeArguments: [suiObject?.type!],
			});

			txb.transferObjects([item], txb.pure.address(currentAccount?.address!));

			return executeTransaction(txb);
		},

		onSuccess: () => {
			setTimeout(() => {
				queryClient.invalidateQueries({ queryKey: [QueryKey.Escrow] });
			}, 1_000);
		},
	});
}
</details> <details> <summary>

Add src/components/escrows/Escrow.tsx

</summary>
tsx
import { useCurrentAccount, useCurrentClient } from '@mysten/dapp-kit-react';
import { ArrowDownIcon, ArrowUpIcon, Cross1Icon } from '@radix-ui/react-icons';
import { Button } from '@radix-ui/themes';
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';

import { SuiObjectDisplay } from '@/components/SuiObjectDisplay';
import { CONSTANTS, QueryKey } from '@/constants';
import { useGetLockedObject } from '@/hooks/useGetLockedObject';
import { useCancelEscrowMutation } from '@/mutations/escrow';
import { ApiEscrowObject } from '@/types/types';

import { ExplorerLink } from '../ExplorerLink';
import { LockedObject } from '../locked/LockedObject';

/**
 * A component that displays an escrow and allows the user to accept or cancel it.
 * Accepts an `escrow` object as returned from the API.
 */
export function Escrow({ escrow }: { escrow: ApiEscrowObject }) {
	const account = useCurrentAccount();
	const client = useCurrentClient();
	const [isToggled, setIsToggled] = useState(true);
	const { mutate: cancelEscrowMutation, isPending: pendingCancellation } =
		useCancelEscrowMutation();

	const suiObject = useQuery({
		queryKey: ['getObject', escrow?.itemId],
		queryFn: async () => {
			const { object } = await client.core.getObject({
				objectId: escrow?.itemId,
			});
			return object;
		},
	});

	const lockedData = useQuery({
		queryKey: [QueryKey.Locked, escrow.keyId],
		queryFn: async () => {
			const res = await fetch(`${CONSTANTS.apiEndpoint}locked?keyId=${escrow.keyId}`);
			return res.json();
		},
		select: (data) => data.data[0],
		enabled: !escrow.cancelled,
	});

	const { data: suiLockedObject } = useGetLockedObject({
		lockedId: lockedData.data?.objectId,
	});

	const getLabel = () => {
		if (escrow.cancelled) return 'Cancelled';
		if (escrow.swapped) return 'Swapped';
		if (escrow.sender === account?.address) return 'You offer this';
		if (escrow.recipient === account?.address) return "You'll receive this";
		return undefined;
	};
	const getLabelClasses = () => {
		if (escrow.cancelled) return 'text-red-500';
		if (escrow.swapped) return 'text-green-500';
		if (escrow.sender === account?.address)
			return 'bg-blue-50 rounded px-3 py-1 text-sm text-blue-500';
		if (escrow.recipient === account?.address)
			return 'bg-green-50 rounded px-3 py-1 text-sm text-green-700';
		return undefined;
	};

	return (
		<SuiObjectDisplay object={suiObject.data!} label={getLabel()} labelClasses={getLabelClasses()}>
			<div className="p-4 flex gap-3 flex-wrap">
				{
					<p className="text-sm flex-shrink-0 flex items-center gap-2">
						<ExplorerLink id={escrow.objectId} isAddress={false} />
					</p>
				}
				<Button
					className="ml-auto cursor-pointer bg-transparent text-black"
					onClick={() => setIsToggled(!isToggled)}
				>
					Details
					{isToggled ? <ArrowUpIcon /> : <ArrowDownIcon />}
				</Button>
				{!escrow.cancelled && !escrow.swapped && escrow.sender === account?.address && (
					<Button
						color="amber"
						className="cursor-pointer"
						disabled={pendingCancellation}
						onClick={() =>
							cancelEscrowMutation({
								escrow,
								suiObject: suiObject.data!,
							})
						}
					>
						<Cross1Icon />
						Cancel request
					</Button>
				)}
				{isToggled && lockedData.data && (
					<div className="min-w-[340px] w-full justify-self-start text-left">
						{suiLockedObject?.data && (
							<LockedObject
								object={suiLockedObject.data}
								itemId={lockedData.data.itemId}
								hideControls
							/>
						)}

						{!lockedData.data.deleted && escrow.recipient === account?.address && (
							<div className="text-right mt-5">
								<p className="text-xs pb-3">
									When accepting the exchange, the escrowed item is transferred to you and your
									locked item is transferred to the sender.
								</p>
							</div>
						)}
						{lockedData.data.deleted &&
							!escrow.swapped &&
							escrow.recipient === account?.address && (
								<div>
									<p className="text-red-500 text-sm py-2 flex items-center gap-3">
										<Cross1Icon />
										The locked object has been deleted so you cannot accept this anymore.
									</p>
								</div>
							)}
					</div>
				)}
			</div>
		</SuiObjectDisplay>
	);
}
</details> <details> <summary>

Add src/components/escrows/EscrowList.tsx

</summary>

<ImportContent source="examples/trading/frontend/src/components/escrows/EscrowList.tsx" mode="code" />

</details> <details> <summary>

Update src/routes/EscrowDashboard.tsx to include the EscrowList component

</summary>
tsx
import { useCurrentAccount } from '@mysten/dapp-kit-react';
import { InfoCircledIcon } from '@radix-ui/react-icons';
import { Tabs, Tooltip } from '@radix-ui/themes';
import { useState } from 'react';

import { EscrowList } from '../components/escrows/EscrowList';
import { LockedList } from '../components/locked/ApiLockedList';

export function EscrowDashboard() {
	const account = useCurrentAccount();
	const tabs = [
		{
			name: 'Browse Locked Objects',
			component: () => (
				<LockedList
					params={{
						deleted: 'false',
					}}
					enableSearch
				/>
			),
			tooltip: 'Browse locked objects you can trade for.',
		},
		{
			name: 'My Pending Requests',
			component: () => (
				<EscrowList
					params={{
						sender: account?.address,
						swapped: 'false',
						cancelled: 'false',
					}}
					enableSearch
				/>
			),
			tooltip: 'Escrows you have initiated for third party locked objects.',
		},
	];

	const [tab, setTab] = useState(tabs[0].name);

	return (
		<Tabs.Root value={tab} onValueChange={setTab}>
			<Tabs.List>
				{tabs.map((tab, index) => {
					return (
						<Tabs.Trigger key={index} value={tab.name} className="cursor-pointer">
							{tab.name}
							<Tooltip content={tab.tooltip}>
								<InfoCircledIcon className="ml-3" />
							</Tooltip>
						</Tabs.Trigger>
					);
				})}
			</Tabs.List>
			{tabs.map((tab, index) => {
				return (
					<Tabs.Content key={index} value={tab.name}>
						{tab.component()}
					</Tabs.Content>
				);
			})}
		</Tabs.Root>
	);
}
</details>

:::checkpoint

Run your app and ensure you can:

  • View the escrows in the My Pending Requests tab in the Escrows page.
  • Cancel an escrow that you requested.

:::

Accept escrows

To accept the escrow, create a mutation through the useAcceptEscrowMutation hook in src/mutations/escrow.ts. The implementation should be fairly familiar to you now. The accept function accepts the escrow ApiEscrowObject and the locked object ApiLockedObject. The {PACKAGE_ID}::shared::swap Move call is generic, thus it requires the type parameters of the escrowed and locked objects. Query the objects details by using multiGetObjects on Sui client instance. Lastly, execute the {PACKAGE_ID}::shared::swap Move call and transfer the returned escrowed item to the connected wallet account. When the mutation succeeds, invalidate the cache to allow automatic re-fetch of the data.

<details> <summary>

escrow.ts

</summary>
tsx
import { useCurrentAccount, useCurrentClient } from '@mysten/dapp-kit-react';
import { SuiObjectData } from '@mysten/sui/jsonRpc';
import { Transaction } from '@mysten/sui/transactions';
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { CONSTANTS, QueryKey } from '@/constants';
import { useTransactionExecution } from '@/hooks/useTransactionExecution';
import { ApiEscrowObject, ApiLockedObject } from '@/types/types';

/**
 * Builds and executes the PTB to create an escrow.
 */
export function useCreateEscrowMutation() {
	const currentAccount = useCurrentAccount();
	const executeTransaction = useTransactionExecution();

	return useMutation({
		mutationFn: async ({ object, locked }: { object: SuiObjectData; locked: ApiLockedObject }) => {
			if (!currentAccount?.address) throw new Error('You need to connect your wallet!');

			const txb = new Transaction();
			txb.moveCall({
				target: `${CONSTANTS.escrowContract.packageId}::shared::create`,
				arguments: [
					txb.object(object.objectId!),
					txb.pure.id(locked.keyId),
					txb.pure.address(locked.creator!),
				],
				typeArguments: [object.type!],
			});

			return executeTransaction(txb);
		},
	});
}

/**
 * Builds and executes the PTB to cancel an escrow.
 */
export function useCancelEscrowMutation() {
	const currentAccount = useCurrentAccount();
	const executeTransaction = useTransactionExecution();
	const queryClient = useQueryClient();

	return useMutation({
		mutationFn: async ({
			escrow,
			suiObject,
		}: {
			escrow: ApiEscrowObject;
			suiObject: SuiObjectData;
		}) => {
			if (!currentAccount?.address) throw new Error('You need to connect your wallet!');
			const txb = new Transaction();

			const item = txb.moveCall({
				target: `${CONSTANTS.escrowContract.packageId}::shared::return_to_sender`,
				arguments: [txb.object(escrow.objectId)],
				typeArguments: [suiObject?.type!],
			});

			txb.transferObjects([item], txb.pure.address(currentAccount?.address!));

			return executeTransaction(txb);
		},

		onSuccess: () => {
			setTimeout(() => {
				queryClient.invalidateQueries({ queryKey: [QueryKey.Escrow] });
			}, 1_000);
		},
	});
}

/**
 * Builds and executes the PTB to accept an escrow.
 */
export function useAcceptEscrowMutation() {
	const currentAccount = useCurrentAccount();
	const client = useCurrentClient();
	const executeTransaction = useTransactionExecution();
	const queryClient = useQueryClient();

	return useMutation({
		mutationFn: async ({
			escrow,
			locked,
		}: {
			escrow: ApiEscrowObject;
			locked: ApiLockedObject;
		}) => {
			if (!currentAccount?.address) throw new Error('You need to connect your wallet!');
			const txb = new Transaction();

			const { objects } = await client.core.getObjects({
				objectIds: [escrow.itemId, locked.itemId],
			});

			const escrowType = objects.find(
				(x) => !(x instanceof Error) && x.objectId === escrow.itemId,
			)?.type;

			const lockedType = objects.find(
				(x) => !(x instanceof Error) && x.objectId === locked.itemId,
			)?.type;

			if (!escrowType || !lockedType) {
				throw new Error('Failed to fetch types.');
			}

			const item = txb.moveCall({
				target: `${CONSTANTS.escrowContract.packageId}::shared::swap`,
				arguments: [
					txb.object(escrow.objectId),
					txb.object(escrow.keyId),
					txb.object(locked.objectId),
				],
				typeArguments: [escrowType, lockedType],
			});

			txb.transferObjects([item], txb.pure.address(currentAccount.address));

			return executeTransaction(txb);
		},

		onSuccess: () => {
			setTimeout(() => {
				queryClient.invalidateQueries({ queryKey: [QueryKey.Escrow] });
			}, 1_000);
		},
	});
}
</details> <details> <summary>

Update src/components/escrows/Escrow.tsx to include the useAcceptEscrowMutation hook

</summary> <ImportContent source="examples/trading/frontend/src/components/escrows/Escrow.tsx" mode="code" /> </details> <details> <summary>

Update src/routes/EscrowDashboard.tsx to include the EscrowList component

</summary> <ImportContent source="examples/trading/frontend/src/routes/EscrowDashboard.tsx" mode="code" /> </details>

:::checkpoint

Run your app and ensure you can:

  • Accept an escrow that someone else requested.

:::

Finished frontend

At this point, you have a fully functional frontend that allows users to discover trades and interact with listed escrows. The UI is designed to be user-friendly and intuitive, allowing users to easily navigate and interact with the application. Have fun exploring the app and testing out the different features!