Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
集成与开发阅读约 12 分钟

面向大型钱包的 Solana getTokenAccountsByOwner 分页

了解如何使用 getTokenAccountsByOwner 分页枚举大型 Solana 钱包拥有的每个 SPL 代币账户,避免静默截断,并构建可恢复、无遗漏的遍历。

TL;DR

Solana getTokenAccountsByOwner RPC 方法每次调用返回的代币账户数量有上限,因此单次请求会少报拥有大量代币账户的钱包。该方法使用基于游标的分页方案,带有 before 和 limit 参数,其中 before 是上一页最后一个账户的公钥。由于游标不是稳定排序,且账户可能在调用之间被创建或关闭,正确的遍历必须按账户公钥去重,并在空页时终止。按 programId 过滤需要分别查询 SPL Token 和 Token-2022 程序,以避免遗漏账户。本文解释其机制,提供可恢复的 Node.js 实现,并展示如何针对你自己的端点衡量完整性。

为什么单次 getTokenAccountsByOwner 调用对大型钱包不完整

Solana JSON-RPC 方法 getTokenAccountsByOwner 返回给定钱包地址拥有的代币账户。根据 Solana getTokenAccountsByOwner 方法参考,响应受提供方每次调用最大账户数限制。该上限被记录为因提供方而异,OnFinality 未公布具体数字。实际后果是,持有代币账户数量超过上限的钱包只会收到第一页结果,除非调用方显式分页,否则会静默丢失尾部数据。

一个天真的集成只调用一次 getTokenAccountsByOwner 并假定响应完整,会少报大型钱包。这不是 RPC 方法的缺陷;这是为了限制响应大小并保护节点性能而有意设计的。该方法专门提供 before 和 limit 参数,允许调用方通过多次请求遍历完整集合。对于任何需要完整代币账户清单的生产系统,理解这些参数至关重要。

OnFinality Learn 中心 包含关于账户读取和签名分页的相邻指南,但本文专门关注大型钱包的代币账户分页。有关 Solana RPC 方法的更广泛概述,请参阅 Solana RPC API 指南(RPC Assistant)。

  • 响应受提供方最大账户数限制(有文档记录 / 因提供方而异)。
  • 单次调用只返回第一页;尾部被静默省略。
  • before 和 limit 参数支持基于游标的分页。
  • 完整性需要一个循环,直到返回空页才停止。

before 和 limit 如何作为账户集合上的游标工作

getTokenAccountsByOwner 方法接受一个包含 before 和 limit 参数的配置对象。before 参数不是偏移量;它是一个游标,告诉 RPC 节点返回公钥排序在给定公钥之后的账户。limit 参数指定响应中返回的最大账户数。两者结合,允许调用方分页遍历钱包拥有的整个代币账户集合。

正确的循环将上一页最后一个账户的公钥作为下一次请求的 before 值。循环在 RPC 返回空数组时终止,而不是在达到固定数量时终止。这很重要,因为代币账户总数可能在调用之间变化,固定数量要么提前停止,要么无限循环。基于游标的方法自然适应账户集合的当前状态。

getTokenAccountsByOwner 的 Solana 文档规定,响应条目携带代币账户公钥和账户数据。账户数据可以请求不同的编码,包括 jsonParsed 和 base64。分页参数是配置对象的一部分,与 commitment、encoding 和 dataSlice 并列。有关 SPL Token 账户布局的详细说明,请参阅 读取 Solana 账户、租金和代币余额。

  • before 是账户集合上的游标,不是偏移量。
  • 将上一页最后一个账户公钥作为下一个 before 值。
  • 在空页时终止循环,而不是在固定数量时。
  • 配置对象还接受 commitment、encoding 和 dataSlice。

游标不是稳定排序:按账户公钥去重

RPC 按公钥对代币账户排序,但当账户在页面之间被创建或关闭时,这种排序在调用之间并不稳定。如果钱包在遍历中途铸造新的关联代币账户(ATA),新账户的公钥可能排序在当前游标之前,从而移动页边界,导致某些账户被跳过或重复。同样,关闭账户可能导致游标跳过之前位于下一页的账户。

因此,生产级遍历必须按账户公钥去重,而不是信任页面互不相交。最安全的方法是将所有账户公钥收集到一个集合中,只添加之前未见过的账户。这确保即使页边界移动,最终结果也是完整且唯一的代币账户集合。遍历还应准备好处理账户被关闭且不再出现在任何页面中的情况。

这种行为与 Solana RPC 的通用设计一致,即账户集合是实时的,可能在请求之间变化。Solana getProgramAccounts 过滤器与 dataSlice 文章讨论了程序账户分页的类似考虑。有关签名分页,请参阅 Solana getSignaturesForAddress 分页。

  • RPC 按公钥对账户排序,但排序在调用之间不稳定。
  • 页面之间新增或关闭的账户可能移动页边界。
  • 按账户公钥去重,避免遗漏或重复条目。
  • 遍历应容忍中途消失的账户。

Mint 与 programId 过滤:为什么选择会影响完整性

getTokenAccountsByOwner 方法要求过滤器是 mint 或 programId。如果按 mint 过滤,你会获得钱包拥有的该特定 mint 的所有代币账户。如果按 programId 过滤,你会获得钱包拥有的该代币程序的所有代币账户。SPL Token 程序和 Token-2022 程序有不同的程序 ID,因此使用 SPL Token programId 的单次查询不会返回 Token-2022 账户。

要实现完整覆盖,调用方必须分别查询每个 programId。Solana 代币文档 说明 Token-2022 是一个独立的程序,有自己的程序 ID,并且可以包含改变账户布局的扩展。如果只查询一个 programId,同时持有 SPL Token 和 Token-2022 账户的钱包会被少报。因此,完整枚举至少需要两次查询:一次针对 SPL Token 程序,一次针对 Token-2022 程序。

当你只关心特定代币时,按 mint 过滤很有用,但对于完整的钱包清单,programId 过滤更合适。然而,即使使用 programId 过滤,你也必须分别分页每个程序的账户,因为 before 游标的作用域是过滤器。OnFinality Solana 网络页面 提供了连接 Solana RPC 的端点信息。

  • 过滤器必须是 mint 或 programId。
  • SPL Token 和 Token-2022 有不同的程序 ID。
  • 单个 programId 查询会遗漏另一个程序的账户。
  • 分别查询每个 programId,并分页每个结果集。

jsonParsed 与原始 base64:解析失败时以及如何回退

getTokenAccountsByOwner 方法支持 encoding 参数,可以设置为 jsonParsed 或 base64。jsonParsed 编码很方便,因为它以人类可读的 JSON 结构返回账户数据,包括 mint、owner 和 amount。然而,解析形式取决于运行时是否识别账户布局。带有扩展的 Token-2022 账户可能无法正确解析,RPC 可能返回错误或回退到原始数据。

生产读取器不应仅依赖 jsonParsed。相反,它应请求 base64 编码并解码 SPL Token 账户布局的固定偏移量。SPL Token 账户是 165 字节结构,amount 位于固定偏移量。Solana getTokenAccountsByOwner 方法参考 记录了编码选项。对于 Token-2022,账户布局包含扩展,因此 amount 的基础偏移量可能仍然相同,但后面有额外数据。

如果 jsonParsed 失败,调用方可以捕获错误并用 base64 重试。或者,调用方可以始终使用 base64 并实现自己的解析器。这更健壮,但需要理解账户布局。Solana 代币文档 提供了 SPL Token 和 Token-2022 账户结构的详细信息。

  • jsonParsed 很方便,但对于带扩展的 Token-2022 账户可能失败。
  • 回退到 base64 并解码 SPL Token 账户布局的固定偏移量。
  • SPL Token 账户为 165 字节,amount 位于固定偏移量。
  • Token-2022 账户可能有额外的扩展数据。

使用 dataSlice 作为带宽控制及其限制

dataSlice 参数允许调用方仅请求账户数据的一部分,由 offset 和 length 指定。当你只需要特定字段(如 amount)时,这可以减少带宽。然而,dataSlice 有局限:它只适用于账户数据,不适用于账户公钥等元数据。此外,如果使用 dataSlice,就不能使用 jsonParsed 编码,因为解析形式需要完整的账户数据。

对于分页,dataSlice 可用于减小每个响应的大小,如果提供方的限制基于响应大小而非账户数量,则允许每页更多账户。然而,提供方的最大值通常是数量,因此 dataSlice 可能不会增加每页账户数。当你只需要 amount 字段时,它仍然对减少带宽有价值。Solana getProgramAccounts 过滤器与 dataSlice 文章更详细地介绍了程序账户的 dataSlice。

使用 dataSlice 时,你必须知道所需字段的偏移量和长度。对于 SPL Token 账户,amount 位于偏移量 64,长度为 8 字节。这在 SPL Token 源代码和 Solana 代币文档 中有记录。将 dataSlice 与 base64 编码结合使用是高效读取代币账户的常见模式。

  • dataSlice 按偏移量和长度请求账户数据的一部分。
  • 它不能与 jsonParsed 编码结合使用。
  • 对于 SPL Token,amount 位于偏移量 64,长度 8。
  • dataSlice 减少带宽,但可能不会增加每页账户数。

使用 before、去重和稳定快照的可恢复 Node.js 遍历

以下 Node.js 示例演示了一个可恢复的遍历,它按 before 分页,按账户公钥去重,并发出以 (mint, token account) 为键的稳定快照。它分别查询 SPL Token 和 Token-2022 程序 ID。当两个程序都返回空页时,遍历终止。代码使用 @solana/web3.js 库,但同样的逻辑适用于任何 JSON-RPC 客户端。

函数 getTokenAccountsByOwner 使用包含 programId 过滤器、encoding 设置为 base64 以及 before 和 limit 参数的配置对象调用。before 参数更新为当前页最后一个账户公钥。结果累积在以代币账户公钥为键的 Map 中以确保唯一性。最终快照是包含 mint 和代币账户公钥的对象数组。

此实现是可恢复的,因为它可以用最后一个 before 值停止并重新启动。在生产系统中,你会将 before 游标和累积集合持久化到持久存储。OnFinality API 服务 可以为该遍历提供可靠的 RPC 端点。有关定价考虑,请参阅 RPC 定价。

const { Connection, PublicKey } = require('@solana/web3.js');

const SPL_TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');

async function getAllTokenAccounts(connection, owner, limit = 1000) {
  const ownerPubkey = new PublicKey(owner);
  const allAccounts = new Map();

  for (const programId of [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]) {
    let before = undefined;
    while (true) {
      const config = {
        programId,
        encoding: 'base64',
        limit,
      };
      if (before) config.before = before;

      const response = await connection.getTokenAccountsByOwner(ownerPubkey, config);
      const accounts = response.value;
      if (accounts.length === 0) break;

      for (const { pubkey, account } of accounts) {
        if (!allAccounts.has(pubkey.toString())) {
          allAccounts.set(pubkey.toString(), {
            pubkey: pubkey.toString(),
            mint: account.data.slice(0, 32).toString('hex'), // simplified; use proper parsing
            data: account.data,
          });
        }
      }

      before = accounts[accounts.length - 1].pubkey.toString();
    }
  }

  return Array.from(allAccounts.values());
}

// Usage:
// const connection = new Connection('https://your-rpc-endpoint');
// getAllTokenAccounts(connection, 'WalletAddressHere').then(console.log);

针对你自己的端点衡量完整性:结果表

由于提供方最大值和性能特征各不相同,你应该针对自己的端点衡量完整性。下表提供了记录观察结果的模板。使用不同的 limit 值运行遍历,并记录找到的唯一代币账户总数、页数和所用时间。这将帮助你了解特定 RPC 提供方的行为。

要执行测量,请使用具有已知代币账户数量的钱包。你可以通过查询区块浏览器或使用不同的 RPC 提供方来交叉检查总数。将结果记录在下表中。如果总数在多次运行之间变化,可能表明账户在遍历期间被创建或关闭,或者提供方的上限导致截断。

该表应填入你自己的数据。不要依赖本文中的基准数字,因为它们会是捏造的。相反,使用此方法验证你的端点的行为。OnFinality Solana 网络页面 提供了用于测试的端点 URL。

  • Limit 值:遍历中使用的 limit 参数。
  • 页数:进行的 RPC 调用次数。
  • 唯一账户:找到的唯一代币账户总数。
  • 时间(毫秒):遍历的总时间。
  • 备注:观察到的任何错误或异常。
const { Connection, PublicKey } = require('@solana/web3.js');

const SPL_TOKEN_PROGRAM_ID = new PublicKey('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA');
const TOKEN_2022_PROGRAM_ID = new PublicKey('TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb');

async function measureWalk(connection, owner, limit) {
  const ownerPubkey = new PublicKey(owner);
  const seen = new Set();
  let pages = 0;
  const start = Date.now();

  for (const programId of [SPL_TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]) {
    let before = undefined;
    while (true) {
      const config = { programId, encoding: 'base64', limit };
      if (before) config.before = before;
      const response = await connection.getTokenAccountsByOwner(ownerPubkey, config);
      const accounts = response.value;
      pages++;
      if (accounts.length === 0) break;
      for (const { pubkey } of accounts) seen.add(pubkey.toString());
      before = accounts[accounts.length - 1].pubkey.toString();
    }
  }

  const elapsed = Date.now() - start;
  console.log(`limit=${limit} pages=${pages} unique=${seen.size} timeMs=${elapsed}`);
  return { limit, pages, unique: seen.size, timeMs: elapsed };
}

// Usage:
// const connection = new Connection('https://your-rpc-endpoint');
// measureWalk(connection, 'WalletAddressHere', 1000).then(console.log);

基于游标的代币账户分页的局限性和权衡

使用 before 和 limit 的基于游标的分页是枚举大型钱包所有代币账户的唯一可靠方法,但它有取舍。遍历需要多次 RPC 调用,这增加了延迟和成本。游标不是稳定排序,因此去重是强制性的。遍历可能遗漏在页面之间创建和关闭的账户,尽管这种情况很少见。对于完整快照,你可能需要多次运行遍历并进行协调。

另一个限制是 before 游标的作用域是过滤器。如果按 programId 查询,你必须分别分页每个程序。如果按 mint 查询,你必须分别分页每个 mint。对于拥有许多不同代币的钱包,这可能导致大量 RPC 调用。另一种方法是使用 getProgramAccounts 方法并在 owner 上过滤,但该方法有自己的局限性,并非为代币账户枚举而设计。有关流式方法,请参阅 Solana getProgramAccounts 账户流式处理。

最后,提供方最大值并非所有提供方都公布。你可能需要实验以找到有效限制。如果响应大小太大,一些提供方可能返回比 limit 更少的账户。始终检查返回数组的长度,并继续直到收到空页。

  • 多次 RPC 调用增加延迟和成本。
  • 由于排序不稳定,需要去重。
  • 遍历中途创建和关闭的账户可能被遗漏。
  • before 游标的作用域是过滤器,需要按程序或 mint 分别遍历。

排查常见分页故障

如果你的遍历返回的账户少于预期,请检查你是否查询了 SPL Token 和 Token-2022 两个程序 ID。一个常见错误是只查询 SPL Token 程序而遗漏 Token-2022 账户。另一个问题是使用 jsonParsed 编码,对于带扩展的 Token-2022 账户可能失败。切换到 base64 并手动解码。

如果遍历永不终止,请确保你正确更新 before 参数。before 值必须是上一页最后一个账户的公钥。如果你意外传入第一个账户的公钥,你将无限循环。另外,检查你没有使用基于偏移量的方法;before 参数是游标,不是偏移量。

如果遇到速率限制,请减小 limit 参数或在请求之间添加延迟。OnFinality API 服务 提供可扩展的 RPC 端点,可以处理高请求量。有关更多故障排除提示,请参阅 Solana RPC API 指南(RPC Assistant)。

  • 遗漏 Token-2022 账户:查询两个程序 ID。
  • jsonParsed 失败:回退到 base64。
  • 无限循环:确保 before 是最后一个账户公钥。
  • 速率限制:减小 limit 或添加延迟。

后续步骤:将遍历集成到生产系统

要将此遍历集成到生产系统,请将 before 游标和累积集合持久化到持久存储。这允许遍历在崩溃或重启后恢复。使用在代币账户公钥上具有唯一约束的数据库来自动处理去重。定期调度遍历以保持快照最新。

对于实时更新,考虑使用 WebSocket 订阅账户变化,但请注意初始快照仍然需要完整遍历。Solana getProgramAccounts 账户流式处理 文章讨论了程序账户的流式处理,可以改编用于代币账户。有关基于签名的分页,请参阅 Solana getSignaturesForAddress 分页。

最后,针对多个 RPC 提供方测试你的实现以确保完整性。OnFinality Solana 网络页面 提供了用于测试的端点。有关定价和计划,请参阅 RPC 定价。

  • 持久化 before 游标和累积集合以实现可恢复性。
  • 使用在代币账户公钥上具有唯一约束的数据库。
  • 定期调度遍历以保持快照最新。
  • 针对多个 RPC 提供方测试以验证完整性。

永远不用担心基础设施

OnFinality 消除了 DevOps 的繁重工作,让您能够更聪明、更快地构建。

开始