手把手带你构建Sui零信任交换DApp前端 @SUI Move开发必知必会
在之前的文章,我们已经介绍和实践了Sui
官方示例中的 零信任交换(trustless swap) 合约,以及如何 构建Sui的索引器及RPC服务 , 接下来我们将构建了一个DAPP
前端,通过钱包来提交交易,通过访问索引器的RPC服务来获取数据。
本文中的代码来自官方示例:https://github.com/MystenLabs/sui/tree/mainnet-v1.27.2/examples/trading/frontend
系列文章目录:
$ yarn create @mysten/dapp --template react-client-dapp
✔ What is the name of your dApp? (this will be used as the directory name) · trustless_swap_frontend
Done in 41.82s.
$ cd trustless_swap_frontend
$ yarn install
yarn dev
$ pnpm add -D tailwindcss postcss autoprefixer
$ npx tailwindcss init -p
Created Tailwind CSS config file: tailwind.config.js
Created PostCSS config file: postcss.config.js
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}
@tailwind base;
@tailwind components;
@tailwind utilities;
$ yarn add react-hot-toast react-router-dom
该文件定义了项目中所需的常量。
src/constants.ts
/** @ts-ignore */
import demoContract from "../demo-contract.json";
/** @ts-ignore */
import escrowContract from "../escrow-contract.json";
/** @ts-ignore */
import suifrensContract from "../suifrens-contract.json";
export enum QueryKey {
Locked = "locked",
Escrow = "escrow",
GetOwnedObjects = "getOwnedObjects",
}
export const CONSTANTS = {
escrowContract: {
...escrowContract,
lockedType: `${escrowContract.packageId}::lock::Locked`,
lockedKeyType: `${escrowContract.packageId}::lock::Key`,
lockedObjectDFKey: `${escrowContract.packageId}::lock::LockedObjectKey`,
},
demoContract: {
...demoContract,
demoBearType: `${demoContract.packageId}::demo_bear::DemoBear`,
},
suifrensContract: {
...suifrensContract,
suifrensType: `${demoContract.packageId}::suifrens::SuiFrens`,
},
apiEndpoint: "http://localhost:3000/",
};
该文件定义了项目中所有类型,包括:
ApiLockedObject
:索引器API接口返回的托管锁对象类型定义ApiEscrowObject
:索引器API接口返回的托管对象类型定义EscrowListingQuery
:托管列表查询参数类型定义LockedListingQuery
:托管锁对象列表查询参数类型定义
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;
};
在React
根配置必须的多个Provider
:
src/main.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(
<React.StrictMode>
<Theme appearance="light">
<QueryClientProvider client={queryClient}>
<SuiClientProvider networks={networkConfig} defaultNetwork="testnet">
<WalletProvider autoConnect>
<RouterProvider router={router} />
</WalletProvider>
</SuiClientProvider>
</QueryClientProvider>
</Theme>
</React.StrictMode>,
);
关于Sui dapp-kit
提供的Provider
介绍请参看:
https://sdk.mystenlabs.com/dapp-kit/sui-client-provider
https://sdk.mystenlabs.com/dapp-kit/wallet-provider
其中router
还未实现,接下来将进行实现。
该钩子函数主要实现功能为:
ID
查询该对象链上状态
src/hooks/useGetLockedObject.ts
export function useGetLockedObject({ lockedId }: { lockedId: string }) {
return useSuiClientQuery(
"getObject",
{
id: lockedId,
options: {
showType: true,
showOwner: true,
showContent: true,
},
},
{
enabled: !!lockedId,
},
);
}
该钩子函数主要实现功能为:
signTransactionBlock
)RPC
提交交易上链执行(executeTransactionBlock
)
src/hooks/useTransactionExecution.ts
export function useTransactionExecution() {
const client = useSuiClient();
const { mutateAsync: signTransactionBlock } = useSignTransaction();
const executeTransaction = async (
txb: Transaction,
): Promise<SuiTransactionBlockResponse | void> => {
try {
const signature = await signTransactionBlock({
transaction: txb,
});
const res = await client.executeTransactionBlock({
transactionBlock: signature.bytes,
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;
}
该钩子函数主要实现功能为:
$PACKAGE_ID::lock::lock
方法进行对象锁定locked
)及解锁密钥key
返回给交换对象的持有人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);
},
});
}
托管锁对象的持有人可以随时解除锁定,避免交易对手方长时间不去完成交换。
该钩子函数主要实现功能为:
keyId
获取key
对象,并判断该对象持有人是否是当前钱包用户$PACKAGE_ID::lock::unlock
方法进行锁定对象解锁onSuccess
,在onSuccess
回调中,在1秒后将与QueryKey.Locked
相关的查询的缓存数据会被标记为失效,以便触发重新获取更新后的数据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 Transaction();
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);
},
});
}
该钩子函数主要实现功能为:
$PACKAGE_ID::shared::create
方法,传入待交换对象、锁定托管对象KeyId
、锁定托管对象持有人地址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);
},
});
}
该钩子函数主要实现功能为:
escrow.itemId
)以及锁定的交换对象(locked.itemId
)的类型(用于swap
时的类型参数)$PACKAGE_ID::shared::swap
方法,传入托管共享对象、解锁密钥Key
、锁定对象onSuccess
,在onSuccess
回调中,在1秒后将与QueryKey.Escrow
相关的查询的缓存数据会被标记为失效,以便触发重新获取更新后的数据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 Transaction();
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);
},
});
}
该钩子函数主要实现功能为:
$PACKAGE_ID::shared::return_to_sender
方法,传入托管共享对象onSuccess
,在onSuccess
回调中,在1秒后将与QueryKey.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);
},
});
}
该钩子函数主要实现功能为:
useTransactionExecution
钩子函数签名并执行该交易块onSuccess
,在onSuccess
回调中,将会与QueryKey.GetOwnedObjects
相关的查询的缓存数据会被标记为失效,以便触发重新获取更新后的数据
src/mutations/demo.ts
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 Transaction();
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: [QueryKey.GetOwnedObjects],
});
},
});
}
/
:先渲染<Root />
根路由组件,然后执行重定向到<EscrowDashboard />
子路由组件进行渲染
/escrows
:渲染<EscrowDashboard />
子路由组件
/locked
:渲染<LockedDashboard />
子路由组件
src/routes/index.tsx
export const router = createBrowserRouter([
{
path: "/",
element: <Root />,
children: [
{
path: "/",
element: <Navigate to="escrows" replace />,
},
{
path: "escrows",
element: <EscrowDashboard />,
},
{
path: "locked",
element: <LockedDashboard />,
},
],
},
]);
export function Root() {
return (
<div>
<Toaster position="bottom-center" />
<Header />
<Container py="8">
<Outlet />
</Container>
</div>
);
}
该仪表盘中将会有三个子选项卡:
选项卡名 | 组件名 | 功能说明 |
---|---|---|
Requested Escrows | <EscrowList /> |
获取我的所有托管对象列表(当前钱包地址作为接收方) |
Browse Locked Objects | <LockedLis /> |
获取我的所有锁定对象列表 |
My Pending Requests | <EscrowList /> |
获取我的待处理托管对象列表(当前钱包地址作为发送方) |
export function EscrowDashboard() {
const account = useCurrentAccount();
const tabs = [
{
name: "Requested Escrows",
component: () => (
<EscrowList
params={{
recipient: account?.address,
swapped: "false",
cancelled: "false",
}}
/>
),
tooltip: "Escrows requested for your locked objects.",
},
{
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",
}}
/>
),
tooltip: "Escrows you have initiated for third party locked objects.",
},
];
......
该仪表盘中将会有两个子选项卡:
选项卡名 | 组件名 | 功能说明 |
---|---|---|
My Locked Objects | <OwnedLockedList /> |
我创建的锁定对象列表 |
Lock Owned objects | <LockOwnedObjects /> |
锁定我拥有对象选项卡 |
export function LockedDashboard() {
const tabs = [
{
name: "My Locked Objects",
component: () => <OwnedLockedList />,
},
{
name: "Lock Owned objects",
component: () => <LockOwnedObjects />,
},
];
......
export function ExplorerLink({
id,
isAddress,
}: {
id: string;
isAddress?: boolean;
}) {
const [copied, setCopied] = useState(false);
const { network } = useSuiClientContext();
const link = `https://suiexplorer.com/${
isAddress ? "address" : "object"
}/${id}?network=${network}`;
const copy = () => {
navigator.clipboard.writeText(id);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 2000);
toast.success("Copied to clipboard!");
};
......
export function SuiObjectDisplay({
object,
children,
label,
labelClasses,
}: {
object?: SuiObjectData;
children?: ReactNode | ReactNode[];
label?: string;
labelClasses?: string;
}) {
const display = object?.display?.data;
return (
<Card className="!p-0 sui-object-card">
{label && (
<div className={`absolute top-0 right-0 m-2 ${labelClasses}`}>
{label}
</div>
)}
<Flex gap="3" align="center">
<Avatar size="6" src={display?.image_url} radius="full" fallback="O" />
<Box className="grid grid-cols-1">
<Text className="text-xs">
<ExplorerLink id={object?.objectId || ""} isAddress={false} />
</Text>
<Text as="div" size="2" weight="bold">
{display?.name || display?.title || "-"}
</Text>
<Text as="div" size="2" color="gray">
{display?.description || "No description for this object."}
</Text>
</Box>
</Flex>
{children && (
<Inset className="p-2 border-t mt-3 bg-gray-100 rounded-none">
{children}
</Inset>
)}
</Card>
);
}
src/components/Loading.tsx
export function Loading() {
return (
<div role="status" className="text-center ">
<svg
aria-hidden="true"
className="w-8 h-8 text-gray-200 animate-spin fill-gray-900 mx-auto my-3"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
);
}
src/components/InfiniteScrollArea.tsx
export function InfiniteScrollArea({
children,
loadMore,
loading = false,
hasNextPage,
gridClasses = "py-6 grid-cols-1 md:grid-cols-2 gap-5",
}: {
children: ReactNode | ReactNode[];
loadMore: () => void;
loading: boolean;
hasNextPage: boolean;
gridClasses?: string;
}) {
const observerTarget = useRef(null);
// implement infinite loading.
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
loadMore();
}
},
{ threshold: 1 },
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => {
if (observerTarget.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps
observer.unobserve(observerTarget.current);
}
};
}, [observerTarget, loadMore]);
if (!children || (Array.isArray(children) && children.length === 0))
return <div className="p-3">No results found.</div>;
return (
<>
<div className={`grid ${gridClasses}`}>{children}</div>
<div className="col-span-2 text-center">
{loading && <Loading />}
{hasNextPage && !loading && (
<Button
ref={observerTarget}
color="gray"
className="cursor-pointer"
onClick={loadMore}
disabled={!hasNextPage || loading}
>
Load more...
</Button>
)}
</div>
</>
);
}
src/components/escrows/CreateEscrow.tsx
export function CreateEscrow({ locked }: { locked: ApiLockedObject }) {
const [objectId, setObjectId] = useState<string | undefined>(undefined);
const account = useCurrentAccount();
const { mutate: createEscrowMutation, isPending } = useCreateEscrowMutation();
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(
// we're filtering out objects that don't have Display or image_url
// for demo purposes. The Escrow contract works with all objects.
(x) => !!x.data?.display && !!x.data?.display?.data?.image_url,
),
},
);
const getObject = () => {
const object = data?.find((x) => x.data?.objectId === objectId);
if (!object || !object.data) {
return;
}
return object.data;
};
return (
<div className="px-3 py-3 grid grid-cols-1 gap-5 mt-3 rounded">
<div>......</div>
{objectId && (
<div>
<label className="text-xs">You'll be offering:</label>
<ExplorerLink id={objectId} />
</div>
)}
<div className="text-right">
<Button
className="cursor-pointer"
disabled={isPending || !objectId}
onClick={() => {
createEscrowMutation(
{ locked, object: getObject()! },
{
onSuccess: () => {
refetch();
setObjectId(undefined);
},
},
);
}}
>
Create Escrow
</Button>
</div>
</div>
);
}
swap
)及取消托管组件
src/components/escrows/Escrow.tsx
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;
};
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,
});
{!escrow.cancelled &&
!escrow.swapped &&
escrow.sender === account?.address && (
<Button
color="amber"
className="cursor-pointer"
disabled={pendingCancellation}
onClick={() =>
cancelEscrowMutation({
escrow,
suiObject: suiObject.data?.data!,
})
}
>
<Cross1Icon />
Cancel request
</Button>
)}
swap
操作 {!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 will be
transferred to you and your locked item will be transferred
to the sender.
</p>
<Button
className="cursor-pointer"
disabled={isPending}
onClick={() =>
acceptEscrowMutation({
escrow,
locked: lockedData.data,
})
}
>
<CheckCircledIcon /> Accept exchange
</Button>
</div>
)}
src/components/escrows/EscrowList.tsx
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,
});
<InfiniteScrollArea
loadMore={() => fetchNextPage()}
hasNextPage={hasNextPage}
loading={isFetchingNextPage || isLoading}
>
{data?.map((escrow: ApiEscrowObject) => (
<Escrow key={escrow.itemId} escrow={escrow} />
))}
</InfiniteScrollArea>
src/components/locked/OwnedLockedList.tsx
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 (
<>
<InfiniteScrollArea
loadMore={() => fetchNextPage()}
hasNextPage={hasNextPage}
loading={isFetchingNextPage || isLoading}
>
{data?.map((item) => (
<LockedObject key={item.data?.objectId} object={item.data!} />
))}
</InfiniteScrollArea>
</>
src/components/locked/LockOwnedObjects.tsx
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(
// we're filtering out objects that don't have Display or image_url
// for demo purposes. The Escrow contract works with all objects.
(x) => !!x.data?.display && !!x.data?.display?.data?.image_url,
),
},
);
return (
<InfiniteScrollArea
loadMore={() => fetchNextPage()}
hasNextPage={hasNextPage}
loading={isFetchingNextPage}
>
{data?.map((obj) => (
<SuiObjectDisplay object={obj.data!}>
<div className="text-right flex items-center justify-between">
<p className="text-sm">
Lock the item so it can be used for escrows.
</p>
<Button
className="cursor-pointer"
disabled={isPending}
onClick={() => {
lockObjectMutation(
{ object: obj.data! },
{
onSuccess: () => refetch(),
},
);
}}
>
<LockClosedIcon />
Lock Item
</Button>
</div>
</SuiObjectDisplay>
))}
</InfiniteScrollArea>
);
src/components/locked/LockedObject.tsx
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 (
<Locked
locked={{
itemId: itemId || suiObjectId.data?.objectId!,
objectId: object.objectId,
keyId: getKeyId(object),
creator: owner(),
deleted: false,
}}
hideControls={hideControls}
/>
);
}
src/components/locked/ApiLockedList.tsx
export function LockedList({
enableSearch,
params,
}: {
isPersonal?: boolean;
enableSearch?: boolean;
params: LockedListingQuery;
}) {
const [lockedId, setLockedId] = useState("");
const suiClient = useSuiClient();
const { data: searchData } = useGetLockedObject({
lockedId,
});
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,
});
const suiObjects = () => {
if (lockedId) {
if (
!searchData?.data?.type?.startsWith(CONSTANTS.escrowContract.lockedType)
)
return [];
return [searchData?.data!];
}
return data?.flatMap((x) => x.suiObjects) || [];
};
const apiData = () => {
return data?.flatMap((x) => x.api.data);
};
// Find the itemID from the API request to skip fetching the DF on-chain.
// We can always be certain that the itemID can't change for a given `Locked` object.
const getItemId = (objectId?: string) => {
return apiData()?.find((x) => x.objectId === objectId)?.itemId;
};
return (
<>
{enableSearch && (
<TextField.Root className="mt-3">
<TextField.Input
placeholder="Search by locked id"
value={lockedId}
onChange={(e) => setLockedId(e.target.value)}
/>
</TextField.Root>
)}
<InfiniteScrollArea
loadMore={() => fetchNextPage()}
hasNextPage={hasNextPage}
loading={isFetchingNextPage || isLoading}
>
{suiObjects().map((object) => (
<LockedObject
key={object?.objectId!}
object={object!}
itemId={getItemId(object?.objectId)}
/>
))}
</InfiniteScrollArea>
</>
);
}
const menu = [
{
title: "Escrows",
link: "/escrows",
},
{
title: "Manage Objects",
link: "/locked",
},
];
export function Header() {
const { mutate: demoBearMutation, isPending } = useGenerateDemoData();
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>
<Button
className="cursor-pointer"
disabled={isPending}
onClick={() => {
demoBearMutation();
}}
>
New Demo Bear
</Button>
</Box>
<Box className="connect-wallet-wrapper">
<ConnectButton />
</Box>
</Flex>
</Container>
);
}
该服务将从链上订阅相关事件,写到数据库中。
$ npx ts-node indexer.ts
该服务将提供
RPC
接口,供DAPP
查询从链上订阅到索引器里的数据。
$ npx ts-node server.ts
🚀 Server ready at: http://localhost:3000
$ yarn run dev
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
➜ press h to show help
即:执行swap操作
欢迎关注微信公众号:Move中文,开启你的 Sui Move 之旅!
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!