TON DApp 前端开发入门

1,301 阅读9分钟

准备

  1. 在浏览器中安装钱包插件,本文使用的是Tonkeeper钱包。
  2. 水龙头领取测试币。
  3. 向任意账户发送任意数量的TON以激活账户。

配置项目

我们将使用 React 构建我们的前端。为了创建我们的项目,我们将依赖于 Vite 及其 React 模板。为你的项目选择一个名称,例如 my-twa,然后打开终端并运行以下内容:

npm create vite@latest my-twa -- --template react-ts
cd my-twa
npm install

我们需要安装下列的包来跟TON区块链进行交互,安装命令如下:

npm install @ton/ton @ton/core @ton/crypto
npm install @orbs-network/ton-access

由于 ton 这个包对 Nodejs Buffer 的依赖,而后者在浏览器中不可用,因此我们需要安装 polyfill 来解决这个问题。安装命令如下:

npm install vite-plugin-node-polyfills

现在修改文件vite.config.ts使其如下所示:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nodePolyfills } from 'vite-plugin-node-polyfills';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react(), nodePolyfills()],
  base: '/',
});

在终端中执行下列命令启动项目:

npm run dev

然后打开 Web 浏览器并直接访问终端中显示的 URL(如 http://localhost:5173/),可以看到我们的项目已经运行起来了。

设置 TON Connect

TON Connect 是我们的应用程序与终端用户的钱包进行通信的协议。TON Connect React 库将为我们提供许多有用的功能,例如:向用户展示 TON Connect 2 支持的钱包列表、查询用户的钱包的地址以及通过钱包发送交易。

安装 TON Connect

在终端中执行下列命令来安装 TON Connect:

npm install @tonconnect/ui-react

配置 Provider

当我们的应用程序连接到用户的钱包时,钱包将请求用户的许可,并显示如该清单文件中的信息。下面我们将直接使用这个清单文件,实际开发时需要在清单文件中写入项目的相关信息。

修改文件 src/main.tsx 以使用 TON Connect 的 Provider:

import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import { TonConnectUIProvider } from '@tonconnect/ui-react';

// this manifest is used temporarily for development purposes
const manifestUrl = 'https://raw.githubusercontent.com/ton-community/tutorials/main/03-client/test/public/tonconnect-manifest.json';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <TonConnectUIProvider manifestUrl={manifestUrl}>
    <App />
  </TonConnectUIProvider>,
)

实现连接钱包功能

我们要实现的第一个功能是连接钱包。通过连接,用户同意与应用程序共享他们的钱包地址。

编辑文件 src/App.tsx 并将其内容替换为以下内容:

import './App.css';
import { TonConnectButton } from '@tonconnect/ui-react';

function App() {
  return (
    <div>
      <TonConnectButton />
    </div>
  );
}

export default App

然后刷新页面,点击“连接钱包”按钮,然后选择“Tonkeeper”(或任何其他支持的钱包),接着选择“Browser Extension“。

如果一切正常,你应该会在钱包插件的弹窗中看到确认信息。如果选择批准连接,你将在页面上看到你的钱包地址。

从链上读取数据

接下来我们将实现从一个 Counter 合约中读取数据。 是时候与我们的柜台合约互动并显示当前的柜台价值了。为此,我们需要在教程 2 中创建的 TypeScript 接口类。此类非常有用,因为它以抽象实现和编码细节的方式定义了与协定的所有可能的交互。当你的团队中有一个开发人员编写合同,而另一个开发人员构建前端时,这尤其有用。

实现一个异步初始化数据的 hook

接下来我们要做的是实现一个通用的 hook,它将帮助我们初始化异步数据。使用以下内容创建文件 src/hooks/useAsyncInitialize.ts

import { useEffect, useState } from 'react';

export function useAsyncInitialize<T>(func: () => Promise<T>, deps: any[] = []) {
  const [state, setState] = useState<T | undefined>();
  useEffect(() => {
    (async () => {
      setState(await func());
    })();
  }, deps);

  return state;
}

实现一个初始化 RPC 客户端的 hook

接下来,我们将创建另一个 hook,该 hook 将依赖于 useAsyncInitialize,并将在我们的应用程序中初始化一个 RPC 客户端。类似于以太坊上的 Infura 的 RPC 服务提供商将允许我们从链中查询数据。这些提供商运行TON区块链节点,并允许我们通过HTTP与它们进行通信。我们将使用TON Access 的服务,它将免费为我们提供不受限制的 API 访问。

使用以下内容创建文件 src/hooks/useTonClient.ts

import { getHttpEndpoint } from '@orbs-network/ton-access';
import { TonClient } from "@ton/ton";
import { useAsyncInitialize } from './useAsyncInitialize';

export function useTonClient() {
  return useAsyncInitialize(
    async () =>
      new TonClient({
        endpoint: await getHttpEndpoint({ network: 'testnet' }),
      })
  );
}

实现一个初始化合约实例的 hook

我们的最后一个 hook 将构造 Counter 合约的实例,应用程序可以通过实例访问合约中的数据。通常情况下,当前端开发人员开始编写与合约交互的客户端程序时,合约应该已经部署好了。这意味着只需要部署地址即可访问合约。下面我们使用一个已经部署好的合约EQBYLTm4nsvoqJRvs_L-IGNKwWs5RKe19HBK_lFadf19FUfb

使用以下内容创建文件 src/hooks/useCounterContract.ts

import { useEffect, useState } from 'react';
import Counter from '../contracts/counter';
import { useTonClient } from './useTonClient';
import { useAsyncInitialize } from './useAsyncInitialize';
import { Contract, ContractProvider, Sender, Address, Cell, contractAddress, beginCell, OpenedContract } from "@ton/core";

export default class Counter implements Contract {

  static createForDeploy(code: Cell, initialCounterValue: number): Counter {
    const data = beginCell()
      .storeUint(initialCounterValue, 64)
      .endCell();
    const workchain = 0; // deploy to workchain 0
    const address = contractAddress(workchain, { code, data });
    return new Counter(address, { code, data });
  }
  
  constructor(readonly address: Address, readonly init?: { code: Cell, data: Cell }) {}

  async sendDeploy(provider: ContractProvider, via: Sender) {
    await provider.internal(via, {
      value: "0.01", // send 0.01 TON to contract for rent
      bounce: false
    });
  }
  
  async sendIncrement(provider: ContractProvider, via: Sender) {
    const messageBody = beginCell()
      .storeUint(1, 32) // op (op #1 = increment)
      .storeUint(0, 64) // query id
      .endCell();
    await provider.internal(via, {
      value: "0.002", // send 0.002 TON for gas
      body: messageBody
    });
  }

  async getCounter(provider: ContractProvider) {
    const { stack } = await provider.get("counter", []);
    return stack.readBigNumber();
  }
}

export function useCounterContract() {
  const client = useTonClient();
  const [val, setVal] = useState<null | number>();

  const counterContract = useAsyncInitialize(async () => {
    if (!client) return;
    const contract = new Counter(
      Address.parse('EQBYLTm4nsvoqJRvs_L-IGNKwWs5RKe19HBK_lFadf19FUfb')
    );
    return client.open(contract) as OpenedContract<Counter>;
  }, [client]);

  useEffect(() => {
    async function getValue() {
      if (!counterContract) return;
      setVal(null);
      const val = await counterContract.getCounter();
      setVal(Number(val));
    }
    getValue();
  }, [counterContract]);

  return {
    value: val,
    address: counterContract?.address.toString(),
  };
}

添加 UI

下面添加一些简单的 UI,以显示读取到的数据。将 src/App.tsx 替换为以下内容:

import './App.css';
import { TonConnectButton } from '@tonconnect/ui-react';
import { useCounterContract } from './hooks/useCounterContract';

function App() {
  const { value, address } = useCounterContract();

  return (
    <div className='App'>
      <div className='Container'>
        <TonConnectButton />

        <div className='Card'>
          <b>Counter Address</b>
          <div className='Hint'>{address?.slice(0, 30) + '...'}</div>
        </div>

        <div className='Card'>
          <b>Counter Value</b>
          <div>{value ?? 'Loading...'}</div>
        </div>
      </div>
    </div>
  );
}

export default App

然后刷新页面,你应该在页面上看到从链中获取的 Counter 合约地址和读取到的计数值。

更改链上的数据

下面我们将实现发送交易来更改合约中的数据,具体就是调用 Counter 合约的increment方法来使合约中存储的计数值加一。

实现发送交易的 hook

在开始之前,我们将实现另一个 hook,该 hook 将一个sender对象来发送交易和一个connected变量表示钱包的连接状态。

使用以下内容创建文件 src/hooks/useTonConnect.ts

import { useTonConnectUI } from '@tonconnect/ui-react';
import { Sender, SenderArguments } from '@ton/core';

export function useTonConnect(): { sender: Sender; connected: boolean } {
  const [tonConnectUI] = useTonConnectUI();

  return {
    sender: {
      send: async (args: SenderArguments) => {
        tonConnectUI.sendTransaction({
          messages: [
            {
              address: args.to.toString(),
              amount: args.value.toString(),
              payload: args.body?.toBoc().toString('base64'),
            },
          ],
          validUntil: Date.now() + 5 * 60 * 1000, // 提供5分钟给用户进行approve操作
        });
      },
    },
    connected: tonConnectUI.connected,
  };
}

接下来我们要做的是改进现有的 useCounterContract  hook,并添加两个小特性。一是每 5 秒自动轮询一次计数值。这将向用户展示链上的值确实发生了变化。二是返回合约实例中的 sendIncrement 方法。

打开文件 src/hooks/useCounterContract.ts 并将其内容替换为:

import { useEffect, useState } from 'react';
import Counter from '../contracts/counter';
import { useTonClient } from './useTonClient';
import { useAsyncInitialize } from './useAsyncInitialize';
import { Contract, ContractProvider, Sender, Address, Cell, contractAddress, beginCell, OpenedContract } from "@ton/core";

export default class Counter implements Contract {

  static createForDeploy(code: Cell, initialCounterValue: number): Counter {
    const data = beginCell()
      .storeUint(initialCounterValue, 64)
      .endCell();
    const workchain = 0; // deploy to workchain 0
    const address = contractAddress(workchain, { code, data });
    return new Counter(address, { code, data });
  }
  
  constructor(readonly address: Address, readonly init?: { code: Cell, data: Cell }) {}

  async sendDeploy(provider: ContractProvider, via: Sender) {
    await provider.internal(via, {
      value: "0.01", // send 0.01 TON to contract for rent
      bounce: false
    });
  }
  
  async sendIncrement(provider: ContractProvider, via: Sender) {
    const messageBody = beginCell()
      .storeUint(1, 32) // op (op #1 = increment)
      .storeUint(0, 64) // query id
      .endCell();
    await provider.internal(via, {
      value: "0.002", // send 0.002 TON for gas
      body: messageBody
    });
  }

  async getCounter(provider: ContractProvider) {
    const { stack } = await provider.get("counter", []);
    return stack.readBigNumber();
  }
}

export function useCounterContract() {
  const client = useTonClient();
  const [val, setVal] = useState<null | string>();
  const { sender } = useTonConnect();

  const sleep = (time: number) => new Promise((resolve) => setTimeout(resolve, time));

  const counterContract = useAsyncInitialize(async () => {
    if (!client) return;
    const contract = new Counter(
      Address.parse('EQBYLTm4nsvoqJRvs_L-IGNKwWs5RKe19HBK_lFadf19FUfb')
    );
    return client.open(contract) as OpenedContract<Counter>;
  }, [client]);

  useEffect(() => {
    async function getValue() {
      if (!counterContract) return;
      setVal(null);
      const val = await counterContract.getCounter();
      setVal(val.toString());
      await sleep(5000); // sleep 5 seconds and poll value again
      getValue();
    }
    getValue();
  }, [counterContract]);

  return {
    value: val,
    address: counterContract?.address.toString(),
    sendIncrement: () => {
      return counterContract?.sendIncrement(sender);
    },
  };
}

添加UI

下面让我们添加简单的 UI 来调用合约方法。将 src/App.tsx 替换为以下内容:

import './App.css';
import { TonConnectButton } from '@tonconnect/ui-react';
import { useTonConnect } from './hooks/useTonConnect';
import { useCounterContract } from './hooks/useCounterContract';

function App() {
  const { connected } = useTonConnect();
  const { value, address, sendIncrement } = useCounterContract();

  return (
    <div className='App'>
      <div className='Container'>
        <TonConnectButton />

        <div className='Card'>
          <b>Counter Address</b>
          <div className='Hint'>{address?.slice(0, 30) + '...'}</div>
        </div>

        <div className='Card'>
          <b>Counter Value</b>
          <div>{value ?? 'Loading...'}</div>
        </div>

        <button
          className={`Button ${connected ? 'Active' : 'Disabled'}`}
          onClick={() => {
            sendIncrement();
          }}
        >
          Increment
        </button>
      </div>
    </div>
  );
}

export default App

查看效果

刷新页面,你应该在屏幕底部看到一个新的“increment”按钮。记下计数器值并点击这个按钮。

点击后钱包插件将显示一个弹窗,弹窗中会显示交易的 gas 手续费。点击弹窗中的确认按钮后交易将被发出。新交易必须等到它们被打包进区块中才算执行成功,这通常需要 5-10 秒。

如果一切正常,屏幕上的计数器值会自动刷新,你应该看到新的计数值增加了 1。

增加样式

下面添加一些样式来美化我们的应用。

将 src/index.css 替换为以下内容:

:root {
  --tg-theme-bg-color: #efeff3;
  --tg-theme-button-color: #63d0f9;
  --tg-theme-button-text-color: black;
}

.App {
  height: 100vh;
  background-color: var(--tg-theme-bg-color);
  color: var(--tg-theme-text-color);
}

.Container {
  padding: 2rem;
  max-width: 500px;
  display: flex;
  flex-direction: column;
  gap: 30px;
  align-items: center;
  margin: 0 auto;
  text-align: center;
}

.Button {
  background-color: var(--tg-theme-button-color);
  color: var(--tg-theme-button-text-color);
  display: inline-block;
  padding: 10px 20px;
  border-radius: 10px;
  cursor: pointer;
  font-weight: bold;
  width: 100%;
}

.Disabled {
  filter: brightness(50%);
  cursor: initial;
}

.Button.Active:hover {
  filter: brightness(105%);
}

.Hint {
  color: var(--tg-theme-hint-color);
}

.Card {
  width: 100%;
  padding: 10px 20px;
  border-radius: 10px;
  background-color: white;
}

@media (prefers-color-scheme: dark) {
  :root {
    --tg-theme-bg-color: #131415;
    --tg-theme-text-color: #fff;
    --tg-theme-button-color: #32a6fb;
    --tg-theme-button-text-color: #fff;
  }

  .Card {
    background-color: var(--tg-theme-bg-color);
    filter: brightness(165%);
  }

  .CounterValue {
    border-radius: 16px;
    color: white;
    padding: 10px;
  }
}

刷新页面,可以看到页面效果变得更加美观。

原文链接:TON Hello World part 3: Step by step guide for building your first web client