手把手带你构建Sui零信任交换DApp前端 @SUI Move开发必知必会

  • rzexin
  • 更新于 2024-06-27 23:45
  • 阅读 697

手把手带你构建Sui零信任交换DApp前端 @SUI Move开发必知必会

手把手带你构建Sui零信任交换DApp前端 @SUI Move开发必知必会

1 前言

在之前的文章,我们已经介绍和实践了Sui官方示例中的 零信任交换(trustless swap) 合约,以及如何 构建Sui的索引器及RPC服务 , 接下来我们将构建了一个DAPP前端,通过钱包来提交交易,通过访问索引器的RPC服务来获取数据。

本文中的代码来自官方示例:https://github.com/MystenLabs/sui/tree/mainnet-v1.27.2/examples/trading/frontend

系列文章目录:

2 环境准备

2.1 创建初始项目

$ 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

2.2 安装Tailwind CSS

  • 执行命令
$ 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
  • 修改tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
  • 新增index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

2.3 安装依赖库

$ yarn add react-hot-toast react-router-dom

3 代码开发

3.1 常量定义

该文件定义了项目中所需的常量。

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/",
};

3.2 类型定义

该文件定义了项目中所有类型,包括:

  • 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;
};

3.3 配置Provider

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还未实现,接下来将进行实现。

3.4 Hooks钩子函数实现

(1)获取托管锁对象钩子函数

该钩子函数主要实现功能为:

  • 根据托管锁对象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,
    },
  );
}

(2)交易签名及执行钩子函数

该钩子函数主要实现功能为:

  • 签名交易(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;
}

(3)锁定待交换对象钩子函数

该钩子函数主要实现功能为:

  • 检查钱包是否已连接,若没有连接将会报错
  • 构造交易块调用$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);
    },
  });
}

(4)解锁待交换对象钩子函数

托管锁对象的持有人可以随时解除锁定,避免交易对手方长时间不去完成交换。

该钩子函数主要实现功能为:

  • 检查钱包是否已连接,若没有连接将会报错
  • 根据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);
    },
  });
}

(5)创建托管共享对象钩子函数

该钩子函数主要实现功能为:

  • 检查钱包是否已连接,若没有连接将会报错
  • 构造交易块调用$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);
    },
  });
}

(6)接受托管共享对象并执行swap操作钩子函数

该钩子函数主要实现功能为:

  • 检查钱包是否已连接,若没有连接将会报错
  • 获取托管的交换对象(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);
    },
  });
}

(7)取消托管共享对象钩子函数

该钩子函数主要实现功能为:

  • 检查钱包是否已连接,若没有连接将会报错
  • 构造交易块调用$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);
    },
  });
}

(8)测试数据构造钩子函数

该钩子函数主要实现功能为:

  • 检查应用是否已连接钱包,若没有将会报错
  • 构造创建呆萌熊交易并转发给自己的交易块
  • 调用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],
      });
    },
  });
}

3.5 路由实现

(1)创建路由

  • /:先渲染<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 />,
      },
    ],
  },
]);

(2)根路由实现

export function Root() {
  return (
    <div>
      <Toaster position="bottom-center" />
      <Header />
      <Container py="8">
        <Outlet />
      </Container>
    </div>
  );
}

3.6 仪表盘实现

(1)托管对象仪表盘

该仪表盘中将会有三个子选项卡:

选项卡名 组件名 功能说明
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.",
    },
  ];

......

(2)锁定对象仪表盘

该仪表盘中将会有两个子选项卡:

选项卡名 组件名 功能说明
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 />,
    },
  ];

......

3.7 组件实现

(1)浏览器链接组件

  • 根据参数可以返回地址类型或对象类型两种格式的链接
  • 提供了链接拷贝的交互体验
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!");
  };

......

(2)Sui对象展示组件

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>
  );
}

(3)加载进度条组件

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>
  );
}

(4)无限滚动组件

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>
    </>
  );
}

(5)创建托管对象组件

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>
  );
}

(6)接受托管(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>
              )}

(7)托管对象列表查询组件

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>

(8)我的锁定对象列表组件

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>
    </>

(9)锁定我的对象组件

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

(10)锁对象查询组件

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}
    />
  );
}

(11)LockedList组件

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>
    </>
  );
}

(12)Header组件实现

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>
  );
}

4 交互效果

4.1 服务启动

(1)启动索引器indexer服务

该服务将从链上订阅相关事件,写到数据库中。

$ npx ts-node indexer.ts

(2)启动RPC服务

该服务将提供RPC接口,供DAPP查询从链上订阅到索引器里的数据。

$ npx ts-node server.ts
🚀 Server ready at: http://localhost:3000

(3)启动前端服务

$ yarn run dev

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h to show help

4.2 分别创建待交换对象

(1)用户1创建呆萌熊(DemoBear)

image.png

(2)用户2创建SuiFrens

image.png

4.3 用户1锁定待交换对象

  • 用户1点击按钮①进行对象锁定
  • 锁定成功后,在我的锁定对象选项卡(②)中,将能够看到锁定对象
  • 只要尚未交换,持有人可以点击按钮③随时解锁取回待交换对象

image.png

4.4 用户2创建共享托管对象

  • 用户2可以使用一个自己拥有的对象,与其它用户的锁定对象,创建共享托管对象

image.png

  • 可以查看到托管对象中处于交换中的两个对象

image.png

4.5 用户1接受托管交换

即:执行swap操作

image.png

4.6 交换完成

(1)用户1得到SuiFrens

image.png

(2)用户2得到呆萌熊

image.png

5 更多

欢迎关注微信公众号:Move中文,开启你的 Sui Move 之旅!

image.png

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
rzexin
rzexin
0x6Fa5...8165
江湖只有他的大名,没有他的介绍。