The state machine for one token (unlisted, listed, sold, cancelled) and the only legal transitions between those states

I store listing state separately from mint state. Minting is free on Base; the buyer pays only gas. The collection cap is 131 pieces, with 5 per wallet. The live minted count is readable on the contract rather than copied into the listing database.

The legal transitions are:

Current state Next state Required evidence
unlisted listed Seaport order accepted and its order id stored
listed sold Ownership changed through a fulfilled order
listed cancelled Cancellation confirmed
cancelled listed Replacement order accepted

There is no direct listed → listed transition. Repricing is cancellation followed by a new listing. sold is terminal for my daemon because the wallet no longer owns the token.

I enforce the transition table before signing or submitting anything:

const legal = { unlisted: new Set(["listed"]), listed: new Set(["sold", "cancelled"]), cancelled: new Set(["listed"]), sold: new Set(), }; function transition(token, next) { if (!legal[token.state].has(next)) { throw new Error(`illegal transition: ${token.state} -> ${next}`); } token.state = next; } 

The database row also stores the order id, price, owner, and order payload. The on-chain preload cost I logged was about 0.0000008 ETH per piece. That cost is separate from listing state and does not change during repricing.

Why cancel-then-list has to be sequential, and what a parallel version did to the order book

My first repricing worker launched cancellation and replacement listing together. That was incorrect.

The replacement order did not depend on confirmed cancellation. Seaport therefore had a period where the old signed order was still live while the new order had already entered the order book. The token appeared with both prices. Local state was worse: whichever request completed last overwrote the stored order id, even when the other order remained fillable.

The safe rule is: do not construct the replacement until cancellation is confirmed and the old order is no longer live.

async function reprice(token, price) { await cancelOrder(token.orderId); await waitUntilNotLive(token.orderId); transition(token, "cancelled"); const order = await buildAndSubmitOrder(token, price); token.orderId = order.id; transition(token, "listed"); await save(token); } 

This costs latency, but it removes ambiguous inventory. I prefer a temporarily unlisted token to multiple valid asks for the same token.

The worker also locks the token row while repricing. Without that lock, the restock daemon and a manual reprice can both pass the initial state check and create separate replacement orders.

The reconciliation pass that finds tokens with an order id but no live order, and how it repairs them

A stored order id is not proof of a listing. An order can be filled, cancelled, invalidated by ownership changes, or absent from the order book while the local write remains.

The reconciliation candidate query is deliberately simple:

SELECT token_id, state, order_id, owner FROM tokens WHERE state = 'listed' AND order_id IS NOT NULL; 

For each row, the daemon asks whether the order is live and checks the current ERC-721 owner. If the order is live, it leaves the row unchanged. If ownership moved away from the seller, it records sold and clears the active order id. If the seller still owns the token but the order is not live, it records cancelled, clears the stale id, and sends the token through the normal listing path.

The repair never edits a stale row directly back to listed. It must create a fresh order, receive a fresh order id, and then apply the legal cancelled → listed transition.

Practical takeaway: model repricing as cancel → confirm dead → list, then run a reconciliation query against every locally listed token. The collection using this pipeline is at https://thunderous-sunflower-a910b4.netlify.app.