How to Verify a LineKey Agent Payment Receipt with RPC
Run a read-only Node.js script that checks one direct Priors LineKey spend receipt on Robinhood Chain mainnet against your invoice: pinned contracts, Paid, Borrowed and USDG Transfer logs, exact integer amounts.
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 as
verify-payment.mjsin a project of your own, installviem@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 itsidempotencyKeybefore acting on it.
For developers and AI Agents: you need an API key and a transaction hash from your own invoice record. Jump to 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.tois 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'stois 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 oneBorrowed, and exactly one USDGTransferfrom the pool to your payee. Missing, duplicated or malformed known logs reject the receipt. A log markedremovedrejects 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 thePaidlog index. Paidmatches youragentId,payee,amountandref.Borrowedmatches the same loan, agent and payee, and its principal equals the amount. TheTransfervalue equals the amount.Paid.operatoris the transaction sender, and yourEXPECTED_OPERATORif 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
- USDG token on Robinhood Chain: deployments/4663.v2.json
- Event definitions: src/LineKey.sol and sdk/priors-v2.mjs
- How LineKey works: docs/LINE-KEY.md
The script
Save this as verify-payment.mjs. It is written for Node.js 24 and viem@2.57.2.
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:
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.mjsExit 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 asidempotencyKey. 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
blockHashhas 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. - 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.
Next steps
- Browse the datasets directory to see every dataset BlockVectra indexes.
- See the free plan and pricing to check what your account includes.
- Log in to the console to create an API key.
Last updated:
Stablecoin Payments
Build a payment receiver and polling cursor. Verify token contracts, recipients and integer amounts, deduplicate events and reconcile missing or replaced blocks.
Logs vs Transfers API
Choose eth_getLogs for contract event logs or the Token Transfers API for indexed ERC-20 transfer history. Compare block ranges, pagination, coverage and finality.