Skip to main content

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 score is not a rating

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​

MethodWhat 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​

  1. Checks the address (base58check, T…) and the options. Bad input is a VALIDATION error and nothing is sent.
  2. Quotes. If the quote is above maxPrice, it throws PriceTooHighError before reading anything.
  3. 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.
  4. 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 maxPrice check runs again.
  5. Signs a Varnir.Plans holdService hold 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.
  6. 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.

ClasscodeWhenretry
WalletCheckNotEnabledErrorCHECK_NOT_ENABLEDChecks are paused (later), off on this network or plan, or the network has no Plans idsnever / later
InsufficientCreditsErrorINSUFFICIENT_CREDITSThe ledger refused the hold: the balance is too lownever
QuoteExpiredErrorQUOTE_EXPIREDThe ref or hold is past its window, already used, or the price movednew-quote
HoldRejectedErrorHOLD_REJECTEDThe 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)
PriceTooHighErrorPRICE_TOO_HIGHThe quote is above maxPrice (err.price, err.maxPrice)never
UploadRejectedErrorUPLOAD_REJECTEDThe backend refused the read (schema, size, or it didn't match the chain); err.reasonsnew-quote
RateLimitedErrorRATE_LIMITEDPer-IP, per-identity, daily or open-hold limitssame-ref / later
ChargePendingErrorCHARGE_PENDINGHTTP 202: the report is stored, the charge is still settling. err.id, err.resultsame-ref
WalletCheckUnavailableErrorCHECK_UNAVAILABLEThe 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)RequestAnswer
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 keys200 (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 / errorCode
503 paused, not_enabled; 403 not_allowed, plan_not_allowedCHECK_NOT_ENABLED
409 quote_expired, hold_expired, hold_settled, ref_used; 400 bad_refQUOTE_EXPIRED
409 hold_missing (after placing the hold), hold_mismatch; 402 payment_failedHOLD_REJECTED
422 invalid_upload (errors), data_mismatch (reasons); 413 too_largeUPLOAD_REJECTED
any 429 (rate_limited, in_flight, daily_limit, too_many_attempts, upstream_rate_limited); Retry-After when sentRATE_LIMITED
202CHARGE_PENDING
503 busy; 502 ledger_unavailable, writer_failed; 500 store_failed; 409 in_progress; network failureCHECK_UNAVAILABLE
400 bad_request, 422 bad_price, 409 not_shareableVALIDATION
401 unauthorisedNOT_OWNER
404 not_foundNOT_FOUND (getWalletCheck returns null)