本文深入探讨了 Solana 的 Token-2022 标准,它是 SPL Token 程序的一个向后兼容的新版本,支持通过扩展实现额外的功能。
Token-2022 是 SPL Token 程序的一个新的向后兼容版本,它通过 扩展 的形式支持额外的特性。这些扩展的字节码是 Token-2022 程序本身的一部分,没有部署单独的程序。为特定 token 激活的字节码由 mint 或 token 账户中启用的扩展决定。
例如,在最初的 SPL Token 程序中,添加诸如 token 名称、符号或 logo URL 等元数据需要像 Metaplex 这样的外部程序。但在 Token-2022 中,你可以通过在 token mint 上启用元数据扩展来添加元数据。
在本文中,我们将学习 Token-2022 在底层是如何工作的,以及为了支持扩展而做了哪些更改。我们将演练一个使用 Token-2022 创建带有扩展的 Solana token 的实际例子。
首先,我们将研究 Token-2022 在底层是如何扩展原始 SPL Token 程序的,账户布局如何保持兼容,以及指令集如何在不破坏现有程序的情况下增长。
Token-2022 是 SPL Token 程序的一个直接替代品。你之前可以执行的每个操作——铸造、转移、销毁、冻结、更改权限——仍然完全一样地工作。这是因为 Token-2022 保留了原始 SPL mint 账户的前 82 个字节和 token 账户的前 165 个字节,其中包含诸如供应量、小数位数、所有者、冻结权限和金额等字段。 下面是原始 SPL mint 账户的布局:
一个展示 SPL mint 账户前 82 个字节的内存布局的图表
原始程序 (SPL) 仅反序列化和处理该数据;Token-2022 反序列化相同的区域,然后继续读取其后的扩展。
扩展是内置于 Token-2022 程序中的可选特性,你可以在 mint 账户或 token 账户上启用它们。下图显示了 Token-2022 mint TLV 布局:原始 SPL mint 的固定 82 字节,后跟存储扩展数据的可变大小 TLV 区域。
原始 SPL Token 程序具有固定的二进制布局,其中每个字段的大小、类型和值都是预定的。Token-2022 在保留的前 82 到 165 字节区域中使用相同的固定二进制布局,但除此之外,它使用可变的类型-长度-值 (TLV) 编码方案来存储扩展。
类型–长度–值 (TLV) 是一种数据序列化方案。数据中的每个对象包含三个部分:
因为每个条目都说明了它的类型和长度,所以程序可以读取类型来理解它正在解析的内容,读取精确到该数量的字节作为值,然后使用长度直接跳到下一个条目。
即使类型未知,程序仍然可以使用长度来跳过它。
例如,在下图中,类型为 12
的条目的长度为 2
字节,因此它的值字段包含两个字节。如果程序无法识别类型 12
,它可以跳过这 2
个字节并直接移动到下一个条目,类型 20
。
一个说明类型长度值的图表
Token-2022 使用这种 TLV 编码方案实现。每个扩展都表示为一个 TLV 条目,并且具有以下上下文:
这种结构允许多个扩展按顺序打包到同一个账户中,而不会发生冲突。
为了说明 TLV 编码在实践中是如何工作的,让我们来看两个扩展:ImmutableOwner
和 MetadataPointer
。
ImmutableOwner
扩展是一个 Token-2022 扩展,它可以防止 token 账户的所有者在创建后被更改。 我们现在来看看 TLV 布局,并在本文后面讨论更多关于它的用法。
ImmutableOwner
具有以下 TLV 属性:
0x0a
(ImmutableOwner
的唯一 ID)0x01 0x00 0x00 0x00
(数字 1 编码为 4 字节小端整数)0x01
(该扩展存储一个字节的数据值,可以是 0
或 1
)Rust 定义是一个空结构体:
pub struct ImmutableOwner {}
虽然该结构体没有字段,但 TLV 编码仍然为 V
保留一个字节,以表示该标志是否已启用 (1
为启用,0
为禁用)。 这就是为什么该条目具有非零长度的原因。
因此,它的 TLV 布局如下所示:
MetadataPointer
TLV 布局MetadataPointer
扩展定义了 token 的链下元数据的位置以及谁可以更新它。
与 ImmutableOwner
扩展不同,MetadataPointer
包含值:两个公钥值(authority
和 metadata_address
)。
以下是 Rust 结构体的样子:
struct MetadataPointer {
authority: PubKey;
metadata_address: PubKey;
}
在 TLV 中,布局将包含以下信息:
0x1a
: MetadataPointer
的类型 ID (T)0x40 0x00 0x00 0x00
: 长度 (L) = 64 字节 (小端序)<64 bytes>
: 值 (V),序列化的 authority
和 metadata_address
指向元数据的公共地址。因为 V 包含一个 authority
和 metadata_address
公钥,所以让我们使用示例 32 字节公钥来表示它们,以便我们更好地了解布局的样子。
假设账户的 authority
公钥是:
7c4YH58z6Yd1H5pa9vHqPqN8P3f9DuzGcbj2duq5Vn6a
metadata_address
公钥是:
9A4q8Xzj8cQ6w6sKuS27rrR2i1cC6VnV4c7pg1Zg1Vgk
在 Solana 上,公钥是 Base58 编码的 32 字节值。值 (V) 是 authority
和 metadata_address
的串联。 当两个公钥都从 Base58 解码为其原始字节,然后以十六进制形式写入时,生成的 64 字节序列将为:
// authority (32 bytes)
0x62 0x21 0x73 0xa4 0x94 0x0c 0x4c 0x3c 0x29 0x7a 0x7f 0x3c 0x4f 0xc1 0x12 0x3f
0x3b 0x34 0xc6 0x51 0x3f 0x3e 0x24 0x23 0xf3 0x1c 0xaa 0x88 0x83 0x44 0xa3 0x37
// metadata_address (32 bytes)
0x79 0x30 0x0d 0x97 0x56 0x47 0xc2 0x18 0x79 0x35 0x0d 0xe6 0x18 0x9f 0x80 0xec
0xd6 0xca 0x36 0xa5 0xb1 0x77 0x5a 0xa8 0xe4 0x45 0x66 0x7b 0x85 0xf3 0x32 0xe1
假设 Token-2022 账户同时初始化了 ImmutableOwner
和 MetadataPointer
扩展,以下是 TLV 布局的样子:
读取账户时,程序可以迭代 TLV 部分,仅选择性地解码它识别的扩展。 未知扩展类型通过使用它们声明的长度跳过。
让我们考虑一下程序将如何处理未知扩展 UnknownExtension
:
程序读取 UnknownExtension
的 TLV 条目:
0x0b
(一个未知类型)0x14 0x00 0x00 0x00
(20,小端序)0x4…0xed
(20 字节长,如 L 所指定)现在假设未知扩展具有 64 字节的值,程序将从 L 读取值 64,然后向前跳过 64 字节(在 V 上方)以找到下一个 T
。 这种方法使 Token-2022 具有向前兼容性;未来的扩展不会破坏现有程序。
Solana 指令是对链上程序的调用,它由三个字段组成:
原始 SPL Token 程序有 25 个唯一指令。 Token-2022 支持所有这些指令,并在第 25 个指令之后添加新指令以启用新的扩展功能。
换句话说,Token-2022 中的现有 token 指令(如 MintTo
,Transfer
或 Burn
)的行为与 SPL
中的完全相同。
这是一个示例指令布局,它告诉 token 程序将 100 个 token 从 mint 账户铸造到目标 token 账户:
应用程序可以通过简单地更改其交易中的程序 ID 来采用 Token-2022。
以下是超出 Token 程序的原始 25 个指令的 Token-2022 指令列表。 命名为匹配它们初始化或管理的扩展名的 token 指令:
25: InitializeMintCloseAuthority
26: TransferFeeExtension
27: ConfidentialTransferExtension
28: DefaultAccountStateExtension
29: Reallocate
30: MemoTransferExtension
31: CreateNativeMint
32: InitializeNonTransferableMint
33: InterestBearingMintExtension
34: CpiGuardExtension
35: InitializePermanentDelegate
36: TransferHookExtension
37: ConfidentialTransferFeeExtension
38: WithdrawExcessLamports
39: MetadataPointerExtension
40: GroupPointerExtension
41: GroupMemberPointerExtension
42: ConfidentialMintBurnExtension
43: ScaledUiAmountExtension
44: PausableExtension
在 Token-2022 中,所有扩展都必须在初始化 mint 或 token 账户之前指定,以便可以为它们的数据分配足够的空间。 一旦初始化,就无法添加其他扩展。
你可以使用 spl-token CLI 创建带有扩展的 Token-2022 token。 它将计算所需的帐户大小,将每个扩展的 TLV 条目写入 mint 账户,并最终在幕后使用 InitializeMint2
指令初始化 mint。
这是 CLI 模板的样子:
spl-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb \
create-token <扩展标识>
假设我们想向一个 mint 添加两个扩展:一个定义 token 的固定利率 (InterestBearingConfig
),另一个使 mint 能够引用链下元数据 (MetadataPointer
)。 我们可以运行以下命令,附加 —-interest-rate
和 —-enable-metadata
标志。 下面命令中的 5 是利率:
spl-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb \
create-token --interest-rate 5 --enable-metadata
此命令创建一个同时启用了 InterestBearingConfig
和 MetadataPointer
扩展的 mint 账户。 在内部,它使用 ExtensionType::try_calculate_account_len::<Mint>(&[InterestBearingConfig, MetadataPointer])?;
分配完整的账户大小,立即写入利率参数,隐式地初始化 InterestBearingConfig
扩展,并为元数据保留一个 TLV 插槽。 现在,我们必须手动初始化元数据扩展。
try_calculate_account_len
(来自 token-2022 库) 函数根据所选扩展计算所需的总空间,并且每个扩展的初始化指令在最终 mint 初始化锁定账户结构之前配置其特定参数。
如果你运行我们之前提到的 spl-token
命令来启用扩展,你将看到如下输出:
在此阶段:
5
。你会注意到以下消息:“要初始化 mint 中的元数据,请运行 spl-token initialize-metadata 5bL18vT46c7SkdN37F3pb1GdxsN8kTcZCPoRcYj6cS5w <YOUR_TOKEN_NAME> <YOUR_TOKEN_SYMBOL> <YOUR_TOKEN_URI>
,并使用 mint 授权签署。" 在响应中。
我们将运行该命令来最终确定元数据初始化并填充已分配的元数据 TLV 块。
spl-token --program-id TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb \
initialize-metadata 5bL18vT46c7SkdN37F3pb1GdxsN8kTcZCPoRcYj6cS5w \
"MyToken" "MTKN" "https://example.com/mytoken.json"
结果将如下所示:
如果你之前省略了 --enable-metadata
,则此步骤将失败,因为一旦初始化 mint,就无法添加新的 TLV 空间。 因此,你将看到如下错误:
将扩展添加到 mint 所需步骤的摘要包括:
对于像利率扩展这样的扩展,它允许你一次性提供所需的参数(我们上面使用 --interest-rate 5
完成了此操作),你将一次启用和初始化扩展。
以下是一些 token 扩展以及你可以使用哪些标志在 CLI 上启用它们。
扩展 | CLI 标志 |
---|---|
Mint 关闭授权 | –enable-close |
转移费用 | –transfer-fee-basis-points –transfer-fee-maximum-fee |
不可转移 | –enable-non-transferable |
计算利息 | –interest-rate |
永久委托 | –enable-permanent-delegate |
转移Hook | –transfer-hook |
元数据 | –enable-metadata |
元数据指针 | –metadata-address |
保密传输 | –enable-confidential-transfers auto |
Solana Token-2022 源代码中的 ExtensionType
枚举定义了所有可用的扩展。
你已经了解了我们在创建 mint 账户时如何组合 Interest Bearing
和 Metadata
扩展。 你还看到了如何启用和初始化扩展。
但是,并非所有扩展都可以与其他扩展组合。 例如,你不能组合 NonTransferable 和 TransferHook,因为如果 token 无法转移,则转移Hook没有有意义的工作。 SVM 和开发工具强制执行此规则,因此尝试组合这两个扩展将导致执行失败。
此外,你不能组合 ConfidentialTransfer 和 TransferHook 扩展,因为保密转移加密金额并限制可见性。 由于转移Hook依赖于读取转移的金额,因此两者无法协同工作。
以下是一些其他无法协同工作的扩展组合:
在我们进入本文的下一部分之前,让我们总结一下我们到目前为止讨论的内容:
我们现在将讨论两个扩展:(ImmutableOwner
和 NonTransferable
),并演示如何组合它们来构建凭据颁发程序。
旧版 Token 程序允许你使用 SetAuthority
指令将 Token 账户的所有者更改为另一个账户。 Token-2022 使你能够使用 ImmutableOwner
扩展使所有者不可更改。 ImmutableOwner
扩展永久锁定 Token 账户的所有权。 这意味着,一旦创建了具有此扩展的 token 账户,它将永久属于指定的钱包。
以下是一个场景,其中 ImmutableOwner
扩展可以防止网络钓鱼攻击,这些攻击可能在使用旧版 Token 程序时发生,该程序允许在账户创建后更改所有权。
在 旧版 Token 程序 (没有 ImmutableOwner):
AliceTokenAccount
,它来自她的钱包地址和一个特定的 mint。AliceTokenAccount
,因为它不检查所有权更改。使用 Token-2022 的 ImmutableOwner
扩展(默认在 ATA 上启用):
以下是 Token-2022 如何使用 ImmutableOwner
扩展强制执行此约束
当执行 SetAuthority
指令(用于在旧版 SPL token 程序中更改 token 账户所有权的指令)时,它会调用 process_set_authority
函数,该函数检查存在的授权类型。 如果授权类型为 AuthorityType::AccountOwner
,它会触发 ImmutableOwner
检查,返回一个错误并阻止所有权更改:
此实现可以在 GitHub 上的 Token-2022 程序的核心逻辑 中找到。
ImmutableOwner
扩展始终默认为使用 Token-2022 程序创建的 ATA 启用。
当你使用 Token-2022 的 标准 ATA
程序创建 ATA 时,ATA 程序会自动在账户初始化过程中包含 ImmutableOwner
扩展。
无论你是否明确指定它,都会发生这种情况——它内置于 ATA 程序用于 Token-2022 账户的逻辑中。 这与使用系统 createAccount
指令手动创建的 token 账户不同,在后一种情况下,如果需要,你需要显式初始化 ImmutableOwner
扩展。
这种设计确保了最常见的 token 账户模式 (ATA) 默认获得不可变所有权的安全优势。
NonTransferable
扩展完全禁用 token 转移。 你仍然可以将 token 铸造到账户中,但一旦收到,它们就无法发送到任何其他地方。
对此类账户的唯一有效操作是销毁 token (对于来自 Ethereum 背景的人来说,Solana 将 token 销毁视为与转移不同的操作) 或关闭空账户。
这对于构建 token 代表不可转移资产的系统非常有用。 例如,证书或链上身份标记不应在用户之间转移,或者用户欠协议的债务也不应转移。 在这种情况下,你将使用 NonTransferable
扩展发行 NFT。
Token-2022 在程序逻辑中强制执行此限制。 如果你尝试从持有不可转移 token 的账户进行转移,该指令将失败并显示错误。
你可以在 processor.rs
中看到此检查,该程序检查账户中是否存在 NonTransferableAccount
扩展,如果存在,则拒绝转移并显示 NonTransferable
错误。
不可转移的 token 只能铸造到具有不可变所有者的账户中。 这是为了防止通过账户所有权变更进行间接 token 转移。
当使用 NonTransferable
扩展创建 mint 时,为该 mint 创建的任何 token 账户将自动继承两个扩展:NonTransferableAccount
(阻止转移) 和 ImmutableOwner
(阻止所有权更改)。 以下是如何在 Token-2022 程序逻辑中强制执行此操作,并且可以在我们之前在 GitHub 上提到的同一 文件 中找到。
if mint.get_extension::<NonTransferable>().is_ok()
&& destination_account.get_extension::<ImmutableOwner>().is_err()
{
return Err(TokenError::NonTransferableNeedsImmutableOwnership.into());
}
ImmutableOwner
和 NonTransferable
扩展构建一个最小凭据颁发程序本节将介绍:
ImmutableOwner
和 NonTransferable
扩展的 mint。免责声明:这里描述的 Anchor 程序仅用于教育目的。 对于生产环境,请确保你的程序包含完整的验证、安全检查并接受审查。
通过运行命令 anchor init credentials
创建一个新的 Anchor 项目。 我们将把所有代码写入 programs/src/lib.rs
文件。
配置
打开 programs/src/Cargo.toml
并更新 [features]
和 [dependencies]
部分。
在 [features]
部分中,添加 idl-build
并将其链接到 anchor-lang
和 anchor-spl
中的相应子功能。
在 [dependencies]
部分中,添加 anchor-lang
和 anchor-spl
。 确保包含 anchor-lang
的 init-if-needed
功能——我们稍后需要它来创建关联 token 账户 (ATA)。
...
[features]
anchor-debug = []
cpi = ["no-entrypoint"]
default = []
**idl-build = ["anchor-lang/idl-build", "anchor-spl/idl-build"]**
no-entrypoint = []
no-log-ix-name = []
custom-heap = []
custom-panic = []
no-idl = []
[dependencies]
**anchor-lang = {version = "0.31.0", features = ["init-if-needed"]}
anchor-spl = {version = "0.31.0"}**
这些依赖项是我们将在本示例中导入和使用的唯一依赖项。
// 从 Anchor 框架和 SPL token 程序导入必要的模块。
use anchor_lang::prelude::*;
use anchor_spl::{
associated_token::AssociatedToken,
token_2022_extensions::non_transferable_mint_initialize,
token_interface::{
mint_to,
Mint,
TokenAccount,
TokenInterface,
},
};
为了创建我们的 mint 账户,我们使用 NonTransferable
扩展初始化它。 此扩展仅适用于 mint 本身。 稍后,当通过 Token-2022 ATA 程序为此 mint 创建关联 token 账户时,正如我们之前讨论的那样,ATA 程序会自动将 ImmutableOwner
和 NonTransferableAccount
扩展添加到新的 token 账户。
下面的代码展示了我们将如何初始化 mint 账户。
NonTransferableMintInitialize
指令初始化 NonTransferable
扩展。initialize_mint2
指令,使用有效的授权初始化 mint 账户本身。// 从 Anchor 框架和 SPL token 程序导入必要的模块。
use anchor_lang::prelude::*;
use anchor_spl::{
associated_token::AssociatedToken,
token_2022_extensions::non_transferable_mint_initialize,
token_interface::{
mint_to,
Mint,
TokenAccount,
TokenInterface,
},
};
/// 使用 NonTransferable 扩展初始化一个新的凭据 mint。
pub fn initialize_credential_mint(ctx: Context<InitializeCredentialMint>) -> Result<()> {
// 初始化 NonTransferable 扩展。
non_transferable_mint_initialize(CpiContext::new(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token_2022_extensions::NonTransferableMintInitialize {
mint: ctx.accounts.mint.to_account_info(),
token_program_id: ctx.accounts.token_program.to_account_info(),
},
))?;
// 初始化 mint 本身,将小数位数设置为 0,并定义授权。
anchor_spl::token_interface::initialize_mint2(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token_interface::InitializeMint2 {
mint: ctx.accounts.mint.to_account_info(),
},
),
0, // 小数位数设置为 0,因为凭据是整个单元,不能是小数。
&ctx.accounts.mint.key(), // mint 授权是程序派生的地址 (PDA) 本身。
Some(&ctx.accounts.mint.key()), // 冻结授权也是 PDA。
)?;
Ok(())
}
我们将使用在下面的结构体中定义的账户初始化 mint 账户:
该结构体为 mint 分配 98 字节的空间
NonTransferable
扩展。在实践中,我们将使用 ExtensionType::try_calculate_account_len::<PodMint>(&[ExtensionType::NonTransferable])?;
方法动态计算大小。
我在代码中留下了一些注释,以便更好地理解其余部分:
/// 定义 `initialize_credential_mint` 指令所需的帐户。
#[derive(Accounts)]
pub struct InitializeCredentialMint<'info> {
// 要初始化为程序派生地址 (PDA) 的 mint 账户。
#[account(\
init,\
payer = payer,\
// 账户数据空间分配:\
// 8 字节:用于账户鉴别器,Anchor 中账户类型的唯一标识符。\
// 82 字节:SPL Token Mint 账户的标准固定大小。\
// 8 字节:为 NonTransferable 扩展保留的额外空间。\
space = 8 + 82 + 8,\
owner = token_program.key(),\
// 定义程序派生地址 (PDA) 的种子。\
seeds = [b"mint"],\
bump\
)]
pub mint: InterfaceAccount<'info, Mint>,
// 支付交易和租金的账户。
#[account(mut)]
pub payer: Signer<'info>,
// 系统程序,创建账户所必需。
pub system_program: Program<'info, System>,
// SPL token 程序。
pub token_program: Interface<'info, TokenInterface>,
}
现在我们已经创建了 mint 账户,让我们开始发行凭据。 我们将铸造一个 token (凭据 token) 并将其发送给用户。
pub fn issue_credential(ctx: Context<IssueCredential>) -> Result<()> {
// Mint one token to the recipient's associated token account.
// 将一个 token 铸造到接收者关联的 token 账户。
mint_to(
CpiContext::new(
ctx.accounts.token_program.to_account_info(),
anchor_spl::token_interface::MintTo {
mint: ctx.accounts.mint.to_account_info(),
to: ctx.accounts.recipient_ata.to_account_info(),
authority: ctx.accounts.authority.to_account_info(),
},
),
1, // Mint exactly one token.
// 准确地铸造一个 token。
)?;
Ok(())
}
issue_credential
函数需要 IssueCredential
结构体作为其上下文。当发行凭证时,我们使用 Token-2022 关联的 token 账户 (ATA) 程序创建用户的 token 账户。这会自动将 ImmutableOwner
扩展应用于该账户。由于该 mint 被标记为 NonTransferable
,用户的 ATA 也会隐式地应用 NonTransferableAccount
扩展。
/// Defines the accounts required for the `issue_credential` instruction.
/// 定义 `issue_credential` 指令所需的账户。
#[derive(Accounts)]
pub struct IssueCredential<'info> {
// The mint account, must be mutable.
// mint 账户,必须是可变的。
#[account(\
mut,\
seeds = [b"mint"],\
bump,\
constraint = mint.mint_authority.unwrap() == authority.key() // Ensure the authority is the mint authority.\
// 确保授权机构是 mint 授权机构。
)]
pub mint: InterfaceAccount<'info, Mint>,
// The authority signing the transaction (must be the mint authority).
// 签署交易的授权机构(必须是 mint 授权机构)。
#[account(mut)]
pub authority: Signer<'info>,
// The recipient's associated token account, created if it doesn't exist.
// 接收者的关联 token 账户,如果不存在则创建。
#[account(\
init_if_needed,\
payer = authority,\
associated_token::mint = mint,\
associated_token::authority = recipient,\
associated_token::token_program = token_program\
)]
pub recipient_ata: InterfaceAccount<'info, TokenAccount>,
// The recipient of the credential.
// 凭证的接收者。
pub recipient: Signer<'info>,
// The SPL token program.
// SPL token 程序。
pub token_program: Interface<'info, TokenInterface>,
// The associated token program.
// 关联的 token 程序。
pub associated_token_program: Program<'info, AssociatedToken>,
// The system program.
// 系统程序。
pub system_program: Program<'info, System>,
}
因此,有了这个实现,以下操作将是不可能的:
TokenError::NonTransferable
错误。我们已经在 Token-2022 程序源代码中看到过这个错误。该程序的完整源代码可以在 GitHub 上找到。
Token-2022 使得在 Solana 上构建更灵活。我们了解了它的架构以及它如何保持与原始 SPL token 程序的向后兼容性。
我们看到了扩展如何用于向 token 添加新行为。虽然扩展可以一起使用,但其他组合是不允许的。
最后,我们构建了一个凭证发行程序,该程序结合了 NonTransferable
和 ImmutableOwner
扩展。这表明在 mint 上启用 NonTransferable
如何防止 token 被转移,从而强制严格控制凭证的持有方式。
本文是Solana 上的教程系列的一部分。
- 原文链接: rareskills.io/post/token...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!