The guardian runs as a hosted API. This package is a dependency-free client for it, and needs only
fetch (Node 18+, browsers, React Native). It ships as ES modules only.
- Send check
- Before a send to an exchange, find out whether that exchange accepts this exact Solana token. This covers Token-2022 tokens and deposits that are currently suspended.
- Exchange badges
- "This is Binance" the moment an address is pasted.
- Delisting notices
- Exchanges' announced delistings of Solana tokens, matched against your user's holdings on their own device.
Call it from your backend. Any API key shipped inside an extension or app can be extracted. Your wallet calls your backend, and your backend calls the guardian.
Quickstart
Create the client in your backend. Then a badge when the address is pasted, a verdict when the token is chosen, and the delisting list on a timer.
import { createGuardian, isUnavailable, matchDelistings } from '@marani/guardian';
const guardian = createGuardian({
apiKey: process.env.GUARDIAN_KEY!,
baseUrl: 'https://<your guardian host>',
});
// 1. The address is pasted: show a badge.
const badge = await guardian.address(to);
// 2. The token is chosen: get the verdict.
const verdict = await guardian.check({ to, mint, symbol });
if (isUnavailable(verdict)) {
// We couldn't check in time. Say so, and never block on it.
} else {
show(verdict.level, verdict.message);
// e.g. "block", "Binance doesn't accept USDG on Solana. …"
if (verdict.level === 'block') {
// Disable Send. If verdict.swapTo is set, offer
// "Swap to {swapTo.symbol} and send" instead.
}
}
// 3. Delistings, e.g. hourly: fetch the whole list and match it locally.
const list = await guardian.delistings();
if (!isUnavailable(list)) {
const affected = matchDelistings(list.notices, heldMints);
}
Client options
| Option | Required | Meaning |
|---|---|---|
apiKey |
yes | Your API key. |
baseUrl |
yes | Your guardian host, as an http(s) URL. |
timeoutMs |
no | The deadline for a whole call, in milliseconds. The default is 4000. |
fetch |
no | A fetch to use in place of the global one. |
When to call what
| Moment | Call | Notes |
|---|---|---|
| The address is pasted | address(to) |
A known exchange wallet answers in under about 150 ms. Any other address means reading its on-chain history, up to about 2 s. An exchange deposit address found that way is cached for every partner; an address we can't identify is read again on each call. |
| The token is chosen | check({ to, mint, symbol? }) |
mint is the token's mint, or "SOL" for native SOL. symbol is
your display symbol; it can only ever soften a verdict, and one longer than 16 characters is
ignored.
|
| The user answers our question | check({ ..., userMark }) |
After level: 'ask'. Pass 'not-cex' or an exchange id, and remember the
answer per address on your side.
|
| Before signing | check again |
Only if the recipient or the token changed. |
| Hourly, or on wallet open | delistings() then matchDelistings |
The list is small. Your users' holdings never reach us. |
Skip the call for sends to the user's own address. The service covers Solana mainnet only.
The answer
{
"level": "block",
"code": "CEX_TOKEN_NOT_LISTED",
"message": "Binance doesn't accept USDG on Solana. If you send it, it won't be credited.",
"exchange": "binance",
"swapTo": { "symbol": "USDC", "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" },
"notes": [
{
"level": "warn",
"code": "CEX_HOT_WALLET",
"message": "This is Binance's own wallet, not a deposit address. Binance credits only what you send to the deposit address it gave you."
},
{
"level": "warn",
"code": "TOKEN2022_EXCHANGE_CREDIT",
"message": "Binance may be slow to credit USDG: many exchanges credit Token-2022 tokens by hand."
},
{
"level": "info",
"code": "TOKEN2022_PERMANENT_DELEGATE",
"message": "USDG's issuer can move or burn it from any wallet."
}
]
}
| Field | When | Meaning |
|---|---|---|
level |
always |
What to do: block, warn, ok or ask.
|
code |
always | Why, as a code to translate by (table below). |
message |
always | Why, as a sentence ready to show your user. |
exchange |
always | The exchange id, or null when the address isn't one or we can't tell. |
swapTo |
some blocks | The token to swap into and send instead, today USDC. |
feePct |
fee tokens | The token's transfer fee, percent. |
notes |
always | Anything else worth showing, worst first: {level, code, message}. Often empty. |
Listing freshness lives in GET /v1/health (dataAsOf), not on every answer.
The badge
/v1/address answers the recipient alone:
{ "exchange": "kucoin", "addressType": "hot-wallet" }, where addressType is
deposit or hot-wallet (the exchange's own wallet). An unknown address is
{ "exchange": null }, with "likelyExchange": true added when it is probably an
exchange deposit address.
Levels: what to show
| Level | Meaning | Recommended UX |
|---|---|---|
block |
The exchange will not credit this deposit. |
Disable Send and show message. Offer "Swap to {swapTo.symbol} and send" when
swapTo is present.
|
warn |
A real risk, or something we couldn't confirm. | Show message and ask the user to confirm. |
ok |
No problems found. | Proceed. Show any info notes quietly, if at all. |
ask |
We couldn't tell whether the address is an exchange: the usual answer for a personal wallet. |
Show message (it is the question) with a list of exchanges and "No, a personal
wallet", then call check again with the answer as userMark. A lighter
prompt is your call.
|
unavailable |
Comes from the client, not the service: a timeout, a 429 or a 5xx. | "Couldn't check". Never block on it. |
Codes
message is ready in English. For other languages, translate by code, on the
answer and on each note:
| Code | Level | Our English |
|---|---|---|
CEX_TOKEN_NOT_LISTED |
block |
{exchange} doesn't accept {symbol} on Solana. If you send it, it won't be credited. |
CEX_TOKEN_NOT_LISTED |
warn |
{exchange} may not accept {symbol} on Solana. Check their deposit page before sending. |
CEX_DEPOSITS_SUSPENDED |
block |
{exchange} has {symbol} deposits switched off right now. A send could take days to credit, or get stuck. |
CEX_TOKEN_SUPPORTED |
ok |
{exchange} accepts {symbol} on Solana. |
CEX_TOKEN_SUPPORTED |
warn |
{exchange} appears to accept {symbol}. Confirm the token address on their deposit page. |
CEX_NO_DATA |
warn |
We can't confirm {exchange} accepts {symbol} on Solana. Check their deposit page before sending. |
CEX_HOT_WALLET |
warn |
This is {exchange}'s own wallet, not a deposit address. {exchange} credits only what you send to the deposit address it gave you. |
TOKEN2022_EXCHANGE_CREDIT |
warn |
{exchange} may be slow to credit {symbol}: many exchanges credit Token-2022 tokens by hand. |
TOKEN2022_TRANSFER_FEE |
warn |
{symbol} takes a {feePct}% fee on every transfer, so the recipient gets less than you send. |
TOKEN2022_TRANSFER_HOOK |
warn |
{symbol} runs extra code on every transfer. Some exchanges and wallets don't support that. |
TOKEN2022_PERMANENT_DELEGATE |
info |
{symbol}'s issuer can move or burn it from any wallet. |
DESTINATION_UNKNOWN |
ask |
Is this an exchange deposit address? We couldn't tell. |
DESTINATION_LIKELY_EXCHANGE |
ask |
This looks like an exchange deposit address. Which exchange gave it to you? |
TOKEN_UNREADABLE |
warn |
We couldn't read {symbol}, so this send wasn't fully checked. |
NO_ISSUES |
ok |
No problems found. |
New codes may be added. If you get one that isn't listed here, show message and act on the
level.
Delisting notices
delistings() returns every active notice:
{ exchange, symbol, mint, kind, effectiveAt, announcedAt, sourceUrl }.
-
kindisdelist,deposit-suspendorwithdraw-deadline. - Every notice is pinned to an exact Solana mint. We never publish a notice by ticker alone, because tickers collide.
-
matchDelistings(notices, heldMints)keeps the ones your user holds. Run it on the device. - Link
sourceUrl, the exchange's own announcement, but don't prefetch it.
Sources today: Binance, OKX, Bybit, KuCoin and Bitget delisting announcements, polled hourly.
Errors
| HTTP | Client behaviour | Why |
|---|---|---|
| 200 | the response | |
| 400 |
throws GuardianError (bad_address, bad_mint,
bad_user_mark, bad_body, not_a_mint)
|
A bug on the calling side. |
| 401 | throws GuardianError (unauthorized) |
A missing or wrong key. It must never fail silently. |
| 429, 5xx, timeout, network | returns { level: 'unavailable', error } |
Fail open. Your UX decides, and we recommend "couldn't check". |
The default timeout is 4 s. The service answers within about 3 s even for a never-seen address.
createGuardian throws straight away on a missing key, a baseUrl that isn't an
http(s) URL, or a runtime with no fetch. Caught later, those would read as "unavailable" on
every call.
Coverage
| Exchange | Can block | Notes |
|---|---|---|
| Binance, KuCoin, Bitget, HTX, Gate | yes | The exchange publishes the exact mint for each Solana deposit. |
| Coinbase | yes, except tokens it lists without an address | Those soften to warn. |
| Kraken | no (warn) |
Its table is maintained by hand. |
| OKX, Bybit, MEXC | no (CEX_NO_DATA warn) |
We recognise their addresses, but they publish no keyless listing data. |
-
A deposit address that has been used before is usually recognised. A brand-new one answers
ask. -
Listing data refreshes weekly;
GET /v1/healthshows when (dataAsOf). - Warn, never guarantee. The guardian reduces lost deposits; it cannot promise an exchange's crediting.
Exchange ids
binance coinbase kraken kucoin gate
bitget htx okx bybit mexc
Privacy
- For each check we receive the recipient address, the mint, and optionally a symbol and the user's mark. Never the sender, the amount or the user's IP (your backend makes the call).
- We keep exchange-address findings (an address that sweeps into Binance is Binance's, for every partner), token metadata, and per-key counts of verdicts. We keep no request logs with addresses.
- Delisting matching runs on the device; holdings never reach us.