Check a wallet
Before you pay a TRON address, ask what it looks like. checkWallet reads the
address's recent history, pays for the check in credits and returns a short
report with a Safety Score (0 to 100) and the patterns behind it. A Basic
check reads 30 days; a Deep dive reads 90.
The Safety Score is an internal indicator, not a regulated risk rating or
financial advice. It is worked out automatically from public blockchain data
that your device read, and it can be incomplete or wrong. Check for yourself
before you send money. Every result carries this text as disclaimer; show it
wherever you show the score.
Your own process reads the chain (TronGrid, from your IP, with no API key), so Varnir pays no provider bill for your history. The backend checks what you read against the chain, works out the score and writes the report. The SDK never computes the score itself.
TypeScript (@varnir/chain-client) has it today. The other SDKs will follow
the wire contract below.
Example
import {VarnirClient, PriceTooHighError, ChargePendingError} from '@varnir/chain-client';
const client = new VarnirClient({privateKeyHex, identity});
try {
const check = await client.checkWallet({
address: 'TLyqzVGLV1srkB7dToTAEqgDSfPtXRJZYH',
depth: 'basic', // or 'deep'
maxPrice: 150, // credits; refused before anything is paid if higher
onProgress: (p) => console.log(p.stage),
});
console.log(check.score, check.band, check.report.writer.headline);
console.log(check.disclaimer);
} catch (err) {
if (err instanceof PriceTooHighError) console.log(`It costs ${err.price} credits.`);
else if (err instanceof ChargePendingError) console.log('Done; payment still settling:', err.id);
else throw err;
}
The methods
| Method | What it does |
|---|---|
checkWallet({address, chain?, depth?, premium?, refresh?, maxPrice?, ref?, onProgress?, signal?, fetch?, fetchOptions?}) | The whole check. Returns a WalletCheckResult. |
quoteWalletCheck({depth?, size?, multisig?, premium?}) | The price and a ref, without reading or paying. Says whether the check would be free. |
listWalletChecks({limit?, cursor?}) | Your checks, newest first, without report bodies. limit 1 to 50 (default 20); pass nextCursor for the next page. |
getWalletCheck(id) | One of your checks with its report, or null. |
shareWalletCheck(id) | Turns a share link on and returns {id, token, url, sharedAt}. The token is shown once. Only verified, complete reports can be shared. |
revokeWalletCheckShare(id) | Turns the link off. |
Checks are signed with the identity's own onboarding key (privateKeyHex).
A client built with only an attached scoped key can't run them, because the
credit hold is an owner action on the ledger.
WalletCheckResult is {id, report, score, band, cached, older, checkedAt, depth, requestedDepth, price, charged, free, chargePending, verified, share, disclaimer, ref}. cached means a stored result for the same address answered
(at the quoted price). older means that stored result is past the fresh
window. verified means the spot checks passed. They confirm the chain's
current balance, newest transfer and permissions; older rows are as your host
read them (verificationNote). price is the credits charged (0 when free).
What checkWallet does
- Checks the address (base58check,
T…) and the options. Bad input is aVALIDATIONerror and nothing is sent. - Quotes. If the quote is above
maxPrice, it throwsPriceTooHighErrorbefore reading anything. - Reads the chain from your host. TronGrid's keyless limit is about one request a second, so the reader paces itself and backs off. If TronGrid answers 429 or 403, the rest of the read goes through the backend's signed proxy.
- Deep dive only: prices by what the read found and quotes again. The size is
small up to 1,000 transfers, medium up to 5,000, large above that; a
multi-key account adds signer recovery. The
maxPricecheck runs again. - Signs a
Varnir.PlansholdServicehold with your key and submits it, unless the check is free. The hold takes the credits out of your balance for up to 15 minutes. - Posts the read to
POST /api/check. The backend charges the hold only once the report is stored. If the check fails, the hold is released and nothing is charged.
Retries and the ref
The ref is the idempotency key. The quote issues it, the hold signs it and
the check posts it. Every error after the quote carries it as err.ref, and
the result carries it as result.ref.
To resume a check after a timeout, a 202, or an outage, call
checkWallet({address, ref: err.ref}). It never charges twice:
- the ledger treats a second identical hold for the same ref as a no-op;
- the backend answers a ref it has already run with the stored result, and retries a pending capture rather than taking a second one.
A retry posts first and places the hold only if the backend says it is
missing. A ref runs only inside its own window (about 15 minutes); after that
you get QuoteExpiredError and must start without the ref.
Errors
All are VarnirErrors with a stable code, and WalletCheckError subclasses
carrying status, serverCode (the backend's own code), ref, retry and
retryAfterMs. retry tells you what to do next: same-ref (retry with
err.ref), new-quote (start again; nothing was charged), later (wait),
or never.
| Class | code | When | retry |
|---|---|---|---|
WalletCheckNotEnabledError | CHECK_NOT_ENABLED | Checks are paused (later), off on this network or plan, or the network has no Plans ids | never / later |
InsufficientCreditsError | INSUFFICIENT_CREDITS | The ledger refused the hold: the balance is too low | never |
QuoteExpiredError | QUOTE_EXPIRED | The ref or hold is past its window, already used, or the price moved | new-quote |
HoldRejectedError | HOLD_REJECTED | The hold doesn't match or was refused, or the payment couldn't be taken (nothing charged) | new-quote (same-ref if the hold isn't visible yet) |
PriceTooHighError | PRICE_TOO_HIGH | The quote is above maxPrice (err.price, err.maxPrice) | never |
UploadRejectedError | UPLOAD_REJECTED | The backend refused the read (schema, size, or it didn't match the chain); err.reasons | new-quote |
RateLimitedError | RATE_LIMITED | Per-IP, per-identity, daily or open-hold limits | same-ref / later |
ChargePendingError | CHARGE_PENDING | HTTP 202: the report is stored, the charge is still settling. err.id, err.result | same-ref |
WalletCheckUnavailableError | CHECK_UNAVAILABLE | The service, ledger or network failed, or an answer was malformed (serverCode: 'malformed_response') | same-ref (later when busy) |
A cancelled check (signal) rejects with an AbortError. Once a quote exists,
the error also carries ref.
Every answer is parsed strictly. An answer of the wrong shape, a report about
a different address, or a score that disagrees with its own report is refused
as WalletCheckUnavailableError, never passed on.
Wire contract (for the other SDKs)
Base URL: the network's API origin (https://scanner.varnir.site on testnet).
Every route is signed by the identity's own key (requireIdentityOwner):
headers X-Varnir-Api-Key (the public key, as for other signed routes),
X-Varnir-Timestamp (ms) and X-Varnir-Signature. The signature is over
${requestTag}\n${timestamp}\n${bodyText}. requestTag is the request's
own method, path and query exactly as sent, for example
GET /api/check/quote?depth=basic&identity=<id> or POST /api/check. The
backend recomputes it from the URL it received
(apps/scanner-web/src/lib/check-api/http.ts requestTag), so a signature
covers one concrete request and can't be replayed against another report or
read. Build the query once and sign that same string. bodyText is the exact
body sent; for a GET or DELETE it is the empty string.
Route (also the signed requestTag, with its query) | Request | Answer |
|---|---|---|
GET /api/check/quote?depth=basic|deep[&size=small|medium|large&multisig=0|1&premium=0|1]&identity=<id> | (no body) | {depth, size, multisig, premium, service:"check", priceKeys, breakdown:[{key,credits}], price, cap, ref, notAfter, holdMs, config, freeEligible, freeReason, freeNextAt, disclaimer} |
GET /api/check/chain/v1/accounts/<addr>[/transactions[/trc20]]?<TronGrid params>&identity=<id>[&chain=tron-nile] | (no body) | TronGrid's own JSON; meta.links.next points back to this route |
POST /api/check | {owner, depth, ref, upload, refresh?}: exactly these keys | 200 (or 202 while the charge is pending): {id, report, score, band, cached, older, checkedAt, depth, requestedDepth, price, charged, free, chargePending, verified, verificationNote, share:null, disclaimer} |
GET /api/check/reports?identity=<id>[&limit=1..50][&cursor=<nextCursor>] | (no body) | {items:[{id, chain, address, depth, requestedDepth, score, band, credits, free, cached, shared, status, checkedAt, createdAt}], nextCursor} |
GET /api/check/report/<id>?identity=<id> | (no body) | the POST /api/check answer plus shared; 404 for none (or someone else's) |
POST /api/check/report/<id>/share | {owner}: exactly this | {id, token, url, sharedAt}; url is /check/report/<id>#t=<token> (the viewer sends the token in an x-share-token header, never in a query) |
DELETE /api/check/report/<id>/share?identity=<id> | (no body) | {id, shared:false} |
The quote prices only what you ask. Basic sends depth=basic alone. Deep
sends size (from the transfer count of the read), multisig (1 when the
owner or any active permission has more than one key) and premium.
The hold. A Varnir.Plans transaction signed by the identity ($sigs
keyed by the identity id), with a $expire like any other:
{
"$contract": "<Plans contract id>@1",
"$namespace": "varnir",
"$entry": "holdService",
"$i": {"owner": {"$stream": "<identity>", "service": "check",
"priceKeys": ["check.basic"], "credits": 100,
"ref": "<the quote's ref>", "notAfter": <the quote's notAfter>}},
"$o": {},
"$r": {"config": "<Plans config stream id>"}
}
priceKeys and credits are the quote's own priceKeys and price. Testnet
ids: Plans 76f193e869e21934868647c20411a7bf168eda74da82e4635e677e479ec35420,
config 838429b38548b2847e0cc8e2f574c7cff0ef3af065e9289dc928851384dc08ab
(VARNIR_NETWORKS.testnet.contracts.plans / plansConfig). Skip the hold
while freeEligible is true. If the run then answers 409 hold_missing,
place it and post again.
Ledger refusals to map: not enough credits → INSUFFICIENT_CREDITS;
switched off or not open to the … plan → CHECK_NOT_ENABLED;
hold request expired, too far ahead or do not match the current price →
QUOTE_EXPIRED; holds may be open at once → RATE_LIMITED; anything else →
HOLD_REJECTED. On a retry, was already settled means the run already
captured the hold, so post anyway and the backend replays it.
The ref. c1.<code>.<notAfter base36>.<nonce>.<mac>. code is the depth
(b/d), the size (s/m/l), signers (0/1) and premium (0/1). A
retry reads code and notAfter back from the ref, re-quotes those options
for priceKeys and price, and signs the hold with the ref's own notAfter.
The upload. {v:1, chain, address, depth, fetched_at, window:{from,to}, account, transfers, outgoing_txs, counterparties, truncated, partial}: the
WalletCheckUpload type in packages/chain-client/src/wallet-check-fetch.ts.
That file is the reference reader: which TronGrid reads, the page caps, the
pacing and the normalisation. The backend validates the upload strictly
(apps/scanner-web/src/lib/check/validate.ts), and
test/wallet-check-fetch.test.ts pins the SDK reader to the scanner's. Never
upload token names or symbols.
Refusals are {error, message} with the HTTP status:
Status / error | Code |
|---|---|
503 paused, not_enabled; 403 not_allowed, plan_not_allowed | CHECK_NOT_ENABLED |
409 quote_expired, hold_expired, hold_settled, ref_used; 400 bad_ref | QUOTE_EXPIRED |
409 hold_missing (after placing the hold), hold_mismatch; 402 payment_failed | HOLD_REJECTED |
422 invalid_upload (errors), data_mismatch (reasons); 413 too_large | UPLOAD_REJECTED |
any 429 (rate_limited, in_flight, daily_limit, too_many_attempts, upstream_rate_limited); Retry-After when sent | RATE_LIMITED |
| 202 | CHARGE_PENDING |
503 busy; 502 ledger_unavailable, writer_failed; 500 store_failed; 409 in_progress; network failure | CHECK_UNAVAILABLE |
400 bad_request, 422 bad_price, 409 not_shareable | VALIDATION |
401 unauthorised | NOT_OWNER |
404 not_found | NOT_FOUND (getWalletCheck returns null) |