Skip to content
LogoLogo

Market board

A public list of open swap offers (SWAP_OFFERS.md), so a taker can find a counterparty and a price without already knowing the maker. Implemented as arrekta-market (node-rs/crates/arrekta-market, behind the gateway at /market/v1), in both SDKs, in @arrekta/wallet-core, in the Market tab of each wallet's Swap screen, and as a read-only page in the explorer.

The board never holds coins or keys. It stores offer links that anyone could share by hand, checks them against the chain, and passes acceptances back to the maker. If it disappears, every swap in progress can still finish by sharing links directly.

1. What others do (research)

  • SideSwap (Liquid) publishes swap orders on a server and runs them as a three-step PSET exchange: the maker's wallet signs only after it has checked the taker's half. Makers stay online and their wallet signs automatically, which is why fills look instant (SideSwap protocol).
  • LiquiDEX gets down to two steps: the maker signs its input and output with SIGHASH_SINGLE|ANYONECANPAY, so a taker can complete the swap without the maker being online (Blockstream: LiquiDEX, docs).
  • Bisq keeps its offer book on a peer-to-peer network. An offer stays listed only while the maker's app is online, and the maker locks a security deposit, because fiat legs cannot be enforced on chain (Bisq FAQ, whitepaper). Bisq Easy drops the buyer's deposit and relies on seller reputation instead.
  • Nostr NIP-69 (Mostro, p2psats) publishes peer-to-peer orders as signed, replaceable events that any relay can carry, with an expiry tag and an explicit status (NIP-69, p2psats, Mostro).
  • OpenSea/Seaport orders are signed off chain. Cancelling one off chain only hides it from the API, so a firm cancel needs an on-chain transaction (OpenSea cancel).
  • 0x relays push order changes (added, filled, cancelled, expired) over a websocket, and a relay has to watch the chain to drop orders that can no longer fill (0x websocket API, openrelay #35).

What we take from them, and where we do better:

ChoiceOursWhy
Steps3 (maker signs last), like SideSwapEach Arrekta signature covers the whole transaction (§6.4), so there is no `SINGLE
Instant fillsThe wallet completes automatically while unlocked (opt-in), like SideSwapMakers who want this get the same experience; makers who don't still review each fill by hand.
WithdrawingFirm, with no transaction: unsigned offers carry no signatures, so a withdrawn offer cannot be filled from a copySeaport needs gas for a firm cancel; here the maker simply never signs.
Stale offersThe board watches the chain: expired (valid_until), coins spent elsewhere (gone), filledSame job as a 0x relay. Expiry is enforced by consensus, not only by the board.
DepositsNoneBoth legs are on chain in one transaction, so neither side can run off with the other's coins (Bisq needs deposits only for fiat).
SpamEach coin backs at most one open offer, at most 50 open offers per maker, read rate limit at the gatewayListing costs real coins, which stay reserved until the offer closes.

2. Offers

POST /market/v1/offers {offer} lists a arrektaswap: offer. The board accepts it when:

  • it decodes (swap-* codes) and expires within 30 days (offer-too-long);
  • the transaction is version 2 and valid_until is above the tip (offer-no-expiry);
  • it has at most 50 inputs and every input is an unspent coin on chain (coin-spent);
  • no input is already behind another open offer (input-in-offer);
  • the maker (the address of the first input) has fewer than 50 open offers (too-many-offers);
  • both assets exist (unknown-asset).

Posting the same offer again returns the existing listing (200). A new listing returns 201.

Id. hex(TH("market-offer", serialized offer PST)), computed by marketOfferId / market_offer_id. Anyone can work it out from the link.

Pair and price. ARRK is always the quote asset; between two other coins the asset id that sorts first is the quote. side: "sell" means the maker gives the base asset. Price = quote per base, in display units.

States.

StateMeaning
openlisted and fillable
fillinga swap transaction spending its coins is in the mempool
filledthat transaction is in a block (a trade is recorded)
cancelledwithdrawn by the maker
expiredpast expires or valid_until
goneits coins were spent by something else

Withdrawing. POST /market/v1/offers/{id}/cancel {public_key, signature} carries a signed message (PROTOCOL.md §3.5), origin arrekta-market, text Withdraw offer {id} from the {network} market, signed by the key of any of the offer's inputs (signMarketCancel / sign_market_cancel). Wallets also stop reserving the coins.

3. Taking an offer

  1. The taker finds the offer (GET /market/v1/offers?base=&quote=), builds its half and signs it exactly as for a shared link (prepareMarketTake in wallet-core).
  2. POST /market/v1/offers/{id}/acceptances {acceptance} passes it to the maker. The board checks that the terms match the offer, that the maker's inputs and outputs are untouched (swapAcceptanceMatches), that every taker input is signed and every signature verifies (swap-unsigned, bad-signature), that the fee is positive, and that the taker's coins are unspent. At most 10 acceptances can wait on one offer (too-many-acceptances); the acceptance id is the transaction id.
  3. The maker's wallet collects acceptances (GET …/acceptances), checks them again itself, signs and broadcasts. With auto-complete on, syncBoard does this every 20 seconds while the wallet is unlocked. The first acceptance to confirm fills the offer; the others become stale.

The board's checks protect the maker's time, not the maker's coins: the maker's wallet always re-checks before signing, so a compromised board can at worst hide offers or stall them.

4. API

Anonymous through the gateway (admitted on the read rate budget); the gateway forwards to the service with its service token. Bodies are at most 512 KiB.

MethodPathReturns
GET/market/v1/pairs{pairs: [{pair, base, quote, open_offers, best_bid, best_ask, last_price, trades_24h, volume_24h}]}
GET/market/v1/offers?base&quote&side&maker&state&limit{offers: [offer]} (open offers by default, best price first)
GET/market/v1/offers/{id}offer
POST/market/v1/offersoffer (201 new, 200 already listed)
POST/market/v1/offers/{id}/cancel{id, state: "cancelled"}
GET/market/v1/offers/{id}/acceptances{acceptances: [{txid, acceptance, created_at}]}
POST/market/v1/offers/{id}/acceptances{txid, offer_id, state: "pending"}
GET/market/v1/trades?base&quote&limit{trades: [{txid, offer_id, base, quote, side, base_amount, quote_amount, price, height, at}]}

An offer is \{id, offer, pair, base, quote, side, base_amount, quote_amount, price, maker, memo, expires, valid_until, state, txid, created_at, acceptances\}, where base/quote are {asset, ticker, decimals} and amounts are base-unit strings.

SDKTypeScriptPython
readlistMarketPairs, listMarketOffers, getMarketOffer, listMarketAcceptances, listMarketTradeslist_market_pairs, list_market_offers, get_market_offer, list_market_acceptances, list_market_trades
writepostMarketOffer, cancelMarketOffer, postMarketAcceptancepost_market_offer, cancel_market_offer, post_market_acceptance
helpersmarketOfferId, marketCancelMessage, signMarketCancelmarket_offer_id, market_cancel_message, sign_market_cancel

Shared fixture: the market block of sdk/fixtures/swap_offers.json.

5. Wallets

Call (@arrekta/wallet-core)Does
createSwapOffer({…, post, autoComplete})creates the offer and lists it
postSwapOffer(id, {autoComplete})lists an existing offer
marketPairs(), marketBook({base, quote}), marketTrades(…)read the board (mine marks your own offers)
prepareMarketTake(offerId) → confirmSwapTake(id)take an offer; the acceptance goes to the maker through the board
boardAcceptances(swapId)acceptances waiting on your offer
syncBoard()counts acceptances, auto-completes, marks taken swaps done (also inside watch())
cancelSwap(id)releases the coins and withdraws the listing

The board URL is the wallet's gateway (the same setting as the issuer service). Errors: market-unavailable.

Apps: the Swap screen opens on Instant (dealer pools, POOLS.md); Market (pairs → order book → Take → review and sign). New offer lists by default and can complete automatically. My swaps shows board status, "N waiting" with Complete, and "List on the board". The explorer's Market page is read-only and sends takers to their wallet.

6. Operating it

  • Service: arrekta-market, port 3008, its own Postgres database (MARKET_DB_*), started by all_svc/arrekta-compose.sh up platform. The gateway reaches it at ARREKTA_GATEWAY_MARKET_URL.
  • It reads the chain through the node with ARREKTA_INTERNAL_TOKEN (skips the per-IP limit) and checks the board every 10 seconds.
  • End-to-end check on a devnet: node all_svc/market_check.mjs.

7. Next

  • Pools: instant swaps against liquidity instead of a waiting maker: built as dealer pools, POOLS.md.
  • Exchange listings for ARRK, so the board's prices can be shown in dollars.
  • Two-step offers: a SINGLE|ANYONECANPAY-style sighash (a consensus change, PROTOCOL.md and both implementations) would let takers fill without the maker online, as LiquiDEX does.
  • Push updates over a websocket (as 0x does) instead of polling.