# 如何用 RPC 核对 LineKey 代理付款回执

> 原文地址: https://docs.blockvectra.com/zh/guides/verify-agent-payments/

代理通过 Priors LineKey 付款后，交易回执里会留下三条日志。本指南提供一个脚本，通过 BlockVectra RPC 读取一张回执，并与你已经信任的账单比对：对象是 Robinhood Chain 主网（`robinhood_mainnet`，链 ID 4663）上直接调用 LineKey 合约 `spend` 的一笔 USDG 付款。脚本只读：不签名、不发交易、不付款、不放贷、不等待、不扫描日志。比对通过只是对一张回执的观察，不代表最终确定性。

* \*\*第一步：\*\*把[脚本](#脚本)保存为自己项目里的 `verify-payment.mjs`，安装 `viem@2.57.2`，再用自己记录里的账单值设置环境变量。
* \*\*完成标准：\*\*脚本输出一个带 `"match": true` 的 JSON 对象，并且在据此行动之前，你已经保存了其中的 `idempotencyKey`。

面向开发者与 AI Agent：需要一个 [API key](https://console.blockvectra.com/login/?next=%2Fkeys%2F)，以及来自自己账单记录的交易哈希。[直接看脚本](#脚本)。

## 比对通过说明什么

`LineKey.spend(agentId, payee, amount, ref)` 会在代理的信用额度上生成一笔贷款，由资金池把 USDG 直接付给收款方。这样的一笔交易会产生三条可核对的日志：

| 日志         | 发出方     | 内容                                                   |
| ---------- | ------- | ---------------------------------------------------- |
| `Paid`     | LineKey | `agentId`、`payee`、`ref`、`amount`、`loanId`、`operator` |
| `Borrowed` | 信用资金池   | `loanId`、`agentId`、`principal`、`to`                  |
| `Transfer` | USDG 代币 | `from`（资金池）、`to`（收款方）、`value`                        |

只有同时满足下列条件，脚本才接受这张回执：

* 回执属于所请求的交易，状态为成功，且 `receipt.to` 是固定的 LineKey 地址。这里只认直接调用：经由智能账户、中继器或任何其他合约发出的 spend 会被拒绝，因为那时回执的 `to` 不是 LineKey。
* 解码之前先按发出日志的地址归属。任何合约都可以发出名为 `Paid` 的事件，所以只有固定的 LineKey、资金池和 USDG 地址发出的日志才算数；其他地址的日志，以及这些地址发出的其他事件，一律忽略。
* `Paid`、`Borrowed` 各恰好一条，从资金池到收款方的 USDG `Transfer` 也恰好一条。已知日志缺失、重复或格式错误，整张回执被拒绝；带 `removed` 标记的日志同样拒绝。每条已知日志还必须是规范编码：脚本先解码，再按同一份 ABI 重新编码，要求 topics 与 data 逐字相同，因此地址高位填充非零或 data 末尾多出字节都会被拒绝。被采纳的三条日志的日志序号必须互不相同，因为幂等键由 `Paid` 日志序号构成。
* `Paid` 的 `agentId`、`payee`、`amount`、`ref` 与你的账单一致。`Borrowed` 指向同一笔贷款、同一代理与同一收款方，且本金等于金额。`Transfer` 的 `value` 等于金额。`Paid.operator` 等于交易发送方；设置了 `EXPECTED_OPERATOR` 时还要等于它。

比对通过不代表付款不可逆，也不代表代理的密钥仍然有效，或信用额度背后有资产支撑、具备偿付能力。这笔付款是代理的一笔贷款，不是从代理钱包转出的资金；还款与违约不在本检查范围内。

## 可信输入

账单值必须来自你自己的记录系统，不能来自回执、付款方或这次 RPC 返回的内容。

| 变量                    | 含义                                                  |
| --------------------- | --------------------------------------------------- |
| `BLOCKVECTRA_API_KEY` | 你的 API key。从环境变量读取，只放在 `x-api-key` 请求头里             |
| `TX_HASH`             | 要核对的 32 字节交易哈希                                      |
| `AGENT_ID`            | 代理的注册表 ID，十进制整数                                     |
| `PAYEE`               | 账单要求付款到的地址                                          |
| `AMOUNT_RAW`          | 账单金额，以 USDG 最小单位计的正整数字符串。金额与 ID 一律用 `BigInt`，不使用浮点数 |
| `REF`                 | 账单引用，非零的 32 字节十六进制值                                 |
| `EXPECTED_OPERATOR`   | 可选。期望的 operator 地址，会让检查更严格                          |

脚本要求 `REF` 非零。LineKey 本身接受零引用，但零值无法对应某张账单，也不受「同一收款方只能用一次」的限制，所以脚本把它当作无效输入。

合约地址固定在脚本里，来自上游 Priors 项目在提交 `aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8` 上发布的记录。脚本不会校验链上字节码，依赖前请自行查看这些记录和源码；上游部署变化时，需要同步更新脚本。

* Robinhood Chain 上的 LineKey 与资金池：[deployments/4663.line-key.json](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/deployments/4663.line-key.json)
* Robinhood Chain 上的 USDG 代币：[deployments/4663.v2.json](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/deployments/4663.v2.json)
* 事件定义：[src/LineKey.sol](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/src/LineKey.sol) 与 [sdk/priors-v2.mjs](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/sdk/priors-v2.mjs)
* LineKey 的工作方式：[docs/LINE-KEY.md](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/docs/LINE-KEY.md)

## 脚本

保存为 `verify-payment.mjs`。适用于 Node.js 24 与 `viem@2.57.2`。

```js
import { createPublicClient, decodeEventLog, encodeAbiParameters, encodeEventTopics, getAddress, http, isAddress, isHex, parseAbi, toEventSelector } from 'viem';

// Pinned addresses (lineKey, pool, usdg) from the upstream deployment records at commit aca596ad.
const CHAIN_ID = 4663n; // robinhood_mainnet
const RPC_URL = 'https://api.blockvectra.com/v1/robinhood_mainnet';
const LINE_KEY = getAddress('0x1090Df1B8A4B3F1e7D9aeDaA06fd29f56B9214fB');
const POOL = getAddress('0x281210097f0de7A8FB6F87310AF0f089c9C8DE21');
const USDG = getAddress('0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168');

const [paidEvent, borrowedEvent, transferEvent] = parseAbi([
  'event Paid(uint256 indexed agentId, address indexed payee, bytes32 indexed ref, uint256 amount, uint256 loanId, address operator, uint64 generation)',
  'event Borrowed(uint256 indexed loanId, uint256 indexed agentId, uint256 indexed sponsorId, uint256 principal, uint256 fee, uint64 dueAt, address to)',
  'event Transfer(address indexed from, address indexed to, uint256 value)',
]);
// Emitter, topic0 and exact topic count of each known event.
const KNOWN = [
  { name: 'Paid', event: paidEvent, emitter: LINE_KEY, topics: 4 },
  { name: 'Borrowed', event: borrowedEvent, emitter: POOL, topics: 4 },
  { name: 'Transfer', event: transferEvent, emitter: USDG, topics: 3 },
].map((k) => ({ ...k, topic0: toEventSelector(k.event) }));

class Reject extends Error {}
const reject = (message) => { throw new Reject(message); };
const UINT256_LIMIT = 1n << 256n;
const isQuantity = (v) => typeof v === 'string' && /^0x(0|[1-9a-f][0-9a-f]*)$/.test(v);

function readInvoice(env) {
  const { TX_HASH, AGENT_ID, PAYEE, AMOUNT_RAW, REF, EXPECTED_OPERATOR, BLOCKVECTRA_API_KEY } = env;
  if (!/^0x[0-9a-fA-F]{64}$/.test(TX_HASH ?? '')) reject('TX_HASH must be a 32-byte hex hash');
  if (!/^(0|[1-9][0-9]{0,77})$/.test(AGENT_ID ?? '') || BigInt(AGENT_ID) >= UINT256_LIMIT) reject('AGENT_ID must be a uint256 decimal integer');
  if (!isAddress(PAYEE ?? '') || /^0x0{40}$/i.test(PAYEE)) reject('PAYEE must be a valid nonzero address');
  if (!/^[1-9][0-9]{0,77}$/.test(AMOUNT_RAW ?? '') || BigInt(AMOUNT_RAW) >= UINT256_LIMIT) reject('AMOUNT_RAW must be a positive integer in token base units');
  if (!/^0x[0-9a-fA-F]{64}$/.test(REF ?? '') || /^0x0{64}$/.test(REF)) reject('REF must be a nonzero 32-byte hex value');
  if (EXPECTED_OPERATOR !== undefined && (!isAddress(EXPECTED_OPERATOR) || /^0x0{40}$/i.test(EXPECTED_OPERATOR))) reject('EXPECTED_OPERATOR must be a valid nonzero address');
  if (!/^[\x21-\x7e]+$/.test(BLOCKVECTRA_API_KEY ?? '')) reject('BLOCKVECTRA_API_KEY must be set (printable characters only)');
  return {
    txHash: TX_HASH.toLowerCase(),
    agentId: BigInt(AGENT_ID),
    payee: getAddress(PAYEE),
    amount: BigInt(AMOUNT_RAW),
    ref: REF.toLowerCase(),
    operator: EXPECTED_OPERATOR === undefined ? undefined : getAddress(EXPECTED_OPERATOR),
  };
}

const sameAddress = (a, b) => typeof a === 'string' && isAddress(a) && getAddress(a) === b;

function decodeKnown(known, log) {
  const { topics, data } = log;
  if (!Array.isArray(topics) || topics.length !== known.topics || !topics.every((t) => isHex(t, { strict: true }) && t.length === 66)) reject(`malformed ${known.name} topics`);
  if (!isHex(data, { strict: true })) reject(`malformed ${known.name} data`);
  let args;
  let canonical;
  try {
    args = decodeEventLog({ abi: [known.event], data, topics, strict: true }).args;
    const plain = known.event.inputs.filter((input) => !input.indexed);
    canonical = [...encodeEventTopics({ abi: [known.event], eventName: known.name, args }), encodeAbiParameters(plain, plain.map((input) => args[input.name]))];
  } catch {
    return reject(`undecodable ${known.name} log`);
  }
  // Decoding ignores address padding and trailing bytes; the re-encoded log must equal the received lowercase hex exactly.
  if (canonical.join() !== [...topics, data].join()) reject(`non-canonical ${known.name} encoding`);
  return args;
}

function matchReceipt(receipt, invoice) {
  if (!receipt || typeof receipt !== 'object') reject('no receipt');
  if (receipt.status !== '0x1') reject('transaction did not succeed');
  if (String(receipt.transactionHash).toLowerCase() !== invoice.txHash) reject('receipt is for a different transaction');
  if (!sameAddress(receipt.to, LINE_KEY)) reject('not a direct call to the pinned LineKey');
  if (!isAddress(receipt.from ?? '')) reject('receipt has no sender');
  if (!isHex(receipt.blockHash, { strict: true }) || receipt.blockHash.length !== 66) reject('receipt has no block hash');
  if (!isQuantity(receipt.blockNumber)) reject('receipt has no block number');
  if (!Array.isArray(receipt.logs)) reject('receipt has no logs');
  const found = { Paid: [], Borrowed: [], Transfer: [] };
  for (const log of receipt.logs) {
    if (!log || typeof log !== 'object' || typeof log.address !== 'string' || !isAddress(log.address)) reject('malformed log');
    const emitter = getAddress(log.address);
    const known = KNOWN.find((k) => k.emitter === emitter && Array.isArray(log.topics) && String(log.topics[0]).toLowerCase() === k.topic0);
    if (!known) continue; // logs from other contracts or other events are not part of the check
    if (log.removed === true) reject(`removed ${known.name} log`);
    if (String(log.transactionHash).toLowerCase() !== invoice.txHash || String(log.blockHash).toLowerCase() !== receipt.blockHash.toLowerCase()) reject(`${known.name} log belongs to another transaction or block`);
    if (!isQuantity(log.logIndex)) reject(`${known.name} log has no log index`);
    found[known.name].push({ args: decodeKnown(known, log), logIndex: BigInt(log.logIndex) });
  }
  if (found.Paid.length !== 1) reject(`expected exactly one Paid log, found ${found.Paid.length}`);
  if (found.Borrowed.length !== 1) reject(`expected exactly one Borrowed log, found ${found.Borrowed.length}`);
  const poolToPayee = found.Transfer.filter((t) => t.args.from === POOL && t.args.to === invoice.payee);
  if (poolToPayee.length !== 1) reject(`expected exactly one pool-to-payee USDG Transfer, found ${poolToPayee.length}`);

  const paid = found.Paid[0];
  const indices = [paid, found.Borrowed[0], poolToPayee[0]].map((entry) => entry.logIndex);
  if (new Set(indices).size !== indices.length) reject('Paid, Borrowed and Transfer logs share a log index');
  const { agentId, payee, ref, amount, loanId, operator } = paid.args;
  if (agentId !== invoice.agentId || payee !== invoice.payee || amount !== invoice.amount || ref.toLowerCase() !== invoice.ref) reject('Paid does not match the invoice');
  if (operator !== getAddress(receipt.from)) reject('Paid operator is not the transaction sender');
  if (invoice.operator && operator !== invoice.operator) reject('Paid operator is not the expected operator');
  const borrowed = found.Borrowed[0].args;
  if (borrowed.loanId !== loanId || borrowed.agentId !== invoice.agentId || borrowed.principal !== invoice.amount || borrowed.to !== invoice.payee) reject('Borrowed does not match Paid and the invoice');
  if (poolToPayee[0].args.value !== invoice.amount) reject('USDG Transfer value does not match the invoice');

  return {
    chainId: Number(CHAIN_ID),
    txHash: invoice.txHash,
    blockHash: receipt.blockHash,
    blockNumber: BigInt(receipt.blockNumber).toString(),
    paidLogIndex: paid.logIndex.toString(),
    loanId: loanId.toString(),
    agentId: agentId.toString(),
    payee,
    amountRaw: amount.toString(),
    ref,
    operator,
    idempotencyKey: `${CHAIN_ID}:${invoice.txHash}:${paid.logIndex}`,
  };
}

async function main() {
  const invoice = readInvoice(process.env);
  const userAgent = 'AgentPaymentReceiptExample/1' + (process.env.BLOCKVECTRA_INTERNAL_QA === '1' ? ' BlockVectraQA/1' : '');
  const client = createPublicClient({
    transport: http(RPC_URL, {
      retryCount: 0,
      batch: false,
      timeout: 15_000,
      fetchOptions: { headers: { 'x-api-key': process.env.BLOCKVECTRA_API_KEY, 'user-agent': userAgent } },
    }),
  });
  const chainId = BigInt(await client.request({ method: 'eth_chainId' }));
  if (chainId !== CHAIN_ID) reject(`endpoint reports chain ${chainId}, expected ${CHAIN_ID}`);
  const receipt = await client.request({ method: 'eth_getTransactionReceipt', params: [invoice.txHash] });
  if (receipt === null) reject('no receipt yet: pending or unknown transaction, nothing to report');
  const observation = matchReceipt(receipt, invoice);
  console.log(JSON.stringify({ match: true, ...observation }, null, 2));
}

main().catch((error) => {
  console.error(`verify-payment: ${error instanceof Reject ? error.message : (error.shortMessage ?? 'request failed')}`);
  process.exitCode = error instanceof Reject ? 1 : 2;
});
```

在已安装 `viem@2.57.2` 的项目里运行。不要把 key 写在命令行里，也不要留在 shell 历史中：

```bash
npm install viem@2.57.2
read -rs BLOCKVECTRA_API_KEY && export BLOCKVECTRA_API_KEY
export TX_HASH='0x<账单记录中的交易哈希>'
export AGENT_ID='<账单记录中的代理 ID>'
export PAYEE='<账单记录中的收款地址>'
export AMOUNT_RAW='<以 USDG 最小单位计的金额>'
export REF='0x<账单记录中的非零 32 字节引用>'
node verify-payment.mjs
```

退出码 `0` 表示回执匹配；`1` 表示输入或回执被拒绝，原因输出在 stderr；`2` 表示请求失败。匹配时输出 `chainId`、`txHash`、`blockHash`、`blockNumber`、`paidLogIndex`、`loanId`、`agentId`、`payee`、`amountRaw`、`ref`、`operator` 与 `idempotencyKey`；金额、ID、区块号和日志序号都是十进制字符串。

脚本恰好发出两个 JSON-RPC 请求：先 `eth_chainId`，再一次 `eth_getTransactionReceipt`。如果端点返回的链 ID 不符，在第一个请求后就停止；所有输入在发出请求之前先校验。重试和批量请求都已关闭，超时 15 秒，不做轮询。找不到回执（交易未上链或不存在）或请求出错，都会直接结束且不输出结果。

## 据此行动之前

* \*\*保存这笔付款的唯一标识。\*\*用 `链 ID + 交易哈希 + Paid 日志序号` 作唯一键，脚本把它输出为 `idempotencyKey`。仅凭交易哈希不足以区分一笔交易里的多个事件；重复运行或重复投递时，同一笔付款不能被处理两次。
* \*\*回执不等于确认策略。\*\*回执可能位于链头附近、之后被重组替换的区块里。脚本不读取确认数。请自己定好确认深度或等待规则，满足后再运行一次脚本。如果保存的 `blockHash` 变了，或回执消失、不再匹配，就把之前的观察视为已被取代，按你的重组处理流程办理。区块被替换的情形见[稳定币收款监听](https://docs.blockvectra.com/zh/guides/stablecoin-payments/#处理区块重组与确认深度)。
* \*\*匹配不是入账决定。\*\*入账、发货或放行仍由你自己的业务规则决定。本检查不说明代理的授权是否仍然有效，也不说明其背后的额度是否有资产支撑。
* \*\*注意不在范围内的情形。\*\*其他付款路径、经由其他合约发出的 spend、同一账单的部分付款或重复付款、退款，都需要各自的检查。关于 Robinhood Chain 上的端点细节与 RPC 限制，见 [Robinhood Chain 指南](https://docs.blockvectra.com/zh/guides/robinhood-chain/)。

## 下一步

* [浏览数据集目录](https://blockvectra.com/zh/data/)，查看 BlockVectra 索引的全部数据集。
* [查看免费额度与定价](https://blockvectra.com/zh/pricing/#free)，确认账户可用的方案。
* [登录控制台](https://console.blockvectra.com/login/?next=%2Fkeys%2F)创建 API key。
