Peer-to-Peer Atomic Swaps
| MIP | 0006 |
|---|---|
| Status | Proposed |
| Category | Standards |
| Authors | Andrew Fleming @andrew-fleming, Edward Alvarado |
| Created | 2026-02-12 |
Abstract
This proposal specifies a scaffolding for peer-to-peer atomic swaps on Midnight, inspired by Dexie.space on Chia. It leverages Midnight’s native transaction merging to enable trustless atomic swaps without a custom smart contract. Makers create partial (imbalanced) transactions offering tokens; takers complete and submit them; the ledger handles all settlement logic. This standard defines what is published to a shared data-availability (DA) layer (recommended: Celestia, under a common namespace) — the raw MIP-0005 offer bytes, with no envelope — what indexers serve for discovery, and a reference indexer API.
Publishing to a shared DA namespace lets dApps share liquidity: every UI, indexer, and bot reads the same offers, so an offer made in one dApp is takeable in any other.
Motivation
Midnight lacks a standardized mechanism for peer-to-peer token exchange. A native P2P swap standard provides:
- Trustless exchange — atomic swaps guarantee both parties receive their tokens or neither does. No counterparty risk.
- No smart-contract overhead — swaps execute at the protocol level via native transaction merging, with no custom contract deployment or execution cost.
- Interoperability — a standardized offer format lets multiple UIs, indexers, and tools share one liquidity pool.
- Privacy preservation — on-chain activity stays private for shielded tokens (only revealing the token deltas, and nullifiers), for unshielded tokens the UTXOs are public.
Specification
Overview
The swap mechanism leverages Midnight’s native support for imbalanced transactions. A maker creates a partial transaction containing their spend authorization and their expected payment outputs. A taker completes it by adding their own input and output, then merges the transactions.
MAKER'S PARTIAL TX (imbalanced):
Inputs: 100 TokenA
Outputs: 50 NIGHT to maker
TAKER'S PARTIAL TX (imbalanced):
Inputs: 50 NIGHT
Outputs: 100 TokenA to taker
MERGED TX (balanced):
Inputs: 100 TokenA (maker), 50 NIGHT (taker)
Outputs: 50 NIGHT to maker, 100 TokenA to taker
The merged transaction balances because each party’s input funds the other’s output.
Both parties’ expected receipts are committed inside their own partial transactions,
so neither can be shortchanged.
The offer is carried by the single Transaction payload defined in MIP-0005,
which expresses shielded and unshielded swaps.
Relationship to Offer Files (MIP-0005)
MIP-0005 defines the offer itself:
a proven, imbalanced Transaction, its dual representation —
raw canonical bytes for storage, a bech32m string (swapoffer1…) for
interchange and display — and the rule that content addressing hashes the
bytes. This MIP maps that dual representation onto publication and
discovery:
- On-chain (the DA blob) carries the MIP-0005 offer as raw bytes — nothing else. No envelope, no version byte, no metadata.
- Off-chain (the indexer API) carries the offer as its bech32m string plus derived discovery metadata (gives/wants, expiry) and indexer-observed fields.
Bytes, not bech32, on the DA layer. bech32m is a display encoding (MIP-0005). Publishing the bech32m string to a DA layer wastes ~1.6× the bytes for no benefit, exactly as nobody submits a bech32 address on-chain. Publish the raw offer bytes; render bech32m only for humans.
On-chain representation (published to the DA layer)
The blob content is exactly the canonical MIP-0005 payload: the raw bytes
produced by serializing the proven Transaction. There is no wrapper. The
bech32m swapoffer1… string is an encoding of these bytes, not their
source — a publisher holding only the string first decodes it, which by
MIP-0005’s losslessness recovers exactly the serialized transaction, and
publishes those bytes.
With no wrapper, the on-chain form inherits MIP-0005’s properties directly:
the blob is the offer, decode(blob) is the transaction, and the offer’s
content address is the hash of the blob itself.
The blob deliberately carries no gives/wants (derivable from the offer —
see below), no timestamps (not knowable from the offer),
and no public key, signature, or message.
Publishing to the data-availability layer
Makers publish the raw offer bytes as a blob to a shared data-availability (DA) layer. Publishing to a common, permissionless location — rather than to a single dApp’s private backend — is what lets dApps share liquidity: any UI, indexer, or bot reads the same stream of offers, so no one service owns the order flow and an offer created in one dApp is takeable in another.
This standard is DA-agnostic: any blob store with a shared, readable keyspace qualifies, and reimplementations MAY choose another. In practice, implementations are converging on Celestia to keep liquidity unified, and this document recommends it. Offers are posted as Celestia blobs under a single well-known namespace shared by all compliant implementations:
| Parameter | Value |
|---|---|
| DA layer | Celestia (recommended) |
| Namespace | version 0, 10-byte id suffix mn-swap-v1 (see below) |
| Blob contents | the raw canonical MIP-0005 Transaction bytes — no envelope |
- Raw bytes, not bech32 — the DA blob carries the MIP-0005 offer as bytes (bech32m is a display encoding only; see MIP-0005).
- One shared namespace = one shared liquidity pool.
All participants MUST agree on the namespace;
per-dApp namespaces would re-silo liquidity, defeating the purpose.
Celestia namespaces are 29 bytes — a version byte plus a 28-byte id —
and for version-0 (user) namespaces the first 18 id bytes MUST be zero,
leaving 10 freely chosen bytes.
The shared namespace is version
0x00with the 10-byte id suffix0x6d6e2d737761702d7631(ASCIImn-swap-v1); the full 29-byte namespace is0x00, followed by 18 zero bytes, followed by6d6e2d737761702d7631. - Permissionless read/write — any maker posts;
any indexer subscribes to the namespace, validates each offer
(deserialize, derive
gives/wants, enforce the two-sided rule, check liveness), and serves theOffchainOfferPayloadfor discovery (see Reference Indexer API). Celestia is recommended for its neutrality, low cost, and permissionless access. - Publication cost — posting a blob is paid by the poster (the maker) by default; a dApp MAY subsidize or relay publication on the maker’s behalf. Keeping offers minimal (MIP-0005) keeps this cost proportionally low.
- Minimal coordination — the namespace is the only value that must be standardized; everything else is implementation choice.
The DA layer is a transport and broadcast medium, not a settlement or ordering authority: it makes offers discoverable, but settlement still happens on Midnight (see Overview) and offer validity is always re-checked against Midnight state.
Off-chain Offer Payload (served by indexers)
type TokenKind = 'SHIELDED' | 'UNSHIELDED';
interface TokenLeg { token: string; amount: string; type: TokenKind; } // hex RawTokenType, stringified bigint
interface OffchainOfferPayload {
version: 1;
// The offer's content address: lowercase hex SHA-256 of the canonical raw
// Transaction bytes (see MIP-0005, "Content address"). Stable across every
// indexer and equal to the hash of the offer's DA blob.
offerId?: string;
// MIP-0005 bech32m rendering of the same bytes, for display/interchange.
offerBech32?: string;
computed: { // everything the indexer derives / observes
gives: TokenLeg[]; // DERIVED from the offer's imbalances
wants: TokenLeg[]; // DERIVED from the offer's imbalances
expiresAt?: string; // ISO 8601, from the earliest intent TTL, when present
inputNullifiers: string[]; // shielded input nullifiers (hex) for liveness
firstSeenAt: string; // ISO 8601, when the indexer first saw the offer
status?: 'live' | 'consumed' | 'cancelled' | 'expired';
};
}
Example:
{
"version": 1,
"offerBech32": "swapoffer1…",
"computed": {
"gives": [{ "token": "4a8f…", "amount": "100", "type": "SHIELDED" }],
"wants": [{ "token": "0200…", "amount": "50", "type": "SHIELDED" }],
"expiresAt": "2026-06-30T12:00:00Z",
"inputNullifiers": ["7c1d9b…"],
"firstSeenAt": "2026-06-16T09:30:00Z",
"status": "live"
}
}
Presence rules for offerId / offerBech32: at least one MUST be
present. In multi-offer (list) responses an indexer SHOULD serve offerId
and MAY omit offerBech32 — a real offer’s bech32m string is 16–25 KB, so a
100-row page carrying strings is megabytes of redundant payload when each
offer is individually retrievable. In single-offer responses offerBech32
MUST be present. Anyone holding the string derives the id locally
(sha256(decode(offerBech32))), so omitting one form never loses
information.
Everything under computed is produced by the indexer —
either derived from the offer bytes (gives, wants, expiresAt, inputNullifiers)
or observed by the indexer (firstSeenAt, status).
None of it is trusted from the maker.
Only offerBech32 carries over from what was published — and it is merely the
bech32m rendering of the on-chain bytes themselves (MIP-0005 dual
representation), so the API field and the DA blob can never disagree.
Field derivation
| Field | Source |
|---|---|
offerBech32 |
MIP-0005 bech32m encoding of the published offer bytes |
computed.gives / computed.wants |
Net imbalances of the offer, tagged SHIELDED/UNSHIELDED (authoritative) |
computed.expiresAt |
The earliest intent TTL (when the offer carries an intent), or the time the offer’s proof roots leave the node’s root-recency window |
computed.inputNullifiers |
Nullifiers of the offer’s shielded inputs — the keys an indexer watches to mark an offer consumed |
computed.firstSeenAt |
The indexer’s clock when the offer was first seen on the DA layer |
computed.status |
OPTIONAL indexer bookkeeping — observed, not consensus data: live until a backing input is spent or the offer expires (expired; see Offer lifetime). A spend is consumed; an indexer MAY refine it to cancelled where its evidence supports that (see Fill vs cancel) |
Deriving gives / wants
gives and wants are computed from the offer, not supplied by the maker.
For each token type, compute the net imbalance delta = (inputs) − (outputs):
- Shielded: use
ZswapOffer.deltas(which is alreadyinputs − outputsper token), summed across guaranteed and fallible offers. - Unshielded: sum
UtxoSpend.value(inputs) minusUtxoOutput.value(outputs) per token, across all intents’ guaranteed and fallible unshielded offers.
Then delta > 0 ⇒ the maker is a net spender ⇒ the token belongs in gives;
delta < 0 ⇒ net receiver ⇒ wants (with amount −delta).
Each leg is tagged with its value layer:
SHIELDED for deltas that come from a zswap offer,
UNSHIELDED for deltas summed from an intent’s unshielded inputs/outputs.
Because the terms are derived from the proven transaction, they are authoritative:
there is no separate maker-asserted gives/wants that could disagree with the offer.
Indexers MUST derive these fields; they MUST NOT accept them as trusted input.
Offer lifetime
Two independent ledger rules bound how long a published offer stays takeable. They are independent in every sense — different mechanisms, different enforcement points, separately configurable — despite both defaulting to one hour today:
- Intent TTL (unshielded). Each intent’s
ttlmust satisfyt_block ≤ ttl ≤ t_block + global_ttlat apply time, whereglobal_ttlis an on-chain ledger parameter. This is not optional for unshielded value: anUnshieldedOfferexists only as a field of anIntent, andIntent.ttlis non-optional — so an unshielded offer structurally always carries a TTL, and its effective inclusion window is[ttl − global_ttl, ttl]. - Merkle-root recency (shielded). A Zswap offer carries no TTL field at
all, and well-formedness never expires it — it dies only at apply time,
when an input’s Merkle root is no longer accepted. That root history is
time-filtered: the current root is re-inserted every block and entries
older than
t_block − windoware evicted. Consequently the clock runs from the last block whose tree state the offer proved against, not from when the offer was created — on a quiet chain segment the same root keeps refreshing and the offer stays takeable. Implementations deriving an expiry MUST use the root’s last-seen time; deriving it from first-seen expires live offers early. An offer also dies immediately if any input’s nullifier lands on-chain first.
Indexers SHOULD mark offers expired on whichever bound applies to the
offer’s value layers.
Offer Validation
Takers SHOULD verify that an indexer’s advertised gives/wants
match the transaction’s actual imbalances before completing a swap —
the proven transaction is authoritative.
To validate:
- Deserialize the offer (
Transaction) from the offer bytes / bech32m string. - Compute the net imbalances (see Deriving gives / wants).
- Verify they match the advertised
gives/wants.
Indexers MUST perform this derivation themselves and SHOULD reject or flag any offer whose imbalances cannot be computed.
An offer MUST be two-sided
A valid swap offer’s net imbalance MUST contain both at least one give (a token with positive net delta) and at least one want (a token with negative net delta). An offer whose imbalance has a give but no want is a give-only offer: completing it forfeits the maker’s tokens with no counterparty return — it is a giveaway, not a swap. Indexers MUST reject give-only offers, and takers MUST NOT settle one.
Segment placement (informative)
Compliant P2P swap offers carry all value in the guaranteed section,
so both legs are applied atomically in the guaranteed execution phase
(all-or-nothing; no value-level partial success).
Concretely, wallet-built swap offers place the shielded leg in guaranteed_coins (segment 0)
and the unshielded leg in an intent’s guaranteed_unshielded_offer.
The fallible_coins / fallible_unshielded_offer slots are not used for P2P swaps;
they exist for value legs bound to contingent contract execution, which is out of scope here.
Cancellation (informative)
There is no explicit cancellation method: once published, an offer stays takeable until it is consumed or expires. A maker can only invalidate it indirectly, by spending a backing input (or completing the swap themselves). This is not handled in this MIP.
Fee Handling
Either the platform or the taker pays transaction fees. The maker cannot know in advance how much gas the transaction will need, nor when it will happen, and pre-paying would require locking a DUST UTXO into the swap. Fee-splitting is out of scope.
Reference Indexer API
Compliant indexers MUST implement the following REST API,
and SHOULD additionally expose a streaming endpoint
(e.g. a WebSocket at /v1/offers/stream)
that pushes new and updated offers as they are observed on the DA layer.
The API is a text surface, so offers cross it in their bech32m form
(MIP-0005 dual representation): publication accepts the swapoffer1… string,
queries return offerBech32. The indexer decodes to the raw bytes, and those
bytes are what it publishes to — and matches against — the DA layer.
POST /v1/offers — publish
The request body carries the offer as its MIP-0005 bech32m string (the interchange form — this is a JSON API). The indexer decodes it, recovering the canonical transaction bytes, and those bytes are what go on-chain — so the string a maker submits and the blob the DA layer stores can never disagree:
{ "offer": "swapoffer1…" }
Response: { "offerId": "string" }
Validation:
- MUST decode per MIP-0005 (HRP, checksum) and deserialize the offer, then
derive
gives/wantsitself (there are no maker-asserted terms to trust). - MUST reject malformed payloads, undeserializable offers, and give-only offers.
- SHOULD reject duplicates (content-addressed: hash of the raw offer bytes).
GET /v1/offers — query
Indexers SHOULD support filtering by token type (in either gives or wants) and pagination.
Exact query semantics are left to implementations.
Response: { "offers": [ /* OffchainOfferPayload, offerBech32 omittable */ ], "nextCursor": "string" | null }
Pagination is cursor-based: pass the previous response’s nextCursor back as
a query parameter to continue; null means exhausted. There is deliberately
no total field — an exact count is O(table) on every page and immediately
stale on a permissionless namespace, inviting implementations to serve wrong
numbers cheaply or right numbers expensively.
GET /v1/health — health check
Versioning
The on-chain blob needs no version field of its own: it is the raw offer bytes, already versioned by the ledger’s tagged transaction serialization (see MIP-0005 Versioning) — incompatible bytes fail to deserialize rather than being silently misread.
The off-chain OffchainOfferPayload carries an explicit version field
(this revision: 1). Incompatible changes to its schema increment the version
and are specified by a superseding MIP per MIP-0001.
Rationale
On-chain / off-chain split
The previous single OfferPayload conflated two concerns:
what is published and what is discovered.
Publishing wants only the offer bytes
(everything else is derivable, unknowable, or privacy-harming) —
taken to its conclusion in this revision, where the on-chain form IS the
offer bytes, with no envelope at all: a wrapper would need its own canonical
byte encoding for implementations to interoperate, and every field it might
carry is either derivable from the offer or untrustworthy.
Discovery wants the derived, human-useful fields.
Separating them keeps the DA payload minimal (cheaper, more private)
and lets indexers enrich freely.
This mirrors the address model exactly: bytes on the wire, bech32m for humans
(MIP-0005 dual representation).
Protocol-level settlement
Midnight natively supports transaction merging with homomorphic commitment balance checks. The ledger already verifies that inputs carry valid proofs/signatures, that commitments balance per token type, and that no double-spends occur. This provides atomic-swap semantics with no custom logic. A smart contract would add deployment complexity and cost without benefit.
Metadata is derived or observed, never trusted
gives/wants are computed from the proven transaction
and tagged with their value layer (SHIELDED/UNSHIELDED),
so they cannot disagree with reality.
expiresAt comes from the intent TTL, when present.
inputNullifiers are read from the offer’s shielded inputs
and are what an indexer watches to flip status to consumed.
Fill vs cancel (the OPTIONAL cancelled status)
A spent backing input ends an offer, but it does not say how: the offer may
have settled (a taker merged and submitted it — a fill), or the maker may
simply have spent the coins elsewhere (a cancel; there is no on-chain cancel
operation, walking away is the cancel). The cancelled status value lets an
indexer report the second case. It is OPTIONAL and explicitly
best-effort — the classification is not fully deterministic from the data
this MIP requires an indexer to hold, so two conforming indexers may disagree
(consumed vs cancelled) on the same offer. Consumers MUST treat
cancelled as a refinement of consumed, never as a distinct lifecycle
state to depend on: either way the offer is gone and cannot settle.
What an indexer CAN conclude, and from what evidence:
- Settlement is atomic. A fill consumes all of an offer’s inputs in one ledger transaction. Therefore an offer whose inputs were spent across different transactions, or only partially (some inputs spent, others still live while the rest are gone), was cancelled — with certainty. This requires the indexer to record, per spent input, the hash of the spending transaction.
- The converse is a heuristic. All-inputs-in-one-transaction is
necessary for a fill but not sufficient: for a single-input offer any
spend trivially satisfies it, and a maker consolidating their own coins
spends all inputs in one transaction too. An indexer relying on input
grouping alone SHOULD therefore report such offers as
consumed, notcancelled. - Exact classification is possible with output tracking. The offer’s
transaction fixes the maker’s output commitments (the coins paying the
maker their
wants), and transaction merging preserves outputs — so a settling transaction contains, on-chain, exactly the offer’s output commitments alongside its input spends, while a cancel contains none of them. An indexer that additionally tracks created commitments per transaction can classify every offer deterministically, including single-input ones. Indexers doing so MAY reportcancelledwith certainty in both directions.
The same reasoning applies to unshielded inputs (UTXO spends grouped by
spending transaction; unshielded outputs are public, making the output check
strictly easier there).
firstSeenAt replaces the old createdAt:
an offer’s creation time is not knowable from the offer,
and any maker-asserted timestamp would be unverifiable.
The only meaningful, verifiable time is when an indexer first observed the offer.
Indexer as discovery layer
The shared DA namespace is the source of truth for which offers exist; an indexer is just a convenience query layer over it. Anyone can run one by subscribing to the namespace, so the order flow is not owned by any single party — that is what makes liquidity shared rather than siloed. A given indexer offering REST discovery is a privacy leak (it can observe IPs and browsing patterns) but does not affect security: the cryptographic guarantees hold regardless of indexer behavior, and a user can read the DA namespace directly, run their own indexer, or use Tor. There is intentionally no DELETE endpoint — cancellation is spending the backing input; indexers MAY monitor nullifiers/UTXOs to retire stale offers.
Path to Active
Acceptance Criteria
- The shared DA namespace value is finalized and recorded in this document.
- A reference indexer implementation subscribes to the namespace,
derives
gives/wants, enforces the two-sided rule, and tracks liveness via nullifiers/UTXO references. - At least two independent client implementations (UI, bot, or wallet integration) create and take offers through the shared namespace on a test network.
- The wallet SDK can build compliant swap offers.
Implementation Plan
- Core library — build offer payloads, derive
gives/wants, complete and merge swaps, cancel by spending backing inputs. - Reference indexer — REST API serving
OffchainOfferPayload, with gives/wants derivation and nullifier/UTXO monitoring. - Reference UI — browse, create, and complete swaps.
Dependencies: MIP-0005 codec, ledger transaction primitives, wallet API.
Backwards Compatibility Assessment
This proposal introduces a new standard and modifies no existing protocol.
Relative to the previous revision it changes the payload shape
(split into on-chain/off-chain, auth removed,
derived fields nested under computed with type-tagged legs and inputNullifiers,
createdAt → firstSeenAt).
Since neither revision was deployed, there is no persisted data to migrate.
Implementations adopting this standard interoperate with each other
but not with the earlier draft.
Security Considerations
Transaction-level guarantees
Swap security derives from the proven transaction, not from any wrapper. The maker’s partial transaction cryptographically commits to the inputs spent and the outputs received (token types, amounts, recipient). The taker cannot modify these; the ledger enforces balance on merge. Either both parties receive exactly what was committed, or the transaction fails.
Metadata integrity
gives/wants are derived from the transaction,
so a malicious indexer that misrepresents them is caught by any taker
that re-derives from the offer (which they SHOULD do before completing).
There is no maker-supplied metadata at all: everything a taker sees is either
derived from the proven transaction or explicitly indexer-observed.
Stale offers
Offers become invalid when a backing input is spent elsewhere, when an intent TTL passes, or when the shielded proofs’ Merkle roots age out of the ledger’s recency window (see Offer lifetime). Takers discovering stale offers fail at submission; indexers MAY monitor nullifiers/UTXOs and expiry bounds to retire them proactively.
Denial of service
Indexers MAY enforce a minimum offer value; the natural reference floor is the cost of publishing the blob to the DA layer — an offer worth less than its own publication cost is presumptively spam.
Future Work: Authenticated messages without key leakage
The valuable primitive the removed auth block failed to provide is:
a proof that a message accompanies an offer
and was authorized by the same secret that controls the offer’s UTXO,
without revealing a public key.
Note first that even an unauthenticated message has no home today: the
ledger Transaction provides no field for one, and modifying the serialized
bytes invalidates the offer’s proofs and signatures (MIP-0005, Future
Work: attached messages).
Testing
| Scenario | Expected Outcome |
|---|---|
| Happy-path swap | Both parties receive tokens |
| Advertised gives/wants ≠ derived | Indexer rejects / taker detects mismatch |
| Give-only offer published | Indexer rejects; taker refuses to settle |
| On-chain blob round-trip | Blob bytes ARE the offer: deserialize(blob) recreates the exact Transaction, and encode(blob) equals the maker’s swapoffer1… string |
| Off-chain payload derivation | gives/wants equal the transaction’s net imbalances |
| Cancelled offer | Take attempt fails gracefully |
| Expired offer (TTL or root recency) | Take attempt fails; indexer marks expired |
| Network failure during take | Transaction not submitted, tokens safe |
References
- Dexie.space — offer-based P2P trading on Chia
- Celestia — recommended data-availability layer
- Chia offer files
Copyright Waiver
All contributions (code and text) submitted in this MIP must be licensed under the Apache License, Version 2.0. Submission requires agreement to the Midnight Foundation Contributor License Agreement, which includes the assignment of copyright for your contributions to the Foundation.