NKIP-0001 · Draft
Verifiable Deposit Receipts
Abstract. Every swap order gets a receipt signed by the exchange before the deposit address is shown, and an issuer-signed commitment to it is stored by independent witnesses, so a dispute never depends on whether the user remembered to download a letter of guarantee.
| NKIP | 1 |
|---|---|
| Title | Verifiable Deposit Receipts |
| Status | Draft |
| Type | Standards Track |
| Created | 2026-10-26 |
| License | CC0-1.0 |
Files: manifest.schema.json · receipt.schema.json · vectors/v1.json
Contents
Motivation
A letter of guarantee (LoG) protects a user only if they download it, and most don't. Recent no-KYC swap disputes have come down to one side's word against the other's because nobody kept a receipt.
Goals:
- G1 Every order has a receipt the exchange signs and can't later deny.
- G2 The receipt is saved by someone other than the user, without them doing anything.
- G3 Anyone can check a disputed order without the exchange's cooperation.
- G4 "No receipt" counts against the exchange, not the user.
Non-goals:
- Proving delivery on the user's behalf. Once a receipt and the deposit are proven, the issuer has to prove the payout (txid, or tx key for XMR payouts).
- KYC or identity of either party.
- A central registry of orders. Witnesses hold hashes, not plaintext addresses.
Roles
| Role | Who | Job |
|---|---|---|
| Issuer | The exchange | Signs a receipt for every order and publishes its signing key |
| User | The customer | Deposits only after the deposit address is shown; nothing to save |
| Witness | Nostr relays; aggregators routing the order | Store the attestation or receipt, independently of the issuer |
| Verifier | Any directory or tool implementing this NKIP | Pins issuer keys after domain-control verification, checks disputes |
| Log | Hourly Merkle roots of attestations (Nostr events, optional OpenTimestamps) | Proves an attestation existed at a given time |
Specification
The key words MUST, MUST NOT, SHOULD and MAY are used as in RFC 2119.
Receipt
A JSON object signed by the issuer at order creation, before the deposit
address is shown. Schema: nkip-0001/receipt.schema.json.
| Field | Meaning |
|---|---|
v |
Spec version, nkip01/1 |
issuer |
Issuer domain, lowercase, e.g. example.swap |
order_id |
The issuer's order ID |
deposit_asset, deposit_address, deposit_amount |
What the user sends, and where |
payout_asset, payout_address, payout_amount |
What the issuer promises, and where; for float orders the amount is a quoted floor |
rate_type |
fixed or float |
created_at, expires_at |
Integer Unix seconds |
refund_address |
Optional |
policy_hash |
SHA-256 (hex) of the exact bytes of the refund/tolerance policy in force at order time |
key_id |
First 8 bytes of SHA-256 of the signing public key, hex |
sig |
Ed25519 signature, base64url without padding |
Rules:
- Canonical form is RFC 8785 (JCS). Amounts are decimal strings in the asset's base unit (piconero, satoshi, ...).
sigis computed over the JCS bytes of the receipt with thesigfield removed. - Ed25519 is the only v1 algorithm. A PGP-signed LoG MAY be offered as a human-readable copy, but it is not the receipt.
deposit_addressMUST be unique to the order. A reused address makes the order ambiguous, and a dispute on it counts against the issuer.- Amount tolerance, partial or over-payment and late deposits follow the policy named by
policy_hash.
Issuer manifest (nkip01.json)
The issuer publishes https://<issuer>/.well-known/nkip01.json, served with
Content-Type: application/json. Schema: nkip-0001/manifest.schema.json.
{
"v": "nkip01/1",
"issuer": "example.swap",
"adopted_at": 1793000000,
"keys": [
{
"key_id": "3b1f0c9e5a7d2e41",
"alg": "ed25519",
"public_key": "<base64url, 32 bytes>",
"not_before": 1793000000,
"revoked_at": null,
"rotated_from": null,
"rotation_sig": null
}
],
"nostr_pubkey": "<hex, 32 bytes>",
"relays": ["wss://relay.example.org", "wss://relay.example.net", "wss://relay.example.com"],
"policies": [{"policy_hash": "<sha256 hex>", "url": "https://example.swap/policy/v1.txt"}],
"disputes": {"email": "disputes@example.swap", "nostr": "<npub or hex>"}
}
keys[].rotation_sigis the old key's signature (base64url) over the JCS of the new key entry withoutrotation_sig. A new key without it is valid only after the verifier re-checks domain control.revoked_atrevokes a key. Receipts withcreated_at >= revoked_atare invalid. Earlier receipts stay valid only if their attestation is in a log root published beforerevoked_at.- Every
policy_hashused in a receipt MUST be listed with a URL serving those exact bytes. Policy URLs are never edited; a new policy gets a new URL. Verifiers SHOULD archive policy texts when they first see them. adopted_atstarts the issuer's obligations. The "no receipt" rule (G4) applies to orders created 14 days or more afteradopted_at.- Verifiers MUST NOT pin a key on first sight. They pin only after a domain-control verification, and publish the pin in a signed feed.
Commitment and attestation
- Commitment:
SHA-256("nkip01/1" || 0x00 || issuer || 0x00 || deposit_address || 0x00 || payout_address), lowercase hex. The user can recompute it from their own wallet history without having saved anything.order_idis deliberately excluded; uniqueness ofdeposit_addresslinks it to one order. - Attestation: the JCS object
{"v","issuer","commitment","created_at","key_id","sig"}, wheresigis the Ed25519 signature over the JCS of the other five fields.
Nostr event
The attestation is published as a Nostr event (NIP-01) signed by nostr_pubkey:
| Field | Value |
|---|---|
kind |
3711 (provisional; to be registered upstream) |
content |
The attestation JSON |
tags |
["c", <commitment>], ["i", <issuer>], ["v", "nkip01/1"] |
created_at |
Equal to the attestation's created_at (±300 s) |
Anyone can find an attestation with the relay filter {"kinds":[3711],"#c":[<commitment>]}.
A verifier accepts the event only if the Nostr signature matches the manifest's nostr_pubkey and the Ed25519 signature matches a pinned key. Either key alone is not enough.
Witnessing
Before showing the deposit address, the issuer MUST get an attestation into at least one counted witness.
| Channel | Holds | Proves later | Counts |
|---|---|---|---|
| Nostr relays | The attestation event, on 3 or more relays the issuer doesn't control | The issuer committed to this deposit/payout pair at this time | Yes, with OK true from at least 2 relays |
| Aggregator | The full receipt, for orders it routes | The full terms of the order | Yes, with a signed acceptance |
| Browser extension | A local copy of the receipt | The full terms, if the user still has it | No |
- Acceptance signal: a NIP-01
["OK", <event id>, true, ...]from at least 2 relays not operated by the issuer, or an aggregator response that echoes the commitment. The order page SHOULD show the event id next to the deposit address. - Nostr is the default path and needs no aggregator, so an aggregator outage never blocks a compliant issuer. Without an acceptance signal, the issuer MUST NOT show the address and retries.
- Aggregators SHOULD delete full receipts 180 days after
expires_at.
Log
Batchers timestamp attestations. Anyone MAY run one, and a verifier MAY use several.
- Every hour, a batcher collects the kind 3711 events it saw in that hour and sorts their commitments. It builds a Merkle tree as in RFC 6962: leaf =
SHA-256(0x00 || commitment bytes), node =SHA-256(0x01 || left || right). - It publishes kind
3712(provisional) with content{"v":"nkip01/1","hour":<unix hour start>,"root":<hex>,"count":<n>,"leaves_url":<url>}, and MAY anchorrootwith OpenTimestamps. leaves_urlserves the sorted commitment list. An inclusion proof is that list, or the audit path computed from it.- Roots from different batchers need not agree, because each sees different relays. An attestation's existence time is the earliest root that includes it. A batcher that leaves out an event it demonstrably received is untrustworthy for that hour.
Order and dispute flow
- The user requests a quote and enters the payout address.
- The issuer creates the order with a one-time deposit address and signs the receipt.
- The issuer publishes the attestation event to its relays, and hands the receipt to the aggregator for routed orders.
- Only after an acceptance signal does the page show the deposit address (and the event id).
- The user deposits.
- In a dispute, the user gives a verifier the deposit address, the payout address and a deposit proof. For XMR that is the tx key (
check_tx_key) or aget_tx_proofproof; for BTC, the txid. - The verifier recomputes the commitment, queries relays for the attestation (or checks a receipt), and checks the deposit on-chain.
- The verifier notifies the issuer through
disputesin the manifest. Within 7 days the issuer shows the payout (txid, or tx key for an XMR payout) to the committed payout address, or a refund under its policy. - No payout proof means a substantiated claim against the issuer. The verifier publishes the outcome either way.
v1 verifiers MUST support BTC and XMR deposits and payouts. Other chains MAY be supported where a deposit or payout can be proven from public data.
"No receipt" claims
For orders created 14 days or more after adopted_at, a deposit to an address the issuer gave out with no attestation anywhere counts against the issuer. The claim needs an on-chain deposit plus at least one of these:
- a timestamped capture of the issuer's page showing the address (web archive, TLS notary or OpenTimestamps of the saved page);
- the same address in another attested order from that issuer;
- a message from the issuer's
disputescontact, or its support, that names the address; - the aggregator's routing record.
Without any of these, the claim is published as unsubstantiated and does not count.
Requirements
| ID | Level | Goal | Requirement | Acceptance check |
|---|---|---|---|---|
| R1 | MUST | G1 | Issuer signs a JCS/Ed25519 receipt with all required fields for every order | Test vectors pass; schema validates |
| R2 | MUST | G2 | Deposit address is not shown before an attestation is accepted by a counted witness | Order page exposes the event id |
| R3 | MUST | G1 | Manifest at /.well-known/nkip01.json validates against the schema; rotations signed by the old key |
Schema check; rotation chain verifies |
| R4 | MUST | G2 | Receipt is machine-readable on the order page (<script type="application/nkip01+json">) and in the create-order API |
Aggregator and extension parse it |
| R5 | MUST | G3 | Commitments and attestations carry no plaintext address or amount | Log sample check |
| R6 | MUST | G3 | Within 7 days of a valid claim, the issuer produces a payout or refund proof | Recorded either way |
| R7 | MUST | G2 | Attestations go to 3 or more relays the issuer doesn't control | Relay query finds the event |
| R8 | SHOULD | — | A human-readable LoG of the same receipt is offered for download | Manual |
| R9 | SHOULD | G3 | Order status opens with the order ID alone | Manual |
| R10 | MUST | G2 | Aggregators implementing this NKIP reject quotes from NKIP-0001 issuers that lack a valid receipt | Aggregator test |
| R11 | MUST | G3 | A public verify tool needs no account, keeps nothing after the check, and is rate-limited | Code review |
| R12 | SHOULD | G2 | Extensions save receipts locally only and warn before deposit when one is missing | Extension test |
| R13 | MUST | G3 | Verifier key/domain feeds are signed | Signature check |
| R14 | MUST | G1 | This NKIP ships a JSON schema, test vectors and a version field; v1 receipts stay valid after later versions | CI on this repo |
Security considerations
| Threat | Likelihood | Control |
|---|---|---|
| Issuer skips the receipt for one order it plans to keep | High | Detect, don't prevent: an address shown without an attestation is unreceipted; extensions warn before deposit; the claim counts once the evidence standard is met |
| False "no receipt" claims | Medium | Evidence standard: an on-chain deposit plus proof the issuer gave out that address (timestamped page capture, the same address in another receipted order, or the issuer's admission); otherwise unsubstantiated |
| Issuer denies a receipt | Medium | Attestation signed with the pinned key, plus a timestamped root |
| User forges or edits a receipt | Medium | Signature fails; amounts must match the on-chain deposit |
| Replay | Low | One-time deposit address, expires_at, one deposit per receipt |
| Clipboard hijack of the payout address | Medium | Extension compares the on-page address with the receipt |
| Phishing clone | High | Key doesn't match the pin |
| Key substituted at first contact | Low | No first-sight pinning |
| Relays delete or censor events | Low | 3+ relays and independent hourly roots |
| Witness collusion | Low | Only issuer-signed attestations count: a witness can drop data but not forge it |
| Lapsed domain bought by someone else | Medium | Key pinned to the verifier's listing, not just the domain |
| Issuer key stolen | Low | Rotation signed by the old key; revocations published |
| Timing/amount correlation | Low | No amounts in the log; hourly batching |
Open questions
- Floating-rate orders: v1 signs a floor. Re-signing on rate lock is left for a later version.
- Register kinds 3711 and 3712 upstream (nostr-protocol/nips), or pick replacements.
- Should the verifier feed be its own NKIP?
Changelog
- 2026-10-26: Draft. Added manifest format, Nostr event, acceptance signal, log and proofs, revocation rule, policy hosting, v1 chains, dispute channel and claim evidence.