Turns the single-operator maker into an open network: anyone can run a maker, register it with the relay, be discovered on a dashboard, and takers can swap through any of them via the same UI. "Be your own maker."
The swap is non-custodial and atomic, so a hostile maker can't steal your principal. The worst it
can do is stall, after which you refund (HTLC) or reclaim (adaptor). The taker verifies the maker's
on-chain lock against its own independent explorer (mempool.space for tBTC), enforces T1 > T2 with
the fixed upper-bound caps, and fails closed on any contract mismatch. So the only residual risk from
a bad maker is griefing (wasted time, then a refund), not theft. That leaves the network layer a
narrow job: prevent impersonation, roster poisoning, cross-maker message injection, and DoS. None of
those can cost a taker coins, but each degrades the experience.
- Makers are UNTRUSTED (any of them may be malicious). Relay holds no funds/keys (unchanged).
- Adversary goals to deny: (a) register as another maker's identity (steal its reputation); (b) inject messages into a swap between a taker and a different maker; (c) flood the roster with fake makers; (d) exhaust relay resources; (e) replay a captured registration.
A maker derives a stable ed25519 identity keypair from its seed (label relay-identity).
maker_id = hex(ed25519 pubkey) (64 hex). Ownership is proved by signing a relay challenge, so
no central token list is needed and no one can register under someone else's id. The relay
verifies ed25519 natively via node:crypto (no swap-core dependency).
maker → relay : WS connect ?role=maker
relay → maker : { type: '_relay_challenge', relay_id, nonce: <32-byte hex>, expiry, v:1 } # per-connection
maker → relay : { type: 'maker_register', maker_id, sig, info }
# sig = ed25519_sign(priv, "testnetswap-relay-maker|v1|"+relay_id+"|"+nonce+"|"+expiry)
# domain-separated + RELAY-BOUND (relay_id) + TIME-BOUND (expiry): defeats cross-relay replay
relay → maker : { type: '_relay_hello', role: 'maker', maker_id } # on success
| { type: 'error', reason } + close # on failure
Relay verifies: valid ed25519 sig over the exact challenge string with maker_id as the key, and
expiry not in the past. Replay is impossible: the nonce is fresh per connection (a sig for nonce
N can't satisfy nonce N'), and relay_id in the signed string stops a sig captured at one relay from
being replayed at another.
- Permissioned by default (
cfg.openRegistration: false): only ids incfg.allowedMakers: [maker_id,...]may register. An empty allowlist in permissioned mode means no maker can register (a deliberate fail-closed default); setopenRegistration: trueto let any valid ed25519 identity in. - Reconnect / takeover: if
maker_idalready has an OPEN socket, reject the new one ("maker already connected"); if the existing socket is closed/stale, replace it.
maker → relay : { type: 'maker_announce', info } # every ~20s; updates roster info + last_seen
info mirrors the maker's own /api/status: { name?, pairs:[{from,to,rate,min_sats,max_sats, liquidity_free_sats,liquidity_unit}], xmr:{enabled,networks,tickers,...}, stats:{completed, refunded, failed, success_rate}, version }. Everything in info is self-reported (a maker can lie about rates,
liquidity, or stats), so the relay treats it as advertising, not truth. The site labels it as such.
taker → relay : WS connect ?role=taker&maker=<maker_id>
relay → taker : { type: '_relay_hello', role:'taker', sid, maker_online }
taker → relay : <swap-core msg> → relay → maker(maker_id) : { sid, msg }
maker → relay : { sid, msg } → relay → taker(sid) : msg
- Relay binds
sid → { ws, makerId }. A taker session is bound to ONE maker for its lifetime. - Cross-maker injection guard: when a maker sends
{sid,msg}, the relay drops it unlesstakers.get(sid).makerId === thisMakerId. So maker A can never reach maker B's takers. ?makerabsent ⇒ route tocfg.defaultMaker(the operator's id) if set, which preserves the current single-maker clients; otherwise{ type:'error', reason:'no maker selected' }.- Maker offline ⇒
{ type:'error', reason:'maker offline' }(unchanged behavior).
The relay runs a tiny HTTP endpoint (same server as the WS upgrade), CORS-open, read-only:
GET /roster → { ok:true, makers:[ { maker_id, info, connected:true, connected_since,
last_seen, default, vouched } ],
default_maker, generated_at }
GET /health → { ok:true, makers: <n>, takers: <n> }
connected_since / last_seen are relay-observed (not fakeable), the trustworthy uptime
signal; info.stats is self-reported. default marks the operator's fallback maker; vouched
is an operator-set trust label (null unless the operator listed this maker_id in the relay's
vouched config); config-only, a maker can never self-claim it. The site fetches /roster,
renders the dashboard, and seeds the swap widget's maker selector.
Show, per maker: relay-observed uptime (connected_since, not fakeable), advertised rate/liquidity (verified by the taker at quote and before funding, never trusted from the roster), and self-reported swap outcomes as NEUTRAL counts (completed, safely refunded, unfinished), clearly labeled self-reported. A raw completion percentage is labeled "completion rate", NOT a maker success or trust score: most non-completions are taker-side (never funded, closed the tab) or chain timing, and a refund is a safe outcome (the recovery path working), not a maker failure. No scoring/ranking beyond sorting by uptime + liquidity. Anti-Sybil is deferred: on testnet the coins are worthless, so a spam maker only wastes its own time; the caps below bound resource abuse.
A completion rate blends faults the maker cannot control with the few it can, so it must never auto-punish. A real reliability signal measures ONLY cryptographically-attributable maker faults: the taker did its part on time and the maker did not. Classify each terminal swap by on-chain evidence against the protocol deadlines:
taker never funded T1 -> taker-side (not attributable)
taker funded on time, maker never locked a valid T2 -> MAKER FAULT
both locked, taker never claimed -> taker-side (not attributable)
either side refunded after its timelock -> safe outcome (not a fault)
invalid / mismatched maker contract -> MAKER FAULT
relay / chain / RPC error -> environment (not a fault)
Instrumentation: extend the swap state machine to record, per terminal swap, taker_funded_ontime
and maker_locked_valid_before_deadline (both booleans, both cryptographically observable). The
signal is their ratio, shown as EVIDENCE and never an automatic penalty, e.g. "18 of 19 eligible
funded swaps received a valid counter-lock" ("eligible" = the taker funded correctly and on time,
so the maker was obligated to act). Deferred on purpose: it needs a corpus of swaps and a real
multi-maker network to mean anything, and it touches the fund-critical path.
maxMakerscap on concurrent registered makers (e.g. 64); reject beyond it.- Per-IP registration rate limit + the existing
maxPerIpon taker connections. - Prune makers with no
maker_announcewithinmakerStaleMs(e.g. 90s) → drop from roster. - Registration must complete within a short window (e.g. 10s) after the challenge, else close.
- Per-connection message flood cap already applies (makers get a higher cap, as today).
maxPayloadBytesunchanged.
- The operator's maker migrates to the identity model (derives its keypair, registers, announces)
and is advertised as
cfg.defaultMaker, so takers with no?makerstill reach it. - The legacy
makerTokenbecomes optional: if set, it's an additional gate for the operator's own maker; open registration uses the signature scheme.
One path could otherwise break the "griefing-only" claim, alongside several DoS and secure-default gaps. These requirements close them; the implementation MUST honor all of them.
- XSS trust boundary (critical). Roster
infocomes from untrusted strangers and is rendered into the SAME origin that stores takers' spend keys (localStoragetestnetswap.swaps.v1,testnetswap_xmr_recovery). Two mandatory controls, defense-in-depth on top of the existing CSP:- Relay input validation: every
infofield is schema-checked before storing.maker_idmust match^[0-9a-f]{64}$.name≤ 40 chars, restricted charset[\w .\-](no<>&"'/).pairsa fixed numeric/enum schema (from/to are tickers; sats are non-neg integers). Anything malformed/oversized is rejected or dropped, never stored. - Site output encoding: the dashboard + selector render EVERY roster field with
textContentonly. Thehtml:/innerHTMLhelper is forbidden for any roster-derived value.
- Relay input validation: every
- Secure default: open registration is OPT-IN.
cfg.openRegistrationdefaults to false (permissioned: onlycfg.allowedMakersmay register; with the operator's maker allowlisted). An upgrade must NOT silently turn a closed relay into an open network. The operator setsopenRegistration: truedeliberately. - Signature binding + freshness. The signed string is domain-separated AND relay-bound and
time-bound:
"testnetswap-relay-maker|v1|" + relayId + "|" + nonce + "|" + expiryUnix, whererelayId=cfg.relayId(e.g. the relay's public hostname) and the relay REJECTS if now > expiry (short, e.g. 30s). The relay verifies against the nonce IT issued and stored on that socket (never a client-supplied nonce). This defeats cross-relay / relay-in-the-middle signature relay. WSS/TLS is REQUIRED: the signature authenticates only the handshake; on plaintextws://an on-path attacker could hijack the post-auth socket. Document TLS as mandatory for any real deployment. - Announce authentication.
maker_announceis attributed to the authenticated socket (ws._makerIdset at register), NOT to amaker_idin the payload, so a socket can only update its own roster entry. - DoS caps. Per-IP cap on maker connections (pending + registered), same
maxPerIpfamily as takers. A challenged socket must send a validmaker_registerwithinregisterTimeoutMs(~10s) or be closed. Cap concurrent PENDING (challenged-but-unregistered) sockets globally.maxMakersstays but is protected by the per-IP cap so one IP can't fill it. Prune makers with no announce withinmakerStaleMs(~90s): free the slot AND close the socket. - /roster endpoint hardening. Per-IP rate limit, a cached response (regenerated on change or
every few seconds, not per-request), and a bounded size (maxMakers × the validated-small info).
CORS-open +
Cache-Control. It exposes only makers' self-reported info + relay-observed uptime, never taker sids/IPs. - Dashboard trust presentation. Sort by relay-observed uptime (
connected_since), never by self-reported liquidity/stats (gameable). Pin the operator's maker (defaultMaker) first and badge it. Always show the (unforgeable)maker_idnext to the (decorative, unverified)nameso a homograph/display-name spoof can't impersonate a reputable maker. Label allinfo-derived numbers "self-reported." - CSP. The site fetches
https://<relay-host>/roster, a new connect target; add the relay's https origin to/swap.html(and the dashboard page)connect-src(today only thewss://origin is listed). - Default routing.
?makerabsent ⇒ route todefaultMakerif it's currently connected; else{ type:'error', reason:'maker offline' }. The site always sends?makerfrom the roster; the default is only a legacy fallback. - XMR griefing bound (untrusted maker). The taker MUST bound the maker-supplied
t1_blocks/t2_blocksin the XMR quote before locking any tXMR (reject absurd values that would lock funds for an unreasonable time), the adaptor-swap analog of the HTLCT1cap.
- Reputation is relay-level, not swap-counterparty-bound.
maker_idauthenticates to the RELAY (for routing + the roster); it is NOT tied to the maker's on-chain swap keys. So a maker's reputation is "relay-attested identity + self-reported stats," and the swap's SAFETY never depends on it (the taker independently verifies every on-chain lock regardless of which maker). Bindingmaker_idto the maker's swap pubkeys is a v2 enhancement. - tLTC verification still uses the taker's configured (operator-run testnetscan) explorer, the accepted testnet tradeoff; tBTC uses independent mempool.space. Unchanged by multi-maker.
- Mid-swap taker reconnection (resume by sid) is unchanged/limited; keep the tab open.
Anyone can run a maker and join the network. The swap stays non-custodial regardless of who runs the maker; a maker can only decline or stall a swap, never take a taker's coins (the taker verifies every on-chain lock in their own browser). Steps:
- Get the daemon. Clone the repo,
cd swap-maker && npm ci, thennode src/main.js seedto generate an HD seed (back it up; pool keys AND your maker identity both derive from it). - Configure + fund.
cp config.example.json config.json, setapis/rate/limits/timelocks andrelay_url(the network you're joining, e.g.wss://relay.testnetswap.com/). Runnode src/main.js fundto claim CypherFaucet coins into the pool addresses. - Print your identity.
node src/main.js maker-idprints yourmaker_id, the hex of an ed25519 public key derived deterministically from your seed. This is your unforgeable handle on the network (no one else can register it without your seed). - Get listed. Send your
maker_idto the relay operator; they add it toallowedMakers(permissioned relays), or you connect directly if the relay runsopenRegistration: true. Norelay_tokenis needed to join; identity is proven by the ed25519 challenge. (relay_tokenonly matters if you ARE the relay operator gating your owndefaultMaker.) - Run it.
MAKER_SEED=<hex> node src/main.js -c config.json. The daemon connects, signs the relay's challenge, registers, and announces its liquidity every ~20s. You now appear on/network.htmland takers can route swaps to you via?maker=<your-id>.
Run your OWN network instead of joining one: deploy swap-relay (see DEPLOY.md),
set relayId to your relay's hostname, add your maker to allowedMakers (or set
openRegistration: true), and point your site's TESTNETSWAP_RELAY/roster at it. Everything above
is self-hostable and AGPL-3.0.
Client-side quote aggregation (fan out to N makers, pick best); on-chain-verified reputation (binding maker_id to swap keys); federated/gossip discovery (removing the central roster); maker staking/bonding.