Skip to content

Payment Backed Attestations

RunTimeAdmin edited this page Oct 29, 2026 · 13 revisions

Payment-Backed Attestations

**Referenced Files in This Document** - [payment-backed-attestations.md](https://github.com/RunTimeAdmin/Sigvara/blob/main/docs/payment-backed-attestations.md) - [payments.js](https://github.com/RunTimeAdmin/Sigvara/blob/main/oracle/payments.js) - [payment-scan.js](https://github.com/RunTimeAdmin/Sigvara/blob/main/oracle/payment-scan.js) - [payments.test.js](https://github.com/RunTimeAdmin/Sigvara/blob/main/oracle/payments.test.js)

Table of Contents

  1. Introduction
  2. Project Structure
  3. Core Components
  4. Architecture Overview
  5. Detailed Component Analysis
  6. Dependency Analysis
  7. Performance Considerations
  8. Troubleshooting Guide
  9. Conclusion

Introduction

This document explains how Sigvara’s reputation system turns real on-chain payments into trustworthy attestations. Instead of accepting a caller-chosen attester name, the oracle requires a settlement transaction that moved an accepted ERC-20 token to the agent’s own address. The payer is extracted from the blockchain transfer log, not from the request. Verified payments then contribute to fee volume and tenure, and to the success ratio when a credentialed caller reports an outcome, while being protected by anti-fraud rules such as self-payment rejection and per-payer diversity caps.

The design intentionally verifies the settled outcome rather than re-executing any payment facilitator protocol. Whether the client paid through x402, EIP-3009, or a direct transfer, the oracle trusts only what can be independently audited: the Transfer event recorded on chain.

Project Structure

Payment-backed attestations are implemented primarily in the oracle layer:

  • Documentation: describes the policy, configuration, decay, Merkle evidence, and relationship to x402.
  • Payments module: parses receipts, extracts ERC-20 transfers, validates confirmations, timestamps, amounts, and identity.
  • Payment scanner: implements ADR 0003 pull-based scanning so operators converge even when no one submits a settlement hash.
  • Tests: assert verification behavior, error codes, self-payment detection, diversity capping, decay, and multi-sender consistency.
graph TB
Client["Client / x402 Facilitator"] --> OracleAPI["Oracle API"]
OracleAPI --> Payments["payments.js<br/>verifyPayment()"]
OracleAPI --> Scanner["payment-scan.js<br/>planCredits()"]
Payments --> Chain["Ethereum Provider<br/>getTransactionReceipt / getBlock"]
Scanner --> Chain
Payments --> Evidence["Merkle Evidence<br/>txHash, payer, amount, settledAt, outcome"]
Scanner --> Evidence
Loading

Diagram sources

Section sources

Core Components

  • Settlement verification: reads a transaction receipt, confirms it succeeded, checks block confirmations, finds ERC-20 Transfer logs to the agent, groups them by sender, holds each sender to the minimum amount on its own (a zero-value transfer never counts), and derives the payers from the chain.
  • Identity extraction: the attester is the largest contributing sender in the settlement, and every qualifying sender is credited as its own payer; this is taken from indexed topics, never from user input.
  • Anti-fraud controls:
    • Self-payment refusal for both the operator and the agent address.
    • Per-payer contribution cap limiting how much one counterparty can influence feeScore and attestation weight (4 points by default, so five payers with no standing of their own fill the factor).
    • Optional trust weighting based on the payer’s external standing.
  • Decay: each payment carries its block timestamp; older events are weighted by 0.5^(age/half-life) so reputation reflects recent activity.
  • Pull-based scanning: operators independently scan confirmed blocks for eligible transfers, applying the same anti-fraud and diversity logic so two honest operators converge.

Section sources

Architecture Overview

The settlement verification flow proceeds entirely off the oracle’s local opinion and onto the blockchain:

sequenceDiagram
participant Client as "Client"
participant Oracle as "Oracle API"
participant Payments as "payments.verifyPayment"
participant Provider as "Chain Provider"
participant Block as "Block Timestamp"
Client->>Oracle : POST { didHash, success, payment.txHash }
Oracle->>Payments : verifyPayment(provider, cfg, txHash, agentAddress)
Payments->>Provider : getTransactionReceipt(txHash)
Provider-->>Payments : receipt or null
alt Receipt missing or reverted
Payments-->>Oracle : PaymentError(code)
Oracle-->>Client : 402 or 502
else Valid receipt
Payments->>Provider : getBlockNumber()
Provider-->>Payments : head block
Payments->>Payments : check confirmations >= minConfirmations
Payments->>Payments : parse Transfer logs to agent
alt No matching transfer or below minimum
Payments-->>Oracle : PaymentError(code)
Oracle-->>Client : 402
else Valid payment
Payments->>Provider : getBlock(blockNumber)
Provider-->>Block : block with timestamp
Block-->>Payments : settledAt (seconds → milliseconds)
Payments-->>Oracle : { payer, amount, legs, blockNumber, settledAt, txHash }
Oracle-->>Client : 200 verified payer and amount, or 202 with durable false if the state could not be saved
end
end
Loading

Diagram sources

Detailed Component Analysis

Settlement Transaction Verification

Verification begins with strict input validation: the settlement hash must be a 64-character hex string. If verification mode is required, an ERC-20 asset address must also be configured. The oracle then fetches the transaction receipt and rejects:

  • Missing receipts (unmined or unknown).
  • Reverted transactions.
  • Transactions without enough confirmations.
  • Transfers that do not credit the agent’s registered address.
  • Transactions in which no sender paid at least PAYMENT_MIN_AMOUNT, or in which every transfer to the agent has a value of zero.

When multiple Transfer logs from one sender credit the agent in one transaction, they are summed as that sender’s payment. Each sender is held to the minimum on its own, and a zero-value transfer never counts, because on a standard ERC-20 anyone can emit one naming any sender. Every qualifying sender is credited as its own payer, so the per-payer cap sees who actually paid. The largest contributor names the attestation: it decides the attester identity, the cooldown key and the request-level self-payment refusal.

flowchart TD
Start(["verifyPayment Entry"]) --> ValidateHash["Validate txHash format"]
ValidateHash --> CheckAsset["Check PAYMENT_ASSET configured"]
CheckAsset --> FetchReceipt["Fetch transaction receipt"]
FetchReceipt --> ReceiptValid{"Receipt exists and status = 1?"}
ReceiptValid --> |No| RPCOrRevert["Return rpc_error or reverted"]
ReceiptValid --> HeadBlock["Read current block number"]
HeadBlock --> Confirmations["Compute confirmations"]
Confirmations --> Confirmed{"confirmations >= minConfirmations?"}
Confirmed --> |No| Unconfirmed["Return unconfirmed"]
Confirmed --> ParseLogs["Parse ERC-20 Transfer logs to agent"]
ParseLogs --> HasTransfer{"Any matching transfer?"}
HasTransfer --> |No| NoTransfer["Return no_transfer"]
HasTransfer --> GroupBySender["Group matching transfers by sender"]
GroupBySender --> MinAmount{"Any sender above zero and >= minAmount?"}
MinAmount --> |No| BelowMinimum["Return below_minimum, or no_transfer if every value is zero"]
MinAmount --> ReadBlock["Read block for timestamp"]
ReadBlock --> TimestampValid{"timestamp finite?"}
TimestampValid --> |No| RPCError["Return rpc_error"]
TimestampValid --> ReturnVerified["Return verified payer, amount, legs, settledAt, txHash"]
Loading

Diagram sources

Section sources

ERC-20 Transfer Log Parsing

The parser filters logs by:

  • Token contract address matching the configured asset.
  • Event signature matching Transfer(address,address,uint256).
  • Exactly three topics, indicating indexed from and to.
  • Indexed to matching the agent’s registered address.

From the second topic, the from address is recovered by zero-padding and extracting the lower 20 bytes. The third topic identifies the payee. The raw data field holds the transferred value as a BigInt.

The scan applies the same checks to the logs it fetches (decodeTransfer): the emitter must be the configured asset, there must be exactly three topics (a log with four is an ERC-721 Transfer), a log flagged removed was reorganised away and is dropped, and data that is not a single word cannot be an amount.

flowchart TD
Logs["Receipt.logs[]"] --> FilterToken["Match token address"]
FilterToken --> FilterTopic["Match Transfer event signature"]
FilterTopic --> TopicCount{"topics.length === 3?"}
TopicCount --> |No| Skip["Skip non-standard log"]
TopicCount --> |Yes| MatchTo["Match indexed to == agent address"]
MatchTo --> |No| Skip
MatchTo --> ExtractFrom["Extract from from topics[1]"]
ExtractFrom --> ExtractValue["Extract value from data"]
ExtractValue --> PushLeg["Push {from, value}"]
Loading

Diagram sources

Section sources

Anti-Fraud Mechanisms

Self-Payment Detection

A payment is rejected as a self-payment when the payer matches either:

  • The agent’s operator address.
  • The agent’s own address.

This prevents an operator from paying itself for only gas cost and treating the result as indistinguishable from a customer attestation. It raises the floor from free to deliberate but does not fully prevent an attacker from funding a separate wallet.

The check is made for each sender. On the scan, a self-paying sender group is skipped as self_payment and the rest of the transaction is credited. On /attest, the request is refused with self_payment when the largest sender is the operator or the agent address, and any other self-paying leg is skipped, so an operator cannot slip its own leg into a transaction a real customer also paid into.

flowchart TD
Payer["Payer address"] --> CompareOperator{"payer == operator?"}
CompareOperator --> |Yes| RejectSelf["Reject as self_payment"]
CompareOperator --> |No| CompareAgent{"payer == agentAddress?"}
CompareAgent --> |Yes| RejectSelf
CompareAgent --> |No| Accept["Allow arm's-length payment"]
Loading

Diagram sources

Section sources

Diversity Requirements and Per-Payer Caps

Without a cap, one wallet making many payments would look identical to many wallets making one payment. The system limits each counterparty’s contribution to maxPerPayer points of feeScore and the equivalent attestation weight. The default configuration (4 points per payer against a 20-point factor) requires five distinct payers with no standing of their own to saturate the factor (three if each has full standing), reducing the effectiveness of small rings.

Trust weighting can raise a reputable counterparty’s effective cap proportionally to their external hard standing, while unknown or freshly created wallets receive no boost.

flowchart TD
Events["Age-weighted events"] --> GroupByPayer["Group by payer"]
GroupByPayer --> ComputeCap["Compute cap per payer<br/>base × trustMultiplier"]
ComputeCap --> ApplyVolume["Cap volume contribution"]
ComputeCap --> ApplyAttestations["Cap outcome weight"]
ApplyVolume --> Score["feeScore from diversified volume"]
ApplyAttestations --> SuccessRatio["diversified success ratio"]
Loading

Diagram sources

Section sources

Valid Attestation Formats and Transaction Hash Validation

A valid positive attestation includes:

  • didHash: the agent’s DID hash.
  • success: true.
  • payment.txHash: a 32-byte hexadecimal transaction hash.

The oracle does not accept a caller-supplied attester identity; the payer comes from the transfer log.

A request with no token records the payment, as fee volume and tenure, and records no outcome. A settlement hash is public, so whoever submits one has not shown that they paid or that they saw the work, and the first credit for a settlement is final. The response says which happened with outcomeRecorded. Outcomes come from a caller holding a write credential, such as CounterAudit, and a token holder can add one to a payment that was credited without it, once; a recorded outcome is final. An oracle with no credentials configured at all, which only a loopback bind may run, treats every caller as a token holder.

Negative attestations still require a write credential because they directly reduce the score, and a verified payment proves money moved but never that the work failed. Without a token, anything other than success: true returns 401. The responses are 200 (accepted and saved), 202 (accepted, but the state file could not be saved: durable: false, do not resend), 400 (malformed body or unknown agent), 401, 402 (code says why the payment did not check out), 403 (a credential limited to other scopes), 409 (replayed, or outcome_recorded when the outcome is already set), 429 (the cooldown, or the limit of 60 requests a minute per caller; the cooldown applies to every attestation except a token holder's report on a payment the oracle verified in the same request, and is set only after a credit succeeds, so a refused replay does not arm it) and 502 (the RPC failed, which is not a verdict on the payment).

classDiagram
class AttestationRequest {
+string didHash
+boolean success
+Payment payment
}
class Payment {
+string txHash
}
class VerifiedPayment {
+string payer
+bigint amount
+number blockNumber
+number settledAt
+string txHash
}
AttestationRequest --> Payment : "contains"
Payment --> VerifiedPayment : "verified on chain"
Loading

Diagram sources

Section sources

Block Confirmation Requirements

The oracle computes confirmations as head - receipt.blockNumber + 1 and compares them against PAYMENT_MIN_CONFIRMATIONS. The pull scanner uses a higher floor for unattended scanning to avoid crediting potentially reorganized blocks.

  • Attested path: configurable confirmation threshold, defaulting to one.
  • Scanned path: at least six confirmations unless explicitly raised.

The default of one is a statement about the chain. Arc's documentation says that once its consensus layer commits a block the block is final, with no reorganization risk (system overview), so on Arc a receipt the node serves is already settled. That is the chain operator's statement and the repository does not test it, so the launch checklist repeats the question on mainnet, and a chain without that finality needs a higher PAYMENT_MIN_CONFIRMATIONS. A credit is not taken back, because the settlement hash is recorded as used; that is why the unattended scan keeps its six-block margin whatever the chain.

Section sources

Pull-Based Scan, Pacing, and Canary

payment-scan.js finds payments without waiting for anyone to submit them, so two operators with the same chain view converge. It is off by default (PAYMENT_SCAN_ENABLED=1 turns it on) because enabling it changes scores, and it must be on for every operator or for none.

  • Credits: A scanned payment is credited with no outcome. It counts toward fee volume and tenure and stays out of both sides of the success ratio. Every refusal is named: self_payment, below_minimum, zero_value and already_credited.
  • Query shape: Recipients are batched 50 to a getLogs call, over block ranges of PAYMENT_SCAN_CHUNK (the log chunk size, 2000 by default).
  • Pacing: PAYMENT_SCAN_PACE_MS (500 ms by default, 0 to opt out) spaces the calls. Both Arc endpoints limit with a bucket of about 60 requests: back-to-back calls were refused about 60% of the time, 250 ms cleared the first 60 and then failed every call, and 500 ms sustained 72 of 72. It applies only between calls, so a steady scan of one chunk pays nothing.
  • Canary: A getLogs that matches nothing looks the same whether nobody was paid or the filter is wrong. When a scan credits nothing, it re-runs the same filter shape for one recipient and one block against a settlement the operator already holds. passed means the empty range was empty. failed means the range is unverified, so the checkpoint is held and the whole range is scanned again at the next epoch. unavailable (no settlement to point at, or the RPC could not answer) never holds the checkpoint.
  • Checkpoint: It stops one block short of the earliest credit whose block time could not be read, so that payment is retried rather than skipped.

Section sources

Edge Cases

Partial Payments and Multi-Token Settlements

  • Partial payments: Multiple Transfer logs from one sender in one transaction are summed as one payment. Each sender’s total must be above zero and meet PAYMENT_MIN_AMOUNT on its own.
  • Multi-sender settlements: When several senders credit the agent in one transaction, each qualifying leg is recorded as its own payment from its own payer. The largest contributor names the attestation identity, cooldown key, and request-level self-payment check, and an attested outcome goes on that leg alone; the other legs carry no outcome and count toward fee volume and tenure only.
  • Multi-token settlements: Only transfers of the configured PAYMENT_ASSET are considered. Other tokens are ignored.
flowchart TD
Receipt["Transaction receipt"] --> Logs["All Transfer logs"]
Logs --> AssetFilter["Keep only configured asset"]
AssetFilter --> ToFilter["Keep only logs where to == agent"]
ToFilter --> GroupSenders["Group by sender"]
GroupSenders --> MinimumCheck["Each sender above zero and >= minimum?"]
MinimumCheck --> Legs["Qualifying senders become legs"]
Legs --> LargestPayer["Largest leg = attester identity"]
Loading

Diagram sources

Section sources

Cross-Chain Considerations

The implementation assumes a single Ethereum-compatible provider and ERC-20 Transfer event. There is no cross-chain bridge logic in these modules. For cross-chain use:

  • Each chain needs its own oracle instance or provider.
  • The configured PAYMENT_ASSET must refer to the token contract on that chain.
  • Settlement hashes must be valid on the corresponding chain.
  • Confirmation depth should account for finality characteristics of the target chain.

Dependency Analysis

The payment system has clear boundaries between configuration, chain access, verification, and scoring utilities.

graph LR
Config["readConfig()"] --> Verify["verifyPayment()"]
Config --> Diversity["diversifiedVolume() / diversifiedAttestations()"]
Config --> Decay["decayWeight() / decayedVolume()"]
Verify --> Transfers["transfersTo()"]
Verify --> Identity["isSelfPayment()"]
Diversity --> Trust["trustMultiplier()"]
Diversity --> Propagation["propagationScore()"]
Scan["payment-scan.planCredits()"] --> VerifyRules["Same anti-fraud rules"]
Loading

Diagram sources

Section sources

Performance Considerations

  • BigInt arithmetic: Amounts and scaled weights use BigInt to preserve precision for high-decimal tokens and large volumes.
  • Scaled integer weights: Decay weights are multiplied by a scale factor before rounding to avoid floating-point drift.
  • Event pruning: Events whose decay weight falls below a small threshold are pruned once per epoch to prevent unbounded state growth.
  • RPC resilience: Provider failures return explicit rpc_error codes instead of silently rejecting valid payments.
  • Scan pacing: Pull scans pace getLogs calls (500 ms apart by default) to avoid rate-limiting during catch-up runs.

Section sources

Troubleshooting Guide

Common verification errors and their meanings:

Error Code Meaning Likely Cause Recommended Action
bad_tx_hash Settlement hash is not a 32-byte hex string Malformed request Ensure payment.txHash is exactly 64 hex characters after 0x.
not_configured Verification mode is required but no asset is set Missing PAYMENT_ASSET Configure the ERC-20 token used for payments.
rpc_error Provider call failed Node down, network issue, or unreadable block timestamp Retry; do not treat as a rejected payment.
not_found Transaction not found Unmined or unknown hash Wait for mining or verify the correct chain and hash.
reverted Transaction status is not successful Reverted execution Check the transaction on a block explorer.
unconfirmed Not enough confirmations Submitted too early Wait until head - blockNumber + 1 >= minConfirmations.
no_transfer No matching ERC-20 transfer to the agent Wrong token, wrong recipient, wrong agent address, or only zero-value transfers Confirm PAYMENT_ASSET, agent registry address, and token decimals.
below_minimum No sender paid the minimum Every sender’s payment is under PAYMENT_MIN_AMOUNT Increase the settlement amount.
self_payment Payer is operator or agent address Self-dealing attempt Use a genuine counterparty wallet.
already_credited Same settlement already counted for this agent Duplicate submission Do not resubmit the same txHash for the same agent.
replayed HTTP 409 from /attest: this settlement was already credited for this agent and payer Duplicate submission Do not resubmit it. A token holder can still add an outcome if the first credit carried none.
outcome_recorded HTTP 409 from /attest: an outcome is already recorded for this settlement A second outcome report None; the first outcome is final.
durable: false HTTP 202 from /attest: accepted, but the oracle could not save its state file, so a restart before its next good save loses it A failing disk, a full volume or an unwritable state path Do not resend: the payment is credited and the next save that works keeps it, and a resend is refused (409 for a settlement already credited, 429 inside the cooldown). Fix the volume; /health is 503 until a save works.
zero_value The scan skipped a log whose value is zero Anyone can emit a zero-value Transfer naming any sender None; it never counts as a payment.

Section sources

Conclusion

Payment-backed attestations replace anonymous HTTP claims with verifiable on-chain settlement. The oracle reads the payer from ERC-20 transfer logs, refuses self-payments, caps per-payer influence, timestamps payments by block time, and decays old evidence. Pull-based scanning ensures operators converge even when no one submits a settlement hash. While payments prove money moved and establish who paid, they do not prove the quality of work; outcomes come only from credentialed callers, and diversity controls exist to make manipulation more expensive and less effective.

Overview

Clone this wiki locally