本文将介绍如何使用Tact编写Jetton代币合约。Jetton可以理解为TON上的ERC20代币,前段时间很火的NOT、CATI等都是Jetton代币,虽然可以简单理解为ERC20
本文将介绍如何使用 Tact 编写 Jetton 代币合约。Jetton 可以理解为 TON 上的 ERC20 代币,前段时间很火的 NOT、CATI 等都是 Jetton 代币,虽然可以简单理解为 ERC20,但是由于 TON 是异步区块链的关系,Jetton 在实现逻辑上和 ERC20 有着巨大的差别。
在 EVM 的 ERC20 代码中,用户的余额都是以 mapping
的数据结构存储在合约中的,合约中可以存储无限个用户的余额,即 Unbounded data structures。如果想要查询任何用户的余额,只需要调用合约的 balanceOf
方法即可。
但是,在 TON 中,由于底层架构的不同,这种无界数据结构无法实现。TON 采用了 Master - Wallet
的架构来实现 Jetton 代币。
Master
合约中存储了 Jetton 的核心信息,例如 totalSupply
、name
、symbol
等都位于 Jetton Master 合约中。
Wallet
合约并不是我们常用的类似 TonKeeper 这种钱包合约,而是与 Jetton Master 配套的 Jetton Wallet 合约。那么顾名思义,Jetton Wallet 肯定是每个人都独立拥有的钱包,因此 Jetton Wallet 也是每个用户都拥有独属于自己的 Jetton Wallet 合约。每个用户的余额都存储在属于自己的 Jetton Wallet 合约钱包中
来看看这个图例:
图中一共有两个 Jetton 代币,分别是 Jetton A 和 Jetton B,有两个用户 Alice 和 Bob,他们分别拥有各自的合约钱包 Alice Wallet v4 和 Bob Wallet v3,这里就是类似于 TonKeeper 的钱包。而在 Jetton 这里,Alice 和 Bob 也都拥有属于自己的 Jetton Wallet 合约钱包:
那么,如果一个 Jetton 总共有 N 个用户在使用,就会有 N + 1 个合约,分别是 N 个 Jetton Wallet 合约和 1 个 Jetton Master 合约。
ERC20 代币的转账逻辑很简单,在 sender 调用 transfer
的时候,将 sender 的余额减少,recipient 的余额增加即可,这都是在 ERC20 代币自身合约逻辑中实现的。但是到 TON 这边,就复杂了起来,用户的余额都是存储在自己的 Jetton Wallet 合约中的,这种情况需要怎样转账呢,TON 为我们实现了这样一套逻辑:
假设 Bob 希望向 Alice 转账 Jetton 代币,将经历下面的流程:
transfer
消息,消息中包含转账的数量,这里假设是 100。internal transfer
消息,其中包含数量 100。transfer notification
消息,同时向 JOE 发送 excesses
消息。(注意这里 excesses
消息的目的是为了将剩余的 Gas 返还,一般是发送给 sender,即 Bob,这里发送给 JOE 是为了展示在机制上剩余 Gas 发送给谁都可以)TON 为 Jetton 制定了一套标准,常用的是 TEP-74,它类似于 EIP20 这种标准,规定了 Jetton 代币中必须要实现的范式,我们在编写 Jetton 时,都要遵循这个范式。
在学习了解了 Jetton 的基本架构和逻辑之后,我们来学习如何用 Tact 编写 Jetton 代币合约。这里我们参考 Ton-Dynasty 的 Jetton 实现,它是一个使用 Tact 实现的合约库,类似于 Solidity 的 OpenZeppelin 库,我们在平时开发合约的时候可以引用他们的合约加快开发效率。
首先先找到这三个文件(二级菜单是文件中包含的合约名):
为了方便我们学习和编译测试,可以创建一个新的 Blueprint 项目。然后将这几个文件的内容都复制进去,注意需要调整合约上面的导入路径。
来看这几个合约,JettonMaster
就是 Master 合约,但它使用了 trait
关键字,说明它是一个合约基类,需要被继承才能使用。它实际上已经包含了所有的核心逻辑。ExampleJettonMaster
合约继承了 JettonMaster
,可以被直接部署。
与之类似,JettonWallet
就是 Jetton Wallet 合约,也是一个合约基类,需要被继承才能使用。ExampleJettonWallet
合约继承了 JettonWallet
。
我们在 Master 合约的最开始可以看到定义的各式各样的 Message,它的本质是结构体 Struct。凡是用于消息传递的结构体都使用 message
关键字来定义。注意它后面紧跟着的一串 16 进制数字,例如:
message(0x0f8a7ea5) JettonTransfer
这里的 0x0f8a7ea5
称为 opcode。在 Tact 中,所有的 Message 都有一个标识符,例如 JettonTransfer
。但是在 Tact 编译后的更底层语言 FunC 中,实际是不存在这个标识符的,所有的消息都以 opcode 来区分。TON 中的所有数据都是存放在 Cell 结构中,消息体同样也是。opcode 就存在于消息体 Cell 中,当接收者合约收到消息时,会从消息体 Cell 中解析出 opcode,并根据它来区分不同的消息。Tact 为了方便我们编写代码,发明了这样一套语法糖。所以消息的名称叫什么其实都无所谓,只要我们为它指定好 opcode 即可。opcode 是根据 TL-B 的语法计算出来的,我们目前还不需要深入学习,了解即可。
Jetton Master 合约中包含了代币的基本信息:
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。例如:
{
"name": "Huebel Bolt",
"description": "Official token of the Huebel Company",
"symbol": "BOLT",
"decimals": 9,
"image_data": "https://some_image"
}
这里的数据结构是由 TEP-64 规定的。
接着我们来看最下面的两个 get
方法。
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
,也就是需要在子类中进行实现。
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
是一个怎样的流程。
Mint 操作一般只能由 Owner 发起,在 Jetton 中,Owner 向 Master 合约发送 JettonMint
消息:
receive(msg: JettonMint) {
let ctx: Context = context();
self._mint_validate(ctx, msg);
self._mint(ctx, msg);
}
随后合约校验发起者是否为 Owner:
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
铸币,我们重点来看这里:
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
的数量,最后发送消息。
发送消息这部分与我们之前的介绍相比,多了两个字段:
它们分别是 initCode
实例的代码和数据。这是什么意思呢,如果 Owner 想给一个用户 mint 一点代币,但是这个用户他可能并没有对应的 Jetton Wallet 合约,那就需要部署。这里的 code
和 data
就是在部署 Wallet 合约时需要使用到的代码和初始化数据。有了 code
和 data
,并且前面已经获得了接收者的 Jetton Wallet 合约地址(即 contractAddress(initCode)
),就可以在该地址上部署用户拥有的 Wallet 合约。如果合约之前已经被部署过,那就可以省略部署这一步。
其实我们在部署自己的合约时,也可以将整个过程理解成发送消息并附带了 code
和 data
,如果合约不存在则部署,存在则忽略部署步骤,只是一笔简单的消息。
再来看看发送的消息体 JettonInternalTransfer
,它的内容由 TEP-74 规定。
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 来做区分。来看结构体的内容:
transfer notification
消息的 Gas 数量transfer notification
消息的 payload这几个字段大家现在看着可能概念有点模糊,我们在后面的代码中会看到它们的作用。
Burn 部分由于入口在 JettonWallet
合约中,因此放在下部分再讲。
JettonInternalTransfer
消息被发送给了 JettonWallet 合约,它的接收方法如下:
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);
}
首先更新余额,然后对消息的发送者进行校验:
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
消息只会来自两个地方:
这里如果 sender 是 Master 合约,则直接通过。如果 sender 不是 Master,那么它就必须是其它的 Jetton Wallet 合约。首先获取初始发送者的 Jetton Wallet 合约实例,然后校验其地址是否是当前消息的 sender。
随后计算出最终可剩余的 Gas:
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 也可以。
if (msg.forward_ton_amount > 0){
self._internal_transfer_notification(ctx, msg);
}
这里说明了前面 JettonInternalTransfer
消息中 forward_ton_amount
字段的作用。在它大于 0 的时候,会向当前 Jetton Wallet 钱包的 owner,也就是接收者的合约钱包发送一条 transfer notification
消息:
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 转走,一般是转给消息的初始发送者:
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 中的转账逻辑。流程图如下:
如果用户想要将 Jetton 转给别人,则需要发送 JettonTransfer
给自己的 Jetton Wallet 合约:
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
方法:
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 消息方便我们进行错误处理:
bounced(src: bounced<JettonInternalTransfer>) {
self.balance = self.balance + src.amount;
}
这里便是处理错误情况的方法,由于在发送消息之前已经扣除了余额,因此如果消息出错,需要将余额再加回来。但是注意,由于 bounced 消息只能携带 224 bits 的有效数据,可用空间很小,所以其实它的可用性不高。因此在一般的业务开发中,还是推荐手动编码错误处理方法。
最后来看看 Burn 部分,先来看它的流程是怎样的:
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
消息:
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
合约的消息接收部分:
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 的主要逻辑大概就是这样,大家还需多看多思考其中的流程逻辑以及合约关系。
大家可以尝试自行在 Blueprint 中编写部署脚本并部署到测试网中,重点在于学习理解区块浏览器中显示的整个消息流,并与前面介绍的流程图对比是否一致。
本文主要介绍了 Jetton 代币的 Tact 实现,重点在于入门介绍,大家还是要多阅读代码,并尝试上手编写,多思考。这部分的难点主要还是在于各个场景中的消息流走向,是编写 TON 上 DeFi,GameFi 等协议的基础。
欢迎和我交流
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!