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.

13 min readUpdated 6 August 2026escrow, auctions, offers, disputes, wallet

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 a transactionHash. "Connect a wallet address in your profile to bid on an on-chain auction" fires when the listing has an auctionContractAddress, 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

  1. Confirm no money is involved. Open the row on /admin/nft/auction and check that auctionContractAddress is empty. If it is empty, no on-chain escrow exists and there is nothing to return.

  2. Cancel the auction. Use the Active toggle on the row, which calls PUT /api/admin/nft/auction/{id}/status with false and sets the listing to CANCELLED with a cancelledAt stamp. It needs edit.nft.

  3. Tidy up after it. That toggle changes the listing status and nothing else: the ACTIVE bid rows stay ACTIVE and nft_token.isListed stays true, 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.

  4. 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_dispute row with disputeType = 'NOT_RECEIVED', status = 'PENDING', priority = 'HIGH', the buyer as reporterId and the seller as respondentId;
  • notifies every admin holding access.nft.dispute, linking to /admin/nft/dispute;
  • stamps flaggedAt so 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

  1. Read the hold row for the amount, using the query above. Note the offer's currency from the offer row.

  2. 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 the NOT_RECEIVED, HIGH, PENDING one naming the offer id in its description.

  3. Resolve with a refund. POST /api/admin/nft/dispute/{id}/resolve with resolutionType of REFUND or PARTIAL_REFUND, and always pass refundAmount explicitly — the figure from step 1. This needs edit.nft.dispute; access.nft.dispute opens the queue and reads the messages but cannot settle anything.

  4. Check the buyer's wallet. The refund is a credit under idempotency key dispute_refund_<disputeId>, not a hold release. The buyer's inOrder is 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:

Creates or updates any settings key

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/withdrawchain, 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 ACCEPTED offer inside the grace window. It is waiting for POST /api/nft/offer/{id}/confirm from either party. The sweep will deal with it after nftTransferConfirmGraceHours.

  • An EXPIRED offer. Both the expiry job and the stale sweep release the hold before they write that status.

  • A SOLD auction with no ledger rows. The seller-payment and buyer-transfer transaction rows 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 is REJECTED, 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/bid sends every bid on chain with auctionContract.bid({ value: … }), so the money really is sitting inside the NFTAuction contract. The reserve-not-met branch of settleAuctions writes EXPIRED, isListed: false and REJECTED bids and never touches the contract — it does not call endAuction and it issues no refund. The contract does expose pendingReturns, withdraw and emergencyWithdraw, but nothing in the product calls them for you: auction-service.ts has a withdrawFunds helper 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.