给几千个 Solana 钱包分发代币

批次大小、那些还不存在的代币账户、崩溃后怎么安全续跑,以及为什么直推通常不是对的默认选择。

BoltTx Team··16 min read
solana空投spl-token批量交易上链分发

给一万个钱包发代币,不等于一万笔转账。它是一个带部分失败、需要断点续跑、而且成本结构第一次做的人都会意外的分布式任务。

造成损失最大的那个错误,不是分发得慢。而是★崩溃之后重启,把一部分接收方发了两次★。

直推还是自领

在写任何代码之前先定这件事,因为它决定了后面的一切。

直推 自领
谁付手续费 ★你★ 接收方
谁付租金 ★你★ 接收方
成本随什么增长 接收方数量 ★真正来领的人数★
不活跃的钱包 ★照样让你花钱★ 不花钱
复杂度 一个批量任务 一个链上程序

★直推要为每一个接收方付钱,包括那些可能永远不会碰这个代币的大多数人。★ 对于一份"参与度不确定"的大名单,自领模式 —— 通常是针对链上 Merkle root 的证明 —— 把这笔成本转移给了真正想要代币的人

直推适合小名单、已知活跃的接收方,以及你希望代币无需对方操作就到账的场景。 规模一大就该用自领。因为直推更简单而选它、然后在一万个接收方那里发现成本,是很常见的路径。

后文假设你用直推,因为交易机制都在那一侧。

代币账户这个问题

这是空投和 SOL 转账不一样的地方。你没法把一个 SPL 代币发给一个钱包地址。 你发给的是那个钱包针对你这个 mint 的关联代币账户 —— 而对大多数接收方来说,这个账户还不存在

创建它要花租金,由发出这笔交易的人来付:

import {
  getAssociatedTokenAddress,
  createAssociatedTokenAccountIdempotentInstruction,
  createTransferInstruction,
} from "@solana/spl-token";

const ata = await getAssociatedTokenAddress(mint, recipient);

const ixs = [
  // ★幂等版本:账户存不存在都能成功。★
  createAssociatedTokenAccountIdempotentInstruction(
    payer.publicKey, ata, recipient, mint,
  ),
  createTransferInstruction(sourceAta, ata, payer.publicKey, amount),
];

★一定要用幂等那个版本。★ 普通的 createAssociatedTokenAccount 指令在账户已存在时会失败 —— 而这会让整个批次 revert,因为交易是原子的。批次里只要混进一个本来就持有该代币的接收方,同批二十笔转账会跟着一起失败。

老老实实把租金算进预算。 开跑之前先用 getMinimumBalanceForRentExemption 把它算出来:一万个接收方、其中大多数没有这个账户时,账户创建是一笔实打实的开支 —— 往往比交易手续费还大。它只有在接收方之后主动关闭账户时才能取回,所以实务上就当它是花出去了。

如果你的分发任务写对了、问题在吞吐,免费领个 BoltTx key 改一行就能测测提交路径。

批次大小

每个接收方两条指令,两个天花板:

1232 字节。 每个接收方要贡献他的钱包、他的代币账户,以及金额。★账户引用占主导,所以通常是这一条先卡住。★

计算单元。 创建账户并不便宜,所以一个"创建加转账"的批次,比纯转账的批次贵得多

// ★两个都要测,不要假设。★
const sim = await connection.simulateTransaction(tx, {
  replaceRecentBlockhash: true, sigVerify: false,
});
console.log("字节:", tx.serialize().length, "/ 1232");
console.log("单元:", sim.value.unitsConsumed);

实际上,当需要创建账户时,这个数落在每笔交易几个到十几个接收方的区间;账户已存在时可以更多。★address lookup table 在这里有用,因为 mint、代币程序和支付方在每个批次里都重复出现。★

按"代币账户是否已存在"给接收方排序。 纯转账的批次能装下比"还要创建账户"的批次多得多的接收方,把两者分开能抬高你的平均批次大小

可续跑是硬性要求

一个一万接收方的任务一定会被打断。从一开始就照着这个前提设计,而不是等第一次事故之后再补。

// ★发送之前就落库,不是发送之后。★
for (const batch of batches) {
  const batchId = hash(batch.recipients);
  if (await isComplete(batchId)) continue;

  await recordAttempt(batchId, batch);        // ★之前★
  const sig = await send(batch);
  await recordSignature(batchId, sig);

  const outcome = await resolve(sig, lastValidBlockHeight);
  await recordOutcome(batchId, outcome);
}

★这个顺序比它看起来更重要。★ 如果你只在成功之后才记录,那么"发送完成、记录之前"崩溃,会留下一个已经落块、却被标记为未完成的批次 —— 于是重启时又发了一次。

重启时,先把状态未知的签名查清楚再重发。 一个"有签名记录、没有结果记录"的批次正是那个含糊的情况:先查签名状态,只有在它确实从没落块时才重建

const prior = await connection.getSignatureStatus(recordedSig,
  { searchTransactionHistory: true });   // ★restart: the status cache has aged out★
if (prior.value && !prior.value.err) {
  await recordOutcome(batchId, { status: "success" });
  continue;                                   // ★不要重发★
}

★重发一笔完全相同的已签名交易是安全的。重建不是。★ 重建出来的交易有新签名,如果原来那笔已经落块而你没观察到,它会执行第二次。

对着链上核对,不要对着自己的日志

任务报告完成时,你的日志说的只是你相信发生了什么。★在告诉任何人"完成了"之前,先对着链上核对。★

// 独立对账。
const holders = await connection.getProgramAccounts(TOKEN_PROGRAM_ID, {
  filters: [
    { dataSize: 165 },
    { memcmp: { offset: 0, bytes: mint.toBase58() } },
  ],
});

把这个集合和你的目标名单比对。差异才是重点 —— 那些你认为发过、却什么都没持有的接收方,或者持有量超出预期的接收方(那指向一次重复发送)。

这次对账也正是让你能回答那个必然会被问到的问题的东西:"我没收到我的。" 没有它,你只是在把自己的日志复述给对方,而那不算证据

承诺之前先算成本

用一个真实样本估算,而不是在表格里猜:

// 真跑一个批次,然后外推。
const perBatch = {
  baseFee: 5000,                        // 一个签名
  priorityFee: measuredPriorityFee,
  rent: newAccounts * rentExemptAmount, // ★通常是最大的一项★
};

★新建代币账户的租金通常占主导。★ 团队为交易手续费做预算、发现它很小,然后被账户创建的成本吓一跳 —— 而这正是规模化之后自领模式存在的真正原因。

也要为 revert 留预算。 一个半路失败的批次照样要花手续费,而一次大规模分发一定会有一些。

上链应该是什么水平

我们投递节点上的真实交易:中位确认 336 毫秒 —— 不到一个 slot。

★对分发任务来说,尾巴决定了你的总耗时。★ 过期的批次必须重建并重发,所以一条宽尾巴会把一个你按分钟估算的任务,变成一个跑得久得多的任务。

BoltTx 在这里的位置

我们负责提交。不管你的接收方名单、你的批量、或者你的自领程序。

提交走我们自建的四区域投递节点,带 SWQoS 路由,不暴露公共 mempool。对分发任务来说,有用的属性是一个稳定的分布 —— 它让总耗时变得可预测,而不是取决于网络状况。

你在本地签名。我们不托管资金、不代签、不修改交易内容。tip 带在交易里、从你自己的钱包链上支付,交易失败时跟着一起 revert —— 这是 Solana 原子交易的机制决定的。只有到达链上的交易才计费。

免费领 API key,没有月费:

const connection = new Connection("https://la.bolttx.io/?api-key=YOUR_KEY");

常见问题

怎么给几千个 Solana 钱包空投代币? 每笔交易装多个接收方,在发送之前就把进度落库,并把每个批次收敛到一个终态。硬性要求是可续跑崩溃后不重复发送

一笔 Solana 交易能装多少个接收方? 需要创建代币账户时通常是几个到十几个,账户已存在时可以更多。每个 32 字节的账户引用,通常比计算更早撞上 1232 字节上限。

我的空投交易为什么对某些接收方失败? 最常见的是对方代币账户已存在、而你用了非幂等的创建指令,于是整个原子批次 revert。改用 createAssociatedTokenAccountIdempotentInstruction

空投里代币账户由谁付钱? 由发出交易的人付。在直推分发里那就是你,针对每一个还没有该账户的接收方 —— 而且这通常是整个任务里最大的一项成本。

该用直推还是自领? 规模大了用自领,因为它把手续费和租金成本转移给真正想要代币的人。直推适合小名单、已知活跃的接收方,以及"无需对方操作就到账"很重要的场景。

空投崩溃后怎么续跑? 每个批次在发送之前落库,记下它的签名,重启时把所有"有签名、没结果"的记录查清楚。确认从没落块才重发相同字节,绝不在没查之前就重建

重发空投交易安全吗? 相同的已签名字节是安全的,因为一个签名最多被收录一次。重建不安全,因为新签名在原交易已落块而你没观察到时会执行第二次。

一次 Solana 空投要花多少钱? 每笔交易的基础手续费,加优先费,再加每一个被创建的代币账户的租金。用真实批次实测再外推,因为租金那一项通常盖过手续费

怎么验证空投确实完成了? 对着链上对账,而不是对着自己的日志。 查询该 mint 的代币账户,把持有者和你的目标名单比对 —— 差异才是这件事的全部意义

接收方地址无效会怎样? 那条指令失败,整个原子批次 revert。要在构建名单时就校验地址,因为一个坏地址会让你赔上整个批次和手续费。

批量之前该给接收方排序吗? 该,按代币账户是否已存在排。纯转账的批次能装下比"还要创建账户"的批次更多的接收方,分开能抬高平均批次大小。

空投批次能并行跑吗? 能,要限制并发。但它们都写同一个来源代币账户,所以那个账户上的争用会限制并行到底能帮上多少忙。

一次大规模空投要多久? 取决于批次大小,以及尾部有多少会过期需要重发。要从真实样本估算而不是从理想情况估算,因为过期批次通常正是任务超时的原因

空投需要用 address lookup table 吗? 有帮助,因为 mint、代币程序和支付方在每个批次里都重复。接收方每次不同,所以节省幅度小于账户固定的机器人,但仍然值得。

接收方在空投后关掉代币账户会怎样? 他们会取回你付的租金。这是预期行为,也是"向不活跃接收方直推很贵"的原因之一 —— 你出钱建的账户可能立刻被关掉。

已经持有该代币的接收方怎么处理? 幂等的创建指令会处理好 —— 账户存不存在它都成功,所以新持有者和老持有者混在一个批次里也能跑通,不需要特殊分支。

延伸阅读

返回博客列表