# How to Verify a LineKey Agent Payment Receipt with RPC

> Original page: https://docs.blockvectra.com/en/guides/verify-agent-payments/

An agent that pays through a Priors LineKey leaves a transaction receipt with three logs. This guide gives you a script that reads one receipt through BlockVectra RPC and checks it against an invoice you already trust: a direct `spend` call on the LineKey contract on Robinhood Chain mainnet (`robinhood_mainnet`, chain ID 4663), paid in USDG. The script only reads: it never signs, sends, pays, lends, waits or scans logs. A match is an observation about one receipt, not a finality guarantee.

* **First step:** [Save the script](#the-script) as `verify-payment.mjs` in a project of your own, install `viem@2.57.2`, and set the invoice values from your own records.
* **Done when:** the script prints one JSON object with `"match": true`, and you have stored its `idempotencyKey` before acting on it.

For developers and AI Agents: you need an [API key](https://console.blockvectra.com/login/?next=%2Fkeys%2F) and a transaction hash from your own invoice record. [Jump to the script](#the-script).

## What a match tells you

`LineKey.spend(agentId, payee, amount, ref)` draws a loan on the agent's credit line and the pool pays USDG straight to the payee. One such transaction emits three logs you can check:

| Log        | Emitted by      | Carries                                                   |
| ---------- | --------------- | --------------------------------------------------------- |
| `Paid`     | LineKey         | `agentId`, `payee`, `ref`, `amount`, `loanId`, `operator` |
| `Borrowed` | the credit pool | `loanId`, `agentId`, `principal`, `to`                    |
| `Transfer` | the USDG token  | `from` (the pool), `to` (the payee), `value`              |

The script accepts the receipt only when all of these hold:

* The receipt belongs to the requested transaction, its status is success, and `receipt.to` is the pinned LineKey address. This is a direct call: a spend sent through a smart account, relayer or any other contract is rejected, because then the receipt's `to` is not LineKey.
* Each log is attributed by the address that emitted it before it is decoded. Any contract can emit an event named `Paid`, so only the pinned LineKey, pool and USDG addresses count. Logs from other addresses, and other events from those addresses, are ignored.
* There is exactly one `Paid`, exactly one `Borrowed`, and exactly one USDG `Transfer` from the pool to your payee. Missing, duplicated or malformed known logs reject the receipt. A log marked `removed` rejects it. Each known log must also be canonically encoded: the script decodes it, re-encodes it with the same ABI, and requires identical topics and data, so padded address bytes or trailing data are rejected. The three accepted logs must have different log indexes, because the idempotency key is built from the `Paid` log index.
* `Paid` matches your `agentId`, `payee`, `amount` and `ref`. `Borrowed` matches the same loan, agent and payee, and its principal equals the amount. The `Transfer` value equals the amount. `Paid.operator` is the transaction sender, and your `EXPECTED_OPERATOR` if you set one.

A match does not mean the payment is irreversible, that the agent's key is still authorized, or that the credit line is backed or solvent. The payment is a loan of the agent, not a transfer from the agent's wallet; repayment and defaults are outside this check.

## Trusted inputs

The invoice values must come from your own system of record, never from the receipt, the payer or this RPC response.

| Variable              | Meaning                                                                                                               |
| --------------------- | --------------------------------------------------------------------------------------------------------------------- |
| `BLOCKVECTRA_API_KEY` | Your API key. Read from the environment and sent only in the `x-api-key` header                                       |
| `TX_HASH`             | The 32-byte transaction hash you are checking                                                                         |
| `AGENT_ID`            | The agent's registry ID, a decimal integer                                                                            |
| `PAYEE`               | The address the invoice expects to be paid                                                                            |
| `AMOUNT_RAW`          | The invoiced amount in USDG base units, a positive integer string. Amounts and IDs use `BigInt`, never floating point |
| `REF`                 | The invoice reference, a nonzero 32-byte hex value                                                                    |
| `EXPECTED_OPERATOR`   | Optional. The operator address you expect, which tightens the check                                                   |

The script requires a nonzero `REF`. LineKey itself accepts a zero reference, but a zero value identifies no invoice and is not limited to one use per payee, so this script treats it as invalid input.

The contract addresses are pinned in the script from records published by the upstream Priors project at commit `aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8`. The script does not verify the deployed bytecode, so review the records and source before you rely on them, and update the script if the upstream deployment changes.

* LineKey and pool on Robinhood Chain: [deployments/4663.line-key.json](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/deployments/4663.line-key.json)
* USDG token on Robinhood Chain: [deployments/4663.v2.json](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/deployments/4663.v2.json)
* Event definitions: [src/LineKey.sol](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/src/LineKey.sol) and [sdk/priors-v2.mjs](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/sdk/priors-v2.mjs)
* How LineKey works: [docs/LINE-KEY.md](https://github.com/priors-agents/priors/blob/aca596ad3a13ed6354d21ecb2a6249bcbce6c5f8/docs/LINE-KEY.md)

## The script

Save this as `verify-payment.mjs`. It is written for Node.js 24 and `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;
});
```

Run it from a project where `viem@2.57.2` is installed. Keep the key out of the command line and out of shell history:

```bash
npm install viem@2.57.2
read -rs BLOCKVECTRA_API_KEY && export BLOCKVECTRA_API_KEY
export TX_HASH='0x<transaction hash from your invoice record>'
export AGENT_ID='<agent ID from your invoice record>'
export PAYEE='<payee address from your invoice record>'
export AMOUNT_RAW='<amount in USDG base units>'
export REF='0x<nonzero 32-byte reference from your invoice record>'
node verify-payment.mjs
```

Exit code `0` means the receipt matched. `1` means the input or the receipt was rejected, with the reason on stderr. `2` means a request failed. A match prints `chainId`, `txHash`, `blockHash`, `blockNumber`, `paidLogIndex`, `loanId`, `agentId`, `payee`, `amountRaw`, `ref`, `operator` and `idempotencyKey`; amounts, IDs, block numbers and log indexes are decimal strings.

The script sends exactly two JSON-RPC requests: `eth_chainId`, then `eth_getTransactionReceipt` once. It stops after the first if the endpoint reports a different chain ID, and the inputs are validated before any request. Retries and batching are off, the timeout is 15 seconds, and there is no polling. A missing receipt (pending or unknown transaction) or a request error ends the run without a result.

## Before you act on a match

* **Store the identity of the payment.** Use the unique key `chain ID + transaction hash + Paid log index`, which the script prints as `idempotencyKey`. A transaction hash alone can cover more than one event, and a repeated run or delivery must not apply the same payment twice.
* **A receipt is not a confirmation policy.** The receipt can sit in a block near the chain head that a reorg later replaces. The script does not read confirmations. Decide your own depth or waiting rule, then run the script again once it is met. If the stored `blockHash` has changed, or the receipt is gone or no longer matches, treat the earlier observation as superseded and apply your reorg handling. Block replacement is covered in [Stablecoin payments](https://docs.blockvectra.com/en/guides/stablecoin-payments/#handling-block-reorganizations-and-confirmation-depth).
* **A match is not a credit decision.** Crediting, fulfilling or releasing goods remains your business rule. This check says nothing about whether the agent's authorization is still current or whether the line behind it is backed.
* **Know what is out of scope.** Other payment routes, spends made through another contract, partial or repeated payments for one invoice, and refunds need their own checks. For endpoint details on Robinhood Chain and its RPC limits, see [the Robinhood Chain guide](https://docs.blockvectra.com/en/guides/robinhood-chain/).

## Next steps

* [Browse the datasets directory](https://blockvectra.com/en/data/) to see every dataset BlockVectra indexes.
* [See the free plan and pricing](https://blockvectra.com/en/pricing/#free) to check what your account includes.
* [Log in to the console](https://console.blockvectra.com/login/?next=%2Fkeys%2F) to create an API key.
