Bagnotary

Form BN-2 · Docs

Docs

Make a proof, check a proof, call the API. No API key.

What a proof is

A Bagnotary proof says one thing: a wallet held at least a threshold of an ERC-20 token on Robinhood Chain (4663) at one block.

You sign one message. There is no gas and no transaction. Our notary checks your balance once and issues a proof signed with the notary key (EIP-712).

Your address is not in the proof. A proof is a snapshot at block N, valid 24 hours. The holder may have sold since.

Attested by our notary, not zero-knowledge. You trust the notary key and the code that runs it.

Every proof has a link, https://bagnotary.pro/p/<id>, and a raw JSON form:

{
  "version": 1,
  "id": "0xc315dabe438bfaee2221ff0a102abc64",
  "chainId": 4663,
  "token": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
  "symbol": "USDG",
  "decimals": 6,
  "threshold": "250000",
  "blockNumber": 113141528,
  "blockHash": "0x503e2efec7bc531adc862deb150421af0c6f4e7c764316a9de0365861354994c",
  "issuedAt": 1794550488,
  "expiresAt": 1794636888,
  "label": "",
  "tier": 0,
  "notary": "0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3",
  "signature": "0x206529ccd8fcd6d64a16e03ca2ebce3356e7cdd2f78c46df31f5b0d5ddd7a2c56047dd4a702ca24bab575deb466ccda0e3d2c60a403675740774056f8cab244a1b",
  "display": {
    "threshold": "0.25"
  }
}

The fields id through tier are signed. version, chainId, notary and display are not signed: chainId is part of the EIP-712 domain, and notary is a hint you compare against the published notary address.

There is no field for your address, your signature, the nonce, or your IP.

Make a proof in the browser

  1. Box 1 · Token. Open /prove/ and paste the token contract address. The page shows the symbol, the decimals and the full contract address. The contract address identifies the token, not the symbol.
  2. Box 2 · Threshold. Enter the amount you want to prove, in whole token units. If very few wallets hold that much, the threshold itself narrows down who you are. Pick a threshold many wallets clear.
  3. Box 3 · Signature. Connect your wallet and sign one message. The wallet is asked for two things only: eth_requestAccounts and personal_sign. No gas, no transaction. You do not need to switch your wallet to Robinhood Chain.

The notary reads your balance at the latest block. If it is at or above the threshold, you get a proof card and a link. If it is below, no proof is issued and your balance is not returned.

One wallet can get 3 proofs per token per day.

EOA wallets only. Smart-contract wallets (ERC-1271) are not supported in v1.

The message you sign looks like this (real challenge from the API):

Bagnotary holder proof request

Check my balance once and issue a holder proof.
Signing this is free. It is not a transaction and cannot move funds.

Chain: Robinhood Chain (4663)
Token: 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168
Symbol: USDG
Threshold: 0.25 (250000 raw, 6 decimals)
Label: -
Nonce: 0xc85e1a39a90894b5147799daa45aebd1
Issued: 2026-11-13T06:22:55Z
Expires: 2026-11-13T06:27:55Z
URI: https://bagnotary.pro

Check a proof

In your browser

Open the proof link or paste the proof JSON into /verify/ and press Verify. The signature is recovered on your device, with no network request for that step. The page shows the full signer address and compares it with the notary address from GET /api/notary. It then checks the expiry, and asks Robinhood Chain whether the block exists.

With curl, no API key

By id:

curl -s -X POST https://bagnotary.pro/api/verify \
  -H 'content-type: application/json' \
  -d '{"id":"0xc315dabe438bfaee2221ff0a102abc64"}'
{
  "valid": true,
  "signatureValid": true,
  "signer": "0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3",
  "notary": "0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3",
  "expired": false,
  "expiresAt": 1794636888
}

Or send the whole proof JSON, so the server checks the copy you were given:

curl -s https://bagnotary.pro/api/proof/0xc315dabe438bfaee2221ff0a102abc64 | jq '{proof: .proof}' \
  | curl -s -X POST https://bagnotary.pro/api/verify \
      -H 'content-type: application/json' -d @-

If one character of the proof was changed, the answer is 200 with valid:false and a reason. Here the symbol was edited from USDG to USDX; the signature now recovers to a different address:

curl -s https://bagnotary.pro/api/proof/0xc315dabe438bfaee2221ff0a102abc64 \
  | jq '{proof: (.proof | .symbol = "USDX")}' \
  | curl -s -X POST https://bagnotary.pro/api/verify \
      -H 'content-type: application/json' -d @-
{
  "valid": false,
  "signatureValid": true,
  "signer": "0xd8C976f63f0e8E7Da78Ee90163B43B527c44DcB9",
  "notary": "0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3",
  "expired": false,
  "expiresAt": 1794636888,
  "reason": "wrong_signer"
}

valid is true only when the signature is valid, the signer is the notary and the proof has not expired.

Offline, with Node

You do not need our server to check the signature. Take the domain and types from GET /api/notary and recover the signer yourself:

// npm i viem
import { recoverTypedDataAddress } from 'viem';

const B = 'https://bagnotary.pro';
const notary = await (await fetch(`${B}/api/notary`)).json();
const { proof } = await (await fetch(`${B}/api/proof/0xc315dabe438bfaee2221ff0a102abc64`)).json();

const message = {
  id: proof.id, token: proof.token, symbol: proof.symbol, decimals: proof.decimals,
  threshold: BigInt(proof.threshold), blockNumber: BigInt(proof.blockNumber),
  blockHash: proof.blockHash, issuedAt: BigInt(proof.issuedAt),
  expiresAt: BigInt(proof.expiresAt), label: proof.label, tier: proof.tier,
};
const signer = await recoverTypedDataAddress({
  domain: notary.domain, types: notary.types, primaryType: notary.primaryType,
  message, signature: proof.signature,
});
console.log(signer, signer.toLowerCase() === notary.address.toLowerCase() ? 'NOTARY' : 'NOT THE NOTARY');
console.log(Date.now() / 1000 < proof.expiresAt ? 'not expired' : 'EXPIRED');

Fetch the notary address once and keep it; then the check above needs no network at all.

The same check ships in the repository as scripts/verify.mjs:

node scripts/verify.mjs proof.json [--notary 0x...]
curl -s https://bagnotary.pro/api/proof/<id> | node scripts/verify.mjs - --notary 0x...


$ curl -s https://bagnotary.pro/api/proof/0xc315dabe438bfaee2221ff0a102abc64 > proof.json
$ node scripts/verify.mjs proof.json --notary 0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3
signer:      0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3
notary:      0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3
signature:   readable, signed by notary
expires:     2026-11-14T06:14:48.000Z
claim:       holds >= 0.25 USDG (token 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168) at block 113141528
VALID

Source: repository, running commit .

Make a proof from the terminal

You need curl, jq and Foundry's cast. The challenge lives 5 minutes, so run the three steps in one go.

B=https://bagnotary.pro
TOKEN=0x...            # token contract on Robinhood Chain (4663)
THRESHOLD=10000        # whole token units, as a string

# 1. Ask for a challenge
CH=$(curl -s -X POST $B/api/challenge -H 'content-type: application/json' \
  -d "{\"token\":\"$TOKEN\",\"threshold\":\"$THRESHOLD\"}")
NONCE=$(printf '%s' "$CH" | jq -r .nonce)
MSG=$(printf '%s' "$CH" | jq -r .message)
printf '%s\n' "$MSG"   # read what you are about to sign

# 2. Sign it (EIP-191 personal_sign; no gas, no transaction)
SIG=$(cast wallet sign --interactive "$MSG")
#    or: cast wallet sign --account <keystore name> "$MSG"
#    or: cast wallet sign --ledger "$MSG"

# 3. Send nonce + signature. The address is recovered from the signature; you do not send it.
curl -s -X POST $B/api/prove -H 'content-type: application/json' \
  -d "{\"nonce\":\"$NONCE\",\"signature\":\"$SIG\"}" | jq

Use printf '%s', not echo: in zsh, echo turns the \n in the JSON into real newlines and jq fails.

Step 1 answers:

{
  "nonce": "0xc85e1a39a90894b5147799daa45aebd1",
  "message": "Bagnotary holder proof request\n\nCheck my balance once and issue a holder proof.\n…\nURI: https://bagnotary.pro",
  "expiresAt": 1794551275,
  "token": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
  "symbol": "USDG",
  "decimals": 6,
  "thresholdRaw": "250000"
}

Step 3 answers {"proof":{…},"url":"…"}: the same proof object that GET /api/proof/:id returns (see above), plus its link:

{
  "proof": { "version": 1, "id": "0xc315dabe438bfaee2221ff0a102abc64", … },
  "url": "https://bagnotary.pro/p/c315dabe438bfaee2221ff0a102abc64"
}

If the wallet holds less than the threshold, no proof is issued:

{
  "error": {
    "code": "below_threshold",
    "message": "This wallet holds less than the threshold at block 113103752. No proof was issued."
  }
}

If the RPC does not answer (503 rpc_unavailable), the nonce is kept. Send the same nonce and signature again after retryAfter seconds.

API reference

Base URL https://bagnotary.pro/api. JSON only. No API key, no cookies. Request bodies up to 2 KB.

Errors always look like {"error":{"code":"…","message":"…","retryAfter":15}}; retryAfter is present only when it applies.

GET /api/token/:ca

Reads symbol(), decimals() and totalSupply() of a token contract.

curl -s https://bagnotary.pro/api/token/0x5fc5360d0400a0fd4f2af552add042d716f1d168
{
  "token": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
  "symbol": "USDG",
  "decimals": 6,
  "totalSupply": "641333555029062"
}

POST /api/challenge

Body: {"token":"0x…","threshold":"0.25"}. threshold is a decimal string in whole token units. Returns the exact message to sign, a nonce, and its expiry (5 minutes).

After launch, an optional label (1–32 characters: letters, digits, space and @ _ . # : / -) is accepted for wallets in the holder tier.

{
  "nonce": "0xc85e1a39a90894b5147799daa45aebd1",
  "message": "Bagnotary holder proof request\n\nCheck my balance once and issue a holder proof.\n…\nURI: https://bagnotary.pro",
  "expiresAt": 1794551275,
  "token": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
  "symbol": "USDG",
  "decimals": 6,
  "thresholdRaw": "250000"
}

POST /api/prove

Body: {"nonce":"0x…","signature":"0x…"}. The client does not send an address. The server recovers it from the signature, reads the balance at one block, and issues the proof if the balance is at or above the threshold.

GET /api/proof/:id

id is 0x + 32 hex, or the 32 hex alone. Expired proofs still answer 200 with "expired":true for 30 days, then they are deleted.

{
  "proof": {
    "version": 1,
    "id": "0xc315dabe438bfaee2221ff0a102abc64",
    "chainId": 4663,
    "token": "0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168",
    "symbol": "USDG",
    "decimals": 6,
    "threshold": "250000",
    "blockNumber": 113141528,
    "blockHash": "0x503e2efec7bc531adc862deb150421af0c6f4e7c764316a9de0365861354994c",
    "issuedAt": 1794550488,
    "expiresAt": 1794636888,
    "label": "",
    "tier": 0,
    "notary": "0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3",
    "signature": "0x206529ccd8fcd6d64a16e03ca2ebce3356e7cdd2f78c46df31f5b0d5ddd7a2c56047dd4a702ca24bab575deb466ccda0e3d2c60a403675740774056f8cab244a1b",
    "display": {
      "threshold": "0.25"
    }
  },
  "expired": false,
  "anchor": null
}

POST /api/verify

Body: {"id":"0x…"} or {"proof":{…}}. A broken proof answers 200 with valid:false and reason: bad_signature, wrong_signer, expired or malformed. Each call adds one to the API verifications count.

GET /api/stats

Live counts, cached 5 seconds. block comes from the chain; the other numbers are server counts. If the RPC fails, the last good block is returned with "stale":true.

{
  "proofs": 1,
  "tokens": 1,
  "apiVerifications": 2,
  "featured": "0xc315dabe438bfaee2221ff0a102abc64",
  "block": {
    "number": 113145190,
    "at": 1794550870,
    "stale": false
  },
  "source": {
    "proofs": "server count",
    "tokens": "server count",
    "apiVerifications": "server count",
    "block": "chain"
  }
}

GET /api/notary

The notary address, the EIP-712 domain and types, and the proof validity in seconds.

{
  "address": "0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3",
  "chainId": 4663,
  "domain": {
    "name": "Bagnotary",
    "version": "1",
    "chainId": 4663
  },
  "primaryType": "HolderProof",
  "types": {
    "HolderProof": [
      { "name": "id", "type": "bytes16" },
      { "name": "token", "type": "address" },
      { "name": "symbol", "type": "string" },
      { "name": "decimals", "type": "uint8" },
      { "name": "threshold", "type": "uint256" },
      { "name": "blockNumber", "type": "uint64" },
      { "name": "blockHash", "type": "bytes32" },
      { "name": "issuedAt", "type": "uint64" },
      { "name": "expiresAt", "type": "uint64" },
      { "name": "label", "type": "string" },
      { "name": "tier", "type": "uint8" }
    ]
  },
  "repo": null,
  "commit": "deploy-202611130521",
  "holderTier": {
    "active": false,
    "token": null,
    "min": "1000000",
    "validity": 604800
  },
  "validity": 86400,
  "anchorContract": "0xE8CdD4F14BBf2FDD4e4A572e1FA80c0568289081",
  "anchorChainId": 4663
}

GET /api/health

{"ok":true}

Rate limits

LimitValueAnswer
Per IP, kept in process memory onlychallenge 10/min · prove 5/min · verify 60/min · GET 120/min429 rate_limited + Retry-After
Per wallet × token3 proofs per rolling 24 hours429 quota_exceeded + retryAfter

A below_threshold attempt does not use the wallet × token quota.

Error codes

HTTPcodeWhen
400bad_addressThe token address is not a valid address.
404not_a_contractNo contract at this address on Robinhood Chain (4663).
422not_erc20The contract does not answer like an ERC-20 token.
422unsupported_decimalsDecimals above 36.
400bad_thresholdNot a number, zero or less, or more fraction digits than the token's decimals.
400threshold_above_supplyThe threshold is above the token's total supply.
400bad_labelThe label is outside the allowed characters or longer than 32.
403holder_tier_inactiveA label was sent while the holder tier is not active.
403holder_tier_requiredA label was requested but the wallet is not in the holder tier.
400bad_signatureThe signature could not be read.
404unknown_nonceThe nonce does not exist or was already used.
409nonce_usedThe nonce was used by a request at the same moment.
410challenge_expiredMore than 5 minutes since the challenge. Ask for a new one and sign again.
422below_thresholdThe wallet holds less than the threshold at that block. No proof was issued.
422balance_read_failedbalanceOf reverted.
429quota_exceeded3 proofs for this wallet and token in the last 24 hours.
429rate_limitedToo many requests from this IP.
503rpc_unavailableRobinhood Chain RPC is not answering. Nothing was issued; the nonce is kept.
400bad_idThe proof id is not 32 hex characters.
404not_foundNo proof with this id.

Real examples:

$ curl -s https://bagnotary.pro/api/token/0x0000000000000000000000000000000000000001
{"error":{"code":"not_a_contract","message":"No contract at this address on Robinhood Chain (4663). Is this token on another chain?"}}

$ curl -s https://bagnotary.pro/api/proof/zzz
{"error":{"code":"bad_id","message":"A proof id is 32 hex characters."}}

$ curl -s -X POST https://bagnotary.pro/api/challenge -H 'content-type: application/json' \
    -d '{"token":"0x5fc5360d0400a0fd4f2af552add042d716f1d168","threshold":"0.0000001"}'
{"error":{"code":"bad_threshold","message":"The threshold must be a number above zero with no more decimals than the token has."}}

Signed fields (EIP-712)

domain = { name: "Bagnotary", version: "1", chainId: 4663 }

HolderProof(
  bytes16 id,
  address token,
  string  symbol,
  uint8   decimals,
  uint256 threshold,    // raw units
  uint64  blockNumber,
  bytes32 blockHash,
  uint64  issuedAt,     // unix seconds, server time
  uint64  expiresAt,
  string  label,        // "" for a standard proof
  uint8   tier          // 0 = 24 hours
)

The symbol is what the token contract returns, limited to 16 printable ASCII characters. Two tokens can share a symbol, so always check the token contract address.

A standard proof is valid 86,400 seconds (24 hours) from issuedAt.

Tier 1 (holder tier) proofs are valid 604,800 seconds (7 days) and may carry a label.

Hourly anchor

Anchoring starts when the contract is live.

Every hour, the notary writes one Merkle root of all proofs issued that hour to Robinhood Chain.

Anyone can check a proof was anchored and was not issued later or backdated.

The contract holds no funds and stores no wallet addresses.

Contract: on Robinhood Chain ().

How the root is built

  • Leaf = keccak256(bytes.concat(digest)), where digest is the EIP-712 hash of the proof.
  • Pairs are sorted before hashing (the same rule as OpenZeppelin MerkleProof). One proof in an hour means root = leaf.
  • The anchor job runs at minute :05 and covers proofs with issuedAt in [fromTime, toTime). An hour with no proofs sends no transaction.
  • The contract rejects a time range that starts before the end of the previous one, so a root cannot be written over an earlier hour.

Check it yourself

GET /api/proof/:id adds an unsigned anchor field: {index, root, tx, path[]}, or null while the proof waits for the next anchor at :05 UTC.

"anchor": null

Once the hour is anchored, anchor holds index (the root number in the contract), root, tx and path (the sibling hashes from your leaf to the root).

Read the stored root from the contract and compare:

cast call 0xE8CdD4F14BBf2FDD4e4A572e1FA80c0568289081 "anchors(uint256)(bytes32,uint32,uint64,uint64,uint64)" 0 \
  --rpc-url https://robinhood-rpc.publicnode.com

The answer is root, proofs (how many proofs that hour), fromTime, toTime and anchoredAt. Use the index from your proof's anchor field in place of 0.

The proof page and /verify/ rebuild the root from the digest and path in your browser and compare it with anchors(N) read from the chain.

Notary address

Every proof is signed by this address:

0x24A6997d8d65C927dc6263bca1aDF76C7B21Cca3

Check it against GET /api/notary and the footer of every page. The private key is kept on the server only.

The code is open source: repository, running commit .

Limits of v1

Does the notary see my address?
Yes. The notary server sees your address while it checks the balance. It does not write it to disk.
Does anyone else see it?
Our RPC provider sees a balance query for your address coming from our server.
Does a proof show who is holding it?
No. A proof is a bearer link: it shows that a wallet qualified, not who is showing it to you.
Can the threshold give me away?
If very few wallets hold that much, the threshold itself narrows down who you are. Pick a threshold many wallets clear.
Does it work with a smart-contract wallet?
EOA wallets only. Smart-contract wallets (ERC-1271) are not supported in v1.
Did I still hold the tokens after the proof?
A proof is a snapshot at block N, valid 24 hours. The holder may have sold since.
What do I have to trust?
No. Attested by our notary, not zero-knowledge. You trust the notary key and the code that runs it. See Privacy.

Not audited

No third party has reviewed the Bagnotary server, the web app, or the BagnotaryAnchor contract.

The proofs rest on our notary key and on the code described on this page. Use them with that in mind.