From 06749b1d2f8bd1d33899bbed0ff33a1fcd916db8 Mon Sep 17 00:00:00 2001 From: Will Riches Date: Fri, 23 Feb 2024 17:31:59 +0000 Subject: [PATCH] Add Escrow app example to docs (#15812) ## Description Added the smart contract component to the Esrow app example --------- Co-authored-by: Daniel Lam Co-authored-by: Ronny Roland --- .../content/guides/developer/app-examples.mdx | 1 + .../guides/developer/app-examples/escrow.mdx | 6 - .../guides/developer/app-examples/trading.mdx | 29 + .../app-examples/trading/backend.mdx | 160 ++++ .../app-examples/trading/frontend.mdx | 817 ++++++++++++++++++ .../app-examples/trading/indexer-api.mdx | 632 ++++++++++++++ docs/content/sidebars/guides.js | 14 +- .../snippets/app-examples-trading-source.mdx | 5 + .../snippets/publish-to-devnet-with-coins.mdx | 4 +- 9 files changed, 1659 insertions(+), 9 deletions(-) delete mode 100644 docs/content/guides/developer/app-examples/escrow.mdx create mode 100644 docs/content/guides/developer/app-examples/trading.mdx create mode 100644 docs/content/guides/developer/app-examples/trading/backend.mdx create mode 100644 docs/content/guides/developer/app-examples/trading/frontend.mdx create mode 100644 docs/content/guides/developer/app-examples/trading/indexer-api.mdx create mode 100644 docs/content/snippets/app-examples-trading-source.mdx diff --git a/docs/content/guides/developer/app-examples.mdx b/docs/content/guides/developer/app-examples.mdx index 3d498f94f64f4..793464b821039 100644 --- a/docs/content/guides/developer/app-examples.mdx +++ b/docs/content/guides/developer/app-examples.mdx @@ -11,6 +11,7 @@ The ever-growing number of examples in this section showcase packages for the Su Sui is dedicated to providing a wide range of examples to guide you in proper programming techniques for the Sui blockchain. This list will continue to grow, so check back often. +- [Trading](./app-examples/trading.mdx): This example demonstrates trading objects on the Sui blockchain using a shared object as an escrow account. - [Blackjack](./app-examples/blackjack.mdx): This example demonstrates the logic behind an on-chain version of the popular casino card game, Blackjack. - [Coin Flip](./app-examples/coin-flip.mdx): The Coin Flip app demonstrates on-chain randomness. - [Distributed Counter](./app-examples/e2e-counter.mdx): An end-to-end example that creates a basic decentralized counter that anyone can increment, but only the object owner can reset it. The example includes Move code to create the package and leverages the Sui TypeScript SDK to provide a basic frontend. diff --git a/docs/content/guides/developer/app-examples/escrow.mdx b/docs/content/guides/developer/app-examples/escrow.mdx deleted file mode 100644 index 7b51c0406a634..0000000000000 --- a/docs/content/guides/developer/app-examples/escrow.mdx +++ /dev/null @@ -1,6 +0,0 @@ ---- -title: Escrow -draft: true ---- - -Content coming soon \ No newline at end of file diff --git a/docs/content/guides/developer/app-examples/trading.mdx b/docs/content/guides/developer/app-examples/trading.mdx new file mode 100644 index 0000000000000..10d4d710b8d42 --- /dev/null +++ b/docs/content/guides/developer/app-examples/trading.mdx @@ -0,0 +1,29 @@ +--- +title: Trading +hide_table_of_contents: true +--- + +Escrow refers to a legal concept where a third party holds and regulates payment of the funds required for two parties involved in a given transaction. It helps make transactions more secure by keeping the payment in a secure escrow account that is released only when all of the terms of an agreement are met as overseen by the party acting as the escrow authority. + +This guide and example demonstrate an atomic swap on Sui, which is similar to an escrow but does not require a trusted third party. Instead, this example uses a [shared object](../../../concepts/object-ownership/shared.mdx) 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. + +:::info + +See [Shared versus Owned Objects](../sui-101/shared-owned.mdx) for more information on the differences between object types. + +::: + +The guide is split into three parts: + +1. [Backend](./trading/backend.mdx): The modules that hold the state and perform the swaps. +1. [Indexing and API Service](./trading/indexer-api.mdx): A service that indexes chain state to discover trades, and an API service to read this data. +1. [Frontend](./trading/frontend.mdx): Enables users to list objects for sale and to accept trades. + +{@include: ../../../snippets/app-examples-trading-source.mdx} + +## Prerequisites + +Before getting started, make sure you have: + +- [Installed the latest version of Sui](../getting-started/sui-install.mdx). +- [Configured a valid network environment](../../../references/cli/client.mdx#set-current-environment), as you will be deploying the module on Testnet. diff --git a/docs/content/guides/developer/app-examples/trading/backend.mdx b/docs/content/guides/developer/app-examples/trading/backend.mdx new file mode 100644 index 0000000000000..91b983d007070 --- /dev/null +++ b/docs/content/guides/developer/app-examples/trading/backend.mdx @@ -0,0 +1,160 @@ +--- +title: Trading Backend +sidebar_label: Backend +--- + +:::note Multi-Page Guide + +This is the first in a [three-part guide](../trading.mdx) on how to build a trustless atomic swap on Sui. + +::: + +This particular protocol consists of three phases: + +1. One party `lock`s their object, obtaining a `Locked` object and its `Key`. This party can `unlock` their object to preserve liveness if the other party stalls before completing the second stage. +2. The other party registers a publicly accessible, shared `Escrow` object. This effectively locks their object at a particular version as well, waiting for the first party to complete the swap. The second party is able to request their object is returned to them, to preserve liveness as well. +3. The first party sends their locked object and its key to the shared `Escrow` object. This completes the swap, as long as all conditions are met: The sender of the swap transaction is the recipient of the `Escrow`, the key of the desired object (`exchange_key`) in the escrow matches the key supplied in the swap, and the key supplied in the swap unlocks the `Locked`. + +Let's create a Sui project using the terminal command `sui move new escrow`. Create a file in the sources directory named `shared.move`, and let's go through the code together. + +## shared.move + +{@include: ../../../../snippets/app-examples-trading-source.mdx} + +Let's go through the code line by line: + +### Structs + +#### `Escrow` + +This struct represents an object held in escrow. It contains the following fields: + +- `id`: Unique identifier for the escrow object. +- `sender`: Address of the owner of the `escrowed` object. +- `recipient`: Intended recipient of the `escrowed` object. +- `exchange_key`: ID of the key that opens the lock on the object sender wants from the recipient. +- `escrowed`: The actual object held in escrow. + +```move +struct Escrow has key, store { + id: UID, + sender: address, + recipient: address, + exchange_key: ID, + escrowed: T, +} +``` + +The ID of the key is used as the `exchange_key`, rather than the ID of the object, to ensure that the object is not modified after a trade has been initiated. + +### Error codes + +Two constants are defined to represent potential errors during the execution of the swap: + +- `EMismatchedSenderRecipient`: The `sender` and `recipient` of the two escrowed objects do not match. +- `EMismatchedExchangeObject`: The `exchange_for` fields of the two escrowed objects do not match. + +```move +const EMismatchedSenderRecipient: u64 = 0; +const EMismatchedExchangeObject: u64 = 1; +``` + +### Public functions + +#### `create` + +This function is used to create a new escrow object. It takes four arguments: the object to be escrowed, the ID of the key that opens the lock on the object the sender wants from the recipient, the intended recipient, and the transaction context. + +```move +public fun create( + escrowed: T, + exchange_key: ID, + recipient: address, + ctx: &mut TxContext +) { + let escrow = Escrow { + id: object::new(ctx), + sender: tx_context::sender(ctx), + recipient, + exchange_key, + escrowed, + }; + + transfer::public_share_object(escrow); +} +``` + +#### `swap` + +This function is used to perform the swap operation. It takes four arguments: the escrow object, the key, the locked object, and the transaction context. + +```move +public fun swap( + escrow: Escrow, + key: Key, + locked: Locked, + ctx: &TxContext, +): T { + let Escrow { + id, + sender, + recipient, + exchange_key, + escrowed, + } = escrow; + + assert!(recipient == tx_context::sender(ctx), EMismatchedSenderRecipient); + assert!(exchange_key == object::id(&key), EMismatchedExchangeObject); + + // Do the actual swap + transfer::public_transfer(lock::unlock(locked, key), sender); + object::delete(id); + + escrowed +} +``` + +The `object::delete` function call is used to delete the shared `Escrow` object. Previously, Move supported only the deletion of owned objects, but [shared-object deletion has since been enabled](https://github.com/MystenLabs/sui/pull/16008). + +#### `return_to_sender` + +This function is used to cancel the escrow and return the escrowed item to the sender. It takes two arguments: the escrow object and the transaction context. + +```move +public fun return_to_sender( + escrow: Escrow, + ctx: &TxContext +): T { + let Escrow { + id, + sender, + recipient: _, + exchange_key: _, + escrowed, + } = escrow; + + assert!(sender == tx_context::sender(ctx), EMismatchedSenderRecipient); + object::delete(id); + escrowed +} +``` + +Once again, the shared `Escrow` object is deleted after the escrowed item is returned to the sender. + +### Tests + +The code includes several tests to ensure the correct functioning of the atomic swap process. These tests cover successful swaps, mismatches in sender or recipient, mismatches in the exchange object, tampering with the object, and returning the object to the sender. + +In conclusion, this code provides a robust and secure way to perform atomic swaps of objects in a decentralized system, without the need for a trusted third party. It uses shared objects and a series of checks to ensure that the swap only occurs if all conditions are met. + +## Deployment + +{@include: ../../../../snippets/initialize-sui-client-cli.mdx} + +{@include: ../../../../snippets/publish-to-devnet-with-coins.mdx} + +## Next steps + +You have written and deployed the Move package. To turn this into a complete dApp with frontend, you need to create a frontend. For the frontend to be updated, create an indexer that listens to the blockchain as escrows are made and swaps are fulfilled. + +For the next step, you [create the indexing service](./indexer-api.mdx). \ No newline at end of file diff --git a/docs/content/guides/developer/app-examples/trading/frontend.mdx b/docs/content/guides/developer/app-examples/trading/frontend.mdx new file mode 100644 index 0000000000000..a588450ff3fd6 --- /dev/null +++ b/docs/content/guides/developer/app-examples/trading/frontend.mdx @@ -0,0 +1,817 @@ +--- +title: Trading Frontend +sidebar_label: Frontend +toc_max_heading_level: 2 +--- + +:::note Multi-Page Guide + +This is the third in a [three-part guide](../trading.mdx) on how to build a trustless atomic swap on Sui. + +::: + +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. + +## Prerequisites + +{@include: ../../../../snippets/app-examples-trading-source.mdx} + +Before getting started, make sure you: + +- Understand [the mechanism behind the Escrow smart contract backend](../trading.mdx). +- Check out [indexing service guide](./indexer-api.mdx) to learn how we index on-chain data and API endpoints exposed to serve data query requests. +- Install [`pnpm` through this guide](https://pnpm.io/installation) as we will use it as our package manager. +- Check out [Sui Typescript SDK](https://sdk.mystenlabs.com/typescript) for basic usage on how to interact with Sui with Typescript. +- Check out [Sui dApp Kit](https://sdk.mystenlabs.com/dapp-kit) to learn basic building blocks for developing a dApp in the Sui ecosystem with React.js. +- Check out [React Router](https://reactrouter.com/en/main) as we use it to navigate between different routes in our UI website. +- `dApp Kit` provides a set of hooks for making query and mutation calls to Sui blockchain. These hooks are thin wrappers around query and mutation hooks from `@tanstack/react-query`. Please check out [@tanstack/react-query](https://tanstack.com/query/latest/docs/framework/react/overview) to learn the basic usage for managing, caching, mutating server state. +- This project is bootstrapped through `pnpm create @mysten/dapp`. Please check out [@mysten/create-dapp](https://sdk.mystenlabs.com/dapp-kit/create-dapp) for how to scaffold a React.js Sui dApp project quickly. + +## 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`. + +:::warning + +The following code snippets are not the full source code. The snippets are meant to focus on relevant logic important to the functionality of the example and features of Sui. + +::: + +## Set up providers {#set-up-providers} + +Set up and configure several providers at the root of your React.js tree to ensure different libraries including `dApp Kit`, `@tanstack/react-query`, `react-router-dom` work as expected. + +```ts title='src/main.tsx' +import { createNetworkConfig, SuiClientProvider, WalletProvider } from '@mysten/dapp-kit'; +import { getFullnodeUrl } from '@mysten/sui.js/client'; +import { Theme } from '@radix-ui/themes'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider } from 'react-router-dom'; + +import { router } from '@/routes/index.tsx'; + +const queryClient = new QueryClient(); + +const { networkConfig } = createNetworkConfig({ + localnet: { url: getFullnodeUrl('localnet') }, + devnet: { url: getFullnodeUrl('devnet') }, + testnet: { url: getFullnodeUrl('testnet') }, + mainnet: { url: getFullnodeUrl('mainnet') }, +}); + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + + + + + + + , +); +``` + +## Connect wallet + +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 don't need to worry about repeating yourself doing the same logic all over again. + +Place the `ConnectButton` in the header: + +```ts title='src/components/Header.tsx' +import { ConnectButton } from '@mysten/dapp-kit'; +import { Box, Button, Container, Flex, Heading } from '@radix-ui/themes'; + +export function Header() { + return ( + + + + + + ); +} +``` + +## Type definitions + +All the type definitions are in `src/types/types.ts`. + +`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. + +```ts title='src/types/types.ts' +export type ApiLockedObject = { + id?: string; + objectId: string; + keyId: string; + creator?: string; + itemId: string; + deleted: boolean; +}; + +export type ApiEscrowObject = { + id: string; + objectId: string; + sender: string; + recipient: string; + keyId: string; + itemId: string; + swapped: boolean; + cancelled: boolean; +}; + +export type EscrowListingQuery = { + escrowId?: string; + sender?: string; + recipient?: string; + cancelled?: string; + swapped?: string; + limit?: string; +}; + +export type LockedListingQuery = { + deleted?: string; + keyId?: string; + limit?: string; +}; +``` + +## Execute transaction hook + +In the frontend, you might need to execute a transaction block in multiple places, hence it's better to extract the transaction execution logic and reuse it everywhere. Let's examine the execute transaction hook. + +```ts title='src/hooks/useTransactionExecution.ts' +import { useSignTransactionBlock, useSuiClient } from '@mysten/dapp-kit'; +import { SuiTransactionBlockResponse } from '@mysten/sui.js/client'; +import { TransactionBlock } from '@mysten/sui.js/transactions'; +import toast from 'react-hot-toast'; + +export function useTransactionExecution() { + const client = useSuiClient(); + const { mutateAsync: signTransactionBlock } = useSignTransactionBlock(); + + const executeTransaction = async ( + txb: TransactionBlock, + ): Promise => { + try { + const signature = await signTransactionBlock({ + transactionBlock: txb, + }); + + const res = await client.executeTransactionBlock({ + transactionBlock: signature.transactionBlockBytes, + signature: signature.signature, + options: { + showEffects: true, + showObjectChanges: true, + }, + }); + + toast.success('Successfully executed transaction!'); + return res; + } catch (e: any) { + toast.error(`Failed to execute transaction: ${e.message as string}`); + } + }; + + return executeTransaction; +} +``` + +The hook logic is straightforward. A `TransactionBlock` is the input, sign it with the current connected wallet account, execute the transaction block, return the execution result, and finally display a basic toast message to indicate whether the transaction is successful or not. + +Use the `useSuiClient()` hook from `dApp Kit` to retrieve the Sui client instance configured in the [**Set up providers**](#set-up-providers) step. The `useSignTransactionBlock()` function is another hook from `dApp kit` that helps to sign the transaction block 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 block, the `executeTransactionBlock()` on the Sui client instance of the Sui TypeScript SDK. Use `react-hot-toast` as another dependency to toast transaction status to users. + +## Generate demo data + +:::info + +The full source code of the demo bear smart contract is available at [Trading Contracts Demo directory](https://github.com/MystenLabs/sui/examples/trading/contracts/demo) + +::: + +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. + +```ts title='src/mutations/demo.ts' +import { useCurrentAccount } from '@mysten/dapp-kit'; +import { TransactionBlock } from '@mysten/sui.js/transactions'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { CONSTANTS } from '@/constants'; +import { useTransactionExecution } from '@/hooks/useTransactionExecution'; + +// SPDX-License-Identifier: Apache-2.0 +export function useGenerateDemoData() { + const account = useCurrentAccount(); + const executeTransaction = useTransactionExecution(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + if (!account?.address) throw new Error('You need to connect your wallet!'); + const txb = new TransactionBlock(); + + const bear = txb.moveCall({ + target: `${CONSTANTS.demoContract.packageId}::demo_bear::new`, + arguments: [txb.pure.string(`A happy bear`)], + }); + + txb.transferObjects([bear], txb.pure.address(account.address)); + + return executeTransaction(txb); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ['getOwnedObjects'], + }); + }, + }); +} +``` + +As previously mentioned, the 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 two 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 is fairly straightforward, executing a Move call of a package named `demo_bear::new` to create a dummy bear object and transfer it to the connected wallet account, all within the same `TransactionBlock`. The 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. The 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 **Setup Providers** step. + +Now the logic to create a dummy bear object exists. You just need to attach it into the button in the header. + +```ts title='src/components/Header.tsx' +import { useGenerateDemoData } from '@/mutations/demo'; + +export function Header() { + const { mutate: demoBearMutation, isPending } = useGenerateDemoData(); + return ( + + + + + + ); +} +``` + +## Lock/unlock owned-object mutation + +Locking and unlocking of owned objects are two crucial on-chain actions in this application and are very likely to be used all over. Hence, it's beneficial to extract their logic into separated mutating functions to enhance reusability and encapsulation. + +### Lock 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 our 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 block. + +```ts title='src/mutations/locked.ts' +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 TransactionBlock(); + + 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); + }, + }); +} +``` + +### Unlock owned objects + +To unlock the object, execute the `unlock` Move function identified by `{PACKAGE_ID}::lock::unlock`. The implementation is straightforward, 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. + +```ts title='src/mutations/locked.ts' +export function useUnlockMutation() { + const account = useCurrentAccount(); + const executeTransaction = useTransactionExecution(); + const client = useSuiClient(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + lockedId, + keyId, + suiObject, + }: { + lockedId: string; + keyId: string; + suiObject: SuiObjectData; + }) => { + if (!account?.address) throw new Error('You need to connect your wallet!'); + const key = await client.getObject({ + id: keyId, + options: { + showOwner: true, + }, + }); + + if ( + !key.data?.owner || + typeof key.data.owner === 'string' || + !('AddressOwner' in key.data.owner) || + key.data.owner.AddressOwner !== account.address + ) { + toast.error('You are not the owner of the key'); + return; + } + + const txb = new TransactionBlock(); + + const item = txb.moveCall({ + target: `${CONSTANTS.escrowContract.packageId}::lock::unlock`, + typeArguments: [suiObject.type!], + arguments: [txb.object(lockedId), txb.object(keyId)], + }); + + txb.transferObjects([item], txb.pure.address(account.address)); + + return executeTransaction(txb); + }, + onSuccess: () => { + setTimeout(() => { + // invalidating the queries after a small latency + // because the indexer works in intervals of 1s. + // if we invalidate too early, we might not get the latest state. + queryClient.invalidateQueries({ + queryKey: [QueryKey.Locked], + }); + }, 1_000); + }, + }); +} +``` + +## Create/accept/cancel escrow mutations + +To create, accept, or cancel escrows, it's better to implement mutations for each of these actions to allow reusability and encapsulation. + +### Create escrows + +To create escrows, include a mutating function through the `useCreateEscrowMutation` hook in `src/mutations/escrow.ts`. The mutation implementation is pretty straightforward. 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). + +```ts title='src/mutations/escrow.ts' +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 TransactionBlock(); + 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); + }, + }); +} +``` + +### 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 the connected wallet account. When the mutation succeeds, invalidate the cache to allow automatic re-fetch of the data. + +```ts title='src/mutations/escrow.ts' +import { ApiEscrowObject, ApiLockedObject } from '@/types/types'; + +export function useAcceptEscrowMutation() { + const currentAccount = useCurrentAccount(); + const client = useSuiClient(); + 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 TransactionBlock(); + + const escrowObject = await client.multiGetObjects({ + ids: [escrow.itemId, locked.itemId], + options: { + showType: true, + }, + }); + + const escrowType = escrowObject.find((x) => x.data?.objectId === escrow.itemId)?.data?.type; + + const lockedType = escrowObject.find((x) => x.data?.objectId === locked.itemId)?.data?.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); + }, + }); +} +``` + +### 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. + +```ts ts title='src/mutations/escrow.ts' +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 TransactionBlock(); + + 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); + }, + }); +} +``` + +## Locked dashboard + +The UI has a tab for users to manage their owned objects to be ready for escrow trading. The code of this tab lives in the file `src/routes/LockedDashBoard.tsx`. In this tab, there are two sub-tabs: + +- **My Locked Objects** tab to list out all of owned `Locked` objects. +- **Lock Own Objects** tab to lock owned objects. + +### My Locked Objects tab + +Let's 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. + +```ts title='src/components/locked/OwnedLockedList.tsx' +import { useCurrentAccount, useSuiClientInfiniteQuery } from '@mysten/dapp-kit'; + +import { InfiniteScrollArea } from '@/components/InfiniteScrollArea'; +import { CONSTANTS } from '@/constants'; + +import { LockedObject } from './LockedObject'; + +/** + * Similar to the `ApiLockedList` but fetches the owned locked objects + * but fetches the objects from the on-chain state, instead of relying on the indexer API. + */ +export function OwnedLockedList() { + const account = useCurrentAccount(); + const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = + useSuiClientInfiniteQuery( + 'getOwnedObjects', + { + filter: { + StructType: CONSTANTS.escrowContract.lockedType, + }, + owner: account?.address!, + options: { + showContent: true, + showOwner: true, + }, + }, + { + enabled: !!account?.address, + select: (data) => data.pages.flatMap((page) => page.data), + }, + ); + return ( + <> + fetchNextPage()} + hasNextPage={hasNextPage} + loading={isFetchingNextPage || isLoading} + > + {data?.map((item) => )} + + + ); +} +``` + +Fetch the owned `Locked` objects directly from Sui blockchain using the `useSuiClientInfiniteQuery()` hook from `dApp Kit`. This hook is a thin wrapper around Sui blockchain RPC calls, reference the documentation to learn more about these [RPC hooks](https://sdk.mystenlabs.com/dapp-kit/rpc-hooks). Basically, supply the RPC endpoint you want to execute, in this case it's the [`getOwnedObjects` endpoint](https://docs.sui.io/sui-api-ref#suix_getownedobjects). Supply the connected wallet account and the `Locked` object struct type to the call. The struct type is usually identified by the format of `{PACKAGE_ID}::{{MODULE_NAME}}::{{STRUCT_TYPE}}`. The returned data is stored inside the cache at query key `getOwnedObjects`. Recall the previous section where you invalidate the data at this key after the mutation succeeds, the `useSuiClientInfiniteQuery()` hook automatically re-fetches the data, thus you don't have to worry about the out-dated data living in your frontend application. + +#### `LockedObject` and `Locked` component + +The `` (`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 `` component for rendering. The `` fetches the locked item object ID if the prop `itemId` is not supplied by using `dApp Kit` `useSuiClientQuery()` hook to call the `getDynamicFieldObject` RPC endpoint. Recalling that in this smart contract, the locked item is put into a dynamic object field. + +The `` (`src/components/locked/partials/Locked.tsx`) component is mainly responsible for rendering the `ApiLockedObject`. It also consists of several on-chain interactions: unlock the locked objects and create an escrow out of the locked object. + +```ts title='src/components/locked/LockedObject.tsx' +/** + * Acts as a wrapper between the `Locked` object fetched from API + * and the on-chain object state. + * + * Accepts an `object` of type `::locked::Locked`, fetches the itemID (though the DOF) + * and then renders the `Locked` component. + * + * ItemId is optional because we trust the API to return the correct itemId for each Locked. + */ +export function LockedObject({ + object, + itemId, + hideControls, +}: { + object: SuiObjectData; + itemId?: string; + hideControls?: boolean; +}) { + const owner = () => { + if (!object.owner || typeof object.owner === 'string' || !('AddressOwner' in object.owner)) + return undefined; + return object.owner.AddressOwner; + }; + + const getKeyId = (item: SuiObjectData) => { + if (!(item.content?.dataType === 'moveObject') || !('key' in item.content.fields)) return ''; + return item.content.fields.key as string; + }; + + // Get the itemID for the locked object (We've saved it as a DOF on the SC). + const suiObjectId = useSuiClientQuery( + 'getDynamicFieldObject', + { + parentId: object.objectId, + name: { + type: CONSTANTS.escrowContract.lockedObjectDFKey, + value: { + dummy_field: false, + }, + }, + }, + { + select: (data) => data.data, + enabled: !itemId, + }, + ); + + return ( + + ); +} +``` + +### Lock owned object + +You have all the logic you need to implement this UI. Use the same `useSuiClientInfiniteQuery()` hook to query all the owned objects of the connected wallet. Filter out objects that do not exist in the Object Display `display.data.image_url` as you can assume the valid NFTs conform to the Object Display and have an image in the metadata. Lastly, use the lock mutation from `useLockObjectMutation()` hook whenever the user clicks the lock button. + +```ts title='src/components/locked/LockOwnedObjects.tsx' +export function LockOwnedObjects() { + const account = useCurrentAccount(); + + const { mutate: lockObjectMutation, isPending } = useLockObjectMutation(); + + const { data, fetchNextPage, isFetchingNextPage, hasNextPage, refetch } = + useSuiClientInfiniteQuery( + 'getOwnedObjects', + { + owner: account?.address!, + options: { + showDisplay: true, + showType: true, + }, + }, + { + enabled: !!account, + select: (data) => + data.pages + .flatMap((page) => page.data) + .filter((x) => !!x.data?.display && !!x.data?.display?.data?.image_url), + }, + ); + + return ( + fetchNextPage()} + hasNextPage={hasNextPage} + loading={isFetchingNextPage} + > + {data?.map((obj) => ( + +
+

Lock the item so it can be used for escrows.

+ +
+
+ ))} +
+ ); +} +``` + +## Escrow dashboard + +The UI has a place for users to discover, create, and execute trades. The code of this tab lives in the file `src/routes/EscrowDashboard.tsx`. In this tab, there are three sub-tabs: + +- **Requested Escrows** tab to list out all of the escrow requested for locked objects. +- **Browse Locked Objects** tab to browse locked objects to trade for. +- **My Pending Requests** tab to browse escrows you have initiated for third-party locked objects. + +### Requested escrows + +Let's take a look at the **Requested Escrows** tab by examining `src/components/escrows/EscrowList.tsx`. This time, the data is retrieved by using `useInfiniteQuery` directly from `react-query`. Fetch the data by calling the API service that you already implemented in the [Escrow Indexing and API Service Guide](./indexer-api.mdx). Call the `/escrows` endpoint to fetch all the escrows requested to you. The rationale behind using an API service to fetch the data is because the indexed data includes additional information that allows query efficiency and flexibility. You can fetch specific escrows satisfying different configured query clauses rather than limited query features of Sui blockchain RPC endpoints. + +```ts title='src/components/escrows/EscrowList.tsx' +import { constructUrlSearchParams, getNextPageParam } from '@/utils/helpers'; + +const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } = useInfiniteQuery({ + initialPageParam: null, + queryKey: [QueryKey.Escrow, params, escrowId], + queryFn: async ({ pageParam }) => { + const data = await fetch( + CONSTANTS.apiEndpoint + + 'escrows' + + constructUrlSearchParams({ + ...params, + ...(pageParam ? { cursor: pageParam as string } : {}), + ...(escrowId ? { objectId: escrowId } : {}), + }), + ); + return data.json(); + }, + select: (data) => data.pages.flatMap((page) => page.data), + getNextPageParam, +}); +``` + +The `Escrow` component renders the details of an escrow by providing `ApiEscrowObject` as a rendering property. There is some data you need to fetch to gather necessary escrow information for display in the UI: + +- Query the escrowed item object directly from Sui blockchain by using `useSuiClientQuery('getObject')` as this is the only way to have its Object Display metadata. +- Fetch the `ApiLockedObject` corresponding to the escrow's key ID from the API service as this is the most efficient way to fetch the locked object in a complex query. +- Fetch the on-chain `Locked` object corresponding to the returned `ApiLockedObject` to pass it onto ``. + +```ts title='src/components/escrows/Escrow.tsx' +export function Escrow({ escrow }: { escrow: ApiEscrowObject }) { + const account = useCurrentAccount(); + const [isToggled, setIsToggled] = useState(true); + const { mutate: acceptEscrowMutation, isPending } = useAcceptEscrowMutation(); + const { mutate: cancelEscrowMutation, isPending: pendingCancellation } = + useCancelEscrowMutation(); + + const suiObject = useSuiClientQuery('getObject', { + id: escrow?.itemId, + options: { + showDisplay: true, + showType: true, + }, + }); + + 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, + }); + + ... +} +``` + +As the last step, reuse the `accept` and `cancel` escrow mutations in corresponding buttons. + +### Browse locked objects + +The `src/components/locked/ApiLockedList.tsx` component renders all the on-chain locked objects based on the `LockedListingQuery` property. Call the API service to fetch the `ApiLockedObject` data using the provided query parameters. One caveat around the API service is that the `creator` field of the `ApiLockedObject` could be stale because the `Locked` object has the `store` ability. This means that the object can be transferred freely, hence, the ownership might not be correctly tracked by the API service. That's why you still fetch from the Sui blockchain as an additional step to define the object with latest on-chain information to ensure its data correctness in regards to ownership. + +```ts title='src/components/locked/ApiLockedList.tsx' +const { data, fetchNextPage, hasNextPage, isLoading, isFetchingNextPage } = useInfiniteQuery({ + initialPageParam: null, + queryKey: [QueryKey.Locked, params, lockedId], + queryFn: async ({ pageParam }) => { + const data = await ( + await fetch( + CONSTANTS.apiEndpoint + + 'locked' + + constructUrlSearchParams({ + deleted: 'false', + ...(pageParam ? { cursor: pageParam as string } : {}), + ...(params || {}), + }), + ) + ).json(); + + const objects = await suiClient.multiGetObjects({ + ids: data.data.map((x: ApiLockedObject) => x.objectId), + options: { + showOwner: true, + showContent: true, + }, + }); + + return { + suiObjects: objects.map((x) => x.data), + api: data, + }; + }, + select: (data) => data.pages, + getNextPageParam, + enabled: !lockedId, +}); +``` + +### My Pending Requests tab + +The **My Pending Requests** tab uses the same `` component as **Requested Escrows** tab as they're both trying to display the escrows. The only difference is that the former fetches all the escrows with current wallet as recipient, while the latter fetches all the escrows with current wallet as sender. diff --git a/docs/content/guides/developer/app-examples/trading/indexer-api.mdx b/docs/content/guides/developer/app-examples/trading/indexer-api.mdx new file mode 100644 index 0000000000000..a9fe1647ef4a4 --- /dev/null +++ b/docs/content/guides/developer/app-examples/trading/indexer-api.mdx @@ -0,0 +1,632 @@ +--- +title: Escrow Indexing and API Service +sidebar_label: Indexing and API Service +--- + +:::note Multi-Page Guide + +This is the second in a [three-part guide](../trading.mdx) on how to build a trustless atomic swap on Sui. + +::: + +In most cases where you want to enhance a dApp and ensure it is production ready, you need to have an indexing service (indexer) listening to the blockchain for on-chain events, shaping the data to fit your application needs, and storing the transformed data into the local off-chain database so you can query them in the most efficient way. Joining hands with an indexer, you expose an API allowing the frontend to query the indexed data and update the screen as escrows are made and swaps are fulfilled. + +Architecturally, the service does the heavy lifting of indexing the data, while the other service exposes the data through an API convention for external consumption. + +## Prerequisites + +{@include: ../../../../snippets/app-examples-trading-source.mdx} + +Before getting started, make sure you: + +- Understand [the mechanism behind the Trading backend](../trading.mdx). +- Install [`pnpm` through this guide](https://pnpm.io/installation) as this example uses it as the package manager. +- Check out the [indexer's README](https://github.com/MystenLabs/sui/examples/trading/api) to setup the development environment. +- Check out [Prisma](https://www.prisma.io/) to get an overall sense of the technology that facilitates all the database interactions. +- Check out [Express](https://expressjs.com/) to learn how to set up a web server application in Node.js. This server bootstraps your API service. +- Check out [Sui TypeScript SDK](https://sdk.mystenlabs.com/typescript) for basic usage on how to interact with Sui with TypeScript. + +## Indexing service + +The indexing service fetches `Escrow` objects by `sender` and `recipient`. + +### Data model + +In most cases, when you're working with a database directly from a backend service, you might want to use some sort of database libraries and toolings to abstract away database creation and management complexity. In this case, the example uses [Prisma](https://www.prisma.io/) to manage all the database interactions, such as defining data models and database migrations. + +First of all, design what data to index: + +```ts title='prisma/schema.prisma' +/// Our `Locked` objects list +model Locked { + // Keeping an ID to use as a pagination cursor + // There's an issue with BigInt for sqlite, so use a plain ID. + id Int @id @default(autoincrement()) + objectId String @unique + keyId String? + creator String? + itemId String? + deleted Boolean @default(false) + + @@index([creator]) + @@index([deleted]) +} + +/// Swap objects list +model Escrow { + // Keeping an ID to use as a pagination cursor + // There's an issue with BigInt for sqlite, so use a plain ID. + id Int @id @default(autoincrement()) + objectId String @unique + sender String? + recipient String? + keyId String? + itemId String? + swapped Boolean @default(false) + cancelled Boolean @default(false) + + @@index([recipient]) + @@index([sender]) +} +``` + +These data models represent the `Locked` and `Escrow` object. Compared to their on-chain version, which contains much less attributes due to initial smart contract design, they have additional fields providing extra information that helps to facilitate any queries at a later stage. + +```ts title='prisma/schema.prisma' +/// Saves the latest cursor for a given key. +model Cursor { + id String @id + eventSeq String + txDigest String +} +``` + +Most indexing services need to implement some sort of checkpoint mechanism to ensure it picks up the progress where it left off even after it returns from a crash. `Cursor` is the checkpoint that you store in your persistent database to ensure the data remains and is unaffected by incidents. + +Next, let's explore the logic keeping the service listening to blockchain signals and reacting accordingly. + +### `event-indexer.ts` + +Let's first examine `event-indexer.ts`: + +#### Imports + +```ts title='event-indexer.ts' +import { EventId, SuiClient, SuiEvent, SuiEventFilter } from '@mysten/sui.js/client'; + +import { CONFIG } from '../config'; +import { prisma } from '../db'; +import { getClient } from '../sui-utils'; +import { handleEscrowObjects } from './escrow-handler'; +import { handleLockObjects } from './locked-handler'; +``` + +These lines import the necessary modules and dependencies for the script. The `EventId`, `SuiClient`, `SuiEvent`, and `SuiEventFilter` types are imported from the `@mysten/sui.js/client` package. The `CONFIG` constant is imported from the local `config` module, `prisma` from the local `db` module, `getClient` from the local `sui-utils` module, and the `handleEscrowObjects` and `handleLockObjects` functions from the local `escrow-handler` and `locked-handler` modules respectively. + +#### Type definitions + +```ts title='event-indexer.ts' +type SuiEventsCursor = EventId | null | undefined; + +type EventExecutionResult = { + cursor: SuiEventsCursor; + hasNextPage: boolean; +}; + +type EventTracker = { + // The module that defines the type, with format `package::module` + type: string; + filter: SuiEventFilter; + callback: (events: SuiEvent[], type: string) => any; +}; +``` + +Three custom types are defined here: `SuiEventsCursor`, `EventExecutionResult`, and `EventTracker`. `SuiEventsCursor` is a type alias for `EventId | null | undefined`, representing the possible states of a cursor pointing to events on the Sui network. `EventExecutionResult` represents the result of executing an event job, including the updated cursor and a flag indicating whether there are more pages of events to process. `EventTracker` represents an event tracker, which includes the type of the event, a filter for the event, and a callback function to handle the event. + +#### Constants + +```ts title='event-indexer.ts' +const EVENTS_TO_TRACK: EventTracker[] = [ + { + type: `${CONFIG.SWAP_CONTRACT.packageId}::lock`, + filter: { + MoveEventModule: { + module: 'lock', + package: CONFIG.SWAP_CONTRACT.packageId, + }, + }, + callback: handleLockObjects, + }, + { + type: `${CONFIG.SWAP_CONTRACT.packageId}::shared`, + filter: { + MoveEventModule: { + module: 'shared', + package: CONFIG.SWAP_CONTRACT.packageId, + }, + }, + callback: handleEscrowObjects, + }, +]; +``` + +The `EVENTS_TO_TRACK` constant is an array of `EventTracker` objects. Each `EventTracker` specifies a type of event to track, a filter for the event, and a callback function to handle the event. In this case, the script tracks two types of events: lock events and shared events. The filter for each event specifies the module and package ID for the event. The callback function for each event is either `handleLockObjects` or `handleEscrowObjects`, depending on the type of event. + +#### Functions + +```ts title='event-indexer.ts' +const executeEventJob = async ( + client: SuiClient, + tracker: EventTracker, + cursor: SuiEventsCursor, +): Promise => { + try { + // Get the events from the chain. + // This implementation goes from start to finish. + // This also allows filling in a database from scratch! + const { data, hasNextPage, nextCursor } = await client.queryEvents({ + query: tracker.filter, + cursor, + order: 'ascending', + }); + + // Handle the data transformations defined for each event. + await tracker.callback(data, tracker.type); + + // Only update the cursor if extra data is fetched (which means there was a change). + if (nextCursor && data.length > 0) { + await saveLatestCursor(tracker, nextCursor); + + return { + cursor: nextCursor, + hasNextPage, + }; + } + } catch (e) { + console.error(e); + } + // By default, return the same cursor as passed in. + return { + cursor, + hasNextPage: false, + }; +}; +``` + +This function executes an event job. It takes a `SuiClient`, an `EventTracker`, and a `SuiEventsCursor` as arguments, and returns a promise that resolves to an `EventExecutionResult`. The function tries to get the events from the chain according to the filter defined in the `EventTracker`. If successful, it handles the data transformations defined for each event and updates the cursor if there were changes. If an error occurs during execution, it logs the error and returns the original cursor without updating it. + +```ts title='event-indexer.ts' +const runEventJob = async (client: SuiClient, tracker: EventTracker, cursor: SuiEventsCursor) => { + const result = await executeEventJob(client, tracker, cursor); + + // Trigger a timeout. Depending on the result, we either wait 0ms or the polling interval. + setTimeout( + () => { + runEventJob(client, tracker, result.cursor); + }, + result.hasNextPage ? 0 : CONFIG.POLLING_INTERVAL_MS, + ); +}; +``` + +This function runs an event job. It takes a `SuiClient`, an `EventTracker`, and a `SuiEventsCursor` as arguments. It calls `executeEventJob` and schedules another call to `runEventJob` based on the result of the execution. If there are more pages of events to process, it waits for the polling interval defined in `CONFIG.POLLING_INTERVAL_MS` before making the next call. Otherwise, it makes the call immediately. + +```ts title='event-indexer.ts' +const getLatestCursor = async (tracker: EventTracker) => { + const cursor = await prisma.cursor.findUnique({ + where: { + id: tracker.type, + }, + }); + + return cursor || undefined; +}; +``` + +This function gets the latest cursor for an event tracker. It takes an `EventTracker` as an argument and returns a promise that resolves to the cursor. If the cursor is undefined, it retrieves the cursor from the database. + +```ts title='event-indexer.ts' +const saveLatestCursor = async (tracker: EventTracker, cursor: EventId) => { + const data = { + eventSeq: cursor.eventSeq, + txDigest: cursor.txDigest, + }; + + return prisma.cursor.upsert({ + where: { + id: tracker.type, + }, + update: data, + create: { id: tracker.type, ...data }, + }); +}; +``` + +This function saves the latest cursor for an event tracker to the database. It takes an `EventTracker` and a `SuiEventsCursor` as arguments and returns a promise that resolves to the saved cursor. If the cursor already exists in the database, it updates the existing entry. Otherwise, it creates a new entry. + +```ts title='event-indexer.ts' +export const setupListeners = async () => { + for (const event of EVENTS_TO_TRACK) { + runEventJob(getClient(CONFIG.NETWORK), event, await getLatestCursor(event)); + } +}; +``` + +This function sets up all the listeners for the events to track. It iterates over the `EVENTS_TO_TRACK` array and calls `runEventJob` for each event tracker, passing the `SuiClient`, the event tracker, and the latest cursor for the event tracker as arguments. + +Now let’s take a look at `escrow-handler.ts`: + +### `escrow-handler.ts` + +#### Imports + +```ts title='escrow-handler.ts' +import { SuiEvent } from '@mysten/sui.js/client'; +import { Prisma } from '@prisma/client'; + +import { prisma } from '../db'; +``` + +These lines import the necessary modules and dependencies for the script. The `SuiEvent` type is imported from the `@mysten/sui.js/client` package. The `Prisma` namespace is imported from the `@prisma/client package`. The `prisma` instance is imported from the local `db` module. + +#### Type definitions + +```ts title='escrow-handler.ts' +type EscrowEvent = EscrowCreated | EscrowCancelled | EscrowSwapped; + +type EscrowCreated = { + sender: string; + recipient: string; + escrow_id: string; + key_id: string; + item_id: string; +}; + +type EscrowSwapped = { + escrow_id: string; +}; + +type EscrowCancelled = { + escrow_id: string; +}; +``` + +Four custom types are defined here: `EscrowEvent`, `EscrowCreated`, `EscrowSwapped`, and `EscrowCancelled`. `EscrowEvent` is a union type that can be any of `EscrowCreated`, `EscrowCancelled`, or `EscrowSwapped`. `EscrowCreated` represents the data associated with an escrow creation event. `EscrowSwapped` represents the data associated with an escrow swap event. `EscrowCancelled` represents the data associated with an escrow cancellation event. + +#### Functions + +```ts title='escrow-handler.ts' +export const handleEscrowObjects = async (events: SuiEvent[], type: string) => { + const updates: Record = {}; + + for (const event of events) { + if (!event.type.startsWith(type)) throw new Error('Invalid event module origin'); + const data = event.parsedJson as EscrowEvent; + + if (!Object.hasOwn(updates, data.escrow_id)) { + updates[data.escrow_id] = { + objectId: data.escrow_id, + }; + } + + // Escrow cancellation case + if (event.type.endsWith('::EscrowCancelled')) { + const data = event.parsedJson as EscrowCancelled; + updates[data.escrow_id].cancelled = true; + continue; + } + + // Escrow swap case + if (event.type.endsWith('::EscrowSwapped')) { + const data = event.parsedJson as EscrowSwapped; + updates[data.escrow_id].swapped = true; + continue; + } + + const creationData = event.parsedJson as EscrowCreated; + + // Handle creation event + updates[data.escrow_id].sender = creationData.sender; + updates[data.escrow_id].recipient = creationData.recipient; + updates[data.escrow_id].keyId = creationData.key_id; + updates[data.escrow_id].itemId = creationData.item_id; + } + + // As part of the demo and to avoid having external dependencies, we use SQLite as our database. + // Prisma + SQLite does not support bulk insertion & conflict handling, so we have to insert these 1 by 1 + // (resulting in multiple round-trips to the database). + // Always use a single `bulkInsert` query with proper `onConflict` handling in production databases (e.g Postgres) + const promises = Object.values(updates).map((update) => + prisma.escrow.upsert({ + where: { + objectId: update.objectId, + }, + create: update, + update, + }), + ); + await Promise.all(promises); +}; +``` + +This function handles all events emitted by the `escrow` module. It takes an array of `SuiEvent` objects and a string representing the type of the events as arguments. The function processes each event and updates the corresponding escrow object in the database accordingly. If an event indicates that an escrow was canceled or swapped, the function marks the corresponding escrow object as canceled or swapped. If an event indicates that an escrow was created, the function creates a new escrow object with the details from the event. + +### `locked-handler.ts` + +#### Imports + +```ts title='locked-handler.ts' +import { SuiEvent } from '@mysten/sui.js/client'; +import { Prisma } from '@prisma/client'; + +import { prisma } from '../db'; +``` + +These lines import the necessary modules and dependencies for the script. The `SuiEvent` type is imported from the `@mysten/sui.js/client` package. The `Prisma` namespace is imported from the `@prisma/client package`. The `prisma` instance is imported from the local `db` module. + +#### Type definitions + +```ts title='locked-handler.ts' +type LockEvent = LockCreated | LockDestroyed; + +type LockCreated = { + creator: string; + lock_id: string; + key_id: string; + item_id: string; +}; + +type LockDestroyed = { + lock_id: string; +}; +``` + +Three custom types are defined here: `LockEvent`, `LockCreated`, and `LockDestroyed`. `LockEvent` is a union type that can be either `LockCreated` or `LockDestroyed`. `LockCreated` represents the data associated with a lock creation event. `LockDestroyed` represents the data associated with a lock destruction event. + +#### Functions + +```ts title='locked-handler.ts' +export const handleLockObjects = async (events: SuiEvent[], type: string) => { + const updates: Record = {}; + + for (const event of events) { + if (!event.type.startsWith(type)) throw new Error('Invalid event module origin'); + const data = event.parsedJson as LockEvent; + const isDeletionEvent = !('key_id' in data); + + if (!Object.hasOwn(updates, data.lock_id)) { + updates[data.lock_id] = { + objectId: data.lock_id, + }; + } + + // Handle deletion + if (isDeletionEvent) { + updates[data.lock_id].deleted = true; + continue; + } + + // Handle creation event + updates[data.lock_id].keyId = data.key_id; + updates[data.lock_id].creator = data.creator; + updates[data.lock_id].itemId = data.item_id; + } + + // As part of the demo and to avoid having external dependencies, we use SQLite as our database. + // Prisma + SQLite does not support bulk insertion & conflict handling, so we have to insert these 1 by 1 + // (resulting in multiple round-trips to the database). + // Always use a single `bulkInsert` query with proper `onConflict` handling in production databases (e.g Postgres) + const promises = Object.values(updates).map((update) => + prisma.locked.upsert({ + where: { + objectId: update.objectId, + }, + create: { + ...update, + }, + update, + }), + ); + await Promise.all(promises); +}; +``` + +This function handles all events emitted by the `lock` module. It takes an array of `SuiEvent` objects and a string representing the type of the events as arguments. The function processes each event and updates the corresponding locked object in the database accordingly. If an event indicates that a lock was destroyed, the function marks the corresponding locked object as deleted. If an event indicates that a lock was created, the function creates a new locked object with the details from the event. + +## API service + +As we mentioned earlier, we should expose the indexed data for external consumption through an API service. Particularly, the example uses [Express](https://expressjs.com/) to build a Node.js HTTP API. + +### API design + +#### 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. + +```ts title='utils/api-queries.ts' +export enum WhereParamTypes { + STRING, + NUMBER, + BOOLEAN, +} + +export type WhereParam = { + key: string; + type: WhereParamTypes; +}; + +/** Parses a where statement based on the query params. */ +export const parseWhereStatement = (query: Record, acceptedParams: WhereParam[]) => { + const params: Record = {}; + for (const key of Object.keys(query)) { + const whereParam = acceptedParams.find((x) => x.key === key); + if (!whereParam) continue; + + const value = query[key]; + if (whereParam.type === WhereParamTypes.STRING) { + params[key] = value; + } + if (whereParam.type === WhereParamTypes.NUMBER) { + const number = Number(value); + if (isNaN(number)) throw new Error(`Invalid number for ${key}`); + + params[key] = number; + } + + // Handle boolean expected values. + if (whereParam.type === WhereParamTypes.BOOLEAN) { + let boolValue; + if (value === 'true') boolValue = true; + else if (value === 'false') boolValue = false; + else throw new Error(`Invalid boolean for ${key}`); + + params[key] = boolValue; + } + } + return params; +}; +``` + +#### Query pagination + +Pagination is another crucial part to ensure your API returns sufficient and/or ordered chunk 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. + +```ts title='utils/api-queries.ts' +export type ApiPagination = { + take?: number; + orderBy: { + id: 'asc' | 'desc'; + }; + cursor?: { + id: number; + }; + skip?: number; +}; + +/** + * A helper to prepare pagination based on `req.query`. + * Only primary key cursor + ordering for this example. + */ +export const parsePaginationForQuery = (body: Record) => { + const pagination: ApiPagination = { + orderBy: { + id: Object.hasOwn(body, 'sort') && ['asc', 'desc'].includes(body.sort) ? body.sort : 'desc', + }, + }; + + // Prepare pagination limit (how many items to return) + if (Object.hasOwn(body, 'limit')) { + const requestLimit = Number(body.limit); + + if (isNaN(requestLimit)) throw new Error('Invalid limit value'); + + pagination.take = requestLimit > CONFIG.DEFAULT_LIMIT ? CONFIG.DEFAULT_LIMIT : requestLimit; + } else { + pagination.take = CONFIG.DEFAULT_LIMIT; + } + + // Prepare cursor pagination (which page to return) + if (Object.hasOwn(body, 'cursor')) { + const cursor = Number(body.cursor); + if (isNaN(cursor)) throw new Error('Invalid cursor'); + pagination.skip = 1; + pagination.cursor = { + id: cursor, + }; + } + + return pagination; +}; +``` + +### API endpoints + +All the endpoints are defined in `server.ts`, particularly, there are two endpoints: + +- `/locked` to query `Locked` objects. +- `/escrows` to query `Escrow` objects. + +The implementation for both endpoints is pretty straightforward. You define a list of valid query keys, such as `deleted`, `creator`, `keyId`, and `objectId` for `Locked` data and `cancelled`, `swapped`, `recipient`, and `sender` for `Escrow` data. Pass the URL query string into the pre-defined utilities to output the correct parameters that Prisma can use. + +```ts title='server.ts' +import { prisma } from './db'; +import { + formatPaginatedResponse, + parsePaginationForQuery, + parseWhereStatement, + WhereParam, + WhereParamTypes, +} from './utils/api-queries'; + +app.get('/locked', async (req, res) => { + const acceptedQueries: WhereParam[] = [ + { + key: 'deleted', + type: WhereParamTypes.BOOLEAN, + }, + { + key: 'creator', + type: WhereParamTypes.STRING, + }, + { + key: 'keyId', + type: WhereParamTypes.STRING, + }, + { + key: 'objectId', + type: WhereParamTypes.STRING, + }, + ]; + + try { + const locked = await prisma.locked.findMany({ + where: parseWhereStatement(req.query, acceptedQueries)!, + ...parsePaginationForQuery(req.query), + }); + + return res.send(formatPaginatedResponse(locked)); + } catch (e) { + console.error(e); + return res.status(400).send(e); + } +}); + +app.get('/escrows', async (req, res) => { + const acceptedQueries: WhereParam[] = [ + { + key: 'cancelled', + type: WhereParamTypes.BOOLEAN, + }, + { + key: 'swapped', + type: WhereParamTypes.BOOLEAN, + }, + { + key: 'recipient', + type: WhereParamTypes.STRING, + }, + { + key: 'sender', + type: WhereParamTypes.STRING, + }, + ]; + + try { + const escrows = await prisma.escrow.findMany({ + where: parseWhereStatement(req.query, acceptedQueries)!, + ...parsePaginationForQuery(req.query), + }); + + return res.send(formatPaginatedResponse(escrows)); + } catch (e) { + console.error(e); + return res.status(400).send(e); + } +}); +``` + +## Next steps + +With the code successfully deployed on Testnet, you can now [create a frontend](./frontend.mdx) to display the trading data and to allow users to interact with the Move modules. diff --git a/docs/content/sidebars/guides.js b/docs/content/sidebars/guides.js index d605f2393f702..158a089478fc7 100644 --- a/docs/content/sidebars/guides.js +++ b/docs/content/sidebars/guides.js @@ -131,7 +131,19 @@ const guides = [ items: [ 'guides/developer/app-examples/e2e-counter', 'guides/developer/app-examples/auction', - 'guides/developer/app-examples/escrow', + { + type: 'category', + label: 'Trading', + link: { + type: 'doc', + id: 'guides/developer/app-examples/trading', + }, + items: [ + 'guides/developer/app-examples/trading/backend', + 'guides/developer/app-examples/trading/indexer-api', + 'guides/developer/app-examples/trading/frontend', + ], + }, 'guides/developer/app-examples/trusted-swap', 'guides/developer/app-examples/tic-tac-toe', 'guides/developer/app-examples/recaptcha', diff --git a/docs/content/snippets/app-examples-trading-source.mdx b/docs/content/snippets/app-examples-trading-source.mdx new file mode 100644 index 0000000000000..4437550ccea38 --- /dev/null +++ b/docs/content/snippets/app-examples-trading-source.mdx @@ -0,0 +1,5 @@ +:::info + +You can view the [complete source code for this app example](https://github.com/MystenLabs/sui/tree/main/examples/trading) in the Sui repository. + +::: \ No newline at end of file diff --git a/docs/content/snippets/publish-to-devnet-with-coins.mdx b/docs/content/snippets/publish-to-devnet-with-coins.mdx index 2922699610619..65a5708ee208b 100644 --- a/docs/content/snippets/publish-to-devnet-with-coins.mdx +++ b/docs/content/snippets/publish-to-devnet-with-coins.mdx @@ -1,6 +1,6 @@ -Before being able to publish your package to Devnet, you need Devnet SUI tokens. To get some, join the [Sui Discord](https://discord.gg/Sui), complete the verification steps, enter the `#devnet-faucet` channel and type `!faucet `. For other ways to get SUI in your Devnet account, see [Get SUI Tokens](/guides/developer/getting-started/get-coins). +Before being able to publish your package to Testnet, you need Testnet SUI tokens. To get some, join the [Sui Discord](https://discord.gg/Sui), complete the verification steps, enter the `#testnet-faucet` channel and type `!faucet `. For other ways to get SUI in your Testnet account, see [Get SUI Tokens](/guides/developer/getting-started/get-coins). -Now that you have an account with some Devnet SUI, you can deploy your contracts. To publish your package, use the following command in the same terminal or console: +Now that you have an account with some Testnet SUI, you can deploy your contracts. To publish your package, use the following command in the same terminal or console: ``` sui client publish --gas-budget