The instruction, in full

How Cancel Bid works

Everything this app sends to Solana, why the program will only ever pay your own escrow back to you, and how to check that claim yourself instead of taking our word for it.

Each marketplace gets its own account layout and its own cancel instruction, so each needs its own page like this one. This is Solanart, the first one covered.

1. The moving parts

There is no database, no indexer and no custody. The app is a Next.js frontend, two route handlers that exist to keep an RPC key out of the browser, and a server-rendered ledger that reads the chain directly. The only state held on a server is a cache of NFT names and a census of bid accounts committed to the repo, neither of which is per-wallet.

PieceWhat it does
src/lib/solanart.tsDecodes bid accounts, builds the Cancel Bid instruction, builds and simulates the transaction. No React, no network client of its own.
src/components/rescue.tsxThe UI. Looks up bids, runs simulations, asks your wallet to sign, waits for confirmation.
src/app/api/rpc/route.tsA method-allowlisted, origin-checked JSON-RPC proxy. The browser never sees the upstream endpoint.
src/app/api/nft/route.tsRead-only Metaplex metadata lookup so a bid can show which NFT it was for. Cosmetic.
src/lib/leaderboard.tsScores a committed census of bid accounts against a live scan of the program. This is what the Manifest on the home page ranks.

Your keys never leave your wallet. The app builds unsigned bytes and hands them to the wallet extension through the Wallet Standard. It has no way to sign anything on its own.

2. Finding the stale bids

Solanart bids live in accounts owned by the marketplace program, each exactly 233 bytes, each holding the escrowed SOL as its own lamport balance. Finding yours is one indexed RPC call per role, run in parallel:

getProgramAccounts(CJsLwbP1iu5DuUikHEJnLfANgKy6stB2uFgvBBHoyxwz, {
  filters: [
    { dataSize: 233 },
    { memcmp: { offset: 97, bytes: <wallet> } },  // authority: can sign
  ],
})

The same query runs again at offset 1, the refund destination, because those two fields are not always the same wallet. Searching only the refund field would list money you cannot release. Searching only the authority field would hide money owed to you. The results are merged by account address.

No PDA derivation happens anywhere. The bid account address comes straight out of the lookup, so there is no chance of the app deriving an address that points somewhere it should not.

3. The bid account, byte for byte

OffsetTypeField
0u8tag, always 1
1pubkeyrefund destination. Where the escrow is returned. Does not sign.
33pubkeyNFT mint
65pubkeyself
97pubkeyauthority. The only key that can sign a cancel.
129pubkeyNFT token account
161pubkeyself, repeated
193pubkeymarketplace treasury
225u64bid amount in lamports

The layout was recovered by decoding live accounts and resolving each field against its on-chain owner, then confirmed by simulating cancels both ways round against real accounts. On most bids offsets 1 and 97 hold the same wallet, which hides the distinction. On the ones where they differ, swapping them produces InvalidAccountData: the program checks each slot against its own field.

Decoding is defensive because an RPC response is untrusted input. The decoder rejects anything that is not 233 bytes or does not carry tag 1, and the caller independently re-checks that each returned account is actually owned by the Solanart program rather than trusting the node to have honoured the filters it was given.

4. The Cancel Bid instruction

Instruction data is a single byte, 0x03, with no arguments. There is nothing in it to tamper with: no amount, no destination, no fee.

#AccountSignerWritable
0authority (from offset 97)yesyes
1bid accountnoyes
2refund destination (from offset 1)noyes
3NFT mintnono
4System Programnono

Only one key signs, and it is the one the account says is allowed to. The refund destination is written to, not signed by, which is what lets a bid pay out to a wallet other than the one signing when those fields differ. Both values are read out of the on-chain account. The app supplies neither.

The program closes the bid account, which returns the escrow plus its rent exemption, roughly 0.00251 SOL more than the recorded bid. That is why the amount shown is the account's lamport balance rather than the bid field.

5. Building the transaction

Cancels are batched eight per transaction. Each adds around 75 bytes against Solana's 1232 byte packet limit, so eight is comfortable headroom rather than the maximum. Larger sets are split into several transactions, each signed and confirmed in turn.

The fee payer is the connected wallet, which must also be the authority for every bid in the batch. A legacy message is compiled on purpose: it is the format of the transactions already known to work against this program. No address lookup tables, no compute budget instruction, no priority fee.

Cost to you is one signature, about 0.000005 SOL, per transaction. There is no fee instruction, no tip account and no transfer to anyone but you.

6. Simulate before you sign

Two different simulations run, both against mainnet state, neither broadcast:

The Simulate button is a dry run you can press on any wallet, including one you do not control. It works because sigVerify: false tells the node to execute the instruction without checking signatures and throw the result away. Nothing is sent and no balance moves.

The preflight runs inside the cancel flow on the exact bytes about to be signed, before your wallet is ever opened. If it fails, the flow throws and nothing reaches the wallet. Simulation and signing go through the same builder, so what gets dry run is byte for byte what gets signed.

Both requests ask the node to return the post-simulation state of every bid account, and the app only reports the escrow as drained when the run succeeded and the accounts came back at zero lamports. A failed simulation reports null for every account because nothing ran, and reading that as "closed" would turn a failure into a success.

When a simulation does fail, it is almost never the program refusing the cancel. In a sample of 120 bids dry run across the whole population, every failure was the signing wallet being unable to pay the network fee: a wallet emptied years ago now holds less than the 0.000005 SOL it costs to release tens of SOL. The app names that case in plain language instead of printing the error code. Sending such a wallet about 0.002 SOL unlocks the claim immediately.

7. The manifest, and what recovered means

The home page ends with a ledger of the largest open bids. It is not backed by a database, because there is nothing to record: the program is dead, so no bid can ever be created again and the population is fixed.

scripts/snapshot.ts writes a census of every bid account into the repo, sorted by size. Rendering the page scans the program once more with a zero-length dataSlice, which returns the addresses of the accounts that still exist and nothing else. Anything in the census that is missing from that scan has been closed, and closed means paid out. That difference is the entire mechanism.

It is also the honest limit of the number. A closed account proves the money left the program. It cannot prove this app is what released it, and a bid consumed by a sale would look identical. The page says so. When the live scan fails, the ledger still renders from the census and marks nothing as recovered rather than guessing.

The scan is a full program enumeration, so the page is rebuilt on a ten minute timer rather than per visitor.

8. Why this cannot take anything from you

Four layers, in order of how much you should trust them. The first is the only one that matters if the others are compromised.

The program, on chain

The Solanart program validates each account slot against the corresponding field in the bid account it is closing. A cancel signed by anyone other than the recorded authority fails. A cancel paying anyone other than the recorded destination fails. Nothing this app or a copy of it does can change that, because the check runs on the validator, not in your browser.

The instruction set

Only discriminator 3 is implemented. The same program has an accept bid path, discriminator 5, which is what an outside party has been using to buy NFTs into stale above-floor offers. It is deliberately not built and should not be: it would turn a tool that returns people their own money into a tool for taking other people's. There is also no token approval, no delegate, no set authority and no transfer instruction anywhere in this codebase.

The client guard

Before building any batch, the app re-checks that every bid's authority equals the connected wallet and throws if not. Cancel buttons only render for bids that pass the same check. This is defence in depth, not the actual protection: the program would reject those transactions regardless.

The RPC proxy

The upstream endpoint may carry an API key, so it is read server-side only and never reaches the browser. That makes the proxy a potential open relay, so it is closed down: a fixed allowlist of ten methods, batches capped at twenty calls, getProgramAccounts refused for any program other than Solanart, and any POST whose Origin is not this app's own rejected outright. Upstream errors never surface the upstream URL.

9. Treating chain data as hostile

Everything an RPC returns is attacker-influenced input. Account data can be crafted, NFT names and metadata URIs are arbitrary strings written by whoever minted them, and an RPC node can lie about anything.

The specific mitigations:

  • Account owner, data length and tag are re-verified after the lookup, not assumed from the filters.
  • Borsh string lengths in NFT metadata are bounds-checked before any slice, so a bad length cannot read past the buffer.
  • NFT names and images are display only. They never influence which accounts a transaction touches.
  • A lying node cannot cause loss: it can hide bids or fake a simulation result, but the transaction it would make you sign still has to pass the on-chain checks. Your wallet shows you the real instruction either way.

10. Check it yourself

Do not trust this page. Trust your wallet's transaction preview and the chain.

  • Your wallet should show one program, CJsLwbP1iu5DuUikHEJnLfANgKy6stB2uFgvBBHoyxwz, and nothing else. If it shows a transfer, an approval, or a second program, reject it.
  • Press Simulate first. It is read only and tells you the exact amount that would come back.
  • bun test pins the decoder and the instruction against a real mainnet bid account and a real successful Cancel Bid transaction. If those fail, this app is no longer sending the instruction known to work and should not ship.
  • bun run verify simulates cancels against live mainnet state for a sample of the largest stale bids and exits non-zero if any fail. It signs nothing.

11. Known limits

  • Solanart is the only marketplace covered so far. A bid stranded on any other dead marketplace is still stranded, and this app will not find it.
  • Even within Solanart, the second program, 5ZfZAwP2m93waazg8DkrrVmsupeiPEvaEHowiUP7UAbJ, holds 64 SOL of collection-wide offers across 95 accounts. They are variable length rather than a fixed 233 bytes and the layout has not been decoded, so those are not claimable here yet.
  • Bids denominated in SPL tokens have not been investigated.
  • The RPC proxy has no rate limiting. The origin check keeps other people's sites off the key, but not a script that sets the header itself, and /api/nft is a GET, which browsers send without an origin at all. It is a load concern, not a funds concern.
  • Only the first batch is simulated when a wallet holds more than eight bids. A later transaction cannot be dry run accurately while an earlier one has not landed.
  • Phishing copies are the real risk. Check the domain before connecting, and prefer a link you found yourself over one someone sent you.