docs/content/guides/developer/app-examples/trustless-swap.mdx
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:
The guide also shows how to build an app that:
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
sui --version in your terminal or console, it responds with the currently installed version.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.sui client balance in your terminal or console. If there is no balance, acquire SUI from the faucet (not available in Mainnet).:::
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.tomlTo 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 KeyWith 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
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 />
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.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:
lock function in lock.move
<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:
unlock function in lock.move
<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
:::
Locked and KeyMove'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:
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 />
#[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].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
Move.toml)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.
:::
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.
shared.move
<ImportContent source="examples/trading/contracts/escrow/sources/shared.move" mode="code" noComments />
</details>Trading proceeds in three steps:
lock module you wrote earlier.escrow.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).
create function in shared.move
<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.
sender, because Escrow objects are shared and anybody can access them.Escrow by value so that it can clean it up after extracting the escrowed object, reclaiming the storage rebate for the sender and cleaning up an unused object on chain.return_to_sender function in shared.move
<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.
Escrow by value because it consumes it after the swap is complete.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.unlock further checks that the key matches the locked object that was provided.swap function. You can do this because you checked that the transaction sender is the recipient, and it makes this API more composable. Programmable transaction blocks (PTBs) provide the flexibility to decide whether to transfer the object as it is received or do something else with it.swap function in shared.move
<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
:::
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
:::
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
<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
:::
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.
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
The core of the indexer is an event loop, initialized in a function called setupListeners.
The indexer queries events related to the escrow module, using a queryEvent filter, and keeps track of a cursor representing the latest event it has processed so it can resume indexing from the right place even if it is restarted. The filter is looking for any events whose type is from the escrow module of the Move package (see the event-indexer.ts code that follows).
The core event job works by polling. It queries RPC for events following its latest cursor and sends them to a callback for processing. If it detects more than one page of new events, it immediately requests the next page. Otherwise, the job waits for the next polling interval before checking again.
<details> <summary>event-indexer.ts
The callback is responsible for reading the event and updating the database accordingly. For demo purposes, SQLite is being used, and so you need to issue a separate UPSERT to the database for each escrowed object. In a production setting, however, you would want to batch requests to the database to optimize data flow.
escrow-handler.ts
:::tip Additional resources
:::
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.
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.
parseWhereStatement in api-queries.ts
<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>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.
parsePaginationForQuery in api-queries.ts
<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>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
Now that you have an indexer and an API service, you can deploy your move package and start the indexer and API service.
Install dependencies by running pnpm install --ignore-workspace or yarn install --ignore-workspace.
Setup the database by running pnpm db:setup:dev or yarn db:setup:dev.
Deploy the Sui package
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:
$ 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:
$ sui client new-env --alias testnet --rpc https://fullnode.testnet.sui.io:443
Run the following command to activate the testnet environment:
$ 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:
$ 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:
api folder:$ 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.
$ npx ts-node helpers/create-demo-data.ts
$ 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.
Run both the API and the indexer by running pnpm dev or yarn dev.
Visit http://localhost:3000/escrows or http://localhost:3000/locked
:::checkpoint
You should now have an indexer running.
localhost:3000, you get a message that the service is running: {"message":"🚀 API is functional 🚀"}.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.:::
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.
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">Complete the smart contracts and understand their design.
Implement the backend to learn how to index on-chain data and expose it through an API.
Deploy your smart contracts and started the backend indexer.
:::tip Additional resources
@mysten/dapp. This is used within this project to quickly scaffold a React-based Sui app.:::
The UI design consists of three parts:
Manage Objects.Escrows.The first step is to set up the client app. Run the following command to scaffold a new app from your frontend folder.
$ pnpm create @mysten/dapp --template react-client-dapp
$ yarn create @mysten/dapp --template react-client-dapp
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.
First, set up import aliases to make the code more readable and maintainable. This allows you to import files using @/ instead of relative paths.
Replace the content of tsconfig.json with the following:
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.
Replace the content of vite.config.ts with the following:
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.
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">$ pnpm add tailwindcss@latest postcss@latest autoprefixer@latest
$ yarn add tailwindcss@latest postcss@latest autoprefixer@latest
Next, generate the Tailwind CSS configuration file by running the following:
$ npx tailwindcss init -p
Replace the content of tailwind.config.js with the following:
Add the src/styles/ directory and add base.css:
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:
:::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.
:::
Create a src/utils/ directory and add the following file:
Create a src/components/ directory and add the following components:
ExplorerLink.tsx
InfiniteScrollArea.tsx
<ImportContent source="examples/trading/frontend/src/components/InfiniteScrollArea.tsx" mode="code" />
</details> <details> <summary>Loading.tsx
SuiObjectDisplay.tsx
Install the necessary dependencies:
<Tabs groupId="packagemanager"> <TabItem label="PNPM" value="pnpm">$ pnpm add react-hot-toast
$ yarn add react-hot-toast
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">$ pnpm add react-router-dom
$ yarn add react-router-dom
Then, create a src/routes/ directory and add index.tsx. This file contains the routing configuration:
Add the following respective files to the src/routes/ directory:
root.tsx. This file contains the root component that is rendered on every page:
LockedDashboard.tsx. This file contains the component for the Manage Objects page.
export function LockedDashboard() {
return (
<div>
<h1>Locked Dashboard</h1>
</div>
);
}
EscrowDashboard.tsx. This file contains the component for the Escrows page.
export function EscrowDashboard() {
return (
<div>
<h1>Escrow Dashboard</h1>
</div>
);
}
Update src/main.tsx by replacing the App component with the RouterProvider and replace "dark" with "light" in the Theme component:
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:
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>
);
}
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:
Manage Objects and Escrows pages.The styles should be applied. The Header component should look like this:
:::
All the type definitions are in src/types/types.ts. Create this file and add the following:
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.
Now, display the objects owned by the connected wallet address. This is the Manage Objects page.
First add this file src/components/locked/LockOwnedObjects.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>
);
}
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.
Next, update src/routes/LockedDashboard.tsx to include the LockOwnedObjects component:
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>
);
}
:::checkpoint
Run your app and ensure you can:
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.
:::
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:
<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.
:::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:
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
:::checkpoint
Run your app and ensure you can:
Manage Objects tab.:::
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:
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);
},
});
}
Update src/components/locked/LockOwnedObjects.tsx to include the useLockObjectMutation hook:
LockOwnedObjects.tsx
<ImportContent source="examples/trading/frontend/src/components/locked/LockOwnedObjects.tsx" mode="code" />
</details>:::checkpoint
Run your app and ensure you can:
The object should disappear from the list of owned objects. You view and unlock locked objects in later steps.
:::
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.
OwnedLockedList.tsx
<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 componentThe <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.
LockedObject.tsx
<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.
Locked.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>
);
}
Update src/routes/LockedDashboard.tsx to include the OwnedLockedList component:
LockedDashboard.tsx
:::checkpoint
Run your app and ensure you can:
:::
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
Update src/components/locked/partials/Locked.tsx to include the useUnlockObjectMutation hook:
Locked.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>
);
}
:::checkpoint
Run your app and ensure you can:
:::
Update src/routes/EscrowDashboard.tsx to include the LockedList component:
EscrowDashboard.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>
);
}
Add src/components/locked/ApiLockedList.tsx:
ApiLockedList.tsx
<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.
Add src/hooks/useGetLockedObject.ts
:::checkpoint
Run your app and ensure you can:
Browse Locked Objects tab in the Escrows page.:::
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).
escrow.ts
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);
},
});
}
Update src/components/locked/partials/Locked.tsx to include the useCreateEscrowMutation hook
<ImportContent source="examples/trading/frontend/src/components/locked/partials/Locked.tsx" mode="code" />
</details> <details> <summary>Add src/components/escrows/CreateEscrow.tsx
<ImportContent source="examples/trading/frontend/src/components/escrows/CreateEscrow.tsx" mode="code" />
</details>:::checkpoint
Run your app and ensure you can:
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.
:::
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.
escrow.ts
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);
},
});
}
Add src/components/escrows/Escrow.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>
);
}
Add src/components/escrows/EscrowList.tsx
<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
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>
);
}
:::checkpoint
Run your app and ensure you can:
My Pending Requests tab in the Escrows page.:::
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.
escrow.ts
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);
},
});
}
Update src/components/escrows/Escrow.tsx to include the useAcceptEscrowMutation hook
Update src/routes/EscrowDashboard.tsx to include the EscrowList component
:::checkpoint
Run your app and ensure you can:
:::
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!