```
NKIP: 1
Title: Verifiable Deposit Receipts
Status: Draft
Type: Standards Track
Created: 2026-10-26
License: CC0-1.0
```

# NKIP-0001: Verifiable Deposit Receipts

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.

## 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`](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, ...). `sig` is computed over the JCS bytes of the receipt with the `sig` field 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_address` MUST 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`](nkip-0001/manifest.schema.json).

```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_sig` is the old key's signature (base64url) over the JCS of the new key entry without `rotation_sig`. A new key without it is valid only after the verifier re-checks domain control.
- `revoked_at` revokes a key. Receipts with `created_at >= revoked_at` are invalid. Earlier receipts stay valid only if their attestation is in a log root published before `revoked_at`.
- Every `policy_hash` used 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_at` starts the issuer's obligations. The "no receipt" rule (G4) applies to orders created 14 days or more after `adopted_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_id` is deliberately excluded; uniqueness of `deposit_address` links it to one order.
- **Attestation:** the JCS object `{"v","issuer","commitment","created_at","key_id","sig"}`, where `sig` is 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 anchor `root` with OpenTimestamps.
- `leaves_url` serves 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

1. The user requests a quote and enters the payout address.
2. The issuer creates the order with a one-time deposit address and signs the receipt.
3. The issuer publishes the attestation event to its relays, and hands the receipt to the aggregator for routed orders.
4. Only after an acceptance signal does the page show the deposit address (and the event id).
5. The user deposits.
6. 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 a `get_tx_proof` proof; for BTC, the txid.
7. The verifier recomputes the commitment, queries relays for the attestation (or checks a receipt), and checks the deposit on-chain.
8. The verifier notifies the issuer through `disputes` in 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.
9. 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 `disputes` contact, 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.
