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:
| Choice | Ours | Why |
|---|---|---|
| Steps | 3 (maker signs last), like SideSwap | Each Arrekta signature covers the whole transaction (§6.4), so there is no `SINGLE |
| Instant fills | The wallet completes automatically while unlocked (opt-in), like SideSwap | Makers who want this get the same experience; makers who don't still review each fill by hand. |
| Withdrawing | Firm, with no transaction: unsigned offers carry no signatures, so a withdrawn offer cannot be filled from a copy | Seaport needs gas for a firm cancel; here the maker simply never signs. |
| Stale offers | The board watches the chain: expired (valid_until), coins spent elsewhere (gone), filled | Same job as a 0x relay. Expiry is enforced by consensus, not only by the board. |
| Deposits | None | Both legs are on chain in one transaction, so neither side can run off with the other's coins (Bisq needs deposits only for fiat). |
| Spam | Each coin backs at most one open offer, at most 50 open offers per maker, read rate limit at the gateway | Listing 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_untilis 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.
| State | Meaning |
|---|---|
open | listed and fillable |
filling | a swap transaction spending its coins is in the mempool |
filled | that transaction is in a block (a trade is recorded) |
cancelled | withdrawn by the maker |
expired | past expires or valid_until |
gone | its 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
- The taker finds the offer (
GET /market/v1/offers?base="e=), builds its half and signs it exactly as for a shared link (prepareMarketTakein wallet-core). 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.- The maker's wallet collects acceptances (
GET …/acceptances), checks them again itself, signs and broadcasts. With auto-complete on,syncBoarddoes this every 20 seconds while the wallet is unlocked. The first acceptance to confirm fills the offer; the others becomestale.
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.
| Method | Path | Returns |
|---|---|---|
| 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"e&side&maker&state&limit | {offers: [offer]} (open offers by default, best price first) |
| GET | /market/v1/offers/{id} | offer |
| POST | /market/v1/offers | offer (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"e&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.
| SDK | TypeScript | Python |
|---|---|---|
| read | listMarketPairs, listMarketOffers, getMarketOffer, listMarketAcceptances, listMarketTrades | list_market_pairs, list_market_offers, get_market_offer, list_market_acceptances, list_market_trades |
| write | postMarketOffer, cancelMarketOffer, postMarketAcceptance | post_market_offer, cancel_market_offer, post_market_acceptance |
| helpers | marketOfferId, marketCancelMessage, signMarketCancel | market_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 byall_svc/arrekta-compose.sh up platform. The gateway reaches it atARREKTA_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.