本文将介绍如何使用 Tact 编写 Jetton 代币合约。Jetton 可以理解为 TON 上的 ERC20 代币,前段时间很火的 NOT、CATI 等都是 Jetton 代币,虽然可以简单理解为 ERC20,但是由于 TON 是异步区块链的关系,Jetton 在实现逻辑上和 ERC20 有着巨大的差别。
## 无界数据结构(Unbounded data structures)
在 EVM 的 ERC20 代码中,用户的余额都是以 `mapping` 的数据结构存储在合约中的,合约中可以存储无限个用户的余额,即 ***Unbounded data structures***。如果想要查询任何用户的余额,只需要调用合约的 `balanceOf` 方法即可。
但是,在 TON 中,由于底层架构的不同,这种无界数据结构无法实现。TON 采用了 `Master - Wallet` 的架构来实现 Jetton 代币。
## Jetton 架构
`Master` 合约中存储了 Jetton 的核心信息,例如 `totalSupply`、`name`、`symbol` 等都位于 Jetton Master 合约中。
`Wallet` 合约并不是我们常用的类似 TonKeeper 这种钱包合约,而是与 Jetton Master 配套的 Jetton Wallet 合约。那么顾名思义,Jetton Wallet 肯定是每个人都独立拥有的钱包,因此 Jetton Wallet 也是每个用户都拥有独属于自己的 Jetton Wallet 合约。每个用户的余额都存储在属于自己的 Jetton Wallet 合约钱包中
来看看这个图例:
![image](https://hackmd.io/_uploads/ry4rURcrkg.png)
图中一共有两个 Jetton 代币,分别是 Jetton A 和 Jetton B,有两个用户 Alice 和 Bob,他们分别拥有各自的合约钱包 Alice Wallet v4 和 Bob Wallet v3,这里就是类似于 TonKeeper 的钱包。而在 Jetton 这里,Alice 和 Bob 也都拥有属于自己的 Jetton Wallet 合约钱包:
+ Jetton A Master
+ Alice Jetton A Wallet
+ Bob Jetton A Wallet
+ Jetton B Master
+ Alice Jetton B Wallet
+ Bob Jetton B Wallet
那么,如果一个 Jetton 总共有 N 个用户在使用,就会有 N + 1 个合约,分别是 N 个 Jetton Wallet 合约和 1 个 Jetton Master 合约。
ERC20 代币的转账逻辑很简单,在 sender 调用 `transfer` 的时候,将 sender 的余额减少,recipient 的余额增加即可,这都是在 ERC20 代币自身合约逻辑中实现的。但是到 TON 这边,就复杂了起来,用户的余额都是存储在自己的 Jetton Wallet 合约中的,这种情况需要怎样转账呢,TON 为我们实现了这样一套逻辑:
![image](https://hackmd.io/_uploads/ryq8UCcr1l.png)
假设 Bob 希望向 Alice 转账 Jetton 代币,将经历下面的流程:
1. Bob 的钱包向自己拥有的 Jetton Wallet 合约发送 `transfer` 消息,消息中包含转账的数量,这里假设是 100。
2. Bob 的 Jetton Wallet 钱包接收到消息后,将自己的 Jetton 余额减去 100,然后向 Alice 的 Jetton Wallet 钱包发送 `internal transfer` 消息,其中包含数量 100。
3. Alice 的 Jetton Wallet 钱包 接收到消息后,将自己的 Jetton 余额增加 100,然后向 Alice 的合约钱包发送 `transfer notification` 消息,同时向 JOE 发送 `excesses` 消息。(注意这里 `excesses` 消息的目的是为了将剩余的 Gas 返还,一般是发送给 sender,即 Bob,这里发送给 JOE 是为了展示在机制上剩余 Gas 发送给谁都可以)
## Jetton 标准范式
TON 为 Jetton 制定了一套标准,常用的是 [TEP-74](https://github.com/ton-blockchain/TEPs/blob/master/text/0074-jettons-standard.md),它类似于 EIP20 这种标准,规定了 Jetton 代币中必须要实现的范式,我们在编写 Jetton 时,都要遵循这个范式。
## 编写 Jetton 代币合约
在学习了解了 Jetton 的基本架构和逻辑之后,我们来学习如何用 Tact 编写 Jetton 代币合约。这里我们参考 [Ton-Dynasty](https://github.com/Ton-Dynasty/tondynasty-contracts) 的 Jetton 实现,它是一个使用 Tact 实现的合约库,类似于 Solidity 的 OpenZeppelin 库,我们在平时开发合约的时候可以引用他们的合约加快开发效率。
首先先找到这三个文件(二级菜单是文件中包含的合约名):
+ JettonMaster.tact
+ JettonMaster
+ JettonWallet.tact
+ JettonWallet
+ jetton_example.tact
+ ExampleJettonWallet
+ ExampleJettonMaster
为了方便我们学习和编译测试,可以创建一个新的 Blueprint 项目。然后将这几个文件的内容都复制进去,注意需要调整合约上面的导入路径。
来看这几个合约,`JettonMaster` 就是 Master 合约,但它使用了 `trait` 关键字,说明它是一个合约基类,需要被继承才能使用。它实际上已经包含了所有的核心逻辑。`ExampleJettonMaster` 合约继承了 `JettonMaster`,可以被直接部署。
与之类似,`JettonWallet` 就是 Jetton Wallet 合约,也是一个合约基类,需要被继承才能使用。`ExampleJettonWallet` 合约继承了 `JettonWallet`。
### JettonMaster
我们在 Master 合约的最开始可以看到定义的各式各样的 Message,它的本质是结构体 Struct。凡是用于消息传递的结构体都使用 `message` 关键字来定义。注意它后面紧跟着的一串 16 进制数字,例如:
```txt=
message(0x0f8a7ea5) JettonTransfer
```
这里的 `0x0f8a7ea5` 称为 opcode。在 Tact 中,所有的 Message 都有一个标识符,例如 `JettonTransfer`。但是在 Tact 编译后的更底层语言 FunC 中,实际是不存在这个标识符的,所有的消息都以 opcode 来区分。TON 中的所有数据都是存放在 Cell 结构中,消息体同样也是。opcode 就存在于消息体 Cell 中,当接收者合约收到消息时,会从消息体 Cell 中解析出 opcode,并根据它来区分不同的消息。Tact 为了方便我们编写代码,发明了这样一套语法糖。所以消息的名称叫什么其实都无所谓,只要我们为它指定好 opcode 即可。opcode 是根据 TL-B 的语法计算出来的,我们目前还不需要深入学习,了解即可。
Jetton Master 合约中包含了代币的基本信息:
```txt=
total_supply: Int; // the total number of issued jettons
mintable: Bool; // flag which indicates whether minting is allowed
owner: Address; // owner of this jetton
jetton_content: Cell; // data in accordance to Token Data Standard #64
```
值得注意的是 `jetton_content` 字段,它是一个 Cell 类型的数据。我们之前说过,TON 上的所有数据都是存放在 Cell 结构中。`jetton_content` 中一般存储 Jetton 的 metadata。例如:
```json=
{
"name": "Huebel Bolt",
"description": "Official token of the Huebel Company",
"symbol": "BOLT",
"decimals": 9,
"image_data": "https://some_image"
}
```
这里的数据结构是由[ TEP-64](https://github.com/ton-blockchain/TEPs/blob/master/text/0064-token-data-standard.md) 规定的。
接着我们来看最下面的两个 `get` 方法。
```tact=
get fun get_jetton_data(): JettonData {
return JettonData{
total_supply: self.total_supply,
mintable: self.mintable,
admin_address: self.owner,
jetton_content: self.jetton_content,
jetton_wallet_code: self.calculate_jetton_wallet_init(myAddress()).code
};
}
```
`get_jetton_data` 方法返回 Jetton 的一些数据,这也是在 TEP-74 中规定的方法,注意最后的一项数据是 `jetton_wallet_code`,它是 Jetton Master 对应的 Jetton Wallet 合约的代码,但是这个 `calculate_jetton_wallet_init` 方法当前是 `abstract`,也就是需要在子类中进行实现。
```tact=
get fun get_wallet_address(owner_address: Address): Address {
let initCode: StateInit = self.calculate_jetton_wallet_init(owner_address);
return contractAddress(initCode);
}
```
`get_wallet_address` 是查询用户 Jetton Wallet 合约地址的方法。前面说过,每个用户都有自己独立的 Jetton Wallet,它的地址就是通过这个方法计算出来的。我们可以看到第一行获取了一个 `StateInit` 类型的数据,可以将它理解为合约代码与初始化参数构成了一个实例。第二行使用 `contractAddress` 获取这个实例的地址。这也从代码层面说明了,合约的地址是由合约代码和初始化参数唯一确定的。
再来看 `JettonMaster` 合约剩下的代码,目前,主要还有两部分的逻辑:
+ Mint
+ Burn
我们先来看 `mint` 是一个怎样的流程。
![image](https://hackmd.io/_uploads/BJ2AqlhHJe.png)
Mint 操作一般只能由 Owner 发起,在 Jetton 中,Owner 向 Master 合约发送 `JettonMint` 消息:
```tact=
receive(msg: JettonMint) {
let ctx: Context = context();
self._mint_validate(ctx, msg);
self._mint(ctx, msg);
}
```
随后合约校验发起者是否为 Owner:
```tact!
virtual inline fun _mint_validate(ctx: Context, msg: JettonMint) {
require(ctx.sender == self.owner, "JettonMaster: Sender is not a Jetton owner");
require(self.mintable, "JettonMaster: Jetton is not mintable");
}
```
`Context` 是合约中获取上下文的对象,例如 sender 就是 `ctx.sender`。这里校验 sender 是否为 owner,以及是否可以 mint(`mintable`)。
最后执行内部方法 `_mint` 铸币,我们重点来看这里:
```tact!
virtual inline fun _mint(ctx: Context, msg: JettonMint) {
let initCode: StateInit = self.calculate_jetton_wallet_init(msg.receiver);
self.total_supply = self.total_supply + msg.amount;
send(SendParameters {
to: contractAddress(initCode),
value: 0,
bounce: true,
mode: SendRemainingValue,
body: JettonInternalTransfer {
query_id: 0,
amount: msg.amount,
response_address: msg.origin,
from: myAddress(),
forward_ton_amount: msg.forward_ton_amount,
forward_payload: msg.forward_payload
}.toCell(),
code: initCode.code,
data: initCode.data
});
}
```
首先获取到这笔 mint 接收人的 Jetton Wallet 合约示例 `initCode`,然后增加 `total_supply` 的数量,最后发送消息。
发送消息这部分与我们之前的介绍相比,多了两个字段:
+ code: initCode.code
+ data: initCode.data
它们分别是 `initCode` 实例的代码和数据。这是什么意思呢,如果 Owner 想给一个用户 mint 一点代币,但是这个用户他可能并没有对应的 Jetton Wallet 合约,那就需要部署。这里的 `code` 和 `data` 就是在部署 Wallet 合约时需要使用到的代码和初始化数据。有了 `code` 和 `data`,并且前面已经获得了接收者的 Jetton Wallet 合约地址(即 `contractAddress(initCode)`),就可以在该地址上部署用户拥有的 Wallet 合约。如果合约之前已经被部署过,那就可以省略部署这一步。
其实我们在部署自己的合约时,也可以将整个过程理解成发送消息并附带了 `code` 和 `data`,如果合约不存在则部署,存在则忽略部署步骤,只是一笔简单的消息。
再来看看发送的消息体 `JettonInternalTransfer`,它的内容由 TEP-74 规定。
```tact!
body: JettonInternalTransfer {
query_id: 0,
amount: msg.amount,
response_address: msg.origin,
from: myAddress(),
forward_ton_amount: msg.forward_ton_amount,
forward_payload: msg.forward_payload
}.toCell()
```
`JettonInternalTransfer` 结构体的名字其实可以随意,这是一个只在 Tact 中有意义的标识符,在 FunC 中,其实并不存在消息体名字,都是以消息的 opcode 来做区分。来看结构体的内容:
+ query_id,可以理解为一个查询 id,一般没什么太大意义
+ amount,要转账的 Jetton 数量
+ response_address,最终剩余的 Gas 返还者,一般的消息的发起 sender
+ from:一般是 Jetton Master 地址或用户地址
+ forward_ton_amount:附加给 `transfer notification` 消息的 Gas 数量
+ forward_payload:附加给 `transfer notification` 消息的 payload
这几个字段大家现在看着可能概念有点模糊,我们在后面的代码中会看到它们的作用。
Burn 部分由于入口在 `JettonWallet` 合约中,因此放在下部分再讲。
### JettonWallet
`JettonInternalTransfer` 消息被发送给了 JettonWallet 合约,它的接收方法如下:
```tact!
receive(msg: JettonInternalTransfer) {
let ctx: Context = context();
self.balance = self.balance + msg.amount;
require(self.balance >= 0, "JettonWallet: Not allow negative balance after internal transfer");
self._internal_transfer_validate(ctx, msg);
let remain: Int = self._internal_transfer_estimate_remain_value(ctx, msg);
if (msg.forward_ton_amount > 0){
self._internal_transfer_notification(ctx, msg);
}
self._internal_transfer_excesses(ctx, msg, remain);
}
```
首先更新余额,然后对消息的发送者进行校验:
```tact!
virtual inline fun _internal_transfer_validate(
ctx: Context,
msg: JettonInternalTransfer
) {
if(ctx.sender != self.jetton_master){
// 要求 sender 是 msg.from 的 Jetton 钱包地址
let init: StateInit = self.calculate_jetton_wallet_init(msg.from);
require(ctx.sender == contractAddress(init), "JettonWallet: Only Jetton master or Jetton wallet can call this function");
}
// 如果是来自 master 的消息,直接通过
}
```
`JettonInternalTransfer` 消息只会来自两个地方:
+ Master 合约,用于 mint
+ 其它的 Jetton Wallet 合约,用于转账
这里如果 sender 是 Master 合约,则直接通过。如果 sender 不是 Master,那么它就必须是其它的 Jetton Wallet 合约。首先获取初始发送者的 Jetton Wallet 合约实例,然后校验其地址是否是当前消息的 sender。
随后计算出最终可剩余的 Gas:
```tact!
virtual inline fun _internal_transfer_estimate_remain_value(
ctx: Context,
msg: JettonInternalTransfer
): Int {
let tonBalanceBeforeMsg: Int = myBalance() - ctx.value;
let storage_fee: Int = self.minTonsForStorage - min(tonBalanceBeforeMsg, self.minTonsForStorage);
let remain: Int = ctx.value - (storage_fee + self.gasConsumption);
if (msg.forward_ton_amount > 0) {
remain = remain - (ctx.readForwardFee() + msg.forward_ton_amount);
}
return remain;
}
```
`myBalance()` 是包含入场 Gas 的当前合约 TON 余额,因此 `myBalance() - ctx.value` 是接收消息之前合约的余额
TON 上的合约需要缴纳租金才能持续使用,因此这里需要给合约中留下一部分 TON 作为租金的数量。`minTonsForStorage` 的值,合约中硬编码为 `ton("0.01")`,是一个经过测试的可以支付一段时间租金的数量。并不是非要这个数,你想写 0.001 也可以,0.1 也可以。
```tact!
if (msg.forward_ton_amount > 0){
self._internal_transfer_notification(ctx, msg);
}
```
这里说明了前面 `JettonInternalTransfer` 消息中 `forward_ton_amount` 字段的作用。在它大于 0 的时候,会向当前 Jetton Wallet 钱包的 owner,也就是接收者的合约钱包发送一条 `transfer notification` 消息:
```tact!
virtual inline fun _internal_transfer_notification(
ctx: Context,
msg: JettonInternalTransfer
) {
if (msg.forward_ton_amount > 0) {
send(SendParameters {
to: self.owner,
value: msg.forward_ton_amount,
mode: SendPayGasSeparately,
bounce: false,
body: JettonTransferNotification {
query_id: msg.query_id,
amount: msg.amount,
sender: msg.from,
forward_payload: msg.forward_payload
}.toCell()
});
}
}
```
这条消息在有些场景中非常重要。由于 Jetton 中不存在 ***Approve - TransferFrom*** 的机制,因此在一些应用例如 DEX 中,无法用使用该机制,Jetton 只能由用户手动转入。那么 DEX 合约怎么知道 Jetton 已经转入呢,就是靠 `TransferNotification` 消息。在 DEX 等应用中,需要存在接收 `TransferNotification` 消息的逻辑。当收到该消息时,DEX 便知道有一笔新的 Jetton 转入,然后开始进行操作。
最后再调用 `_internal_transfer_excesses` 方法将剩余的 Gas 转走,一般是转给消息的初始发送者:
```tact=
virtual inline fun _internal_transfer_excesses(
ctx: Context,
msg: JettonInternalTransfer,
remain: Int
){
if((msg.response_address != newAddress(0, 0)) && remain > 0){
send(SendParameters {
to: msg.response_address,
value: remain,
bounce: false,
mode: SendIgnoreErrors,
body: JettonExcesses {
query_id: msg.query_id
}.toCell()
});
}
}
```
我们来看看 Jetton Wallet 中的转账逻辑。流程图如下:
![image](https://hackmd.io/_uploads/rJyMig2Byx.png)
如果用户想要将 Jetton 转给别人,则需要发送 `JettonTransfer` 给自己的 Jetton Wallet 合约:
```tact=
receive(msg: JettonTransfer) {
let ctx: Context = context();
self.balance = self.balance - msg.amount;
require(self.balance >= 0, "JettonWallet: Not enough jettons to transfer");
// 校验 sender 是该 Jetton 钱包的 owner
self._transfer_validate(ctx, msg);
self._transfer_estimate_remain_value(ctx, msg);
self._transfer_jetton(ctx, msg);
}
```
首先减去要转出的数量,随后通过 `_transfer_validate` 方法校验 sender 是否为该 Jetton Wallet 的 Owner。然后是 `_transfer_estimate_remain_value` 方法:
```tact=
virtual inline fun _transfer_estimate_remain_value(ctx: Context, msg: JettonTransfer) {
let fwd_count: Int = 1;
if (msg.forward_ton_amount > 0) {
fwd_count = 2;
}
require(ctx.value > fwd_count * ctx.readForwardFee() + 2 * self.gasConsumption + self.minTonsForStorage, "Not enough funds to transfer");
}
```
简单来讲,该方法就是计算出整个转账流程所需的最小 Gas 数量,并要求初始的 Gas 大于这个数量。由于 TON 的一次交易是由多个交易组成的交易链,并且不具备原子性,因此为了避免中途 Gas 不足的场景,就需要在交易的初始入口校验 Gas 是否充足。
合约中的 `gasConsumption` 和 `minTonsForStorage` 都是经过大量的测试得出的合适值,并不一定非要用某个数字,只是这个数字比较合适。
`ctx.readForwardFee()` 是发送消息所需的花费,也就是我们上篇文章类比的 ***邮费***,它并不包含附带的 Gas。如果 `forward_ton_amount` 大于 0,则说明还需要发送一条 `TransferNotification` 消息,一共是两条消息。可能有朋友问,还有一条 `excesses` 消息怎么没有算进去,因为它转移的是剩余的 Gas,如果没有剩余的 Gas,那也是正常情况,就无需发送 `excesses` 消息。
我们注意到,发送 `JettonInternalTransfer` 消息时指定了 `bounce` 为 `true`,说明我们希望在消息遇到错误时可以返回一条 bounced 消息方便我们进行错误处理:
```tact=
bounced(src: bounced<JettonInternalTransfer>) {
self.balance = self.balance + src.amount;
}
```
这里便是处理错误情况的方法,由于在发送消息之前已经扣除了余额,因此如果消息出错,需要将余额再加回来。但是注意,由于 bounced 消息只能携带 224 bits 的有效数据,可用空间很小,所以其实它的可用性不高。因此在一般的业务开发中,还是推荐手动编码错误处理方法。
最后来看看 Burn 部分,先来看它的流程是怎样的:
![image](https://hackmd.io/_uploads/H19mjlnBJe.png)
```tact=
receive(msg: JettonBurn) {
let ctx: Context = context();
self.balance = self.balance - msg.amount;
require(self.balance >= 0, "JettonWallet: Not enough balance to burn tokens");
// 校验 sender 是 Jetton 钱包的 owner
self._burn_validate(ctx, msg);
self._burn_tokens(ctx, msg);
}
```
首先扣除余额,然后通过 `_burn_validate` 方法来校验 sender 是否为该 Jetton Wallet 合约的 Owner,最后通过 `_burn_tokens` 方法向 Jetton Master 合约发送 `JettonBurnNotification` 消息:
```tact=
virtual inline fun _burn_tokens(ctx: Context, msg: JettonBurn) {
send(SendParameters{
to: self.jetton_master,
value: 0,
mode: SendRemainingValue,
bounce: true,
body: JettonBurnNotification{
query_id: msg.query_id,
amount: msg.amount,
sender: self.owner,
response_destination: msg.response_destination
}.toCell()
});
}
```
回到 `JettonMaster` 合约的消息接收部分:
```tact=
receive(msg: JettonBurnNotification) {
let ctx: Context = context();
self._burn_notification_validate(ctx, msg);
self._burn_notification(ctx, msg);
}
```
`_burn_notification_validate` 与 `_internal_transfer_validate` 方法类似,都是校验 sender 是否合法。随后在 `_burn_notification` 方法中,将 `total_supply` 更新,然后将剩余的 Gas 返还。
Jetton 的主要逻辑大概就是这样,大家还需多看多思考其中的流程逻辑以及合约关系。
## 部署 Jetton
大家可以尝试自行在 Blueprint 中编写部署脚本并部署到测试网中,重点在于学习理解区块浏览器中显示的整个消息流,并与前面介绍的流程图对比是否一致。
## 总结
本文主要介绍了 Jetton 代币的 Tact 实现,重点在于入门介绍,大家还是要多阅读代码,并尝试上手编写,多思考。这部分的难点主要还是在于各个场景中的消息流走向,是编写 TON 上 DeFi,GameFi 等协议的基础。
## 关于我
欢迎[和我交流](https://linktr.ee/xyymeeth)