When money or an NFT is stuck
The wallet-address refusal that blocks a fresh install, blocked auction settlements, flagged escrow, the pause endpoint's 404, and what actually clears each one.
Four things in this addon can leave a customer holding neither their money nor their NFT, and none of them resolve on their own. Each has a specific row, a specific flag, and — in three of the four cases — a door that is not where you would expect it to be.
Work the symptom, not the cause. The one at the top is the most common blocker on a new install and it is not a bug.
"Connect a wallet address in your profile"
This is a 400, not a permissions problem, and it means exactly what it says:
the account has no user.walletAddress.
The message is assembled in nft/utils/nft-auth.ts as "Connect a wallet
address in your profile to <action>", so the trailing words tell you which
door was refused:
| What the customer was doing | Route | Message ends with |
|---|---|---|
| Buying at a fixed price | POST /api/nft/listing/{id}/buy |
…to buy an NFT |
| Transferring a token | POST /api/nft/token/{id}/transfer |
…to transfer an NFT |
| Approving a token for the marketplace | POST /api/nft/token/{id}/approval |
…to verify an approval transaction |
Three more doors refuse for the same reason with their own wording:
POST /api/nft/bid— two separate refusals. "Connect a wallet address in your profile to place bids with transaction verification" fires whenever the request carries atransactionHash. "Connect a wallet address in your profile to bid on an on-chain auction" fires when the listing has anauctionContractAddress, because the bid is sent to that contract from a real address. A database-only auction with no hash takes the bid without either.POST /api/nft/offer/{id}/confirm— "Cannot verify transfer: both the buyer and the seller must have a wallet address set in their profile". Both sides. One missing address here is what sends an accepted offer into the 24-hour unwind.POST /api/nft/auction/deploy— "Seller must have wallet address set".POST /api/nft/auction/{id}/settle— "No valid winning bid found or winner missing wallet address" and "Seller wallet address not found". A winner who unlinked their wallet after bidding stalls the settlement here.
The fix is on the customer's own profile
The column is a read-only mirror. It is written by exactly one thing: the
SIWE wallet-link flow at /user/profile?tab=wallet, where the customer
connects a wallet and signs a message. That writes a provider_user row with
provider = 'WALLET', and an afterSave hook on that model copies the address
into user.walletAddress.
walletAddress is not on the admin user-edit form, and it is not in that
endpoint's update schema — the field is simply ignored.
The user model actively refuses the write. Both beforeUpdate and
beforeBulkUpdate throw "user.walletAddress is a mirror of providerUser and
may only be changed by the SIWE link flow" unless the write comes from the
mirror hook itself. A hand-written value would also disagree with
provider_user, which is what the login and wallet-linking paths actually read.
Tell the customer to link their wallet. There is no other supported route.
One address per account: linking an address that already belongs to a different account returns 409 "This wallet address is already linked to a different account."
POST /api/nft/contract/deploy does not refuse a creator with no linked wallet.
It deploys the collection contract with the platform master wallet as the
contract owner and as the on-chain royalty recipient, and neither can be changed
afterwards. That creator can never collect a royalty from an external
marketplace.
Make wallet linking part of creator onboarding, before anyone deploys anything. See Collections.
Blocked auction settlement
Row: nft_listing.settlementBlockedAt is not null and status is still
ACTIVE.
Where you see it: the Blocked settlements tile in the Decision queue on
/admin/nft, which links to Admin → NFT → Trading → Auctions
(/admin/nft/auction). Affected rows carry a red Settlement blocked badge,
and the view dialog shows Settlement blocked at under Timeline → Settlement.
The same badge appears on /admin/nft/listing.
What it means
The settleAuctions cron stamps this when an auction ends with a winning bid
but the listing has no auctionContractAddress (or the collection has no
chain). Auction escrow lives inside the per-listing NFTAuction contract —
bid sends the money to it, endAuction pays the seller out of it. With no
contract, no money was ever escrowed anywhere.
So the good news first: nobody has been charged and nobody is owed a refund. The bids are database rows. What is stuck is the token — it is off the market with a nominal winner and nothing that can pay the seller.
The flag also stops the cron re-selecting the same unsettleable rows every ten minutes, which used to fill the batch window and starve auctions that could settle.
What clears the flag, and why it is not an admin action
Exactly one thing writes settlementBlockedAt back to NULL:
POST /api/nft/auction/deploy. That route checks listing.sellerId === user.id
and enforces the sell_nft KYC feature — it is the seller's screen
(/nft/token/{id}/list), not an admin one. No admin route, and no control on
the admin console, clears the flag.
The deploy route rejects an auctionEndTime that is not in the future, and it
overwrites the listing's endTime with whatever you pass. Clearing the flag
therefore reopens the auction.
The bids already on that listing were never sent to any contract, because there
was no contract. If the reopened auction ends with those rows still ACTIVE,
the cron calls endAuction on a contract holding nothing, and then flips
ownership and writes an nft_sale for money that never moved. That converts a
stuck auction into a real loss for the seller.
The safe recovery
-
Confirm no money is involved. Open the row on
/admin/nft/auctionand check thatauctionContractAddressis empty. If it is empty, no on-chain escrow exists and there is nothing to return. -
Cancel the auction. Use the Active toggle on the row, which calls
PUT /api/admin/nft/auction/{id}/statuswithfalseand sets the listing toCANCELLEDwith acancelledAtstamp. It needsedit.nft. -
Tidy up after it. That toggle changes the listing status and nothing else: the
ACTIVEbid rows stay ACTIVE andnft_token.isListedstaystrue, so the token can still look listed. Ask the seller to relist it — listing it again sets the flag correctly and, this time, deploys a contract. -
Tell the bidders. They were never charged, but they believe they won or were outbid. Nothing notifies them.
The seller cannot do step 2 themselves. DELETE /api/nft/listing/{id} returns
409 "Cannot cancel auction with active bids. Contact support if needed."
That message is pointing at you.
Why you should see few new ones
Two guards were added after this failure mode was found. POST /api/nft/listing
now refuses an auction whose collection contract is not deployed or whose token
has no blockchainTokenId, and the seller's listing screen calls
/api/nft/auction/deploy immediately after creating the listing, deleting the
listing again if the deployment fails. Blocked rows on a current install are
almost always legacy.
Flagged escrow
Row: nft_offer.flaggedAt is not null on an offer whose status is still
ACCEPTED.
Where you see it: the Flagged escrow tile in the Decision queue on
/admin/nft, linking to Admin → NFT → Trading → Offers
(/admin/nft/offer). The row carries a red Flagged badge and a Flagged for
review entry in its timeline.
What it means
An accepted offer whose on-chain transfer is never confirmed is unwound after
nftTransferConfirmGraceHours (default 24) by the expireOffers cron. Normally
that just releases the buyer's hold and puts the token back on the market.
flaggedAt is written when the release itself fails with
InsufficientHeldFundsError — the buyer's wallet.inOrder is smaller than what
the hold ledger says was reserved. Placing the hold moved that money out of
wallet.balance, so the buyer really is short by that amount and really has no
NFT.
The sweep also:
- creates an
nft_disputerow withdisputeType = 'NOT_RECEIVED',status = 'PENDING',priority = 'HIGH', the buyer asreporterIdand the seller asrespondentId; - notifies every admin holding
access.nft.dispute, linking to/admin/nft/dispute; - stamps
flaggedAtso the sweep stops re-processing and re-notifying the same offer every five minutes forever.
PUT /api/admin/nft/offer/{id}/status releases a hold only when the offer was
ACTIVE. A flagged offer is ACCEPTED, so switching it off writes
CANCELLED and cancelledAt and moves no money at all. It will look like you
resolved it.
Establish the amount from the hold row, never from the fee
The escrow was sized when the offer was made, as amount + marketplace fee at that moment. The wallet service wrote it as a transaction row under a stable
idempotency key. That row is the authority:
SELECT userId, walletId, amount, createdAt
FROM transaction
WHERE idempotencyKey = 'nft_offer_hold_<offerId>';Recomputing from today's nftMarketplaceFeePercentage is wrong whenever the fee
changed in between: too high and you hand back other operations' held funds, too
low and the residue is stranded forever. Every release path in the product reads
this row for exactly that reason.
Resolving it
-
Read the hold row for the amount, using the query above. Note the offer's
currencyfrom the offer row. -
Open the dispute at
/admin/nft/dispute. There is no menu entry for it — the NFT admin menu's Community group is Creators and Activity only. The only links in are on the dashboard at/admin/nft: the Open Disputes tile in the Decision queue and the Dispute Management button. Type the URL if you have lost the dashboard. Yours is theNOT_RECEIVED,HIGH,PENDINGone naming the offer id in its description. -
Resolve with a refund.
POST /api/admin/nft/dispute/{id}/resolvewithresolutionTypeofREFUNDorPARTIAL_REFUND, and always passrefundAmountexplicitly — the figure from step 1. This needsedit.nft.dispute;access.nft.disputeopens the queue and reads the messages but cannot settle anything. -
Check the buyer's wallet. The refund is a credit under idempotency key
dispute_refund_<disputeId>, not a hold release. The buyer'sinOrderis untouched, so if only part of the hold went missing you now have a wallet that needs reconciling on its own.
With no refundAmount, a REFUND resolves the figure from the most recent
nft_sale for the dispute's listingId, then from that listing's price. A
flagged offer has no nft_sale row, and its dispute's listingId is
whatever the token's most recent listing happened to be — a different price
entirely, or absent, in which case the call fails with "Cannot issue refund:
refund currency could not be determined".
A flagged escrow means the wallet ledger disagreed with itself, which does not happen on its own. Treat it as a signal to check that account's other holds before you close the ticket.
Emergency pause answers "Marketplace contract not found"
POST /api/nft/marketplace/pause looks up the platform setting
nft_marketplace_address_<chain> — lower-cased chain — and returns 404
unless it exists and equals the address you sent. Unpause, config, whitelist
and withdraw all do the same lookup.
Nothing writes that key. POST /api/nft/marketplace/deploy records the
contract in the nft_marketplace table and never touches settings. On any
install where nobody wrote it by hand, the emergency controls are dead in an
emergency.
Write it before you need it. It is not on any settings form, but the generic settings endpoint will create it:
Send {"nft_marketplace_address_eth": "0x…"} for each chain you have deployed
to. New keys are allowed as long as the name matches
[A-Za-z0-9_][A-Za-z0-9_.:-]*, and the endpoint clears the settings cache when
it is done.
Settings are served from an in-process Map in front of a Redis hash in front
of the table. A row written by hand reaches neither cache, so the running
backend keeps answering 404. Use the endpoint, or restart the backend after the
insert.
There is a second caveat that the key does not fix: the shipped NFTMarketplace
contract does not implement OpenZeppelin's Pausable, so pausing is a
platform-side flag your own screens honour rather than a guarantee the chain
will refuse a transaction. Marketplace contracts covers
both.
Fees that are not stuck, but are not yours yet
Fees from on-chain sales sit inside the marketplace contract as a native-token
balance. There is no alert, no scheduled sweep and no dashboard tile for it.
The only place it is visible is the per-chain card at the top of
Admin → NFT → Trading → Marketplace (/admin/nft/marketplace) and the
available-balance line on its Revenue Withdrawal tab.
Withdraw with POST /api/nft/marketplace/withdraw — chain,
contractAddress and a reason are required; omit amount to take everything
and omit withdrawalAddress to send to the contract's configured fee recipient.
Set yourself a recurring reminder. See Marketplace contracts and Fees and royalties.
What is not stuck
-
An
ACCEPTEDoffer inside the grace window. It is waiting forPOST /api/nft/offer/{id}/confirmfrom either party. The sweep will deal with it afternftTransferConfirmGraceHours. -
An
EXPIREDoffer. Both the expiry job and the stale sweep release the hold before they write that status. -
A
SOLDauction with no ledger rows. The seller-payment and buyer-transfertransactionrows written at auction settlement are informational mirrors of an on-chain payment; the settlement does not abort if they fail. Check the chain, not the wallet. -
A database-only auction that ended below its reserve. It becomes
EXPIRED, every bid isREJECTED, and no money was ever held.This is only safe while the listing has no
auctionContractAddress, and that is no longer the common case — the seller's listing screen deploys an auction contract immediately. Once one exists,POST /api/nft/bidsends every bid on chain withauctionContract.bid({ value: … }), so the money really is sitting inside theNFTAuctioncontract. The reserve-not-met branch ofsettleAuctionswritesEXPIRED,isListed: falseandREJECTEDbids and never touches the contract — it does not callendAuctionand it issues no refund. The contract does exposependingReturns,withdrawandemergencyWithdraw, but nothing in the product calls them for you:auction-service.tshas awithdrawFundshelper that no route and no cron invokes. An on-chain auction that fails its reserve is stuck, and clearing it is an on-chain job, not an admin-console one.
If the cron process is not running, none of the above happens at all: offers
never expire, auctions never settle, and nothing is flagged. Check the admin
cron screen for expireOffers (every 5 minutes) and settleAuctions (every 10
minutes) before you investigate a single row.
Related
- Troubleshooting — minting, images, menus and permissions.
- Listings, sales, auctions and offers — the mechanics behind each state above.
- Settings —
nftTransferConfirmGraceHoursand the rest. - Marketplace contracts — pause, config and withdrawal.
- Permissions —
edit.nft,edit.nft.dispute,access.nft.dispute.