Crypto OS
Technical Crypto OS第二阶段 · Smart Contract Engineering

T11 · Token Program

为什么 Solana 上的 Token 不是一份独立合约?

练习的能力
Builder
动手
在 Devnet 创建一个 Mint,给两个钱包各建 ATA,完成一次转账。
AI Lab
让 AI 解释 Token-2022 的扩展能力,再核对哪些扩展会让常见钱包显示异常。

一个现实问题

在 EVM 上发一个代币,流程你已经很熟了:写一份合约、部署、拿到一个地址,用户的余额存在你这份合约的 mapping 里。你的代币 = 你的一份代码。

你到 Solana 上做同一件事,三个意外接连出现。

第一个:你没有部署任何程序,只发了一笔交易,代币就有了。总量、精度、谁能增发,全部设置好了。你写的代码行数是零。

第二个:你想给朋友转 100 个,失败了。原因是「对方还没有这个代币的账户」。你得先替他开一个户,而且开户要交一笔押金。

第三个:你想在转账时加一点自定义逻辑——比如每笔抽 1%。你找不到任何地方可以写这段代码。

三个意外指向同一个问题:这个代币的逻辑到底跑在哪里?如果不是我的代码,那是谁的?

思想实验

对比两种发币方式。

方式 A:每个代币自己开一家银行。

你盖一栋楼(部署合约),楼里放一本账本(mapping),账本上记着「谁有多少」。一千个代币就是一千栋楼、一千本账本、一千份几乎一模一样的代码。

钱包要支持一千个代币,理论上要认识一千栋楼——好在大家盖的楼长得差不多(T9 的标准),所以钱包按同一套接口去问就行。但「长得差不多」不是强制的,所以就有了 T9 那三天的事故。

方式 B:全世界只有一套银行软件。

不盖楼了。所有代币共用同一份程序。每个代币只是一条发行信息:总量多少、精度几位、谁能增发、谁能冻结。而每个人的余额,是一个属于他自己的独立账户,账户里写着三件事:这是哪个代币、属于谁、有多少。

现在把这个模型的后果一条条推出来,这一章的所有内容都在里面:

  1. 钱包只要认识一份程序,就认识所有代币。 显示、转账、授权全部统一,不需要为每个代币适配。
  2. 代币作者没有地方写自定义逻辑。 转账的代码是那份共享程序的,不是你的。于是 T9 里的「转账扣费」「余额自变」这些东西,在这个模型下根本不可能存在
  3. 余额账户必须显式创建。 因为它是一个独立的账户,得有人创建它、有人为它的存储付押金。「给谁转账,谁得先有户」就是从这来的。
  4. 想要自定义行为,只能由那份共享程序开放扩展点。 于是有了新版本的代币程序和它的扩展机制。

第 2 条和第 4 条合起来是这一章最有意思的地方:Solana 先用「共享程序」消灭了 T9 的那一整类坑,然后又用「扩展」把其中一部分有节制地放了回来。

你来决定

你要给 500 个新用户空投代币,他们大多数还没有这个代币的账户。怎么处理?

观察结果

四个选项本质上都在回答同一个问题:这块存储的押金,由谁出,归谁。

做法谁付押金押金归谁失败风险适合场景
直接转高,对方没户就失败已知对方有账户
你替他开户用户主动推送、要求高转化
用户自己开用户用户主动领取式发放
同笔交易幂等创建由你指定用户最低几乎所有场景的默认做法

一条值得单独记住的结论:

同一笔存储成本,在 EVM 上藏在 Gas 里由转账者一次性烧掉,在 Solana 上变成一笔可退还的押金,归账户所有者。

这不是谁更便宜的问题,是成本被放在了不同的位置,因此可以被不同的人利用。上面那个「领空投再关户拿押金」的套路,在 EVM 上根本不存在,因为那笔钱一开始就烧掉了,没有人能退。

设计任何 Solana 上的发放机制时,把这一条放进风险清单:你付出去的押金,最终会落到谁手里。

建立模型

两种账户

Solana 上的代币由两类账户构成,它们都是 T10 讲的普通账户,owner 是那份共享的代币程序。

Mint 账户(一个代币一个)
 ├─ supply             当前总量
 ├─ decimals           精度
 ├─ mintAuthority      谁能增发。可以设成「无」,从此不可增发
 └─ freezeAuthority    谁能冻结持有人的账户。可以设成「无」

Token 账户(一个人一个代币一个)
 ├─ mint               这是哪个代币的账户
 ├─ owner              这个账户属于谁
 ├─ amount             余额
 ├─ delegate           授权给谁,以及授权多少
 └─ state              正常 / 已冻结

关联账户是其中最重要的一个约定:一个人的某个代币账户,地址由「持有人地址 + 代币程序 + Mint 地址」派生出来。这正是 T10 的派生地址在起作用——任何人都能算出「某某人的某某代币账户在哪」,不需要查询。

钱包能列出你所有的代币余额,靠的就是这个:按 owner 过滤出所有属于你的代币账户。

和 ERC20 逐条对照

这张表是这一章的核心产出,值得反复看:

问题ERC20Solana 上的 Token
代码在哪每个代币一份,各写各的全网共享一份程序
发一个新代币部署一份合约发一笔交易创建 Mint 账户
余额存在哪代币合约里的 mapping持有人各自独立的账户
收款前需要准备吗不需要需要先有账户并付押金
能否自定义转账逻辑能,这是 T9 所有坑的来源基础版不能;新版通过扩展有限开放
授权模型approve 额度delegate,一个账户同时只能有一个
谁付存储成本转账者,烧在 Gas 里账户创建者,一笔可退押金
名称、图标等元数据写在合约里由另外的程序或扩展提供
增发权限合约代码里的逻辑Mint 账户上的一个字段,可以被放弃
冻结某人需要代币自己实现黑名单程序原生支持,看 freezeAuthority 在不在

倒数两行特别值得注意:增发和冻结在这里是程序原生能力,写在数据字段上,任何人都能一眼查到。 在 EVM 上你得读源码才知道有没有后门;在这里,只要读一下 Mint 账户,mintAuthorityfreezeAuthority 是不是空,一目了然。

这是一个被低估的优点:把权限变成数据,比把权限藏在代码里更容易审计。

新版代币程序与扩展

共享程序解决了一致性,也带来了刚性:没有人能加任何新功能。于是出现了一个新版本的代币程序,它在保持同样模型的同时,允许在创建 Mint 时选择开启若干扩展

常见的扩展覆盖这几类能力:转账时收取费用、代币不可转让、新账户默认冻结、元数据直接存在 Mint 上、利息累积、以及在转账时调用一个外部程序的钩子。

到这里,一件值得停下来想的事发生了:

转账收费、余额自变、转账时调用外部代码——这些正是 T9 里让集成方翻车的那几类行为。

区别在哪?在于它们现在是显式声明在账户上的,而不是藏在代码里的。集成方可以在接受一个代币之前,读一遍它开了哪些扩展,然后决定支不支持。T9 里你必须读源码才能发现的东西,在这里变成了一个可以被程序化检查的字段。

但风险并没有消失,只是换了形态:

  • 钱包和协议需要逐个扩展去适配。 一个开了冷门扩展的代币,在很多界面上会显示异常,或者干脆不显示。
  • 转账钩子意味着转账会执行外部代码。 集成方必须把它当成不可信的外部调用来对待——这和 T9 里那个带回调的代币标准是同一类风险。
  • 老的集成方完全不认识这些扩展。 按旧假设写的代码,遇到收费扩展照样记错账。

所以这一章的真正结论是:共享程序消灭了「行为不可预测」,扩展把它以「可声明、可检查」的形式放了回来。可检查不等于安全,只是让检查成为可能。

它叫什么

Token Program代币程序

一份被所有代币共享的程序。它定义了创建、增发、转账、授权、冻结、销毁这些动作。

因为所有代币共用它,钱包和协议只需要适配一次。代币作者不部署代码,只创建数据。

Mint铸造账户

一个代币的发行信息账户:总量、精度、增发权限、冻结权限。

它就是这个代币的「地址」——人们说「某某代币的地址」时,指的是它的 Mint 账户地址。

Token Account代币账户

某个人持有某个代币的账户,记录着所属代币、所有者、余额、授权和冻结状态。

一个人持有 N 种代币,就有 N 个这样的账户,每个都需要免租押金。

ATA关联代币账户

由「持有人 + 代币」派生出来的那个代币账户,地址可以被任何人算出来。

它让「某某人的某某代币在哪」成为一个确定的答案,而不需要查询或维护索引。这是 PDA 在实际系统里最成功的一个应用。

Mint / Freeze Authority增发与冻结权限

写在 Mint 账户上的两个字段。可以被永久放弃,设成「无」。

研究任何一个 Solana 代币时,第一件事就是读这两个字段。增发权限还在,意味着总量随时可以变;冻结权限还在,意味着发行方可以随时让任何人的余额动不了。

Transfer Hook转账钩子

新版代币程序的一个扩展:转账时调用一个指定的外部程序。

它让代币重新获得了自定义转账行为的能力,也把「转账会执行不可信代码」这个风险带了回来。集成时要按外部调用对待,而不是按一次普通转账。

动手

动手在 Devnet 创建一个 Mint,给两个钱包各建 ATA,完成一次转账Solana CLI + spl-token CLI0 元,全程 Devnet,测试币从水龙头领

全程用命令行,不写一行代码。目的是让账户模型在你手上变成具体的东西

切到 Devnet 并领水。

solana config set --url devnet
solana address
solana airdrop 2
solana balance

全程 Devnet,不要用任何持有真实资产的钱包。 给这个练习单独生成一个密钥文件。

创建一个 Mint。

spl-token create-token --decimals 6

记下输出的 Mint 地址。注意这一步的本质:你只是创建了一个账户,没有部署任何程序。

然后去读它:

solana account <MINT>
spl-token display <MINT>

看一眼 mintAuthorityfreezeAuthority 现在是谁——是你。

给自己创建代币账户并增发。

spl-token create-account <MINT>
spl-token mint <MINT> 1000
spl-token balance <MINT>

创建账户那一步,注意命令输出里的押金金额,再用 solana balance 对比前后的变化。那笔钱没有消失,它躺在你刚创建的账户里。

生成第二个钱包,给它转账。

solana-keygen new --no-bip39-passphrase -o ./wallet2.json
solana address -k ./wallet2.json

先试一次不加任何额外参数的转账,看它怎么失败:

spl-token transfer <MINT> 10 <WALLET2>

错误信息会告诉你对方没有账户。这就是本章第二个意外的现场。

再加上「顺便帮对方创建账户」的参数重试:

spl-token transfer <MINT> 10 <WALLET2> --fund-recipient

这一次成功了,而且你的 SOL 余额又少了一笔——你替对方付了押金。

验证关联账户地址是「算」出来的。

spl-token address --token <MINT> --owner <WALLET2> --verbose
spl-token accounts --owner <WALLET2>

你在对方什么都没做的情况下,就知道了他的代币账户地址。这就是派生地址的价值。

放弃增发权限,再试着增发。

spl-token authorize <MINT> mint --disable
spl-token mint <MINT> 1

第二条命令会失败。现在再 spl-token display 一次,mintAuthority 已经是空的了。

这个字段就是别人研究你的代币时会看的第一样东西。 你刚刚做的,是一次公开且不可逆的承诺。

用新版代币程序再来一遍。 先查参数:

spl-token create-token --help

从帮助里找到指定新版程序的参数,以及开启扩展的参数。挑一个扩展(比如转账费)创建第二个代币,走完同样的流程,然后用一个常见钱包导入这两个代币,对比显示效果

不同钱包的支持程度差别很大,这一步的观察正是下面 AI Lab 要核对的东西。

AI Lab

AI Lab让 AI 解释新版代币程序的扩展能力,你去核对哪些扩展会让常见钱包显示异常Level 2 · AI Copilot

分三步,第三步是这个 Lab 真正的产出。

第一步:
列出新版 Solana 代币程序提供的扩展能力。每一个说明:
它改变了什么行为、必须在什么时候开启、能不能事后关闭。
如果你不确定某个扩展是否存在,明确标注出来。

第二步:
在这些扩展里,哪些会让按旧假设写的集成方出错?
具体说明:会错在哪一步、错的表现是什么。

第三步:
把这些扩展和 ERC20 世界里的非标准实现做一个对照表:
哪些是同一类问题的两种形态?哪些是 Solana 独有的?

第一步你会拿到一份看起来很完整的清单。逐个去官方文档搜。 模型在这类「枚举一个规范里有哪些条目」的任务上,命中率高,但几乎一定会混进一两个不存在的——而它编出来的名字通常非常合理,因为它是按「这里应该有一个这样的能力」推出来的。

第二步质量通常不错,因为这是推理题不是记忆题。记住这条分界线:需要精确回忆的交给核对,需要归纳推理的交给模型。 T9 的 AI Lab 里你已经见过一次,这里是同一条规律的第二个样本。

第三步是给你自己的收获。做完这张对照表,你会发现 EVM 和 Solana 在代币这件事上是同一组权衡的两种不同排布:一个默认开放、靠生态约定收敛;一个默认统一、靠显式扩展放开。哪种更好取决于你在意什么,但两边的集成方都必须做同一件事:在接受一个代币之前,搞清楚它到底会怎么行动。

最后动手验一次:在 Devnet 上创建几个开了不同扩展的代币,用两三个常见钱包导入,截图对比。模型说的「钱包支持情况」几乎全部需要实测,因为这是一个每周都在变的事实。

AI 说完之后,你必须自己验证

  • 它列出的每一个扩展名,你都在官方文档里查到了;模型很擅长编出听起来很合理的扩展名
  • 它说「某某钱包支持或不支持」的每一条结论,你都在 Devnet 上用真实钱包试过
  • 它有没有把扩展说成默认开启,实际上扩展必须在创建 Mint 时显式选择
  • 它给的 CLI 参数,你都跑过 --help 核对过是否存在于你装的这个版本
  • 它有没有说清哪些扩展会改变转账的实际到账金额,哪些不会
  • 它有没有把这些扩展和 ERC20 的非标准实现做对比,如果没有,追问它

真实案例

交易所上币时忽略了账户创建

一个交易所在支持某个代币的提现时,直接调用转账,没有处理「用户还没有这个代币账户」的情况。

结果是大量提现失败,用户反复重试,客服被淹没。更糟的是有些系统把失败当成了成功,账目对不上。

在 Solana 上做任何转出,都要先回答「对方有账户吗,没有的话谁来创建」。 这是一个在 EVM 上根本不存在的必答题。

转账费扩展让集成方记错账

一个代币开启了转账费扩展。某个按「转出金额」记账的协议没有处理它,收到的比记的少。

这和 T9 里那个扣费代币的事故一模一样,只是换了一条链和一种表达方式。

区别在于:这一次,协议本来可以在接受这个代币之前,读一下它的 Mint 账户就发现这个扩展。检查的可能性存在了,但检查还是得有人去做。

冻结权限没有放弃的代币

一些代币发行方保留了冻结权限,意味着他们可以随时让任意持有人的账户无法转出。

对合规型稳定币来说这是必需的功能;对一个声称去中心化的项目来说,这是一个很多人没注意到的后门。

好消息是这件事完全公开且一秒就能查到:读 Mint 账户,看 freezeAuthority 在不在。这应该出现在你研究任何 Solana 代币的第一步。

关闭账户回收押金被当成套利

代币账户在余额清零后可以被关闭,押金退还给账户所有者。

在「项目方替用户创建账户」的空投里,这条规则被系统性地利用:脚本批量领取、卖出、关户,把项目方付出的押金一笔笔收走。领的人越多,项目方流出的 SOL 越多。

教训是那句已经说过的话:你付出去的押金,最终会落到谁手里。 设计发放机制时,这一项必须算进成本模型。

改一个变量

如果增发权限没有被放弃

总量随时可以变。任何基于「固定总量」的估值、分配、锁仓承诺,都只是一句话而不是一个保证。

这不一定是坏事——很多协议需要持续增发来做激励。但它必须被明确披露,而不是让用户自己去读账户才发现。

研究一个代币时,这一条排在所有链上指标之前。

如果冻结权限还在发行方手里

发行方可以让任何人的余额动不了,包括被存进 DeFi 协议的那部分。

对集成方来说,这意味着一个新的风险维度:你的清算路径可能在最需要它的时候被冻结掉。 这和 T9 里「代币可被暂停」是同一类风险,只是在这里它是一个字段,不是一段代码。

如果你的 DEX 按输入金额计算,而代币开了转账费扩展

池子收到的比记录的少,每一笔交易都在往外漏一点点。

和 T9 的解法完全一样:用余额差记账,而不是用声明的金额。 换了链、换了模型,防御手段是同一个——因为问题的本质从来都是「声明的金额不等于实际到账」。

如果用户把自己的代币账户关掉了

账户消失,押金退回。下次有人给他转账,会再次遇到「对方没有账户」。

如果你的系统缓存了「这个用户的代币账户地址」并假设它一直存在,就会出错。正确做法是每次转出前检查账户是否存在,或者直接用幂等创建。

账户可以被创建,也可以被销毁。 这是 EVM 的 mapping 心智模型里完全没有的一个状态。

带走的问题

3
为什么需要 Token?

为什么需要 Token?这一章给出了一个结构性的答案:在这个模型里,Token 甚至不需要自己的代码,它只是一份被共享程序解释的数据。这提示了一件事——很多你以为必须写成合约的东西,其实只需要一份被公认的数据格式。

5
谁在支付?

谁在支付?这一章的答案非常具体:每一个代币账户的免租押金,都有一个明确的出资人和一个明确的受益人,而这两者可以不是同一个人。 任何发放、空投、激励机制的成本模型里,这一项都要单独列出来。

9
谁承担风险?

谁承担风险?增发权限、冻结权限、转账钩子——三样都是发行方持有、持有人承担后果的东西。好消息是它们在这个模型里全部是可以被一次查询读出来的数据。坏消息是绝大多数人从来没查过。

本章自测

一句话带走

Token 是一份被所有人共享的程序,加上一堆属于各自持有人的账户。

做完这一章的动手环节了?勾上它查看全部进度

本页目录