MCP server: One MCP server.

presign-guard

@fizzl13MCP server · External

Safe to sign? EVM, Solana, XRPL, Algorand txs, permits, tokens; OFAC wallet screen: green/orange/red

Fraud & scam detectionv1.5.0JavaScript
Freeno charge per call

One address for any MCP client. No sign-in needed on this server.

presign-guard explainer video
From the README · 1 of 2

What it does

3 read4 other

Grouped by tool name. At most: only reads or looks up information.

What it costs

Free

Odel charges nothing per call. The publisher may charge for its own service.

Tools

7 · grouped by tool name · checked 11 Oct 2026

Read · 3Look things up

  • presign_quick_check
  • presign_check
  • presign_check_explain

Other · 4Neither hinted nor named

  • token_quick_verdict
  • token_verdict
  • wallet_approvals
  • feedback
Try it in the Inspector
README37 min read

x402 payable · Live status

A pre-sign risk check for AI agents. Before an agent signs a transaction, approval, or EIP-712 signature, it pays a few cents per call over x402 and gets back a green / orange / red verdict with machine-readable reason codes. Optionally, it also gets a plain-language explanation in Dutch or English.

Watch the 1-minute explainer: presign-guard.fizzl.eu/media/explainer.mp4

presign-guard explainer video

New: the token verdict in 45 seconds: presign-guard.fizzl.eu/media/token.mp4

Part of Klaartaal by FIZZL AI.

Use it in a few lines: presign-guard-wallet

For agents on viem, the npm package presign-guard-wallet wraps the wallet so every sendTransaction, writeContract and signTypedData is checked first: green signs, orange stops (or asks), red never signs. Each verdict's signed receipt is verified before the wallet acts.

js
import { guardWallet } from "presign-guard-wallet";

const wallet = guardWallet(walletClient, { pay, onOrange: "stop" }); // pay = an x402 fetch, $0.01 per check
await wallet.writeContract({ address: token, abi, functionName: "approve", args: [spender, amount] });

npm install presign-guard-wallet viem @x402/fetch @x402/evm · source and options

Endpoints

RoutePriceReturns
POST /v1/check$0.01 USDCVerdict, reason codes, decoded subject
POST /v1/check/explain$0.03 USDCThe same, plus a plain-language explanation (lang: "nl" or "en")
GET /v1/token?chain=…&address=…$0.01 USDC, Base or SolanaToken verdict: grade, reason codes, one-line summary, market data (see below)
GET /v1/approvals?chain=…&address=…$0.02 USDC, Base or SolanaWallet approval audit: every open token approval, its spender, and which to revoke (see below)
GET /v1/screen?address=…$0.01 USDC, Base or SolanaWallet screen: any chain's address against the OFAC SDN list, plus theft and phishing reports for EVM (see below)
GET /v1/sanctionsfreeOpen data: every digital currency address on the OFAC SDN list with its entry (name, programs, currency), the list's publish date; refreshed every 6 hours. For screening locally, e.g. with x402-payer-screen
GET /v1/credits/100, /v1/credits/1000$0.80 / $7.00 USDCA pack of prepaid credits (20% / 30% off): see Credit packs
GET /v1/creditsfreePack prices, credit cost per call, and your balance (with the x-credit-key header)
POST /mcpfree / paidMCP server (Streamable HTTP): see below
POST /feedbackfreeReport a bug or a missing feature: see Feedback
GET /healthfreeLiveness
GET /openapi.jsonfreeOpenAPI 3.1 spec with prices (x-payment-info)
GET /.well-known/x402freex402 discovery manifest

Payment is x402 v2 with the exact scheme, in USDC on Base (the token verdict and the approval audit also on Solana), or for every paid route in RLUSD on the XRP Ledger (xrpl:0, the same dollar amount, issuer rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De; the payer signs an XRPL Payment with the challenge's extra.invoiceId as InvoiceID, see @x402/xrpl, and the server verifies and submits it itself). The 402 carries Bazaar discovery metadata (input example, input and output schema), and the challenge is mirrored into the JSON body for clients that don't read the PAYMENT-REQUIRED header. You are never charged for an error. Invalid requests (400) and upstream outages (503) cancel settlement, and they always return verdict: null, never a guessed verdict.

Credit packs

Agents that call presign-guard often can prepay: one x402 payment of $0.80 for 100 credits or $7.00 for 1000 credits (20% and 30% off), valid for a year. A credit is $0.01 of checks: POST /v1/check, GET /v1/token and GET /v1/screen cost 1, GET /v1/approvals 2, POST /v1/check/explain 3.

bash
# 1. Buy a pack (any x402 client); the answer holds your key
GET /v1/credits/100   ->  { "credit_key": "pgc_…", "credits": 100, "expires_at": "…" }

# 2. Send the key instead of paying; no new payment needed
curl -X POST https://presign-guard.fizzl.eu/v1/check -H 'x-credit-key: pgc_…' -H 'content-type: application/json' -d '{…}'
#    -> the verdict (signed as usual), with headers x-credit-status: paid and x-credits-remaining: 99

# 3. Balance
curl https://presign-guard.fizzl.eu/v1/credits -H 'x-credit-key: pgc_…'

A call that fails (400 or 5xx) gives its credits back. With no key, a malformed or unknown key, or too few credits, the call gets the normal 402 (x-credit-status says why), so a client can always fall back to paying per call. Keep the key secret: anyone who has it can spend the credits, and it cannot be recovered (only a SHA-256 of it is stored). Credits work over HTTP; MCP tools are paid per call.

Packs are only offered when balances persist: set CREDITS_REDIS_URL (a persistent Redis, for example a free Upstash database; Render only). Without it the credit routes don't exist.

MCP

https://presign-guard.fizzl.eu/mcp is an MCP server (Streamable HTTP, stateless) for Claude, Cursor and agent frameworks, listed in the official MCP registry as io.github.Fizzl13/presign-guard.

Using Claude Code for trading agents? See Pre-trade checks for Claude Code agents: a token check and an Ichimoku trend check before every order, with a ready-made CLAUDE.md rule.

ToolPriceReturns
presign_quick_checkfree, 10 calls/hourThe verdict only (green, orange or red)
presign_check$0.01 USDC via x402The full verdict and reason codes, as POST /v1/check
presign_check_explain$0.03 USDC via x402The same plus a plain-language explanation, as POST /v1/check/explain
token_quick_verdictfree, shares the 10 calls/hourThe token verdict and grade only
token_verdict$0.01 USDC via x402The full token verdict, as GET /v1/token
wallet_approvals$0.02 USDC via x402The wallet approval audit, as GET /v1/approvals
screen_wallet$0.01 USDC via x402The wallet sanctions screen, as GET /v1/screen
feedbackfreeReport a bug or a missing feature, as POST /feedback

The three check tools also take an XRP Ledger transaction: {"type": "xrpl", "tx": {…unsigned tx JSON…}, "network": "xrpl:0"}, and an Algorand transaction or group: {"type": "algorand", "transaction": "<base64 msgpack>"} or "transactions": [...] (no chainId), with the same rules as POST /v1/check.

The paid tools are paid inside the MCP call with the x402 MCP transport (_meta["x402/payment"]), on Base or Solana (all paid tools also in RLUSD on the XRP Ledger), to the same payout wallets as the HTTP routes. Invalid input is refused before payment, and a failed check is not charged.

Token verdict

Token verdict video

GET /v1/token?chain=solana&address=<mint> (or chain=base|ethereum|arbitrum|optimism|polygon|bsc with a 0x token contract) answers one question before an agent buys, holds or accepts a token: is the token itself a trap?

XRP Ledger: chain=xrpl&address=CURRENCY.rIssuer (e.g. RLUSD.rMxCKbEDwqr76QuheSUMdEGf4B9xJ8m5De; names longer than 3 characters may be given as text or as the ledger's 40-hex code). Read from the issuer account on the ledger and DexScreener's XRPL market: red TOKEN_FROZEN (global freeze), TOKEN_ISSUER_NOT_FOUND, TOKEN_IMPERSONATION (called RLUSD but not Ripple's); orange CLAWBACK_ENABLED, PERMISSIONED_TOKEN (RequireAuth), MINT_AUTHORITY_ACTIVE (the issuer isn't blackholed, so it can issue more), TRANSFER_FEE / HIGH_TRANSFER_FEE, plus the market rules (LOW_LIQUIDITY, NO_DEX_MARKET, NEW_TOKEN); info ISSUER_BLACKHOLED (master key off, no usable regular key and no signer list: fixed supply and settings), FREEZE_AUTHORITY_ACTIVE (no NoFreeze flag). Ripple's own RLUSD keeps clawback, freeze and minting on purpose: info, with ISSUER_IS_RIPPLE.

Algorand: chain=algorand&address=<asset id> (e.g. 31566704 for USDC). Read from the asset's parameters on a public node (AlgoNode; ALGORAND_INDEXER_URL overrides its indexer), the indexer (age, holders) and DexScreener's Algorand market: red TOKEN_IMPERSONATION (called USDC, USDt or USD but not Circle's 31566704 or Tether's 312769), TOKEN_NOT_FOUND; orange CLAWBACK_ENABLED (the clawback address can take tokens out of any account), PERMISSIONED_TOKEN (holdings frozen by default), TOKEN_IMPERSONATION for an asset named ALGO, NEW_TOKEN (created less than a day ago), TOP_HOLDERS_CONCENTRATED (of the circulating supply: the reserve and the DEX pool are left out; app and vault accounts can't be told apart from wallets yet), LOW_LIQUIDITY, NO_DEX_MARKET; info FREEZE_AUTHORITY_ACTIVE, ISSUER_RENOUNCED (no manager, freeze or clawback address left), KNOWN_STABLECOIN (Circle's USDC and Tether's USDt keep clawback and freeze on purpose), INDEXER_UNAVAILABLE. An asset's supply is fixed when it is created, so there is no minting on Algorand.

json
{
  "verdict": "orange",
  "grade": "RISKY",
  "one_liner": "RISKY: 1% transfer fee; $21k liquidity; 1 h old (+2 more)",
  "reasons": [{ "code": "TRANSFER_FEE", "severity": "orange", "details": { "feePct": 1 } }, "…"],
  "token": { "chain": "solana", "address": "…", "name": "…", "symbol": "…" },
  "market": { "priceUsd": 0.0004, "liquidityUsd": 21000, "marketCapUsd": 400000, "volume24hUsd": 90000, "firstPairAt": "…", "ageSeconds": 3600, "url": "https://dexscreener.com/…" },
  "sources": ["goplus", "dexscreener", "rugcheck"],
  "checkedAt": "…"
}

Grades: SAFE (green), CAUTION (one orange reason), RISKY (two or more), AVOID (red). The one-liner states facts only.

SeverityCodes
redRUGGED, NON_TRANSFERABLE, MALICIOUS_AUTHORITY; EVM: TOKEN_HONEYPOT, TOKEN_AIRDROP_SCAM, TOKEN_IMPERSONATION
orangeAI_TOKEN_IMPERSONATION (AI second opinion via TypeSafe Jev, when the server has it set up: the name or symbol pretends to be another token; never red), MINT_AUTHORITY_ACTIVE, FREEZE_AUTHORITY_ACTIVE, PERMISSIONED_TOKEN, BALANCE_MUTABLE, CLOSABLE, TRANSFER_HOOK, TRANSFER_FEE, HIGH_TRANSFER_FEE (≥10%), TRANSFER_FEE_UPGRADABLE, LP_NOT_LOCKED (<50% locked, token younger than 30 days), LOW_LIQUIDITY (<$50k), NO_DEX_MARKET, NEW_TOKEN (<24 h), TOP_HOLDERS_CONCENTRATED (top holder >20% or top 10 >50%, pools and locked accounts excluded; on EVM only wallets count, not contracts); EVM: the GoPlus token codes of /v1/check (TOKEN_HIGH_TAX, TOKEN_UNVERIFIED, …), TOKEN_CANNOT_BUY and TOKEN_PAUSED (transfers are paused right now)
infoMUTABLE_METADATA, GENESIS_LAUNCH_ESCROW, NO_SOCIALS, TOKEN_ON_TRUST_LIST, NO_SECURITY_DATA, RUGCHECK_DANGER, RUGCHECK_UNAVAILABLE, LP_NOT_LOCKED on older tokens, on trust-list tokens (USDC, USDT, WETH): the issuer's powers, LP_NOT_LOCKED, LOW_LIQUIDITY and NO_DEX_MARKET (DexScreener undercounts quote assets); EVM: TOKEN_PAUSABLE and TOKEN_BLACKLIST (the issuer can pause transfers or blacklist holders; a green one-liner says so)

A permissioned Solana token (Token ACL, sRFC 37, which Metaplex MPL-3643 builds on) is a Token-2022 mint whose new token accounts start frozen and whose freeze authority is a Token ACL MintConfig PDA; an issuer-chosen gate program decides which wallets are thawed. When GoPlus reports a freeze authority, the verdict reads the mint and its MintConfig from a Solana RPC (SOLANA_RPC_URL, default the public mainnet endpoint) and reports one PERMISSIONED_TOKEN reason instead of FREEZE_AUTHORITY_ACTIVE: only approved wallets can hold or send it, so a buyer who isn't approved may not be able to sell. The response then carries a permissioned block (gate program, config authority, default-frozen, permanent delegate, pausable). A permanent delegate stays a separate BALANCE_MUTABLE.

When the top holders of a Solana token look concentrated, the verdict first asks the chain who owns them: a Metaplex Genesis launch bucket (an account of program GNS1S5J5AspKXgpjz6SvKL66kPaKWAhaGRhCqPRxii2B, holding supply until the launch distributes it) is not a whale. It is left out of the count and reported as GENESIS_LAUNCH_ESCROW with its share.

For a proxy token (USDC, cbBTC, EURC on Base) GoPlus reports no pause or blacklist fields, so the verdict asks the token itself with eth_call on the standard getters (paused, pauser, PAUSER_ROLE, blacklister, isBlacklisted, isBlackListed); those reasons carry source: "onchain" and the role address, and sources includes chain. If the chain RPC is down they are left out and the verdict still answers.

On a token on the GoPlus trust list (USDC, USDT), the issuer's powers (mint, freeze, change balances) are info: the issuer keeps them on purpose. Missing data never makes a token red. If GoPlus or DexScreener is down there is no verdict (503, not charged); if RugCheck is down the verdict comes without it and says so.

Wallet approval audit

GET /v1/approvals?chain=base&address=<wallet> (or chain=ethereum|arbitrum|optimism|polygon|bsc) is the follow-up to /v1/check: that one asks "should I sign this approval?", this one asks "which approvals did I already give, and which should I revoke?". Agents with their own wallet can run it as a periodic check.

json
{
  "verdict": "red",
  "grade": "AVOID",
  "one_liner": "AVOID: 5 approvals, 1 to a flagged address, 1 to a plain wallet, 1 unlimited; revoke 3",
  "reasons": [{ "code": "SPENDER_MALICIOUS", "severity": "red", "details": { "count": 1, "spenders": ["0x…"] } }, "…"],
  "summary": { "approvals": 5, "tokens": 2, "unlimited": 2, "toRevoke": 3 },
  "approvals": [{
    "token": { "address": "0x…", "symbol": "USDC" },
    "spender": { "address": "0x…", "name": null, "trusted": false, "contract": true },
    "amount": "500", "unlimited": false, "approvedAt": "…", "severity": "red",
    "codes": [{ "code": "SPENDER_MALICIOUS", "severity": "red", "details": { "behaviors": ["phishing_activities"] } }],
    "revoke": true
  }, "…"],
  "revokeUrl": "https://revoke.cash/address/0x…?chainId=8453"
}
SeverityCodes (per approval; the wallet-level reasons count them)
redSPENDER_MALICIOUS (the spender is flagged by GoPlus)
orangeAPPROVAL_TO_WALLET (the spender is a plain wallet, not a contract), SPENDER_SUSPICIOUS (GoPlus doubt list), SPENDER_UNVERIFIED (contract source not verified), UNLIMITED_APPROVAL (to a spender not on the GoPlus trust list)
infoUNLIMITED_APPROVAL_TRUSTED (e.g. Permit2), STALE_APPROVAL (older than a year), TOKEN_FLAGGED (the approved token itself), NO_APPROVALS

Every approval with an orange or red code has revoke: true. The grades are the same as the token verdict. Source: GoPlus token_approval_security (ERC-20 allowances); NFT approvals are not covered. If GoPlus is down there is no verdict (503, not charged).

Wallet screen

GET /v1/screen?address=<address> answers "may I pay this address, or accept money from it?". One address, any chain: the format tells which (EVM 0x…, Solana, Tron, Bitcoin, XRP Ledger, Algorand, or another currency OFAC lists). For sellers screening who pays them, and for agents checking who they are about to pay.

The address is matched exactly against every digital currency address in the OFAC SDN list, which presign-guard downloads from the US Treasury itself (SDN.XML) and re-checks every 6 hours. That matters beyond EVM: GoPlus answers sanctioned: 0 for OFAC-listed Tron, Solana and XRPL addresses. EVM addresses also get the GoPlus address reports (theft, phishing, laundering, mixers).

json
{
  "verdict": "red",
  "grade": "AVOID",
  "one_liner": "AVOID: on the OFAC SDN list (BANK MARKAZI JOMHOURI ISLAMI IRAN)",
  "reasons": [{ "code": "SANCTIONED_ADDRESS", "severity": "red", "details": { "list": "OFAC SDN", "matches": ["…"], "published": "2026-10-09" } }],
  "address": { "address": "TNiq9AXBp9EjUqhDhrwrfvAA8U3GUQZH81", "format": "tron" },
  "sanctions": {
    "list": "OFAC SDN", "listed": true, "published": "2026-10-09", "source": "ofac-sdn",
    "matches": [{ "uid": 4632, "name": "BANK MARKAZI JOMHOURI ISLAMI IRAN", "type": "Entity", "programs": ["IRAN", "IFSR", "IRGC", "SDGT"], "currency": "TRX" }]
  },
  "sources": ["ofac-sdn"]
}
SeverityCodes
redSANCTIONED_ADDRESS (on the OFAC SDN list), and GoPlus reports for EVM: STEALING_ATTACK, PHISHING_ACTIVITIES, MONEY_LAUNDERING, SANCTIONED, CYBERCRIME, FINANCIAL_CRIME, …
orangeMIXER, BLACKLIST_DOUBT (GoPlus, EVM)
infoSANCTIONS_SCREENED (not listed, with the list's publish date), NO_REPORTS, REPUTATION_NOT_COVERED (no scam data for this chain), REPUTATION_UNAVAILABLE (GoPlus down), SANCTIONS_LIST_STALE (no successful refresh for 3 days)

It is an exact match on the addresses OFAC publishes, not legal advice: it does not cover unlisted addresses of sanctioned persons or indirect exposure. Without any list (just started and the Treasury site down; PG1 stands in for EVM addresses) there is no verdict (503, not charged). /health shows the list's size and date under ofac.

Request types

jsonc
// Token approval
{ "type": "approval", "chainId": 8453, "token": "0x…", "spender": "0x…", "amount": "1000000" }

// Raw transaction (approve / increaseAllowance / setApprovalForAll are decoded)
{ "type": "transaction", "chainId": 8453, "to": "0x…", "data": "0x…", "value": "0", "from": "0x…" }

// EIP-712 signature: pass the eth_signTypedData_v4 payload as an object or JSON string
{ "type": "signature", "chainId": 8453, "typedData": { "domain": {…}, "types": {…}, "primaryType": "PermitSingle", "message": {…} } }

Supported chains: 1, 10, 56, 137, 8453, 42161.

Plain messages (personal_sign, eth_sign)

jsonc
// The text or 0x-hex about to be signed; method defaults to personal_sign, chainId and from are optional
{ "type": "message", "method": "personal_sign", "message": "app.example wants you to sign in with your Ethereum account:\n0x…", "origin": "https://app.example", "from": "0x…" }

A message moves no tokens by itself, but two kinds are traps. Red: ETH_SIGN (eth_sign signs a raw 32-byte hash, so nobody can see what is agreed to; it can be a transaction or permit in disguise), SIGN_IN_DOMAIN_MISMATCH (a Sign-In with Ethereum message, EIP-4361, for another site than the origin asking: a phishing site relaying a real site's login). Orange: UNREADABLE_MESSAGE (the bytes aren't readable text; details.looksLike: "a 32-byte hash" when it is exactly 32 bytes), SIGN_IN_URI_MISMATCH (the message's URI is on another site than its domain), SIGN_IN_ADDRESS_MISMATCH (it signs in another account than from), SIGN_IN_CHAIN_MISMATCH (its Chain ID differs from chainId). Info: SIGN_IN_REQUEST (the site, account, URI and expiry of a sign-in message), PLAIN_MESSAGE. The origin gets the same phishing-list and domain-age checks as any request, and intent works too. The answer's subject carries kind: "message", the method and the first 200 characters of the text (preview).

Solana transactions

jsonc
// The base64 wire transaction about to be signed (legacy or v0); from is optional (default: the fee payer)
{ "type": "solana", "transaction": "AQAAAAAA…", "origin": "https://…" }

Solana has no allowances like EVM: drainers go after the account itself. The instructions are decoded and the transaction is simulated on the RPC (simulateTransaction, signatures not checked, SOLANA_RPC_URL overrides the public endpoint) to see what really happens to the wallet, also inside other programs. Red: SOLANA_TOKEN_ACCOUNT_TAKEOVER (SetAuthority gives the owner or close authority of the wallet's token account to someone else: the account and everything in it is theirs), SOLANA_ACCOUNT_ASSIGN (System Assign hands the wallet to a program). Orange: SOLANA_UNLIMITED_DELEGATE (Approve with an unlimited amount), SOLANA_DURABLE_NONCE (once signed it never expires), SOLANA_CLOSE_TO_OTHER (a token account closed with its SOL sent elsewhere), SOLANA_SET_AUTHORITY (another authority change), HIDDEN_APPROVAL (a delegate set that no instruction shows), SIMULATION_FAILS. Info: SOLANA_TOKEN_DELEGATE (a limited delegate), SIMULATED (the wallet's SOL and token changes), SOLANA_LOOKUP_TABLES, SIMULATION_UNAVAILABLE. A takeover or assign seen only in the simulation is red too (details.hidden: true). The answer carries simulation: { ok, changes, fee }; origin and intent work as for EVM.

XRP Ledger transactions

jsonc
// An unsigned XRPL transaction (no chainId; network defaults to "xrpl:0", mainnet)
{ "type": "xrpl", "network": "xrpl:0", "tx": { "TransactionType": "Payment", "Account": "r…", "Destination": "r…", "Amount": "15000" } }

There are no token approvals on the XRPL: what drains accounts there is handing the account over. Red: XRPL_REGULAR_KEY_CHANGE (SetRegularKey to another key), XRPL_SIGNER_LIST_CHANGE (SignerListSet with other signers), XRPL_DISABLE_MASTER_KEY (AccountSet asfDisableMaster), XRPL_ACCOUNT_DELETE (all remaining XRP goes to the destination), XRPL_FAKE_RLUSD (a token called RLUSD that isn't issued by Ripple), PHISHING_SITE. Orange: XRPL_PARTIAL_PAYMENT, XRPL_DESTINATION_TAG_MISSING (the destination, often an exchange, requires a tag), XRPL_DESTINATION_REFUSES (Deposit Authorization), XRPL_DESTINATION_NOT_ACTIVATED, XRPL_DESTINATION_NO_TRUSTLINE, XRPL_DESTINATION_DISALLOWS_XRP, XRPL_ISSUER_CAN_CLAW_BACK / XRPL_ISSUER_FROZEN / XRPL_TRANSFER_FEE / XRPL_ISSUER_NOT_FOUND (on a TrustSet; Ripple's own RLUSD has clawback by design, which is info), XRPL_ENABLE_CLAWBACK, XRPL_ENABLE_DEPOSIT_AUTH, XRPL_SIGNER_LIST_REMOVED, XRPL_ESCROW_TO_OTHER, XRPL_NFT_GIVEAWAY (an NFT sell offer for nothing), XRPL_OFFER_FAR_BELOW_MARKET (a DEX order whose limit is more than 5% worse than the best rate the order book or AMM pool gives now: on a thin book it can fill down to that price; details.yourRate, marketRate, worseByPct, source), XRPL_NO_MARKET (no book or pool for the pair), the issuer checks above for the token an order buys and for AMM deposits and new pools, XRPL_TX_NOT_ANALYZED (a transaction type this check doesn't judge), LOOKALIKE_SITE, NEW_DOMAIN, DOMAIN_NOT_REGISTERED. Info: XRPL_NEW_DESTINATION, XRPL_REGULAR_KEY_REMOVED, XRPL_LEDGER_UNAVAILABLE. The ledger is read through public JSON-RPC (xrplcluster.com; testnet.xrpl-labs.com), cached for 5 minutes. Same price and route as the EVM check.

Algorand transactions

jsonc
// One base64 msgpack transaction (algosdk.encodeUnsignedTransaction, or a signed one), or a whole atomic group;
// no chainId: the network (mainnet or testnet) comes from the transaction's genesis hash
{ "type": "algorand", "transaction": "iqNhbXTOAA9CQKNmZWXN…" }
{ "type": "algorand", "transactions": ["iqNhbXTO…", "iqRhcGlk…"], "from": "ABC…" }

Algorand has no token approvals either: one field on an ordinary-looking transaction gives the account away. Every transaction the wallet sends (from, or the first transaction's sender) is judged by its fields; transactions from other accounts in the group are theirs to sign. Red: ALGORAND_REKEY (rekeyTo hands the account's signing power to another key), ALGORAND_CLOSE_ACCOUNT (closeRemainderTo sends all remaining ALGO to someone and closes the account), ALGORAND_ASSET_CLOSE (an asset transfer's closeRemainderTo sends the whole balance of that asset to someone else), ALGORAND_FAKE_STABLECOIN (an asset called USDC or USDt that isn't Circle's 31566704 or Tether's 312769). Orange: ALGORAND_CLAWBACK_TRANSFER, ALGORAND_ASSET_CLAWBACK / ALGORAND_ASSET_DEFAULT_FROZEN (opting in to such an asset), ALGORAND_APP_UPDATE_OR_DELETE, ALGORAND_APP_CLEAR_STATE, ALGORAND_ASSET_CONTROL_CHANGE / ALGORAND_ASSET_CONTROL_CLEARED / ALGORAND_ASSET_DESTROY, ALGORAND_KEYREG_NONPARTICIPATION, ALGORAND_HIGH_FEE (over 0.1 ALGO), ALGORAND_FAKE_ALGO, ALGORAND_GROUP_UNSEEN (one transaction of a group: the rest runs with it unchecked), ALGORAND_GROUP_MISMATCH, ALGORAND_TX_NOT_ANALYZED, SIMULATION_FAILS. The group is simulated on a public node (algod simulate, empty signatures allowed; ALGORAND_ALGOD_URL / ALGORAND_TESTNET_ALGOD_URL override AlgoNode), which also tells how much a close would send (details.closingAlgo, details.closingAmount). subject.transactions lists what each transaction does. origin and intent work as for the other types.

Optional on every EVM type: "intent": "swap 10 USDC for ETH on Uniswap", what the agent says it is doing, in one sentence (up to 500 characters). TypeSafe's Jev compares it with what signing actually does (the decoded action, who gets an allowance, a payment or tokens, how much, unlimited or not): signing that does more than, or something else than, the intent is orange (INTENT_MISMATCH, details.intent, details.probability, details.decidedBy jev or jev+claude); a clear match is info (INTENT_MATCHES). This catches a prompt-injected agent about to sign something its user never asked for, such as an unlimited approval while it says it is paying for an API. Only when the server has TypeSafe set up; never red, never makes a verdict green.

Optional on every type: "origin": "https://…", the site asking for the signature or transaction (a URL or a hostname). Its domain age is looked up: a domain registered less than 30 days ago is orange (NEW_DOMAIN), which catches the fresh phishing sites wallet drainers run on. It is also checked against phishing lists and lookalikes: on MetaMask's eth-phishing-detect blocklist (via PG1) or GoPlus's phishing list is red (PHISHING_SITE, details.flaggedBy names the lists); a lookalike of a known brand (PG1's typosquat detection, e.g. metamask-login.com → metamask.io) is orange (LOOKALIKE_SITE); with METAMASK_SCAN=on (off by default: MetaMask's site-scanner endpoint has no published API or licence), a site MetaMask's own site scanner (Blockaid) blocks is orange (WALLET_BLOCKS_SITE), not red, because that scanner also blocks legitimate browser-payment pages. A source that can't be reached is SITE_REPUTATION_UNAVAILABLE (info), never a clean result. ORIGIN_REPUTATION=off turns these checks off. Local and IP origins are not looked up.

Recognised signatures: EIP-2612 Permit, DAI-style permit, Permit2 (PermitSingle, PermitBatch, PermitTransferFrom, batch and witness variants), EIP-3009 TransferWithAuthorization / ReceiveWithAuthorization (what x402 asks an agent to sign to pay), and Seaport OrderComponents.

An x402 payment moves one fixed amount to one recipient and grants no allowance, so paying a plain wallet is green (PAYMENT_AUTHORIZATION, info). It turns red if the recipient is flagged, and orange if the amount is effectively unlimited or the authorization stays valid for more than a month. Anything else comes back at least orange (UNRECOGNIZED_SIGNATURE).

Transaction simulation. Send a transaction with from (the wallet that would send it) and presign-guard runs it against the latest block (eth_simulateV1 with traceTransfers, on all six chains: 1, 10, 56, 137, 8453 and 42161) to see what it really does, also inside routers, multicalls and EIP-7702 batches. The answer then carries simulation: { ok, changes: [{ type: "transfer"|"approve", asset: "native"|"erc20"|"erc721"|"erc1155"|"nft_all", token, symbol, from, to, amount, rawAmount, tokenId }], gasUsed, source: "alchemy"|"rpc" }, and an info line SIMULATED with what leaves and what arrives. Orange: HIDDEN_APPROVAL (the transaction sets an approval, or an approval for all NFTs of a collection, that the decoded call doesn't show), SIMULATION_NFT_OUT (an NFT leaves the wallet), SIMULATION_FAILS (it would revert; details.error). With intent, the intent check compares it with the simulated balance changes, so "swap 10 USDC" while 5,000 USDC leaves is caught. Runs on Alchemy when the server has ALCHEMY_API_KEY, otherwise (or when Alchemy fails) on the chain's public RPC (RPC_URL_<chainId> overrides); SIMULATION=off turns it off. When no RPC answers, the check goes on without it (never a guessed green). GET /health shows a startup self-test per chain under simulation.

Address poisoning. With from, the recipients of a transfer (what the simulation sees leave the wallet, else the decoded transfer) are compared with the addresses this wallet has sent ETH or tokens to before (its latest 1,000 transfers, via Alchemy's alchemy_getAssetTransfers). A new recipient that starts and ends like one of them (at least 3 characters each side, 7 together) is red ADDRESS_POISONING with details.lookalikeOf: the classic trick of planting a look-alike address in the wallet's history so it gets copied. Info: KNOWN_RECIPIENT (paid before), NEW_RECIPIENT (never paid, no look-alike). Needs ALCHEMY_API_KEY; without it, or when Alchemy doesn't answer, the check is skipped (ADDRESS_HISTORY=off turns it off). GET /health shows its self-test under addressHistory.

x402 requirements check. Paying a 402 challenge? Send the entry of its accepts list you are paying as x402 (either { "accepted": { … } } or the entry itself, x402 v2 amount or v1 maxAmountRequired, CAIP-2 eip155:8453 or v1 base) next to the EIP-3009 payment. presign-guard checks that the signature pays exactly what the seller asked, read from the typed data, never from a claim. Red: X402_AMOUNT_ABOVE_REQUIRED (it pays more than asked; details.required, details.signing), X402_RECIPIENT_MISMATCH (another payTo), X402_ASSET_MISMATCH (another token), X402_NETWORK_MISMATCH (another chain). Orange: X402_VALIDITY_TOO_LONG (the authorization stays valid well past the seller's maxTimeoutSeconds, at least an hour; anyone holding it can settle it until then, like a bearer token), X402_DOMAIN_MISMATCH (the seller's extra.name/version differ from the signature's EIP-712 domain), X402_SCHEME_UNEXPECTED, X402_NETWORK_UNKNOWN. Info: X402_REQUIREMENTS_MATCH, or X402_AMOUNT_BELOW_REQUIRED (the seller will refuse it). Why: research on x402 in 2026 (Hardening x402, Five Attacks on x402, a study of 15 facilitators) shows a hostile server or a broken client library can inflate the price, swap the recipient or token, or ask for a long-lived authorization.

Mandate check (x402 authority extension, draft). An agent that pays under a spending mandate from its principal (x402-mandate/1: "spend up to CAP, to recipients in R, until T", Ed25519-signed) can send it along with the EIP-3009 payment as mandate: { mandate, alg: "Ed25519", sig, paymentId }. presign-guard checks the payment about to be signed against the grant, offline: the issuer's signature, payer = subject, token = asset, recipient in recipients (or "*", orange MANDATE_ANY_RECIPIENT), amount within perPayment and cap, not past notAfter, and the authorization's nonce equal to the binding of (mandate, paymentId) (§7), so the settled payment proves which grant it used. Outside the mandate is red (MANDATE_INVALID, MANDATE_BAD_SIGNATURE, MANDATE_WRONG_PAYER, MANDATE_WRONG_ASSET, MANDATE_RECIPIENT_OUT_OF_SCOPE, MANDATE_OVER_LIMIT, MANDATE_EXPIRED, MANDATE_BINDING_MISMATCH with details.expectedNonce); inside it adds MANDATE_OK (info) and the answer carries mandate: { ok, digest }. Payer, recipient and asset may be written as a bare 0x address, a CAIP-10 account (eip155:8453:0x…) or a CAIP-19 asset (eip155:8453/erc20:0x…). Only the single-payment checks (§6, §7): cumulative spend against the cap needs the accountant's spend log and is not checked. The wire values match the draft's conformance vectors byte for byte (test/mandate.test.mjs).

Response

json
{
  "version": "2",
  "verdict": "red",
  "reasons": [
    { "code": "UNLIMITED_APPROVAL", "severity": "orange", "subject": "0x…", "details": { "token": "0x…" } },
    { "code": "SIGNATURE_GRANT_TO_EOA", "severity": "red", "subject": "0x…" }
  ],
  "subject": { "chainId": 8453, "kind": "permit2_allowance", "offchain": true, "grants": [ … ] },
  "scope": "…",
  "sources": ["goplus"],
  "checkedAt": "2026-09-23T12:00:00.000Z"
}

Every token that is approved, permitted or paid is also checked with GoPlus token security (honeypot, impersonation, owner powers, taxes). Each grant in subject.grants carries tokenSymbol and tokenName when GoPlus knows the token. The verdict is the most severe reason: any red makes it red, otherwise any orange makes it orange. info reasons never change the verdict.

Reason codes

SeverityCodes
redADDRESS_POISONING (with from: the recipient looks like an address the wallet paid before, see Address poisoning), X402_AMOUNT_ABOVE_REQUIRED, X402_RECIPIENT_MISMATCH, X402_ASSET_MISMATCH, X402_NETWORK_MISMATCH (with x402: the payment is not what the seller asked), PHISHING_SITE (the origin is on a phishing list; details.flaggedBy), PHISHING_ACTIVITIES, STEALING_ATTACK, SANCTIONED and other GoPlus address flags, MALICIOUS_DELEGATE (an EIP-7702 wallet delegates to a flagged contract; details name the delegate and the flags), SANCTIONED_ADDRESS (on the OFAC SDN list; details name the SDN entry and program, and alsoFlaggedBy: ["goplus"] replaces GoPlus's own SANCTIONED for the same address), CREATOR_OF_MALICIOUS_CONTRACTS, MALICIOUS_CONTRACT_BEHAVIOR, ON_DOUBT_LIST, UNLIMITED_APPROVAL_TO_EOA, SIGNATURE_GRANT_TO_EOA, NEW_WALLET_SPENDER (a plain-wallet spender with no on-chain history or first seen under a day ago; from PG1's check_wallet_age. Age is per chain, so a wallet that looks new on an L2 is also looked up on Ethereum and the older first-seen counts), ORDER_PAYS_YOU_NOTHING, and for the token itself TOKEN_HONEYPOT, TOKEN_IMPERSONATION (details name the real token), TOKEN_AIRDROP_SCAM
orangeINTENT_MISMATCH (with intent: signing does more than, or something else than, what the agent says it is doing), HIDDEN_APPROVAL / SIMULATION_NFT_OUT / SIMULATION_FAILS (with from: see Transaction simulation), X402_VALIDITY_TOO_LONG / X402_DOMAIN_MISMATCH (with x402, see above), LOOKALIKE_SITE (origin imitates a known brand), AI_LOOKALIKE_SITE / AI_LURE_SITE / AI_TOKEN_IMPERSONATION / AI_SIGNATURE_IMPERSONATION (AI second opinion: the site name imitates a brand or promises free tokens, a token's name pretends to be another token, or a signature request names its contract after a known protocol or token such as Permit2, Uniswap or USD Coin while the contract is not that one; details.decidedBy = rule (a dapp domain with its dot turned into a hyphen, such as pump-fun.io, a signature named exactly like a famous protocol, or on the XRP Ledger a token whose currency name copies RLUSD, such as RLUSD2 or RLUSDC: decided by code, also without TypeSafe), jev or jev+claude (then with details.probability; only when the server has TypeSafe set up); never red, never makes a verdict green), WALLET_BLOCKS_SITE (MetaMask's site scanner blocks the origin), UNVERIFIED_DELEGATE (an EIP-7702 wallet delegates to unverified code), UNLIMITED_APPROVAL, UNLIMITED_TRANSFER, APPROVAL_FOR_ALL, APPROVAL_TO_EOA, SIGNATURE_TRANSFER, LONG_LIVED_PERMISSION, NONCANONICAL_PERMIT2, UNVERIFIED_CONTRACT, RECENTLY_DEPLOYED, MARKETPLACE_ORDER, UNRECOGNIZED_SIGNATURE, BLACKLIST_DOUBT, MIXER, NEW_DOMAIN (origin registered under 30 days ago), NEW_WALLET_SPENDER (a plain-wallet spender first seen under 7 days ago, or under a day when PG1 only has a minimum age, details.minimum: true; details.ageDays, details.firstSeen, details.chain), DOMAIN_NOT_REGISTERED, and for the token TOKEN_PAUSED (transfers are paused right now), TOKEN_OWNER_CAN_CHANGE_BALANCES, TOKEN_OWNERSHIP_RECLAIMABLE, TOKEN_HIDDEN_OWNER, TOKEN_SELFDESTRUCT, TOKEN_CANNOT_SELL_ALL, TOKEN_CREATOR_MADE_HONEYPOTS, TOKEN_HIGH_TAX (buy or sell tax of 10% or more), TOKEN_UNVERIFIED
infoINTENT_MATCHES (with intent: signing does what the agent says), KNOWN_RECIPIENT / NEW_RECIPIENT (with from: the recipient was or wasn't paid before), EIP7702_DELEGATED_WALLET (a plain wallet with EIP-7702 code, treated as a wallet; details name the delegate contract, which is screened like a spender), PARTIAL_SOURCE_DATA (GoPlus returned partial data for this address), PAYMENT_AUTHORIZATION, REVOKES_APPROVAL, OFFCHAIN_SIGNATURE, SIGNATURE_EXPIRED, UPGRADEABLE_PROXY (only when the chain confirms a standard proxy layout: EIP-1967 or its beacon, EIP-1822, the older OpenZeppelin slot or an EIP-1167 clone; details name the kind and what it points to, or confirmed: false when the chain RPC can't be asked), ON_TRUST_LIST, UNDECODED_CALL, and issuer controls on the token (USDC has several): TOKEN_MINTABLE, TOKEN_PAUSABLE, TOKEN_BLACKLIST (for a proxy token GoPlus has no answer, so the chain is asked, as in the token verdict: source: "onchain" and the role address), TOKEN_UPGRADEABLE, TOKEN_TAX_MODIFIABLE, TOKEN_TRADING_COOLDOWN, TOKEN_TAX, TOKEN_ON_TRUST_LIST, TOKEN_NO_SECURITY_DATA (GoPlus has no record of the token), WALLET_AGE (how long a plain-wallet spender has been on-chain, and on which chain it was first seen; minimum: true when the wallet can only be older than shown), DOMAIN_AGE, DOMAIN_AGE_UNKNOWN (no RDAP data for that TLD), SITE_ALLOWLISTED (origin on MetaMask's allowlist), SITE_REPUTATION_UNAVAILABLE (a phishing-list source could not be reached; details.sources), SANCTIONS_SCREENED (the address is not on the OFAC SDN list; details.listSynced is when PG1 last synced the list, so the signed receipt shows which version was used), and when PG1 can't be reached SANCTIONS_SCREEN_UNAVAILABLE / DOMAIN_AGE_UNAVAILABLE / WALLET_AGE_UNAVAILABLE (the check goes on; GoPlus still carries a sanctions flag)

Signed verdicts

Every paid answer (HTTP and MCP) carries a receipt signed by presign-guard, so you can later prove which verdict was delivered for which request, not only that you paid:

json
"receipt": {
  "request_id": "5f0c…",
  "route": "POST /v1/check",
  "input_sha256": "9a1e…",
  "payment": { "network": "eip155:8453", "asset": "0x8335…", "amount": "10000", "pay_to": "0x6B0F…", "payer": "0x0fD3…", "nonce": "0x5c1d…", "proof": "eip3009" },
  "signed_at": "2026-09-27T09:30:00.000Z",
  "signer": "0x…",
  "algorithm": "eip191-canonical-json-v1",
  "signature": "0x…"
}
  • What is signed: the whole response with receipt.signature left out, as canonical JSON (profile js-json-stringify-sorted-utf16-ascii-v1: keys sorted by UTF-16 code units at every level, no whitespace, every code unit from U+007F up escaped as lowercase \uXXXX, numbers spelled as JavaScript's JSON.stringify writes them — 1.0 → 1, 0.000001 → 0.000001, 1e21 → 1e+21 — then UTF-8 bytes). Python's json.dumps matches only for ASCII keys and integers (it writes 1.0 and 1e-06); use examples/canonical.py, with EIP-191 personal_sign. Flipping the verdict, or moving it to another request id, breaks the signature.
  • input_sha256 is the SHA-256 of the canonical JSON of {"route": …, "input": …}, where input is your JSON body (POST), your query parameters as strings (GET), or the tool arguments (MCP, route mcp <tool>). Recompute it to prove the verdict answers your request.
  • payment ties the verdict to the payment that bought it, from your x402 payment payload. On Base: the payer and the EIP-3009 nonce, so anyone can find the settlement on-chain as the USDC contract's AuthorizationUsed(payer, nonce) event without trusting us. On Solana: the payer and a SHA-256 of the signed transaction you sent (the facilitator adds its fee-payer signature at settlement, so the final transaction id is not known when we sign).
  • Signer addresses: /.well-known/presign-guard-signer.json. Retired signers stay listed with their dates, so old receipts keep verifying.
  • Check one for free: POST /v1/verify with {"response": <the signed answer>, "route": "POST /v1/check", "input": <what you sent>} returns valid, signer, known_signer and input_matches.

Verify it yourself (Node, viem):

js
import { recoverMessageAddress } from "viem";
const { signature, ...rest } = answer.receipt;
const signer = await recoverMessageAddress({ message: canonicalJson({ ...answer, receipt: rest }), signature });
// signer must equal answer.receipt.signer and be listed in /.well-known/presign-guard-signer.json

Python (eth-account), with examples/canonical.py (not json.dumps, which spells numbers differently):

python
from canonical import verify_receipt
assert verify_receipt(answer) == answer["receipt"]["signer"]
# and the signer must be listed in /.well-known/presign-guard-signer.json (or certified by the payout wallet)

Key rotation without client updates: the payout wallet (0x6B0F4651eD42893ab58139938175E4a69f175F25, the payTo of every payment) authorises each signing key with a certificate: a personal_sign over

fizzl receipt signer
service: presign-guard
signer: <signing key address>
valid_from: <YYYY-MM-DD>

Set it as RECEIPT_SIGNER_CERT (YYYY-MM-DD:0x<signature>; the page /sign-receipt-key produces it from the wallet's browser; /sign-receipt-key?service=x402-doctor does the same for x402 Doctor, so the payout wallet signs on one site only). It is checked at startup and carried inside every receipt as receipt.cert, so a client that pins only the payout wallet verifies receipts from a new key offline. To rotate: generate a new RECEIPT_SIGNER_SECRET, sign a new certificate, move the old address to RECEIPT_RETIRED_SIGNERS.

The signing key comes from RECEIPT_SIGNER_SECRET (any long random string; it holds no funds and signs nothing but receipts). Without it, answers are unsigned.

Not covered

The simulation of signatures (only transactions are simulated), and what a site does with a signed plain message. Treat a green verdict as "no known risk signals", not as a guarantee.

Feedback

Found a bug, or missing something? Send it with POST /feedback (free, no payment) or the MCP tool feedback:

bash
curl -X POST https://presign-guard.fizzl.eu/feedback \
  -H 'content-type: application/json' \
  -d '{"type": "feature", "message": "Support Arbitrum in /v1/token", "endpoint": "/v1/token"}'

type is bug, feature or other; message is required (up to 2000 characters); endpoint and contact are optional. The answer is 202 with an id. At most 10 reports per hour per caller. Reports go to the usage log, and a person reads every one; nothing in a report is run or changed automatically. GET /feedback shows the schema.

Run locally

bash
cp .env.example .env   # fill in PAY_TO and ANTHROPIC_API_KEY
npm install
npm test               # 36 tests, network mocked
npm run dev

End-to-end payment test (Base Sepolia)

Fund a throwaway wallet with Base Sepolia test USDC, set AGENT_PRIVATE_KEY and CHECK_URL in .env, then run:

bash
npm run client

The script makes a valid call, which should return 200 with a settlement receipt, and an invalid call, which should return 400 with no charge.

Deploy to Render

The live service runs on Base mainnet: render.yaml sets X402_NETWORK=eip155:8453, which needs CDP_API_KEY_ID and CDP_API_KEY_SECRET (Coinbase CDP facilitator; the service refuses to start on mainnet without them). After the first paid call settles through CDP, the routes are listed in the CDP Bazaar. To test without real money, set X402_NETWORK=eip155:84532 (Base Sepolia, public x402.org facilitator); without X402_NETWORK the code also defaults to Base Sepolia.

PAY_TO_SOLANA (optional) is a Solana address for USDC payments on Solana, offered on every paid route (the checks, the token verdict and the approvals audit); those payments settle through the PayAI facilitator (SOLANA_FACILITATOR_URL to override). Without it, they are paid on Base (and XRPL / Algorand when set).

ALGORAND_PAY_TO (optional) is an Algorand address for USDC payments on Algorand (ASA 31566704), offered on every paid route and credit pack at the same dollar price; those payments settle through GoPlausible's public facilitator (ALGORAND_FACILITATOR_URL to override), which pays the Algorand fee. The account must have opted in to USDC. Unset or off: no Algorand option.

MPP_SECRET (optional, mainnet) turns on MPP payment next to x402 on the paid routes, for agents that speak MPP (mppx): method evm, USDC on Base, settled by the CDP facilitator only after a successful answer. Any long random string; it signs the challenge ids. MPP_TEMPO_RECIPIENT adds MPP method tempo (push mode: the agent sends USDC.e on Tempo with the MPP memo and answers with the transaction hash, checked on a Tempo RPC): the address that receives it on Tempo. MPP_TEMPO_CHAIN=42431 switches to the Moderato testnet, MPP_TEMPO_RPC overrides the RPC.

MPP_TEMPO_OPERATOR_KEY (optional, with MPP_TEMPO_RECIPIENT and CREDITS_REDIS_URL) adds MPP sessions (intent session, mppx): the agent opens a payment channel on Tempo once and pays each call with a signed voucher, no transaction per call. The channel pays out to MPP_TEMPO_RECIPIENT; the key is a separate operator account that only settles and closes channels and pays the gas (a few dollars of USDC.e on Tempo is plenty). Channel state lives in Redis; spend is settled on-chain after $0.50 or 10 minutes.

PAY_TO must be an EVM address (0x + 40 hex characters). Surrounding spaces are trimmed; anything else stops the server at startup with a clear error, so a typo can't publish an unpayable 402.

Data sources

Risk data comes from the GoPlus Security API. Sanctions screening uses the OFAC SDN list straight from the US Treasury, in the wallet screen and in every check: the spender or recipient on EVM, and on Solana, the XRP Ledger and Algorand every party a transaction pays, hands control to or trades with (OFAC_SDN_URL overrides the address, OFAC_SDN=off skips the download; credited as ofac-sdn in sources). Until the list is loaded after a restart, EVM checks screen through PG1, which also gives the domain age (public OFAC and RDAP data, credited as pg1 in sources; set PG1_API_KEY to a PG1 membership key to be exempt from PG1's anonymous rate limit); the token verdict adds RugCheck (Solana) and DexScreener (market data). Transactions sent with from are simulated with the standard eth_simulateV1 call, on Alchemy when ALCHEMY_API_KEY is set (credited as alchemy in sources), else on the public RPC. Plus eth_getCode on a public RPC to recognise EIP-7702 wallets (override with RPC_URL_<chainId>). Explanations come from Claude (Anthropic).

Similar modules

Browse Fraud & scam detection