Tether’s Q2 2026 attestation put total assets at roughly $187.75 billion against $184.6 billion in outstanding USDT, a buffer of about $3.15 billion, or 1.71%. Circle reported that 84% of USDC reserves sat in the Circle Reserve Fund as of June 30, 2026, with the rest in bank cash. Both numbers came from the issuers themselves, checked by an outside accounting firm on a single day, then published as a PDF. If you hold six figures in stablecoins, or you’re building a product that settles in them, that’s a thin amount of verification for how much money rides on it.
This tutorial builds a script that checks stablecoin backing independently, using on-chain data you can read yourself rather than a report you have to trust. You’ll read live token supply from the blockchain, pull a Chainlink Proof-of-Reserve feed where one exists, cross-reference the issuer’s own published attestation, and wire the whole thing into an alert that fires when something looks off. By the end you’ll have a working Node.js project, not just a set of curl commands to memorize.
This is a coding tutorial, not investment advice, and it won’t catch every failure mode. Read the limits section before you trust the output.
Why build this now rather than just reading the quarterly PDF? Because the stakes have grown faster than the reporting cadence has. TRM Labs tracked 207 hacking incidents and roughly $972 million in aggregate crypto losses across the first half of 2026, with a median loss of about $219,000 per hack, according to its mid-year report. Stablecoins sit at the center of a huge share of that activity, either as the asset being stolen or the rail attackers cash out through. A script that watches supply and reserve data continuously costs nothing to run and catches a category of problem that a report published every three months structurally cannot.
Why Trusting a Stablecoin’s Own Numbers Isn’t Enough
Stablecoin issuers publish reserve reports because regulators and users demand it, not because the reports are continuous or independently generated. Tether’s disclosures are attestations, meaning an accounting firm checked management’s claims against supporting documents at one point in time. That’s different from a full financial statement audit, and it’s worth knowing the difference before you cite one as proof of anything ongoing. Circle publishes monthly summaries with similar limits: a snapshot, a percentage breakdown, and a promise that things look the same next month.
None of that is dishonest. It’s just slow and manual, and it leaves a gap between report dates where nobody outside the issuer knows what’s actually backing the tokens in your wallet. On-chain data closes part of that gap. Total token supply is public and verifiable in real time, on every chain the token lives on. Combine that with whatever reserve data is available on-chain, plus the issuer’s own attestation as a sanity check, and you get a verification pipeline that doesn’t depend on taking anyone’s word for it between report dates.
The goal here isn’t to replace the accounting firm. It’s to catch the cases an attestation can’t: a sudden supply spike that doesn’t match any announced mint, a Proof-of-Reserve feed that’s gone stale for days, or a coverage ratio that’s drifted since the last published report. Those are things code can watch continuously that a quarterly PDF cannot.
What Proof-of-Reserve Actually Proves — And Where It Falls Short
Chainlink’s Proof-of-Reserve feeds work like its price feeds: a smart contract implementing AggregatorV3Interface, updated when a reserve value crosses a deviation threshold or a heartbeat timer expires, readable by anyone through latestRoundData(). Chainlink’s own documentation describes PoR as covering stablecoins, wrapped assets, and real-world assets, but the feeds that actually exist in production lean heavily toward wrapped and collateralized assets like WBTC rather than the two biggest stablecoins by market cap. Don’t assume a PoR feed exists for a given token just because it’s a stablecoin. Check the official address registry at docs.chain.link/data-feeds/proof-of-reserve/addresses for the exact chain and asset before you build against it.
Where a PoR feed does exist, it proves that a specific custodian or contract holds the reported quantity of a specific asset at the last update time. It does not prove the reserve is unencumbered, that it hasn’t been lent out or pledged elsewhere, or that every issuer-controlled wallet has been included in the count. A stale feed that stopped updating three weeks ago will happily keep returning its last good answer unless you explicitly check the timestamp.
The update mechanics are worth understanding before you build anything against them. Chainlink feeds, PoR included, don’t stream continuously. An aggregator updates its stored answer only when the underlying value moves past a configured deviation threshold, or when a heartbeat timer expires, whichever comes first. Both settings are feed-specific: a slow-moving reserve might have a wide deviation band and a long heartbeat, while a volatile one updates more often. Pull the exact heartbeat and deviation values for your target feed from the feed’s page at data.chain.link or its on-chain configuration before you decide what counts as “stale” in your own staleness check. Hardcoding a generic threshold across every feed you monitor is a common shortcut that produces false alarms on slow feeds and misses real problems on fast ones.
Here’s how the three verification methods compare in practice:
| Method | What it confirms | Update frequency | Main weakness |
|---|---|---|---|
| On-chain total supply | Exact circulating token count per chain | Every block | Says nothing about backing assets |
| Chainlink Proof-of-Reserve | Reported custodial holdings at last update | Deviation threshold or heartbeat | Not deployed for every stablecoin; trusts the reporting node set |
| Issuer attestation (Tether, Circle) | Total reserve composition on report date | Monthly or quarterly | Single point-in-time snapshot, not continuous |
| Exchange-reported reserves | Custodial balances the exchange claims to hold | Varies, often self-published | No independent verification of underlying custody |
Combining all three is the point of this tutorial. Supply gives you the denominator, PoR gives you a live numerator where it exists, and the attestation gives you a trusted-but-infrequent baseline to catch drift against.
Prerequisites and Tool Versions
You’ll need a machine with Node.js 22.x LTS or newer, npm, and an RPC endpoint for at least one EVM chain (a free tier from a provider like Alchemy or Infura works fine for this tutorial). Sign up for whichever provider you prefer, create a project, and grab the HTTPS RPC URL for mainnet. You don’t need a wallet, private key, or any funds for this project since every call in this tutorial is a read against public contract state, nothing here submits a transaction. This walkthrough pins package versions retrieved directly from the npm registry at the time of writing:
- Node.js 22 LTS or newer
- ethers.js 6.17.0
- viem 2.56.9
- node-cron 3.x for scheduling
- An archive-capable RPC URL if you want to query historical blocks, otherwise a standard RPC endpoint is enough
Confirm the exact versions you’re installing rather than trusting this list months later:
npm view ethers version
npm view viem version
node --version
Step 1-2: Scaffold the Project and Connect to an RPC
Start with a clean project folder and install both libraries. You’ll use ethers.js for the supply read and viem for the Proof-of-Reserve read, mostly so the tutorial shows both idioms, though in a real project picking one is fine.
mkdir stablecoin-verifier && cd stablecoin-verifier
npm init -y
npm install [email protected] [email protected] node-cron dotenv
mkdir src
touch .env src/config.js src/supply.js src/reserves.js src/verify.js
Put your RPC URL and any alert webhook in .env, never hardcoded in source:
RPC_URL=https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY
ALERT_WEBHOOK_URL=https://hooks.example.com/your-endpoint
STALENESS_THRESHOLD_SECONDS=90000
Load it once in src/config.js and export a ready-to-use provider so the rest of the scripts don’t repeat connection logic:
import 'dotenv/config';
import { ethers } from 'ethers';
if (!process.env.RPC_URL) {
throw new Error('RPC_URL is required in .env');
}
export const provider = new ethers.JsonRpcProvider(process.env.RPC_URL);
export const STALENESS_THRESHOLD = Number(process.env.STALENESS_THRESHOLD_SECONDS || 90000);
Step 3: Read On-Chain Total Supply With ethers.js
Every ERC-20 exposes totalSupply(). That’s the denominator for your coverage ratio, and it updates every block, so it’s the most trustworthy number in this whole pipeline. Keep every value as a bigint. Converting an 18-decimal token balance to a JavaScript number silently loses precision once you’re past a few billion units, which matters when you’re checking a $180 billion supply.
// src/supply.js
import { provider } from './config.js';
import { ethers } from 'ethers';
const ERC20_ABI = [
'function totalSupply() view returns (uint256)',
'function decimals() view returns (uint8)',
];
export async function readTotalSupply(tokenAddress) {
const token = new ethers.Contract(tokenAddress, ERC20_ABI, provider);
const [rawSupply, decimals] = await Promise.all([
token.totalSupply(),
token.decimals(),
]);
return {
raw: rawSupply,
formatted: ethers.formatUnits(rawSupply, decimals),
decimals,
};
}
Run it against a token address and you get output like this:
$ node -e "import('./src/supply.js').then(m => m.readTotalSupply('0xTOKEN_ADDRESS').then(console.log))"
{ raw: 184632918456123000000000000n, formatted: '184632918.456123', decimals: 6 }
Confirm the token contract address against the issuer’s own documentation or a block explorer before wiring it into anything automated. Copying an address from a random tutorial or forum post is how people end up monitoring the wrong token entirely.
Step 4: Read a Chainlink Proof-of-Reserve Feed With viem
Where a PoR feed exists for your target asset, read it the same way you’d read a price feed, through AggregatorV3Interface. The example below uses the documented WBTC PoR aggregator pattern from Chainlink’s own docs at docs.chain.link/data-feeds/proof-of-reserve, but treat any address as a starting point to verify against the current registry, not a value to hardcode blindly into production.
// src/reserves.js
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
import 'dotenv/config';
const client = createPublicClient({
chain: mainnet,
transport: http(process.env.RPC_URL),
});
const POR_ABI = [
{
type: 'function',
name: 'latestRoundData',
stateMutability: 'view',
inputs: [],
outputs: [
{ type: 'uint80' }, { type: 'int256' }, { type: 'uint256' },
{ type: 'uint256' }, { type: 'uint80' },
],
},
{ type: 'function', name: 'decimals', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint8' }] },
];
export async function readPorFeed(feedAddress) {
const [decimals, round] = await Promise.all([
client.readContract({ address: feedAddress, abi: POR_ABI, functionName: 'decimals' }),
client.readContract({ address: feedAddress, abi: POR_ABI, functionName: 'latestRoundData' }),
]);
const [, answer, , updatedAt] = round;
return {
reserve: answer,
decimals,
updatedAt: new Date(Number(updatedAt) * 1000),
};
}
Note the return shape: answer in a PoR feed is a reserve quantity, like a token count or ounces of gold, not a dollar price the way most Chainlink feeds report. Mixing up units here is one of the most common mistakes people make wiring PoR into an existing price-feed integration, so double-check the feed’s documented units before you compare it to anything.
Step 5: Calculate the Coverage Ratio
With supply and reserve both in hand, the coverage ratio is simple division, but do it carefully to avoid unit mismatches between the two numbers.
export function coverageRatio(reserveAmount, reserveDecimals, supplyAmount, supplyDecimals) {
const scale = 10n ** 18n;
const normalizedReserve = (reserveAmount * scale) / (10n ** BigInt(reserveDecimals));
const normalizedSupply = (supplyAmount * scale) / (10n ** BigInt(supplyDecimals));
if (normalizedSupply === 0n) throw new Error('Supply cannot be zero');
return Number((normalizedReserve * 10000n) / normalizedSupply) / 10000;
}
A result of 1.0 means reserves exactly match circulating supply. Tether’s Q2 2026 attestation implies a ratio around 1.017, based on $187.75 billion in reported assets against $184.6 billion outstanding. Anything meaningfully below 1.0 for a PoR-backed asset deserves an alert, not a shrug.
Step 6: Cross-Reference Issuer Attestation Reports
Since most major stablecoins don’t have a live PoR feed, your fallback numerator comes from the issuer’s own published attestation. This part can’t be fully automated because these reports ship as PDFs on the issuer’s site, not as structured on-chain data, so the practical approach is to store the latest figures as a small config file you update manually each time a new report drops, then let the script diff current supply against that baseline.
// src/attestations.json
{
"USDT": {
"reportDate": "2026-06-30",
"totalAssetsUsd": 187750000000,
"totalLiabilitiesUsd": 184600000000,
"source": "Tether quarterly attestation"
},
"USDC": {
"reportDate": "2026-06-30",
"reserveFundPct": 0.84,
"source": "Circle monthly reserve report"
}
}
Compare current on-chain supply against totalLiabilitiesUsd from the last report. If live supply has grown well past the reported liabilities figure without a corresponding new attestation, that’s your signal the numbers are stale and it’s time to either update the baseline from a fresh report or flag the gap for manual review. This won’t catch a bad report the day it’s published, but it will catch supply drifting silently for months between disclosures, which is the more common failure mode.
Tether and Circle structure their disclosures differently, and your baseline file should reflect that rather than forcing both into one shape. Tether reports a consolidated total-assets-versus-total-liabilities figure each quarter, which maps cleanly onto a coverage ratio. Circle’s monthly reports instead break the Circle Reserve Fund down by percentage allocation across cash and short-term government securities, without always restating the absolute dollar total in the same document. When the two report shapes don’t line up, don’t force a false equivalence between them. Store whatever figures each issuer actually publishes, and note the report type alongside the numbers so anyone reading your output later understands what they’re comparing.
Step 7: Add Staleness and Sanity Checks
A feed that stopped updating doesn’t throw an error, it just keeps answering with old data. Guard against that explicitly, along with a few other conditions that should stop your pipeline rather than quietly report a wrong number.
import { STALENESS_THRESHOLD } from './config.js';
export function validatePorReading(reading) {
const ageSeconds = (Date.now() - reading.updatedAt.getTime()) / 1000;
if (reading.reserve <= 0n) {
throw new Error('PoR feed returned zero or negative reserve');
}
if (ageSeconds > STALENESS_THRESHOLD) {
throw new Error(`PoR feed stale: last updated ${Math.round(ageSeconds / 3600)}h ago`);
}
return true;
}
Fail closed. If a feed is stale, negative, or unreachable, the correct output is an alert saying so, not a silently skipped check or a fallback to a cached number that looks fine.
Step 8: Track Mint and Burn Events in Real Time
Polling total supply on a schedule catches drift, but watching Transfer events from the zero address (mints) and to the zero address (burns) gives you a live feed of exactly when and how much supply changed, which is useful context when a coverage ratio suddenly moves.
import { ethers } from 'ethers';
import { provider } from './config.js';
const ZERO = '0x0000000000000000000000000000000000000000';
export function watchMintsAndBurns(tokenAddress, onEvent) {
const iface = new ethers.Interface(['event Transfer(address indexed from, address indexed to, uint256 value)']);
const filter = { address: tokenAddress, topics: [iface.getEvent('Transfer').topicHash] };
provider.on(filter, (log) => {
const parsed = iface.parseLog(log);
const { from, to, value } = parsed.args;
if (from === ZERO) onEvent({ type: 'mint', value, txHash: log.transactionHash });
if (to === ZERO) onEvent({ type: 'burn', value, txHash: log.transactionHash });
});
}
A large, unexplained mint right before a coverage ratio dips is a much more useful alert than the ratio dip alone. This turns your verifier from a periodic checker into something closer to real-time monitoring. Large issuers routinely mint and burn nine-figure amounts of a stablecoin in single transactions as part of ordinary treasury operations and exchange rebalancing, so don’t treat every mint as suspicious. What you’re watching for is a mint that doesn’t correlate with any announced treasury activity, or one that lands right before your coverage ratio starts drifting down.
Step 9-10: Aggregate Multi-Chain Supply and Build Alerts
Most major stablecoins circulate on more than one chain, and each chain’s contract has its own totalSupply(). A verifier that only checks Ethereum mainnet is missing a large share of the picture for a token like USDT, which also circulates on Tron, and USDC, which also circulates on Base and several other L2s. Loop over every chain you care about and sum the results.
// src/verify.js
import { readTotalSupply } from './supply.js';
const DEPLOYMENTS = [
{ chain: 'ethereum', address: '0xTOKEN_ETH' },
{ chain: 'base', address: '0xTOKEN_BASE' },
// add every chain the token is deployed on
];
export async function aggregateSupply() {
let totalRaw = 0n;
const perChain = [];
for (const deployment of DEPLOYMENTS) {
const { raw, formatted } = await readTotalSupply(deployment.address);
totalRaw += raw;
perChain.push({ chain: deployment.chain, formatted });
}
return { totalRaw, perChain };
}
export async function sendAlert(message) {
await fetch(process.env.ALERT_WEBHOOK_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: message }),
});
}
Wire sendAlert into every failure path: stale feeds, coverage ratios below your threshold, and mint events above a size you define as notable. A webhook to Slack, Discord, or a simple email relay is enough to start. You don’t need a dedicated monitoring stack for this.
Step 11-12: Schedule Recurring Checks and Print a CLI Report
Run the full pipeline on a schedule with node-cron, and print a readable summary each time it runs so you can eyeball results without digging through logs.
import cron from 'node-cron';
import { aggregateSupply, sendAlert } from './verify.js';
import { readPorFeed } from './reserves.js';
import { validatePorReading } from './checks.js';
cron.schedule('0 */6 * * *', async () => {
const { totalRaw, perChain } = await aggregateSupply();
console.log(`[${new Date().toISOString()}] Total supply: ${totalRaw}`);
perChain.forEach((c) => console.log(` ${c.chain}: ${c.formatted}`));
try {
const por = await readPorFeed(process.env.POR_FEED_ADDRESS);
validatePorReading(por);
console.log(` PoR reserve: ${por.reserve} (updated ${por.updatedAt.toISOString()})`);
} catch (err) {
await sendAlert(`Reserve check failed: ${err.message}`);
}
});
A healthy run prints something like this:
[2026-09-25T06:00:00.000Z] Total supply: 184632918456123000000000000
ethereum: 92316459.228
base: 41205112.887
PoR reserve: 41210000 (updated 2026-09-25T05:48:12.000Z)
If you’d rather run this outside a long-lived Node process, swap node-cron for a system-level cron job or a systemd timer calling node src/verify.js directly. That’s usually the more reliable option for anything running on a server you don’t babysit constantly.
Complete Working Project Structure
Once every step above is in place, your project should look like this:
stablecoin-verifier/
├── .env
├── package.json
├── src/
│ ├── config.js # RPC provider + env config
│ ├── supply.js # on-chain totalSupply reads
│ ├── reserves.js # Chainlink PoR feed reads
│ ├── checks.js # staleness + sanity validation
│ ├── attestations.json # manually updated issuer report baselines
│ ├── events.js # mint/burn Transfer event watcher
│ └── verify.js # orchestrator + cron schedule + alerts
└── README.md
Each file does one job and imports only what it needs from config.js, which keeps the RPC connection and environment loading in a single place. That matters more than it sounds like once you add a second or third chain and don’t want three copies of the same provider setup drifting out of sync.
Before you leave this running unattended, do one manual end-to-end pass: trigger the cron function directly with node -e, confirm the printed supply figures roughly match what a block explorer shows for the same contract, and force a failure (feed an obviously wrong address into readPorFeed) to confirm your alert actually fires and reaches the webhook. It’s much easier to catch a broken alert path during a five-minute manual test than to discover it during an actual reserve incident three months later.
Common Pitfalls to Avoid
Most of the mistakes people make building a verifier like this aren’t in the blockchain logic, they’re in the small assumptions around it: unit conversions, staleness handling, and secrets management. Here’s what trips people up most often.
- Converting bigint to number. Any token supply or reserve value that passes through JavaScript’s
Numbertype loses precision past 2^53. Keep everything asbigintuntil the final display step. - Assuming a PoR feed exists for your token. Most large stablecoins don’t have one. Check the official address registry before writing code against an address you found in an old tutorial or forum thread.
- Mixing up reserve units. A PoR
answeris a quantity (tokens, ounces), not a USD price like a standard Chainlink price feed. Compare like units, not price against quantity. - Ignoring bridged supply. Checking only the canonical mainnet contract misses every unit circulating on other chains for a multi-chain token like USDT or USDC.
- Trusting a cached reading past its heartbeat. A feed that hasn’t updated in days will still return a value on request. Always check
updatedAtagainst your staleness threshold before using the number. - Hardcoding RPC keys or webhook URLs in source. Keep secrets in
.envand add it to.gitignorebefore your first commit, not after you notice a key in a public repo.
Troubleshooting Guide
Work through these in order when a check fails or the output looks wrong. Most issues trace back to a wrong address, a units mismatch, or an RPC provider behaving differently under a scheduled job than it did in your terminal.
- “call revert exception” on totalSupply(): You likely have the wrong contract address for that chain. Verify it against the issuer’s official documentation, not a third-party aggregator site.
- PoR feed returns 0 for every field: The address you’re calling probably isn’t a PoR aggregator on that network. Re-check the registry for the correct address per chain.
- Coverage ratio comes out wildly wrong (like 1000x off): A decimals mismatch between the token and the reserve feed. Normalize both to the same scale before dividing, as shown in Step 5.
- RPC requests timing out under cron: Free-tier RPC endpoints often rate-limit background jobs harder than interactive use. Add retry logic with backoff, or upgrade to a paid tier if checks run frequently.
- Mint/burn event listener stops firing after a while: WebSocket-based providers can silently drop connections. Add a reconnect handler, or switch to a polling-based log query on an interval instead of a persistent subscription.
- node-cron job never triggers: Double-check your cron expression with a validator before assuming the library is broken. A misplaced field is the most common cause.
- Alert webhook returns 400: Most chat webhook integrations expect a specific JSON shape. Confirm the exact payload format your destination (Slack, Discord, a custom endpoint) requires rather than assuming a generic
{ text }body always works. - Attestation baseline drifts out of date: Because
attestations.jsonis manually maintained, set a recurring calendar reminder to check the issuer’s transparency page each time a new report is due, or the diff check in Step 6 becomes meaningless.
Advanced Tips for Production Monitoring
For anything beyond a personal script, move alert thresholds out of code and into a config file so you can tune sensitivity without redeploying. Log every raw reading (supply, PoR value, timestamp) to a time-series store rather than just the computed ratio, since historical raw data lets you recompute thresholds retroactively if you get them wrong the first time. A simple SQLite table with one row per check is enough to start, and it gives you a record you can graph later without standing up a full observability stack on day one.
If you’re monitoring more than two or three tokens, run each chain’s supply check as an independent job rather than one sequential loop. A single slow or rate-limited RPC endpoint shouldn’t block every other chain’s check from completing on schedule. Consider also tracking large holder concentration alongside supply and reserves. A coverage ratio can look healthy while a handful of wallets hold enough of the circulating supply to create real redemption risk that reserve math alone won’t show you.
Stablecoin Reserve Snapshot: 2026 Attestation Data
Here’s what the two largest dollar-pegged stablecoins reported in their most recent 2026 disclosures at the time of writing:
| Stablecoin | Report period | Key figure disclosed | Report type |
|---|---|---|---|
| USDT (Tether) | Q2 2026 | ~$187.75B total assets vs. ~$184.6B outstanding (~1.71% buffer) | Quarterly attestation |
| USDC (Circle) | June 30, 2026 | 84% of reserves held in the Circle Reserve Fund, remainder in bank cash | Monthly reserve report |
Neither figure tells you what the reserve composition looks like today. Both are snapshots from their respective report dates, which is exactly the gap the verifier built in this tutorial is meant to monitor between disclosures.
Regulatory Landscape: GENIUS Act vs MiCA at a Glance
Reserve disclosure isn’t just best practice anymore, it’s increasingly a legal requirement. The U.S. GENIUS Act, enacted in 2025, requires permitted payment stablecoin issuers to hold at least one dollar of permitted reserves for every dollar issued, using only specified liquid assets like U.S. currency, insured bank deposits, and short-term Treasuries. It also mandates monthly reserve composition reports examined by a registered public accounting firm, CEO and CFO certification of those reports, and full annual audited financial statements for issuers with more than $50 billion outstanding, according to the bill text published by the House Financial Services Committee.
| Requirement | US GENIUS Act | EU MiCA |
|---|---|---|
| Reserve backing ratio | 1:1 in permitted liquid assets | Reserve asset management rules for asset-referenced and e-money tokens |
| Reporting frequency | Monthly composition reports | Ongoing disclosure obligations under issuer authorization |
| Independent examination | Registered public accounting firm | Subject to competent national authority oversight |
| Full audit threshold | Required above $50B outstanding | Authorization-dependent, set by regulator |
Both frameworks push in the same direction: more frequent disclosure, more independent examination, and higher scrutiny as an issuer’s outstanding supply grows. A verification script like the one in this tutorial complements that regulatory reporting rather than replacing it, filling the on-chain gaps between officially mandated reports.
MiCA takes a different structural approach than the GENIUS Act. Instead of one federal reserve rule, it splits stablecoin-like instruments into asset-referenced tokens and e-money tokens, each with its own authorization process, reserve-asset management obligations, and redemption-rights requirements enforced by national competent authorities across EU member states. If you’re building compliance tooling for a European audience, treat the two frameworks as separate systems to check against, not interchangeable versions of the same rule. A verifier built for one regulatory context won’t automatically satisfy the other.
What This Tool Won’t Tell You
Be honest with anyone relying on this verifier’s output about what it can’t see. It cannot confirm that reserve assets are legally segregated from the issuer’s other obligations. It cannot detect if a custodian has quietly lent out reserve assets to a third party. It cannot see off-chain liabilities, pending legal claims against the issuer, or whether a bank holding reserve cash is itself under stress. Those are exactly the failure modes that have caused real stablecoin and exchange collapses in the past, and none of them show up in a coverage ratio calculated from public blockchain data.
Treat this tool’s green light as “nothing detectable is wrong right now,” not “this stablecoin is definitely solvent.” That distinction matters if you’re presenting this data to a team, a board, or a client. Pair the automated checks with periodic manual review of the issuer’s actual attestation PDF, since the full document usually contains breakdowns (custodian names, security-level detail, asset maturities) that never make it into the summary numbers this script consumes.
Frequently Asked Questions
Does Chainlink Proof-of-Reserve cover USDT and USDC directly?
Not fully. Chainlink’s documentation describes PoR as supporting stablecoins, wrapped assets, and real-world assets in general, but public PoR deployments have historically been more common for wrapped and collateralized assets like WBTC than for USDT or USDC specifically. Always check the current address registry for your exact token and chain rather than assuming coverage.
What’s the difference between an attestation and a full audit?
An attestation is limited assurance that management’s claims match supporting documents at a specific date. An audit under standard accounting rules is a broader, more rigorous examination of financial statements as a whole. Tether’s quarterly reports and Circle’s monthly summaries are attestations, not full audits.
How often should I run the verification checks?
Every six hours is a reasonable default for supply and PoR checks, since total supply changes gradually for large stablecoins. Mint and burn event watching, by contrast, should run continuously so you catch large supply changes as they happen rather than on the next scheduled poll. If you’re checking a smaller or more volatile token, shorten the polling interval accordingly, since a six-hour window on a thinly traded stablecoin could miss a meaningful drift entirely.
Can this script catch reserves that are pledged or lent out elsewhere?
No. On-chain supply and PoR feeds show reported holdings, not whether those holdings are encumbered, rehypothecated, or otherwise committed elsewhere off-chain. That’s a limitation of the underlying data sources, not something a script can verify independently. For that layer of assurance you still need the issuer’s attestation, the accounting firm’s engagement letter, and in some cases direct confirmation from the custodian bank, none of which live on a blockchain.
Do I need a paid RPC provider to run this?
A free tier is enough for periodic polling every few hours across a couple of chains. If you add continuous event watching across many chains, or run checks more frequently, you’ll likely hit free-tier rate limits and want a paid plan.
What happens if a Proof-of-Reserve feed goes stale?
The feed keeps returning its last recorded value indefinitely unless you explicitly check the updatedAt timestamp against a staleness threshold, as shown in Step 7. Without that check, a script can report a healthy coverage ratio based on data that’s weeks old.
Can I adapt this for stablecoins beyond USDT and USDC?
Yes. Swap the contract addresses in DEPLOYMENTS and update attestations.json with the relevant issuer’s published figures. The core pipeline (supply read, PoR read where available, staleness checks) doesn’t change per token.
Does the GENIUS Act require real-time reserve reporting?
No. It requires monthly reserve composition reports examined by a registered accounting firm, plus annual audited financials for large issuers, according to the bill text. That’s more frequent than many past voluntary attestations, but still not continuous, which is why on-chain monitoring between report dates remains useful.




