Logo
新用户订阅 RPC,首月享 6.5 折优惠查看优惠
OnFinality Learn
网络与协议指南阅读约 14 分钟

Solana logsSubscribe 通知:解析负载契约

深入剖析 Solana logsSubscribe PubSub 通知信封、LogsResponse 字段、err 语义以及日志深度重组机制,并提供故障排查手册。

TL;DR

Solana logsSubscribe 通知是一种 JSON-RPC 2.0 通知:它携带 jsonrpc、method 以及包含订阅 id 和 LogsResponse 结果的 params 对象,并且没有 id,因为节点主动推送而非请求响应。LogsResponse 包含 context.slot 以及 value.signature、value.err 和 value.logs;在重组流时,context.slot 是排序键,value.err 在成功时为 null,失败时为解码后的错误对象。由于 value.logs 是扁平字符串数组,要将指令与其日志关联,需要跟踪 Program invoke 和 Program success/failed 行的深度计数器,而非读取固定索引。承诺级别可能导致同一 slot 被多次投递,因此需要精确一次处理的消费者必须按签名去重,并优先采用所见的最强承诺。本指南将协议文档行为、建议以及可复现的测量方法区分开来,供您在自己的端点上运行。

PubSub 通知信封及其为何不是请求/响应

Solana 的 WebSocket PubSub API 遵循 JSON-RPC 2.0 规范,其中通知是一个没有 id 的请求对象,服务器不会对其回复。JSON-RPC 2.0 规范 将此信封定义为 jsonrpc、method 和 params,没有 id 字段。正是这一缺失导致了大多数解析错误:客户端无法将通知关联到 JSON-RPC id,因为没有每个通知的 id 可供匹配。

相反,关联通过订阅 id 进行。当您发送 logsSubscribe 请求时,节点返回的结果就是订阅 id。该订阅的每个后续通知都在 params.subscription 中携带相同的 id。您的客户端必须维护一个从订阅 id 到本地处理程序的映射,并通过该映射路由每个传入帧。这与其他流式方法使用的关联模型相同,在 JSON-RPC 通知 ID 关联与批量排序 中有更深入的介绍。

一个实际后果是,单个 WebSocket 连接可以承载多个订阅,来自不同订阅的帧会任意交错。如果您假设下一帧属于您发送的最后一个请求,那么一旦打开第二个订阅,就会错误路由数据。信封仅通过 method 和 params.subscription 自我描述,因此请将这两个字段视为路由键。

  • jsonrpc:始终为字符串 "2.0"。
  • method:订阅方法名,例如 "logsNotification"。
  • params.subscription:订阅调用返回的数字订阅 id。
  • params.result:此事件的 LogsResponse 负载。
  • 无 id 字段:节点不期望也不发送通知的响应。

LogsResponse 结构:context、slot、signature、err 和 logs

Solana 关于 logsSubscribe 的文档将通知结果定义为包含两个顶级字段的 LogsResponse:context 和 value。context 包含 slot 编号。value 包含 signature、err 和 logs。这就是整个契约,每个字段对正确解析都至关重要。

context.slot 是重组流的排序键。签名标识交易,但签名不是单调的,也不能告诉您节点观察到事件的顺序。如果您要构建按时间排序的活动视图,请先按 context.slot 排序或分桶,然后在每个 slot 内按签名排序。将签名视为排序键会产生在负载下看起来乱序的流。

value.logs 是扁平的字符串数组。它不是结构化树,不是对象数组,也不是按指令分组。节点按照运行时产生的顺序发出与交易日志消息中相同的文本行。您想要的任何结构,例如哪些日志属于哪条指令,都必须由解析器重建。关于 解码 getTransaction meta:日志与内部指令 的配套指南从 RPC 侧介绍了相同的日志格式,解码逻辑可复用。

  • context.slot:节点观察到交易的 slot;用于排序。
  • value.signature:交易签名;用于去重。
  • value.err:成功时为 null,失败时为解码后的错误对象。
  • value.logs:按运行时发出顺序排列的扁平字符串数组。

err 字段:成功时为 null,否则为解码后的失败对象

交易成功时 value.err 为 null。交易失败时,value.err 是一个镜像交易 meta err 的对象。常见形状是 {InstructionError: [index, reason]},其中 index 是从零开始的指令索引,reason 是描述失败的字符串或嵌套对象。由于形状与 getTransaction meta 匹配,您用于 RPC 响应的同一解码器可以在此处无需修改地复用。

这里隐藏着一个微妙且代价高昂的 bug。如果客户端将任何非 null 的 err 视为暂时性网络故障并重试同一交易,它将重新提交一个确定性失败。如果错误是由程序逻辑引起的 InstructionError,重试将以相同方式失败,并可能再次消耗费用。正确的解释是交易已被包含并失败;err 字段是结果,不是传输错误。

区分传输层问题与链上失败。WebSocket 连接断开、超时或 JSON 解析错误是传输问题。格式良好的通知中非 null 的 value.err 是链上结果。您的重试策略应仅适用于前者,而告警应将后者视为业务事件。

  • null:交易成功。
  • {InstructionError: [index, reason]}:特定指令失败。
  • 根据失败类别可能出现其他对象形状;请防御性解码。
  • 切勿仅因 value.err 非 null 就重试交易。

使用深度计数器重组扁平日志数组

由于 value.logs 是扁平数组,将日志与指令关联的唯一可靠方法是跟踪嵌套深度。运行时会发出诸如 "Program <ID> invoke [1]"、"Program log: ..."、"Program <ID> success" 和 "Program <ID> failed" 的行。invoke 行上的方括号数字是深度。每次 invoke 递增深度;每次 success 或 failed 递减深度。深度为 N 时发出的日志属于在深度 N 打开的调用。

深度计数器让您可以构建结构化视图:帧栈,每个帧包含程序 id、深度和日志行列表。当您看到 invoke 行时,压入一个帧。当您看到 Program log 行时,将其追加到顶部帧。当您看到 success 或 failed 时,弹出帧并将其附加到父帧。这与内部指令解码使用的算法相同,在 解码 getTransaction meta:日志与内部指令 中有描述。

不要假设固定索引。每条指令的日志行数随计算预算、程序行为以及程序是否发出日志而变化。将 logs[3] 读取为“第一条指令的日志”的解析器会在第一条发出不同行数的交易上出错。深度跟踪是唯一稳定的方法。

  • invoke 行打开一个帧,并在方括号中携带深度。
  • Program log 行追加到当前顶部帧。
  • success 和 failed 行关闭当前帧。
  • 计算单元行是信息性的,不改变深度。

承诺级别、重复签名与精确一次处理

logsSubscribe 接受 commitment 参数,Solana 文档指出 confirmed 和 finalized 可能在不同时间投递同一 slot 的通知。finalized 通知可以取代 confirmed 通知。如果您在 confirmed 订阅,稍后在 finalized 订阅,或者在重连后重新订阅,您可能会多次看到同一签名。

需要精确一次处理的消费者必须按签名去重,并优先采用所见的最强承诺。维护一个从签名到观察到的最高承诺级别的映射,仅当承诺提升或签名为新时才发出下游事件。这是建议,不是协议保证:节点不承诺每个签名只投递一次。

承诺注意事项与排序相互作用。slot N 的 confirmed 通知可能在 slot N-1 的 finalized 通知之后到达。如果您的下游消费者假设 slot 单调递增,就会看到明显的回退。先按承诺级别分桶,然后在每个桶内按 context.slot 排序,仅当签名的承诺增强时才提升它。

  • confirmed 和 finalized 都可能投递同一 slot。
  • 按签名去重;优先采用最强承诺。
  • 不要假设跨承诺级别的 context.slot 单调递增。
  • 重连后重新订阅可能重放近期事件。

可运行的 Node.js 示例:订阅、解码并跟踪深度

以下示例打开 WebSocket,使用 mentions 过滤器订阅,解码每个通知,打印签名、slot 和 err,并重组日志深度。它使用 ws 包,并假设有一个 Solana JSON-RPC WebSocket 端点。将端点替换为您自己的提供商 URL。无论连接到公共端点还是托管端点(如 OnFinality 的 Solana 网络),该模式都适用。

解析器有意保持最小化。它不按签名去重,也不处理承诺提升;这些留作上一节描述的练习。目标是展示信封契约的工作代码,以便您将其适配到自己的管道。

const WebSocket = require('ws');

const ENDPOINT = process.env.SOLANA_WS_ENDPOINT;
const ws = new WebSocket(ENDPOINT);
let subscriptionId = null;

function parseLogs(logs) {
  const stack = [];
  const frames = [];
  for (const line of logs) {
    const invoke = line.match(/^Program (\S+) invoke \[(\d+)\]$/);
    const done = line.match(/^Program (\S+) (success|failed)$/);
    const log = line.match(/^Program log: (.*)$/);
    if (invoke) {
      stack.push({ programId: invoke[1], depth: Number(invoke[2]), logs: [] });
    } else if (log && stack.length) {
      stack[stack.length - 1].logs.push(log[1]);
    } else if (done && stack.length) {
      const frame = stack.pop();
      frame.status = done[2];
      frames.push(frame);
    }
  }
  return frames;
}

ws.on('open', () => {
  ws.send(JSON.stringify({
    jsonrpc: '2.0',
    id: 1,
    method: 'logsSubscribe',
    params: [{ mentions: ['11111111111111111111111111111111'] }, { commitment: 'confirmed' }]
  }));
});

ws.on('message', (raw) => {
  const msg = JSON.parse(raw.toString());
  if (msg.id === 1 && msg.result) {
    subscriptionId = msg.result;
    console.log('subscribed with id', subscriptionId);
    return;
  }
  if (msg.method !== 'logsNotification') return;
  if (msg.params.subscription !== subscriptionId) return;
  const { context, value } = msg.params.result;
  console.log('slot', context.slot, 'sig', value.signature, 'err', value.err);
  const frames = parseLogs(value.logs);
  for (const f of frames) {
    console.log('  program', f.programId, 'depth', f.depth, 'status', f.status);
  }
});

ws.on('close', () => console.log('closed'));
ws.on('error', (e) => console.error('error', e.message));

结果表:针对您自己的端点测量通知行为

提供商行为各不相同。协议契约有文档记录,但投递时机、重复率和重连重放因提供商而异,应进行测量而非假设。下表是您针对自己的端点运行的方法。用观察到的值填写;不要将任何行视为通用常量。

在固定窗口内运行订阅,例如一小时,并记录计数。在 confirmed 和 finalized 下重复以进行比较。如果您运营多个端点,请对每个端点运行相同脚本并比较。这是描述您自己投递行为的唯一可靠方法。

  • 端点 URL:您测试的 WebSocket 端点。
  • 承诺级别:confirmed 或 finalized。
  • 窗口:运行的开始和结束时间戳。
  • 收到的通知总数:logsNotification 帧的计数。
  • 唯一签名数:不同 value.signature 值的计数。
  • 重复签名数:总数减去唯一数。
  • 非 null err 计数:value.err 不为 null 的通知数。
  • 最大 slot 间隔:连续 context.slot 值之间的最大差值。
  • 重连重放:强制重连后再次看到的签名。

故障排查:空通知、重复和 id 冲突

空通知通常意味着过滤器太窄或承诺级别太严格。mentions 过滤器仅匹配提及给定公钥的交易。如果您按程序 id 过滤,您只会看到提及该程序的交易,而不是该程序处理的每笔交易。扩大过滤器或使用 all 订阅并在客户端过滤。同时确认在开始路由帧之前已捕获订阅 id。

跨承诺级别的重复签名是预期行为,不是 bug。如果您在 confirmed 和 finalized 订阅,或者在重连后重新订阅,同一签名可能出现多次。按签名去重并优先采用最强承诺。如果您在单个承诺级别内看到重复,请检查客户端是否打开了两个订阅并将两者路由到同一处理程序。

非网络故障的非 null err 是链上结果。不要重试交易。解码错误对象,记录指令索引和原因,并将其路由到您的业务逻辑。如果您看到高比例的非 null err 值,请检查程序逻辑而非传输。

重连后的订阅 id 冲突发生在客户端重用过期 id 或未能清除其映射时。重连时,丢弃所有先前的订阅 id,重新订阅,并根据新响应重建映射。连接生命周期细节在 Solana RPC WebSocket 方法与连接生命周期 中介绍。

  • 空流:扩大过滤器或降低承诺级别。
  • 重复:按签名去重,优先采用最强承诺。
  • 非 null err:链上失败,不是传输错误。
  • id 冲突:重连时清除订阅映射。

logsSubscribe 契约的局限性与权衡

logsSubscribe 提供日志,而非结构化指令。您得到的是扁平字符串数组,必须自行重建结构。这很灵活,但将解析负担放在客户端,且日志格式的任何变化都可能破坏解析器。该格式在实践中稳定,但不是版本化模式。

通知信封没有 id,因此无法使用 JSON-RPC id 关联。您必须维护自己的订阅映射。这很简单,但在重连或多订阅场景下容易出错。JSON-RPC 通知 ID 关联与批量排序 指南介绍了通用模式。

承诺级别不保证精确一次投递。您必须去重和提升。这为消费者增加了状态,意味着您不能将流视为简单队列。如果需要更强的保证,可能需要与 getTransaction 或单独的索引器进行对账。

最后,WebSocket 投递是尽力而为的。节点可能在负载下丢弃帧,客户端可能在重连期间错过事件。对于关键管道,将 logsSubscribe 视为低延迟信号,并与持久源对账。Solana WebSocket API(RPC Assistant) 页面和 Solana 订阅过滤器机制 指南介绍了相关权衡。

  • 扁平日志需要客户端重建结构。
  • 没有 id 意味着您必须维护自己的订阅映射。
  • 承诺级别需要去重和提升逻辑。
  • WebSocket 投递是尽力而为的;关键管道需对账。

后续步骤:从解析到生产管道

一旦能够解码通知,下一步是让消费者具备弹性。添加签名去重、承诺提升以及针对乱序 slot 的有界缓冲区。持久化已处理的最高 slot,以便在重启后恢复而无需重新处理所有内容。

如果大规模运行,考虑使用托管端点以减少运维开销。OnFinality 的 Solana 网络 提供 WebSocket 访问,RPC 定价 页面描述了套餐。对于希望更高层接口的团队,API 服务 和 OnFinality Learn 中心 有相关指南。

最后,针对真实流量测试解析器。使用上面的结果表来描述您的端点,并重新查看 Solana RPC WebSocket 方法与连接生命周期 指南以了解重连和保活模式。正确的信封解析与弹性连接处理的结合,才能使 logsSubscribe 管道达到生产就绪。

  • 添加去重、承诺提升和有界缓冲区。
  • 持久化已处理的最高 slot 以确保重启安全。
  • 考虑使用托管端点以应对规模。
  • 针对真实流量测试并填写结果表。

永远不用担心基础设施

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

开始